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>
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)
|
||||
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 155 KiB |
|
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;
|
||||
}
|
||||
|
||||