Skip to content

Add runtime-bound sessions and runtime UI#43

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

Add runtime-bound sessions and runtime UI#43
ashwin-pc wants to merge 7 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.


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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant