Skip to content

Artifacts: a per-run file token on links, and scripts run in HTML artifacts - #126

Merged
Evanfeenstra merged 1 commit into
mainfrom
feature/artifact-read-tokens
Oct 8, 2026
Merged

Evanfeenstra merged 1 commit into
mainfrom
feature/artifact-read-tokens

Conversation

@Evanfeenstra

Copy link
Copy Markdown
Contributor

Why

An HTML page an agent built rendered blank: every artifact is served Content-Security-Policy: sandbox, which runs no script. Allowing scripts was unsafe not because of the origin (it stays opaque under sandbox allow-scripts) but because the UI put the deployment key on every artifact URL as ?key=, and a document can always read its own location. A page an agent wrote could have lifted the key and sent it anywhere. A separate origin would not have fixed that either.

What

The key leaves the URL first. fileToken (src/auth.ts) is the key attenuated to one scope: an HMAC-SHA256 of run:<id> or job:<id> under STRUT_API_KEY, base64url. No store, rotates with the key, absent when no key is configured.

  • GET /artifacts/:runId and GET /jobs/:id/files mint it as token.
  • The one global gate also opens a GET/HEAD under that scope with ?t= (carriesFileToken). A token on any other path, for another run or job, on a write, or tampered, stays 401.
  • The UI fetches it once per run or job (web/src/use-artifact-url.ts, cached in api.ts, cleared when the key changes) and its links carry ?t=. Leaked, a token is worth exactly the files the page already shows.

Then scripts. text/html gets sandbox allow-scripts, never allow-same-origin. The viewer's frame is sandbox="allow-scripts". The file routes add access-control-allow-origin: * so a page can fetch its sibling data from its opaque origin; the token gates the read, not the origin. Everything else still runs no script (an .svg opened as a document could).

Verified

  • 1365 unit tests pass; engine and web type-checks and the web build are clean.
  • Live, on a scratch engine with a key set: the listing minted a token, the file served with sandbox allow-scripts, and without a token or with another run's token the route answered 401.
  • In a browser, opened top-level, the artifact's script ran and reported its URL holds ?t= and no key. The web app rendered the link and the viewer frame with ?t=.

Not in this PR

The url fields of GET …/runs/:runId/artifacts and the run.end callback carry no token. Hive proxies those with its key, so nothing changes for it; adding the token there would let a host hand a link straight to a browser.

…ifacts

An HTML page an agent built rendered blank: every artifact is served
`Content-Security-Policy: sandbox`, which runs no script. Allowing
scripts was unsafe not because of the origin (it stays opaque) but
because the UI put the DEPLOYMENT key on every artifact URL as `?key=`,
and a document can always read its own location.

So the key leaves the URL first. `fileToken` (auth.ts) is the key
attenuated to one scope: an HMAC of `run:<id>` or `job:<id>` under
STRUT_API_KEY, no store, rotates with the key, absent with no key. The
listings (`GET /artifacts/:runId`, `GET /jobs/:id/files`) mint it as
`token`; the global gate also opens a GET/HEAD under that scope with
`?t=` (`carriesFileToken`); the UI fetches it once per run or job
(`use-artifact-url.ts`) and its links carry `?t=`. Leaked, a token is
worth the files the page already shows.

Then `text/html` gets `sandbox allow-scripts` (never
`allow-same-origin`), the viewer's frame `sandbox="allow-scripts"`, and
the file routes `access-control-allow-origin: *` so a page can fetch
its sibling data from its opaque origin. Everything else still runs no
script.

Tests: the token functions, and a gate test that another run's token,
a job's token on a run, a token on a non-file route, a write and a
tampered token all stay 401. Docs: API.md, AGENTS.md, plans/jobs.md.
@Evanfeenstra
Evanfeenstra merged commit 43d18c6 into main Oct 8, 2026
1 check passed
Evanfeenstra added a commit to stakwork/stakgraph that referenced this pull request Oct 8, 2026
…ump strut (#1753)

* lab: hand a file read carrying strut's file token through to strut; bump strut

stakwork/strut#126 took the embed JWT off the strut UI's artifact URLs
(an HTML artifact now runs script, and a page can read its own URL), so
the <img> and frame loads under /lab went out with no credential and
labAuth answered with a Basic challenge — the browser's login dialog,
on prod.

labAuth now passes a GET/HEAD of one run's artifacts or one job's files
that carries strut's file token (`?t=`) straight to strut. Strut
(stakwork/strut#127, pinned here) signs the token under STRUT_SECRET_KEY
when it has no API key — index.ts derives that from API_TOKEN — and
refuses any `?t=` it cannot vouch for whatever its gate's mode, so the
token is as good as the credential that fetched the listing it came
from, and worth only that run's files. Nothing else changes: a read
without `?t=`, a write, or any other path still needs the lab
credential, and the path is parsed as isUiAsset parses it.

* bump strut to the #127 merge commit
Evanfeenstra added a commit to stakwork/hive that referenced this pull request Oct 9, 2026
…on, instead of as a static url frame (#5454)

A job's plan.html (strut's `Plan Mode`) reached the canvas as a `url`
artifact: an iframe on the strut artifact reader, which keeps strut's
`Content-Security-Policy: sandbox`, so the page rendered static. Strut
now lets scripts run in an HTML artifact (stakwork/strut#126, #127), but
only on a read that carries its per-run file token — a read hive never
makes, since the reader authenticates to the swarm with the swarm key.

So the page goes the way a stored page does. `mapStrutArtifacts` keeps a
strut-written `html` as `html`; `readStrutArtifact` hands its viewer
`{ swarmId, key }` (the second shape of `ArtifactContents.html`, beside
the stored page's slug) with no fetch of its own; `HtmlArtifactSource`
gains a third shape whose proxy URL is the strut reader; and
`HtmlArtifactFrame` fetches the bytes with the session, re-types them
into a blob and renders them in its `allow-scripts` frame under its CSP
meta tag — the posture its stored pages already have. No navigation to
the reader, no HTML rendered at a hive URL, and the reader route and its
`sandbox` header are unchanged for anyone who opens the URL itself. A
swarm page has no share address of its own (`address` may return null).
Strut's file token is not involved.

Tests: the mapping, the content parser, the loader, the proxy URL and
the frame with the new shape. CANVAS_CHAT.md updated.
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.

1 participant