Skip to content

Add runtime-bound sessions and runtime UI#43

Draft
ashwin-pc wants to merge 12 commits into
mainfrom
runtime-first-sessions
Draft

Add runtime-bound sessions and runtime UI#43
ashwin-pc wants to merge 12 commits into
mainfrom
runtime-first-sessions

Conversation

@ashwin-pc

Copy link
Copy Markdown
Owner

Summary

This draft PR moves the runtime-bound session work onto a reviewable branch.

It adds:

  • runtime protocol/client/provider/store plumbing
  • command/Docker/local runner providers
  • persisted per-session runtime bindings
  • runtime-aware state/messages/prompt/abort/session creation/opening/folder selection
  • a Runtimes panel with connect/disconnect and copyable examples
  • runtime-first folder picker behavior
  • regression coverage for runtime/local switching and runtime-scoped folder selection

Current status

Validation from the working branch before opening this PR:

  • npm run typecheck passed
  • runtime vitest suite passed
  • targeted runtime e2e passed on desktop/mobile
  • npm run test:unit passed
  • full npm test passed
  • npm run build passed

Known follow-ups / review focus

This is intentionally still draft/WIP. Areas needing review and follow-up:

  • Guided sandbox-runtime onboarding should replace the current advanced JSON-first connect UI.
  • Several session APIs still need full runtime routing or explicit unavailable/unsupported behavior.
  • Runtime model/auth APIs should be first-class so the model picker shows only models available inside the selected runtime.
  • Runtime lifecycle hooks (start/stop/status/reconnect) need a typed adapter design.
  • Stale local/dev runtime registrations should be hidden or cleaned up.

Notes

The current implementation avoids silent fallback for core runtime-bound chat routes, but the broader UI still needs a stricter sandbox-first policy before this should be considered ready for merge.

@ashwin-pc ashwin-pc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Reviewed the whole branch. Verdict: the foundation layer is on the right track — the integration layer needs a course correction before this grows further.

Keep as-is (this part is good)

  • server/runner.ts as a standalone stdio JSON-RPC agent host is exactly the right shape: one runner that works identically on host, in Docker, via docker exec, or over SSH. Best decision in the branch.
  • The NDJSON protocol (protocol.ts, stdioClient.ts) is simple, debuggable, and transport-agnostic.
  • RuntimeBindingStore with atomic writes + serialized write queue matches the runtime-binding design doc.
  • Docker security posture (--network none default, env allowlist, read-only /app, no arbitrary host mounts from API params) and the honest limitations section in docs/docker-runtime.md.
  • Degraded runtimeUnavailable* states so the UI survives a missing runtime.
  • Layered tests (protocol / provider / runner / API / e2e).

Core problem: runner sessions are a parallel session universe

Runner sessions bypass liveSessions, viewer leases, the realtime pipeline, tool cards, streaming, the tree, compaction, models, thinking, and images. To compensate, 5+ core routes (/api/state, /api/messages, /api/prompt, /api/abort, /api/sessions/open, plus git/fs/artifacts/new-chat/cwd) each grew an if (runnerBinding...) fork with its own error handling. This is the mockMode branching pattern, multiplied.

User-visible symptoms:

  • No streaming: the runner only emits session.prompt.start/done/error, so sandboxed sessions show nothing until the full response completes — no deltas, no tool cards, no thinking. Big enough UX cliff that people will avoid sandboxed sessions.
  • /api/models returns models: [], thinking is hardcoded off, and prompt images are silently dropped (see inline comment — that one is a bug, not a gap).

The design doc says: “Make existing API routes runtime-aware without duplicating UI behavior.” The current shape duplicates behavior.

Suggested inversion

  1. In runner.ts, add sessions.subscribe: call session.subscribe(...) and forward every agent event as { event: "session.event", data: { sessionId, event } }. ~15 lines, and it is the same event shape broadcast() + frontend realtime.ts already consume — streaming, tool cards, and unread tracking then work for free.
  2. Define a narrow SessionHost interface (state, messages, prompt(+images), abort, setModel, subscribe, …). Local = today's in-process path; remote = an adapter over StdioRuntimeClient. Routes resolve sessionId → host once, in one place, instead of per-route forks.
  3. Forward models.list / setModel / thinking / images (base64) through the protocol so the runner protocol becomes “everything a session can do”, not “the subset forked so far”.

This is also the first slice of the server refactor we discussed: SessionHost is the same seam that lets mock mode become a third host implementation, killing both sets of if forks together. I'd do steps 1–2 before adding any more proxied routes — each new fork raises the cost of the inversion.

Other structural points (details inline)

  • The three providers (StdioRunnerProvider / DockerRunnerProvider / CommandRunnerProvider) are ~90% copy-paste; collapse to one class + a Docker config factory.
  • runner.ts re-implements safeArtifactName, textFromContent, git status/diff from server.ts — extract shared server/git.ts / server/artifacts.ts on main first so runner and server import the same code instead of drifting.
  • Rename “experimental” — it's load-bearing now — and consider gating the always-on local StdioRunnerProvider behind an env flag; it spawns a second tsx process to do what the in-process path already does.

Suggested merge sequencing

  1. Mergeable nearly as-is: protocol.ts, stdioClient.ts (with the non-JSON-line fix), bindings.ts, runtimeStore.ts, runtime panel UI, docs.
  2. Collapse the three providers.
  3. Full event forwarding in runner.ts.
  4. SessionHost + remove per-route forks (the big one).
  5. Inline bug fixes: bindings growth/caching, image drop, token-into-container, stdout parse fragility.

Comment thread server/runtime/stdioClient.ts Outdated
try {
message = parseRuntimeLine(line) as RuntimeResponse | RuntimeEvent | undefined;
} catch (error) {
this.failAll(error instanceof Error ? error : new Error(String(error)));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Bug risk: a single non-JSON line on stdout calls failAll, killing every pending request and effectively bricking the client. The example configs in runtimePanel.ts run npm exec --yes tsx …, and npm happily prints banners/progress to stdout, so this will happen in practice.

Suggest: ignore unparseable lines (log them to stderr), or require the ready handshake and drop anything received before it. failAll should be reserved for process exit/error.

Comment thread server/runtime/dockerProvider.ts Outdated
"-w", "/app",
"-e", `PI_RUNNER_CWD=${this.containerWorkspace}`,
];
if (this.token) args.push("-e", "PI_WEB_TOKEN");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This ships the pi-web bearer token into the sandboxed container, but runner.ts never uses PI_WEB_TOKEN. Unnecessary secret inside the trust boundary you're building — drop it.

kind?: "local" | "container" | "ssh";
};

export class CommandRunnerProvider {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

CommandRunnerProvider, StdioRunnerProvider, and DockerRunnerProvider are ~90% identical — same sessionFiles map, same request wrappers, same rememberSession/sessionParams. They differ only in how the spawn command is computed.

Suggest one provider class taking { id, label, kind, command, args, cwd, processCwd }, with Docker reduced to a factory function that turns DockerRunnerOptions into that config (dockerArgs() already is that computation). One class, one test suite, and runtimeKindForProvider/defaultCwdForRunnerProvider/isDockerRunnerProvider type-sniffing in server.ts disappears too.

Comment thread server/runner.ts
const session = await resolveSession(params);
const message = String(params.message || "").trim();
if (!message) throw new Error("message is required");
send({ event: "session.prompt.start", data: { ...sessionState(session), isStreaming: true } });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is the root of the biggest gap in the branch: only prompt.start/done/error are emitted, so runner sessions have no streaming — no assistant deltas, no tool cards, no thinking indicator until the entire response finishes.

Suggest adding a sessions.subscribe method that calls session.subscribe(...) and forwards every agent event as { event: "session.event", data: { sessionId, event } }. It's the same event shape broadcast() and the frontend realtime.ts already consume, so streaming/tool cards/unread tracking come for free on the server side once the events flow.

Comment thread server/runner.ts Outdated
const loader = new DefaultResourceLoader({ cwd, agentDir: getAgentDir() });
await loader.reload();
const result = await createAgentSession({ cwd, sessionManager, authStorage, modelRegistry, resourceLoader: loader });
liveSessions.set(result.session.sessionId, result.session);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

runner.ts re-implements safeArtifactName, textFromContent, and git status/diff that already exist in server.ts (the two safeArtifactNames are only coincidentally identical today and will drift). Worth extracting shared server/git.ts / server/artifacts.ts modules on main first so both import the same code — this dovetails with the server.ts refactor anyway.

Comment thread server.ts Outdated
envAllowlist: process.env.PI_WEB_DOCKER_ENV_ALLOWLIST?.split(",").map((item) => item.trim()).filter(Boolean),
})
: undefined;
const experimentalRunnerSessionIds = new Set<string>();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

experimentalRunnerSessionIds (Set) and runnerSessionRuntimeIds (Map) encode the same fact and are updated in tandem in ~6 places — they can drift. A single Map<sessionId, runtimeId> covers both (has() replaces the Set). Also suggest renaming away from "experimental" — this is load-bearing routing state now, and the name leaks into runnerProviderForSession's fallback logic.

Comment thread server.ts Outdated
const runnerProvider = runnerProviderForSession(requestedSessionId);
const runnerState = await runnerProvider.state(requestedSessionId) as any;
const status = await runnerProvider.gitStatus(runnerState.cwd) as any;
return sendJson(res, 200, { ok: true, cwd: runnerState.cwd, branch: status.branch || "", files: [], isRepo: Boolean(status.isRepo), raw: status.porcelain || "" });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This returns files: [] + raw porcelain while the local path returns parsed files, so the git panel renders differently depending on runtime. You already have parseStatusLine in this file — parse the porcelain server-side so the response shape is uniform regardless of where git ran.

Comment thread server.ts
return sendJson(res, 200, { ok: true, runtimes: [...runtimeRegistry.list(), ...runtimeRunnerProviders.map(runtimeSummaryForProvider)] });
}

if (method === "POST" && url.pathname === "/api/runtimes/connect") {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Worth flagging explicitly: this endpoint is authenticated arbitrary command execution on the host, and unlike a shell in a session it persists across restarts via runtimeStore and is invisible afterwards. pi-web is inherently RCE-by-design behind the token, but this deserves (a) a callout in the docs like you did for Docker, and (b) possibly an env opt-in (PI_WEB_ALLOW_CUSTOM_RUNTIMES=1) so a token leak doesn't silently gain a persistence mechanism.

Comment thread server.ts Outdated
try {
const artifactProvider = runnerProviderForSession(sessionId);
const runnerState = await artifactProvider.state(sessionId) as any;
const artifact = await artifactProvider.readArtifactBase64(runnerState.cwd, name);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No size cap: a large artifact is fully buffered in the runner, JSON/base64-encoded (×1.33), then buffered again here. Add a max-bytes check in the runner's artifacts.readBase64 (reject over, say, 10–20 MB) — chunked transfer can come later if needed.

Comment thread server/runtime/stdioProvider.ts Outdated
messages: number;
};

export class StdioRunnerProvider {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This provider is registered unconditionally and spawns a second tsx subprocess to do what the in-process path already does. Great as a test/dev vehicle, but as a default user-selectable runtime it doubles memory and confuses "Local machine" vs "Local runner process". Suggest gating it behind an env flag (e.g. PI_WEB_LOCAL_RUNNER=1).

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Pushed follow-up review fixes through b351082.\n\nAddressed so far:\n- collapsed runner providers onto the shared command-backed provider base\n- gated local runner behind PI_WEB_LOCAL_RUNNER=1\n- removed PI_WEB_TOKEN from Docker runtime env\n- ignored non-protocol stdout lines instead of failing all pending runtime RPCs\n- added runner session event forwarding via sessions.subscribe / session.event\n- proxied runtime model list/set + thinking metadata through runner RPC\n- forwarded prompt images to runtime sessions instead of dropping them\n- added max artifact size check before base64 buffering\n- cached runtime bindings and stopped persisting unbounded local bindings\n- replaced several remaining host fallbacks with explicit unsupported-runtime responses\n- gated persistent custom command runtimes behind PI_WEB_ALLOW_CUSTOM_RUNTIMES=1 outside dev/mock\n- updated Docker runtime docs with the security defaults\n\nValidation after these commits:\n- npm run typecheck passed\n- runtime vitest suite passed\n- targeted runtime e2e passed\n- npm run test:unit passed\n- full npm test passed\n- npm run build passed\n\nStill not claiming merge-ready: the full SessionHost inversion/guided sandbox onboarding remains the next structural step.

@ashwin-pc ashwin-pc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Re-review after 9427fce + b351082

This round resolved almost everything from the last review, and resolved it well:

  • ✅ stdioClient no longer bricks on non-protocol stdout lines
  • PI_WEB_TOKEN no longer shipped into the container (+ docs updated)
  • ✅ Provider triplication collapsed — Docker/Stdio are now thin subclasses of CommandRunnerProvider
  • ✅ Full pi event stream forwarded (sessions.subscribesession.eventpi_event broadcast) — streaming works
  • ✅ Images passed through to runner sessions (the silent-drop bug is gone)
  • ensureLocal no longer persists unbounded local rows; binding store has a write-through cache
  • experimentalRunnerSessionIds Set removed; single runnerSessionRuntimeIds Map
  • ✅ Runner git status parsed with parseStatusLine + ahead/behind
  • ✅ Artifact size cap (20MB, env-tunable)
  • ✅ Local runner gated behind PI_WEB_LOCAL_RUNNER=1
  • /api/runtimes/connect gated behind PI_WEB_ALLOW_CUSTOM_RUNTIMES in prod, with docs callout
  • ✅ Bonus: host-only routes now return explicit 501s instead of silently operating on the host cwd, and model switching actually works for runner sessions

Why the remaining items are blocking, not follow-ups

Main has no runtime concept today. This PR introduces it, so whatever shape merges becomes the foundation everything else builds on. Deferring the structural items means merging an architecture we already know we want to replace, and re-reviewing the same surface twice. Since nothing has shipped, there's no churn cost to doing it right the first time — so I'm marking the structural items as blocking.

Blocking:

  1. SessionHost inversion — the per-route runtimeBindingIfAny forks have grown to ~15 call sites. Details inline.
  2. Event forwarding parity — forwarded runner events skip the host-side enrichment pipeline (tool startedAt, lastActivityAt, unread recovery, stats), and runtimeForEvent is keyed on container paths. Details inline.
  3. Runner-side session/subscription leakliveSessions/subscriptions grow forever.
  4. models.set state-merge hack in /api/model — have the runner return full session state instead.
  5. Helper duplication between runner.ts and server.ts — extract a shared module before the copies drift.
  6. Frontend fetch/error-parsing duplicationsessionDrawer and runtimePanel each hand-roll runtime fetching and error parsing; add the shared API helper now rather than after 10 more call sites exist.
  7. Test coverage for the two headline fixes — event forwarding and image passthrough are currently untested.

Nits (genuinely fine to skip): rename experimentalRunnerWebState, malformed-protocol-line hang, prompt timeout with large images, document.execCommand fallback.

The protocol, runner, provider hierarchy, binding store, security posture, and UI are all in good shape — the remaining work is integration shape, not rework.

Comment thread server.ts Outdated
const binding = await runnerBindingForSession(sessionId);
return binding.binding || binding.provider ? binding : undefined;
}
function runtimeUnsupported(res: ServerResponse, feature: string, binding?: { binding?: SessionRuntimeBinding; provider?: RunnerProvider }) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: invert to a SessionHost interface instead of per-route forks.

runtimeUnsupported + runtimeBindingIfAny guards now appear at ~15 route call sites (git/repos, git/log, git/commit, git/image, git/sync, web-header-action, session/stats, session/tree ×3, commands, command, shell, compaction/abort, session/name, delete), plus full forks in messages, model ×2, prompt, git/status, git/diff, artifacts, and the WS hello. Every new route added to pi-web from now on has to remember to add a guard, and every new runtime capability means touching N routes.

Since this PR is what introduces the runtime concept to main, this is the moment to shape it. Define one interface both session kinds implement:

interface SessionHost {
  state(): Promise<WebState>;
  messages(): Promise<SimplifiedMessage[]>;
  prompt(message: string, images?: ImagePart[]): Promise<void>;
  abort(): Promise<void>;
  gitStatus?(): Promise<GitStatus>;      // optional = capability
  listModels?(): Promise<ModelState>;
  // ...
}
function hostForSession(sessionId: string): Promise<SessionHost>;

Routes then call host.gitStatus?.() ?? unsupported(res, "Git status") — the 501 behavior you added falls out of optional methods for free, unsupported capabilities are impossible to forget, and mock mode becomes a third host instead of its own special case. The LocalSessionHost wraps the existing live-session code; the RunnerSessionHost wraps CommandRunnerProvider. This is mostly moving code you already wrote, not new logic — but doing it after merge means re-reviewing all 15 sites twice.

Comment thread server.ts Outdated
const piEvent = data?.event;
if (sessionId) runnerSessionRuntimeIds.set(sessionId, provider.id);
broadcast({ type: "pi_event", sessionId, sessionFile, event: piEvent });
broadcast({ type: "session_runtime_changed", sessionId, sessionFile, runtime: runtimeForEvent(sessionFile, piEvent) });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: forwarded runner events bypass the host-side enrichment pipeline, and runtimeForEvent is keyed on container paths.

Compare with the local event path (~line 2280): before broadcasting, it stamps startedAt on tool_execution_start, propagates it to tool_execution_update/end, sets lastActivityAt via markRuntimeActivity, and additionally emits session_stats_changed/models_updated. Runner events are broadcast raw, so for runtime sessions the UI loses tool durations, activity timestamps, and stats updates — a quiet quality gap that will read as "runtime sessions feel broken".

Separately, runtimeForEvent(sessionFile, piEvent) and noteRuntimeEventForUnreadRecovery key host-side maps (runtimeStartedAts, runtimeLastActivityAts) by sessionFile — but here sessionFile is a container path that will never match host session listings, so unread recovery and runtime status for these sessions are silently wrong.

Fix: extract the enrichment block from the local subscribe handler into a shared enrichAndBroadcastPiEvent(sessionId, sessionFile, event) and call it from both paths, and key the runtime-activity maps by sessionId (or a normalized key) rather than raw file path. This pairs naturally with the SessionHost change.

Comment thread server/runner.ts
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const liveSessions = new Map<string, any>();
const subscriptions = new Map<string, () => void>();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: liveSessions and subscriptions grow without bound.

Every sessions.state/sessions.prompt/sessions.subscribe on a new session adds an entry to both maps and nothing ever removes them — each entry holds a full agent session (messages, model registry hooks) plus a live subscription. A long-lived Docker runner serving a user who touches many sessions will accumulate all of them in memory.

Minimum fix: an LRU cap (e.g. keep the N most recently used sessions; on eviction call the stored unsubscribe and drop the session). Also consider a sessions.release RPC so the server can proactively evict when a binding is deleted or a session goes idle.

Comment thread server.ts Outdated
const id = String(body.id || "").trim();
if (!provider || !id) return sendJson(res, 400, { ok: false, error: "provider and id are required" });

const runnerBinding = await runnerBindingForSession(requestedSessionId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: replace the state-merge hack with a full state return from the runner.

experimentalRunnerWebState({ ...runnerState, ...models }, runnerProvider) merges two differently-shaped objects (models has current/models/cwd, runnerState has model/sessionId/...), then patches model/thinkingLevel back on top. It works, but it's exactly the kind of shape-coupling that breaks silently when either side changes.

Simpler: have the runner's models.set handler return { ...sessionState(session), models: modelState(session) } (it already has the session in hand), so the server gets one authoritative post-change state and this route becomes broadcast(state); sendJson(res, 200, state). Also saves the second round-trip (setModel then state).

Comment thread server/runner.ts Outdated
return { path, parent: dirname(path), dirs };
}

async function gitStatus(cwdValue: unknown) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: extract shared helpers instead of duplicating server.ts logic.

gitStatus, gitDiff, safeArtifactName, the artifact path convention (.pi/web/artifacts), and listDirectories here are near-copies of code in server.ts. They've already drifted once this PR (this side gained -b porcelain headers; the artifact side gained a size cap the host route doesn't have).

Move them to something like server/shared/{git,artifacts,fsList}.ts imported by both server.ts and runner.ts. The runner is spawned via tsx from the same source tree in every deployment mode (local, Docker mounts /app, custom commands run the same file), so sharing modules is safe — and it guarantees host sessions and runner sessions return identical shapes for the same operations.

});

describe("runtime runner spike", () => {
it("serves health, filesystem, git, artifacts, and events over stdio", async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: the two headline fixes from this revision are untested.

This test exercises health/fs/git/artifacts/session CRUD, but not:

  1. Event forwardingsessions.subscribe returning ok, and a session.event envelope arriving on the client with { sessionId, sessionFile, event } shape (you can trigger at least session.created deterministically without a model key).
  2. Image passthroughsessions.prompt with an images array accepting an image-only prompt (no message), and rejecting a prompt with neither.

Both were the fixes for real bugs (no streaming; images silently dropped), so they're the regressions most worth guarding. A third cheap one: artifacts.readBase64 rejecting a file over PI_RUNNER_MAX_ARTIFACT_BYTES with a tiny env override.

Comment thread server.ts Outdated
webHeaderActions: [],
};
}
function experimentalRunnerWebState(runnerState: any, provider: RunnerProvider) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit: experimentalRunnerWebState is now a misnomer — it renders state for all runtime providers including the non-experimental Docker one. Rename to runnerWebState (falls out naturally if the SessionHost refactor lands).

Comment thread server/runtime/stdioClient.ts Outdated
message = parseRuntimeLine(line) as RuntimeResponse | RuntimeEvent | undefined;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
process.stderr.write(`[runtime] ignored non-protocol stdout line: ${line.slice(0, 500)}${line.length > 500 ? "…" : ""} (${detail})\n`);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit: ignoring unparseable lines is the right default (fixes the npm-banner problem), but note the trade-off: a truncated/corrupt protocol line now means the matching request silently hangs until its timeout instead of failing fast. If you want the best of both: when the ignored line starts with {"id":, log at a louder level — that's almost certainly a mangled response, not banner noise.

Comment thread server/runtime/commandProvider.ts Outdated

messages(sessionId: string) { return this.start().request("sessions.messages", this.sessionParams(sessionId)); }

async prompt(sessionId: string, message: string, images?: unknown[]) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit: sessions.prompt uses a 30s request timeout, and the request line now carries base64 image payloads. Over a slow transport (SSH runner, cold Docker) a few multi-MB images could plausibly blow the window. Consider a larger timeout for prompt specifically, or scale it when images.length > 0.

Comment thread src/runtimes/runtimePanel.ts Outdated
}
}

async function connectRuntime() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit: document.execCommand("copy") is deprecated; fine as a fallback, but the navigator.clipboard branch already covers every browser that can run this app (secure context is required for the token flow anyway). Could drop the fallback entirely and simplify.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Pushed 1a42c8f addressing the new blocking review round.\n\nCovered in this slice:\n- introduced a SessionHost routing seam for local / runner / unavailable sessions and moved state, messages, prompt, abort, models, git status/diff, artifacts, and local-only capability guards through it\n- extracted shared helpers for git, artifact paths/names/base64 caps, and directory listing so host and runner use the same logic\n- moved runner event forwarding through shared pi-event enrichment so tool start timestamps, activity timestamps, runtime state, stats notifications, and unread recovery use session-id keyed maps instead of container paths\n- changed runner models.set to return full session state + model state; removed the server-side state merge/second round trip\n- added runner session LRU/release cleanup for liveSessions and subscriptions\n- consolidated frontend runtime API access into src/runtimes/api.ts\n- added regression coverage for runner event subscription/forwarding, image-only prompt passthrough, empty prompt rejection, and artifact size-cap rejection\n- handled the stdout malformed-protocol nit and longer image prompt timeout\n\nValidation after this commit:\n- npm run typecheck passed\n- runtime vitest suite passed\n- targeted runtime e2e passed\n- npm run test:unit passed\n- full npm test passed\n- npm run build passed

@ashwin-pc ashwin-pc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Re-review after 1a42c8f "Introduce session host runtime routing"

All seven blocking items from the previous review are genuinely resolved, and resolved well:

  1. SessionHost inversion — capability-based interface with three implementations (local/runner/unavailable), one sessionHostForSession() factory, and all ~25 routes uniformly using host?.capability + unsupportedHostCapability. The 501 behavior now falls out of optional methods; the per-route forks are gone.
  2. Event parityenrichPiEventForBroadcast/broadcastPiEvent shared between local and runner paths (tool startedAt, lastActivityAt, stats, models_updated), and runtime-activity maps re-keyed by runtimeMapKey(sessionId, sessionFile) — the container-path bug is fixed.
  3. Runner memory — LRU cap with unsubscribe+dispose on eviction, plus sessions.release.
  4. models.set returns full session state; the route is one call, one broadcast.
  5. Shared helpersserver/shared/{git,artifacts,fsList}.ts; runner git status even gained full parity (untracked, upstream, ahead/behind, fetchRemote).
  6. Frontend API consolidationsrc/runtimes/api.ts, one normalizer + one error parser.
  7. Tests for forwarding, image passthrough, artifact cap. All four nits fixed too.

I verified this empirically in a clean worktree: typecheck passes, and the unit/API/runtime suites pass — with one exception that is the first of two remaining blockers.

Blocking

  1. tests/runtime-runner.test.ts image-prompt test is not hermetic — it depends on a live model API key + network and fails deterministically without one (details inline). This will fail in CI and for any contributor without credentials.
  2. Runner session deletion must be implemented, not deferred — I initially called the unused provider.release()/runtimeBindingStore.remove() surface a non-blocking observation, but tracing the data flow shows deletion is a required part of this feature, not a follow-up (details inline on listSessionInfos).

Once these two land, this is mergeable as far as I'm concerned — the architecture is now the one we wanted on day one.

}
}, 60_000);

it("accepts image prompts and rejects empty prompts", async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: this test requires live model credentials and fails without them.

The session.event assertion depends on the agent loop actually starting. I traced it with a debug harness against the runner: with no API key available, session.prompt fails before the agent loop (session.prompt.error: "No API key found for the selected model..."), so no session.event is ever forwarded and waitForEvent times out after 15s. In my environment this fails 4/5 runs; in keyless CI it will fail 100% of the time.

Two hermetic options:

  1. Assert the deterministic contract instead: sessions.prompt resolves { ok: true } (proving the image-accepting path), and either session.prompt.start or session.prompt.error arrives (proving the event plumbing) — no model call required:
const promptEvent = waitForEvent(client, (e) => e.event === "session.prompt.start" || e.event === "session.prompt.error");
  1. Or keep the stronger session.event assertion but gate it: it.skipIf(!process.env.ANTHROPIC_API_KEY)(...), keeping the empty-prompt rejection assertion in an unconditional test.

Option 1 is better — it tests your code rather than Anthropic's uptime.

Comment thread server.ts Outdated
if (mockMode) return mockSessions.map((info) => simplifySessionInfo(info as any, info.cwd || piCwd));
const bindings = (await runtimeBindingStore.read()).bindings;
if (noSession) return runtimeBoundSessionInfos(bindings);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Blocking: implement runner session deletion — it's required, not deferrable.

This line is why: the bindings file is the source of truth for the session drawer (runtimeBoundSessionInfos(bindings) feeds /api/sessions directly). Combined with two other facts:

  • runtimeBindingStore.set() is called on every runner session creation, and runtimeBindingStore.remove() is never called anywhere;
  • the runner host defines no deleteSession capability, so /api/sessions/delete returns 501 for runner sessions;

…every runner session ever created becomes a permanent, undeletable row in every user's session drawer, and the bindings file grows without bound. That's the same class of unbounded-growth bug as the ensureLocal issue fixed in 9427fce — fixed for local sessions, reintroduced for runner sessions.

So provider.release() and bindings.remove() aren't optional scaffolding — they're the two halves of a half-implemented feature. And thanks to the SessionHost refactor, finishing it is small (~40 lines), because the delete route is already host-agnostic (UI-state removal + session_deleted broadcast happen in the route; the host only supplies the deletion):

  1. Runner: sessions.delete RPC → releaseRunnerSession(sessionId) + unlink(sessionFile).
  2. Provider: deleteSession(sessionId) → RPC + drop from sessionFiles/subscribedSessionIds (reusing release()).
  3. makeRunnerSessionHost: add deleteSessionprovider.deleteSession(...), then runtimeBindingStore.remove(sessionId) + runnerSessionRuntimeIds.delete(sessionId), return { id: sessionId, disposition: "deleted" }.

If the runtime is unavailable, deletion of the binding should still work (remove the row so the drawer entry goes away) — arguably via the unavailable host too, so users can clean up sessions whose runtime is gone for good.

Alternative considered and rejected: if deletion really were deferrable, the dead release/remove surface should be deleted rather than shipped unused — but it isn't deferrable, because the drawer fills with immortal rows from day one.

localStorage.setItem(knownSessionCwdsStorageKey, JSON.stringify(Array.from(cwds)));
}

async function responseError(response: Response) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit: responseError and fetchRuntimeOptions are now one-line aliases for parseApiError/listRuntimes. Fine as churn-reducers, but consider inlining the imports at call sites so there's exactly one name for each operation.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Pushed cfbc138 for the latest two blockers.\n\nChanges:\n- made the runtime-runner image prompt test hermetic: it now asserts the image prompt is accepted and that deterministic runner prompt lifecycle events arrive, without requiring model credentials/network\n- implemented runner session deletion end-to-end:\n - runner RPC sessions.delete releases live session/subscription and unlinks the session file\n - provider deleteSession drops remembered session/subscription state\n - runner and unavailable SessionHost implementations remove runtime bindings and runtime maps so drawer rows are actually deletable, including when the runtime is gone\n- added runtime API coverage for deleting a live runner session and deleting an unavailable runtime-bound session after disconnect\n\nValidation:\n- npm run typecheck passed\n- runtime vitest suite passed\n- full npm test passed on rerun (first run had an unrelated compact tool-card e2e flake; targeted rerun of that test passed)\n- npm run build passed

@ashwin-pc
ashwin-pc force-pushed the runtime-first-sessions branch from cfbc138 to 36c1911 Compare July 9, 2026 11:59
@ashwin-pc

Copy link
Copy Markdown
Owner Author

Runtime/workbench UX follow-up plan

After testing the current runtime-first branch, I think the right next step is to keep the current safe primitive (sessions are runtime-bound) but change the product UX to a VS Code-like active runtime/workbench context.

Proposed model:

  • Connecting/attaching a runtime is done once via a runtime manager.
  • The app has an active workbench runtime (Local, Docker pi-web, SSH devbox, etc.).
  • New sessions default to the active runtime.
  • Folder picking is scoped to the active runtime; cwd is runtime-relative.
  • Existing sessions remain bound to the runtime where they were created.
  • Opening a session from another runtime switches/reflects the active runtime to that session's runtime.
  • No silent fallback: unavailable runtime sessions show reconnect/rebind/history actions.

Important architecture point:

  • Runtime sessions should be authoritative in the runtime, not host-local session storage.
  • Host pi-web should keep only connection config plus a locator/cache for offline/deep-link/recovery cases.
  • Session identity should become effectively composite: { runtimeId, sessionId }.

Implementation plan:

  1. Add runtime-owned session listing

    • Add runner RPC sessions.list.
    • Add/adjust /api/sessions?runtimeId=... to list sessions from the selected runtime.
    • Keep local session listing unchanged for runtimeId=local.
  2. Add active workbench runtime state

    • Frontend state gets activeRuntimeRef, distinct from currentSession.runtimeRef.
    • Header/status gets a runtime switcher.
    • Runtime manager remains for connect/disconnect/health; normal users should not paste JSON for every session.
  3. Route APIs through composite session identity

    • Prefer { runtimeId, sessionId } for state/messages/prompt/model/git/delete/etc.
    • Continue using SessionHost, but resolve from the explicit runtime/session locator.
  4. Update new-session and folder flows

    • New sessions use the active runtime by default.
    • Folder picker no longer asks for runtime every time unless the user chooses “Change runtime”.
    • Directory browsing uses /api/fs/dirs?runtimeId=....
  5. Update session drawer UX

    • Show sessions scoped/grouped by active runtime.
    • Optional “All runtimes” view later.
    • Opening a session from another runtime updates the active runtime context.
    • Unavailable runtimes show explicit recovery UI.
  6. Migrate UI state keys

    • Pinned sessions, unread state, markers, cached rows, and realtime updates need { runtimeId, sessionId } keys to avoid collisions.
    • Keep migration/backcompat for existing local-only keys.
  7. Tests

    • Runner sessions.list.
    • /api/sessions?runtimeId local + runtime + unavailable.
    • Active runtime switcher defaults new sessions/folder picker.
    • Opening a runtime session switches active runtime.
    • Local UX unchanged when no extra runtimes exist.
    • Offline runtime sessions never fall back to local.
    • Composite keys for pinned/unread/markers.

This means the current branch has the right lower-level invariant (runtime-bound sessions, no silent fallback), but the next UX iteration should make runtime selection a workbench context rather than a repeated per-session choice.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Thoughts on the workbench pivot — the product model is right (VS Code Remote is the correct frame, and "runtime-authoritative sessions, host keeps connection config + a locator/cache" is the correct inversion; the bindings file today is a cache pretending to be truth). Two pushbacks on the plan, then edge cases — with how VS Code Remote answers each, since it has hit all of them.

Pushback 1: composite identity {runtimeId, sessionId} is the wrong cut

Step 6 (migrating pins/unread/markers/cached rows/realtime keys + backcompat shims) is the most expensive, highest-regression-risk step in the plan — and the problem it solves doesn't exist:

  • Session IDs are UUIDv7; cross-runtime collision is not a real risk. The actual problem is routing ("which runtime do I ask about this session?"), and the locator answers that.
  • So make runtimeId a routing attribute — explicit param on API calls, field on the locator, field in realtime envelopes (all additive) — not part of identity. UI-state stores keep plain sessionId keys; no migration, no shims.
  • Composite identity is worse in the one edge case where it matters: if the same session store is ever visible via two runtimes, composite keys turn one session into two drawer rows with divergent unread/pin state. sessionId-as-identity + "last known route" degrades gracefully.

VS Code does use composite identity (the remote authority baked into every URI) — but only because paths aren't globally unique. Session IDs are. Use whatever is globally unique as identity; for pi-web that's already sessionId alone.

Pushback 2: don't switch the active runtime implicitly on session open

VS Code never switches a window's remote context — the window is the context, so opening something from another remote opens a new window. With implicit switching in one pi-web tab: user peeks at an old Docker session, and their new-session default and folder picker silently changed underneath them. Safer: viewing a session shows that session's runtime contextually in the header, but the workbench default changes only on explicit action (or switches with a visible "Workbench runtime → Docker pi-web · undo" affordance).

Related: activeRuntimeRef must be per-client (per-tab state, like VS Code's per-window authority), never server-global — otherwise two tabs fight over it, which is the same multi-client bug class as a server-global current session. This conveniently requires the routing-attribute design from pushback 1.

Edge cases to settle in the plan (with VS Code's answers)

1. Ephemeral storage vs. "authoritative runtime" (the big one). The current Docker provider runs --rm containers with sessions in the container's agent dir — if the runtime is authoritative, a restart means those sessions authoritatively cease to exist. VS Code's rule: never make disposable infrastructure authoritative for durable state (Dev Containers treats container + server as rebuildable precisely because nothing irreplaceable is inside; durable stuff is bind mounts + synced/local state). So either mount a persistent volume for the session store (making Docker behave like Remote-SSH, where the remote disk is durable), or explicitly adopt "runner sessions are disposable" in the UX. Claiming authority over --rm storage while promising a truthful drawer is not an option.

2. Missing ≠ deleted → split deletion into two verbs. VS Code's Recent list is pure launcher stubs: never GC'd, unreachable entries just fail on open with retry, and "Remove from Recently Opened" is a purely local operation. Copy that split: "Remove from list" = local locator/metadata removal, always available offline, no tombstones needed (if the runtime later reasserts the session, that's correct — the data exists); "Delete session" = requires the runtime up, deletes the file. This dissolves the whole tombstone/resurrection/GC problem. (Note: the current unavailable-host delete in cfbc138 is the first verb wearing the second verb's name — worth renaming when this lands.)

3. Runtime identity = declarative config, not the instance. VS Code's authority is derived from ssh_config alias / devcontainer.json — never the container ID — which is why a rebuilt container inherits all per-workspace state. Key the locator by the user-declared runtime config id (already exists). With a durable session volume (#1), same-id-new-container becomes a non-event; instance fingerprints only matter if #1 is skipped.

4. Mid-stream runtime death → reconnect before declaring dead. VS Code: reconnect banner with automatic retries; ptys survive brief disconnects because the server owns them; only server death is terminal; never silent fallback. pi-web maps cleanly: the runner owns the agent loop, so a transport blip should reconnect + resubscribe and the stream resumes. Only runner-process death is terminal — and that's when the host must sweep enrichment maps (runtimeStartedAts, toolStartedAts, running modes) for sessions routed to that runtime and broadcast terminal state, or sessions show "running" forever. Add provider reconnect/resubscribe-with-backoff to the plan.

5. cwd history is runtime-relative. VS Code paths are full URIs with authority, so host/remote paths structurally can't mix. pi-web: key remembered cwds (knownSessionCwds) by runtime id, or the folder picker will offer /workspace against the local runtime.

6. Ordering and skew. VS Code orders recents by local open time, never remote mtimes. Order the drawer by host-observed activity (the host already sees every event), not runtime-reported timestamps — sidesteps clock skew on SSH boxes entirely.

7. Smaller ones: drawer renders from the locator cache immediately with background reconcile (never block on docker exec); deep links / WS hello for a not-yet-connected runtime session serve the locator stub + recovery UI, not 404; /api/sessions?runtimeId=... responses should include runtimeRef per session now so the deferred "All runtimes" view is pure aggregation later; cap/paginate sessions.list.

Sequencing

Ship the pivot as a new PR on top of this one — this branch is mergeable-shaped and pivoting it would bury five review rounds. Suggested slices: (1) sessions.list + runtime-scoped drawer with cache-first rendering, (2) workbench switcher + new-session/folder defaults, (3) recovery UX + reconnect/resubscribe. Identity stays sessionId-only unless a concrete need appears. And the quiet most-important line in the plan is "normal users should not paste JSON" — the guided Docker/SSH forms are what make the runtime manager real.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Confirming cfbc138 resolves both blockers from the last review round:

  1. Hermetic test — the image-prompt test now asserts the deterministic contract (prompt accepted + session.prompt.start/session.prompt.error lifecycle event) with no model credentials/network dependency. Verified the reasoning against a debug trace of the keyless path.
  2. Runner session deletion end-to-endsessions.delete RPC (release + unlink), provider deleteSession dropping remembered state, and both the runner and unavailable SessionHost implementations removing bindings + runtime maps, so drawer rows are deletable even when the runtime is gone. API test coverage included for both paths.

No outstanding blockers from my side on this branch. Remaining discussion is about the workbench follow-up plan (see previous comment) — which should land as a separate PR.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Implemented the revised VS Code-style workbench model in a9fe02b.

Key changes:

  • Moved the workbench runtime selector to the session drawer footer beside Settings; New session is at the top.
  • Removed runtime selection from the empty-session/folder UI.
  • Switching runtime now changes the complete browser-tab workbench: tabs, session drawer, folders, model/auth context, git, artifacts, composer, and tools. The switch is explicit and warns that tabs will be replaced.
  • Quick tabs and pins are filtered to the selected runtime.
  • Connect-and-use, guided connection forms, and simple Use/Reconnect/Forget management; forgetting a runtime removes its cached locators but not runtime-owned session data.
  • Settings clearly separate global preferences from runtime-owned model/auth defaults.

Also fixed the cmd-api misclassification bug. Root cause was a combination of three unsafe shortcuts:

  1. a local command-backed test runtime inherited the host PI_CODING_AGENT_DIR, so its sessions.list saw host-local sessions;
  2. reconciliation was allowed to overwrite a session's runtime binding;
  3. requests without an explicit runtime inferred routing from that stale binding, and forgetting a runtime did not purge its locators.

Fixes:

  • local command runtimes now receive a dedicated agent directory;
  • session runtime bindings are immutable across reconciliation;
  • missing runtime routing means Local and never consults locator metadata;
  • Forget purges all locators for that runtime;
  • stale per-tab selections recover after the runtime is forgotten.

I cleared the corrupted local locator cache after backing it up. The live server now reports 188 local sessions, all with runtimeRef.id = local, zero runtime bindings, and only the built-in Local runtime registered.

Validation:

  • npm test passed across unit, desktop, tablet, mobile, and auth shards.
  • npm run build passed.
  • git diff --check passed.
  • Manual browser verification passed for the drawer footer selector, Settings, local session list, and runtime management flow.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Correction to my previous status update: live testing against a real Apple container exposed remaining runtime integration blockers, so the PR is not merge-ready despite the passing automated suites.

The agreed scope in the PR explicitly says runtime model/auth APIs must be first-class. We currently proxy models.list/models.set, but omitted auth.status and any guided way to authenticate the selected runtime. The result is an unexplained empty model picker for a correctly isolated runtime. Manually copying host auth.json is useful for diagnosis, but is not an acceptable product UX or completion of the agreed scope.

The same live pass confirmed several local-only capabilities are only rejected rather than represented clearly in the runtime workbench (slash/shell commands, rename, full stats/context, git sync, extension header/footer actions, compaction cancellation). I will audit these against the intended parity contract, route the capabilities that should work, and make any intentionally unsupported capability explicit/disabled in the UI with tests.

Credential isolation itself remains correct: host credentials must not be copied into a runtime silently. What is missing is runtime-scoped auth status/onboarding.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Adding a security requirement from live runtime review: provider-only egress is a merge blocker for managed container runtimes.

Proposed fail-closed topology for Apple container, Docker, and Podman:

  • runtime joins a unique internal network with no direct internet route;
  • a separate egress proxy joins both that internal network and the external/default network;
  • runtime Node processes use the proxy (NODE_USE_ENV_PROXY=1, HTTPS_PROXY/HTTP_PROXY);
  • proxy permits CONNECT only to the explicit domains required by the runtime's configured model/auth providers and denies everything else, including IP literals and non-TLS ports;
  • no published inbound ports;
  • UI shows the effective policy and exact allowlist, and proxy deny logs are available for diagnosis.

This prevents tools from bypassing the proxy by clearing environment variables because the runtime network itself has no external route. Dependencies/container images are provisioned before isolation; arbitrary npm/git/curl access remains blocked at runtime.

Policy modes should be explicit: none, provider-only (default for managed containers), and unrestricted (warning/opt-in). Existing user-managed containers must be inspected and reported as verified internal or unverified; pi-web must not claim isolation it did not establish. SSH runtimes rely on remote-machine firewall/proxy policy.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Architecture decision before the next implementation pass: replace copied container auth + provider egress proxy with a host-side model broker for managed local containers.

  • Apple container / Docker / Podman runtimes run with zero network egress (none).
  • Runtime runner keeps ownership of sessions, transcript, tools, cwd, tree, and model selection state.
  • Model inference uses typed bidirectional RPC over the existing stdio transport: runner requests an approved provider/model; pi-web host invokes pi-ai with host auth and streams model events/results back.
  • The broker accepts model operations only, never arbitrary URLs.
  • Host credentials never enter the container; OAuth refresh remains host-side.
  • Tools cannot bypass policy through curl, raw sockets, DNS, or clearing proxy variables because the runtime has no network route.
  • SSH/other-machine runtimes continue using auth and networking configured on that machine.

This supersedes the proposed proxy-sidecar and automatic auth-copy design for managed local containers. Implementation and threat-model tests remain blockers for PR #43.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Implemented and pushed the managed-container host model broker in 52b5a7f.

Architecture now in the branch

  • Apple container / Docker / Podman runners use typed bidirectional model RPC over their existing stdio transport.
  • Runtime owns session/transcript/tools/cwd/tree and model selection; host owns provider endpoint resolution, credentials, OAuth refresh, and pi-ai streaming.
  • Broker validates host-available provider/model ids and never accepts arbitrary HTTP methods or URLs.
  • Runner-supplied API keys, headers, environment, callbacks, signals, URL/baseUrl, and unknown stream options are discarded. Only an explicit safe option allowlist crosses the boundary.
  • Closing the runtime transport aborts active host model streams.
  • Broker-mode runners use in-memory dummy auth, scrub credential-shaped environment variables, and never read runtime auth.json.
  • SSH remains remote-auth/network owned.

Network policy

  • Built-in Docker always uses --network none and no longer forwards model credential env vars by default.
  • Guided Docker/Podman require inspected network mode none.
  • Internal Docker/Podman bridges are rejected because embedded DNS can remain an egress channel.
  • Guided Apple containers require an inspected hostOnly network and --no-dns.
  • Persisted guided runtimes are re-inspected on every server startup and are blocked before spawn if policy is no longer verified.
  • Advanced command runtimes are explicitly reported as unverified rather than receiving a false isolation claim.
  • Runtime UI shows effective network policy and model transport.

Real Apple-container proof

pi-web-project-runtime was recreated with preserved workspace/session/dependency volumes on:

  • network: pi-web-project-internal
  • Apple mode: hostOnly
  • DNS: disabled

The manually copied runtime /root/.pi/agent/auth.json was deleted. After a clean supervised server/runner restart:

  • runtime connected with modelTransport = host-broker, networkPolicy = none;
  • 41 host-authenticated models were available without runtime credentials;
  • direct external IP and HTTPS attempts from the container failed;
  • a real openai-codex/gpt-5.5 prompt completed through the broker with exact response BROKER_ISOLATED_OK;
  • the temporary authoritative runtime session was deleted afterward;
  • a real internet-capable Apple probe container was rejected by guided connect with HTTP 400 before runner health/spawn.

Validation

  • npm run typecheck
  • npm run build
  • 127 unit tests
  • full auth/desktop/tablet/mobile E2E suite: clean with PI_WEB_E2E_SHARDS=2 npm test
  • two unrelated high-concurrency UI flakes encountered on separate 3-shard runs each passed immediately in focused reruns; the final 2-shard full run was clean
  • git diff --check
  • browser dogfood of workbench switching, runtime details, and broker-auth settings text; no console errors

Container auth copying and the proxy sidecar proposal are superseded by this design. PR remains draft for the separate runtime capability audit/contract work.

@ashwin-pc ashwin-pc left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review round 4: a9fe02b (workbench UX) + 52b5a7f (host model broker)

Verified empirically in a clean worktree: typecheck passes, unit suite 126/127 — the one failure is itself a finding (B1 below).

Prior feedback: 7 of 8 items implemented

✅ runtimeId as routing attribute (plain sessionId keys everywhere, PinnedSession.runtimeId optional filter only) · ✅ per-tab activeRuntimeRef in sessionStorage, no implicit switch on session open, no server-global state · ✅ runner sessions.list + cache-first drawer (cached=1 from the locator, reconcile pass guarded by sessionRefreshSerial) · ✅ two-verb deletion (/api/sessions/remove local-only vs /api/sessions/delete through the host) · ✅ per-runtime cwd history with legacy migration · ✅ ordering by host-observed binding.updatedAt · ✅ provider reconnect with backoff + resubscribe + enrichment-map sweep and terminal broadcast on disconnect. ⚠️ Missing≠deleted is partial — runtime-down is handled well, runtime-up-but-session-absent is not (B3).

Why the broker is the right architecture (for the record)

The threat is tool code steered by an injectable model, running inside the container. Comparing the three candidate designs:

Credentials Egress control
Copy auth into container Stealable by any tool call; baked into image layers/volumes; OAuth tokens multiply Must open the network for model APIs — which opens it for exfiltration
Egress proxy / sidecar Still in-container, or the proxy injects them (= a broker at the HTTP layer) HTTP-level allowlisting of streaming/websocket APIs is fragile; DNS is a covert channel; per-runtime infrastructure
Host broker over stdio Never enter the container; refresh stays host-side Container gets --network none; the only path out is a typed RPC surface whose destinations the host chooses

The broker filters operations instead of traffic, rides the transport that already exists, and the JSON-lines framing gives a structural guarantee: functions/fetch/callbacks physically cannot cross. The residual channel (context sent to an approved model API) exists in every agent architecture including local pi-web.

Disconnects don't change the calculus. The runner's lifetime already is the stdio transport (docker exec -i/ssh child): when it dies, events are invisible and prompting is impossible regardless of who makes model calls. The broker only adds "in-flight model stream aborts," producing a cleanly failed turn persisted to the session file — the same blast radius as restarting pi-web mid-turn on main. The one property lost (an orphaned runner silently completing a turn offline) was never reliable or observable. Offline autonomy and credential isolation are mutually exclusive for containers; SSH runtimes with remote auth exist for the autonomy case.

Terminology matters: pi-web verifies, it does not manage. pi-web only ever connects to runtimes the user created. So the honest taxonomy is not managed/unmanaged but verifiable vs unverifiable: guided Docker/Podman can be inspected (point-in-time), Apple containers partially (hostOnly ≠ none), SSH and custom commands not at all. Verification can therefore inform but never decide — which drives the B2 redesign below.

Blocking (4)

  • B1 — Broker mode's model surface is not closed; ambient-credential models bypass the broker entirely (server/runner.ts:30, inline).
  • B2 — Broker enablement is inferred, not chosen: unverified custom runtimes get host-credential access by default (server.ts:3403, inline — with the agreed redesign: required manual decision at connect, no defaults, badge + tooltip display).
  • B3 — Runtime-up-but-session-absent silently vanishes from the drawer — no deleted disposition, no locator GC, resurrects as "unavailable" later (server.ts:1184, inline).
  • B4 — "Delete session data" can report success while deleting nothing (server.ts:1925, inline).

Concerns (8)

C1 honest network-badge taxonomy (none/host-only/unverified/unknown) + C4 attestation hardening (pin container ID, reject socket mounts, re-verify per spawn) — networkIsolation.ts:90 inline · C2 no concurrency cap on broker streams + C3 runner-controlled request-id collisions — modelBroker.ts:119 inline · C5 pendingHostRequests never time out; a transient host.models.list failure bricks brokerReady forever — runner.ts:99 inline · C6 drawer refresh rewrites the bindings file once per session — bindings.ts:53 inline · C7 runner LRU can dispose a mid-stream session — runner.ts:45 inline · C8 "Remove from list" resurrects on reconnect without warning + decorative pagination — sessionDrawer.ts:646 inline.

Nits

switchWorkbenchRuntime creates a persistent empty session file on every switch (sessionDrawer.ts:352-368) · enableBrokerInRunnerCommand enables the broker by regex-rewriting npm exec inside the command string — fragile; pass env explicitly through the provider · allowlisted broker option values aren't type-checked (e.g. transport accepts any shape) — validate primitives · 250KB decorative new-chat-loading.mp4 baked into git history plus a 150ms seek-timeout replay hack; CSS/Lottie would be smaller and simpler · deep link with ?sessionId= but no runtimeId silently forces the tab's workbench to local, overriding the persisted choice (types.ts:398-403).


With B1–B4 fixed, this is the architecture we said on day one we wanted to land in one piece — no interim states to migrate away from, credentials isolated by construction, and honest UI about what pi-web can and cannot vouch for.

Comment thread server/runner.ts
}
}
let authStorage = modelBrokerEnabled ? AuthStorage.inMemory() : AuthStorage.create();
let modelRegistry = modelBrokerEnabled ? ModelRegistry.inMemory(authStorage) : ModelRegistry.create(authStorage);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

B1 (blocking): broker mode's model surface is not closed — ambient-credential models leak in and bypass the broker.

ModelRegistry.inMemory() still loads the full built-in catalog (its constructor calls loadModels()), and hasConfiguredAuth consults ambient sources that survive the denylist env scrub above (line 25-27):

  • AWS_PROFILE does not match API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL, so Bedrock reports <authenticated>;
  • Google Vertex ADC is a file on disk (~/.config/gcloud/application_default_credentials.json) — no env var involved at all, and GOOGLE_CLOUD_PROJECT/GOOGLE_CLOUD_LOCATION also survive the scrub.

Consequence, reproduced on my machine: in broker mode, models.list returns 41 Bedrock models alongside the broker catalog; models.set can select one; the prompt then streams directly from the runner using ambient credentials — the broker is bypassed by construction, not by breach. The same applies after ensureModelBroker() swaps registries, because the broker registry is also ModelRegistry.inMemory and also contains the built-ins.

Fix by construction, not by scrubbing: in broker mode the session's registry must contain only broker-registered models — filter getAvailable()/find() to api === MODEL_BROKER_API, or build the registry without the built-in catalog. Once the registry is closed, the env scrub becomes defense-in-depth instead of load-bearing (though switching it from denylist to allowlist is still worth doing).

Comment thread tests/runtime-runner.test.ts Outdated
try {
await expect(client.request("health")).resolves.toMatchObject({ modelTransport: "host-broker" });
const created = await client.request<any>("sessions.create", { cwd });
await expect(client.request("models.list", { sessionId: created.sessionId })).resolves.toMatchObject({

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

B1 canary — this assertion fails on any machine with AWS/gcloud config (hermeticity recurrence #3, but this time the failure is real signal): models.list returns the ambient Bedrock/Vertex models alongside the mock broker model, so toMatchObject({ models: [oneModel] }) mismatches. When B1 is fixed by closing the broker registry, this test becomes hermetic for free — which is a good acceptance criterion: this exact test passing on a machine with AWS_PROFILE set proves the surface is closed.

Comment thread server.ts Outdated
processCwd: guidedConfig?.processCwd || (typeof body.processCwd === "string" ? body.processCwd : undefined),
...(agentDir ? { agentDir } : {}),
kind,
modelBroker: guidedConfig?.modelBroker ?? (kind === "container"),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

B2 (blocking): broker enablement is inferred, not chosen — unverified runtimes get host-credential access by default.

kind defaults to "container" for custom runtimes (line 3388), so with PI_WEB_ALLOW_CUSTOM_RUNTIMES=1 an arbitrary command — including one that reaches another machine — registers with networkPolicy: "unverified" yet still gets the broker handler attached (line 1660: attached iff provider.modelBroker). Host model credentials become spendable from an unisolated, internet-capable process that nobody consciously authorized. runtimeStore similarly accepts any persisted modelBroker: true on load. The "unverified" label is honest but gates nothing.

Agreed redesign (no defaults anywhere): since pi-web can only verify egress for some runtime types and never manage any of them, verification can inform but must not decide. Make modelBroker a required, explicit field on /api/runtimes/connect — reject the request if absent. The UI gathers it via a first-connect prompt: "How should this runtime access models?" → host credentials (brokered) vs runtime's own credentials — nothing pre-selected, one line each, ⓘ tooltips for depth (e.g. host+networked: "anything running there can spend — not read — your credentials while connected"; runtime+isolated: "only models running inside it will work"). Persisted, editable in runtime details ("takes effect on reconnect"), displayed as a compact Models: host / Models: runtime badge with tooltip.

This makes the bug class "broker enabled without anyone deciding" structurally unrepresentable — stronger than any policy table. Note the escape from my earlier framing: broker-off + --network none is not incoherent (a container running ollama inside works fine with runtime models and zero egress), which is exactly why no default is safe to guess.

Comment thread server.ts Outdated
continue;
}
try {
const page = await provider.listSessions({ limit: options.limit, cursor: options.cursor });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

B3 (blocking): runtime-up-but-session-absent silently vanishes — the unfinished half of missing≠deleted.

A successful listSessions emits only what the runner returned. A cached binding that the runner no longer reports (session file deleted inside the runtime, out-of-band cleanup) simply disappears from the drawer: no "deleted" disposition, no locator GC. The stale binding then stays in the store forever and resurrects as a "runtime unavailable" row the next time the runtime disconnects — a ghost lifecycle where a deleted session is invisible while the runtime is healthy and undead while it isn't.

Since this branch already treats the runtime as authoritative when reachable, the reconcile pass should diff the page against cached bindings for this runtime: bindings missing from a successful, complete listing (mind the pagination cursor — don't GC on a truncated page) get their locator removed and UI metadata GC'd, ideally with a session_removed broadcast so open tabs react. Runtime-down keeps the current keep-and-annotate behavior, which is correct.

Comment thread server.ts Outdated
return provider.abortBranchSummary(sessionId) as Promise<Record<string, any>>;
},
async deleteSession() {
await provider.deleteSession(sessionId);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

B4 (blocking): "Delete session data" can report success while deleting nothing.

Runner sessions.delete unlinks only if it can resolve a sessionFile — from its live map or from the params. The provider fills params.sessionFile from its in-memory sessionFiles map, which is empty after a host restart until a listSessions/state call repopulates it. In that window the runner returns { ok: true, deleted: false }, and this host path ignores the flag, removes the locator anyway, and tells the user disposition: "deleted" — the runtime file is silently orphaned.

Fix: thread the binding's sessionFile into the delete request as a fallback, and treat deleted: false as a failure (surface "could not locate session data; use Remove from list to forget it" rather than lying). The locator should only be removed when the runner confirms the unlink or confirms the file was already gone (ENOENT).

const auth = await this.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) throw new Error(auth.error);
const controller = new AbortController();
active.set(request.id, controller);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C2 + C3: cap concurrent streams; reject runner-controlled request-id collisions.

C2 — nothing limits how many host.models.stream requests one runtime can run concurrently: unbounded host credential spend, and unbounded buffering of stream events into the runner's stdin if it reads slowly. A small per-runtime cap (4–8 concurrent, 429-style error beyond) bounds both.

C3 — request.id comes from the runner. A duplicate id overwrites the previous AbortController in active, and whichever stream finishes first deletes the survivor's entry — leaving an un-abortable stream that dispose() can no longer reach. Reject duplicate ids up front (if (active.has(request.id)) throw).

The rest of this handler is right, for the record: allowlist + post-spread override of apiKey/headers/env/signal means smuggled options can't win, and the JSON transport already guarantees functions can't cross. Worth adding: type-check the allowlisted values (e.g. transport accepts any shape today) so malformed options fail fast instead of inside pi-ai.

Comment thread server/runner.ts Outdated
const brokerStreams = new Map<string, AssistantMessageEventStream>();
let brokerReady: Promise<void> | undefined;

function requestHost<T>(method: string, params?: unknown, requestId = randomUUID()): Promise<T> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C5: host requests never time out, and a transient failure bricks the broker until restart.

(a) pendingHostRequests entries have no timeout and are not rejected when stdin closes/errors. A host.models.stream dropped during a host restart leaves the promise and its captured brokerStreams entry pending forever — the session hangs mid-turn instead of failing cleanly. Reject all pending entries on stdin close and add a generous per-request timeout.

(b) ensureModelBroker caches brokerReady permanently — if the initial host.models.list fails once (host busy during reconnect), the rejected promise is cached and every subsequent request fails with the same stale error until the runner is restarted. Clear brokerReady on rejection so the next request retries.

These two are precisely the difference between "transport blip = cleanly aborted turn + auto-recover" and "transport blip = hung session" — they're what makes the disconnect story in the review body actually true.

Comment thread server/runner.ts
sessionActivityAt.delete(sessionId);
}

function rememberLiveSession(session: any) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C7: LRU eviction can dispose a session mid-stream. Loading the 51st live session (e.g. sessions.state while someone browses the drawer) evicts the oldest via releaseRunnerSession, which calls session.dispose() without checking isStreaming/isCompacting — killing an in-flight prompt; the host never receives session.prompt.done and the session shows stuck. Skip actively-streaming sessions when choosing an eviction victim (evict the oldest idle one; if all are streaming, exceed the cap temporarily rather than kill work).

return data.bindings.find((binding) => binding.sessionId === sessionId);
}

async set(binding: Omit<SessionRuntimeBinding, "updatedAt"> & { updatedAt?: string }): Promise<SessionRuntimeBinding> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C6: every non-cached /api/sessions refresh rewrites this file once per session. reconcileRunnerSessionInfo calls set() per listed session, and set() unconditionally unshifts + atomically rewrites the whole JSON file even when nothing changed — a drawer refresh of a 200-session runtime is 200 serialized temp-file write+rename cycles, on every open/refresh/websocket-triggered reload, and the listing awaits each one. Early-return when the merged binding is deep-equal to the stored one; consider batching the reconcile pass into a single write.

renderSessionBar();
updateSessionButtonUnread();
params.set("runtimeId", runtimeId);
params.set("limit", "200");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C8 + pagination nit.

C8 — "Remove from list" deletes only the locator, so the next successful listSessions reconcile re-creates the binding and the row returns — for exactly the temporarily-down-runtime case the UI offers Remove for. With B3's reconcile-GC this becomes correct behavior (the data really exists, so a reachable runtime re-asserting it is truth), but the confirm dialog should say so: "this row will return if the runtime reconnects and the session still exists."

Pagination — limit=200 with the cursor never consumed: nextCursor is only propagated when options.runtimeId is set and the client ignores it, so runtimes with >200 sessions silently truncate — and combined with B3's silent-drop, older sessions with valid locators just disappear with no "show more". Either wire the cursor into the drawer or drop the pretense and document the cap.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Pushed 51fb42c addressing review round 4, including B1–B4 and the runtime message-edit regression found during live dogfood.

Blocking findings

  • B1 — closed broker model surface: broker-mode registries now expose/find/authenticate only models whose API is pi-web-model-broker. Built-in ambient AWS profile / Google ADC models cannot appear or be selected. The canary now runs with ambient AWS_PROFILE and GOOGLE_CLOUD_PROJECT values and asserts the exact one-model broker catalog.
  • B2 — explicit broker grant: modelBroker is required on every connect request; absent values are rejected. Guided and advanced UI have an unselected model-access choice with host-spend/runtime-ownership warnings. The choice is persisted, shown as models: host / models: runtime, and can be changed only through an explicit reconnect. Provider construction no longer infers a default. Existing saved configs without an explicit boolean are not hydrated.
  • B3 — authoritative missing-session reconciliation: the drawer requests a complete cursor walk (all=1). Only after a successful complete listing, runtime locators absent from the authoritative set are removed in one store update, UI metadata is GC'd, and session_removed is broadcast. Failed/truncated listings never GC locators. The UI still renders cache-first.
  • B4 — truthful delete: the binding's persisted sessionFile is threaded through as a fallback after host/provider restart. deleted:false is now an error telling the user to use Remove from list; the locator is retained unless the runner confirms the file path was unlinked or already absent.

Concerns / hardening

  • Network taxonomy is now honest: Docker/Podman none ✓, Apple host-only ✓ with an explicit warning that host services remain reachable, custom unverified, SSH unknown.
  • Guided attestation rejects Docker/Podman/Apple engine socket mounts, pins the adapter-reported container ID into the exec config, records verification time, and re-inspects immediately before every runner spawn/reconnect.
  • Broker streams have a per-runtime concurrency cap, reject duplicate runner request IDs, and strictly type-check allowlisted option values.
  • Runner→host requests now time out, reject on stdin close, abort timed-out streams, and clear failed broker initialization so a later request retries.
  • LRU eviction skips streaming/compacting sessions and may temporarily exceed the cap rather than kill work.
  • Unchanged binding reconciliation now avoids disk writes; authoritative stale removals are batched into one atomic write.
  • Remove-from-list confirmation now says the row returns if authoritative data still exists.
  • The drawer consumes all runner pages instead of silently truncating at 200.
  • Runtime switches no longer create an empty authoritative session file merely by switching workbenches.
  • Deep links without runtimeId no longer overwrite the persisted workbench selection with Local.
  • Broker activation is passed explicitly through process/container/SSH environment handling rather than regex-rewriting npm exec command strings.

Runtime user-message editing

The reason Edit/Rerun showed only Copy was not missing tree navigation. Runtime /api/messages dropped the session-entry IDs that gate those actions in messageList.ts. Runner message responses now carry compaction-aware active-branch entry IDs, and the host preserves them while simplifying messages/tool cards.

Verified live on apple:pi-web-project: the runtime user message now has entry id 218764f9 and renders Edit, Rerun, and Copy actions.

Validation

  • npm run typecheck
  • 132 unit tests
  • full PI_WEB_E2E_SHARDS=2 npm test: all auth/desktop/tablet/mobile shards passed
  • npm run build
  • git diff --check
  • supervised restart with per-spawn Apple isolation reinspection
  • live runtime remains connected with modelTransport=host-broker, networkPolicy=host-only, DNS disabled, direct external-IP access blocked, and no runtime auth.json
  • fresh real openai-codex/gpt-5.5 broker prompt returned exact REVIEW_BROKER_OK; temporary session deleted

The 250 KB loading MP4 replacement remains a non-blocking media/UX nit rather than part of the security or runtime-correctness changes. PR remains draft for the separately tracked capability-contract audit.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Capability audit completed in e9e060b.

The runner now advertises a typed capability contract in health, runtime summaries, and session runtimeRef state. Current support is explicit:

  • supported: message Edit/Rerun/Continue through conversation-tree branching;
  • unavailable: session rename, slash commands, shell commands, full session statistics, git sync, extension UI, and compaction cancellation.

Frontend handling now follows that contract instead of probing/failing or falling back locally:

  • runtime details list the unavailable capability set;
  • session title rename is non-interactive with an explanatory title/ARIA state;
  • slash and shell commands are blocked client-side with an explicit runtime explanation;
  • git sync/rebase controls are disabled with an explanation;
  • compaction Cancel is disabled with an explanation;
  • missing capability metadata remains backward-compatible, while explicit false is authoritative.

Validation after the capability commit:

  • typecheck passed;
  • 132 unit tests passed;
  • complete 2-shard auth/desktop/tablet/mobile suite passed;
  • production build passed;
  • GitHub CI passed;
  • live Apple runtime reconnected after supervised restart with the full capability map, host broker, host-only verification timestamp, and no runtime credentials;
  • browser dogfood confirmed runtime user messages show Edit/Rerun/Copy, rename is disabled with the correct explanation, and shell input is blocked client-side without touching the runtime transcript.

This closes the separately tracked capability-contract audit and the security/reconciliation findings from review round 4.

@ashwin-pc
ashwin-pc marked this pull request as ready for review July 16, 2026 03:48

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9e060b7bb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server.ts Outdated
Comment thread server.ts Outdated
@ashwin-pc

Copy link
Copy Markdown
Owner Author

Added another regression-focused test layer in 3bcd85c after asking whether broad suite coverage alone was enough.

New browser tests now explicitly cover the runtime-only paths that previously relied on unit tests/live dogfood:

  • runtime message payload with entryId renders Edit and Rerun on desktop and mobile;
  • clicking runtime Edit sends the exact entry id to tree navigation and loads editor text;
  • runtime rename is disabled with the capability explanation;
  • unsupported runtime shell and slash commands are blocked client-side and make zero /api/shell / /api/command requests;
  • guided connect cannot proceed until model access is explicitly chosen, proving there is no UI default.

Post-test validation:

  • focused message-action matrix: 10/10 desktop+mobile passed;
  • 132 unit tests passed;
  • complete 2-shard auth/desktop/tablet/mobile suite passed with the new tests included;
  • typecheck passed;
  • production build passed;
  • git diff --check passed;
  • GitHub CI passed.

This supplements the existing broker, ambient-credential, network isolation, deletion fallback, authoritative reconciliation, reconnect, runner protocol, API integration, and live Apple-container tests.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This draft PR introduces a runtime-authoritative “workbench” concept to pi-web, enabling sessions to be bound to a specific runtime (local, container, SSH, command-backed runner) and adding UI + server plumbing to connect/manage runtimes while routing core session operations through the selected runtime boundary.

Changes:

  • Add runtime protocol/client/provider/store plumbing on the server (stdio runner, command/docker providers, network isolation verification, host model broker, persisted runtime configs + per-session runtime bindings).
  • Add runtime-aware UI and routing (workbench runtime selector, Runtimes panel, runtime-scoped folder picker + settings scoping, capability-gated controls).
  • Add comprehensive regression coverage (unit/API tests and Playwright E2E) for runtime/local switching, runtime-scoped session lists, and routing behavior.

Reviewed changes

Copilot reviewed 54 out of 58 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/ui-polish.test.ts Updates UI unit tests for model summary + compact layout expectations.
tests/runtime-runner.test.ts Adds integration tests for stdio runner protocol (fs/git/artifacts/sessions/models).
tests/runtime-provider.test.ts Adds tests for stdio/command/docker runtime providers and reconnection/isolation behavior.
tests/runtime-network-isolation.test.ts Adds tests for guided container isolation verification (Apple/Docker).
tests/runtime-bindings.test.ts Adds tests for persisted per-session runtime bindings and runtime registry behavior.
tests/runtime-api.test.ts Adds end-to-end API test for runtime-aware routing and recovery semantics.
tests/pi-web-session-tool.test.ts Adds tests for bundled pi_web_session orchestration tool normalization + auth.
tests/model-broker.test.ts Adds tests ensuring host model broker strips runner-supplied auth/options and enforces limits.
tests/e2e/session-bar.spec.ts Updates session bar E2E coverage for runtime-scoped pinned tabs.
tests/e2e/pi-web.spec.ts Adds E2E coverage for workbench runtime UI, runtime panel connect flows, and folder scoping.
tests/e2e/message-actions.spec.ts Adds E2E coverage for runtime message entry ids + capability-gated actions.
src/styles/statusBar.css Styles new workbench runtime button + capability-disabled title behavior.
src/styles/settings.css Adds styling for the new Runtimes panel/cards/examples UI.
src/styles/sessions.css Adjusts session drawer footer layout to include runtime selector affordance.
src/styles/responsive.css Responsive tweaks for compact model summary layout.
src/styles/folderPicker.css Restyles empty-cwd chooser and adds runtime-related folder picker UI styling.
src/styles/composer.css Updates compact composer model control styling and adds “current model details” block styles.
src/status/statusBar.ts Disables rename UI affordance when runtime reports rename unsupported.
src/settings/settings.ts Scopes “new session defaults” UI/controls based on selected workbench runtime.
src/runtimes/runtimePanel.ts Implements Runtimes panel UI: list, connect (guided/advanced), examples, disconnect.
src/runtimes/api.ts Adds runtime API client + option normalization helpers.
src/realtime/realtime.ts Adds runtime capability gating (compaction cancel) + runtime connection change events.
src/models/modelSettings.ts Adds compact model summary parsing + current-model detail block rendering.
src/messages/messageList.ts Surfaces runtime-unavailable transcript conditions as an in-chat system error.
src/main.ts Wires runtime panel + runtime-aware meta updates, URL runtimeId handling, git sync gating.
src/git/statusView.ts Disables rebase/sync affordance when runtime lacks gitSync capability.
src/git/panel.ts Blocks git sync actions when runtime lacks gitSync support; passes support flag to views.
src/composer/composer.ts Gates shell/slash commands based on runtime capabilities; preserves runtimeId in URL updates.
src/app/types.ts Adds runtime types, per-tab persisted active runtime, and runtimeId URL param support.
src/app/icons.ts Adds “server” icon for runtime UI.
src/app/elements.ts Adds runtime/workbench/runtime-panel element bindings and settings header elements.
src/app/api.ts Includes runtime routing header and runtimeId on websocket URL when non-local.
server/shared/git.ts Adds shared git helpers for status/diff and safe path handling.
server/shared/fsList.ts Adds shared filesystem directory listing + mkdir helpers with validation.
server/shared/artifacts.ts Adds shared artifact path helpers + base64 read with size cap.
server/sessionUiState.ts Extends pinned sessions to store optional runtimeId.
server/runtime/stdioProvider.ts Adds stdio runner provider wrapper for local runner process.
server/runtime/stdioClient.ts Adds stdio runtime JSONL protocol client with request/event handling.
server/runtime/runtimeStore.ts Adds persisted runtime config store with atomic writes and validation.
server/runtime/registry.ts Adds runtime registry and defines the local runtime ref/capabilities.
server/runtime/protocol.ts Defines runtime protocol types and capability maps (local vs runner).
server/runtime/networkIsolation.ts Adds guided container isolation verification (docker/Apple) and command parsing.
server/runtime/modelBroker.ts Adds host model broker request handler enforcing safe options + concurrency bounds.
server/runtime/dockerProvider.ts Adds Docker workspace provider building isolated docker run args + volume persistence.
server/runtime/commandProvider.ts Adds command-backed runner provider with reconnect/resubscribe logic + capability model.
server/runtime/bindings.ts Adds per-session runtime binding store with immutability and cleanup helpers.
README.md Documents bundled session tool + runtime workbench model and env vars.
package.json Bumps pi dependencies and adds typebox dependency.
package-lock.json Updates lockfile for bumped pi packages and typebox.
index.html Adds workbench runtime button and Runtimes panel markup + empty-cwd UI markup updates.
docs/runtime-binding-design.md Updates design doc to runtime-authoritative workbench/session routing model.
docs/docker-runtime.md Adds documentation for Docker workspace runtime and security model.
.pi/extensions/pi-web-session.ts Adds bundled pi_web_session tool implementation and parameter schema.
.gitignore Ignores runtime binding/runtime config temp test artifacts under .pi/.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/styles/folderPicker.css
Comment thread server/runtime/networkIsolation.ts Outdated
@ashwin-pc
ashwin-pc marked this pull request as draft July 16, 2026 11:50
@ashwin-pc

Copy link
Copy Markdown
Owner Author

Runtime/local parity audit after the latest Codex + Copilot reviews:

Accidental divergences found by review (fixed in ce84a94)

  1. Prompt queue mode — valid finding. followUp/steer reached SessionHost.prompt but the runner host discarded it. It now flows through provider RPC and is applied as AgentSession.prompt({ streamingBehavior }) while streaming.
  2. Artifact links — valid finding. Runtime transcript URLs lacked session/runtime routing, and preview code discarded query strings. Runtime markdown/html/video/tool-image URLs now retain explicit routing end to end.
  3. Invalid CSS min() subtraction — fixed with calc().
  4. Network verification return type omitted networkVerifiedAt — fixed.

Supported with local-equivalent runtime routing

Session create/open/list/delete/remove; durable runtime ownership; prompt + streaming/tool/thinking events; image transport; model list/set/thinking; host-broker inference; message refresh; Edit/Rerun/Continue and conversation tree; response abort; branch-summary abort; cwd browsing/mkdir; artifact reads/previews; reconnect/recovery; low-level git status/diff.

Explicit remaining runtime gaps

  • manual session rename;
  • slash commands (including extension/prompt/skill command discovery/execution);
  • !/!! shell escapes;
  • full session statistics/context meter;
  • full Git panel: repository discovery, history, commit details, image diff previews, and sync (only low-level status/diff RPC exists today);
  • extension web UI: header/footer actions, git tabs, and interactive extension dialogs;
  • automatic-compaction cancellation (automatic compaction itself still runs);
  • local prompt-image persistence semantics: runtime images reach the model/session, but unlike Local they are not additionally written under .pi/web/uploads with a filesystem note for tools.

The capability contract now reports the full Git panel separately and disables it rather than letting it open into a 501. The other unsupported controls are likewise disabled/explained; there is no local fallback.

Validation for ce84a94: 137 unit tests, full auth/desktop/tablet/mobile E2E, typecheck, production build, and diff check all pass. Focused tests cover queue-mode forwarding/application and routed runtime artifact previews on desktop/mobile.

I moved the PR back to draft because whether these explicit gaps are acceptable product scope—or should be implemented for parity before merge—is a product decision, not something the capability contract should conceal.

@ashwin-pc

Copy link
Copy Markdown
Owner Author

Follow-up validation after the parity fixes:

  • The first CI run on ce84a94 hit an unrelated mobile visual-test race: the helper observed the drawer container as visible while its close button had already become hidden. e994106 makes setup close the drawer through the stable session toggle and waits for the drawer to be hidden.
  • Focused mobile hero/diff visual tests passed.
  • The full GitHub PR check on e994106 passed: typecheck, 137 unit tests, and the complete E2E matrix.
  • After a supervised live restart, apple:pi-web-project reconnected with the expected capability map, modelTransport=host-broker, networkPolicy=host-only, and a fresh networkVerifiedAt attestation.
  • The Codex artifact/queue-mode threads and both Copilot findings are resolved.

The PR remains draft only for the documented product-scope decision on the explicit runtime parity gaps; there are no known accidental fallback/routing regressions left from this review round.

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.

2 participants