Skip to content

feat(desktop): provide managed HTML artifact preview endpoints - #5316

Merged
liugddx merged 4 commits into
apache:mainfrom
liugddx:feat/5238-managed-artifact-preview
Sep 15, 2026
Merged

liugddx merged 4 commits into
apache:mainfrom
liugddx:feat/5238-managed-artifact-preview

Conversation

@liugddx

@liugddx liugddx commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #5238. Refs #5213. Based on upstream/main c22768c, including merged #5261.

  • Provide Desktop-owned HTML snapshots over loopback HTTP, without shell servers or file-URL policy changes.
  • Integrate Generated Files → Open in default app and publish a session-bound ArtifactPreview tool without requiring a pre-existing browser origin.
  • Probe the exact endpoint with a deadline before returning reachable; keep loaded: false. HTTP reachability and OS launch are not browser-load evidence.
  • Release listeners/memory after 30 minutes, on Desktop Artifact deletion, target retirement and shutdown. Save As / Show in Folder still export the original file.

Security and supported content

Per-lease loopback origin and 256-bit bearer path; exact Host/path validation; GET/HEAD only; no directory serving, CORS, caching or disk staging. Limits: 8 MiB each and 16 concurrent reservations, including pending reads.

Self-contained HTML supports inline interaction and embedded images/fonts. CSP sandbox excludes same-origin authority, remote resources, fetch requests, forms, frames and popups. This is not OS network isolation: an external browser can still navigate away from the document. Workspace assets are not served. Save As is the alternative for documents needing external resources. The URL belongs to the Desktop, even for artifacts streamed from a remote Host.

Verification

  • Workspace dependency builds, Desktop typecheck/main build, Biome and git diff --check: passed.
  • 56 tests passed across managed preview, Artifact IPC and native capability publication/admission.
  • Mutation check: removing Host validation failed the policy regression (200 instead of 404); restoring it returned the suite to green.
  • Real Maka Dev on macOS: opened an existing registered HTML Artifact from Generated Files → Open in default app. System browser Chrome Canary loaded Preview E2E Ready via the managed HTTP endpoint. Clicking Run interaction produced Interaction confirmed.
  • A fresh model-driven generation + ArtifactPreview run was attempted but stopped before tool execution with Model input limit exceeds the context window. This is NOT claimed as passed. Tool publication, session binding, admission and structured evidence are covered by protocol tests.

Scope boundary

Complete managed-endpoint implementation for #5238; not completion of all harness work. Capability preflight remains #5234 / existing #5254. Structured browser-open/load verification remains #5235. This PR does not close #5213 or #5235.

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Codex implemented the service, integration, tests and documentation.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technical NO-GO: two P2 correctness/lifecycle defects and one P3 lease-isolation defect. The exact Host/path checks, per-origin listener, CSP sandbox, byte cap, and successful-read cleanup otherwise matched the stated boundary.

Validation completed on this head: clean install, build:test, Desktop 2502/2502, focused preview/artifact/native-capability 56/56, full typecheck/lint/format/ASF checks, renderer architecture 112/112, E2E budget 38, production renderer build, diff check, and a clean merge tree with current main c22768c3b0dc47518f6f8584e864f86f0b1e5379. Hosted test is red only in the unchanged Side Chat follow-up E2E; after building the renderer, that exact E2E passed locally 1/1. I did not exercise a native macOS/Windows browser launch or a real remote Host outage.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

}
const chunks: Buffer[] = [];
let size = 0;
const total = await client.streamArtifact(sessionId, artifactId, async (chunk) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bound these remote reads by the caller signal and the preview lifetime. assertActive() only runs after getArtifact/streamArtifact resolve (or when a chunk arrives), while the Desktop client issues artifact.query without a timeout. With the compiled provider and a streamArtifact promise that never settled, Host cancellation left the tool call pending and releaseSession() also stayed pending. Separately, 16 stalled getArtifact calls still occupied every reservation after a 25 ms TTL and the 17th call failed Too many active previews; the timer is not installed until after the stream/probe completes. A stalled or reconnecting Host can therefore block Session retirement and exhaust previews across targets indefinitely. Race each read against cancellation/deadline, release the reservation immediately, and cover a no-response Host.

}
}

async revoke(scope: string, sessionId: string, artifactId: string): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Revoke previews when the owning Session is retired, not only when an individual Artifact is deleted. The production sessions:remove path calls releaseNativeSession, but that function only aborts provider invocations and releases browser/desktop-interaction resources; it never calls this service. A compiled IPC probe prepared a URL, permanently removed its Session, and the URL still returned 200. Because the endpoint is a bearer snapshot, deleted Session content remains accessible for up to 30 minutes. Add a Session-scoped revoker to the retirement path, including revision-family members, and a regression that removes the Session then fails to fetch the URL.

try {
await deps.preview.openExternal(endpoint.url);
} catch (error) {
await deps.preview.service.revoke(deps.preview.scope, sessionId, artifactId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Release only the endpoint created by this launch attempt. revoke(scope, sessionId, artifactId) closes every lease for the Artifact. In a compiled IPC probe, the first open returned a live URL, the second openExternal failed, and the first URL was then closed. A transient later launch failure should not tear down an already working preview. Return a lease-specific release handle for this catch while keeping broad revoke for Artifact deletion, and add a two-preview regression.

@liugddx

liugddx commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

I compared the failing E2E files against the latest upstream/main (c6de7e257). Neither test has changed on main, and this PR does not touch either file. The hosted failures remain: side-chat-followups.spec.ts:79 and skill-draft-lifecycle.spec.ts:110. I opened #5327 to track them. These are separate from the managed HTML Artifact preview changes and should be fixed there; the red CI check still needs a rerun after the unrelated E2E issue is addressed.

@liugddx

liugddx commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

I checked latest upstream/main (2bc8a54): the failing E2E files remain unchanged, so the red CI is still unrelated to this PR. I updated #5327 with the latest evidence. The clean worktree cannot run E2E until workspace dependencies/build artifacts are installed; no regression has been established.

Includes apache#5315, which waits for Composer admission readiness before consecutive Side Chat sends. Clean workspace build and 56 focused Artifact/native-capability tests passed.

Generated-by: Codex
@liugddx

liugddx commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Updated this PR with upstream/main via merge commit 8a3c12ea190bd62d68a961ad900d8a4ceae69f86.

The Side Chat failure was a test synchronization race: the queue row can appear before the prior submit response releases Composer's Send gate. A controlled delayed-response probe confirmed that pressing Enter in that interval makes no second Host call and leaves the draft in the editor. Upstream #5315 already contains the fix using awaitSendReady(companion), so I used that implementation and closed the redundant draft #5340.

Validation on the updated PR: clean full workspace build passed; 56 focused Artifact/native-capability tests passed; hosted CI is now green, including 39 Desktop E2E tests passed: https://github.com/apache/maka/actions/runs/34941877780

The Skill Draft menu-click failure did not recur in this hosted run. Its independent root cause was not established, so this is evidence that the current CI passes, not proof that an intermittent menu issue cannot recur. No timeouts were increased and no assertions were weakened for this change.

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent agent review. Reviewed at 8a3c12ea190bd62d68a961ad900d8a4ceae69f86. I am an AI agent (executing seat @kabi-opus) publishing through a shared GitHub account; this is an automated review and does not substitute for independent human review.

No P0–P2 findings.

This opens a local HTTP surface in the Electron main process and serves generated HTML to a real browser, so I treated the description's security paragraph as a set of claims to check against the code rather than as documentation.

Every claim in "Security and supported content" holds

  • Loopback only, and no listener before it is needed. listen(0, '127.0.0.1') binds an ephemeral loopback port. new ManagedArtifactPreview() at boot only constructs the object; each createServer() happens inside prepare, per lease. Nothing is listening at startup.
  • 256-bit bearer path. randomBytes(32) from node:crypto, hex-encoded.
  • Exact Host and path validation. req.headers.host !== host pins the request to this lease's 127.0.0.1:<port>, which is the mitigation that matters against DNS rebinding. The path check is req.url !== path — full-string equality, so no path is ever parsed, joined or resolved. Traversal and directory serving are not defended against here; they are structurally impossible, which is a stronger position than a sanitizer.
  • GET/HEAD only, with 405 and an Allow header, and HEAD returns no body.
  • Limits are real and cover the pending window. PREVIEW_MAX_BYTES is checked against the declared sizeBytes and again against the actual streamed total, with a final size !== artifact.sizeBytes || total !== size exactness check — a lying declared size cannot get past the cap. MAX_PREVIEWS is enforced by inserting the lease into this.leases before the asynchronous read begins, which is what makes "including pending reads" accurate rather than aspirational.
  • Four release paths exist, matching the description: the 30-minute TTL timer, per-artifact release, scope retirement (which also adds to retiredScopes so new leases are refused), and close(). assertActive() runs after every await and inside the stream callback, so a lease torn down mid-read aborts rather than completing into a dead lease.

The CSP does what the prose says, directive by directive: sandbox allow-scripts without allow-same-origin gives the document an opaque origin and, with no allow-popups, blocks popups; default-src 'none' covers frame-src/media-src by fallback; scripts and styles are inline-only with no host source; img-src/font-src are data:/blob: so nothing remote loads; connect-src 'none' removes fetch, XHR and WebSocket; base-uri, form-action and frame-ancestors are all 'none'. Cache-Control: no-store, Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff are set on every response, including the 404.

I also want to note what the description does not claim, because that honesty is load-bearing: it states plainly that this is not OS network isolation and that an external browser can navigate away, and it marks the model-driven generation run as not passed rather than quietly omitting it.

Ablation

The description records one mutation check on Host validation. I ran a different one so a second guard is covered: replacing the bearer-path comparison so only Host and lease liveness are checked turns two tests red, including isolates leases by origin and rejects credentials for another preview — the cross-lease isolation property itself. I confirmed the marker reached the emitted dist/ before reading the result.

Evidence

build:main clean (zero TS errors) and 56/56 across managed-artifact-preview, runtime-host-artifacts-ipc-main and runtime-host-native-capabilities, matching the description's figure.

A note for anyone else verifying locally: @maka/desktop's main build consumes the built .d.ts from packages/ui/dist, not that package's sources. My first attempt failed with subscribeToReaderScroll does not exist in two files this PR does not touch, purely because my packages/ui/dist predated the scroll-authority rename that has since landed on main. Rebuilding @maka/ui at this head cleared it. A build's exit code tells you the compile succeeded, not which branch produced the artifacts it read.

Not covered by me

I did not open the endpoint in a real browser, so the manual macOS run described — Chrome Canary loading the preview and the interaction confirming — is the author's evidence, not mine. I did not exercise the model-driven path, which the description already excludes. One deliberate non-finding: the path comparison is an ordinary string compare rather than constant-time, but the attacker model that would need — local code execution to reach the loopback port, plus timing resolution through Node's HTTP stack — already grants direct access to the artifact, so I do not think it is worth changing.

Code review, CI status and merge readiness are separate. At this SHA the reported check is green, mergeable=MERGEABLE, mergeStateStatus=BLOCKED. This approval covers code only and is not a statement that the PR may be merged.

@liugddx
liugddx merged commit 512605f into apache:main Sep 15, 2026
1 check passed

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Withdrawing my approval (@kabi-opus, automated agent review, shared account). Reviewed at 8a3c12ea190bd62d68a961ad900d8a4ceae69f86.

Please do not count my earlier APPROVED on this head as a review gate. Another reviewer reported two P2s on the same head; I went back to check them against the code rather than defend my own post, and both are substantiated. My approval was not wrong about what it examined — it was incomplete, in a way I should have caught.

Deletion does not revoke a live preview on every path

What I verified originally was runtime-host-artifacts-ipc-main.ts:85-86: the Desktop delete handler calls deleteArtifact and then preview.service.revoke(...). That is correct, and it is the only place in apps/desktop/src that deletes an artifact. My mistake was generalising from it without asking whether deletion can originate anywhere else. It can:

  • packages/runtime-host/src/server/deep-research-coordinator.ts:91delete: (artifactId) => this.#artifacts.deleteOwnedArtifactInSession(sessionId, artifactId, 'deep_research'). An agent deleting its own artifact never reaches the Desktop handler.
  • packages/runtime-host/src/server/session-sidecar-purge.ts:36 and session-retirement-coordinator.ts:125purgeSessionArtifacts(sessionId) on session teardown.
  • packages/runtime-host/src/server/artifact-coordinator.ts:70 — the artifact.delete operation itself carries no coupling to the preview service; the revoke lives one layer up, in the Desktop caller.

None of these notify ManagedArtifactPreview, so the lease survives until its 30-minute TTL and the loopback URL keeps serving the deleted artifact's bytes. closeScope does not close this gap either: it is keyed on targetEpoch, not on sessionId, so purging a session's artifacts leaves that session's leases running.

The disclosure is bounded — the bearer path was already handed to the browser that opened it, so no new party gains access. But "I deleted it" not taking effect is exactly the expectation a delete action sets, and it is the reason someone deletes a generated document in the first place.

The preview quota is global, and exhaustion is reachable

My earlier review said the reservation "covers the pending window", and that remains true: the lease is inserted before the async read, so concurrent preparations cannot exceed the bound. But that is a narrower property than the one that matters, and I did not evaluate the wider one.

managed-artifact-preview.ts:71 tests this.leases.size >= MAX_PREVIEWS with no partition by scope or session. With MAX_PREVIEWS = 16, a 30-minute TTL, no eviction and a model-callable tool, one session can hold every lease and deny previews to every other session for half an hour. The thrown message — "Too many active previews; wait for expiry" — describes that outcome accurately; there is no recourse short of waiting.

What still stands from my review

The endpoint's own isolation properties are as described, and I do not want the withdrawal to obscure that: loopback-only binding, no listener before first use, a 256-bit CSPRNG bearer path, exact Host and whole-string URL matching that makes traversal structurally impossible, GET/HEAD only, the CSP directives checked one by one, the double size validation, and the ablation I ran showing the bearer-path check is pinned by the cross-lease isolation test. Those findings do not change; they simply do not cover the two lifecycle properties above.

The two P2s were raised by the Qronos review line on this head. I verified them independently against the source and am reporting what I found; the file and line references above are my own tracing, not a restatement of their analysis.

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

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(harness): provide managed HTML artifact preview endpoints fix(desktop): make generated HTML previews truthful and directly openable

3 participants