Skip to content

feat(host): install verification builds through Host - #3274

Draft
vkuprin wants to merge 12 commits into
callstack:mainfrom
vkuprin:feat/host-install-from-source
Draft

vkuprin wants to merge 12 commits into
callstack:mainfrom
vkuprin:feat/host-install-from-source

Conversation

@vkuprin

@vkuprin vkuprin commented Oct 7, 2026 •

Copy link
Copy Markdown

Stacked on #3273. Please review only the top three commits.

Summary

Verification builds install through Host, private GitHub Actions artifacts included (ADR 0021 §6).

The daemon resolves github-actions-artifact sources by id, by run and name, or by name alone, which picks the newest live artifact from the repository's own runs, never a fork pull request.

The token stays on the daemon:

  • It comes only from the daemon's AGENT_DEVICE_GITHUB_TOKEN, never from a request.
  • It goes only to an archive URL under the named repository, and the downloader drops it at the storage redirect.
  • AGENT_DEVICE_GITHUB_REPOSITORIES limits which repositories it reads.
  • A client holding a token refuses to reuse a daemon started without it or with another.

The lookup runs after the install's local checks, with its own timeout. GitHub source parsing moves out of http-server.ts.

Closes #3268. 24 files, 786 gross lines vs #3273.

Validation

Tested commit 7b3f7eaab:

  • pnpm check:affected --run: all pass except a flaky affected-selector test (ENOTEMPTY on temp cleanup; unchanged from main). The 17 checks after it pass when run separately.

  • Tests cover fork filtering, .. names, a foreign archive URL, the token dropped at the redirect, and each typed refusal.

  • Live iOS run on iPhone 17 Pro (iOS 26.2) through proxy, since Host can't allocate before Host lease side: allocator contract trim, Simlock adapter, coordinator/journal, renewal, recovery #3269:

    agent-device install-from-source --github-actions-artifact callstack/react-native-paper:react-native-paper-example.app.zip --platform ios

    Installed in 63 s; open, snapshot -i, close and disconnect passed. No state dir or daemon log contains the token.

@thymikee

thymikee commented Oct 7, 2026

Copy link
Copy Markdown
Member

The token handling in this PR needs one fix before merge: a worker can send the daemon's GitHub token to api.github.com paths outside the artifact route. I reviewed 8748f7d.

findArtifact builds the URL from encodeURIComponent(owner) and encodeURIComponent(repo) (link). That function leaves . and .. unchanged, and URL parsing removes the dot segments. A worker that sends {owner:'..', repo:'user', artifactId:-1} through Host (the wire parser at http-server.ts:256-278 accepts any non-empty string and any integer) makes the daemon send its Bearer token to https://api.github.com/user/actions/artifacts/-1. The returned archive_download_url also gets the Authorization header with no origin check (line 60). Dropping the header on redirects only protects later hops, not the first request. Today the practical impact is low, since only GET is possible and few GitHub endpoints have this shape. Still, it breaks the invariant the issue states. The rule is that every request carrying the token is built from validated GitHub coordinates and goes to the api.github.com origin only. Please enforce it once in resolveGitHubActionsArtifactSource, which the HTTP, CLI and config sources all reach. owner and repo should match /^[A-Za-z0-9_.-]+$/ and not be . or ... artifactId and runId should be positive safe integers, refused with a typed INVALID_ARGS reason. new URL(archive_download_url).origin should equal GITHUB_API_ORIGIN before any header is attached. Please add tests for .. and for a foreign-origin archive_download_url.

Not blocking, and you can take or leave these: the lookup in install-source-resolution.ts:44 uses getRequestSignal(requestId) ?? new AbortController().signal with no timeout, so a stalled GitHub connection can hang install_source, and it should share the timeout and cancel classification that downloadToTempFile owns; requestJson repeats requestHop from install-source-download.ts:56 without its network-error classification; non-404 failures such as 401 and 403 throw COMMAND_FAILED with no typed reason, and a bad body throws a raw SyntaxError from JSON.parse; the help strings at flag-definitions-target.ts:395 and the install.ts:47 summary still say "resolved by a remote daemon", while the website docs now say the daemon resolves the artifact with its own token; and the new test in host-front-end.test.ts:322 would pass without c3ceac1, because the pass-through policy comes from an earlier commit in the stack.

On simplicity, I think the shape is right: one resolver turns the artifact into the existing url source. Could requestJson reuse the downloader's requestHop, with its timeout and classification, instead of re-implementing approve-then-request? That means exporting requestHop, or a small shared approved-request helper, from install-source-download.ts first.

The checks show no failures, and the PR body says pnpm check:affected passed locally apart from the known affected-selector flake from #3272. The live iOS run went through proxy, not Host, because Host cannot allocate devices until #3269, so it does not cover the Host hop. The stub-daemon test does, and the daemon route is the same. I did not run any tests. I also did not confirm that the spawned daemon inherits AGENT_DEVICE_GITHUB_TOKEN, that the list-artifacts endpoint returns the newest artifact first (which the per_page=1 lookup relies on), or the "no token in state or logs" claim. Before merge, the origin and coordinate checks above need to land with their tests.

vkuprin added 12 commits October 7, 2026 07:04
Add `agent-device host`, the Host front-end from ADR 0021 §3. It runs as
its own process, starts or reuses the local HTTP daemon, and serves it to
remote verification workers through the daemon proxy.

Workers authenticate with one persistent service credential. Host creates
it on first start at <state dir>/host/service-credential.json (directory
0700, file 0600) and reuses it after every restart. A malformed or
group/other-readable file stops startup with a typed reason, and so does a
TLS file Host cannot read. Both checks run before any daemon starts.
--tls-cert and --tls-key serve HTTPS. A wildcard bind advertises the
machine's hostname, since workers cannot dial 0.0.0.0.

The proxy command behaves as before. Its daemon startup and listen helpers
move into a module both commands use.

Closes callstack#3265
- Host checks that the TLS certificate and key load together, and that
  the key is mode 0600, before any daemon starts. A bind other than
  loopback without TLS is refused (host-tls-required), so the service
  token never travels in cleartext.
- A new credential reaches disk only once Host is serving, so a start
  that fails earlier never hides the token from the next one. A
  credential another start wrote first is refused (host-credential-raced).
- A credential that is a link or unreadable gets a typed reason, and
  platforms without POSIX ownership skip the mode check.
- The advertised URL keeps the host name the operator bound to, and the
  worker command in the startup output names the real URL and token.
- The proxy command keeps its original code. Host's daemon and listen
  helpers live in src/cli/host/local-daemon.ts.
- The host help topic moves into its own module.
Add `host` to the reviewed device-claim policy set, give the
--tls-cert/--tls-key flags their own Host bucket in the integration
progress model, list `hostCommand` with the dynamically loaded CLI handlers
in the fallow production exemptions, and waive the operator-facing `host`
help topic from the help benchmark.
Host now owns identity and the public route policy (ADR 0021 §6):

- The front-end drops every identity a client claims (tenant headers and
  body fields) and sends the credential's principal to the daemon as
  x-agent-device-principal on the daemon-token loopback request.
- The daemon reads that header only after the daemon token matched and
  treats it as an attested tenant, so sessions are isolated under the
  principal and req.internal.hostPrincipal carries it to admission. With an
  auth hook configured the header is refused with a typed reason.
- Administration routes, macos-app allocation, inputs naming a path on the
  Host machine and allowDownload are refused with 403 and a typed
  details.reason. The host-path inputs move out of the macos-app lease into
  one declaration both policies read.
- Anonymous /health shows only ok, service and rpcProtocolVersion.

The principal handoff is the contract proposed to the lease side on callstack#3264.

Closes callstack#3266
- The proxy gains an optional admitRpc hook that runs on an authorized
  /rpc request with its params already parsed. Host uses it instead of
  re-reading the body and repeating the token check, so oversized and
  unauthorized requests get the proxy's own answers. Plain proxy sets no
  hook and behaves as before.
- Path positionals come from each command's schema (`path`, `appOrPath`,
  `payloadOrJson`) instead of a hand-kept table, so trace, push and
  session save-script are covered. URLs no longer exempt a positional, an
  upload id exempts only install and reinstall, and only the exact temp
  locations the remote client writes are accepted.
- Batch steps are checked one by one, and replay and test are refused
  (host-script-refused) because Host cannot check their nested actions.
- launchConsole counts as a Host path, macos-app is matched the way the
  daemon normalizes it, and /admin/* is simply not served.
- The daemon reads the principal header only on a request that holds the
  daemon token, so an unauthenticated caller learns nothing about an
  auth hook.
- Host answers with an error response instead of rejecting.
… changes

DaemonHealthPayload widens `service` with 'agent-device-host', and the
auxiliary HTTP authorizer reads x-agent-device-principal after the daemon
token check. Both are additive under ADR 0006: released peers send and parse
the same bytes as before.
On Host, `--device "<type>"` names a device type Host allocates a fresh
device for (ADR 0021 §5), instead of a name resolved against inventory.

- The client asks the endpoint's health first, through the cache the RPC
  transport already fills. On `service: agent-device-host` it skips the
  inventory lookup and allocates with the type, platform and --os-version in
  the device-selection fields lease.allocate already carries.
- A Host that does not advertise the `device-shape` feature fails with
  host-shape-unsupported before any lease request (§8). Plain proxy is
  unchanged.
- The daemon builds a strict { platform, deviceType, osVersion? } shape for
  a lease_allocate that carries a Host principal, calls the new
  HostShapeAllocator seam, and only then publishes the Host lease bound to
  the returned device; an allocation it cannot publish is given back.
  A UDID or serial, or a daemon with no allocator, is refused with a typed
  reason.

The seam is the lease side's to implement (callstack#3269). Nothing configures it in
production yet; the tests drive it through the scripted allocator fake.

Closes callstack#3267
…ot allocate

- After a by-type allocation, the command addresses the device the lease
  bound (its UDID or serial) instead of a name another simulator may
  share.
- On a Host, every lease-allocating command is checked before any lease
  request. A missing --device, a UDID or serial, a missing token
  (host-unauthenticated) and a different type on a session that already
  holds one (host-shape-mismatch) are typed refusals. The platform comes
  from the connection when --platform is absent.
- The daemon validates the lease scope before it provisions anything,
  gives an allocation back when the requester has left, and keeps the
  original error if giving it back fails. --os-version must be a string.
- The daemon advertises device-shape exactly when it has an allocator,
  and the Host service name is one shared constant.
- The proxy lease device selection moves into its own module, loaded on
  demand, so connection-runtime.ts stays under 1,000 lines.
List DaemonHealthFeature, DAEMON_HOST_DEVICE_SHAPE_FEATURE and
DAEMON_HOST_SERVICE in the wire manifest, and acknowledge the additive
`features` field on the health payload, its builder and the client's health
parser under ADR 0006.
The daemon now resolves github-actions-artifact install sources itself
instead of refusing them (ADR 0021 §6). It looks the artifact up through
the GitHub REST API (by id, by run and name, or the newest with that name),
authorized with AGENT_DEVICE_GITHUB_TOKEN from its own environment; a
request never carries a GitHub token. The archive URL then goes through the
existing approved downloader, which drops Authorization at the cross-origin
storage redirect.

Missing token, missing artifact and expired artifact are refused with typed
reasons (github-token-missing, github-artifact-not-found,
github-artifact-expired). URL and artifact sources reach the daemon through
Host unchanged; Host paths stay refused by the route policy.

Closes callstack#3268
…an run

- A name alone now picks the newest live artifact from the repository's
  own runs, so a fork pull request's upload can never stand in for it.
- Owner and repository names are checked at every entry point, and the
  token goes only to an archive URL under the named repository on the
  GitHub API. AGENT_DEVICE_GITHUB_REPOSITORIES limits which repositories
  the daemon token reads.
- The lookup reuses the downloader's approved request path and byte
  limit, has its own timeout, follows GitHub API redirects, and gives
  401, 403/429, other statuses and non-JSON bodies typed reasons.
- The handler looks the artifact up only after its local checks pass,
  instead of spending API calls on requests that could never install.
- The daemon publishes a fingerprint of its token, and a client holding
  a token refuses to reuse a daemon started without it or with another.
- The token variable is declared once, in src/daemon-github-token.ts, and
  the GitHub source parsing moves out of http-server.ts.
…ion-kit

Add the ./github-actions-artifact-source entry to the provision-kit exports
map and its R11 package-boundary pin.
@vkuprin
vkuprin force-pushed the feat/host-install-from-source branch from 1b3bd41 to 7b3f7ea Compare October 7, 2026 05:26
@thymikee

thymikee commented Oct 7, 2026

Copy link
Copy Markdown
Member

Thanks for the update. At 7b3f7ea the earlier findings are fixed, and one new problem remains in the archive URL check. CI is green (1 check, 0 not passing), and no conflicts are known.

The archive check in archiveUrl compares the URL path to /repos/${owner}/${repo}/actions/artifacts/ with exact case, using the owner and repo the worker typed (https://github.com/callstack/agent-device/blob/7b3f7ea/packages/provision-kit/src/github-actions-artifact-source.ts#L157). GitHub's API ignores case on owner and repo, and I believe it returns archive_download_url with the canonical name, such as Expensify/App. If so, a worker who types expensify/app:ios-sim finds the artifact, then gets a github-artifact-url-unexpected refusal that says the URL is outside the repository. The API call is already spent, and a renamed or transferred repository fails the same way. The allowlist check at :107-108 already lowercases, so the two checks disagree. The rule is that one repository identity is compared case-insensitively everywhere the resolver scopes the token. Please compare the archive path prefix case-insensitively, using the same lowercased repository that assertRepository checks, and add a test where the API returns Acme/Mobile for a request typed as acme/mobile. I could not confirm against the live GitHub API that it returns canonical casing, so I rate this as likely, not certain.

Not blocking, and you can take or leave these: artifactId and runId still accept any integer in github-actions-artifact-params.ts:72, so requiring Number.isSafeInteger(v) && v > 0 would be tighter; the INVALID_ARGS name refusal has no typed details.reason, so the .. test keys on message text; and no test covers assertDaemonGitHubTokenMatches with the new fingerprint (a daemon.json with a missing or different fingerprint plus AGENT_DEVICE_GITHUB_TOKEN should reject with DAEMON_GITHUB_TOKEN_MISMATCH) or an api.github.com archive URL under another repository.

Is any new seam needed here? I think not, since the delta reuses the downloader's approved hop and the single resolver still feeds the existing url source.

I did not run tests, and I did not trace whether the download diagnostics could log the url-source headers that now carry the daemon token. The live run went through the proxy, not Host, with a lowercase repository name, so it does not exercise the case mismatch. Only the stub-daemon test covers the Host hop. Before merge, the archive prefix comparison needs to be case-insensitive, with the canonical-casing test added.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host: install verification builds through Host, including private GitHub Actions artifacts

2 participants