Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/runtime/hosted-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,42 @@ export class HostedOnlyBotsRequestError extends Error {
}
}

/**
* Classify a failed hosted request for local diagnostics without copying its
* message, URL, headers or body: a timeout, an HTTP status/code, or an error name.
*/
const SAFE_FAILURE_LABEL = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
const safeFailureLabel = (value: string | null): string | null => (value !== null && SAFE_FAILURE_LABEL.test(value) ? value : null);

export const describeHostedFailure = (error: unknown): Record<string, string | number | null> => {
// The server chooses `code`; validate it like an error name so it cannot carry message text.
if (error instanceof HostedOnlyBotsRequestError) return { errorClass: "http", status: error.status, code: safeFailureLabel(error.code) };
const name = error instanceof Error ? error.name : typeof error;
if (name === "TimeoutError") return { errorClass: "timeout" };
if (name === "AbortError") return { errorClass: "aborted" };
return { errorClass: safeFailureLabel(name) ?? "unknown" };
};

/**
* Warning for a bounded recall that fell back to free hot memory. Only a timeout, abort,
* network failure or unconfirmed server error leaves open whether the paid request settled.
*/
export const describeBoundedRecallFallback = (error: unknown): string => {
const prefix = "Saved-context retrieval unavailable; continuing with free hot memory.";
if (error instanceof HostedOnlyBotsRequestError) {
const details = error.details && typeof error.details === "object" ? (error.details as Record<string, unknown>) : null;
if (error.code === "paid_operation_commit_unknown") {
return `${prefix} The payment outcome is unknown; retry only with the same idempotency key to resolve it without a duplicate charge.`;
}
if (error.code === "recall_deadline_exceeded" && details?.charged_sats === 0) {
return `${prefix} The server missed its response budget (HTTP ${error.status}) and charged nothing; a retry is safe.`;
}
if (details?.charged_sats === 0) return `${prefix} The server could not deliver the request (HTTP ${error.status}) and charged nothing; a retry is safe.`;
if (error.status < 500) return `${prefix} The server rejected the request (HTTP ${error.status}).`;
}
return `${prefix} A timed-out or failed paid request may still settle; inspect billing before retrying.`;
};

const extractHostedError = (data: unknown): { message: string | null; code: string | null; details: unknown } => {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return { message: null, code: null, details: undefined };
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/lifecycle-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
pickHotContextValidationCommand,
} from "./hot-context-cues";
import { buildRuntimeBillingRemediation, formatRuntimeBillingRemediation, isBillingBlockedError } from "./billing-remediation";
import { HostedOnlyBotsClient, type HostedSession } from "./hosted-client";
import { describeBoundedRecallFallback, describeHostedFailure, HostedOnlyBotsClient, type HostedSession } from "./hosted-client";
import { isLocalOnlyRuntimeProfile, readLocalHotMemory, writeLocalHotMemory } from "./local-hot-memory";
import { workspaceHotScope, selectWorkspaceHotState } from "./workspace-hot-memory";
import { buildRuntimeContinuityOutcome, parseRuntimeContinuityOutcomeContent } from "./continuity-outcomes";
Expand Down Expand Up @@ -1795,10 +1795,10 @@ export class RuntimeLifecycleManager {
} catch (error) {
if (!isBillingBlockedError(error) && packRequest.recallPolicy === "bounded") {
contextPack = buildLocalOnlyContextPack(event, packRequest);
warnings.push("Saved-context retrieval unavailable; continuing with free hot memory. A timed-out paid request may still settle; inspect billing before retrying.");
warnings.push(describeBoundedRecallFallback(error));
actions.push(this.recordAction({ eventId: event.eventId, profileName, type: "context_pack", status: "failed",
mode: profile.memoryMode, reason: "Bounded recall unavailable; preserved free hot-memory delivery", queueItemId: null,
details: { type: event.type, freeFallback: true } }));
details: { type: event.type, freeFallback: true, failure: describeHostedFailure(error) } }));
} else {
if (!isBillingBlockedError(error)) throw error;
contextPack = buildLocalOnlyContextPack(event, packRequest);
Expand Down
45 changes: 44 additions & 1 deletion tests/runtime/hosted-client-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { schnorr } from "@noble/curves/secp256k1";
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { afterEach, describe, expect, it, vi } from "vitest";

import { HostedOnlyBotsClient } from "../../src/runtime/hosted-client";
import { describeBoundedRecallFallback, describeHostedFailure, HostedOnlyBotsClient, HostedOnlyBotsRequestError } from "../../src/runtime/hosted-client";

const servers: Server[] = [];
afterEach(async () => {
Expand Down Expand Up @@ -208,3 +208,46 @@ describe("hosted client security boundaries", () => {
expect(receivedBody).toEqual({ code: "obj_test", agent_id: "agent-test" });
});
});

describe("describeHostedFailure", () => {
it("distinguishes a recall timeout from an HTTP failure without copying messages", () => {
const timeout = new DOMException("The operation was aborted due to timeout https://secret.example/?token=x", "TimeoutError");
expect(describeHostedFailure(timeout)).toEqual({ errorClass: "timeout" });
const http = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "secret detail" });
expect(describeHostedFailure(http)).toEqual({ errorClass: "http", status: 503, code: "recall_deadline_exceeded" });
expect(describeHostedFailure(new TypeError("fetch failed: secret.example"))).toEqual({ errorClass: "TypeError" });
expect(JSON.stringify(describeHostedFailure(Object.assign(new Error("x"), { name: "bad name with token=1" })))).not.toContain("token");
// A server-chosen code that is not a plain identifier is dropped, not copied.
const unsafeCode = new HostedOnlyBotsRequestError({ status: 500, code: "oops token=secret https://x", message: "m" });
expect(describeHostedFailure(unsafeCode)).toEqual({ errorClass: "http", status: 500, code: null });
expect(describeHostedFailure(new HostedOnlyBotsRequestError({ status: 502, code: "a".repeat(65), message: "m" })).code).toBeNull();
});
});

describe("describeBoundedRecallFallback", () => {
it("keeps unknown commits distinct from confirmed free failures and contradicting charge claims", () => {
const uncertain = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 503, code: "paid_operation_commit_unknown", message: "private", details: { charged_sats: 0 } }));
expect(uncertain).toContain("same idempotency key");
expect(uncertain).not.toContain("charged nothing");
const contradictory = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "private", details: { charged_sats: 2 } }));
expect(contradictory).toContain("may still settle");
const rejected = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 400, code: "bad_request", message: "private", details: { charged_sats: 0 } }));
expect(rejected).toContain("charged nothing");
expect(rejected).not.toContain("missed its response budget");
});
it("says nothing was charged only when the server confirms it, and keeps the settle caution otherwise", () => {
const deadline = new HostedOnlyBotsRequestError({ status: 503, code: "recall_deadline_exceeded", message: "late",
details: { charged_sats: 0, retryable: true } });
expect(describeBoundedRecallFallback(deadline)).toContain("charged nothing");
expect(describeBoundedRecallFallback(deadline)).not.toContain("may still settle");
const timeout = new DOMException("timed out", "TimeoutError");
expect(describeBoundedRecallFallback(timeout)).toContain("may still settle");
expect(describeBoundedRecallFallback(new TypeError("fetch failed"))).toContain("may still settle");
const gateway = new HostedOnlyBotsRequestError({ status: 504, code: null, message: "gateway timeout" });
expect(describeBoundedRecallFallback(gateway)).toContain("may still settle");
const rejected = describeBoundedRecallFallback(new HostedOnlyBotsRequestError({ status: 400, code: "bad_request", message: "secret detail" }));
expect(rejected).toContain("HTTP 400");
expect(rejected).not.toContain("may still settle");
expect(rejected).not.toContain("secret detail");
});
});
62 changes: 61 additions & 1 deletion tests/runtime/hosted-lifecycle-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const jsonResponse = (body: unknown, status = 200): Response =>
headers: { "content-type": "application/json" },
});

const installFetchMock = (npub: string, contextStatus: number) => {
const installFetchMock = (npub: string, contextStatus: number, contextPack?: () => Promise<Response>) => {
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
if (url.pathname === "/v1/auth/challenge") {
Expand All @@ -52,6 +52,7 @@ const installFetchMock = (npub: string, contextStatus: number) => {
});
}
if (url.pathname === "/v1/context/pack") {
if (contextPack) return contextPack();
if (contextStatus !== 200) {
return jsonResponse({ error: { code: "payment_required", message: "funding required" } }, contextStatus);
}
Expand Down Expand Up @@ -173,4 +174,63 @@ describe("hosted lifecycle prompt boundary", () => {
expect(promptSurface).not.toContain("SERVER_AUTHORED_FRAGMENT_MUST_NOT_REACH_THE_PROMPT");
if (contextStatus === 200) expect(result.contextPack?.packId).toBe("hosted-pack-safe-id");
});

it.each([
{
label: "the server's recall deadline",
contextPack: async () => jsonResponse({ error: { code: "recall_deadline_exceeded", message: "late",
details: { deadline_ms: 2500, charged_sats: 0, retryable: true } } }, 503),
failure: { errorClass: "http", status: 503, code: "recall_deadline_exceeded" },
warning: "charged nothing",
},
{
label: "the client's recall timeout",
contextPack: async () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); },
failure: { errorClass: "timeout" },
warning: "may still settle",
},
{
label: "an unacknowledged paid commit",
contextPack: async () => jsonResponse({ error: { code: "paid_operation_commit_unknown", message: "uncertain", details: { retry_with_same_key: true } } }, 503),
failure: { errorClass: "http", status: 503, code: "paid_operation_commit_unknown" },
warning: "same idempotency key",
},
])("records why bounded recall fell back to free hot memory after $label", async ({ contextPack, failure, warning }) => {
// Local-first sessions deliberately skip paid automatic recall. Exercise the
// still-supported hosted path explicitly rather than depending on defaults.
vi.stubEnv("CROSSHEAD_HOT_SYNC_ENABLED", "false");
const signer = identity();
const home = mkdtempSync(path.join(os.tmpdir(), "crosshead-bounded-fallback-"));
homes.push(home);
installFetchMock(signer.npub, 200, contextPack);
await setupRuntimeProfile({
home,
profileName: "default",
apiBase: "https://crosshead.sh",
npub: signer.npub,
nsec: signer.nsec,
memoryMode: "assist",
});

const daemon = new OnlyBotsLocalRuntimeDaemon(home);
const result = await daemon.submitLifecycleEvent({
runtime: "claude-code",
type: "turn.started",
workspaceId: "repo:security-test",
sessionId: "current-session",
turnId: "current-turn",
payload: {
goal: "Continue the verified local work",
recallPolicy: "bounded",
hotMemorySnapshot: verifiedHotSnapshot,
},
});

const fallback = result.actions.find((action) => action.type === "context_pack" && action.status === "failed");
expect(fallback?.details).toMatchObject({ freeFallback: true, failure });
expect(result.warnings.filter((entry) => entry.startsWith("Saved-context retrieval unavailable"))).toEqual([
expect.stringContaining(warning),
]);
expect(JSON.stringify(result.contextPack)).toContain("CLIENT_VERIFIED_HOT_MEMORY_MUST_REACH_THE_PROMPT");
});
});
Loading