codexteer 0.17.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.
- package/CHANGELOG.md +28 -1
- package/README.md +127 -14
- package/assets/remote-device-key.swift +94 -0
- package/bin/codexteer-update.mjs +3 -0
- package/bin/codexteer.mjs +6 -2
- package/docs/cloud.md +113 -0
- package/docs/desktop-cloud.md +36 -0
- package/docs/distribution.md +11 -19
- package/docs/orchestration.md +120 -0
- package/docs/remote-cli-validation.json +139 -0
- package/docs/remote-control.md +65 -0
- package/docs/remote-device-enrollment.md +91 -0
- package/docs/remote-observation.md +57 -0
- package/docs/remote-operations.md +89 -0
- package/docs/thread-create.md +20 -45
- package/package.json +11 -2
- package/scripts/update-desktop.applescript +51 -0
- package/src/agent-launch.mjs +27 -0
- package/src/app-server.mjs +34 -10
- package/src/checkpoint.mjs +52 -16
- package/src/cli.mjs +201 -23
- package/src/cloud-api.mjs +118 -0
- package/src/cloud-cli.mjs +319 -0
- package/src/cloud-connection.mjs +64 -0
- package/src/cloud-id.mjs +16 -0
- package/src/cloud-observe.mjs +178 -0
- package/src/cloud-send.mjs +33 -0
- package/src/cloud.mjs +172 -0
- package/src/connection.mjs +12 -1
- package/src/creation-verification.mjs +30 -10
- package/src/desktop-cloud-api.mjs +119 -0
- package/src/desktop-cloud-id.mjs +18 -0
- package/src/desktop-cloud-project.mjs +148 -0
- package/src/desktop-cloud.mjs +98 -0
- package/src/desktop.mjs +3 -1
- package/src/directive.mjs +5 -2
- package/src/distribution.mjs +2 -2
- package/src/evidence.mjs +3 -0
- package/src/findings.mjs +7 -7
- package/src/help.mjs +140 -28
- package/src/journal.mjs +37 -9
- package/src/launcher.mjs +16 -9
- package/src/monitor.mjs +18 -6
- package/src/native-command.mjs +10 -2
- package/src/native-rpc.mjs +15 -2
- package/src/observe.mjs +21 -7
- package/src/orchestrate-prompt.mjs +96 -0
- package/src/orchestrate.mjs +29 -0
- package/src/paged-read.mjs +7 -1
- package/src/projects.mjs +27 -2
- package/src/prompt-command.mjs +25 -0
- package/src/prompt.mjs +25 -8
- package/src/remote-client-info.mjs +16 -0
- package/src/remote-control-auth.mjs +109 -0
- package/src/remote-control-socket.mjs +137 -0
- package/src/remote-control.mjs +134 -0
- package/src/remote-device-key.mjs +41 -0
- package/src/remote-device-login.mjs +76 -0
- package/src/remote-device-protocol.mjs +72 -0
- package/src/remote-device-store.mjs +71 -0
- package/src/remote-device-worker.mjs +48 -0
- package/src/remote-device.mjs +202 -0
- package/src/remote-io.mjs +163 -0
- package/src/remote-key-access.mjs +76 -0
- package/src/remote-key-worker.mjs +38 -0
- package/src/remote-keychain.mjs +72 -0
- package/src/remote-management.mjs +234 -0
- package/src/remote.mjs +132 -0
- package/src/resource.mjs +8 -6
- package/src/runner.mjs +6 -1
- package/src/standalone-create.mjs +4 -3
- package/src/store.mjs +6 -3
- package/src/supervise.mjs +25 -53
- package/src/supervision.mjs +41 -19
- package/src/target.mjs +25 -0
- package/src/task-id.mjs +23 -0
- package/src/thread-create.mjs +4 -3
- package/src/thread-id.mjs +21 -6
- package/src/thread-verify.mjs +108 -0
- package/src/turn-repair.mjs +228 -0
- package/src/update.mjs +158 -0
- package/docs/thread-create-native-investigation.md +0 -136
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { copyFile, lstat, mkdir, open, readdir, realpath, rename } from "node:fs/promises";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { codexHome } from "./runtime.mjs";
|
|
7
|
+
import { normalizeThreadId } from "./thread-id.mjs";
|
|
8
|
+
import { withStoreLock } from "./store.mjs";
|
|
9
|
+
import { BUNDLED_CLI } from "./wrapper.mjs";
|
|
10
|
+
|
|
11
|
+
// This is an offline rollout-format repair, not an App Server interrupt API.
|
|
12
|
+
// Only enable a version after the isolated replay test has verified its parser.
|
|
13
|
+
export const REPAIR_CLI_VERSIONS = Object.freeze(["0.162.0-alpha.17.2", "0.164.0-alpha.11.1"]);
|
|
14
|
+
const failure = (message, code) => Object.assign(new Error(message), { code });
|
|
15
|
+
const invalid = message => failure(message, "REPAIR_HISTORY_UNSUPPORTED");
|
|
16
|
+
const stamp = s => [s.dev, s.ino, s.size, s.mtimeMs, s.ctimeMs].join(":");
|
|
17
|
+
const identity = s => [s.dev, s.ino, s.size, s.uid, s.gid, s.mode, s.nlink].join(":");
|
|
18
|
+
const uuid = value => typeof value === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
|
19
|
+
|
|
20
|
+
async function owned(file, directory = false) {
|
|
21
|
+
const s = await lstat(file);
|
|
22
|
+
if (s.isSymbolicLink() || s.uid !== process.getuid() || (s.mode & 0o022)
|
|
23
|
+
|| (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1)) {
|
|
24
|
+
throw failure("Unsafe history path, ownership, permissions, or hard link.", "REPAIR_UNSAFE_PATH");
|
|
25
|
+
}
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function findRepairRollout(home, threadId) {
|
|
30
|
+
const found = [];
|
|
31
|
+
async function walk(dir) {
|
|
32
|
+
await owned(dir, true);
|
|
33
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
34
|
+
const file = path.join(dir, entry.name);
|
|
35
|
+
if (entry.name.endsWith(`-${threadId}.jsonl`)) { await owned(file); found.push(file); }
|
|
36
|
+
else if (entry.isDirectory()) await walk(file);
|
|
37
|
+
else if (entry.isSymbolicLink()) throw failure("A history directory contains a symbolic link; inspect it before repair.", "REPAIR_UNSAFE_PATH");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const root of ["sessions", "archived_sessions"]) {
|
|
41
|
+
try { await walk(path.join(home, root)); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
42
|
+
}
|
|
43
|
+
if (found.length !== 1) throw failure(`Expected one rollout for this thread; found ${found.length}.`, "REPAIR_ROLLOUT_AMBIGUOUS");
|
|
44
|
+
return found[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Stream JSONL, parsing each record transiently. Return turn metadata and hashes,
|
|
48
|
+
// without including conversation item contents in the inspection report.
|
|
49
|
+
export async function scanRepairRollout(file, threadId) {
|
|
50
|
+
const before = await owned(file), handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
51
|
+
const hash = createHash("sha256"), turns = new Map();
|
|
52
|
+
let carry = Buffer.alloc(0), ordinal = 0, metadata, lastRoot;
|
|
53
|
+
const parse = line => {
|
|
54
|
+
let r;
|
|
55
|
+
try { r = JSON.parse(line.toString("utf8")); } catch { throw invalid("History contains invalid JSON; nothing can be repaired automatically."); }
|
|
56
|
+
if (r.ordinal !== ordinal++) throw invalid("History ordinals are missing or discontinuous.");
|
|
57
|
+
if (ordinal === 1) {
|
|
58
|
+
if (r.type !== "session_meta" || r.payload?.id !== threadId || r.payload?.history_mode !== "paginated") throw invalid("Repair requires this thread's paginated rollout metadata.");
|
|
59
|
+
metadata = r.payload;
|
|
60
|
+
} else if (r.type === "session_meta") throw invalid("Multiple session metadata records are not supported.");
|
|
61
|
+
if (r.type !== "event_msg") return;
|
|
62
|
+
const p = r.payload;
|
|
63
|
+
if (p?.type === "task_started") {
|
|
64
|
+
if (!uuid(p.turn_id) || turns.has(p.turn_id)) throw invalid("Repeated or unidentified turn starts cannot be repaired automatically.");
|
|
65
|
+
const independent = (p.root_turn_id == null || p.root_turn_id === p.turn_id)
|
|
66
|
+
&& (!p.turn_attribution || (p.turn_attribution.turn_id === p.turn_id && (p.turn_attribution.root_turn_id == null || p.turn_attribution.root_turn_id === p.turn_id) && p.turn_attribution.parent_turn_id == null));
|
|
67
|
+
const turn = { turn_id: p.turn_id, start_ordinal: r.ordinal, started_at: p.started_at ?? null, successor_id: null, terminal: null, independent };
|
|
68
|
+
if (independent) { if (lastRoot) lastRoot.successor_id = p.turn_id; lastRoot = turn; }
|
|
69
|
+
turns.set(p.turn_id, turn);
|
|
70
|
+
} else if (["task_complete", "turn_aborted"].includes(p?.type)) {
|
|
71
|
+
const t = turns.get(p.turn_id);
|
|
72
|
+
if (!t || t.terminal) throw invalid("Unmatched or repeated terminal records need manual inspection.");
|
|
73
|
+
t.terminal = p.type;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
if (stamp(await handle.stat()) !== stamp(before)) throw failure("History changed before inspection.", "REPAIR_HISTORY_CHANGED");
|
|
78
|
+
for await (const chunk of handle.createReadStream({ autoClose: false, highWaterMark: 1024 * 1024 })) {
|
|
79
|
+
hash.update(chunk);
|
|
80
|
+
const buffer = carry.length ? Buffer.concat([carry, chunk]) : chunk;
|
|
81
|
+
let from = 0, end;
|
|
82
|
+
while ((end = buffer.indexOf(10, from)) !== -1) {
|
|
83
|
+
if (end - from > 64 * 1024 * 1024) throw invalid("A history record exceeds the repair size limit.");
|
|
84
|
+
parse(buffer.subarray(from, end)); from = end + 1;
|
|
85
|
+
}
|
|
86
|
+
carry = Buffer.from(buffer.subarray(from));
|
|
87
|
+
if (carry.length > 64 * 1024 * 1024) throw invalid("A history record exceeds the repair size limit.");
|
|
88
|
+
}
|
|
89
|
+
if (carry.length || !metadata) throw invalid("History is empty or ends with an incomplete record. Nothing was changed.");
|
|
90
|
+
if (stamp(await handle.stat()) !== stamp(before) || stamp(await owned(file)) !== stamp(before)) throw failure("History changed during inspection. Retry after work has stopped.", "REPAIR_HISTORY_CHANGED");
|
|
91
|
+
const unclosed = [...turns.values()].filter(t => !t.terminal);
|
|
92
|
+
return { file, thread_id: threadId, source: before, sha256: hash.copy().digest("hex"), hash,
|
|
93
|
+
next_ordinal: ordinal, candidates: unclosed.filter(t => t.independent && t.successor_id), unresolved_latest: unclosed.filter(t => !t.successor_id) };
|
|
94
|
+
} finally { await handle.close(); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function repairProcessBlockers(stdout) {
|
|
98
|
+
if (!stdout.trim()) throw failure("Empty process inventory; repair is blocked.", "REPAIR_OFFLINE_UNVERIFIED");
|
|
99
|
+
return stdout.split("\n").filter(Boolean).flatMap(line => {
|
|
100
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
101
|
+
if (!match) throw failure("Cannot parse process inventory; repair is blocked.", "REPAIR_OFFLINE_UNVERIFIED");
|
|
102
|
+
const name = path.basename(match[2].trim());
|
|
103
|
+
return /^(?:codex(?:[-_].*)?|chatgpt|codex desktop)$/i.test(name) ? [{ pid: Number(match[1]), executable: name }] : [];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function requireRepairOffline() {
|
|
108
|
+
if (process.platform !== "darwin") throw failure("Offline repair is currently verified on macOS only.", "REPAIR_OFFLINE_UNVERIFIED");
|
|
109
|
+
let output;
|
|
110
|
+
try { output = execFileSync("/bin/ps", ["-axo", "pid=,comm="], { encoding: "utf8", timeout: 5000, maxBuffer: 4 * 1024 * 1024 }); }
|
|
111
|
+
catch { throw failure("Cannot inspect processes. Run repair from an external terminal after quitting Codex.", "REPAIR_OFFLINE_UNVERIFIED"); }
|
|
112
|
+
const blockers = repairProcessBlockers(output);
|
|
113
|
+
if (blockers.length) throw failure(`Quit Codex/ChatGPT and all Codex CLI/app-server processes first (${blockers.map(p => `${p.executable} PID ${p.pid}`).join(", ")}). Run --apply from an external terminal; this command does not stop them.`, "REPAIR_REQUIRES_OFFLINE");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function repairRuntimeVersion() {
|
|
117
|
+
let version;
|
|
118
|
+
try { version = execFileSync(BUNDLED_CLI, ["--version"], { encoding: "utf8", timeout: 5000 }).trim().replace(/^codex-cli /, ""); }
|
|
119
|
+
catch { throw failure("Cannot verify the bundled Codex parser version.", "REPAIR_RUNTIME_UNVERIFIED"); }
|
|
120
|
+
if (!REPAIR_CLI_VERSIONS.includes(version)) throw failure(`Offline repair has not been verified with Codex ${version}. No history was changed.`, "REPAIR_RUNTIME_UNVERIFIED");
|
|
121
|
+
return version;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function report(scan, apply) {
|
|
125
|
+
return { thread_id: scan.thread_id, rollout_path: scan.file, mode: apply ? "apply" : "dry-run", status: scan.candidates.length ? "residual_candidates" : "no_residual_candidates",
|
|
126
|
+
candidates: scan.candidates, unresolved_latest: scan.unresolved_latest, source_sha256: scan.sha256,
|
|
127
|
+
history_changed: false, verification: "persisted-rollout-only", requires_offline: true,
|
|
128
|
+
note: "Candidates have a later top-level turn start and no terminal record. The latest unfinished turn is never repaired. Live API idle/interrupted alone does not prove persisted repair." };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function privateDirectory(dir) {
|
|
132
|
+
await mkdir(dir, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
|
|
133
|
+
const s = await owned(dir, true);
|
|
134
|
+
if (s.mode & 0o077) throw failure("Repair backup directories must be private (0700).", "REPAIR_UNSAFE_PATH");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function saveManifest(file, data) {
|
|
138
|
+
const temp = `${file}.tmp`;
|
|
139
|
+
const h = await open(temp, "wx", 0o600);
|
|
140
|
+
try { await h.writeFile(JSON.stringify(data, null, 2) + "\n"); await h.sync(); } finally { await h.close(); }
|
|
141
|
+
await rename(temp, file);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Opening a writable descriptor can coincide with metadata-only changes (for
|
|
145
|
+
// example filesystem/provenance bookkeeping). A timestamp mismatch alone does
|
|
146
|
+
// not establish a content change. Hash the exact descriptor we will append to,
|
|
147
|
+
// while requiring stable identity, permissions and metadata throughout the read.
|
|
148
|
+
export async function verifyRepairAppendHandle(handle, file, scan) {
|
|
149
|
+
const before = await handle.stat(), pathBefore = await owned(file);
|
|
150
|
+
const details = { expected: stamp(scan.source), descriptor: stamp(before), path: stamp(pathBefore) };
|
|
151
|
+
const changed = message => Object.assign(failure(message, "REPAIR_HISTORY_CHANGED"), { repair_stat: details });
|
|
152
|
+
if (identity(before) !== identity(scan.source) || identity(pathBefore) !== identity(scan.source) || stamp(before) !== stamp(pathBefore)) {
|
|
153
|
+
throw changed("History identity, size or permissions changed before append.");
|
|
154
|
+
}
|
|
155
|
+
const hash = createHash("sha256");
|
|
156
|
+
for await (const chunk of handle.createReadStream({ start: 0, autoClose: false, highWaterMark: 1024 * 1024 })) hash.update(chunk);
|
|
157
|
+
if (hash.digest("hex") !== scan.sha256) throw changed("History contents changed before append.");
|
|
158
|
+
const after = await handle.stat(), pathAfter = await owned(file);
|
|
159
|
+
if (stamp(after) !== stamp(before) || stamp(pathAfter) !== stamp(before) || identity(after) !== identity(before) || identity(pathAfter) !== identity(before)) {
|
|
160
|
+
throw changed("History changed while verifying the append descriptor.");
|
|
161
|
+
}
|
|
162
|
+
return { source: after, metadata_revalidated: stamp(before) !== stamp(scan.source) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const openAppendHandle = file => open(file, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW);
|
|
166
|
+
|
|
167
|
+
export async function repairTurns(threadInput, { apply = false, home = codexHome() } = {},
|
|
168
|
+
{ offline = requireRepairOffline, runtimeVersion = repairRuntimeVersion, now = () => new Date(), openForAppend = openAppendHandle } = {}) {
|
|
169
|
+
const threadId = normalizeThreadId(threadInput);
|
|
170
|
+
home = await realpath(home); await owned(home, true);
|
|
171
|
+
if (apply) { await offline(); await runtimeVersion(); }
|
|
172
|
+
const work = async () => {
|
|
173
|
+
const file = await findRepairRollout(home, threadId), scan = await scanRepairRollout(file, threadId), data = report(scan, apply);
|
|
174
|
+
if (!apply || !scan.candidates.length) return data;
|
|
175
|
+
// The trailing live/unfinished turn needs its own proper shutdown first.
|
|
176
|
+
if (scan.unresolved_latest.length) throw failure("The latest turn also lacks a terminal record. Stop it normally before repairing older turns.", "REPAIR_LATEST_UNFINISHED");
|
|
177
|
+
await offline();
|
|
178
|
+
const version = await runtimeVersion(), repairedAt = now().toISOString();
|
|
179
|
+
const append = Buffer.from(scan.candidates.map((t, i) => JSON.stringify({ timestamp: repairedAt, ordinal: scan.next_ordinal + i, type: "event_msg",
|
|
180
|
+
payload: { type: "turn_aborted", turn_id: t.turn_id, root_turn_id: t.turn_id, reason: "interrupted", ...(t.started_at == null ? {} : { started_at: t.started_at }) },
|
|
181
|
+
})).join("\n") + "\n");
|
|
182
|
+
// Do not invent completed_at/duration_ms: the historical stop time is unknown.
|
|
183
|
+
const expectedHash = scan.hash.copy().update(append).digest("hex");
|
|
184
|
+
const root = path.join(home, "codex-steer"), backups = path.join(root, "turn-repairs");
|
|
185
|
+
await privateDirectory(root); await privateDirectory(backups);
|
|
186
|
+
const directory = path.join(backups, randomUUID()); await privateDirectory(directory);
|
|
187
|
+
const backup = path.join(directory, "rollout.jsonl"), manifestFile = path.join(directory, "repair.json");
|
|
188
|
+
await copyFile(file, backup, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
|
|
189
|
+
const backupHandle = await open(backup, "r+");
|
|
190
|
+
try { await backupHandle.chmod(0o600); await backupHandle.sync(); } finally { await backupHandle.close(); }
|
|
191
|
+
const saved = await scanRepairRollout(backup, threadId);
|
|
192
|
+
if (saved.sha256 !== scan.sha256) throw failure("Backup does not match inspected history. Original history was not changed.", "REPAIR_HISTORY_CHANGED");
|
|
193
|
+
const manifest = { schema: 1, thread_id: threadId, rollout_path: file, backup_path: backup, repaired_at: repairedAt, cli_version: version,
|
|
194
|
+
original_sha256: scan.sha256, expected_sha256: expectedHash, original_bytes: scan.source.size, append_utf8: append.toString("utf8"),
|
|
195
|
+
repaired_turn_ids: scan.candidates.map(t => t.turn_id), status: "prepared" };
|
|
196
|
+
await saveManifest(manifestFile, manifest);
|
|
197
|
+
const dirHandle = await open(directory, "r"); try { await dirHandle.sync(); } finally { await dirHandle.close(); }
|
|
198
|
+
let changed = false;
|
|
199
|
+
try {
|
|
200
|
+
await offline();
|
|
201
|
+
if (await runtimeVersion() !== version || stamp(await owned(file)) !== stamp(scan.source)) throw failure("Runtime or history changed before repair.", "REPAIR_HISTORY_CHANGED");
|
|
202
|
+
const h = await openForAppend(file);
|
|
203
|
+
try {
|
|
204
|
+
const verified = await verifyRepairAppendHandle(h, file, scan);
|
|
205
|
+
// Hashing a large rollout takes time. Recheck process liveness and the
|
|
206
|
+
// exact file immediately before the write, not just before the hash.
|
|
207
|
+
await offline();
|
|
208
|
+
if (stamp(await h.stat()) !== stamp(verified.source) || stamp(await owned(file)) !== stamp(verified.source)) throw failure("History changed after append verification.", "REPAIR_HISTORY_CHANGED");
|
|
209
|
+
manifest.append_descriptor_sha256_verified = true;
|
|
210
|
+
manifest.metadata_revalidated = verified.metadata_revalidated;
|
|
211
|
+
changed = true; // A partial write is also a mutation and must be reported.
|
|
212
|
+
await h.writeFile(append); await h.sync();
|
|
213
|
+
} finally { await h.close(); }
|
|
214
|
+
await offline();
|
|
215
|
+
const after = await scanRepairRollout(file, threadId);
|
|
216
|
+
if (after.sha256 !== expectedHash || after.candidates.length || after.unresolved_latest.length) throw failure("Post-repair history differs from the expected append. Inspect the backup and history before restarting Codex.", "REPAIR_VERIFICATION_FAILED");
|
|
217
|
+
manifest.status = "verified"; await saveManifest(manifestFile, manifest);
|
|
218
|
+
return { ...data, status: "repaired", history_changed: true, repaired_turn_ids: manifest.repaired_turn_ids,
|
|
219
|
+
backup_path: backup, receipt_path: manifestFile, repaired_sha256: expectedHash, verification: "append-and-sha256-verified",
|
|
220
|
+
note: "Missing terminal records were appended; prior bytes and ordinals are unchanged. Codex will project them on reload. Desktop rendering and the next live turn still need verification. Read/watch cursors should be refreshed." };
|
|
221
|
+
} catch (e) {
|
|
222
|
+
e.history_changed = changed; e.backup_path = backup; e.receipt_path = manifestFile;
|
|
223
|
+
e.message += ` Backup: ${backup}. Do not retry or overwrite history blindly.`;
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
return apply ? withStoreLock(`turn-repair-${threadId}`, work, { home }) : work();
|
|
228
|
+
}
|
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
|
+
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
# 中継AIなしのDesktopチャット作成経路の調査
|
|
2
|
-
|
|
3
|
-
実装・直接作成・承認往復の後続結果は[現在の運用と実機検証](thread-create.md)を参照。以下は実装前の調査時点の記録です。
|
|
4
|
-
|
|
5
|
-
調査日: 2026-10-14 JST。対象はインストール済みDesktop `26.1007.30912` (build `20156`, bundle ID `com.openai.codex`) と同梱CLI `0.162.0-alpha.17.2`。この調査ではチャット作成、指示送信、所属変更、Desktop再起動、実装の置換を行っていない。
|
|
6
|
-
|
|
7
|
-
## 結論と検証の境界
|
|
8
|
-
|
|
9
|
-
モデル推論を伴わずにDesktopのnative toolを実行できる接続経路がある。認証されたnative RPCの `tools/list` に `create_thread`、`move_thread_to_project`、`list_projects` が存在し、`tools/call` で `list_projects` を実行できた。追加のAIターンや中継指示は不要だった。
|
|
10
|
-
|
|
11
|
-
native `create_thread` はDesktop内でプロジェクトの全フォルダを解決し、スケジュールと同じ `threadProjectAssignments.setAssignment` を使って所属を保存する。実装候補は既存Desktop wrapperからこのnative toolを直接呼ぶ方法である。ただし、今回の直接RPC検証は読み取りまで。直接RPCによる作成、所属の実機反映、初回指示、CLI終了後の承認往復は未検証であり、現行のAI中継実装はまだ置換していない。
|
|
12
|
-
|
|
13
|
-
以前の「複数フォルダ・重複フォルダにはAI中継が必要」という判断は、native RPCの直接呼出しを調べきる前だった。native RPCの存在と読み取り実測により、その判断は修正する必要がある。
|
|
14
|
-
|
|
15
|
-
## スケジュールが正しい所属になる理由
|
|
16
|
-
|
|
17
|
-
スケジュールの `Ll` は新しいthreadを作成した後、初回ターン開始前にDesktopの所属サービスを呼ぶ。
|
|
18
|
-
|
|
19
|
-
```js
|
|
20
|
-
await threadProjectAssignments.setAssignment({
|
|
21
|
-
threadId,
|
|
22
|
-
assignment: { projectKind: "local", projectId: project.projectId },
|
|
23
|
-
});
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Desktopの所属ストアは `thread-project-assignments` / `projectless-thread-ids` であり、更新時に `thread-project-memberships-updated` を通知する。App Serverのcanonical `projectId` だけを設定する経路と一致しないため、同じcwdを持つ「競馬」と `prediction_model` で分類の差が生じていた。直接global stateを書き換える方法は採用しない。Desktop自身も汎用 `set-global-state` によるこの所属ストアの変更を拒否している。
|
|
27
|
-
|
|
28
|
-
native toolの作成処理は次の経路を通る。
|
|
29
|
-
|
|
30
|
-
1. `create_thread` handler (`execution-fde3a4937b6a.js` の `sp`) が引数を検証し、Desktopのチャット作成処理へ渡す。
|
|
31
|
-
2. `sso` → `MZr` が指定したDesktopプロジェクトIDからcwd、全workspace roots、`projectAssignment` を取得する。
|
|
32
|
-
3. `HGi` → manager `startConversation` → `zln` がthreadを作成し、初回ターン前のcallbackで `saveThreadProjectAssignment` を呼ぶ。
|
|
33
|
-
4. このcallbackはDesktop hostの `threadProjectAssignments.setAssignment` を呼び、所属ストアと表示へ反映する。callbackがない構成でも `threadWorkspaceStorage.setProjectAssignment` が同サービスを呼ぶ。
|
|
34
|
-
5. Desktopが初回指示とターン開始を担当する。明示的なsourceThreadIdがあるnative toolでは指示が `codex_delegation` として保存される。
|
|
35
|
-
|
|
36
|
-
`CAr` はプロジェクト内の手動表示順を保存する処理であり、所属登録の本体ではない。所属登録は上記の `setAssignment`。
|
|
37
|
-
|
|
38
|
-
native `move_thread_to_project` もDesktopのsidebar移動処理から所属サービスの `setMembership` を呼ぶ。これは既存のApp Server作成経路に所属登録だけ追加する候補だが、作成と移動の部分成功や初回ターンの順序を別に管理する必要がある。
|
|
39
|
-
|
|
40
|
-
## native RPCと認証
|
|
41
|
-
|
|
42
|
-
Desktopは子プロセスに `CODEX_APP_TOOLS_PIPE_PATH` を渡す。既存shared wrapperがこの値を継承していることを、選択中wrapperの環境の該当キーだけを読み取って確認した。環境全体や秘密値は出力していない。
|
|
43
|
-
|
|
44
|
-
native pipeはUnix domain socket。macOSでは長さ4バイトのnative endian (今回LE) + UTF-8 JSONのframeを使い、frame上限は8 MiB。newline JSONのApp Server protocolとは別物である。socketは所有者専用で、接続時にnativeのsocket peer認証が行われる。既存wrapperはDesktop同梱の署名付きNodeを直接使う。認証を無効化・迂回する方法は使っていない。
|
|
45
|
-
|
|
46
|
-
native serverの `Vae` は `tools/call` を `callDynamicAppTool` に渡す。mainのdispatcherは準備済みDesktop windowを選び、rendererの `canCallTool` / `dispatchToolCall` がnative handlerを実行する。ここに中継AIの推論はない。
|
|
47
|
-
|
|
48
|
-
```json
|
|
49
|
-
{
|
|
50
|
-
"id": "request-uuid",
|
|
51
|
-
"jsonrpc": "2.0",
|
|
52
|
-
"method": "tools/call",
|
|
53
|
-
"params": {
|
|
54
|
-
"arguments": {},
|
|
55
|
-
"callerSource": "codex",
|
|
56
|
-
"callId": "unique-call-uuid",
|
|
57
|
-
"hostId": "local",
|
|
58
|
-
"namespace": "codex_app",
|
|
59
|
-
"threadId": "existing-caller-thread-id",
|
|
60
|
-
"turnId": "actual-stored-caller-turn-id",
|
|
61
|
-
"tool": "list_projects"
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
`threadId` / `turnId` は呼出し元の監査・関連付け用contextであり、モデルを起動するための指示ではない。今回のprobeは実在するチャットとその保存済みターンだけを使用した。架空IDは使用していない。rendererは呼出し元threadを読み込めることを確認し、callIdの実行claimを取る。idleの保存済みターンを使った読み取りも成功したが、作成toolで同じcontextが使えるかは未検証。
|
|
67
|
-
|
|
68
|
-
`taskProject/assign` という別RPCもあるが、これはWorkのlocal executorの実在environmentIdとthreadのenvironment一致が必要な経路である。通常のCodexチャットに `environmentId: "local"` を付ければ使えるAPIではない。この候補では使用しない。
|
|
69
|
-
|
|
70
|
-
## 読み取りの実測
|
|
71
|
-
|
|
72
|
-
一時probe `/private/tmp/codexteer-native-read-probe.mjs` を同梱Node `/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node` で実行した。選択中shared runtimeのプロセス確認とローカルIPC読み取りのみ、sandbox外で実施した。
|
|
73
|
-
|
|
74
|
-
| 確認 | 結果 |
|
|
75
|
-
| --- | --- |
|
|
76
|
-
| `tools/list` (`threadStartKind: all`) | 57 native tools。作成・所属移動・プロジェクト一覧のschemaを取得 |
|
|
77
|
-
| `tools/call list_projects`、現在の実在activeターン | `success: true`、23プロジェクト |
|
|
78
|
-
| 同呼出し、既存idleチャットの保存済みcompletedターン | `success: true`、23プロジェクト |
|
|
79
|
-
| idleチャットの後続読取り | idle、latest turnは同じcompletedターン、turn数4。新しいターンは発生していない |
|
|
80
|
-
|
|
81
|
-
idle contextは既存検証チャット `01a135bf-1087-7902-8b78-0fb683b025d6`、保存済みターン `01a135cb-47ba-73a0-befe-6eb41452655e`。現在のcontextはthread `01a135a4-c1ea-7081-be4c-3c5ab7b11193`、turn `01a135d2-9375-78f0-8f39-ced3c29a5d98`。
|
|
82
|
-
|
|
83
|
-
native一覧は次のDesktop IDを返した。作成先にはnative一覧のIDを使う。App Serverのcanonical IDと混ぜない。
|
|
84
|
-
|
|
85
|
-
| label | projectKind | Desktop projectId |
|
|
86
|
-
| --- | --- | --- |
|
|
87
|
-
| codex-steer | local | `8b8c19b2-5a36-4683-8b2f-28a489e03724` |
|
|
88
|
-
| 競馬 | local | `ad16728e-81d9-499f-81c8-fdf13f0f9d89` |
|
|
89
|
-
| prediction_model | local | `cad845a7-dfb1-45b8-8503-b9aca1ee89d6` |
|
|
90
|
-
| 競馬 | chatgpt | `g-p-69379d229d7081918b1efb6f5c107d17` |
|
|
91
|
-
|
|
92
|
-
localの「競馬」と `prediction_model` は両方ともpathが `/Users/phat/Desktop/app_race-calc_h/prediction_model` だが、IDは異なる。同名ChatGPTプロジェクトも存在するので、プロジェクト解決ではkindとIDを確認する。
|
|
93
|
-
|
|
94
|
-
## codexteerへの組込み候補
|
|
95
|
-
|
|
96
|
-
```text
|
|
97
|
-
terminal CLI → 共有App Serverのcommand/exec
|
|
98
|
-
→ 固定の署名付きNode helper(thread/turnやモデル推論なし)
|
|
99
|
-
→ Desktop native RPC tools/call create_thread
|
|
100
|
-
→ Desktopでproject roots解決・setAssignment・初回ターン開始
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
実装時には、wrapper自身のnative接続では署名情報検証に失敗することを確認したため、既存App Serverの正規の子としてhelperを起動する経路を採用した。command/execは独立したコマンド実行APIで、チャットやAIターンを作らない。matching CLI schemaで対応を確認し、固定の署名付きNodeとhelperに限定する。詳細と実測は現在の運用文書を参照。
|
|
104
|
-
|
|
105
|
-
端末からの呼出しには実在するcaller contextの明示または確実な解決が必要である。context用チャットのモデル起動や追加ターンは不要な見込みだが、作成toolで検証してから契約にする。現在の `--via-thread` のようなAIへのメッセージ配送は廃止候補となる。
|
|
106
|
-
|
|
107
|
-
nativeの作成引数は以下で、promptと指定プロジェクトをそのまま渡せる。
|
|
108
|
-
|
|
109
|
-
```json
|
|
110
|
-
{
|
|
111
|
-
"prompt": "initial instruction",
|
|
112
|
-
"title": "optional title",
|
|
113
|
-
"target": {
|
|
114
|
-
"type": "project",
|
|
115
|
-
"projectId": "Desktop projectId",
|
|
116
|
-
"environment": { "type": "local" }
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
モデル・thinkingはnative schemaの対応範囲内で指定可能。cwd・sandbox・approval policy等を任意に上書きする引数はこのnative toolにないため、既存CLIのオプションをそのまま対応済みとは扱わない。native経路でDesktopの設定を使うか、所属移動だけを使う別経路が必要。
|
|
122
|
-
|
|
123
|
-
組込み後の必須検証は、単一フォルダと「競馬」の全7フォルダ、共有cwdでの正しいDesktop所属、初回指示の完全一致、短命CLI終了後のDesktop承認継続・承認往復、途中失敗の同一attemptでの照合である。tool callIdがあることを作成のidempotency保証と解釈せず、不明結果を自動再送しない。
|
|
124
|
-
|
|
125
|
-
native RPCはこのDesktop内部のversion依存interfaceであり、公開App Server互換契約ではない。wrapperのcapabilityとnative schema/接続を確認し、未対応なら作成前に止める。AI中継へ自動fallbackしない。稼働中wrapperへコードを追加するには対応版で起動し直す必要があるが、この調査では再起動していない。
|
|
126
|
-
|
|
127
|
-
## 調査に使用したインストール済みコード
|
|
128
|
-
|
|
129
|
-
ASARは読み取り専用で展開し、bundleや署名は変更していない。minified名はこのbuildに限る。
|
|
130
|
-
|
|
131
|
-
| ASAR resource | SHA-256 |
|
|
132
|
-
| --- | --- |
|
|
133
|
-
| `.vite/build/main-CqEoQDOU.js` | `09d19386e1f23cf4ccbc950fc40d85a1e8a064a08be588b2a4be6a89f6ada9e6` |
|
|
134
|
-
| `webview/assets/app-initial-6350d04672d0.js` | `604b5d2de4fbb41f3d0dc5b3662129c558e1c7b8aac8cc87225286f58dcfaf8f` |
|
|
135
|
-
| `webview/assets/app-shared-8bd9d0399038.js` | `607ad0866dd25ed3764711b4467a9b60708353a599485b5249ff4cbe0e870245` |
|
|
136
|
-
| `webview/assets/execution-fde3a4937b6a.js` | `5c300497b7331bfa317231251e1097385405b70f2746b1fe8959c74e56735811` |
|