fix(runner): preserve phase 4b request terminal and goal semantics

Five protocol corrections from the CTO review of the Phase 4b work.

Canonical runtime request outcome. The real driver emitted `resolution`
while the scripted driver and browser read `action`, so a real resolved
card reported an unknown outcome. `harnessRuntimeRequestOutcome` is now
the one payload every driver emits for a terminal `runtime_request.*`
fact, carrying `requestId`, `requestKind`, `turnId`, and `itemId` in the
payload as well as the event binding.

Decoded request identities. Path segments are split before they are
decoded, so a percent-encoded reserved character stays inside the segment
the browser meant it for; malformed encoding fails with 400.

Resolution shapes validated by request kind.
`parseHarnessRuntimeRequestResolution` requires `answers` for a
`user_input` submit and `content` for an elicitation submit, and rejects
a mismatched body instead of accepting it as an empty response. Both
drivers and the browser transport run it, and the request card now sends
elicitation answers as `content`.

One terminal fact per reconnect. The demo server snapshots after the
close rather than before it, so requests the close already cancelled are
not replayed into the driver's stale-request recovery path and reported
terminal a second time. Reading afterwards also carries the closing
events' source sequence into the resumed session.

Goal support retired only on a provider denial. A transport or protocol
failure during `thread/goal/get` said nothing about what the build
supports but still set `goals: false`. `CodexRpcError` now carries the
JSON-RPC code, and the capability is retired only when the provider
answers that it will not serve the method.

Three defects surfaced while verifying the above are fixed here too: a
same-origin `EventSource` GET sends no `Origin`, so requiring one on
`/stream` rejected the console's own subscription and no live-console
test could connect; the browser payload redactor covered only
`.paperclip` paths and leaked the app-server's `~/.codex/memories`; and
the browser suite shares one server across 19 sessions against an
interactive cap of 16, now sized through `PAPERCLIP_PHASE4B_MAX_SESSIONS`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5 committed 2026-08-08 06:04:03 +00:00
1 parent 405a4a4506
commit cda1fb03ab
37 files changed
+5525 -3859

No files matched your search

@@ -145,7 +145,7 @@ test.describe("Phase 4b live console", () => {
await expect(second.getByTestId("request-action-accept_for_session")).toHaveCount(0);
});
test("submits a user-input answer and lets an unanswered request expire", async ({ page }) => {
test("submits user-input and elicitation answers and lets an unanswered request expire", async ({ page }) => {
test.setTimeout(60_000);
await openConsole(page);
await runManifest(page, "user-input");
@@ -158,12 +158,22 @@ test.describe("Phase 4b live console", () => {
{ timeout: 15_000 },
);
await expect(page.getByTestId("request-card").nth(1)).toHaveAttribute(
// An elicitation submits `content`, not `answers`. The runner validates the
// body against the request kind, so a card that sent the user-input shape
// would be rejected and stay pending here.
const elicitation = page.getByTestId("request-card").nth(1);
await expect(elicitation).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
await elicitation.getByTestId("request-answer").fill("stable");
await elicitation.getByTestId("request-action-submit").click();
await expect(elicitation).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
await expect(elicitation).toContainText("resolved — Submit");
await expect(page.getByTestId("request-card").nth(2)).toHaveAttribute(
"data-status",
"expired",
{ timeout: 25_000 },
);
await expect(page.getByTestId("request-card").nth(1)).toContainText("expired before response");
await expect(page.getByTestId("request-card").nth(2)).toContainText("expired before response");
await shot(page, "desktop-10-request-expired");
});
@@ -17,6 +17,10 @@ export default defineConfig({
// Keep streamed chunks slow enough that interrupt races are reachable
// from a test, and fast enough that the suite stays quick.
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "60",
// One server serves the whole suite, and every test leaves its session
// behind when its page closes, so the interactive default of 16 is
// exhausted partway through.
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
},
url: "http://127.0.0.1:4179",
reuseExistingServer: false,
@@ -2,7 +2,7 @@ import * as React from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import type { TranscriptRequestEntry } from "./transcript-model";
import { runtimeRequestSubmission, type TranscriptRequestEntry } from "./transcript-model";
const KIND_LABELS: Record<string, string> = {
command_approval: "Command",
@@ -149,7 +149,7 @@ export function RequestCard({
onClick={() =>
submit(
action === "submit"
? { action: "submit", answers: { answer: { answers: [answer] } } }
? runtimeRequestSubmission(entry.requestKind, answer)
: { action },
)
}
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { Phase4bScriptedDriver } from "../../../../src/mock-core/phase4b-scripted-driver";
import type { HarnessSession } from "../../../../src/contracts/harness-driver";
import {
parseHarnessRuntimeRequestResolution,
type HarnessSession,
} from "../../../../src/contracts/harness-driver";
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
import {
applyPrpEvent,
@@ -14,6 +17,7 @@ import {
capabilityRows,
composerState,
goalAvailability,
runtimeRequestSubmission,
transcriptRole,
type TranscriptEntry,
} from "./transcript-model";
@@ -246,6 +250,40 @@ describe("goalAvailability", () => {
});
});
describe("runtimeRequestSubmission", () => {
it("shapes each submit body the way its request kind is validated", async () => {
expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
action: "submit",
answers: { answer: { answers: ["staging"] } },
});
expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
action: "submit",
content: { answer: "stable" },
});
// The driver is the authority on shape, so both bodies are checked against
// the real validator rather than against this test's expectations alone.
for (const [requestKind, answer] of [
["user_input", "staging"],
["elicitation", "stable"],
] as const) {
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(requestKind, answer),
)).not.toThrow();
// ...and the other kind's body is rejected, which is what the browser
// used to send for elicitation.
const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
expect(() =>
parseHarnessRuntimeRequestResolution(
requestKind,
runtimeRequestSubmission(wrongKind, answer),
)).toThrow(/rejected its resolution/);
}
});
});
describe("capabilityRows", () => {
it("gives every unsupported capability a diagnostic string", () => {
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
@@ -156,6 +156,22 @@ function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
return facts;
}
/**
* Builds the submit body a request kind actually accepts. Elicitation answers
* travel as `content` and user-input answers as `answers`; the runner validates
* both against the request kind, so sending the wrong one fails closed rather
* than resolving the request with an empty response.
*/
export function runtimeRequestSubmission(
requestKind: string,
answer: string,
): Record<string, unknown> {
if (requestKind === "elicitation") {
return { action: "submit", content: { answer } };
}
return { action: "submit", answers: { answer: { answers: [answer] } } };
}
function requestActions(details: unknown): string[] {
const record = payloadRecord(details);
return Array.isArray(record.actions)
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

@@ -28,6 +28,13 @@ export function createPhase4bBrowserMiddleware(options = {}) {
options.chunkDelayMs ?? process.env.PAPERCLIP_PHASE4B_CHUNK_DELAY_MS ?? "45",
10,
);
// Abandoned demo sessions hold their slot until the process exits, so a long
// scripted run needs headroom the interactive default does not. The server
// still clamps this to its own hard ceiling.
const maxActiveSessions = Number.parseInt(
options.maxActiveSessions ?? process.env.PAPERCLIP_PHASE4B_MAX_SESSIONS ?? "",
10,
);
let bootstrap = null;
let bindHost = options.bindHost ?? "127.0.0.1";
@@ -43,6 +50,7 @@ export function createPhase4bBrowserMiddleware(options = {}) {
host: bindHost,
workingDirectory,
manifests: runner.phase4bDemoManifestCatalogue(),
...(Number.isInteger(maxActiveSessions) ? { maxActiveSessions } : {}),
driverFactory: (taskEnvelope, manifestId) =>
driverMode === "codex"
? new runner.CodexAppServerDriver({
@@ -84,6 +84,164 @@ export type HarnessRuntimeRequestResolution =
content: Record<string, unknown>;
};
export type HarnessRuntimeRequestAction = HarnessRuntimeRequestResolution["action"];
const RUNTIME_REQUEST_ACTIONS: readonly HarnessRuntimeRequestAction[] = [
"accept",
"accept_for_session",
"decline",
"cancel",
"submit",
];
export class HarnessRuntimeRequestResolutionError extends Error {
readonly code = "invalid_resolution" as const;
readonly requestKind: HarnessRuntimeRequestKind;
constructor(requestKind: HarnessRuntimeRequestKind, detail: string) {
super(`${requestKind} rejected its resolution: ${detail}`);
this.name = "HarnessRuntimeRequestResolutionError";
this.requestKind = requestKind;
}
}
function plainRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function parseAnswers(value: unknown): Record<string, { answers: string[] }> | null {
const fields = plainRecord(value);
if (fields === null || Object.keys(fields).length === 0) return null;
const parsed: Record<string, { answers: string[] }> = {};
for (const [field, entry] of Object.entries(fields)) {
const answer = plainRecord(entry);
if (answer === null || !Array.isArray(answer.answers)) return null;
if (answer.answers.some((value) => typeof value !== "string")) return null;
parsed[field] = { answers: [...(answer.answers as string[])] };
}
return parsed;
}
/**
* Validates a resolution against the kind of request it answers, so a
* mismatched submit fails closed instead of degrading into an accepted empty
* response. Every driver runs this before it touches its provider, and the
* browser transport runs it again at its own untrusted edge.
*/
export function parseHarnessRuntimeRequestResolution(
requestKind: HarnessRuntimeRequestKind,
value: unknown,
): HarnessRuntimeRequestResolution {
const candidate = plainRecord(value) ?? {};
const rawAction = candidate.action;
if (
typeof rawAction !== "string" ||
!RUNTIME_REQUEST_ACTIONS.includes(rawAction as HarnessRuntimeRequestAction)
) {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
`unsupported action ${JSON.stringify(rawAction) ?? "undefined"}`,
);
}
const action = rawAction as HarnessRuntimeRequestAction;
if (action !== "submit" && ("answers" in candidate || "content" in candidate)) {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
`${action} does not carry submitted form data`,
);
}
if (requestKind === "user_input") {
if (action === "accept" || action === "accept_for_session") {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"user input requires submit, decline, or cancel",
);
}
if (action !== "submit") return { action };
if ("content" in candidate) {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"user input submissions carry answers, not content",
);
}
const answers = parseAnswers(candidate.answers);
if (answers === null) {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"submit requires answers shaped as { field: { answers: [string] } }",
);
}
return { action, answers };
}
if (requestKind === "elicitation") {
if (action === "accept_for_session") {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"elicitation does not support session acceptance",
);
}
if (action !== "submit") return { action };
if ("answers" in candidate) {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"elicitation submissions carry content, not answers",
);
}
const content = plainRecord(candidate.content);
if (content === null || Object.keys(content).length === 0) {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"submit requires a non-empty content object",
);
}
return { action, content: structuredClone(content) };
}
if (action === "submit") {
throw new HarnessRuntimeRequestResolutionError(
requestKind,
"approval requests do not accept submitted form data",
);
}
return { action };
}
/**
* The single payload shape every driver emits for a terminal
* `runtime_request.*` fact. Request identity travels in the payload as well as
* the event binding, so a consumer that only reads payloads still settles the
* request it belongs to.
*/
export type HarnessRuntimeRequestOutcome = {
requestId: string;
requestKind: HarnessRuntimeRequestKind;
turnId: string;
itemId: string;
action?: HarnessRuntimeRequestAction;
reason?: string;
};
export function harnessRuntimeRequestOutcome(
request: Pick<
HarnessRuntimeRequest,
"requestId" | "requestKind" | "turnId" | "itemId"
>,
outcome: { action?: HarnessRuntimeRequestAction | null; reason?: string | null } = {},
): HarnessRuntimeRequestOutcome {
return {
requestId: request.requestId,
requestKind: request.requestKind,
turnId: request.turnId,
itemId: request.itemId,
...(outcome.action ? { action: outcome.action } : {}),
...(outcome.reason ? { reason: outcome.reason } : {}),
};
}
export interface HarnessThreadGoal {
threadId: string;
objective: string;
@@ -195,6 +195,56 @@ function proxyContainsCredentials(value: string): boolean {
}
}
export const CODEX_METHOD_NOT_FOUND = -32_601;
export const CODEX_INVALID_REQUEST = -32_600;
/**
* Codes where the provider refused the call itself rather than failing while
* serving it: the method is absent (-32601), or the request was rejected
* outright, which is how the Codex app-server reports a feature it has
* switched off (-32600 `goals feature is disabled`). Server-error codes
* (-32603 and the -32000..-32099 implementation range) and transport failures
* describe one attempt, not what the build supports.
*/
const METHOD_UNAVAILABLE_CODES: readonly number[] = [
CODEX_METHOD_NOT_FOUND,
CODEX_INVALID_REQUEST,
];
/**
* A provider error response, carrying its JSON-RPC code. Callers need the code
* to tell "this provider build will not serve the method" apart from "the call
* failed this time", which are different facts about a capability.
*/
export class CodexRpcError extends Error {
readonly code: number | null;
constructor(message: string, code: number | null) {
super(message);
this.name = "CodexRpcError";
this.code = code;
}
get methodUnavailable(): boolean {
return this.code !== null && METHOD_UNAVAILABLE_CODES.includes(this.code);
}
}
/**
* True only when the provider answered and its answer denies the method. A
* plain `Error` here means the call never got an answer — a transport or
* protocol failure — which must not be read as a missing capability.
*/
export function isCodexMethodUnavailable(error: unknown): boolean {
return error instanceof CodexRpcError && error.methodUnavailable;
}
function rpcErrorCode(value: unknown): number | null {
if (typeof value !== "object" || value === null) return null;
const code = (value as { code?: unknown }).code;
return typeof code === "number" && Number.isFinite(code) ? code : null;
}
interface PendingRequest {
resolve: (value: Record<string, unknown>) => void;
reject: (error: Error) => void;
@@ -363,7 +413,10 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
}
this.#pending.delete(message.id);
if (hasError) {
pending.reject(new Error(redactCodexDiagnostic(boundedJson(message.error))));
pending.reject(new CodexRpcError(
redactCodexDiagnostic(boundedJson(message.error)),
rpcErrorCode(message.error),
));
} else if (isRecord(message.result)) {
pending.resolve(message.result);
} else {
@@ -34,11 +34,14 @@ import {
replayPersistedPhase4Events,
validatePhase4ResultProposal,
} from "../../mock-core/phase4-codex-runner.js";
import type {
CodexAppServerTransport,
CodexRpcNotification,
CodexRpcServerRequest,
CodexServerRequestHandler,
import {
CODEX_INVALID_REQUEST,
CODEX_METHOD_NOT_FOUND,
CodexRpcError,
type CodexAppServerTransport,
type CodexRpcNotification,
type CodexRpcServerRequest,
type CodexServerRequestHandler,
} from "./app-server-transport.js";
class TestQueue<T> implements AsyncIterable<T> {
@@ -477,7 +480,7 @@ describe("Codex app-server Phase 4 driver", () => {
requestId: scenario.id,
turnId,
resolution: { action: "forged" } as unknown as HarnessRuntimeRequestResolution,
})).rejects.toThrow("unsupported runtime request resolution");
})).rejects.toThrow("unsupported action");
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
await session.resolveRuntimeRequest?.({
requestId: scenario.id,
@@ -595,19 +598,217 @@ describe("Codex app-server Phase 4 driver", () => {
params: Object.fromEntries(Object.entries(params).filter(([key]) => key !== "threadId")),
}))).toEqual(fixture.goals.map(({ method, params }) => ({ method, params })));
const unsupportedTransport = new FakeCodexTransport();
unsupportedTransport.rejectMethods.set("thread/goal/get", new Error("method not found"));
const unsupportedDriver = makeDriver([unsupportedTransport]);
const unsupported = await unsupportedDriver.openSession({
runId: "run-goals-unsupported",
normalizedSessionId: "normalized-goals-unsupported",
expect((await makeDriver([new FakeCodexTransport()]).descriptor()).capabilities)
.toMatchObject({ goals: true });
// Both denials a real app-server sends: the method is absent, and the
// build has the feature switched off.
for (const denial of [
new CodexRpcError('{"code":-32601,"message":"method not found"}', CODEX_METHOD_NOT_FOUND),
new CodexRpcError(
'{"code":-32600,"message":"goals feature is disabled"}',
CODEX_INVALID_REQUEST,
),
]) {
const unsupportedTransport = new FakeCodexTransport();
unsupportedTransport.rejectMethods.set("thread/goal/get", denial);
const unsupportedDriver = makeDriver([unsupportedTransport]);
const unsupported = await unsupportedDriver.openSession({
runId: "run-goals-unsupported",
normalizedSessionId: "normalized-goals-unsupported",
workingDirectory: "/workspace",
});
expect((await unsupportedDriver.descriptor()).capabilities).toMatchObject({ goals: false });
await expect(unsupported.goal?.({ action: "get" }))
.rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
await unsupported.close({ reason: "fixture complete" });
}
await session.close({ reason: "fixture complete" });
});
it("keeps goal support advertised when the probe fails transiently", async () => {
// A transport or protocol failure is evidence about this call, not about
// what the provider implements, so it must not retire the capability.
for (const failure of [
new Error("codex app-server transport closed"),
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
]) {
const transport = new FakeCodexTransport();
transport.rejectMethods.set("thread/goal/get", failure);
const driver = makeDriver([transport]);
const session = await driver.openSession({
runId: "run-goals-transient",
normalizedSessionId: "normalized-goals-transient",
workingDirectory: "/workspace",
});
expect((await driver.descriptor()).capabilities).toMatchObject({ goals: true });
// The capability survives, so the operation is still offered and the
// next call reaches the provider instead of failing closed locally.
transport.rejectMethods.delete("thread/goal/get");
await expect(session.goal?.({ action: "get" })).resolves.toBeNull();
expect(transport.calls.filter(({ method }) => method === "thread/goal/get")).toHaveLength(2);
await session.close({ reason: "fixture complete" });
}
});
it("validates runtime request resolutions against the kind of request they answer", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-resolution-shapes",
normalizedSessionId: "normalized-resolution-shapes",
workingDirectory: "/workspace",
});
expect((await unsupportedDriver.descriptor()).capabilities).toMatchObject({ goals: false });
await expect(unsupported.goal?.({ action: "get" }))
.rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
const { turnId } = await session.startTurn({ message: { role: "user", text: "Ask me things." } });
// Not `async`: an async function would flatten and await the pending
// provider response, which only settles once the request is resolved.
function open(id: string, method: string): Promise<Record<string, unknown>> {
return transport.invoke({
id,
method,
params: { threadId: "thread-1", turnId, itemId: `item-${id}`, reason: `fixture ${id}` },
});
}
const settled = () => new Promise((resolve) => setTimeout(resolve, 0));
const userInput = open("ask-input", "item/tool/requestUserInput");
await settled();
for (const resolution of [
{ action: "submit" },
{ action: "submit", answers: {} },
{ action: "submit", answers: { field: {} } },
{ action: "submit", answers: { field: { answers: [7] } } },
{ action: "submit", content: { answer: "wrong shape" } },
{ action: "accept" },
]) {
await expect(session.resolveRuntimeRequest?.({
requestId: "ask-input",
turnId,
resolution: resolution as unknown as HarnessRuntimeRequestResolution,
})).rejects.toThrow(/user_input rejected its resolution/);
// A rejected resolution must leave the request answerable.
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
}
await session.resolveRuntimeRequest?.({
requestId: "ask-input",
turnId,
resolution: { action: "submit", answers: { field: { answers: ["staging"] } } },
});
expect(await userInput).toEqual({ answers: { field: { answers: ["staging"] } } });
const elicitation = open("ask-elicitation", "mcpServer/elicitation/request");
await settled();
for (const resolution of [
{ action: "submit" },
{ action: "submit", content: {} },
{ action: "submit", answers: { field: { answers: ["wrong shape"] } } },
{ action: "accept_for_session" },
]) {
await expect(session.resolveRuntimeRequest?.({
requestId: "ask-elicitation",
turnId,
resolution: resolution as unknown as HarnessRuntimeRequestResolution,
})).rejects.toThrow(/elicitation rejected its resolution/);
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
}
await session.resolveRuntimeRequest?.({
requestId: "ask-elicitation",
turnId,
resolution: { action: "submit", content: { answer: "green" } },
});
expect(await elicitation).toEqual({
action: "accept",
content: { answer: "green" },
_meta: null,
});
const approval = open("ask-approval", "item/commandExecution/requestApproval");
await settled();
await expect(session.resolveRuntimeRequest?.({
requestId: "ask-approval",
turnId,
resolution: {
action: "submit",
answers: { field: { answers: ["nope"] } },
} as unknown as HarnessRuntimeRequestResolution,
})).rejects.toThrow(/command_approval rejected its resolution/);
await session.resolveRuntimeRequest?.({
requestId: "ask-approval",
turnId,
resolution: { action: "accept" },
});
expect(await approval).toEqual({ decision: "accept" });
await session.close({ reason: "fixture complete" });
await unsupported.close({ reason: "fixture complete" });
});
it("emits one canonical outcome payload for every terminal runtime request fact", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-outcome",
normalizedSessionId: "normalized-outcome",
workingDirectory: "/workspace",
});
const events: PrpEvent[] = [];
void (async () => {
for await (const event of session.events()) events.push(event);
})();
const { turnId } = await session.startTurn({ message: { role: "user", text: "Approve this." } });
const resolved = transport.invoke({
id: "outcome-resolved",
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId, itemId: "item-resolved", reason: "approve" },
});
const cancelled = transport.invoke({
id: "outcome-cancelled",
method: "item/commandExecution/requestApproval",
params: { threadId: "thread-1", turnId, itemId: "item-cancelled", reason: "approve" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await session.resolveRuntimeRequest?.({
requestId: "outcome-resolved",
turnId,
resolution: { action: "accept_for_session" },
});
await resolved;
await session.close({ reason: "operator_closed" });
await cancelled;
await new Promise((resolve) => setTimeout(resolve, 0));
const terminal = events.filter(({ eventType }) =>
eventType === "runtime_request.resolved" || eventType === "runtime_request.cancelled");
expect(terminal.map(({ eventType, turnId, itemId, payload }) => ({
eventType,
turnId,
itemId,
payload,
}))).toEqual([
{
eventType: "runtime_request.resolved",
turnId,
itemId: "item-resolved",
payload: {
requestId: "outcome-resolved",
requestKind: "command_approval",
turnId,
itemId: "item-resolved",
action: "accept_for_session",
},
},
{
eventType: "runtime_request.cancelled",
turnId,
itemId: "item-cancelled",
payload: {
requestId: "outcome-cancelled",
requestKind: "command_approval",
turnId,
itemId: "item-cancelled",
reason: "session_closed",
},
},
]);
for (const event of terminal) expect(validatePrpEvent(event).ok).toBe(true);
});
it("redacts browser-visible request details and diagnostics from the fixture markers", async () => {
@@ -21,6 +21,8 @@ import {
HarnessOperationAlreadyTerminalError,
HarnessReconciliationError,
HarnessStaleTurnError,
harnessRuntimeRequestOutcome,
parseHarnessRuntimeRequestResolution,
} from "../../contracts/harness-driver.js";
import {
PHASE4_BLOCK_RESULT_OUTPUT_SCHEMA,
@@ -42,6 +44,7 @@ import type { NativeUserMessage } from "../../contracts/types.js";
import {
ProcessCodexAppServerTransport,
createSanitizedCodexEnvironment,
isCodexMethodUnavailable,
redactCodexDiagnostic,
type CodexAppServerTransport,
type CodexRpcNotification,
@@ -464,9 +467,20 @@ export class CodexAppServerDriver implements HarnessDriver {
const response = await transport.request("thread/goal/get", { threadId });
return parseThreadGoal(response.goal);
} catch (error) {
this.#caps.goals = false;
if (isCodexMethodUnavailable(error)) {
// The provider answered, and its answer is that this build has no goal
// API. That is the only evidence that retires the capability.
this.#caps.goals = false;
this.#options.onDiagnostic?.(
redactCodexDiagnostic(`thread goals unavailable: ${String(error)}`),
);
return undefined;
}
// A transport or protocol failure says nothing about what the provider
// supports, so the capability stays as advertised and the goal is merely
// unknown until the next call.
this.#options.onDiagnostic?.(
redactCodexDiagnostic(`thread goals unavailable: ${String(error)}`),
redactCodexDiagnostic(`thread goal probe failed: ${String(error)}`),
);
return undefined;
}
@@ -661,10 +675,11 @@ class CodexHarnessSession implements HarnessSession {
context: input.opened.context,
});
for (const stale of input.stalePendingRuntimeRequests ?? []) {
this.#emit("runtime_request.cancelled", {
requestId: stale.requestId,
reason: "transport_recovered",
}, { turnId: stale.turnId, itemId: stale.itemId });
this.#emit(
"runtime_request.cancelled",
harnessRuntimeRequestOutcome(stale, { reason: "transport_recovered" }),
{ turnId: stale.turnId, itemId: stale.itemId },
);
}
this.#emit("item.completed", {
kind: "thread_lineage",
@@ -836,12 +851,17 @@ class CodexHarnessSession implements HarnessSession {
if (pending.request.turnId !== input.turnId || this.#activeTurnId !== input.turnId) {
throw new HarnessStaleTurnError(input.turnId);
}
const response = runtimeRequestResponse(pending.request, input.resolution);
const resolution = parseHarnessRuntimeRequestResolution(
pending.request.requestKind,
input.resolution,
);
const response = runtimeRequestResponse(pending.request, resolution);
this.#pendingRuntimeRequests.delete(input.requestId);
this.#emit("runtime_request.resolved", {
requestId: input.requestId,
resolution: input.resolution.action,
}, { turnId: input.turnId, itemId: pending.request.itemId });
this.#emit(
"runtime_request.resolved",
harnessRuntimeRequestOutcome(pending.request, { action: resolution.action }),
{ turnId: input.turnId, itemId: pending.request.itemId },
);
pending.settle(response);
}
@@ -1116,10 +1136,11 @@ class CodexHarnessSession implements HarnessSession {
const pending = this.#pendingRuntimeRequests.get(requestId);
if (pending === undefined || threadId !== this.#opened.threadId) return;
this.#pendingRuntimeRequests.delete(requestId);
this.#emit("runtime_request.cancelled", {
requestId,
reason: "provider_resolved",
}, { turnId: pending.request.turnId, itemId: pending.request.itemId });
this.#emit(
"runtime_request.cancelled",
harnessRuntimeRequestOutcome(pending.request, { reason: "provider_resolved" }),
{ turnId: pending.request.turnId, itemId: pending.request.itemId },
);
pending.settle(safeRequestResponse(pending.request.method, "cancel"));
return;
}
@@ -1484,11 +1505,12 @@ class CodexHarnessSession implements HarnessSession {
}
#cancelPendingRequests(reason: string): void {
for (const [requestId, pending] of this.#pendingRuntimeRequests) {
this.#emit("runtime_request.cancelled", { requestId, reason }, {
turnId: pending.request.turnId,
itemId: pending.request.itemId,
});
for (const pending of this.#pendingRuntimeRequests.values()) {
this.#emit(
"runtime_request.cancelled",
harnessRuntimeRequestOutcome(pending.request, { reason }),
{ turnId: pending.request.turnId, itemId: pending.request.itemId },
);
pending.settle(safeRequestResponse(pending.request.method, "cancel"));
}
this.#pendingRuntimeRequests.clear();
@@ -1724,14 +1746,15 @@ function runtimeRequestPrompt(
return labels[kind];
}
/**
* Maps an already-validated resolution onto the provider's response shape.
* `parseHarnessRuntimeRequestResolution` is the only gate on shape, so every
* branch here answers a resolution the request kind actually accepts.
*/
function runtimeRequestResponse(
request: HarnessRuntimeRequest,
resolution: HarnessRuntimeRequestResolution,
): Record<string, unknown> {
const action = (resolution as { action?: unknown }).action;
if (!["accept", "accept_for_session", "decline", "cancel", "submit"].includes(String(action))) {
throw new Error(`unsupported runtime request resolution ${String(action)}`);
}
if (request.requestKind === "command_approval" || request.requestKind === "file_approval") {
if (resolution.action === "submit") {
throw new Error(`${request.requestKind} does not accept submitted form data`);
@@ -1754,25 +1777,20 @@ function runtimeRequestResponse(
};
}
if (request.requestKind === "user_input") {
if (resolution.action === "submit" && "answers" in resolution) {
return { answers: structuredClone(resolution.answers) };
if (resolution.action !== "submit" || !("answers" in resolution)) {
// Declines and cancels are the only non-submit answers the validator
// lets through, and neither carries form data.
return { answers: {} };
}
if (resolution.action === "accept" || resolution.action === "accept_for_session") {
throw new Error("user input requires submit, decline, or cancel");
}
return { answers: {} };
return { answers: structuredClone(resolution.answers) };
}
if (resolution.action === "submit" && "content" in resolution) {
return { action: "accept", content: structuredClone(resolution.content), _meta: null };
}
if (resolution.action === "accept_for_session") {
throw new Error("elicitation does not support session acceptance");
if (resolution.action === "submit" || resolution.action === "accept_for_session") {
throw new Error("elicitation submissions require content");
}
return {
action: resolution.action === "submit" ? "accept" : resolution.action,
content: null,
_meta: null,
};
return { action: resolution.action, content: null, _meta: null };
}
function parseThreadGoal(value: unknown): HarnessThreadGoal | null {
@@ -272,10 +272,12 @@ export const PHASE4B_DEMO_MANIFESTS: readonly Phase4bDemoManifest[] = [
{
id: "user-input",
name: "User input and expiry",
purpose: "Answer a typed user-input request, then watch an unanswered request expire.",
scenarios: ["requests", "user-input", "expiry"],
objective: "Collect the deployment target, then let a request expire.",
prompt: "Ask which environment to use, then ask a question nobody answers.",
purpose:
"Answer a typed user-input request and an elicitation, then watch an unanswered request expire.",
scenarios: ["requests", "user-input", "elicitation", "expiry"],
objective: "Collect the deployment target and release channel, then let a request expire.",
prompt:
"Ask which environment and channel to use, then ask a question nobody answers.",
capabilities: capabilities(),
startDelayMs: 0,
beats: [
@@ -291,10 +293,20 @@ export const PHASE4B_DEMO_MANIFESTS: readonly Phase4bDemoManifest[] = [
},
{
kind: "request",
requestId: "request-expiring",
requestId: "request-elicitation",
requestKind: "elicitation",
method: "mcpServer/elicitation/request",
itemId: "input-2",
prompt: "Which release channel should the tool use?",
details: { fields: [{ name: "channel", label: "Channel" }] },
actions: ["submit", "decline", "cancel"],
},
{
kind: "request",
requestId: "request-expiring",
requestKind: "elicitation",
method: "mcpServer/elicitation/request",
itemId: "input-3",
prompt: "Optional detail — this request expires if nobody answers.",
details: { fields: [{ name: "detail", label: "Detail" }] },
actions: ["submit", "decline", "cancel"],
@@ -304,6 +316,7 @@ export const PHASE4B_DEMO_MANIFESTS: readonly Phase4bDemoManifest[] = [
],
expectedObservations: [
"The user-input card renders a text field and a submit action.",
"The elicitation card submits its answer as content and resolves.",
"The submitted answer never appears in the resolution acknowledgement.",
"The unanswered request resolves to `expired before response` on its own.",
],
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { request as httpRequest } from "node:http";
import { harnessRuntimeRequestOutcome } from "../contracts/harness-driver.js";
import type {
HarnessDriver,
HarnessDriverDescriptor,
@@ -47,6 +48,8 @@ class StubSession implements HarnessSession {
readonly queue = new Queue<PrpEvent>();
readonly runId: string;
readonly normalizedSessionId: string;
readonly requestId: string;
readonly requestKind: HarnessRuntimeRequest["requestKind"];
sourceSequence: number;
activeTurnId: string | null;
pending: HarnessRuntimeRequest[];
@@ -55,17 +58,25 @@ class StubSession implements HarnessSession {
constructor(input: {
runId: string;
normalizedSessionId: string;
requestId?: string;
requestKind?: HarnessRuntimeRequest["requestKind"];
sourceSequence?: number;
activeTurnId?: string | null;
pending?: HarnessRuntimeRequest[];
/**
* Requests a crash left behind. Like the real driver, recovery reports each
* one terminal rather than adopting it as live.
*/
stalePending?: HarnessRuntimeRequest[];
goal?: PersistedHarnessSession["goal"];
resumed?: boolean;
}) {
this.runId = input.runId;
this.normalizedSessionId = input.normalizedSessionId;
this.requestId = input.requestId ?? "request-demo";
this.requestKind = input.requestKind ?? "command_approval";
this.sourceSequence = input.sourceSequence ?? 0;
this.activeTurnId = input.activeTurnId ?? null;
this.pending = structuredClone(input.pending ?? []);
this.pending = [];
this.currentGoal = input.goal ?? null;
this.emit(input.resumed ? "session.resumed" : "session.started", {
driverSessionId: "thread-demo",
@@ -74,9 +85,19 @@ class StubSession implements HarnessSession {
sandbox: {
writableRoots: [
"/srv/paperclip/home/.paperclip/instances/company/codex-home/memories",
// The real app-server reports this one; it leaked before the
// redactor covered every hidden directory under a home root.
"/srv/paperclip/home/.codex/memories",
],
},
});
for (const stale of input.stalePending ?? []) {
this.emit(
"runtime_request.cancelled",
harnessRuntimeRequestOutcome(stale, { reason: "transport_recovered" }),
{ turnId: stale.turnId, itemId: stale.itemId },
);
}
}
ids() {
@@ -97,9 +118,11 @@ class StubSession implements HarnessSession {
this.emit("turn.accepted", { turnId: "turn-demo" }, { turnId: "turn-demo" });
this.emit("turn.started", { status: "inProgress" }, { turnId: "turn-demo" });
const request: HarnessRuntimeRequest = {
requestId: "request-demo",
requestKind: "command_approval",
method: "item/commandExecution/requestApproval",
requestId: this.requestId,
requestKind: this.requestKind,
method: this.requestKind === "elicitation"
? "mcpServer/elicitation/request"
: "item/commandExecution/requestApproval",
turnId: "turn-demo",
itemId: "command-demo",
status: "pending",
@@ -138,11 +161,14 @@ class StubSession implements HarnessSession {
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void> {
const request = this.pending.find(({ requestId }) => requestId === input.requestId);
if (request === undefined) throw new Error(`unknown runtime request ${input.requestId}`);
this.pending = this.pending.filter(({ requestId }) => requestId !== input.requestId);
this.emit("runtime_request.resolved", {
requestId: input.requestId,
resolution: input.resolution.action,
}, { turnId: input.turnId, itemId: "command-demo" });
this.emit(
"runtime_request.resolved",
harnessRuntimeRequestOutcome(request, { action: input.resolution.action }),
{ turnId: input.turnId, itemId: request.itemId },
);
}
async goal(input: HarnessGoalOperation) {
@@ -189,7 +215,15 @@ class StubSession implements HarnessSession {
};
}
async close(): Promise<void> {
/** Mirrors the real driver: a graceful close settles what is still pending. */
async close(input: { reason: string }): Promise<void> {
for (const request of this.pending.splice(0)) {
this.emit(
"runtime_request.cancelled",
harnessRuntimeRequestOutcome(request, { reason: input.reason }),
{ turnId: request.turnId, itemId: request.itemId },
);
}
this.queue.close();
}
@@ -220,6 +254,11 @@ class StubSession implements HarnessSession {
class StubDriver implements HarnessDriver {
openInputs: OpenHarnessSessionInput[] = [];
constructor(
readonly requestId = "request-demo",
readonly requestKind: HarnessRuntimeRequest["requestKind"] = "command_approval",
) {}
async descriptor(): Promise<HarnessDriverDescriptor> {
return {
kind: "stub",
@@ -240,7 +279,11 @@ class StubDriver implements HarnessDriver {
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
this.openInputs.push(structuredClone(input));
return new StubSession(input);
return new StubSession({
...input,
requestId: this.requestId,
requestKind: this.requestKind,
});
}
async recoverSession(snapshot: PersistedHarnessSession): Promise<HarnessSessionRecoveryResult> {
@@ -249,9 +292,11 @@ class StubDriver implements HarnessDriver {
session: new StubSession({
runId: snapshot.runId!,
normalizedSessionId: snapshot.normalizedSessionId!,
requestId: this.requestId,
requestKind: this.requestKind,
sourceSequence: snapshot.lastSourceSequence,
activeTurnId: snapshot.activeTurnId,
pending: snapshot.pendingRuntimeRequests,
stalePending: snapshot.pendingRuntimeRequests,
goal: snapshot.goal,
resumed: true,
}),
@@ -612,4 +657,157 @@ describe("Phase 4b package-local demo server", () => {
).then((response) => response.json()) as { events: PrpEvent[] };
expect(afterReconnect.events.some(({ eventType }) => eventType === "session.resumed")).toBe(true);
});
it("admits an origin-less same-origin stream but still requires an origin to mutate", async () => {
const server = new Phase4bDemoServer({
workingDirectory: "/safe/server-owned-workspace",
driverFactory: () => new StubDriver(),
});
servers.push(server);
const { url } = await server.start();
const created = await jsonBrowserRequest(url, "/api/phase4b/sessions", { startTurn: false })
.then((response) => response.json()) as { sessionId: string };
// A same-origin EventSource GET sends Fetch Metadata but no Origin header.
const stream = await fetch(
`${url}/api/phase4b/sessions/${created.sessionId}/stream?after=0`,
{ headers: { "sec-fetch-site": "same-origin" } },
);
expect(stream.status).toBe(200);
expect(stream.headers.get("content-type")).toContain("text/event-stream");
await stream.body?.cancel();
const mutation = await fetch(`${url}/api/phase4b/sessions/${created.sessionId}/turns`, {
method: "POST",
headers: { "sec-fetch-site": "same-origin", "content-type": "application/json" },
body: JSON.stringify({ text: "no origin" }),
});
expect(mutation.status).toBe(403);
await expect(mutation.json()).resolves.toMatchObject({ error: "origin_required" });
});
it("resolves a request identity that carries reserved characters", async () => {
// Upstream request identities are opaque. The browser percent-encodes the
// segment, so the server has to decode it before it can match.
const requestId = "req/2026?scope=a#b c%d";
const server = new Phase4bDemoServer({
workingDirectory: "/safe/server-owned-workspace",
driverFactory: () => new StubDriver(requestId),
});
servers.push(server);
const { url } = await server.start();
const created = await jsonBrowserRequest(url, "/api/phase4b/sessions", {
objective: "Resolve a reserved-character request identity.",
}).then((response) => response.json()) as Record<string, unknown>;
const sessionId = String(created.sessionId);
expect(created.pendingRequests).toEqual([expect.objectContaining({ requestId })]);
const encoded = encodeURIComponent(requestId);
expect(encoded).not.toBe(requestId);
const resolved = await jsonBrowserRequest(
url,
`/api/phase4b/sessions/${sessionId}/requests/${encoded}/resolve`,
{ turnId: "turn-demo", resolution: { action: "accept" } },
);
expect(resolved.status).toBe(200);
await expect(resolved.json()).resolves.toMatchObject({ pendingRequests: [] });
const malformed = await jsonBrowserRequest(
url,
`/api/phase4b/sessions/${sessionId}/requests/%E0%A4%A/resolve`,
{ turnId: "turn-demo", resolution: { action: "accept" } },
);
expect(malformed.status).toBe(400);
await expect(malformed.json()).resolves.toMatchObject({ error: "invalid_path_encoding" });
});
it("fails closed on a submit body that does not match the request kind", async () => {
const server = new Phase4bDemoServer({
workingDirectory: "/safe/server-owned-workspace",
driverFactory: () => new StubDriver("request-elicit", "elicitation"),
});
servers.push(server);
const { url } = await server.start();
const created = await jsonBrowserRequest(url, "/api/phase4b/sessions", {
objective: "Answer an elicitation.",
}).then((response) => response.json()) as Record<string, unknown>;
const sessionId = String(created.sessionId);
const resolve = `/api/phase4b/sessions/${sessionId}/requests/request-elicit/resolve`;
for (const resolution of [
{ action: "submit" },
{ action: "submit", content: {} },
{ action: "submit", answers: { answer: { answers: ["user-input shape"] } } },
{ action: "accept_for_session" },
]) {
const rejected = await jsonBrowserRequest(url, resolve, { turnId: "turn-demo", resolution });
expect(rejected.status).toBe(422);
await expect(rejected.json()).resolves.toMatchObject({
error: "invalid_runtime_request_resolution",
});
// A rejected submit must never settle the request.
const state = await browserRequest(url, `/api/phase4b/sessions/${sessionId}`)
.then((response) => response.json()) as Record<string, unknown>;
expect(state.pendingRequests).toEqual([
expect.objectContaining({ requestId: "request-elicit" }),
]);
}
const accepted = await jsonBrowserRequest(url, resolve, {
turnId: "turn-demo",
resolution: { action: "submit", content: { answer: "green" } },
});
expect(accepted.status).toBe(200);
await expect(accepted.json()).resolves.toMatchObject({ pendingRequests: [] });
});
it("appends exactly one terminal fact per request across a reconnect", async () => {
const server = new Phase4bDemoServer({
workingDirectory: "/safe/server-owned-workspace",
driverFactory: () => new StubDriver(),
});
servers.push(server);
const { url } = await server.start();
const created = await jsonBrowserRequest(url, "/api/phase4b/sessions", {
objective: "Reconnect while a request is still pending.",
}).then((response) => response.json()) as Record<string, unknown>;
const sessionId = String(created.sessionId);
expect(created.pendingRequests).toEqual([
expect.objectContaining({ requestId: "request-demo" }),
]);
const reconnected = await jsonBrowserRequest(
url,
`/api/phase4b/sessions/${sessionId}/reconnect`,
{},
);
expect(reconnected.status).toBe(200);
const { events } = await browserRequest(url, `/api/phase4b/sessions/${sessionId}/events?after=0`)
.then((response) => response.json()) as { events: PrpEvent[] };
const terminal = events.filter(({ eventType }) =>
eventType.startsWith("runtime_request.") && eventType !== "runtime_request.created");
expect(terminal).toEqual([
expect.objectContaining({
eventType: "runtime_request.cancelled",
turnId: "turn-demo",
itemId: "command-demo",
payload: {
requestId: "request-demo",
requestKind: "command_approval",
turnId: "turn-demo",
itemId: "command-demo",
reason: "browser_reconnect",
},
}),
]);
// The resumed session continues the sequence rather than replaying it.
expect(events.map(({ sourceSeq }) => sourceSeq)).toEqual(
events.map((_, index) => index + 1),
);
expect(events.some(({ eventType }) => eventType === "session.resumed")).toBe(true);
});
});
@@ -6,11 +6,12 @@ import {
type ServerResponse,
} from "node:http";
import type {
HarnessDriver,
HarnessGoalOperation,
HarnessRuntimeRequestResolution,
HarnessSession,
import {
parseHarnessRuntimeRequestResolution,
type HarnessDriver,
type HarnessGoalOperation,
type HarnessRuntimeRequestResolution,
type HarnessSession,
} from "../contracts/harness-driver.js";
import {
createPhase4TaskEnvelope,
@@ -107,9 +108,14 @@ function browserSafe(value: unknown, depth = 0): unknown {
);
}
/**
* Hides absolute host paths under any home root. The provider names its own
* state directories in session context — `.codex/memories` as well as
* `.paperclip` — and none of them mean anything to a browser.
*/
function redactBrowserString(value: string): string {
return redactCodexDiagnostic(value).replace(
/\/(?:srv\/paperclip\/home|home\/[^/\s]+)\/\.paperclip\/[^\s"'<>),\]}]*/g,
/\/(?:srv\/paperclip\/home|home\/[^/\s]+|Users\/[^/\s]+)\/\.[^\s"'<>),\]}]*/g,
"<server-path>",
);
}
@@ -164,9 +170,27 @@ function prpCapabilities(capabilities: NativeSessionCapabilities): PrpCapabiliti
};
}
/**
* Splits first, then decodes, so a percent-encoded reserved character stays
* inside the segment the browser meant it for. Upstream request identities are
* opaque and may contain `/`, `?`, or `#`.
*/
function pathParts(request: IncomingMessage): string[] {
const url = new URL(request.url ?? "/", "http://phase4b.invalid");
return url.pathname.split("/").filter(Boolean);
return url.pathname
.split("/")
.filter(Boolean)
.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
throw new Phase4bHttpError(
400,
"invalid_path_encoding",
"Phase 4b path segments must be valid percent-encoded UTF-8",
);
}
});
}
function errorStatus(error: unknown): number {
@@ -275,7 +299,11 @@ function requireTransportAdmission(
if (origin !== null && origin !== `http://${host.toLowerCase()}`) {
throw new Phase4bHttpError(403, "invalid_origin", "Phase 4b cross-origin requests are forbidden");
}
if ((mutation || request.url?.includes("/stream") === true) && origin === null) {
// Only mutations are required to name their origin. A same-origin `GET` —
// which is what the `EventSource` stream is — sends no `Origin` header at
// all, so demanding one there rejects the console's own subscription;
// `Sec-Fetch-Site: same-origin` above is what proves that request safe.
if (mutation && origin === null) {
throw new Phase4bHttpError(403, "origin_required", "Phase 4b browser request Origin is required");
}
}
@@ -547,10 +575,22 @@ export class Phase4bDemoServer {
"Runtime request resolution does not match its session and turn",
);
}
// The browser is untrusted, so the shape is validated against the kind of
// request it answers here as well as inside the driver.
let resolution: HarnessRuntimeRequestResolution;
try {
resolution = parseHarnessRuntimeRequestResolution(pending.requestKind, body.resolution);
} catch (error) {
throw new Phase4bHttpError(
422,
"invalid_runtime_request_resolution",
error instanceof Error ? error.message : String(error),
);
}
await entry.session.resolveRuntimeRequest?.({
requestId: parts[5],
turnId: String(body.turnId ?? ""),
resolution: record(body.resolution) as HarnessRuntimeRequestResolution,
turnId: body.turnId,
resolution,
});
} else if (action === "goal" && parts[5]) {
await entry.session.goal?.(goalOperation(parts[5], body));
@@ -635,9 +675,15 @@ export class Phase4bDemoServer {
}
async #reconnect(entry: DemoEntry): Promise<void> {
const snapshot = await entry.session.snapshot();
await entry.session.close({ reason: "browser_reconnect" });
await entry.consumeTask.catch(() => undefined);
// The snapshot is taken after the close, not before it. A graceful close
// already appends the terminal fact for every request it cancels, so a
// pre-close snapshot would hand those same requests to the driver's
// stale-request recovery path and append a second terminal fact for each.
// Reading afterwards also carries the closing events' source sequence into
// the resumed session, keeping source order continuous across the seam.
const snapshot = await entry.session.snapshot();
const recovery = await entry.driver.recoverSession?.(snapshot);
if (!recovery?.recovered || recovery.session === undefined) {
throw new Error(`session resume failed: ${recovery?.reason ?? "driver cannot resume"}`);
@@ -256,6 +256,62 @@ describe("Phase 4b scripted driver", () => {
await session.close({ reason: "test" });
});
it("fails an elicitation submit closed unless it carries content", async () => {
const { session, stream } = await openSession("user-input");
const { turnId } = await session.startTurn({ message: { role: "user", text: "Ask." } });
const first = await stream.waitFor("runtime_request.created");
await session.resolveRuntimeRequest?.({
requestId: (first.payload as { request: { requestId: string } }).request.requestId,
turnId,
resolution: { action: "submit", answers: { environment: { answers: ["staging"] } } },
});
// `waitFor` replays the first match, so the elicitation beat is polled for
// by kind rather than by event type.
type CreatedRequest = { requestId: string; requestKind: string; itemId: string };
let request: CreatedRequest | undefined;
for (let attempt = 0; attempt < 50 && request === undefined; attempt += 1) {
await tick();
request = stream.events
.filter((event) => event.eventType === "runtime_request.created")
.map((event) => (event.payload as { request: CreatedRequest }).request)
.find((candidate) => candidate.requestKind === "elicitation");
}
if (request === undefined) throw new Error("the elicitation request never opened");
for (const resolution of [
{ action: "submit" },
{ action: "submit", content: {} },
{ action: "submit", answers: { channel: { answers: ["stable"] } } },
]) {
await expect(session.resolveRuntimeRequest?.({
requestId: request.requestId,
turnId,
resolution: resolution as never,
})).rejects.toThrow(/elicitation rejected its resolution/);
}
// Every rejection left the request answerable rather than settling it.
expect(session.pendingRuntimeRequests?.().map(({ requestId }) => requestId))
.toContain(request.requestId);
await session.resolveRuntimeRequest?.({
requestId: request.requestId,
turnId,
resolution: { action: "submit", content: { channel: "stable" } },
});
await tick();
const resolved = stream.events.filter((event) =>
event.eventType === "runtime_request.resolved");
expect(resolved.at(-1)?.payload).toEqual({
requestId: request.requestId,
requestKind: "elicitation",
turnId,
itemId: "input-2",
action: "submit",
});
expect(JSON.stringify(resolved.at(-1)?.payload)).not.toContain("stable");
await session.close({ reason: "test" });
});
it("reports the exact diagnostic when goals are unsupported", async () => {
const { session } = await openSession("goals-unsupported");
await expect(session.goal?.({ action: "get" })).rejects.toThrow(
@@ -2,7 +2,10 @@ import {
HarnessCapabilityUnavailableError,
HarnessOperationAlreadyTerminalError,
HarnessStaleTurnError,
harnessRuntimeRequestOutcome,
parseHarnessRuntimeRequestResolution,
type HarnessDriver,
type HarnessRuntimeRequestAction,
type HarnessDriverDescriptor,
type HarnessGoalOperation,
type HarnessRuntimeRequest,
@@ -107,7 +110,8 @@ interface PendingRequestEntry {
request: HarnessRuntimeRequest;
gate: Gate;
outcome: "pending" | "resolved" | "expired" | "cancelled";
action: string | null;
action: HarnessRuntimeRequestAction | null;
reason: string | null;
timer: ReturnType<typeof setTimeout> | null;
}
@@ -276,8 +280,12 @@ class Phase4bScriptedSession implements HarnessSession {
if (input.turnId.length > 0 && input.turnId !== entry.request.turnId) {
throw new HarnessStaleTurnError(input.turnId);
}
const resolution = parseHarnessRuntimeRequestResolution(
entry.request.requestKind,
input.resolution,
);
entry.outcome = "resolved";
entry.action = input.resolution.action;
entry.action = resolution.action;
this.#settleRequest(entry, "runtime_request.resolved");
}
@@ -366,6 +374,7 @@ class Phase4bScriptedSession implements HarnessSession {
for (const entry of this.#pending.values()) {
if (entry.outcome !== "pending") continue;
entry.outcome = "cancelled";
entry.reason = input.reason;
this.#settleRequest(entry, "runtime_request.cancelled");
}
this.#interruptGate?.open();
@@ -412,11 +421,14 @@ class Phase4bScriptedSession implements HarnessSession {
clearTimeout(entry.timer);
entry.timer = null;
}
this.#emit(eventType, {
requestId: entry.request.requestId,
requestKind: entry.request.requestKind,
...(entry.action === null ? {} : { action: entry.action }),
}, { turnId: entry.request.turnId, itemId: entry.request.itemId });
this.#emit(
eventType,
harnessRuntimeRequestOutcome(entry.request, {
action: entry.action,
reason: entry.reason,
}),
{ turnId: entry.request.turnId, itemId: entry.request.itemId },
);
entry.gate.open();
}
@@ -573,6 +585,7 @@ class Phase4bScriptedSession implements HarnessSession {
gate: new Gate(),
outcome: "pending",
action: null,
reason: null,
timer: null,
};
this.#pending.set(request.requestId, entry);
@@ -597,6 +610,7 @@ class Phase4bScriptedSession implements HarnessSession {
]);
if (entry.outcome === "pending") {
entry.outcome = "cancelled";
entry.reason = "turn_interrupted";
this.#settleRequest(entry, "runtime_request.cancelled");
return false;
}