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,18 @@
1
+ import { normalizeThreadId } from "./thread-id.mjs";
2
+ export function isDesktopCloudTask(value) {
3
+ if (typeof value !== "string") return false;
4
+ if (value.startsWith("durable_")) return true;
5
+ try { const u = new URL(value); return u.protocol === "codex:" && u.hostname === "threads" && u.searchParams.getAll("hostId").includes("durable"); } catch { return false; }
6
+ }
7
+ export function desktopCloudId(value) {
8
+ if (!isDesktopCloudTask(value)) throw Object.assign(new Error("Desktop Cloud requires a durable_ ID or a link with hostId=durable."), { code: "CLOUD_TARGET_CONFLICT" });
9
+ let uuid = value.slice(8);
10
+ if (!value.startsWith("durable_")) {
11
+ const url = new URL(value);
12
+ if (url.searchParams.getAll("hostId").length !== 1 || url.username || url.password || url.port) throw Object.assign(new Error("Ambiguous Desktop Cloud link."), { code: "CLOUD_TARGET_CONFLICT" });
13
+ uuid = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
14
+ }
15
+ return "durable_" + normalizeThreadId(uuid);
16
+ }
17
+ export const desktopCloudUuid = value => desktopCloudId(value).slice(8);
18
+ export const desktopCloudLink = value => `codex://threads/${desktopCloudUuid(value)}?hostId=durable`;
@@ -0,0 +1,148 @@
1
+ import { createHash, createPublicKey, randomBytes, randomUUID, verify } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { open } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { discoverRuntime, codexHome } from "./runtime.mjs";
8
+ import { BUNDLED_NODE } from "./wrapper.mjs";
9
+ import { discoverNativeEndpoint, nativeRpc, inspectNativeEndpoint } from "./native-rpc.mjs";
10
+ import { connectDesktopCloud } from "./desktop-cloud-api.mjs";
11
+ import { desktopCloudId, desktopCloudUuid } from "./desktop-cloud-id.mjs";
12
+ import { listProjects } from "./projects.mjs";
13
+ import { listRecords, readRecord, writeRecord, withStoreLock, storePath } from "./store.mjs";
14
+ const failure = (message, code = "DESKTOP_CLOUD_PROJECT_UNAVAILABLE", uncertain = false) => Object.assign(new Error(message), { code, uncertain });
15
+ const sha = value => createHash("sha256").update(value).digest("hex");
16
+
17
+ export async function discoverProjectApi({ discover = discoverRuntime, endpoint = discoverNativeEndpoint, run = spawnSync } = {}) {
18
+ const selected = await discover(), { state } = selected;
19
+ const owned = run("/bin/ps", ["-p", String(state.pid), "-o", "uid=", "-o", "comm="], { encoding: "utf8", timeout: 3000 });
20
+ const owner = owned.stdout?.trim().match(/^(\d+)\s+(.+)$/);
21
+ if (owned.status !== 0 || !owner || Number(owner[1]) !== process.getuid() || owner[2] !== BUNDLED_NODE || state.node_path !== BUNDLED_NODE) throw failure("Cannot verify the selected Desktop process.");
22
+ const target = await endpoint(state);
23
+ // Extract only the inherited endpoint and public verification key from this
24
+ // one owner-validated process. Never emit or persist its environment.
25
+ const processEnv = run("/bin/ps", ["eww", "-p", String(state.pid), "-o", "command="], { encoding: "utf8", timeout: 3000, maxBuffer: 1024 * 1024 });
26
+ const pipe = processEnv.stdout?.match(/(?:^| )CODEX_APP_TOOLS_PIPE_PATH=([^ \n]*)/)?.[1];
27
+ const encoded = processEnv.stdout?.match(/(?:^| )CODEX_TASK_WORKSPACE_VERIFYING_IDENTITY=([^ \n]*)/)?.[1];
28
+ if (processEnv.status !== 0 || pipe !== target.socketPath || !encoded || !/^[A-Za-z0-9+/]{40,200}={0,2}$/.test(encoded)) throw failure("The selected Desktop has no verifiable project API identity.");
29
+ let key;
30
+ try { key = createPublicKey({ key: Buffer.from(encoded, "base64"), format: "der", type: "spki" }); } catch { throw failure("Invalid Desktop project API verification key."); }
31
+ if (key.asymmetricKeyType !== "ed25519") throw failure("Unsupported Desktop project API signature.");
32
+ return { ...selected, endpoint: target, key, key_id: sha(key.export({ format: "der", type: "spki" })) };
33
+ }
34
+ export function verifyProjectReply(reply, method, params, key) {
35
+ if (!["taskProject/list", "taskProject/assign"].includes(method) || typeof reply?.payload !== "string" || Buffer.byteLength(reply.payload) > 8 * 1024 * 1024
36
+ || typeof reply.signature !== "string" || !/^[A-Za-z0-9+/]{86}==$/.test(reply.signature)) throw failure("Invalid signed project response.", "DESKTOP_CLOUD_PROJECT_PROTOCOL");
37
+ let body, valid = false;
38
+ try { valid = verify(null, Buffer.from(reply.payload), key, Buffer.from(reply.signature, "base64")); body = JSON.parse(reply.payload); } catch {}
39
+ if (!valid || body?.domain !== `codex.${method.replace("/", ".")}.v1` || !isDeepStrictEqual(body.request, params)) throw failure("Project response signature or request binding did not match.", "DESKTOP_CLOUD_PROJECT_PROTOCOL");
40
+ return method === "taskProject/list" ? body.catalog : body.project;
41
+ }
42
+ async function current(api, discover = discoverRuntime) {
43
+ const next = await discover();
44
+ if (next.paths.home !== api.paths.home || next.paths.socket !== api.paths.socket || next.state.pid !== api.state.pid || next.state.instance !== api.state.instance) throw failure("Desktop changed during the operation. Inspect the receipt before retrying.", "DESKTOP_CLOUD_PROJECT_BINDING_CHANGED");
45
+ await inspectNativeEndpoint(api.endpoint.socketPath, api.endpoint.fingerprint);
46
+ }
47
+ export function validateProjectCatalog(catalog, environmentId) {
48
+ if (catalog?.environmentId !== environmentId || !Array.isArray(catalog.projects) || catalog.projects.length > 10000) throw failure("Invalid project catalog environment.", "DESKTOP_CLOUD_PROJECT_PROTOCOL");
49
+ const ids = new Set();
50
+ for (const row of catalog.projects) {
51
+ if (typeof row?.projectId !== "string" || !row.projectId || row.projectId.length > 400 || ids.has(row.projectId) || typeof row.name !== "string" || !row.name.trim()
52
+ || !Array.isArray(row.rootPaths) || !row.rootPaths.length || row.rootPaths.some(root => typeof root !== "string" || root.includes("\0") || !(path.isAbsolute(root) || path.win32.isAbsolute(root)))) throw failure("Invalid project catalog entry.", "DESKTOP_CLOUD_PROJECT_PROTOCOL");
53
+ ids.add(row.projectId);
54
+ }
55
+ return catalog.projects.map(row => ({ project_id: row.projectId, name: row.name, roots: [...row.rootPaths] }));
56
+ }
57
+ async function catalogOnApi(api, environmentId, { call = nativeRpc, check = current } = {}) {
58
+ await check(api);
59
+ const params = { environmentId, nonce: randomBytes(32).toString("hex") };
60
+ let response;
61
+ try { response = await call(api.endpoint, { jsonrpc: "2.0", id: randomUUID(), method: "taskProject/list", params }); }
62
+ catch { throw failure("Desktop did not authorize project assignment for this task's actual environment. This API requires its own ready Desktop executor; the environment ID is never substituted."); }
63
+ const rows = validateProjectCatalog(verifyProjectReply(response, "taskProject/list", params, api.key), environmentId);
64
+ await check(api); return rows;
65
+ }
66
+ async function targetEnvironment(client, id) {
67
+ const { thread } = await client.request("thread/read", { threadId: id });
68
+ if (thread?.id !== id || !Array.isArray(thread.environments) || thread.environments.length !== 1 || typeof thread.environments[0]?.environmentId !== "string" || !thread.environments[0].environmentId) throw failure("Cannot verify the Cloud task's environment.", "DESKTOP_CLOUD_PROJECT_PROTOCOL");
69
+ return thread.environments[0].environmentId;
70
+ }
71
+ export async function readDesktopProjectState(home, threadInput) {
72
+ const id = desktopCloudUuid(threadInput), file = await open(path.join(home, ".codex-global-state.json"), constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ try {
74
+ const info = await file.stat();
75
+ if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o022) || info.size > 16 * 1024 * 1024) throw failure("Unsafe Desktop project state.", "DESKTOP_CLOUD_PROJECT_STATE_UNSAFE");
76
+ const bytes = Buffer.alloc(16 * 1024 * 1024 + 1), { bytesRead } = await file.read(bytes, 0, bytes.length, 0); if (bytesRead > 16 * 1024 * 1024) throw failure("Desktop project state exceeded its limit.");
77
+ const state = JSON.parse(bytes.subarray(0, bytesRead).toString("utf8")), assignment = state["thread-project-assignments"]?.[id];
78
+ if (assignment != null && (typeof assignment.projectKind !== "string" || typeof assignment.projectId !== "string" || !assignment.projectId || assignment.projectId.length > 400)) throw failure("Invalid Desktop project membership record.", "DESKTOP_CLOUD_PROJECT_PROTOCOL");
79
+ return { assignment: assignment ?? null, host_id: state["thread-project-membership-host-ids"]?.[id] ?? null,
80
+ projectless: Array.isArray(state["projectless-thread-ids"]) && state["projectless-thread-ids"].includes(id) };
81
+ } finally { await file.close(); }
82
+ }
83
+ export async function desktopCloudProjectDoctor(target, selector, { connect = connectDesktopCloud, discoverApi = discoverProjectApi, catalog = catalogOnApi,
84
+ readState = readDesktopProjectState, projects = listProjects } = {}) {
85
+ const id = desktopCloudId(target); let client, api;
86
+ const report = { assignment_api: { status: "unavailable" }, membership: { status: "unverified" } };
87
+ try {
88
+ client = await connect(); const environment = await targetEnvironment(client, id);
89
+ try { api = await discoverApi(); } catch (error) { report.assignment_api.reason_code = error.code ?? "DESKTOP_CLOUD_PROJECT_UNAVAILABLE"; return report; }
90
+ let rows;
91
+ try { rows = await catalog(api, environment); report.assignment_api.status = "available"; }
92
+ catch (error) { if (error.code === "DESKTOP_CLOUD_PROJECT_PROTOCOL" || error.code === "DESKTOP_CLOUD_PROJECT_BINDING_CHANGED") throw error; report.assignment_api.reason_code = error.code ?? "DESKTOP_CLOUD_PROJECT_UNAVAILABLE"; report.assignment_api.reason = "The actual Cloud environment was not accepted by Desktop's executor-bound API."; }
93
+ let selected;
94
+ if (selector !== undefined) {
95
+ const inventory = rows ?? (await projects()).flatMap(row => (row.desktop_project_ids ?? []).map(legacy => ({ ...row, canonical_project_id: row.project_id, project_id: legacy })));
96
+ const matches = inventory.filter(row => row.project_id === selector || row.canonical_project_id === selector || row.name === selector);
97
+ if (matches.length !== 1) throw failure("Select exactly one Desktop project ID or exact name.", "PROJECT_NOT_FOUND"); selected = matches[0];
98
+ report.selected_project = selected;
99
+ }
100
+ const state = await readState(api.paths.home, id);
101
+ report.membership = { status: state.assignment?.projectKind === "local" ? (state.host_id === "durable" ? "registered" : "recorded_host_unverified") : state.projectless ? "projectless" : "source_unknown",
102
+ project_id: state.assignment?.projectId ?? null, host_id: state.host_id, source: "read-only-desktop-profile-state" };
103
+ if (selected) report.membership.matches_selected_project = state.assignment?.projectKind === "local" && state.assignment.projectId === selected.project_id && state.host_id === "durable";
104
+ return report;
105
+ } finally { try { client?.close(); } catch { /* Preserve diagnostics. */ } }
106
+ }
107
+ export async function assignDesktopCloudProject(target, selector, { connect = connectDesktopCloud, discoverApi = discoverProjectApi, catalog = catalogOnApi,
108
+ call = nativeRpc, check = current, readState = readDesktopProjectState, ...storage } = {}) {
109
+ const id = desktopCloudId(target);
110
+ if (typeof selector !== "string" || !selector.trim()) throw failure("--project is required.", "PROJECT_REQUIRED");
111
+ return withStoreLock(`desktop-cloud-project-${id}`, async () => {
112
+ if ((await listRecords("desktop-cloud-projects", storage)).some(row => row.thread_id === id && row.delivery_status === "unknown")) throw failure("An earlier assignment is unknown. Inspect its receipt; do not replay it.", "DESKTOP_CLOUD_PROJECT_PENDING");
113
+ let client, receipt, api, dispatching = false;
114
+ try {
115
+ client = await connect(); const environmentId = await targetEnvironment(client, id); api = await discoverApi();
116
+ if (api.paths.home !== (storage.home ?? codexHome())) throw failure("Desktop project profile does not match the CLI profile.", "DESKTOP_CLOUD_PROJECT_BINDING_CHANGED");
117
+ const rows = await catalog(api, environmentId), matches = rows.filter(row => row.project_id === selector || row.name === selector);
118
+ if (matches.length !== 1) throw failure("Select one project returned by the signed catalog.", "PROJECT_NOT_FOUND");
119
+ const selected = matches[0], attempt = randomUUID();
120
+ receipt = { schema: 1, type: "desktop-cloud-project", attempt_id: attempt, thread_id: id, environment_id: environmentId, project_id: selected.project_id,
121
+ delivery_status: "not_sent", verification_status: "unverified", receipt_path: storePath("desktop-cloud-projects", attempt, storage.home) };
122
+ await writeRecord("desktop-cloud-projects", attempt, receipt, storage); await check(api);
123
+ if (await targetEnvironment(client, id) !== environmentId) throw failure("Task environment changed before assignment.", "DESKTOP_CLOUD_PROJECT_BINDING_CHANGED");
124
+ const params = { environmentId, projectId: selected.project_id, threadId: desktopCloudUuid(id), nonce: randomBytes(32).toString("hex") };
125
+ receipt.delivery_status = "unknown"; await writeRecord("desktop-cloud-projects", attempt, receipt, storage); dispatching = true;
126
+ const response = await call(api.endpoint, { jsonrpc: "2.0", id: randomUUID(), method: "taskProject/assign", params });
127
+ const assigned = verifyProjectReply(response, "taskProject/assign", params, api.key);
128
+ if (!isDeepStrictEqual(assigned, { environmentId, projectId: selected.project_id, threadId: desktopCloudUuid(id) })) throw failure("Assignment response target did not match.", "DESKTOP_CLOUD_PROJECT_PROTOCOL", true);
129
+ receipt.delivery_status = "accepted"; receipt.proof_sha256 = sha(response.payload);
130
+ await writeRecord("desktop-cloud-projects", attempt, receipt, storage); await check(api);
131
+ const stored = await readState(api.paths.home, id);
132
+ receipt.verification_status = stored.assignment?.projectKind === "local" && stored.assignment.projectId === selected.project_id && stored.host_id === "durable" ? "verified" : "not_observed";
133
+ await writeRecord("desktop-cloud-projects", attempt, receipt, storage); return receipt;
134
+ } catch (error) {
135
+ if (receipt) {
136
+ if (receipt.delivery_status !== "accepted") receipt.delivery_status = dispatching && ![-32601, -32602].includes(error.rpcCode) ? "unknown" : "not_sent";
137
+ try { await writeRecord("desktop-cloud-projects", receipt.attempt_id, { ...receipt, error_code: error.code ?? "DESKTOP_CLOUD_PROJECT_UNAVAILABLE" }, storage); } catch { error.receipt_update_required = true; }
138
+ Object.assign(error, { attempt_id: receipt.attempt_id, receipt_path: receipt.receipt_path, delivery_status: receipt.delivery_status, uncertain: receipt.delivery_status === "unknown" });
139
+ }
140
+ throw error;
141
+ } finally { try { client?.close(); } catch { /* Preserve diagnostics. */ } }
142
+ }, storage);
143
+ }
144
+ export async function desktopCloudProjectReceipt(attempt, storage = {}) {
145
+ if (typeof attempt !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(attempt)) throw failure("Invalid assignment attempt ID.");
146
+ const row = await readRecord("desktop-cloud-projects", attempt, storage);
147
+ if (!row || row.type !== "desktop-cloud-project" || row.attempt_id !== attempt) throw failure("Assignment receipt not found."); return row;
148
+ }
@@ -0,0 +1,98 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { connectDesktopCloud, desktopCloudEnvironments } from "./desktop-cloud-api.mjs";
3
+ import { desktopCloudId, desktopCloudLink } from "./desktop-cloud-id.mjs";
4
+ import { fetchThread } from "./observe.mjs";
5
+ import { readRecord, writeRecord, storePath, withStoreLock, listRecords } from "./store.mjs";
6
+ const failure = (message, code = "DESKTOP_CLOUD_OPTIONS_INVALID", uncertain = false) => Object.assign(new Error(message), { code, uncertain });
7
+ const hash = value => createHash("sha256").update(value).digest("hex");
8
+ export async function desktopCloudReceipt(attempt, storage = {}) {
9
+ if (!/^[a-f0-9-]{36}$/.test(attempt ?? "")) throw failure("Invalid creation attempt ID.");
10
+ const receipt = await readRecord("desktop-cloud-creations", attempt, storage);
11
+ if (!receipt || receipt.attempt_id !== attempt) throw failure("Creation receipt not found.", "DESKTOP_CLOUD_CREATION_RECEIPT_NOT_FOUND");
12
+ return receipt;
13
+ }
14
+ export async function createDesktopCloudTask(selector, prompt, options = {}, { environments = desktopCloudEnvironments, connect = connectDesktopCloud, ...storage } = {}) {
15
+ if (options.project !== undefined) throw failure("Creation cannot currently register a Desktop project for a newly allocated Cloud environment. No chat was created. Use doctor --thread TARGET --project PROJECT to check an existing task's executor-bound assignment API.", "DESKTOP_CLOUD_PROJECT_UNAVAILABLE");
16
+ if (typeof selector !== "string" || !selector.trim() || typeof prompt !== "string" || !prompt.trim()) throw failure("An --environment ID or exact name and initial instruction are required.");
17
+ if (options.title !== undefined && (typeof options.title !== "string" || !options.title.trim() || options.title.length > 1000)) throw failure("Invalid --title.");
18
+ const matches = (await environments()).environments.filter(row => row.id === selector || row.name === selector);
19
+ if (matches.length !== 1) throw failure("Select exactly one accessible environment configuration.");
20
+ const selected = matches[0], attempt = randomUUID();
21
+ let client, receipt = { schema: 1, attempt_id: attempt, environment_config_id: selected.id, environment_name: selected.name, connection_mode: "desktop-cloud",
22
+ project_membership: "not_requested", creation_status: "not_created", delivery_status: "not_sent", sent: false, prompt_sha256: hash(prompt), dry_run: !!options.dryRun }, creating = false;
23
+ if (options.dryRun) return receipt;
24
+ receipt.receipt_path = storePath("desktop-cloud-creations", attempt, storage.home);
25
+ const save = () => writeRecord("desktop-cloud-creations", attempt, receipt, storage);
26
+ try {
27
+ client = await connect();
28
+ const models = await client.request("model/list", { limit: 100 });
29
+ const defaults = models?.data?.filter(row => row.isDefault === true);
30
+ if (defaults?.length !== 1 || typeof defaults[0].model !== "string") throw failure("Cloud did not identify one default model.");
31
+ receipt.creation_status = "dispatching"; await save(); creating = true;
32
+ const result = await client.request("thread/start", { model: defaults[0].model, serviceName: "codex_cloud", threadSource: "user", pluginsMcp: { productSku: "codex" },
33
+ config: { "features.mcp_app_ui": true }, environments: [{ environmentConfigId: selected.id }], deferredEnvironment: true, ephemeral: false }, { mutation: true });
34
+ receipt = { ...receipt, creation_status: "created", thread_id: desktopCloudId(result.thread.id), deep_link: desktopCloudLink(result.thread.id) }; creating = false; await save();
35
+ if (options.title) await client.request("thread/name/set", { threadId: receipt.thread_id, name: options.title }, { mutation: true });
36
+ const { sendTrackedMessage } = await import("./journal.mjs");
37
+ const sent = await sendTrackedMessage(receipt.thread_id, prompt, { newTurn: true, connection: "desktop-cloud" }, { ...storage,
38
+ send: (target, text, settings) => sendDesktopCloudInput(target, text, settings, { ...storage, connect: async () => ({ request: (...args) => client.request(...args), close() {} }) }) });
39
+ receipt = { ...receipt, ...sent }; await save(); return receipt;
40
+ } catch (error) {
41
+ if (creating) receipt.creation_status = error.uncertain !== false ? "unknown" : "not_created";
42
+ if (error.delivery_status) Object.assign(receipt, { delivery_status: error.delivery_status, sent: error.sent });
43
+ if (error.message_id) Object.assign(receipt, { message_id: error.message_id, client_message_id: error.client_message_id });
44
+ receipt.error_code = error.code ?? "DESKTOP_CLOUD_FAILED";
45
+ try { await save(); } catch { error.receipt_update_required = true; }
46
+ Object.assign(error, { execution_environment: "cloud", creation_status: receipt.creation_status, thread_id: receipt.thread_id, attempt_id: attempt, receipt_path: receipt.receipt_path, sent: receipt.sent, delivery_status: receipt.delivery_status }); throw error;
47
+ } finally { try { client?.close(); } catch { receipt.runtime_cleanup_required = true; } }
48
+ }
49
+ export async function sendDesktopCloudInput(target, text, { dryRun = false, newTurn = false, clientMessageId = randomUUID(), beforeSend } = {}, { connect = connectDesktopCloud, ...storage } = {}) {
50
+ const id = desktopCloudId(target);
51
+ if (typeof text !== "string" || !text.trim()) throw failure("A non-empty message is required.");
52
+ const plan = { thread_id: id, deep_link: desktopCloudLink(id), client_message_id: clientMessageId, backend: "desktop-cloud", connection_mode: "desktop-cloud", delivery_action: newTurn ? "new-turn" : "steer", dry_run: dryRun, message_characters: [...text].length };
53
+ if (dryRun) return { ...plan, sent: false, delivery_status: "not_sent" };
54
+ return withStoreLock(`desktop-cloud-send-${id}`, async () => {
55
+ const pending = (await listRecords("desktop-cloud-sends", storage)).find(row => row.thread_id === id && row.delivery_status === "unknown");
56
+ if (pending) throw failure("An earlier Desktop Cloud delivery is unknown. Inspect history; it must not be replayed automatically.", "DESKTOP_CLOUD_DELIVERY_PENDING");
57
+ let client, dispatching = false, receipt = { ...plan, prompt_sha256: hash(text), sent: false, delivery_status: "not_sent" };
58
+ const save = () => writeRecord("desktop-cloud-sends", clientMessageId, receipt, storage);
59
+ try {
60
+ client = await connect();
61
+ const thread = await fetchThread(client, id), active = thread.turns.filter(turn => turn.status === "inProgress");
62
+ if (newTurn ? thread.status?.type !== "idle" || active.length !== 0 : active.length !== 1) throw failure("The task state does not match the requested send. Read it again; idle continuation requires --new-turn.", "STALE_OBSERVATION");
63
+ const environments = thread.environments;
64
+ if (!Array.isArray(environments) || environments.length !== 1 || typeof environments[0].environmentId !== "string" || !environments[0].environmentId.startsWith("ccarenv_") || typeof environments[0].environmentConfigId !== "string") throw failure("Cannot verify the task's Cloud environment.");
65
+ await beforeSend?.(thread, client);
66
+ receipt.delivery_status = "unknown"; await save(); dispatching = true;
67
+ const params = { threadId: id, input: [{ type: "text", text, text_elements: [] }], clientUserMessageId: clientMessageId };
68
+ const result = await client.request(newTurn ? "turn/start" : "turn/steer", newTurn ? { ...params, environments, deferredEnvironment: true } : { ...params, expectedTurnId: active[0].id }, { mutation: true });
69
+ const turnId = newTurn ? result?.turn?.id : result?.turnId;
70
+ if (typeof turnId !== "string" || (!newTurn && turnId !== active[0].id)) throw failure("Cloud did not confirm the expected turn. Inspect history before another send.", "DESKTOP_CLOUD_PROTOCOL_ERROR", true);
71
+ receipt = { ...receipt, turn_id: turnId, sent: true, delivery_status: "accepted" };
72
+ try { await save(); } catch { receipt.receipt_update_required = true; } return receipt;
73
+ } catch (error) {
74
+ const unknown = dispatching && error.uncertain !== false;
75
+ receipt = { ...receipt, sent: unknown ? null : false, delivery_status: unknown ? "unknown" : "not_sent", error_code: error.code ?? "DESKTOP_CLOUD_FAILED" };
76
+ try { await save(); } catch { error.receipt_update_required = true; }
77
+ Object.assign(error, { uncertain: unknown, sent: receipt.sent, delivery_status: receipt.delivery_status }); throw error;
78
+ } finally { try { client?.close(); } catch { receipt.runtime_cleanup_required = true; } }
79
+ }, storage);
80
+ }
81
+ export async function listDesktopCloudTasks(options = {}, { connect = connectDesktopCloud } = {}) {
82
+ if (!Number.isInteger(options.limit ?? 20) || options.limit < 1 || options.limit > 100) throw failure("--limit must be 1..100.");
83
+ const client = await connect();
84
+ try { const data = await client.request("thread/list", { limit: options.limit ?? 20, ...(options.cursor ? { cursor: options.cursor } : {}) });
85
+ if (!Array.isArray(data?.data)) throw failure("Invalid task inventory.");
86
+ return { threads: data.data.map(row => ({ thread_id: desktopCloudId(`durable_${row.id}`), deep_link: desktopCloudLink(`durable_${row.id}`), title: row.name ?? null, status: row.status })), next_cursor: data.nextCursor ?? null };
87
+ } finally { try { client.close(); } catch { /* Do not mask the read result. */ } }
88
+ }
89
+
90
+ export async function confirmDesktopCloudDelivery(entry, verification, storage = {}) {
91
+ if (verification.status !== "stored" || entry.delivery_status === "not_sent") return;
92
+ const id = desktopCloudId(entry.thread_id);
93
+ return withStoreLock(`desktop-cloud-send-${id}`, async () => {
94
+ const receipt = await readRecord("desktop-cloud-sends", entry.client_message_id, storage);
95
+ if (!receipt || receipt.thread_id !== id || receipt.prompt_sha256 !== hash(entry.wire_text) || receipt.delivery_status === "not_sent") return;
96
+ await writeRecord("desktop-cloud-sends", entry.client_message_id, { ...receipt, delivery_status: "accepted", sent: true, turn_id: verification.turn_id, delivery_verification: "stored" }, storage);
97
+ }, storage);
98
+ }
package/src/desktop.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
- import { threadComposerDeepLink, threadDeepLink } from "./thread-id.mjs";
4
+ import { threadComposerDeepLink, threadDeepLink, parseThreadTarget } from "./thread-id.mjs";
5
5
  import { readDesktopSettings } from "./settings.mjs";
6
+ import { targetHost, remoteReadOnly } from "./target.mjs";
6
7
 
7
8
  const APP_PATH = "/Applications/ChatGPT.app";
8
9
  const APP_BUNDLE_ID = "com.openai.codex";
@@ -179,6 +180,7 @@ export function sendDesktopMessage(
179
180
  message,
180
181
  { dryRun = false, waitMs = 1500, newTurn = false, keepFocus = false } = {},
181
182
  ) {
183
+ if ((parseThreadTarget(threadId).hostId ?? targetHost()) !== "local") throw remoteReadOnly();
182
184
  if (message.trim() === "") throw new Error("Message must not be empty.");
183
185
  validateWaitMs(waitMs);
184
186
 
package/src/directive.mjs CHANGED
@@ -21,7 +21,7 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
21
21
  ...(concern ? { finding_id: concern.id, finding_revision: concern.revision } : {}), ...(supervisor ? { supervisor_id: supervisor } : {}) };
22
22
  const lines = [`[codexteer ${id}]`, `送信者: ${metadata.source}`, `種類: ${kindLabels[metadata.kind]}`];
23
23
  for (const ref of refs) lines.push(`根拠: ${JSON.stringify(ref.ref)}${ref.sha256 ? ` (sha256:${ref.sha256})` : " (参照URL)"}`);
24
- if (observed) lines.push(`観測対象ターン: ${observed.active_turn_id ?? "停止中"}`);
24
+ if (observed) lines.push(`観測対象ターン: ${observed.active_turn_id ?? "未確定"}`);
25
25
  if (supersedes) lines.push(`置き換える指示: ${supersedes}。この指示を以後の方針として扱ってください。`);
26
26
  if (retracts) lines.push(`撤回する指示: ${retracts}。以後の方針から除外してください。`);
27
27
  if (expiresAt) lines.push(`有効期限: ${expiresAt}`);
@@ -34,7 +34,10 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
34
34
  else {
35
35
  if (thread.turns?.some(t => t.itemsView && t.itemsView !== "full") || !thread.turns?.length) thread = await fetchThread(client, threadId);
36
36
  const current = threadSnapshot(thread);
37
- if (current.user_revision !== observed.user_revision || current.state.active_turn_id !== observed.active_turn_id) throw Object.assign(new Error("New user input or a different turn was observed. Read the task again before sending."), { code: "STALE_OBSERVATION" });
37
+ const latestChanged = observed.latest_turn_id !== undefined && observed.latest_turn_id !== (thread.turns?.at(-1)?.id ?? null);
38
+ // Older v1 cursors cannot distinguish two ambiguous active snapshots.
39
+ const unpinned = observed.latest_turn_id === undefined && thread.historyMode === "paginated" && thread.turns.filter(t => t.status === "inProgress").length > 1;
40
+ if (latestChanged || unpinned || current.user_revision !== observed.user_revision || current.state.active_turn_id !== observed.active_turn_id) throw Object.assign(new Error("New user input or a different turn was observed, or the old cursor cannot identify the latest turn. Read the task again before sending."), { code: "STALE_OBSERVATION" });
38
41
  }
39
42
  }
40
43
  await verifyEvidence(refs);
@@ -38,7 +38,7 @@ async function checkAncestors(directory) {
38
38
  }
39
39
  }
40
40
 
41
- async function resolveHome(home) {
41
+ export async function prepareCodexHome(home) {
42
42
  let existing = path.resolve(home), canonical;
43
43
  const missing = [];
44
44
  while (true) {
@@ -53,6 +53,8 @@ async function resolveHome(home) {
53
53
  canonical = path.join(canonical, part);
54
54
  await ownedDirectory(canonical, true);
55
55
  }
56
+ const homeInfo = await lstat(canonical);
57
+ if (!homeInfo.isDirectory() || homeInfo.uid !== process.getuid() || (homeInfo.mode & 0o7022)) throw failure("CODEX_HOME must be an owner-controlled directory.");
56
58
  return canonical;
57
59
  }
58
60
 
@@ -86,8 +88,8 @@ export async function describeDistribution(root = PACKAGE_ROOT) {
86
88
  root = await realpath(root);
87
89
  await checkAncestors(root);
88
90
  check(await lstat(root), true, false);
89
- const names = ["package.json", "LICENSE", "bin/codexteer.mjs", "bin/codexteer-wrapper.mjs", "assets/send-pururu.wav",
90
- "scripts/send.applescript", "scripts/inspect.applescript", "scripts/accessibility.applescript"];
91
+ const names = ["package.json", "LICENSE", "bin/codexteer.mjs", "bin/codexteer-wrapper.mjs", "bin/codexteer-update.mjs", "assets/send-pururu.wav", "assets/remote-device-key.swift",
92
+ "scripts/send.applescript", "scripts/inspect.applescript", "scripts/accessibility.applescript", "scripts/update-desktop.applescript"];
91
93
  for (const directory of ["bin", "assets", "scripts", "src", "node_modules", "node_modules/ws", "node_modules/ws/lib"]) {
92
94
  check(await lstat(path.join(root, directory)), true, false);
93
95
  }
@@ -150,9 +152,7 @@ export async function prepareDeployment(home, { sourceRoot = PACKAGE_ROOT } = {}
150
152
  const distribution = await describeDistribution(sourceRoot);
151
153
  // CODEX_HOME may itself be a user-configured symlink; only its canonical
152
154
  // owner-writable directory is accepted. Never follow links below that root.
153
- const canonicalHome = await resolveHome(home);
154
- const homeInfo = await lstat(canonicalHome);
155
- if (!homeInfo.isDirectory() || homeInfo.uid !== process.getuid() || (homeInfo.mode & 0o7022)) throw failure("CODEX_HOME must be an owner-controlled directory.");
155
+ const canonicalHome = await prepareCodexHome(home);
156
156
  const stateRoot = path.join(canonicalHome, "codex-steer"), root = path.join(stateRoot, "runtimes");
157
157
  await ownedDirectory(stateRoot, true); await ownedDirectory(root, true);
158
158
  const target = path.join(root, distribution.id), lock = path.join(root, `.install-${distribution.id}`);
package/src/evidence.mjs CHANGED
@@ -2,8 +2,11 @@ import { stat, realpath } from "node:fs/promises";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
+ import { targetHost } from "./target.mjs";
6
+ import { remoteFingerprintFile } from "./remote-io.mjs";
5
7
 
6
8
  export async function fingerprintFile(input) {
9
+ if (targetHost() !== "local") return remoteFingerprintFile(input);
7
10
  const ref = path.resolve(input), canonical = await realpath(ref), before = await stat(canonical);
8
11
  if (!before.isFile()) throw new Error("Evidence must be a file or an http(s)/codex URL.");
9
12
  const hash = createHash("sha256");
package/src/findings.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { normalizeThreadId } from "./thread-id.mjs";
2
+ import { normalizeTaskId } from "./task-id.mjs";
3
3
  import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
4
4
  import { collectEvidence } from "./evidence.mjs";
5
5
  import { decodeCursor, digest } from "./observe.mjs";
@@ -16,7 +16,7 @@ const text = (value, label, max = 8000) => {
16
16
  const contextHash = record => digest({ condition: record.condition, evidence: record.evidence });
17
17
 
18
18
  export async function getFinding(threadInput, id, storage = {}) {
19
- const threadId = normalizeThreadId(threadInput), record = await readRecord("findings", id, storage);
19
+ const threadId = normalizeTaskId(threadInput), record = await readRecord("findings", id, storage);
20
20
  if (!record || record.schema !== 1 || record.id !== id || record.thread_id !== threadId) throw failure("Finding not found for this task.", "FINDING_NOT_FOUND");
21
21
  if (!Number.isSafeInteger(record.revision) || record.revision < 1 || !["open", "resolved", "dismissed"].includes(record.disposition)
22
22
  || !Array.isArray(record.evidence) || typeof record.condition !== "string" || !ratings.includes(record.evaluation?.rating)) throw failure("Invalid finding record.", "FINDING_INVALID");
@@ -30,7 +30,7 @@ export async function findingMessages(record, storage = {}) {
30
30
  }
31
31
 
32
32
  export async function createFinding(threadInput, { key, title, condition, evidence = [], basedOn, ...storage }) {
33
- const threadId = normalizeThreadId(threadInput);
33
+ const threadId = normalizeTaskId(threadInput);
34
34
  if (typeof key !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(key)) throw failure("--key requires a stable short identifier for this concern.", "FINDING_INVALID");
35
35
  text(title, "--title", 240); text(condition, "--condition");
36
36
  const refs = await collectEvidence(evidence), observed = basedOn ? decodeCursor(basedOn, threadId) : null;
@@ -77,7 +77,7 @@ export async function findingStatus(threadInput, id, storage = {}) {
77
77
  }
78
78
 
79
79
  export async function listFindings(threadInput, { all = false, limit = 50, ...storage } = {}) {
80
- const threadId = normalizeThreadId(threadInput);
80
+ const threadId = normalizeTaskId(threadInput);
81
81
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw failure("--limit must be between 1 and 1000.", "FINDING_INVALID");
82
82
  const records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
83
83
  // Recheck closed resolutions too: changed inputs make them unresolved again.
@@ -90,7 +90,7 @@ export async function listFindings(threadInput, { all = false, limit = 50, ...st
90
90
  }
91
91
 
92
92
  async function changeFinding(threadInput, id, update, storage) {
93
- const threadId = normalizeThreadId(threadInput);
93
+ const threadId = normalizeTaskId(threadInput);
94
94
  return withStoreLock(`message-${threadId}`, async () => {
95
95
  const record = await getFinding(threadId, id, storage);
96
96
  const updated = { ...await update(record), updated_at: new Date().toISOString() };
@@ -124,7 +124,7 @@ export async function dismissFinding(threadInput, id, { reason, ...storage }) {
124
124
  }
125
125
 
126
126
  export async function reopenFinding(threadInput, id, { reason, evidence, basedOn, condition, ...storage }) {
127
- const threadId = normalizeThreadId(threadInput);
127
+ const threadId = normalizeTaskId(threadInput);
128
128
  text(reason, "--reason"); if (condition !== undefined) text(condition, "--condition");
129
129
  const refs = evidence === undefined ? undefined : await collectEvidence(evidence);
130
130
  const observed = basedOn === undefined ? undefined : decodeCursor(basedOn, threadId);
@@ -145,7 +145,7 @@ export async function evaluateFinding(threadInput, id, { rating, reason, ...stor
145
145
  }
146
146
 
147
147
  export async function findingStats(threadInput, storage = {}) {
148
- const threadId = normalizeThreadId(threadInput), records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId);
148
+ const threadId = normalizeTaskId(threadInput), records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId);
149
149
  const counts = Object.fromEntries(ratings.map(rating => [rating, 0]));
150
150
  let sent = 0;
151
151
  for (const entry of records) {