codexteer 0.16.0 → 0.17.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.
@@ -0,0 +1,40 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const unsafe = () => Object.assign(new Error("App Server socket has unsafe ownership, permissions, file type, or link target."), { code: "SOCKET_UNSAFE" });
6
+ const identity = info => [info.dev, info.ino, info.ctimeMs];
7
+ const same = (a, b) => JSON.stringify(identity(a)) === JSON.stringify(identity(b));
8
+
9
+ function checkSocket(info) {
10
+ if (info.uid !== process.getuid() || (info.mode & 0o077) || !info.isSocket()) throw unsafe();
11
+ }
12
+
13
+ // Callers validate the advertised path's private parent directories separately.
14
+ // Codex 0.155 publishes a deterministic alias to a protected socket:
15
+ // codex-rs/app-server-transport/src/transport/unix_socket.rs and uds/src/daemon_directory.rs.
16
+ // Accept only that layout; never follow arbitrary links or repair permissions.
17
+ export async function inspectAppServerSocket(file) {
18
+ const advertised = await fs.lstat(file);
19
+ if (!advertised.isSymbolicLink()) {
20
+ checkSocket(advertised);
21
+ return { socket: file, fingerprint: [identity(advertised)] };
22
+ }
23
+ // Link permission bits do not describe access to its target.
24
+ if (advertised.uid !== process.getuid()) throw unsafe();
25
+ const canonical = path.join(await fs.realpath(path.dirname(file)), path.basename(file));
26
+ // Upstream deliberately ignores TMPDIR, HOME and CODEX_HOME for this root.
27
+ const directory = path.join(await fs.realpath("/tmp"), `codex-daemon-${process.getuid()}`);
28
+ const socket = path.join(directory, createHash("sha256").update(canonical).digest("hex"));
29
+ if (await fs.readlink(file) !== socket) throw unsafe();
30
+ const parent = await fs.lstat(directory);
31
+ if (parent.uid !== process.getuid() || !parent.isDirectory() || (parent.mode & 0o777) !== 0o700) throw unsafe();
32
+ const target = await fs.lstat(socket);
33
+ checkSocket(target); // lstat rejects a second link, even if it ends at a socket.
34
+ const currentParent = await fs.lstat(directory);
35
+ if (!same(advertised, await fs.lstat(file)) || await fs.readlink(file) !== socket
36
+ || parent.dev !== currentParent.dev || parent.ino !== currentParent.ino
37
+ || parent.mode !== currentParent.mode || parent.uid !== currentParent.uid) throw unsafe();
38
+ // Directory timestamps change when unrelated profiles create sockets.
39
+ return { socket, fingerprint: [identity(advertised), [parent.dev, parent.ino], identity(target)] };
40
+ }
@@ -0,0 +1,134 @@
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, verifyControlEndpoint } 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 { fetchThread } from "./observe.mjs";
10
+ import { ensureDesktopSubscription } from "./subscription.mjs";
11
+ import { discoverNativeEndpoint, inspectNativeEndpoint, nativeToolJson } from "./native-rpc.mjs";
12
+ import { nativeOnAppServer, inspectNativeCommandProtocol, mutationOutcomeUnknown } from "./native-command.mjs";
13
+ import { desktopMembership, initialInstructionMatches } from "./creation-verification.mjs";
14
+ import { writeRecord, storePath } from "./store.mjs";
15
+
16
+ const failure = (message, code = "CREATION_FAILED") => Object.assign(new Error(message), { code });
17
+
18
+ export function validateStandaloneCreationCatalog(catalog) {
19
+ const schema = catalog?.tools?.find(tool => tool.name === "move_thread_to_project" && tool.namespace === "codex_app")?.inputSchema;
20
+ const list = catalog?.tools?.find(tool => tool.name === "list_projects" && tool.namespace === "codex_app")?.inputSchema;
21
+ const project = schema?.properties?.projectId;
22
+ if (list?.type !== "object" || schema?.properties?.threadId?.type !== "string"
23
+ || !schema.required?.includes("threadId") || !schema.required.includes("projectId")
24
+ || !(project?.type === "string" || project?.anyOf?.some(type => type.type === "string"))) {
25
+ throw failure("Desktop native tools do not expose local project assignment.", "NATIVE_CREATION_UNSUPPORTED");
26
+ }
27
+ return { status: "verified", source: "desktop-native-assignment-schema", schema_sha256: createHash("sha256").update(JSON.stringify([list, schema])).digest("hex") };
28
+ }
29
+
30
+ // No caller chat is required. The initial instruction starts exactly one real
31
+ // task/turn; only that new task supplies the native self-assignment context.
32
+ export async function createStandaloneProjectThread(selector, prompt, settings, {
33
+ discover = discoverRuntime, connect = RpcClient.connect, aliases = projectAliases,
34
+ endpoint = discoverNativeEndpoint, inspectEndpoint = inspectNativeEndpoint, inspectCommand = inspectNativeCommandProtocol, native,
35
+ workspace = projectWorkspace, history = fetchThread, membership = desktopMembership,
36
+ inspectControl = verifyControlEndpoint, subscribe = ensureDesktopSubscription, write = writeRecord, sleep = delay,
37
+ } = {}) {
38
+ const attemptId = randomUUID();
39
+ const title = settings.title ?? [...prompt.trim().replace(/\s+/gu, " ")].slice(0, 80).join("");
40
+ let client, receipt, storage, creating = false, sending = false, accepted = false;
41
+ try {
42
+ const { paths, state } = await discover(); storage = { home: paths.home };
43
+ assertRuntimeOperation(state, "send_new_turn");
44
+ await inspectControl(paths);
45
+ const nativeEndpoint = await endpoint(state), command = await inspectCommand(state, { standalone: true });
46
+ client = compatibleClient(await connect(paths.socket, { timeoutMs: settings.timeoutMs + 5000 }), state);
47
+ const invokeNative = native ?? ((...args) => nativeOnAppServer(client, ...args));
48
+ const nativeOptions = { timeoutMs: settings.timeoutMs };
49
+ const protocol = { ...validateStandaloneCreationCatalog(await invokeNative(nativeEndpoint, "catalog", undefined, undefined, nativeOptions)), command };
50
+ const mapping = await aliases(paths.home), project = resolveProject(await projectsOnClient(client), selector, mapping), scope = await workspace(project);
51
+ const matches = Object.keys(mapping).filter(id => mapping[id] === project.id);
52
+ const desktopId = mapping[selector] === project.id ? selector : matches.length === 1 ? matches[0] : undefined;
53
+ if (!desktopId) throw failure("Cannot uniquely map this local project to Desktop. No chat was created.", "DESKTOP_PROJECT_MAPPING_UNVERIFIED");
54
+ const check = async () => {
55
+ const current = await discover(); assertRuntimeOperation(current.state, "send_new_turn");
56
+ if (current.state.instance !== state.instance || current.paths.socket !== paths.socket || current.paths.control !== paths.control || current.state.cli_version !== state.cli_version) throw failure("Runtime changed. Inspect the receipt before retrying.", "RUNTIME_CHANGED");
57
+ await inspectControl(current.paths); await inspectEndpoint(nativeEndpoint.socketPath, nativeEndpoint.fingerprint);
58
+ const live = validateProject((await client.request("project/read", { projectId: project.id })).project);
59
+ if (!isDeepStrictEqual(live.roots, project.roots) || live.name !== project.name || (await aliases(paths.home))[desktopId] !== project.id) throw failure("Project source folders or Desktop mapping changed.", "PROJECT_CHANGED");
60
+ };
61
+ await check();
62
+ receipt = { schema: 3, attempt_id: attemptId, native_call_id: attemptId, creation_mode: "standalone", project_id: project.id, desktop_project_id: desktopId, project_name: project.name,
63
+ context_thread_id: null, context_turn_id: null, cwd: scope.cwd, runtime_workspace_roots: scope.runtimeWorkspaceRoots,
64
+ title, title_source: settings.title === undefined ? "initial-instruction" : "explicit", backend: "app-server-native-assignment", connection_mode: "shared", protocol,
65
+ creation_status: "not_created", sent: false, delivery_status: "not_sent", verification_status: "not_started",
66
+ message_characters: [...prompt].length, prompt_sha256: createHash("sha256").update(prompt).digest("hex"), dry_run: !!settings.dryRun };
67
+ if (settings.dryRun) return receipt;
68
+ receipt.receipt_path = storePath("creations", attemptId, paths.home);
69
+ await write("creations", attemptId, { ...receipt, creation_status: "dispatching" }, storage);
70
+ await check(); creating = true;
71
+ const started = await client.request("thread/start", { cwd: scope.cwd, projectId: project.id, runtimeWorkspaceRoots: scope.runtimeWorkspaceRoots,
72
+ ephemeral: false, ...(settings.model === undefined ? {} : { model: settings.model }) }, { mutation: true });
73
+ if (!started?.thread?.id) throw Object.assign(failure("No created task ID was confirmed. Do not retry automatically.", "NATIVE_CREATION_UNKNOWN"), { uncertain: true });
74
+ const threadId = normalizeThreadId(started.thread.id);
75
+ Object.assign(receipt, { thread_id: threadId, deep_link: threadDeepLink(threadId), creation_status: "created" });
76
+ await write("creations", attemptId, receipt, storage);
77
+ await check();
78
+ // Naming also materializes the turnless rollout on supported servers;
79
+ // without it both metadata/update and Desktop's resume can fail.
80
+ await client.request("thread/name/set", { threadId, name: title }, { mutation: true });
81
+ // An untouched, turnless thread can have no durable rollout yet. Persist
82
+ // its canonical assignment through the API before Desktop resumes it.
83
+ await client.request("thread/metadata/update", { threadId, projectId: project.id }, { mutation: true });
84
+ // Subscribe on Desktop's long-lived connection before starting any work.
85
+ await subscribe(paths.control, threadId); receipt.desktop_subscription = "verified";
86
+ await check();
87
+ const prepared = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
88
+ const preparedRoots = prepared?.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
89
+ if (prepared?.id !== threadId || prepared.projectId !== project.id || prepared.cwd !== scope.cwd
90
+ || !Array.isArray(preparedRoots) || preparedRoots.length !== scope.runtimeWorkspaceRoots.length || !scope.runtimeWorkspaceRoots.every(root => preparedRoots.includes(root))
91
+ || prepared.canAcceptDirectInput === false || (await history(client, threadId)).turns?.length !== 0) {
92
+ throw failure("New task's scope or empty initial history changed before delivery. No instruction was sent.", "CREATION_SCOPE_MISMATCH");
93
+ }
94
+ receipt.client_message_id = randomUUID();
95
+ await write("creations", attemptId, { ...receipt, delivery_status: "dispatching" }, storage);
96
+ sending = true;
97
+ const delivery = await client.request("turn/start", { threadId, input: [{ type: "text", text: prompt }], clientUserMessageId: receipt.client_message_id,
98
+ ...(settings.thinking === undefined ? {} : { effort: settings.thinking }) }, { mutation: true });
99
+ if (typeof delivery?.turn?.id !== "string" || !delivery.turn.id.trim()) throw Object.assign(failure("No initial turn ID was confirmed. Inspect the task before resending.", "INITIAL_TURN_UNCONFIRMED"), { uncertain: true });
100
+ accepted = true;
101
+ Object.assign(receipt, { turn_id: delivery.turn.id, sent: true, delivery_status: "accepted", native_context_thread_id: threadId, native_context_turn_id: delivery.turn.id });
102
+ await write("creations", attemptId, receipt, storage);
103
+ const context = { threadId, turnId: delivery.turn.id };
104
+ const inventory = nativeToolJson(await invokeNative(nativeEndpoint, "list_projects", {}, context, nativeOptions));
105
+ const selected = inventory.projects?.filter(item => item.projectKind === "local" && item.hostId === "local" && item.projectId === desktopId);
106
+ if (selected?.length !== 1 || selected[0].label !== project.name || selected[0].path !== project.roots[0].path) throw failure("Desktop project mapping was not confirmed. Inspect the created task.", "DESKTOP_PROJECT_MAPPING_UNVERIFIED");
107
+ await check();
108
+ const assigned = await invokeNative(nativeEndpoint, "move_thread_to_project", { threadId, projectId: desktopId }, context, { ...nativeOptions, callId: attemptId });
109
+ nativeToolJson(assigned);
110
+ if (assigned.native_request_cleanup_required) receipt.native_request_cleanup_required = true;
111
+ const verify = async () => {
112
+ const thread = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
113
+ const roots = thread?.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
114
+ return thread?.id === threadId && thread.projectId === project.id && thread.cwd === scope.cwd
115
+ && Array.isArray(roots) && roots.length === scope.runtimeWorkspaceRoots.length && scope.runtimeWorkspaceRoots.every(root => roots.includes(root))
116
+ && thread.name === title
117
+ && await membership(paths.home, threadId, desktopId) && initialInstructionMatches(await history(client, threadId), prompt);
118
+ };
119
+ let verified = await verify();
120
+ for (let retry = 0; retry < 20 && !verified; retry++) { await sleep(250); verified = await verify(); }
121
+ if (!verified) throw failure("Created task's assignment, roots, title or initial instruction could not be verified. Do not recreate it.", "CREATION_VERIFICATION_FAILED");
122
+ Object.assign(receipt, { verification_status: "verified", desktop_project_membership: "verified", app_server_project_membership: "verified", initial_instruction: "verified" });
123
+ try { await write("creations", attemptId, receipt, storage); } catch { receipt.receipt_update_required = true; }
124
+ return receipt;
125
+ } catch (error) {
126
+ const created = !!receipt?.thread_id, unknownCreation = creating && !created && mutationOutcomeUnknown(error);
127
+ const unknownDelivery = sending && !accepted && mutationOutcomeUnknown(error);
128
+ Object.assign(error, { attempt_id: attemptId, native_call_id: attemptId, thread_id: receipt?.thread_id, receipt_path: receipt?.receipt_path,
129
+ creation_status: created ? "created" : unknownCreation ? "unknown" : "not_created", sent: accepted ? true : unknownDelivery ? null : false,
130
+ delivery_status: accepted ? "accepted" : unknownDelivery ? "unknown" : "not_sent" });
131
+ 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; }
132
+ throw error;
133
+ } finally { try { client?.close(); } catch { if (accepted && receipt) receipt.runtime_cleanup_required = true; } }
134
+ }
package/src/supervise.mjs CHANGED
@@ -5,10 +5,10 @@ import { realpath } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { randomUUID } from "node:crypto";
8
- import { prepareDeployment } from "./distribution.mjs";
8
+ import { prepareCodexHome, prepareDeployment } from "./distribution.mjs";
9
9
  import { normalizeThreadId } from "./thread-id.mjs";
10
10
  import { codexHome } from "./runtime.mjs";
11
- import { validateSupervisionPolicy } from "./prompt.mjs";
11
+ import { supervisorPrompt as pathSupervisorPrompt, validateSupervisionPolicy } from "./prompt.mjs";
12
12
  import { registerSupervisor, controlSupervisor } from "./supervision.mjs";
13
13
  import { connectionMode } from "./connection.mjs";
14
14
 
@@ -21,31 +21,36 @@ function shellArgument(value) {
21
21
  return `'${value.replaceAll("'", "'\\''")}'`;
22
22
  }
23
23
 
24
- export async function prepareSupervisorPrompt(threadInput, policy, { agent = "claude", connection = "shared" } = {}) {
24
+ export async function prepareSupervisorPrompt(threadInput, policy, { agent = "claude", connection = "shared", usePathCli = false } = {}) {
25
25
  const threadId = normalizeThreadId(threadInput);
26
26
  validateSupervisionPolicy(policy);
27
27
  connectionMode(connection);
28
28
  if (!SUPERVISOR_AGENTS.includes(agent)) throw new Error("--agent must be claude or codex.");
29
+ const supervisor = { session_id: randomUUID(), owner: agent, connection };
30
+ const sessionOptions = `--supervisor ${shellArgument(supervisor.session_id)} --connection ${connection}`;
31
+ if (usePathCli) {
32
+ return { ...pathSupervisorPrompt(threadId, `codexteer ${sessionOptions}`, policy, supervisor, { usePathCli: true }),
33
+ cli_mode: "path", deployment: null, node: null, supervisor };
34
+ }
29
35
  const node = { path: await realpath(process.execPath), version: process.version };
30
36
  const home = codexHome();
31
37
  shellArgument(node.path); shellArgument(home);
32
38
  const deployment = await prepareDeployment(home);
33
39
  // Bind the validated canonical profile as well as the saved executable.
34
- const supervisor = { session_id: randomUUID(), owner: agent, connection };
35
- const command = `CODEX_HOME=${shellArgument(deployment.codex_home)} ${shellArgument(node.path)} ${shellArgument(path.join(deployment.directory, "bin/codexteer.mjs"))} --require-node-version ${shellArgument(node.version)} --supervisor ${shellArgument(supervisor.session_id)} --connection ${connection}`;
40
+ const command = `CODEX_HOME=${shellArgument(deployment.codex_home)} ${shellArgument(node.path)} ${shellArgument(path.join(deployment.directory, "bin/codexteer.mjs"))} --require-node-version ${shellArgument(node.version)} ${sessionOptions}`;
36
41
  // Render with the saved distribution's implementation as well as its CLI.
37
42
  // An update to the source/cache after placement cannot mix prompt and code.
38
43
  const { supervisorPrompt } = await import(pathToFileURL(path.join(deployment.directory, "src/prompt.mjs")).href);
39
- return { ...supervisorPrompt(threadId, command, policy, supervisor), deployment, node, supervisor };
44
+ return { ...supervisorPrompt(threadId, command, policy, supervisor), cli_mode: "saved", deployment, node, supervisor };
40
45
  }
41
46
 
42
47
  function launchFailure(agent, error) {
43
48
  return Object.assign(new Error(`Could not start ${agent} (${error.code ?? "START_FAILED"}). Check that it is installed and executable on PATH.`), { code: error.code ?? "START_FAILED" });
44
49
  }
45
50
 
46
- export async function superviseAgent(threadInput, { agent = "claude", agentArgs = [], policy, connection = "shared" } = {}) {
47
- const { prompt, thread_id: threadId, supervisor, deployment } = await prepareSupervisorPrompt(threadInput, policy, { agent, connection });
48
- const storage = { home: deployment.codex_home };
51
+ export async function superviseAgent(threadInput, { agent = "claude", agentArgs = [], policy, connection = "shared", usePathCli = false } = {}) {
52
+ const { prompt, thread_id: threadId, supervisor, deployment } = await prepareSupervisorPrompt(threadInput, policy, { agent, connection, usePathCli });
53
+ const storage = { home: deployment?.codex_home ?? await prepareCodexHome(codexHome()) };
49
54
  await registerSupervisor(threadId, supervisor.session_id, { ...storage, owner: agent, launcherPid: process.pid, connection });
50
55
  try { return await launchSupervisorAgent(agent, agentArgs, prompt); }
51
56
  finally {
@@ -0,0 +1,127 @@
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 } 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 schemas = ["list_projects", "create_thread"].map(name => catalog?.tools?.find(tool => tool.name === name && tool.namespace === "codex_app")?.inputSchema);
30
+ const creation = schemas[1], project = creation?.properties?.target?.anyOf?.find(target => target.properties?.type?.enum?.includes("project"));
31
+ const local = project?.properties?.environment?.anyOf?.find(environment => environment.properties?.type?.enum?.includes("local"));
32
+ 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");
33
+ return { status: "verified", source: "desktop-native-schema", schema_sha256: createHash("sha256").update(JSON.stringify(schemas)).digest("hex") };
34
+ }
35
+
36
+ export async function createProjectThread(selector, prompt, options = {}, dependencies = {}) {
37
+ const settings = creationOptions(options);
38
+ if (typeof selector !== "string" || !selector.trim()) throw failure("--project is required.", "PROJECT_REQUIRED");
39
+ if (typeof prompt !== "string" || !prompt.trim()) throw failure("An initial instruction is required.", "INITIAL_INSTRUCTION_REQUIRED");
40
+ // An environment variable must not silently turn standalone user work into
41
+ // delegated work or inherit another task's permissions.
42
+ if (options.contextThread === undefined) return createStandaloneProjectThread(selector, prompt, { ...settings, dryRun: !!options.dryRun }, dependencies);
43
+ const {
44
+ discover = discoverRuntime, connect = RpcClient.connect, aliases = projectAliases,
45
+ endpoint = discoverNativeEndpoint, inspectEndpoint = inspectNativeEndpoint, inspectCommand = inspectNativeCommandProtocol, native,
46
+ workspace = projectWorkspace, caller = readSendState, history = fetchThread,
47
+ membership = desktopMembership, write = writeRecord, sleep = delay,
48
+ } = dependencies;
49
+ const sourceId = options.contextThread;
50
+ const contextId = normalizeThreadId(sourceId), attemptId = randomUUID();
51
+ let client, receipt, storage, dispatched = false, accepted = false;
52
+ try {
53
+ const { paths, state } = await discover(); storage = { home: paths.home };
54
+ assertRuntimeOperation(state, "read");
55
+ const nativeEndpoint = await endpoint(state);
56
+ const commandProtocol = await inspectCommand(state);
57
+ client = compatibleClient(await connect(paths.socket, { timeoutMs: settings.timeoutMs + 5000 }), state);
58
+ const invokeNative = native ?? ((...args) => nativeOnAppServer(client, ...args));
59
+ const source = await caller(client, contextId), turn = source.turns.at(-1);
60
+ 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");
61
+ const context = { threadId: contextId, turnId: turn.id }, nativeOptions = { timeoutMs: settings.timeoutMs };
62
+ const protocol = { ...validateNativeCreationCatalog(await invokeNative(nativeEndpoint, "catalog", undefined, undefined, nativeOptions)), command: commandProtocol };
63
+ const mapping = await aliases(paths.home), project = resolveProject(await projectsOnClient(client), selector, mapping), scope = await workspace(project);
64
+ const listNative = async () => {
65
+ const inventory = nativeToolJson(await invokeNative(nativeEndpoint, "list_projects", {}, context, nativeOptions));
66
+ if (!Array.isArray(inventory.projects)) throw failure("Desktop returned an invalid project catalog.", "NATIVE_PROTOCOL_ERROR");
67
+ const matches = inventory.projects.filter(item => item.projectKind === "local" && item.hostId === "local" && (mapping[item.projectId] === project.id || item.projectId === project.id));
68
+ 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");
69
+ return matches[0];
70
+ };
71
+ const selected = await listNative();
72
+ const check = async () => {
73
+ const current = await discover();
74
+ 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");
75
+ await inspectEndpoint(nativeEndpoint.socketPath, nativeEndpoint.fingerprint);
76
+ const live = validateProject((await client.request("project/read", { projectId: project.id })).project);
77
+ if (!isDeepStrictEqual(live.roots, project.roots) || live.name !== project.name) throw failure("Project source folders changed before creation.", "PROJECT_CHANGED");
78
+ if ((await listNative()).projectId !== selected.projectId) throw failure("Native project changed before creation.", "PROJECT_CHANGED");
79
+ };
80
+ await check();
81
+ 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,
82
+ context_thread_id: contextId, context_turn_id: turn.id, cwd: scope.cwd, runtime_workspace_roots: scope.runtimeWorkspaceRoots,
83
+ title: settings.title ?? null, backend: "desktop-native-rpc", connection_mode: "shared", protocol, creation_status: "not_created", sent: false, delivery_status: "not_sent",
84
+ verification_status: "not_started", message_characters: [...prompt].length, prompt_sha256: createHash("sha256").update(prompt).digest("hex"), dry_run: !!options.dryRun };
85
+ if (options.dryRun) return receipt;
86
+ receipt.receipt_path = storePath("creations", attemptId, paths.home);
87
+ await write("creations", attemptId, { ...receipt, creation_status: "dispatching" }, storage);
88
+ await check();
89
+ dispatched = true;
90
+ 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]])) };
91
+ const raw = await invokeNative(nativeEndpoint, "create_thread", args, context, { ...nativeOptions, callId: attemptId });
92
+ if (raw.native_request_cleanup_required) receipt.native_request_cleanup_required = true;
93
+ const result = nativeToolJson(raw);
94
+ const id = result.threadId ?? (result.status === "created" ? result.conversationId : undefined);
95
+ if (!id) throw Object.assign(failure("Native creation returned no child ID. Do not retry automatically.", "NATIVE_CREATION_UNKNOWN"), { uncertain: true });
96
+ const threadId = normalizeThreadId(id);
97
+ accepted = !!result.threadId || result.firstTurn?.status === "accepted";
98
+ Object.assign(receipt, { thread_id: threadId, deep_link: threadDeepLink(threadId), creation_status: "created", sent: accepted ? true : null, delivery_status: accepted ? "accepted" : "unknown" });
99
+ await write("creations", attemptId, receipt, storage);
100
+ if (!accepted) throw failure("Child exists but its initial turn was not confirmed. Inspect the child before resending.", "INITIAL_TURN_UNCONFIRMED");
101
+ const thread = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
102
+ if (thread?.id !== threadId || thread.cwd !== scope.cwd || !await membership(paths.home, threadId, selected.projectId)) throw failure("Created child did not confirm the selected Desktop project.", "DESKTOP_MEMBERSHIP_MISMATCH");
103
+ const roots = thread.environments?.find(environment => environment.environmentId === "local")?.runtimeWorkspaceRoots;
104
+ 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");
105
+ let saved = await history(client, threadId), named = thread;
106
+ // Native acceptance can precede the first input's persisted history item.
107
+ // Wait only for read-side materialization; never repeat create or turn/start.
108
+ const ready = () => saved.turns?.[0]?.items?.some(item => item.type === "userMessage" || (item.type === "functionCallOutput" && item.namespace === "codex_app" && item.name === "create_thread"));
109
+ for (let retry = 0; retry < 20 && (!ready() || (settings.title !== undefined && named.name == null)); retry++) {
110
+ await sleep(250);
111
+ saved = await history(client, threadId);
112
+ named = (await client.request("thread/read", { threadId, includeTurns: false })).thread;
113
+ }
114
+ 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");
115
+ if (thread.projectId !== project.id) await client.request("thread/metadata/update", { threadId, projectId: project.id }, { mutation: true });
116
+ if ((await client.request("thread/read", { threadId, includeTurns: false })).thread?.projectId !== project.id) throw failure("Canonical project assignment was not confirmed.", "CREATION_SCOPE_MISMATCH");
117
+ Object.assign(receipt, { verification_status: "verified", desktop_project_membership: "verified", app_server_project_membership: "verified", initial_instruction: "verified", desktop_subscription: "native-owned" });
118
+ try { await write("creations", attemptId, receipt, storage); } catch { receipt.receipt_update_required = true; }
119
+ return receipt;
120
+ } catch (error) {
121
+ const created = !!receipt?.thread_id, unknown = !accepted && (created || (dispatched && mutationOutcomeUnknown(error)));
122
+ Object.assign(error, { attempt_id: attemptId, native_call_id: attemptId, thread_id: receipt?.thread_id, receipt_path: receipt?.receipt_path,
123
+ creation_status: created ? "created" : unknown ? "unknown" : "not_created", sent: accepted ? true : unknown ? null : false, delivery_status: accepted ? "accepted" : unknown ? "unknown" : "not_sent" });
124
+ 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; }
125
+ throw error;
126
+ } finally { try { client?.close(); } catch { if (accepted && receipt) receipt.runtime_cleanup_required = true; } }
127
+ }
package/src/wrapper.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { chmod, stat } from "node:fs/promises";
2
+ import { accessSync, constants as fsConstants, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { once } from "node:events";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
+ import path from "node:path";
5
6
  import { claimRuntime } from "./runtime.mjs";
6
7
  import { connectSocket } from "./rpc.mjs";
7
8
  import { relay } from "./bridge.mjs";
@@ -9,8 +10,61 @@ import { DesktopSubscriptions, serveSubscriptions } from "./subscription.mjs";
9
10
  import { VERSION, PACKAGE_NAME } from "./version.mjs";
10
11
  import { RUNTIME_PROTOCOL, RUNTIME_CAPABILITIES } from "./compatibility.mjs";
11
12
  import { describeDistribution } from "./distribution.mjs";
13
+ import { inspectAppServerSocket } from "./socket-endpoint.mjs";
12
14
 
13
- export const BUNDLED_CLI = "/Applications/ChatGPT.app/Contents/Resources/codex";
15
+ const DESKTOP_RESOURCES = "/Applications/ChatGPT.app/Contents/Resources";
16
+ const LEGACY_BUNDLED_CLI = path.join(DESKTOP_RESOURCES, "codex");
17
+
18
+ function isWithinDirectory(directory, candidate) {
19
+ const relative = path.relative(directory, candidate);
20
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
21
+ }
22
+
23
+ function resolveManifestCli(packageDirectory, resourcesDirectory) {
24
+ try {
25
+ const root = realpathSync(packageDirectory);
26
+ if (!isWithinDirectory(resourcesDirectory, root)) return null;
27
+ const manifest = JSON.parse(readFileSync(path.join(root, "codex-package.json"), "utf8"));
28
+ if ((manifest.variant != null && manifest.variant !== "codex")
29
+ || (manifest.layoutVersion != null && (!Number.isSafeInteger(manifest.layoutVersion) || manifest.layoutVersion < 1))
30
+ || typeof manifest.entrypoint !== "string" || manifest.entrypoint.length === 0
31
+ || manifest.entrypoint.includes("\0") || path.isAbsolute(manifest.entrypoint)
32
+ || manifest.entrypoint.split(/[\\/]/).some(part => part === "" || part === "." || part === "..")) return null;
33
+
34
+ const entrypoint = realpathSync(path.resolve(root, manifest.entrypoint));
35
+ if (!isWithinDirectory(root, entrypoint) || !statSync(entrypoint).isFile()) return null;
36
+ accessSync(entrypoint, fsConstants.X_OK);
37
+ return entrypoint;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function resolveBundledCli() {
44
+ try {
45
+ const resourcesDirectory = realpathSync(DESKTOP_RESOURCES);
46
+ const packages = readdirSync(resourcesDirectory, { withFileTypes: true })
47
+ .filter(entry => entry.isDirectory() || entry.isSymbolicLink())
48
+ .map(entry => path.join(resourcesDirectory, entry.name))
49
+ .sort();
50
+ const manifestCli = packages.map(directory => resolveManifestCli(directory, resourcesDirectory)).find(Boolean);
51
+ if (manifestCli) return manifestCli;
52
+ } catch {
53
+ // Fall through to the pre-manifest app layout.
54
+ }
55
+
56
+ try {
57
+ if (statSync(LEGACY_BUNDLED_CLI).isFile()) {
58
+ accessSync(LEGACY_BUNDLED_CLI, fsConstants.X_OK);
59
+ return LEGACY_BUNDLED_CLI;
60
+ }
61
+ } catch {
62
+ // Preserve the legacy path for a useful ENOENT diagnostic at startup.
63
+ }
64
+ return LEGACY_BUNDLED_CLI;
65
+ }
66
+
67
+ export const BUNDLED_CLI = resolveBundledCli();
14
68
  export const BUNDLED_NODE = "/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node";
15
69
  const DESKTOP_EXECUTABLE = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT";
16
70
 
@@ -95,6 +149,7 @@ export async function runWrapper(args, { executable = BUNDLED_CLI, env = process
95
149
  codex_steer_package: PACKAGE_NAME, codex_steer_version: VERSION, codex_steer_protocol: RUNTIME_PROTOCOL,
96
150
  codex_steer_capabilities: RUNTIME_CAPABILITIES,
97
151
  distribution_sha256: distribution.sha256,
152
+ native_tools_pipe: env.CODEX_APP_TOOLS_PIPE_PATH ?? null,
98
153
  });
99
154
  let child;
100
155
  let socket;
@@ -110,14 +165,22 @@ export async function runWrapper(args, { executable = BUNDLED_CLI, env = process
110
165
  });
111
166
  let spawnError;
112
167
  child.on("error", error => { spawnError = error; });
168
+ let endpoint;
169
+ let socketError;
113
170
  for (let i = 0; i < 100; i++) {
114
171
  if (interrupted || spawnError || child.exitCode != null || child.signalCode != null) throw new Error("App Server exited during startup.");
115
- if (await stat(lease.paths.socket).catch(() => null)) break;
172
+ try {
173
+ endpoint = await inspectAppServerSocket(lease.paths.socket);
174
+ break;
175
+ } catch (error) {
176
+ if (error.code !== "ENOENT") throw error;
177
+ socketError = error;
178
+ }
116
179
  await delay(100);
117
180
  }
118
- await chmod(lease.paths.socket, 0o600);
181
+ if (!endpoint) throw socketError ?? new Error("App Server socket did not become ready during startup.");
119
182
  await lease.update({ server_pid: child.pid });
120
- socket = await connectSocket(lease.paths.socket);
183
+ socket = await connectSocket(endpoint.socket);
121
184
  subscriptions = new DesktopSubscriptions(socket);
122
185
  stopSubscriptions = await serveSubscriptions(lease.paths.control, subscriptions);
123
186
  let initId;