Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
98 changes: 84 additions & 14 deletions apps/account-directory/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ type AccountRoute =
export const DEFAULT_ONLINE_WINDOW_MS = 90_000;
const MAX_PUBKEY_CHARS = 128;
const remoteJwksByUrl = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
const CORRELATION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

type CallerTokenFailureReason =
| "authentication unavailable"
Expand Down Expand Up @@ -464,11 +466,56 @@ function trustedWebClientOrigin(env: Env): string | null {
function withCors(response: Response, origin: string): Response {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", origin);
headers.set("access-control-expose-headers", "Server-Timing");
headers.set(
"access-control-expose-headers",
"Server-Timing, X-ADE-Correlation-ID",
);
headers.set("vary", "Origin");
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}

function requestCorrelationId(request: Request): string {
const provided = request.headers.get("x-ade-correlation-id")?.trim() ?? "";
return CORRELATION_ID_PATTERN.test(provided)
? provided.toLowerCase()
: crypto.randomUUID();
}

function withCorrelationId(response: Response, correlationId: string): Response {
const headers = new Headers(response.headers);
headers.set("x-ade-correlation-id", correlationId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

function logDirectoryLifecycle(args: {
correlationId: string;
route: AccountRoute | null;
method: string;
status: number;
durationMs: number;
}): void {
const outcome = args.status < 400
? "ok"
: args.status < 500
? "client_error"
: "server_error";
console.log(JSON.stringify({
ts: new Date().toISOString(),
svc: "ade-account-directory",
kind: "request_completed",
correlationId: args.correlationId,
route: args.route?.kind ?? "other",
method: args.method,
status: args.status,
outcome,
durationMs: Math.max(0, Math.round(args.durationMs)),
}));
}

async function handleRequestCore(
request: Request,
env: Env,
Expand Down Expand Up @@ -511,42 +558,65 @@ export async function handleRequest(
env: Env,
options: DeviceAuthorizationRequestOptions = {},
): Promise<Response> {
const startedAt = performance.now();
const correlationId = requestCorrelationId(request);
const url = new URL(request.url);
const route = routeAccount(url.pathname);
const requestOrigin = request.headers.get("origin");
const allowedOrigin = trustedWebClientOrigin(env);
const corsOrigin = requestOrigin && allowedOrigin && requestOrigin === allowedOrigin
? allowedOrigin
: null;
const finish = (response: Response, applyCors = false): Response => {
const correlatedResponse = withCorrelationId(response, correlationId);
logDirectoryLifecycle({
correlationId,
route,
method: request.method,
status: correlatedResponse.status,
durationMs: performance.now() - startedAt,
});
return applyCors && corsOrigin
? withCors(correlatedResponse, corsOrigin)
: correlatedResponse;
};
if (request.method === "OPTIONS") {
const route = routeAccount(url.pathname);
if (!route || route.kind !== "list") return text("not found", 404);
if (!corsOrigin) return text("origin not allowed", 403);
if (request.headers.get("access-control-request-method")?.toUpperCase() !== "GET") {
return text("method not allowed", 405);
const allowedMethod = route?.kind === "list"
? "GET"
: route?.kind === "delete"
? "DELETE"
: null;
if (!allowedMethod) return finish(text("not found", 404));
if (!corsOrigin) return finish(text("origin not allowed", 403));
if (request.headers.get("access-control-request-method")?.toUpperCase() !== allowedMethod) {
return finish(text("method not allowed", 405));
}
const requestedHeaders = (request.headers.get("access-control-request-headers") ?? "")
.split(",")
.map((header) => header.trim().toLowerCase())
.filter(Boolean);
if (requestedHeaders.some((header) => header !== "authorization")) {
return text("headers not allowed", 403);
if (requestedHeaders.some((header) =>
header !== "authorization" && header !== "x-ade-correlation-id"
)) {
return finish(text("headers not allowed", 403));
}
return new Response(null, {
return finish(new Response(null, {
status: 204,
headers: {
"access-control-allow-origin": corsOrigin,
"access-control-allow-headers": "authorization",
"access-control-allow-methods": "GET, OPTIONS",
"access-control-allow-headers": "authorization, x-ade-correlation-id",
"access-control-expose-headers": "X-ADE-Correlation-ID",
"access-control-allow-methods": `${allowedMethod}, OPTIONS`,
"access-control-max-age": "600",
vary: "Origin",
},
});
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// Daemon/native callers omit Origin. Browser callers must match the one
// configured hosted client exactly; reject hostile origins before auth or D1.
if (requestOrigin && routeAccount(url.pathname) && !corsOrigin) {
return text("origin not allowed", 403);
return finish(text("origin not allowed", 403));
}
const response = await handleRequestCore(request, env, options);
return corsOrigin ? withCors(response, corsOrigin) : response;
return finish(response, Boolean(corsOrigin));
}
82 changes: 80 additions & 2 deletions apps/account-directory/test/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,14 +1001,37 @@ describe("machine directory", () => {
}), env);
expect(preflight.status).toBe(204);
expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.ade.dev");
expect(preflight.headers.get("access-control-allow-headers")).toBe("authorization");
expect(preflight.headers.get("access-control-allow-headers")).toBe(
"authorization, x-ade-correlation-id",
);
expect(preflight.headers.get("x-ade-correlation-id")).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);

const deletePreflight = await handleRequest(new Request(
"https://directory.test/account/machines/machine-a",
{
method: "OPTIONS",
headers: {
origin: "https://app.ade.dev",
"access-control-request-method": "DELETE",
"access-control-request-headers": "authorization, x-ade-correlation-id",
},
},
), env);
expect(deletePreflight.status).toBe(204);
expect(deletePreflight.headers.get("access-control-allow-methods")).toBe(
"DELETE, OPTIONS",
);

const allowed = await handleRequest(new Request("https://directory.test/account/machines", {
headers: { origin: "https://app.ade.dev", authorization: `Bearer ${token}` },
}), env);
expect(allowed.status).toBe(200);
expect(allowed.headers.get("access-control-allow-origin")).toBe("https://app.ade.dev");
expect(allowed.headers.get("access-control-expose-headers")).toBe("Server-Timing");
expect(allowed.headers.get("access-control-expose-headers")).toBe(
"Server-Timing, X-ADE-Correlation-ID",
);

const hostilePreflight = await handleRequest(new Request("https://directory.test/account/machines", {
method: "OPTIONS",
Expand Down Expand Up @@ -1054,6 +1077,61 @@ describe("machine directory", () => {
expect(await otherUserList.json()).toEqual({ machines: [] });
});

it("echoes safe correlation ids in responses and structured logs", async () => {
const env = makeEnv();
const token = await mintToken();
const correlationId = "123e4567-e89b-42d3-a456-426614174000";
const log = vi.spyOn(console, "log").mockImplementation(() => {});

const response = await handleRequest(new Request(
"https://directory.test/account/machines?ignored=secret",
{
headers: {
authorization: `Bearer ${token}`,
"x-ade-correlation-id": correlationId.toUpperCase(),
},
},
), env);

expect(response.status).toBe(200);
expect(response.headers.get("x-ade-correlation-id")).toBe(correlationId);
const lifecycle = log.mock.calls
.map(([line]) => String(line))
.find((line) => line.includes('"kind":"request_completed"'));
expect(lifecycle).toBeDefined();
expect(JSON.parse(lifecycle ?? "{}")).toMatchObject({
svc: "ade-account-directory",
kind: "request_completed",
correlationId,
route: "list",
method: "GET",
status: 200,
outcome: "ok",
});
expect(lifecycle).not.toContain(token);
expect(lifecycle).not.toContain("ignored=secret");
log.mockRestore();
});

it("replaces invalid correlation ids instead of reflecting them", async () => {
const token = await mintToken();
const response = await handleRequest(new Request(
"https://directory.test/account/machines",
{
headers: {
authorization: `Bearer ${token}`,
"x-ade-correlation-id": "unsafe-value",
},
},
), makeEnv());

expect(response.status).toBe(200);
expect(response.headers.get("x-ade-correlation-id")).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(response.headers.get("x-ade-correlation-id")).not.toBe("unsafe-value");
});

it("retains the authenticated machine's verified Relay route during a transient health dip", async () => {
const env = makeEnv();
const token = await mintToken({ sub: "user_1" });
Expand Down
3 changes: 2 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ ade chat scheduled-work create --at "2026-07-23T01:05:00-04:00" --prompt "Check
ade chat scheduled-work create --cron "9,29,49 * * * *" --prompt "Check CI and report" --once # five-field cron uses the ADE brain machine's local timezone
ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-native jobs request CronDelete in the owning chat
ade chat wait session-id --for idle --timeout-ms 600000
ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume
ade chat recover session-id --turn turn-id --action nudge # provider-neutral wait | nudge | retry | resume; falls back for older Codex brains
ade chat resolve-unprocessed session-id --steer steer-id --action run-next # durable/idempotent; action is run-next | dismiss
ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane <lane-id> to hand off into another lane
ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); stays in source lane
ade chat models --provider codex --json # model order + supported reasoning tiers
Expand Down
19 changes: 19 additions & 0 deletions apps/ade-cli/src/chatRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
isUnsupportedAgentChatRecoveryActionError,
type AgentChatRecoverCodexTurnArgs,
type AgentChatRecoverTurnArgs,
} from "../../desktop/src/shared/types/chat";

export {
isUnsupportedAgentChatRecoveryActionError as isUnsupportedRecoveryActionError,
};

export const LEGACY_RECOVERY_ACTION_BY_NEUTRAL: Readonly<Record<
AgentChatRecoverTurnArgs["action"],
AgentChatRecoverCodexTurnArgs["action"]
>> = {
wait: "wait",
nudge: "steer",
retry_same_runtime: "interrupt_retry_same_thread",
restart_resume: "restart_resume_thread",
};
Loading