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
package/src/update.mjs ADDED
@@ -0,0 +1,158 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { mkdir, lstat, readFile, writeFile, rename, rmdir, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { codexHome } from "./runtime.mjs";
7
+ import { prepareCodexHome, prepareDeployment } from "./distribution.mjs";
8
+ import { APP_PATH, startDesktop, appServerDoctor } from "./launcher.mjs";
9
+
10
+ const script = fileURLToPath(new URL("../scripts/update-desktop.applescript", import.meta.url));
11
+ const error = (code, message) => Object.assign(new Error(message), { code });
12
+ function run(command, args) {
13
+ const result = spawnSync(command, args, { encoding: "utf8", timeout: 10000 });
14
+ if (result.error || result.status !== 0) throw error("UPDATE_COMMAND_FAILED", result.error?.message ?? result.stderr?.trim() ?? `${command} failed`);
15
+ return result.stdout.trim();
16
+ }
17
+ export function updateUi(operation) { return run("/usr/bin/osascript", [script, operation]); }
18
+ export function updateBuild() {
19
+ return run("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", `${APP_PATH}/Contents/Info.plist`]);
20
+ }
21
+ export function desktopPids() {
22
+ return run("/bin/ps", ["-axo", "pid=,comm="]).split("\n").flatMap(line => {
23
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
24
+ return match?.[2] === `${APP_PATH}/Contents/MacOS/ChatGPT` ? [Number(match[1])] : [];
25
+ });
26
+ }
27
+
28
+ // Separate orchestration from platform effects to exercise restarts/failures without
29
+ // updating the developer's app. No signal-based termination or task resumption.
30
+ export async function performUpdate({ timeoutMs = 600000, initialBuild, initialPids }, {
31
+ build = updateBuild, pids = desktopPids, ui = updateUi,
32
+ quit = () => run("/usr/bin/osascript", ["-e", 'tell application id "com.openai.codex" to quit']),
33
+ start = startDesktop, doctor = appServerDoctor, save = async () => {}, sleep = delay, now = Date.now,
34
+ } = {}) {
35
+ const deadline = now() + timeoutMs;
36
+ let lastPhase = "checking", newBuild;
37
+ await save({ phase: lastPhase, initial_build: initialBuild });
38
+ await ui("check");
39
+ while (now() < deadline) {
40
+ const running = await pids();
41
+ try { newBuild = await build(); }
42
+ catch (e) {
43
+ // Sparkle can temporarily move the bundle away during installation.
44
+ if (!running.length && ["downloading", "installing"].includes(lastPhase)) { await sleep(1000); continue; }
45
+ throw e;
46
+ }
47
+ // Both the on-disk build and process generation must change before quitting
48
+ // the relaunched app. Do not interrupt Sparkle's installation or its old app.
49
+ if (newBuild !== initialBuild && running.length && running.every(pid => !initialPids.includes(pid))) break;
50
+ if (running.length && newBuild === initialBuild && lastPhase !== "installing") {
51
+ let phase;
52
+ try { phase = await ui("advance"); }
53
+ catch (e) {
54
+ // The app can exit between the process snapshot and the AX request.
55
+ if (!(await pids()).length) { await sleep(1000); continue; }
56
+ throw e;
57
+ }
58
+ if (phase === "up_to_date") return { phase, initial_build: initialBuild, final_build: newBuild, wrapper_verified: false };
59
+ if (["downloading", "installing"].includes(phase) && phase !== lastPhase) {
60
+ lastPhase = phase; await save({ phase });
61
+ }
62
+ }
63
+ await sleep(1000);
64
+ }
65
+ if (now() >= deadline) throw error("UPDATE_TIMEOUT", "Update/relaunch did not complete in time. Inspect Codex or its updater for dialogs. No processes were forcibly stopped.");
66
+ await save({ phase: "restoring_wrapper", final_build: newBuild });
67
+ await quit();
68
+ const quitDeadline = now() + 30000;
69
+ while ((await pids()).length) {
70
+ if (now() >= quitDeadline) throw error("UPDATE_QUIT_TIMEOUT", "Codex did not quit normally. Finish its work, quit it, and run codexteer desktop start.");
71
+ await sleep(500);
72
+ }
73
+ await start();
74
+ const report = await doctor();
75
+ if (!report.ready || report.connection?.mode !== "shared" || !report.checks?.bundled_wrapper_node || report.cli_version_status !== "matched") {
76
+ throw error("UPDATE_WRAPPER_UNVERIFIED", "Update installed, but wrapper verification failed. Run codexteer doctor --json.");
77
+ }
78
+ return { phase: "complete", initial_build: initialBuild, final_build: newBuild, wrapper_verified: true, doctor: report };
79
+ }
80
+
81
+ async function privateDirectory(directory) {
82
+ await mkdir(directory, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
83
+ const info = await lstat(directory);
84
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o077)) throw error("UPDATE_STATE_UNSAFE", `Unsafe update state directory: ${directory}`);
85
+ }
86
+ async function stateRoot(requestedHome = codexHome()) {
87
+ const home = await prepareCodexHome(requestedHome);
88
+ const parent = path.join(home, "codex-steer");
89
+ await privateDirectory(parent);
90
+ const root = path.join(parent, "desktop-update");
91
+ await privateDirectory(root);
92
+ return { home, root };
93
+ }
94
+ async function writeStatus(root, state) {
95
+ const temporary = path.join(root, `status-${process.pid}.tmp`);
96
+ await writeFile(temporary, JSON.stringify({ ...state, updated_at: new Date().toISOString() }) + "\n", { mode: 0o600, flag: "wx" });
97
+ await rename(temporary, path.join(root, "status.json"));
98
+ }
99
+ export async function desktopUpdateStatus() {
100
+ const { root } = await stateRoot();
101
+ try {
102
+ const file = path.join(root, "status.json"), info = await lstat(file);
103
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o077)) throw error("UPDATE_STATE_UNSAFE", "Unsafe update status file.");
104
+ const state = JSON.parse(await readFile(file, "utf8"));
105
+ if (state.worker_pid && !["complete", "up_to_date", "failed"].includes(state.phase)) {
106
+ try { process.kill(state.worker_pid, 0); state.worker_alive = true; }
107
+ catch (e) { if (e.code !== "ESRCH") throw e; state.worker_alive = false; }
108
+ }
109
+ return state;
110
+ } catch (e) { if (e.code === "ENOENT") return { phase: "not_started" }; throw e; }
111
+ }
112
+ export async function launchDesktopUpdate({ dryRun = false, timeoutMs = 600000 } = {}, {
113
+ platform = process.platform, executablePath = process.execPath, pids = desktopPids,
114
+ ui = updateUi, build = updateBuild, prepare = prepareDeployment, spawnWorker = spawn,
115
+ home: requestedHome = codexHome(),
116
+ } = {}) {
117
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10000 || timeoutMs > 3600000) throw error("UPDATE_ARGUMENT", "--timeout-ms must be an integer between 10000 and 3600000.");
118
+ if (dryRun) return { dry_run: true, started: false, timeout_ms: timeoutMs, steps: ["official_update_ui", "wait_for_new_build_and_relaunch", "normal_quit", "wrapper_start", "doctor"] };
119
+ if (platform !== "darwin") throw error("UPDATE_PLATFORM", "Desktop update requires macOS.");
120
+ if (process.env.CODEX_APP_SERVER_WS_URL?.trim()) throw error("UPDATE_ENVIRONMENT", "Remove CODEX_APP_SERVER_WS_URL before updating with a shared wrapper.");
121
+ const executable = await realpath(executablePath);
122
+ if (executable.includes(".app/Contents/")) throw error("UPDATE_NODE", "Run desktop update with an external Node installation, so replacing the app cannot remove the worker runtime.");
123
+ const initialPids = pids();
124
+ if (initialPids.length !== 1) throw error("UPDATE_APP_STATE", "Exactly one running Codex Desktop is required.");
125
+ if (ui("preflight") !== "checking") throw error("UPDATE_UI_UNAVAILABLE", "Codex update menu is unavailable. Allow Accessibility/Automation for your terminal and retry.");
126
+ const initialBuild = build();
127
+ const { home, root } = await stateRoot(requestedHome);
128
+ const deployment = await prepare(home);
129
+ const lock = path.join(root, "lock");
130
+ await mkdir(lock, { mode: 0o700 }).catch(e => {
131
+ if (e.code === "EEXIST") throw error("UPDATE_BUSY", `An update lock exists. Check desktop update-status; if its worker has stopped, inspect the app before manually removing ${lock}.`);
132
+ throw e;
133
+ });
134
+ try {
135
+ await writeStatus(root, { phase: "starting", initial_build: initialBuild });
136
+ const child = spawnWorker(executable, [path.join(deployment.directory, "bin/codexteer-update.mjs"), JSON.stringify({ root, timeoutMs, initialBuild, initialPids })], {
137
+ detached: true, stdio: "ignore", cwd: deployment.directory, env: { ...process.env, CODEX_HOME: home },
138
+ });
139
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
140
+ child.unref();
141
+ return { started: true, worker_pid: child.pid, status_command: "codexteer desktop update-status --json", status_path: path.join(root, "status.json") };
142
+ } catch (e) { await writeStatus(root, { phase: "failed", error: e.message }); await rmdir(lock); throw e; }
143
+ }
144
+ export async function updateWorker(options) {
145
+ let state = { phase: "starting", worker_pid: process.pid, initial_build: options.initialBuild };
146
+ const save = async patch => { state = { ...state, ...patch }; await writeStatus(options.root, state); };
147
+ try {
148
+ await save({});
149
+ const running = desktopPids();
150
+ if (updateBuild() !== options.initialBuild || running.length !== 1 || running[0] !== options.initialPids[0]) {
151
+ throw error("UPDATE_APP_CHANGED", "Codex changed after preflight. Inspect its update state before retrying.");
152
+ }
153
+ await save(await performUpdate(options, { save }));
154
+ } catch (e) {
155
+ await save({ phase: "failed", error: { code: e.code ?? "UPDATE_FAILED", message: e.message }, recovery: "Inspect Codex and the updater. After installation finishes, quit normally and run codexteer desktop start, then codexteer doctor --json." });
156
+ process.exitCode = 1;
157
+ } finally { await rmdir(path.join(options.root, "lock")); }
158
+ }
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;