codexteer 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +37 -2
  2. package/README.md +154 -16
  3. package/assets/remote-device-key.swift +94 -0
  4. package/bin/codexteer-update.mjs +3 -0
  5. package/bin/codexteer.mjs +6 -2
  6. package/docs/cloud.md +113 -0
  7. package/docs/desktop-cloud.md +36 -0
  8. package/docs/distribution.md +21 -9
  9. package/docs/orchestration.md +120 -0
  10. package/docs/remote-cli-validation.json +139 -0
  11. package/docs/remote-control.md +65 -0
  12. package/docs/remote-device-enrollment.md +91 -0
  13. package/docs/remote-observation.md +57 -0
  14. package/docs/remote-operations.md +89 -0
  15. package/docs/thread-create.md +90 -0
  16. package/package.json +13 -2
  17. package/scripts/update-desktop.applescript +51 -0
  18. package/src/agent-launch.mjs +27 -0
  19. package/src/app-server.mjs +34 -10
  20. package/src/checkpoint.mjs +52 -16
  21. package/src/cli.mjs +233 -19
  22. package/src/cloud-api.mjs +118 -0
  23. package/src/cloud-cli.mjs +319 -0
  24. package/src/cloud-connection.mjs +64 -0
  25. package/src/cloud-id.mjs +16 -0
  26. package/src/cloud-observe.mjs +178 -0
  27. package/src/cloud-send.mjs +33 -0
  28. package/src/cloud.mjs +172 -0
  29. package/src/connection.mjs +23 -5
  30. package/src/creation-verification.mjs +36 -0
  31. package/src/desktop-cloud-api.mjs +119 -0
  32. package/src/desktop-cloud-id.mjs +18 -0
  33. package/src/desktop-cloud-project.mjs +148 -0
  34. package/src/desktop-cloud.mjs +98 -0
  35. package/src/desktop.mjs +3 -1
  36. package/src/directive.mjs +5 -2
  37. package/src/distribution.mjs +6 -6
  38. package/src/evidence.mjs +3 -0
  39. package/src/findings.mjs +7 -7
  40. package/src/help.mjs +155 -26
  41. package/src/journal.mjs +37 -9
  42. package/src/launcher.mjs +16 -9
  43. package/src/monitor.mjs +18 -6
  44. package/src/native-command.mjs +100 -0
  45. package/src/native-rpc.mjs +116 -0
  46. package/src/native-worker.mjs +25 -0
  47. package/src/observe.mjs +21 -7
  48. package/src/orchestrate-prompt.mjs +96 -0
  49. package/src/orchestrate.mjs +29 -0
  50. package/src/paged-read.mjs +7 -1
  51. package/src/projects.mjs +104 -0
  52. package/src/prompt-command.mjs +25 -0
  53. package/src/prompt.mjs +35 -11
  54. package/src/remote-client-info.mjs +16 -0
  55. package/src/remote-control-auth.mjs +109 -0
  56. package/src/remote-control-socket.mjs +137 -0
  57. package/src/remote-control.mjs +134 -0
  58. package/src/remote-device-key.mjs +41 -0
  59. package/src/remote-device-login.mjs +76 -0
  60. package/src/remote-device-protocol.mjs +72 -0
  61. package/src/remote-device-store.mjs +71 -0
  62. package/src/remote-device-worker.mjs +48 -0
  63. package/src/remote-device.mjs +202 -0
  64. package/src/remote-io.mjs +163 -0
  65. package/src/remote-key-access.mjs +76 -0
  66. package/src/remote-key-worker.mjs +38 -0
  67. package/src/remote-keychain.mjs +72 -0
  68. package/src/remote-management.mjs +234 -0
  69. package/src/remote.mjs +132 -0
  70. package/src/resource.mjs +8 -6
  71. package/src/runner.mjs +6 -1
  72. package/src/runtime.mjs +4 -2
  73. package/src/socket-endpoint.mjs +40 -0
  74. package/src/standalone-create.mjs +135 -0
  75. package/src/store.mjs +6 -3
  76. package/src/supervise.mjs +32 -55
  77. package/src/supervision.mjs +41 -19
  78. package/src/target.mjs +25 -0
  79. package/src/task-id.mjs +23 -0
  80. package/src/thread-create.mjs +128 -0
  81. package/src/thread-id.mjs +21 -6
  82. package/src/thread-verify.mjs +108 -0
  83. package/src/turn-repair.mjs +228 -0
  84. package/src/update.mjs +158 -0
  85. package/src/wrapper.mjs +68 -5
@@ -0,0 +1,128 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { discoverRuntime } from "./runtime.mjs";
5
+ import { RpcClient } from "./rpc.mjs";
6
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
7
+ import { normalizeThreadId, threadDeepLink } from "./thread-id.mjs";
8
+ import { projectsOnClient, projectAliases, resolveProject, projectWorkspace, validateProject } from "./projects.mjs";
9
+ import { readSendState } from "./app-server.mjs";
10
+ import { fetchThread } from "./observe.mjs";
11
+ import { discoverNativeEndpoint, inspectNativeEndpoint, nativeToolJson } from "./native-rpc.mjs";
12
+ import { nativeOnAppServer, inspectNativeCommandProtocol, mutationOutcomeUnknown } from "./native-command.mjs";
13
+ import { desktopMembership, initialInstructionMatches, validateMembershipCatalog } from "./creation-verification.mjs";
14
+ import { writeRecord, storePath } from "./store.mjs";
15
+ import { createStandaloneProjectThread } from "./standalone-create.mjs";
16
+
17
+ const failure = (message, code = "CREATION_FAILED") => Object.assign(new Error(message), { code });
18
+
19
+ export function creationOptions(options = {}) {
20
+ if (["cwd", "permissions", "sandbox", "approvalPolicy", "approvalsReviewer", "viaThread"].some(key => options[key] !== undefined)) throw failure("Native creation uses Desktop's project and permission settings. cwd/permission overrides and --via-thread are unsupported.", "INVALID_CREATION_OPTIONS");
21
+ for (const key of ["title", "model"]) if (options[key] !== undefined && (typeof options[key] !== "string" || !options[key].trim())) throw failure(`${key} must not be empty.`, "INVALID_CREATION_OPTIONS");
22
+ if (options.thinking !== undefined && !["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(options.thinking)) throw failure("Invalid --thinking.", "INVALID_CREATION_OPTIONS");
23
+ const timeoutMs = options.timeoutMs ?? 120000;
24
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) throw failure("--timeout-ms must be 1000..600000.", "INVALID_CREATION_OPTIONS");
25
+ return { timeoutMs, ...Object.fromEntries(["title", "model", "thinking"].filter(key => options[key] !== undefined).map(key => [key, options[key]])) };
26
+ }
27
+
28
+ export function validateNativeCreationCatalog(catalog) {
29
+ const membership = validateMembershipCatalog(catalog);
30
+ const schemas = ["list_projects", "create_thread"].map(name => catalog?.tools?.find(tool => tool.name === name && tool.namespace === "codex_app")?.inputSchema);
31
+ const creation = schemas[1], project = creation?.properties?.target?.anyOf?.find(target => target.properties?.type?.enum?.includes("project"));
32
+ const local = project?.properties?.environment?.anyOf?.find(environment => environment.properties?.type?.enum?.includes("local"));
33
+ if (schemas[0]?.type !== "object" || creation?.properties?.prompt?.type !== "string" || !creation.required?.includes("prompt") || !creation.required.includes("target") || project?.properties?.projectId?.type !== "string" || !project.required?.includes("projectId") || !local) throw failure("Desktop native tools do not expose the required local creation schema.", "NATIVE_CREATION_UNSUPPORTED");
34
+ return { status: "verified", source: "desktop-native-schema", schema_sha256: createHash("sha256").update(JSON.stringify([...schemas, membership])).digest("hex") };
35
+ }
36
+
37
+ export async function createProjectThread(selector, prompt, options = {}, dependencies = {}) {
38
+ const settings = creationOptions(options);
39
+ if (typeof selector !== "string" || !selector.trim()) throw failure("--project is required.", "PROJECT_REQUIRED");
40
+ if (typeof prompt !== "string" || !prompt.trim()) throw failure("An initial instruction is required.", "INITIAL_INSTRUCTION_REQUIRED");
41
+ // An environment variable must not silently turn standalone user work into
42
+ // delegated work or inherit another task's permissions.
43
+ if (options.contextThread === undefined) return createStandaloneProjectThread(selector, prompt, { ...settings, dryRun: !!options.dryRun }, dependencies);
44
+ const {
45
+ discover = discoverRuntime, connect = RpcClient.connect, aliases = projectAliases,
46
+ endpoint = discoverNativeEndpoint, inspectEndpoint = inspectNativeEndpoint, inspectCommand = inspectNativeCommandProtocol, native,
47
+ workspace = projectWorkspace, caller = readSendState, history = fetchThread,
48
+ membership = desktopMembership, write = writeRecord, sleep = delay,
49
+ } = dependencies;
50
+ const sourceId = options.contextThread;
51
+ const contextId = normalizeThreadId(sourceId), attemptId = randomUUID();
52
+ let client, receipt, storage, dispatched = false, accepted = false;
53
+ try {
54
+ const { paths, state } = await discover(); storage = { home: paths.home };
55
+ assertRuntimeOperation(state, "read");
56
+ const nativeEndpoint = await endpoint(state);
57
+ const commandProtocol = await inspectCommand(state);
58
+ client = compatibleClient(await connect(paths.socket, { timeoutMs: settings.timeoutMs + 5000 }), state);
59
+ const invokeNative = native ?? ((...args) => nativeOnAppServer(client, ...args));
60
+ const source = await caller(client, contextId), turn = source.turns.at(-1);
61
+ if (!turn?.id || source.ephemeral || source.environments?.some(environment => environment.environmentId !== "local")) throw failure("A local, persisted caller chat with a stored turn is required.", "CREATION_CONTEXT_INVALID");
62
+ const context = { threadId: contextId, turnId: turn.id }, nativeOptions = { timeoutMs: settings.timeoutMs };
63
+ const protocol = { ...validateNativeCreationCatalog(await invokeNative(nativeEndpoint, "catalog", undefined, undefined, nativeOptions)), command: commandProtocol };
64
+ const mapping = await aliases(paths.home), project = resolveProject(await projectsOnClient(client), selector, mapping), scope = await workspace(project);
65
+ const listNative = async () => {
66
+ const inventory = nativeToolJson(await invokeNative(nativeEndpoint, "list_projects", {}, context, nativeOptions));
67
+ if (!Array.isArray(inventory.projects)) throw failure("Desktop returned an invalid project catalog.", "NATIVE_PROTOCOL_ERROR");
68
+ const matches = inventory.projects.filter(item => item.projectKind === "local" && item.hostId === "local" && (mapping[item.projectId] === project.id || item.projectId === project.id));
69
+ if (matches.length !== 1 || matches[0].label !== project.name || matches[0].path !== project.roots[0].path) throw failure("Selected App Server project does not match exactly one native Desktop project.", "DESKTOP_PROJECT_MAPPING_UNVERIFIED");
70
+ return matches[0];
71
+ };
72
+ const selected = await listNative();
73
+ const check = async () => {
74
+ const current = await discover();
75
+ if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || current.state.cli_version !== state.cli_version) throw failure("Runtime changed. Inspect the receipt before retrying.", "RUNTIME_CHANGED");
76
+ await inspectEndpoint(nativeEndpoint.socketPath, nativeEndpoint.fingerprint);
77
+ const live = validateProject((await client.request("project/read", { projectId: project.id })).project);
78
+ if (!isDeepStrictEqual(live.roots, project.roots) || live.name !== project.name) throw failure("Project source folders changed before creation.", "PROJECT_CHANGED");
79
+ if ((await listNative()).projectId !== selected.projectId) throw failure("Native project changed before creation.", "PROJECT_CHANGED");
80
+ };
81
+ await check();
82
+ receipt = { schema: 2, creation_mode: "delegated", attempt_id: attemptId, native_call_id: attemptId, project_id: project.id, desktop_project_id: selected.projectId, project_name: project.name,
83
+ context_thread_id: contextId, context_turn_id: turn.id, cwd: scope.cwd, runtime_workspace_roots: scope.runtimeWorkspaceRoots,
84
+ title: settings.title ?? null, backend: "desktop-native-rpc", connection_mode: "shared", protocol, creation_status: "not_created", sent: false, delivery_status: "not_sent",
85
+ verification_status: "not_started", message_characters: [...prompt].length, prompt_sha256: createHash("sha256").update(prompt).digest("hex"), dry_run: !!options.dryRun };
86
+ if (options.dryRun) return receipt;
87
+ receipt.receipt_path = storePath("creations", attemptId, paths.home);
88
+ await write("creations", attemptId, { ...receipt, creation_status: "dispatching" }, storage);
89
+ await check();
90
+ dispatched = true;
91
+ const args = { prompt, target: { type: "project", projectId: selected.projectId, environment: { type: "local" } }, ...Object.fromEntries(["title", "model", "thinking"].filter(key => settings[key] !== undefined).map(key => [key, settings[key]])) };
92
+ const raw = await invokeNative(nativeEndpoint, "create_thread", args, context, { ...nativeOptions, callId: attemptId });
93
+ if (raw.native_request_cleanup_required) receipt.native_request_cleanup_required = true;
94
+ const result = nativeToolJson(raw);
95
+ const id = result.threadId ?? (result.status === "created" ? result.conversationId : undefined);
96
+ if (!id) throw Object.assign(failure("Native creation returned no child ID. Do not retry automatically.", "NATIVE_CREATION_UNKNOWN"), { uncertain: true });
97
+ const threadId = normalizeThreadId(id);
98
+ accepted = !!result.threadId || result.firstTurn?.status === "accepted";
99
+ Object.assign(receipt, { thread_id: threadId, deep_link: threadDeepLink(threadId), creation_status: "created", sent: accepted ? true : null, delivery_status: accepted ? "accepted" : "unknown" });
100
+ await write("creations", attemptId, receipt, storage);
101
+ if (!accepted) throw failure("Child exists but its initial turn was not confirmed. Inspect the child before resending.", "INITIAL_TURN_UNCONFIRMED");
102
+ const thread = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
103
+ if (thread?.id !== threadId || thread.cwd !== scope.cwd || !await membership(invokeNative, nativeEndpoint, threadId, selected.projectId, context, nativeOptions)) throw failure("Created child did not confirm the selected Desktop project.", "DESKTOP_MEMBERSHIP_MISMATCH");
104
+ const roots = thread.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
105
+ if (!Array.isArray(roots) || roots.length !== scope.runtimeWorkspaceRoots.length || !scope.runtimeWorkspaceRoots.every(root => roots.includes(root))) throw failure("Created child did not confirm all project source folders.", "CREATION_SCOPE_MISMATCH");
106
+ let saved = await history(client, threadId), named = thread;
107
+ // Native acceptance can precede the first input's persisted history item.
108
+ // Wait only for read-side materialization; never repeat create or turn/start.
109
+ const ready = () => saved.turns?.[0]?.items?.some(item => item.type === "userMessage" || (item.type === "functionCallOutput" && item.namespace === "codex_app" && item.name === "create_thread"));
110
+ for (let retry = 0; retry < 20 && (!ready() || (settings.title !== undefined && named.name == null)); retry++) {
111
+ await sleep(250);
112
+ saved = await history(client, threadId);
113
+ named = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
114
+ }
115
+ if (!initialInstructionMatches(saved, prompt, contextId) || (settings.title !== undefined && named.name !== settings.title)) throw failure("Created child did not preserve the initial instruction/title.", "CREATION_INSTRUCTION_MISMATCH");
116
+ if (thread.projectId !== project.id) await client.request("thread/metadata/update", { threadId, projectId: project.id }, { mutation: true });
117
+ if ((await client.request("thread/read", { threadId, includeTurns: false })).thread?.projectId !== project.id) throw failure("Canonical project assignment was not confirmed.", "CREATION_SCOPE_MISMATCH");
118
+ Object.assign(receipt, { verification_status: "verified", desktop_project_membership: "verified", app_server_project_membership: "verified", initial_instruction: "verified", desktop_subscription: "native-owned" });
119
+ try { await write("creations", attemptId, receipt, storage); } catch { receipt.receipt_update_required = true; }
120
+ return receipt;
121
+ } catch (error) {
122
+ const created = !!receipt?.thread_id, unknown = !accepted && (created || (dispatched && mutationOutcomeUnknown(error)));
123
+ Object.assign(error, { attempt_id: attemptId, native_call_id: attemptId, thread_id: receipt?.thread_id, receipt_path: receipt?.receipt_path,
124
+ creation_status: created ? "created" : unknown ? "unknown" : "not_created", sent: accepted ? true : unknown ? null : false, delivery_status: accepted ? "accepted" : unknown ? "unknown" : "not_sent" });
125
+ if (receipt?.receipt_path) try { await write("creations", attemptId, { ...receipt, creation_status: error.creation_status, sent: error.sent, delivery_status: error.delivery_status, verification_status: "failed", error_code: error.code ?? "CREATION_FAILED" }, storage); } catch { error.receipt_update_required = true; }
126
+ throw error;
127
+ } finally { try { client?.close(); } catch { if (accepted && receipt) receipt.runtime_cleanup_required = true; } }
128
+ }
package/src/thread-id.mjs CHANGED
@@ -1,11 +1,12 @@
1
+ import { targetHost, validateHostId } from "./target.mjs";
1
2
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3
 
3
- export function normalizeThreadId(input) {
4
+ export function parseThreadTarget(input) {
4
5
  if (typeof input !== "string" || input.trim() === "") {
5
6
  throw new Error("Thread ID is required.");
6
7
  }
7
8
 
8
- let value = input.trim();
9
+ let value = input.trim(), hostId;
9
10
  if (value.startsWith("codex://")) {
10
11
  let url;
11
12
  try {
@@ -16,20 +17,34 @@ export function normalizeThreadId(input) {
16
17
  if (url.hostname !== "threads") {
17
18
  throw new Error(`Expected codex://threads/<thread-id>: ${input}`);
18
19
  }
20
+ const hosts = url.searchParams.getAll("hostId");
21
+ if (hosts.length > 1) throw new Error("A thread link must have only one hostId.");
22
+ if (hosts.length) hostId = validateHostId(hosts[0]);
19
23
  value = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
20
24
  }
21
25
 
22
26
  if (!UUID_PATTERN.test(value)) {
23
27
  throw new Error(`Invalid local Codex thread ID: ${input}`);
24
28
  }
25
- return value.toLowerCase();
29
+ return { threadId: value.toLowerCase(), hostId };
30
+ }
31
+
32
+ export function normalizeThreadId(input) {
33
+ const { threadId, hostId } = parseThreadTarget(input);
34
+ if (hostId !== undefined && hostId !== targetHost()) {
35
+ throw Object.assign(new Error("Thread link belongs to another host. Select its host with --host or the host-aware CLI entry point."), { code: "HOST_MISMATCH" });
36
+ }
37
+ return threadId;
26
38
  }
27
39
 
28
40
  export function threadDeepLink(threadId) {
29
- return `codex://threads/${normalizeThreadId(threadId)}`;
41
+ const target = parseThreadTarget(threadId), host = target.hostId ?? targetHost();
42
+ const link = `codex://threads/${target.threadId}`;
43
+ return host === "local" ? link : `${link}?${new URLSearchParams({ hostId: host })}`;
30
44
  }
31
45
 
32
46
  export function threadComposerDeepLink(threadId, prompt = "") {
33
- const query = new URLSearchParams({ prompt });
34
- return `${threadDeepLink(threadId)}?${query}`;
47
+ const link = new URL(threadDeepLink(threadId));
48
+ link.searchParams.set("prompt", prompt);
49
+ return link.toString();
35
50
  }
@@ -0,0 +1,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { discoverRuntime } from "./runtime.mjs";
4
+ import { RpcClient } from "./rpc.mjs";
5
+ import { assertRuntimeOperation, compatibleClient } from "./compatibility.mjs";
6
+ import { normalizeThreadId } from "./thread-id.mjs";
7
+ import { projectAliases, projectWorkspace, validateProject } from "./projects.mjs";
8
+ import { fetchThread } from "./observe.mjs";
9
+ import { discoverNativeEndpoint, inspectNativeEndpoint } from "./native-rpc.mjs";
10
+ import { nativeOnAppServer, inspectNativeCommandProtocol } from "./native-command.mjs";
11
+ import { desktopMembership, initialInstructionText, validateMembershipCatalog } from "./creation-verification.mjs";
12
+ import { readRecord, writeRecord, storePath, withStoreLock } from "./store.mjs";
13
+
14
+ const failure = (message, code = "CREATION_VERIFICATION_FAILED") => Object.assign(new Error(message), { code });
15
+ const sameRoots = (actual, expected) => Array.isArray(actual) && actual.length === expected.length && expected.every(root => actual.includes(root));
16
+ const text = value => typeof value === "string" && !!value.trim();
17
+
18
+ function validateReceipt(receipt, attemptId) {
19
+ if (!receipt || receipt.attempt_id !== attemptId || receipt.dry_run
20
+ || !((receipt.schema === 3 && receipt.creation_mode === "standalone") || (receipt.schema === 2 && receipt.creation_mode === "delegated"))
21
+ || receipt.creation_status !== "created" || receipt.sent !== true || receipt.delivery_status !== "accepted"
22
+ || !text(receipt.thread_id) || normalizeThreadId(receipt.thread_id) !== receipt.thread_id
23
+ || !text(receipt.project_id) || !text(receipt.desktop_project_id) || !text(receipt.cwd) || !path.isAbsolute(receipt.cwd)
24
+ || !Array.isArray(receipt.runtime_workspace_roots) || !receipt.runtime_workspace_roots.length
25
+ || receipt.runtime_workspace_roots.some(root => !text(root) || !path.isAbsolute(root))
26
+ || new Set(receipt.runtime_workspace_roots).size !== receipt.runtime_workspace_roots.length
27
+ || !/^[a-f0-9]{64}$/.test(receipt.prompt_sha256 ?? "") || !Number.isSafeInteger(receipt.message_characters) || receipt.message_characters < 1
28
+ || (receipt.creation_mode === "standalone" ? !text(receipt.turn_id) || !text(receipt.title) : !text(receipt.context_thread_id))) {
29
+ throw failure("A valid created/accepted receipt is required. Inspect the existing task; this command never creates or resends.", "CREATION_RECEIPT_UNVERIFIED");
30
+ }
31
+ }
32
+
33
+ // Recovery of a known created/accepted attempt. Only the codexteer receipt is
34
+ // written. No create, subscribe, move, metadata update, start or steer API is
35
+ // available in this operation; native access is restricted to two read tools.
36
+ export async function verifyProjectThread(attempt, { timeoutMs = 120000 } = {}, {
37
+ discover = discoverRuntime, connect = RpcClient.connect, aliases = projectAliases,
38
+ endpoint = discoverNativeEndpoint, inspectEndpoint = inspectNativeEndpoint, inspectCommand = inspectNativeCommandProtocol, native,
39
+ workspace = projectWorkspace, history = fetchThread, membership = desktopMembership,
40
+ read = readRecord, write = writeRecord, lock = withStoreLock,
41
+ } = {}) {
42
+ const attemptId = normalizeThreadId(attempt);
43
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) throw failure("--timeout-ms must be 1000..600000.", "INVALID_CREATION_OPTIONS");
44
+ const { paths, state } = await discover(), storage = { home: paths.home };
45
+ assertRuntimeOperation(state, "read");
46
+ return lock(`creation-${attemptId}`, async () => {
47
+ const receipt = await read("creations", attemptId, storage);
48
+ validateReceipt(receipt, attemptId);
49
+ let client;
50
+ try {
51
+ const nativeEndpoint = await endpoint(state);
52
+ await inspectCommand(state);
53
+ client = compatibleClient(await connect(paths.socket, { timeoutMs: timeoutMs + 5000 }), state);
54
+ const invoke = native ?? ((...args) => nativeOnAppServer(client, ...args));
55
+ const readNative = (...args) => {
56
+ if (!["catalog", "list_threads"].includes(args[1])) throw failure("Verification permits only native reads.");
57
+ return invoke(...args);
58
+ };
59
+ validateMembershipCatalog(await readNative(nativeEndpoint, "catalog", undefined, undefined, { timeoutMs }));
60
+ const project = validateProject((await client.request("project/read", { projectId: receipt.project_id })).project);
61
+ const scope = await workspace(project, receipt.cwd);
62
+ const thread = (await client.request("thread/read", { threadId: receipt.thread_id, includeTurns: false })).thread;
63
+ const saved = await history(client, receipt.thread_id), first = saved.turns?.[0];
64
+ const instruction = initialInstructionText(saved, receipt.creation_mode === "delegated" ? receipt.context_thread_id : undefined);
65
+ const roots = thread?.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
66
+ const checks = {
67
+ thread_id: thread?.id === receipt.thread_id,
68
+ project_mapping: (await aliases(paths.home))[receipt.desktop_project_id] === receipt.project_id,
69
+ project_roots: project.id === receipt.project_id && sameRoots(scope.runtimeWorkspaceRoots, receipt.runtime_workspace_roots),
70
+ canonical_project_id: thread?.projectId === receipt.project_id,
71
+ cwd: thread?.cwd === receipt.cwd && scope.cwd === receipt.cwd,
72
+ roots: sameRoots(roots, receipt.runtime_workspace_roots),
73
+ title: receipt.title == null || thread?.name === receipt.title,
74
+ initial_turn: text(first?.id) && (receipt.creation_mode !== "standalone" || first.id === receipt.turn_id),
75
+ initial_instruction: typeof instruction === "string" && [...instruction].length === receipt.message_characters
76
+ && createHash("sha256").update(instruction).digest("hex") === receipt.prompt_sha256,
77
+ desktop_membership: text(first?.id) && await membership(readNative, nativeEndpoint, receipt.thread_id, receipt.desktop_project_id,
78
+ { threadId: receipt.thread_id, turnId: first.id }, { timeoutMs }),
79
+ };
80
+ const current = await discover(); assertRuntimeOperation(current.state, "read");
81
+ if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || current.paths.home !== paths.home || current.state.cli_version !== state.cli_version) {
82
+ throw failure("Runtime changed during verification. No task was changed.", "RUNTIME_CHANGED");
83
+ }
84
+ await inspectEndpoint(nativeEndpoint.socketPath, nativeEndpoint.fingerprint);
85
+ const verified = Object.values(checks).every(value => value === true), status = verified ? "verified" : "failed";
86
+ const updated = { ...receipt, receipt_path: storePath("creations", attemptId, paths.home), verification_status: status,
87
+ verification_history: [...(receipt.verification_history ?? []), { checked_at: new Date().toISOString(), status, checks,
88
+ source: "desktop-native-list_threads", desktop_reads_only: true,
89
+ previous_status: receipt.verification_status, ...(receipt.error_code ? { previous_error_code: receipt.error_code } : {}) }] };
90
+ if (verified) {
91
+ Object.assign(updated, { desktop_project_membership: "verified", app_server_project_membership: "verified", initial_instruction: "verified" });
92
+ delete updated.error_code;
93
+ } else {
94
+ updated.error_code = "CREATION_VERIFICATION_FAILED";
95
+ // Never leave earlier certification flags on a failed fresh check.
96
+ delete updated.desktop_project_membership; delete updated.app_server_project_membership; delete updated.initial_instruction;
97
+ }
98
+ try { await write("creations", attemptId, updated, storage); }
99
+ catch { throw Object.assign(failure("Verification finished but its receipt could not be saved. Do not recreate or resend.", "CREATION_RECEIPT_UPDATE_FAILED"), { receipt_update_required: true, verification_checks: checks }); }
100
+ if (!verified) throw Object.assign(failure("Existing task did not pass all creation checks. No task was changed; do not recreate or resend."), { verification_checks: checks });
101
+ return updated;
102
+ } catch (error) {
103
+ Object.assign(error, { attempt_id: attemptId, thread_id: receipt.thread_id, creation_status: receipt.creation_status,
104
+ sent: receipt.sent, delivery_status: receipt.delivery_status, receipt_path: storePath("creations", attemptId, paths.home) });
105
+ throw error;
106
+ } finally { client?.close(); }
107
+ }, storage);
108
+ }
@@ -0,0 +1,228 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { copyFile, lstat, mkdir, open, readdir, realpath, rename } from "node:fs/promises";
4
+ import { execFileSync } from "node:child_process";
5
+ import path from "node:path";
6
+ import { codexHome } from "./runtime.mjs";
7
+ import { normalizeThreadId } from "./thread-id.mjs";
8
+ import { withStoreLock } from "./store.mjs";
9
+ import { BUNDLED_CLI } from "./wrapper.mjs";
10
+
11
+ // This is an offline rollout-format repair, not an App Server interrupt API.
12
+ // Only enable a version after the isolated replay test has verified its parser.
13
+ export const REPAIR_CLI_VERSIONS = Object.freeze(["0.162.0-alpha.17.2", "0.164.0-alpha.11.1"]);
14
+ const failure = (message, code) => Object.assign(new Error(message), { code });
15
+ const invalid = message => failure(message, "REPAIR_HISTORY_UNSUPPORTED");
16
+ const stamp = s => [s.dev, s.ino, s.size, s.mtimeMs, s.ctimeMs].join(":");
17
+ const identity = s => [s.dev, s.ino, s.size, s.uid, s.gid, s.mode, s.nlink].join(":");
18
+ const uuid = value => typeof value === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
19
+
20
+ async function owned(file, directory = false) {
21
+ const s = await lstat(file);
22
+ if (s.isSymbolicLink() || s.uid !== process.getuid() || (s.mode & 0o022)
23
+ || (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1)) {
24
+ throw failure("Unsafe history path, ownership, permissions, or hard link.", "REPAIR_UNSAFE_PATH");
25
+ }
26
+ return s;
27
+ }
28
+
29
+ export async function findRepairRollout(home, threadId) {
30
+ const found = [];
31
+ async function walk(dir) {
32
+ await owned(dir, true);
33
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
34
+ const file = path.join(dir, entry.name);
35
+ if (entry.name.endsWith(`-${threadId}.jsonl`)) { await owned(file); found.push(file); }
36
+ else if (entry.isDirectory()) await walk(file);
37
+ else if (entry.isSymbolicLink()) throw failure("A history directory contains a symbolic link; inspect it before repair.", "REPAIR_UNSAFE_PATH");
38
+ }
39
+ }
40
+ for (const root of ["sessions", "archived_sessions"]) {
41
+ try { await walk(path.join(home, root)); } catch (e) { if (e.code !== "ENOENT") throw e; }
42
+ }
43
+ if (found.length !== 1) throw failure(`Expected one rollout for this thread; found ${found.length}.`, "REPAIR_ROLLOUT_AMBIGUOUS");
44
+ return found[0];
45
+ }
46
+
47
+ // Stream JSONL, parsing each record transiently. Return turn metadata and hashes,
48
+ // without including conversation item contents in the inspection report.
49
+ export async function scanRepairRollout(file, threadId) {
50
+ const before = await owned(file), handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
51
+ const hash = createHash("sha256"), turns = new Map();
52
+ let carry = Buffer.alloc(0), ordinal = 0, metadata, lastRoot;
53
+ const parse = line => {
54
+ let r;
55
+ try { r = JSON.parse(line.toString("utf8")); } catch { throw invalid("History contains invalid JSON; nothing can be repaired automatically."); }
56
+ if (r.ordinal !== ordinal++) throw invalid("History ordinals are missing or discontinuous.");
57
+ if (ordinal === 1) {
58
+ if (r.type !== "session_meta" || r.payload?.id !== threadId || r.payload?.history_mode !== "paginated") throw invalid("Repair requires this thread's paginated rollout metadata.");
59
+ metadata = r.payload;
60
+ } else if (r.type === "session_meta") throw invalid("Multiple session metadata records are not supported.");
61
+ if (r.type !== "event_msg") return;
62
+ const p = r.payload;
63
+ if (p?.type === "task_started") {
64
+ if (!uuid(p.turn_id) || turns.has(p.turn_id)) throw invalid("Repeated or unidentified turn starts cannot be repaired automatically.");
65
+ const independent = (p.root_turn_id == null || p.root_turn_id === p.turn_id)
66
+ && (!p.turn_attribution || (p.turn_attribution.turn_id === p.turn_id && (p.turn_attribution.root_turn_id == null || p.turn_attribution.root_turn_id === p.turn_id) && p.turn_attribution.parent_turn_id == null));
67
+ const turn = { turn_id: p.turn_id, start_ordinal: r.ordinal, started_at: p.started_at ?? null, successor_id: null, terminal: null, independent };
68
+ if (independent) { if (lastRoot) lastRoot.successor_id = p.turn_id; lastRoot = turn; }
69
+ turns.set(p.turn_id, turn);
70
+ } else if (["task_complete", "turn_aborted"].includes(p?.type)) {
71
+ const t = turns.get(p.turn_id);
72
+ if (!t || t.terminal) throw invalid("Unmatched or repeated terminal records need manual inspection.");
73
+ t.terminal = p.type;
74
+ }
75
+ };
76
+ try {
77
+ if (stamp(await handle.stat()) !== stamp(before)) throw failure("History changed before inspection.", "REPAIR_HISTORY_CHANGED");
78
+ for await (const chunk of handle.createReadStream({ autoClose: false, highWaterMark: 1024 * 1024 })) {
79
+ hash.update(chunk);
80
+ const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
81
+ let from = 0, end;
82
+ while ((end = buffer.indexOf(10, from)) !== -1) {
83
+ if (end - from > 64 * 1024 * 1024) throw invalid("A history record exceeds the repair size limit.");
84
+ parse(buffer.subarray(from, end)); from = end + 1;
85
+ }
86
+ carry = Buffer.from(buffer.subarray(from));
87
+ if (carry.length > 64 * 1024 * 1024) throw invalid("A history record exceeds the repair size limit.");
88
+ }
89
+ if (carry.length || !metadata) throw invalid("History is empty or ends with an incomplete record. Nothing was changed.");
90
+ if (stamp(await handle.stat()) !== stamp(before) || stamp(await owned(file)) !== stamp(before)) throw failure("History changed during inspection. Retry after work has stopped.", "REPAIR_HISTORY_CHANGED");
91
+ const unclosed = [...turns.values()].filter(t => !t.terminal);
92
+ return { file, thread_id: threadId, source: before, sha256: hash.copy().digest("hex"), hash,
93
+ next_ordinal: ordinal, candidates: unclosed.filter(t => t.independent && t.successor_id), unresolved_latest: unclosed.filter(t => !t.successor_id) };
94
+ } finally { await handle.close(); }
95
+ }
96
+
97
+ export function repairProcessBlockers(stdout) {
98
+ if (!stdout.trim()) throw failure("Empty process inventory; repair is blocked.", "REPAIR_OFFLINE_UNVERIFIED");
99
+ return stdout.split("\n").filter(Boolean).flatMap(line => {
100
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
101
+ if (!match) throw failure("Cannot parse process inventory; repair is blocked.", "REPAIR_OFFLINE_UNVERIFIED");
102
+ const name = path.basename(match[2].trim());
103
+ return /^(?:codex(?:[-_].*)?|chatgpt|codex desktop)$/i.test(name) ? [{ pid: Number(match[1]), executable: name }] : [];
104
+ });
105
+ }
106
+
107
+ export function requireRepairOffline() {
108
+ if (process.platform !== "darwin") throw failure("Offline repair is currently verified on macOS only.", "REPAIR_OFFLINE_UNVERIFIED");
109
+ let output;
110
+ try { output = execFileSync("/bin/ps", ["-axo", "pid=,comm="], { encoding: "utf8", timeout: 5000, maxBuffer: 4 * 1024 * 1024 }); }
111
+ catch { throw failure("Cannot inspect processes. Run repair from an external terminal after quitting Codex.", "REPAIR_OFFLINE_UNVERIFIED"); }
112
+ const blockers = repairProcessBlockers(output);
113
+ if (blockers.length) throw failure(`Quit Codex/ChatGPT and all Codex CLI/app-server processes first (${blockers.map(p => `${p.executable} PID ${p.pid}`).join(", ")}). Run --apply from an external terminal; this command does not stop them.`, "REPAIR_REQUIRES_OFFLINE");
114
+ }
115
+
116
+ export function repairRuntimeVersion() {
117
+ let version;
118
+ try { version = execFileSync(BUNDLED_CLI, ["--version"], { encoding: "utf8", timeout: 5000 }).trim().replace(/^codex-cli /, ""); }
119
+ catch { throw failure("Cannot verify the bundled Codex parser version.", "REPAIR_RUNTIME_UNVERIFIED"); }
120
+ if (!REPAIR_CLI_VERSIONS.includes(version)) throw failure(`Offline repair has not been verified with Codex ${version}. No history was changed.`, "REPAIR_RUNTIME_UNVERIFIED");
121
+ return version;
122
+ }
123
+
124
+ function report(scan, apply) {
125
+ return { thread_id: scan.thread_id, rollout_path: scan.file, mode: apply ? "apply" : "dry-run", status: scan.candidates.length ? "residual_candidates" : "no_residual_candidates",
126
+ candidates: scan.candidates, unresolved_latest: scan.unresolved_latest, source_sha256: scan.sha256,
127
+ history_changed: false, verification: "persisted-rollout-only", requires_offline: true,
128
+ note: "Candidates have a later top-level turn start and no terminal record. The latest unfinished turn is never repaired. Live API idle/interrupted alone does not prove persisted repair." };
129
+ }
130
+
131
+ async function privateDirectory(dir) {
132
+ await mkdir(dir, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
133
+ const s = await owned(dir, true);
134
+ if (s.mode & 0o077) throw failure("Repair backup directories must be private (0700).", "REPAIR_UNSAFE_PATH");
135
+ }
136
+
137
+ async function saveManifest(file, data) {
138
+ const temp = `${file}.tmp`;
139
+ const h = await open(temp, "wx", 0o600);
140
+ try { await h.writeFile(JSON.stringify(data, null, 2) + "\n"); await h.sync(); } finally { await h.close(); }
141
+ await rename(temp, file);
142
+ }
143
+
144
+ // Opening a writable descriptor can coincide with metadata-only changes (for
145
+ // example filesystem/provenance bookkeeping). A timestamp mismatch alone does
146
+ // not establish a content change. Hash the exact descriptor we will append to,
147
+ // while requiring stable identity, permissions and metadata throughout the read.
148
+ export async function verifyRepairAppendHandle(handle, file, scan) {
149
+ const before = await handle.stat(), pathBefore = await owned(file);
150
+ const details = { expected: stamp(scan.source), descriptor: stamp(before), path: stamp(pathBefore) };
151
+ const changed = message => Object.assign(failure(message, "REPAIR_HISTORY_CHANGED"), { repair_stat: details });
152
+ if (identity(before) !== identity(scan.source) || identity(pathBefore) !== identity(scan.source) || stamp(before) !== stamp(pathBefore)) {
153
+ throw changed("History identity, size or permissions changed before append.");
154
+ }
155
+ const hash = createHash("sha256");
156
+ for await (const chunk of handle.createReadStream({ start: 0, autoClose: false, highWaterMark: 1024 * 1024 })) hash.update(chunk);
157
+ if (hash.digest("hex") !== scan.sha256) throw changed("History contents changed before append.");
158
+ const after = await handle.stat(), pathAfter = await owned(file);
159
+ if (stamp(after) !== stamp(before) || stamp(pathAfter) !== stamp(before) || identity(after) !== identity(before) || identity(pathAfter) !== identity(before)) {
160
+ throw changed("History changed while verifying the append descriptor.");
161
+ }
162
+ return { source: after, metadata_revalidated: stamp(before) !== stamp(scan.source) };
163
+ }
164
+
165
+ const openAppendHandle = file => open(file, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW);
166
+
167
+ export async function repairTurns(threadInput, { apply = false, home = codexHome() } = {},
168
+ { offline = requireRepairOffline, runtimeVersion = repairRuntimeVersion, now = () => new Date(), openForAppend = openAppendHandle } = {}) {
169
+ const threadId = normalizeThreadId(threadInput);
170
+ home = await realpath(home); await owned(home, true);
171
+ if (apply) { await offline(); await runtimeVersion(); }
172
+ const work = async () => {
173
+ const file = await findRepairRollout(home, threadId), scan = await scanRepairRollout(file, threadId), data = report(scan, apply);
174
+ if (!apply || !scan.candidates.length) return data;
175
+ // The trailing live/unfinished turn needs its own proper shutdown first.
176
+ if (scan.unresolved_latest.length) throw failure("The latest turn also lacks a terminal record. Stop it normally before repairing older turns.", "REPAIR_LATEST_UNFINISHED");
177
+ await offline();
178
+ const version = await runtimeVersion(), repairedAt = now().toISOString();
179
+ const append = Buffer.from(scan.candidates.map((t, i) => JSON.stringify({ timestamp: repairedAt, ordinal: scan.next_ordinal + i, type: "event_msg",
180
+ payload: { type: "turn_aborted", turn_id: t.turn_id, root_turn_id: t.turn_id, reason: "interrupted", ...(t.started_at == null ? {} : { started_at: t.started_at }) },
181
+ })).join("\n") + "\n");
182
+ // Do not invent completed_at/duration_ms: the historical stop time is unknown.
183
+ const expectedHash = scan.hash.copy().update(append).digest("hex");
184
+ const root = path.join(home, "codex-steer"), backups = path.join(root, "turn-repairs");
185
+ await privateDirectory(root); await privateDirectory(backups);
186
+ const directory = path.join(backups, randomUUID()); await privateDirectory(directory);
187
+ const backup = path.join(directory, "rollout.jsonl"), manifestFile = path.join(directory, "repair.json");
188
+ await copyFile(file, backup, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
189
+ const backupHandle = await open(backup, "r+");
190
+ try { await backupHandle.chmod(0o600); await backupHandle.sync(); } finally { await backupHandle.close(); }
191
+ const saved = await scanRepairRollout(backup, threadId);
192
+ if (saved.sha256 !== scan.sha256) throw failure("Backup does not match inspected history. Original history was not changed.", "REPAIR_HISTORY_CHANGED");
193
+ const manifest = { schema: 1, thread_id: threadId, rollout_path: file, backup_path: backup, repaired_at: repairedAt, cli_version: version,
194
+ original_sha256: scan.sha256, expected_sha256: expectedHash, original_bytes: scan.source.size, append_utf8: append.toString("utf8"),
195
+ repaired_turn_ids: scan.candidates.map(t => t.turn_id), status: "prepared" };
196
+ await saveManifest(manifestFile, manifest);
197
+ const dirHandle = await open(directory, "r"); try { await dirHandle.sync(); } finally { await dirHandle.close(); }
198
+ let changed = false;
199
+ try {
200
+ await offline();
201
+ if (await runtimeVersion() !== version || stamp(await owned(file)) !== stamp(scan.source)) throw failure("Runtime or history changed before repair.", "REPAIR_HISTORY_CHANGED");
202
+ const h = await openForAppend(file);
203
+ try {
204
+ const verified = await verifyRepairAppendHandle(h, file, scan);
205
+ // Hashing a large rollout takes time. Recheck process liveness and the
206
+ // exact file immediately before the write, not just before the hash.
207
+ await offline();
208
+ if (stamp(await h.stat()) !== stamp(verified.source) || stamp(await owned(file)) !== stamp(verified.source)) throw failure("History changed after append verification.", "REPAIR_HISTORY_CHANGED");
209
+ manifest.append_descriptor_sha256_verified = true;
210
+ manifest.metadata_revalidated = verified.metadata_revalidated;
211
+ changed = true; // A partial write is also a mutation and must be reported.
212
+ await h.writeFile(append); await h.sync();
213
+ } finally { await h.close(); }
214
+ await offline();
215
+ const after = await scanRepairRollout(file, threadId);
216
+ if (after.sha256 !== expectedHash || after.candidates.length || after.unresolved_latest.length) throw failure("Post-repair history differs from the expected append. Inspect the backup and history before restarting Codex.", "REPAIR_VERIFICATION_FAILED");
217
+ manifest.status = "verified"; await saveManifest(manifestFile, manifest);
218
+ return { ...data, status: "repaired", history_changed: true, repaired_turn_ids: manifest.repaired_turn_ids,
219
+ backup_path: backup, receipt_path: manifestFile, repaired_sha256: expectedHash, verification: "append-and-sha256-verified",
220
+ note: "Missing terminal records were appended; prior bytes and ordinals are unchanged. Codex will project them on reload. Desktop rendering and the next live turn still need verification. Read/watch cursors should be refreshed." };
221
+ } catch (e) {
222
+ e.history_changed = changed; e.backup_path = backup; e.receipt_path = manifestFile;
223
+ e.message += ` Backup: ${backup}. Do not retry or overwrite history blindly.`;
224
+ throw e;
225
+ }
226
+ };
227
+ return apply ? withStoreLock(`turn-repair-${threadId}`, work, { home }) : work();
228
+ }