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.
- package/CHANGELOG.md +37 -2
- package/README.md +154 -16
- 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 +21 -9
- 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 +90 -0
- package/package.json +13 -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 +233 -19
- 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 +23 -5
- package/src/creation-verification.mjs +36 -0
- 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 +6 -6
- package/src/evidence.mjs +3 -0
- package/src/findings.mjs +7 -7
- package/src/help.mjs +155 -26
- package/src/journal.mjs +37 -9
- package/src/launcher.mjs +16 -9
- package/src/monitor.mjs +18 -6
- package/src/native-command.mjs +100 -0
- package/src/native-rpc.mjs +116 -0
- package/src/native-worker.mjs +25 -0
- 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 +104 -0
- package/src/prompt-command.mjs +25 -0
- package/src/prompt.mjs +35 -11
- 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/runtime.mjs +4 -2
- package/src/socket-endpoint.mjs +40 -0
- package/src/standalone-create.mjs +135 -0
- package/src/store.mjs +6 -3
- package/src/supervise.mjs +32 -55
- package/src/supervision.mjs +41 -19
- package/src/target.mjs +25 -0
- package/src/task-id.mjs +23 -0
- package/src/thread-create.mjs +128 -0
- 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/src/wrapper.mjs +68 -5
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { discoverRuntime } from "./runtime.mjs";
|
|
5
|
+
import { RpcClient } from "./rpc.mjs";
|
|
6
|
+
import { readSendState } from "./app-server.mjs";
|
|
7
|
+
import { remoteControlConnection } from "./remote-control.mjs";
|
|
8
|
+
import { targetHost, targetContext, remoteTransport, hostFields } from "./target.mjs";
|
|
9
|
+
import { parseThreadTarget, normalizeThreadId, threadDeepLink } from "./thread-id.mjs";
|
|
10
|
+
import { registeredRemoteProjects } from "./projects.mjs";
|
|
11
|
+
import { discoverNativeEndpoint, nativeToolJson, inspectNativeEndpoint } from "./native-rpc.mjs";
|
|
12
|
+
import { nativeOnAppServer, inspectNativeCommandProtocol } from "./native-command.mjs";
|
|
13
|
+
import { validateNativeCreationCatalog, creationOptions } from "./thread-create.mjs";
|
|
14
|
+
import { fetchThread } from "./observe.mjs";
|
|
15
|
+
import { initialInstructionText } from "./creation-verification.mjs";
|
|
16
|
+
import { writeRecord, readRecord, storePath, withStoreLock } from "./store.mjs";
|
|
17
|
+
|
|
18
|
+
const failure = (message, code = "REMOTE_MANAGEMENT_FAILED") => Object.assign(new Error(message), { code, uncertain: false });
|
|
19
|
+
const remotePath = value => typeof value === "string" && (path.win32.isAbsolute(value) || path.posix.isAbsolute(value)) && !/[\0\r\n]/.test(value);
|
|
20
|
+
const windowsPath = value => typeof value === "string" && (/^[a-z]:[\\/]/i.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value));
|
|
21
|
+
const samePath = (a, b) => typeof a === "string" && typeof b === "string" && (a === b || (windowsPath(a) && windowsPath(b) && path.win32.normalize(a).toLowerCase() === path.win32.normalize(b).toLowerCase()));
|
|
22
|
+
export function requirePairedManagement() {
|
|
23
|
+
if (targetHost() === "local" || remoteTransport() !== "paired") throw failure("Remote management requires an explicit remote host and --remote-transport paired.", "REMOTE_MANAGEMENT_TRANSPORT_REQUIRED");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function withRemoteClient(operation, { route = remoteControlConnection, timeoutMs = 35000 } = {}) {
|
|
27
|
+
requirePairedManagement();
|
|
28
|
+
const adapter = route(), runtime = await adapter.discover(), client = await adapter.connect(runtime.paths.socket, { timeoutMs });
|
|
29
|
+
try { return await operation(client, runtime); } finally { client.close(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function remoteProjectsOnClient(client) {
|
|
33
|
+
const projects = [], ids = new Set(), cursors = new Set(); let cursor;
|
|
34
|
+
for (let page = 0; page < 100; page++) {
|
|
35
|
+
const result = await client.request("project/list", { limit: 100, ...(cursor ? { cursor } : {}) });
|
|
36
|
+
if (!Array.isArray(result?.data)) throw failure("Invalid remote project list.", "REMOTE_PROTOCOL_ERROR");
|
|
37
|
+
for (const project of result.data) {
|
|
38
|
+
if (typeof project?.id !== "string" || !project.id || ids.has(project.id) || typeof project.name !== "string" || !Array.isArray(project.roots) || !project.roots.length || project.roots.some(root => !remotePath(root?.path))) throw failure("Invalid remote project identity or roots.", "REMOTE_PROTOCOL_ERROR");
|
|
39
|
+
ids.add(project.id); projects.push(project);
|
|
40
|
+
}
|
|
41
|
+
if (result.nextCursor == null) return projects;
|
|
42
|
+
if (typeof result.nextCursor !== "string" || !result.nextCursor || result.nextCursor.length > 16384 || cursors.has(result.nextCursor)) throw failure("Invalid remote project pagination.", "REMOTE_PROTOCOL_ERROR");
|
|
43
|
+
cursors.add(result.nextCursor); cursor = result.nextCursor;
|
|
44
|
+
}
|
|
45
|
+
throw failure("Remote project list exceeded its limit.", "REMOTE_PROTOCOL_ERROR");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listRemoteProjects(dependencies) {
|
|
49
|
+
return withRemoteClient(async client => {
|
|
50
|
+
const live = await remoteProjectsOnClient(client), saved = await registeredRemoteProjects();
|
|
51
|
+
return live.map(project => ({ project_id: project.id, name: project.name, roots: project.roots.map(root => root.path),
|
|
52
|
+
desktop_project_ids: saved.filter(p => p.host_id === targetHost() && p.roots.length === project.roots.length && p.roots.every(root => project.roots.some(r => samePath(root, r.path)))).map(p => p.project_id),
|
|
53
|
+
project_kind: "remote", ...hostFields(), source: "remote-app-server", availability: "verified", availability_scope: "project-catalog", roots_availability: "unverified" }));
|
|
54
|
+
}, dependencies);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function listRemoteThreads({ limit = 20, desktopOnly = false, cursor, projectId, search } = {}, dependencies) {
|
|
58
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500 || (cursor !== undefined && (typeof cursor !== "string" || !cursor || cursor.length > 16384))) throw failure("Invalid remote list limit or cursor.", "INVALID_ARGUMENT");
|
|
59
|
+
return withRemoteClient(async client => {
|
|
60
|
+
const threads = [], cursors = new Set(), ids = new Set(); let next = cursor;
|
|
61
|
+
for (let page = 0; page < 100; page++) {
|
|
62
|
+
const result = await client.request("thread/list", { limit: Math.min(100, limit - threads.length), useStateDbOnly: true, sortKey: "updated_at",
|
|
63
|
+
...(next ? { cursor: next } : {}), ...(projectId ? { projectId } : {}), ...(search ? { searchTerm: search } : {}) });
|
|
64
|
+
if (!Array.isArray(result?.data) || result.data.length > Math.min(100, limit - threads.length)) throw failure("Invalid remote task list.", "REMOTE_PROTOCOL_ERROR");
|
|
65
|
+
for (const thread of result.data) {
|
|
66
|
+
const id = normalizeThreadId(thread?.id);
|
|
67
|
+
if (ids.has(id)) throw failure("Repeated remote task identity.", "REMOTE_PROTOCOL_ERROR");
|
|
68
|
+
ids.add(id);
|
|
69
|
+
if (desktopOnly && thread.originator !== "Codex Desktop") continue;
|
|
70
|
+
threads.push({ thread_id: id, title: thread.name ?? null, cwd: thread.cwd ?? null, project_id: thread.projectId ?? null,
|
|
71
|
+
created_at: typeof thread.createdAt === "number" ? new Date(thread.createdAt * 1000).toISOString() : null,
|
|
72
|
+
updated_at: typeof thread.updatedAt === "number" ? new Date(thread.updatedAt * 1000).toISOString() : null,
|
|
73
|
+
originator: thread.originator ?? null, deep_link: threadDeepLink(id), ...hostFields() });
|
|
74
|
+
}
|
|
75
|
+
next = result.nextCursor;
|
|
76
|
+
if (next != null && (typeof next !== "string" || !next || next.length > 16384 || cursors.has(next))) throw failure("Invalid remote task pagination.", "REMOTE_PROTOCOL_ERROR");
|
|
77
|
+
if (next == null || threads.length >= limit) return { threads, count: threads.length, next_cursor: next ?? null, has_more: next != null };
|
|
78
|
+
cursors.add(next);
|
|
79
|
+
}
|
|
80
|
+
throw failure("Remote task pagination exceeded its limit.", "REMOTE_PROTOCOL_ERROR");
|
|
81
|
+
}, dependencies);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Desktop owns creation and long-lived subscriptions. The caller is an actual
|
|
85
|
+
// persisted local chat, never a fabricated turn, model relay or a remote task
|
|
86
|
+
// reinterpreted as local. Terminal users may pass --context-thread explicitly.
|
|
87
|
+
export async function remoteDesktopSession({ contextThread = targetContext() ?? process.env.CODEX_THREAD_ID, timeoutMs = 120000 } = {}, dependencies = {}) {
|
|
88
|
+
const { discover = discoverRuntime, connect = RpcClient.connect, endpoint = discoverNativeEndpoint, inspect = inspectNativeCommandProtocol, native = nativeOnAppServer } = dependencies;
|
|
89
|
+
if (!contextThread) throw failure("Desktop lifecycle operations require --context-thread <LOCAL-CHAT-ID>, or CODEX_THREAD_ID inside Codex. Paired list/read/steer do not require it.", "REMOTE_CONTEXT_REQUIRED");
|
|
90
|
+
const source = parseThreadTarget(contextThread);
|
|
91
|
+
if (source.hostId && source.hostId !== "local") throw failure("The Desktop caller must be a real local chat.", "REMOTE_CONTEXT_INVALID");
|
|
92
|
+
const runtime = await discover(), client = await connect(runtime.paths.socket, { timeoutMs: timeoutMs + 5000 });
|
|
93
|
+
try {
|
|
94
|
+
const pipe = await endpoint(runtime.state); await inspect(runtime.state);
|
|
95
|
+
const caller = await readSendState(client, source.threadId), turn = caller.turns.at(-1);
|
|
96
|
+
if (caller.id !== source.threadId || caller.ephemeral || !turn?.id || caller.environments?.some(item => item.environmentId !== "local")) throw failure("A persisted local caller with a stored turn is required.", "REMOTE_CONTEXT_INVALID");
|
|
97
|
+
const context = { threadId: caller.id, turnId: turn.id };
|
|
98
|
+
const check = async () => {
|
|
99
|
+
const current = await discover();
|
|
100
|
+
if (current.paths.socket !== runtime.paths.socket || current.state.instance !== runtime.state.instance) throw failure("Desktop runtime changed.", "RUNTIME_CHANGED");
|
|
101
|
+
await inspectNativeEndpoint(pipe.socketPath, pipe.fingerprint);
|
|
102
|
+
};
|
|
103
|
+
const catalog = await native(client, pipe, "catalog", undefined, undefined, { timeoutMs });
|
|
104
|
+
return { context, catalog, close: () => client.close(), async invoke(action, args, options = {}) {
|
|
105
|
+
if (!["create_thread", "list_threads", "read_thread", "navigate_to_codex_page"].includes(action)) throw failure("Unsupported Desktop management operation.");
|
|
106
|
+
await check();
|
|
107
|
+
return nativeToolJson(await native(client, pipe, action, args, context, { timeoutMs, ...options }));
|
|
108
|
+
} };
|
|
109
|
+
} catch (error) { client.close(); throw error; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function ensureRemoteDesktopSubscription(threadId, { session: existing, ...options } = {}) {
|
|
113
|
+
requirePairedManagement();
|
|
114
|
+
const session = existing ?? await remoteDesktopSession(options);
|
|
115
|
+
try {
|
|
116
|
+
const schema = session.catalog.tools?.find(t => t.namespace === "codex_app" && t.name === "navigate_to_codex_page")?.inputSchema;
|
|
117
|
+
if (schema?.properties?.threadId?.type !== "string") throw failure("Desktop navigation schema is unavailable.", "REMOTE_SUBSCRIPTION_UNVERIFIED");
|
|
118
|
+
const inventory = await session.invoke("list_threads", {});
|
|
119
|
+
const matches = [...(inventory.threads ?? []), ...(inventory.pinnedThreads ?? [])].filter(thread => thread.id === threadId);
|
|
120
|
+
if (matches.length !== 1 || matches[0].hostId !== targetHost()) throw failure("Desktop navigation cannot uniquely identify this task on the selected host. No task was opened.", "HOST_MISMATCH");
|
|
121
|
+
const snapshot = await session.invoke("read_thread", { threadId, hostId: targetHost(), turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 100 });
|
|
122
|
+
if (snapshot.thread?.id !== threadId || snapshot.thread.hostId !== targetHost()) throw failure("Desktop returned a different task or host.", "HOST_MISMATCH");
|
|
123
|
+
// Explicitly opens the selected remote chat on the controlling Desktop;
|
|
124
|
+
// its own persistent App Server connection then resumes/subscribes to it.
|
|
125
|
+
await session.invoke("navigate_to_codex_page", { threadId });
|
|
126
|
+
return { thread_id: threadId, ...hostFields(), desktop_subscription: "native-navigation", deep_link: threadDeepLink(threadId) };
|
|
127
|
+
} finally { if (!existing) session.close(); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scopeMatches(thread, receipt) {
|
|
131
|
+
const roots = thread?.environments?.find(item => item.environmentId === "local")?.runtimeWorkspaceRoots;
|
|
132
|
+
const match = typeof thread?.path === "string" && thread.path.match(/^(.*)[\\/]sessions[\\/](\d{4})[\\/](\d{2})[\\/](\d{2})[\\/]/);
|
|
133
|
+
const artifactRoot = match ? windowsPath(thread.path) ? path.win32.join(match[1], "visualizations", match[2], match[3], match[4], receipt.thread_id)
|
|
134
|
+
: path.posix.join(match[1], "visualizations", match[2], match[3], match[4], receipt.thread_id) : null;
|
|
135
|
+
return thread?.id === receipt.thread_id && thread.projectId === receipt.project_id && samePath(thread.cwd, receipt.cwd)
|
|
136
|
+
&& thread.name === receipt.title && Array.isArray(roots) && new Set(roots).size === roots.length
|
|
137
|
+
&& receipt.runtime_workspace_roots.every(root => roots.some(actual => samePath(actual, root)))
|
|
138
|
+
&& roots.every(root => receipt.runtime_workspace_roots.some(expected => samePath(root, expected)) || (artifactRoot && samePath(root, artifactRoot)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function createRemoteProjectThread(selector, prompt, options = {}, dependencies = {}) {
|
|
142
|
+
requirePairedManagement(); const settings = creationOptions(options), attemptId = randomUUID();
|
|
143
|
+
if (typeof selector !== "string" || !selector.trim() || typeof prompt !== "string" || !prompt.trim()) throw failure("A project and initial instruction are required.", "INVALID_CREATION_OPTIONS");
|
|
144
|
+
let session, receipt, storage, dispatched = false, accepted = false;
|
|
145
|
+
const registry = dependencies.registry ?? registeredRemoteProjects;
|
|
146
|
+
try {
|
|
147
|
+
session = await (dependencies.desktop ?? remoteDesktopSession)({ contextThread: options.contextThread, timeoutMs: settings.timeoutMs });
|
|
148
|
+
validateNativeCreationCatalog(session.catalog);
|
|
149
|
+
return await withRemoteClient(async (client, runtime) => {
|
|
150
|
+
storage = { home: runtime.paths.home };
|
|
151
|
+
const projectList = await remoteProjectsOnClient(client), saved = (await registry()).filter(p => p.host_id === targetHost());
|
|
152
|
+
const candidates = projectList.filter(p => p.id === selector || p.name === selector || saved.some(s => (s.project_id === selector || s.name === selector) && s.roots.length === p.roots.length && s.roots.every(root => p.roots.some(r => samePath(root, r.path)))));
|
|
153
|
+
if (candidates.length !== 1) throw failure("Select exactly one live remote project by ID or name.", candidates.length ? "PROJECT_AMBIGUOUS" : "PROJECT_NOT_FOUND");
|
|
154
|
+
const project = candidates[0], aliases = saved.filter(p => p.roots.length === project.roots.length && p.roots.every(root => project.roots.some(r => samePath(root, r.path))));
|
|
155
|
+
if (aliases.length !== 1) throw failure("Cannot uniquely map the live project to a Desktop remote registration.", "DESKTOP_PROJECT_MAPPING_UNVERIFIED");
|
|
156
|
+
const selected = aliases[0], title = settings.title ?? [...prompt.trim().replace(/\s+/gu, " ")].slice(0, 80).join("");
|
|
157
|
+
receipt = { schema: 4, creation_mode: "remote-native", attempt_id: attemptId, native_call_id: attemptId, ...hostFields(), project_id: project.id, desktop_project_id: selected.project_id,
|
|
158
|
+
project_name: project.name, cwd: project.roots[0].path, runtime_workspace_roots: project.roots.map(root => root.path), title, context_thread_id: session.context.threadId,
|
|
159
|
+
context_turn_id: session.context.turnId, creation_status: "not_created", sent: false, delivery_status: "not_sent", verification_status: "not_started",
|
|
160
|
+
prompt_sha256: createHash("sha256").update(prompt).digest("hex"), message_characters: [...prompt].length, dry_run: !!options.dryRun };
|
|
161
|
+
if (options.dryRun) return receipt;
|
|
162
|
+
receipt.receipt_path = storePath("creations", attemptId, runtime.paths.home);
|
|
163
|
+
const write = value => writeRecord("creations", attemptId, value, { home: runtime.paths.home });
|
|
164
|
+
await write({ ...receipt, creation_status: "dispatching" });
|
|
165
|
+
// Recheck roots/registry before the single native creation dispatch.
|
|
166
|
+
const current = await remoteProjectsOnClient(client), registrations = await registry();
|
|
167
|
+
if (JSON.stringify(current.find(p => p.id === project.id)) !== JSON.stringify(project) || !registrations.some(p => p.host_id === targetHost() && JSON.stringify(p) === JSON.stringify(selected))) throw failure("Remote project changed before creation.", "PROJECT_CHANGED");
|
|
168
|
+
dispatched = true;
|
|
169
|
+
const result = await session.invoke("create_thread", { prompt, title, target: { type: "project", projectId: selected.project_id, environment: { type: "local" } },
|
|
170
|
+
...(settings.model ? { model: settings.model } : {}), ...(settings.thinking ? { thinking: settings.thinking } : {}) }, { callId: attemptId });
|
|
171
|
+
if (typeof result.threadId === "string") { receipt.thread_id = normalizeThreadId(result.threadId); receipt.actual_host_id = result.hostId;
|
|
172
|
+
receipt.deep_link = typeof result.hostId === "string" ? threadDeepLink(`codex://threads/${receipt.thread_id}?hostId=${encodeURIComponent(result.hostId)}`) : null; }
|
|
173
|
+
if (result.hostId !== targetHost() || !receipt.thread_id) throw Object.assign(failure("Creation did not confirm the selected remote host. Inspect the saved returned task; do not retry.", "HOST_MISMATCH"), { uncertain: true });
|
|
174
|
+
receipt.creation_status = "created"; receipt.sent = accepted = true; receipt.delivery_status = "accepted"; receipt.desktop_subscription = "native-owned";
|
|
175
|
+
await write(receipt);
|
|
176
|
+
const thread = (await client.request("thread/read", { threadId: receipt.thread_id, includeTurns: false })).thread;
|
|
177
|
+
if (thread?.id !== receipt.thread_id || !samePath(thread.cwd, receipt.cwd)) throw failure("Created remote task has unexpected scope.", "CREATION_SCOPE_MISMATCH");
|
|
178
|
+
if (thread.projectId != null && thread.projectId !== project.id) throw failure("Created task was assigned to a different canonical project. Its assignment was not overwritten.", "PROJECT_CHANGED");
|
|
179
|
+
if (thread.projectId == null) await client.request("thread/metadata/update", { threadId: receipt.thread_id, projectId: project.id }, { mutation: true });
|
|
180
|
+
const verified = await verifyRemoteCreationOnClient(client, receipt, session, settings.timeoutMs);
|
|
181
|
+
Object.assign(receipt, verified); await write(receipt); return receipt;
|
|
182
|
+
}, dependencies);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
Object.assign(error, { attempt_id: attemptId, native_call_id: attemptId, thread_id: receipt?.thread_id, receipt_path: receipt?.receipt_path,
|
|
185
|
+
creation_status: receipt?.thread_id ? "created" : dispatched && error.uncertain !== false ? "unknown" : "not_created", sent: accepted ? true : dispatched && error.uncertain !== false ? null : false,
|
|
186
|
+
delivery_status: accepted ? "accepted" : dispatched && error.uncertain !== false ? "unknown" : "not_sent" });
|
|
187
|
+
if (receipt?.receipt_path) try { await writeRecord("creations", attemptId, { ...receipt, creation_status: error.creation_status, sent: error.sent, delivery_status: error.delivery_status, verification_status: "failed", error_code: error.code }, storage); } catch { error.receipt_update_required = true; }
|
|
188
|
+
throw error;
|
|
189
|
+
} finally { session?.close(); }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function verifyRemoteCreationOnClient(client, receipt, session, timeoutMs) {
|
|
193
|
+
const deadline = Date.now() + Math.min(timeoutMs, 15000);
|
|
194
|
+
do {
|
|
195
|
+
const thread = (await client.request("thread/read", { threadId: receipt.thread_id, includeTurns: false })).thread, history = await fetchThread(client, receipt.thread_id);
|
|
196
|
+
const snapshot = await session.invoke("list_threads", {}), matches = [...(snapshot.threads ?? []), ...(snapshot.pinnedThreads ?? [])].filter(t => t.id === receipt.thread_id);
|
|
197
|
+
const instruction = initialInstructionText(history, receipt.context_thread_id);
|
|
198
|
+
if (scopeMatches(thread, receipt) && matches.length === 1 && matches[0].hostId === targetHost() && matches[0].projectId === receipt.desktop_project_id
|
|
199
|
+
&& typeof instruction === "string" && createHash("sha256").update(instruction).digest("hex") === receipt.prompt_sha256) {
|
|
200
|
+
return { verification_status: "verified", initial_instruction: "verified", desktop_project_membership: "verified", app_server_project_membership: "verified", turn_id: history.turns[0]?.id,
|
|
201
|
+
observed_runtime_workspace_roots: thread.environments.find(item => item.environmentId === "local").runtimeWorkspaceRoots };
|
|
202
|
+
}
|
|
203
|
+
if (Date.now() >= deadline) break; await delay(250);
|
|
204
|
+
} while (true);
|
|
205
|
+
throw failure("Created task exists but scope, Desktop membership or initial instruction is not yet verified. Use thread verify; never recreate it.", "CREATION_VERIFICATION_FAILED");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function verifyRemoteProjectThread(attemptId, options = {}, dependencies = {}) {
|
|
209
|
+
requirePairedManagement(); normalizeThreadId(attemptId);
|
|
210
|
+
return withStoreLock(`creation-${attemptId}`, async () => {
|
|
211
|
+
const receipt = await readRecord("creations", attemptId);
|
|
212
|
+
if (receipt?.schema !== 4 || receipt.creation_mode !== "remote-native" || receipt.host_id !== targetHost() || receipt.remote_transport !== "paired"
|
|
213
|
+
|| receipt.creation_status !== "created" || receipt.delivery_status !== "accepted" || !receipt.thread_id || !/^[a-f0-9]{64}$/.test(receipt.prompt_sha256 ?? "")) throw failure("A matching created remote receipt is required.", "CREATION_RECEIPT_UNVERIFIED");
|
|
214
|
+
let session;
|
|
215
|
+
const history = status => [...(receipt.verification_history ?? []), { checked_at: new Date().toISOString(), status,
|
|
216
|
+
previous_status: receipt.verification_status, source: "desktop-and-remote-read-only" }];
|
|
217
|
+
try {
|
|
218
|
+
session = await (dependencies.desktop ?? remoteDesktopSession)({ contextThread: receipt.context_thread_id, ...options });
|
|
219
|
+
return await withRemoteClient(async client => {
|
|
220
|
+
const result = { ...receipt, ...await verifyRemoteCreationOnClient(client, receipt, session, options.timeoutMs ?? 120000), verification_history: history("verified") };
|
|
221
|
+
delete result.error_code;
|
|
222
|
+
await writeRecord("creations", attemptId, result); return result;
|
|
223
|
+
}, dependencies);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const failed = { ...receipt, verification_status: "failed", error_code: error.code ?? "CREATION_VERIFICATION_FAILED", verification_history: history("failed") };
|
|
226
|
+
delete failed.desktop_project_membership; delete failed.app_server_project_membership; delete failed.initial_instruction;
|
|
227
|
+
delete failed.observed_runtime_workspace_roots;
|
|
228
|
+
try { await writeRecord("creations", attemptId, failed); } catch { error.receipt_update_required = true; }
|
|
229
|
+
Object.assign(error, { attempt_id: attemptId, thread_id: receipt.thread_id, creation_status: receipt.creation_status,
|
|
230
|
+
sent: receipt.sent, delivery_status: receipt.delivery_status, receipt_path: receipt.receipt_path });
|
|
231
|
+
throw error;
|
|
232
|
+
} finally { session?.close(); }
|
|
233
|
+
});
|
|
234
|
+
}
|
package/src/remote.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { discoverRuntime } from "./runtime.mjs";
|
|
2
|
+
import { RpcClient } from "./rpc.mjs";
|
|
3
|
+
import { readSendState } from "./app-server.mjs";
|
|
4
|
+
import { discoverNativeEndpoint, nativeToolJson } from "./native-rpc.mjs";
|
|
5
|
+
import { inspectNativeCommandProtocol, nativeOnAppServer } from "./native-command.mjs";
|
|
6
|
+
import { parseThreadTarget } from "./thread-id.mjs";
|
|
7
|
+
import { targetHost, targetContext, remoteReadOnly } from "./target.mjs";
|
|
8
|
+
import { assertRuntimeOperation } from "./compatibility.mjs";
|
|
9
|
+
|
|
10
|
+
const failure = (message, code = "REMOTE_PROTOCOL_ERROR") => Object.assign(new Error(message), { code });
|
|
11
|
+
|
|
12
|
+
export function validateRemoteCatalog(catalog) {
|
|
13
|
+
const schema = catalog?.tools?.find(tool => tool.namespace === "codex_app" && tool.name === "read_thread")?.inputSchema;
|
|
14
|
+
if (schema?.properties?.threadId?.type !== "string" || schema.properties.hostId?.type !== "string"
|
|
15
|
+
|| schema.properties.cursor?.type !== "string" || schema.properties.turnLimit?.maximum !== 10
|
|
16
|
+
|| schema.properties.includeOutputs?.type !== "boolean" || schema.properties.maxOutputCharsPerItem?.maximum !== 20000) {
|
|
17
|
+
throw failure("Desktop does not expose the required remote read schema.", "REMOTE_READ_UNSUPPORTED");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pageThread(page, threadId, hostId) {
|
|
22
|
+
if (page?.thread?.id !== threadId || page.thread.hostId !== hostId || page.thread.kind !== "codex") {
|
|
23
|
+
throw failure("Desktop returned a different task or host. No local fallback is allowed.", "HOST_MISMATCH");
|
|
24
|
+
}
|
|
25
|
+
if (!["active", "idle", "notLoaded", "systemError"].includes(page.thread.status?.type)
|
|
26
|
+
|| !Array.isArray(page.turns) || page.turns.length > 10 || page.page?.order !== "newest_first"
|
|
27
|
+
|| typeof page.page.hasMore !== "boolean" || (page.page.hasMore && (typeof page.page.nextCursor !== "string" || !page.page.nextCursor || page.page.nextCursor.length > 16384 || !page.turns.length))) {
|
|
28
|
+
throw failure("Desktop returned invalid remote history pagination.");
|
|
29
|
+
}
|
|
30
|
+
return { id: threadId, name: page.thread.title, cwd: page.thread.cwd, status: page.thread.status, turns: [], observationSource: "desktop-native-public-history" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function publicOutput(output) {
|
|
34
|
+
if (output == null) return "";
|
|
35
|
+
if (typeof output.text !== "string" || typeof output.truncated !== "boolean") throw failure("Desktop returned an invalid public output projection.");
|
|
36
|
+
return output.text + (output.truncated ? "\n[truncated by Desktop]" : "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function publicTurn(turn) {
|
|
40
|
+
if (typeof turn?.id !== "string" || !turn.id || typeof turn.status !== "string" || !Array.isArray(turn.items)) throw failure("Desktop returned an incomplete remote turn.");
|
|
41
|
+
return { ...turn, items: turn.items.map(item => {
|
|
42
|
+
if (typeof item?.id !== "string" || !item.id || typeof item.type !== "string"
|
|
43
|
+
|| (item.type === "userMessage" && (!Array.isArray(item.content) || item.content.some(part => part.type === "text" && typeof part.text !== "string")))) throw failure("Desktop returned an incomplete public item.");
|
|
44
|
+
return item.type === "commandExecution" ? { ...item, aggregatedOutput: publicOutput(item.output) }
|
|
45
|
+
: item.type === "fileChange" ? { ...item, changes: item.changes.map(change => ({ ...change, diff: publicOutput(change.diff) })) } : item;
|
|
46
|
+
}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Adapt Desktop's public, paged native reads to the existing observation code.
|
|
50
|
+
// No raw remote socket, credential copying, implicit resume or mutation API.
|
|
51
|
+
export function remoteReadClient(invoke, hostId, close = () => {}) {
|
|
52
|
+
let first;
|
|
53
|
+
const read = async (threadId, cursor) => {
|
|
54
|
+
const page = await invoke("read_thread", { threadId, hostId, ...(cursor ? { cursor } : {}), turnLimit: 10, includeOutputs: true, maxOutputCharsPerItem: 20000 });
|
|
55
|
+
pageThread(page, threadId, hostId);
|
|
56
|
+
return page;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
close,
|
|
60
|
+
async request(method, params = {}, options = {}) {
|
|
61
|
+
if (method !== "thread/read" || options.mutation) throw remoteReadOnly();
|
|
62
|
+
if (!params.includeTurns) {
|
|
63
|
+
first = await read(params.threadId);
|
|
64
|
+
return { thread: pageThread(first, params.threadId, hostId) };
|
|
65
|
+
}
|
|
66
|
+
let page = first?.thread.id === params.threadId ? first : await read(params.threadId); first = undefined;
|
|
67
|
+
const thread = pageThread(page, params.threadId, hostId), turns = [], ids = new Set(), cursors = new Set();
|
|
68
|
+
for (let count = 0; count < 100; count++) {
|
|
69
|
+
for (const turn of page.turns) {
|
|
70
|
+
if (ids.has(turn.id)) throw failure("Remote history changed or repeated a turn. Read again.", "OBSERVATION_CHANGED");
|
|
71
|
+
ids.add(turn.id); turns.push(publicTurn(turn));
|
|
72
|
+
}
|
|
73
|
+
if (!page.page.hasMore) return { thread: { ...thread, turns: turns.reverse() } };
|
|
74
|
+
if (count === 99) throw failure("Remote history exceeded 100 pages; no partial snapshot was returned.", "REMOTE_HISTORY_LIMIT");
|
|
75
|
+
const cursor = page.page.nextCursor;
|
|
76
|
+
if (cursors.has(cursor)) throw failure("Desktop repeated a remote history cursor.");
|
|
77
|
+
cursors.add(cursor); page = await read(params.threadId, cursor);
|
|
78
|
+
}
|
|
79
|
+
throw failure("Remote history exceeded 100 pages; no partial snapshot was returned.", "REMOTE_HISTORY_LIMIT");
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function remoteObservationConnection({ hostId = targetHost(), contextThread = targetContext() ?? process.env.CODEX_THREAD_ID } = {}, {
|
|
85
|
+
discover = discoverRuntime, connect = RpcClient.connect, endpoint = discoverNativeEndpoint,
|
|
86
|
+
inspectCommand = inspectNativeCommandProtocol, native = nativeOnAppServer, caller = readSendState,
|
|
87
|
+
} = {}) {
|
|
88
|
+
let selected;
|
|
89
|
+
return {
|
|
90
|
+
async discover(home) {
|
|
91
|
+
if (!contextThread) throw failure("Remote reads require a real local caller. Run inside Codex or pass --context-thread <LOCAL-THREAD-ID>.", "REMOTE_CONTEXT_REQUIRED");
|
|
92
|
+
const source = parseThreadTarget(contextThread);
|
|
93
|
+
if (source.hostId !== undefined && source.hostId !== "local") throw failure("--context-thread must identify a local caller.", "REMOTE_CONTEXT_INVALID");
|
|
94
|
+
const runtime = await discover(home);
|
|
95
|
+
assertRuntimeOperation(runtime.state, "read");
|
|
96
|
+
if (selected && (selected.paths.socket !== runtime.paths.socket || selected.state.instance !== runtime.state.instance)) throw failure("The selected shared runtime changed during remote observation.", "RUNTIME_CHANGED");
|
|
97
|
+
selected = runtime;
|
|
98
|
+
return { paths: runtime.paths, state: { native_observation_only: true, remote_host_id: hostId, cli_version: runtime.state.cli_version } };
|
|
99
|
+
},
|
|
100
|
+
async connect(socket, options) {
|
|
101
|
+
if (!selected || socket !== selected.paths.socket) throw failure("Remote discovery is required before connection.");
|
|
102
|
+
const local = await connect(socket, { ...options, timeoutMs: 35000 });
|
|
103
|
+
let closed = false;
|
|
104
|
+
const close = () => {
|
|
105
|
+
if (closed) return;
|
|
106
|
+
closed = true; options?.signal?.removeEventListener("abort", close); local.close();
|
|
107
|
+
};
|
|
108
|
+
options?.signal?.addEventListener("abort", close, { once: true });
|
|
109
|
+
if (options?.signal?.aborted) close();
|
|
110
|
+
try {
|
|
111
|
+
const nativeEndpoint = await endpoint(selected.state);
|
|
112
|
+
await inspectCommand(selected.state);
|
|
113
|
+
const source = await caller(local, parseThreadTarget(contextThread).threadId), turn = source.turns.at(-1);
|
|
114
|
+
if (!turn?.id || source.ephemeral || source.environments?.some(environment => environment.environmentId !== "local")) throw failure("Remote reads require a persisted local caller with a stored turn.", "REMOTE_CONTEXT_INVALID");
|
|
115
|
+
const context = { threadId: source.id, turnId: turn.id };
|
|
116
|
+
validateRemoteCatalog(await native(local, nativeEndpoint, "catalog", undefined, undefined, { timeoutMs: 30000 }));
|
|
117
|
+
const invoke = async (action, args) => {
|
|
118
|
+
const current = await discover(selected.paths.home);
|
|
119
|
+
if (current.paths.socket !== selected.paths.socket || current.state.instance !== selected.state.instance) throw failure("Runtime changed during remote observation.", "RUNTIME_CHANGED");
|
|
120
|
+
try { return nativeToolJson(await native(local, nativeEndpoint, action, args, context, { timeoutMs: 30000 })); }
|
|
121
|
+
catch (error) {
|
|
122
|
+
error.uncertain = false;
|
|
123
|
+
if (error.code === "NATIVE_TIMEOUT") error.code = "TIMEOUT";
|
|
124
|
+
else if (["NATIVE_CONNECTION_FAILED", "NATIVE_CONNECTION_CLOSED"].includes(error.code)) error.code = "CONNECTION_FAILED";
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return remoteReadClient(invoke, hostId, close);
|
|
129
|
+
} catch (error) { close(); throw error; }
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/resource.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { digest } from "./observe.mjs";
|
|
3
3
|
import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { normalizeTaskId } from "./task-id.mjs";
|
|
5
5
|
import { runCommand } from "./runner.mjs";
|
|
6
|
+
import { targetHost } from "./target.mjs";
|
|
6
7
|
|
|
7
8
|
function resourceKey(name) {
|
|
8
9
|
if (!name?.trim() || name.length > 200 || /[\r\n\x00-\x1f]/.test(name)) throw new Error("Resource name must be a non-empty single line (up to 200 characters).");
|
|
@@ -19,7 +20,7 @@ export async function resourceStatus(name, { now = Date.now, ...storage } = {})
|
|
|
19
20
|
export async function acquireResource(name, { owner, ttlMs = 600000, reason = "", condition = "", threadId, now = Date.now, ...storage }) {
|
|
20
21
|
const key = resourceKey(name); validTtl(ttlMs);
|
|
21
22
|
if (!owner?.trim()) throw new Error("--owner is required (for example claude-code or codex).");
|
|
22
|
-
if (threadId) threadId =
|
|
23
|
+
if (threadId) threadId = normalizeTaskId(threadId);
|
|
23
24
|
return withStoreLock(`resource-${key}`, async () => {
|
|
24
25
|
const current = await readRecord("resources", key, storage);
|
|
25
26
|
if (leaseState(current, now()) === "held") throw Object.assign(new Error(`Resource is held by ${current.owner} until ${current.expires_at}.`), { code: "RESOURCE_BUSY" });
|
|
@@ -53,18 +54,19 @@ export async function listResources({ now = Date.now, ...storage } = {}) {
|
|
|
53
54
|
return (await listRecords("resources", storage)).map(lease => ({ name: lease.name, state: leaseState(lease, now()), lease })).sort((a, b) => a.name.localeCompare(b.name));
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
export async function runWithResource(name, argv, { timeoutMs = 0, cwd = process.cwd(), ttlMs = 600000, ...options }) {
|
|
57
|
+
export async function runWithResource(name, argv, { timeoutMs = 0, cwd = targetHost() === "local" ? process.cwd() : undefined, ttlMs = 600000, execBackend, remoteShell, supervisor, connection, ...options }, { execute = runCommand } = {}) {
|
|
57
58
|
if (!argv.length || !Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 86400000) throw new Error("A command and valid --timeout-ms are required.");
|
|
58
59
|
const lease = await acquireResource(name, { ...options, ttlMs }), controller = new AbortController();
|
|
59
|
-
let timer, renewal = Promise.resolve(), stopped = false, leaseError = null, released = false, result;
|
|
60
|
+
let timer, renewal = Promise.resolve(), stopped = false, leaseError = null, released = false, result, uncertain = false;
|
|
60
61
|
const schedule = () => { timer = setTimeout(() => {
|
|
61
62
|
renewal = renewResource(name, lease.token, { ...options, ttlMs }).then(() => { if (!stopped) schedule(); }).catch(e => { leaseError = e.message; controller.abort(); });
|
|
62
63
|
}, Math.floor(ttlMs / 3)); };
|
|
63
64
|
schedule();
|
|
64
|
-
try { result = await
|
|
65
|
+
try { result = await execute(argv, { cwd, timeoutMs, signal: controller.signal, execBackend, remoteShell, threadId: options.threadId, supervisor, connection }); }
|
|
66
|
+
catch (error) { uncertain = !!error.uncertain; if (uncertain) { error.resource = name; error.lease_retained = true; error.remote_execution_status = "unknown"; } throw error; }
|
|
65
67
|
finally {
|
|
66
68
|
stopped = true; clearTimeout(timer); await renewal;
|
|
67
|
-
try { await releaseResource(name, lease.token, options); released = true; } catch (e) { leaseError ??= e.message; }
|
|
69
|
+
if (!uncertain) try { await releaseResource(name, lease.token, options); released = true; } catch (e) { leaseError ??= e.message; }
|
|
68
70
|
}
|
|
69
71
|
return { resource: name, owner: lease.owner, ...result, lease_error: leaseError, released, valid: !leaseError && result.exit_code === 0 && !result.error && !result.timed_out };
|
|
70
72
|
}
|
package/src/runner.mjs
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { targetHost } from "./target.mjs";
|
|
3
|
+
import { runRemoteCommand, runRemoteShellCommand } from "./remote-io.mjs";
|
|
2
4
|
|
|
3
|
-
export function runCommand(argv, { cwd, timeoutMs = 0, onStart = async () => {}, signal: abortSignal } = {}) {
|
|
5
|
+
export function runCommand(argv, { cwd, timeoutMs = 0, onStart = async () => {}, signal: abortSignal, execBackend = "app-server", threadId, remoteShell, supervisor, connection } = {}) {
|
|
4
6
|
if (!argv.length || !argv[0] || !Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 86400000) throw new Error("A command and timeout-ms between 0 and 86400000 are required.");
|
|
7
|
+
if (!["app-server", "thread-shell"].includes(execBackend)) throw new Error("--exec-backend must be app-server or thread-shell.");
|
|
8
|
+
if (targetHost() !== "local") return (execBackend === "thread-shell" ? runRemoteShellCommand : runRemoteCommand)(argv, { cwd, timeoutMs, onStart, signal: abortSignal, threadId, shell: remoteShell, supervisor, connection });
|
|
9
|
+
if (execBackend !== "app-server") throw new Error("thread-shell is an explicit remote execution backend.");
|
|
5
10
|
return new Promise((resolve, reject) => {
|
|
6
11
|
const started = new Date().toISOString();
|
|
7
12
|
let output = "", truncated = false, timedOut = false, failure, killer, timer, startUpdate = Promise.resolve(), closed = false;
|
package/src/runtime.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { lstat, mkdir, readFile, rename, rm, writeFile, realpath } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { inspectAppServerSocket } from "./socket-endpoint.mjs";
|
|
5
6
|
|
|
6
7
|
const runtimeFailure = (message, code) => Object.assign(new Error(message), { code });
|
|
7
8
|
|
|
@@ -75,9 +76,10 @@ export async function discoverRuntime(home) {
|
|
|
75
76
|
if (!isAlive(state.pid) || !isAlive(state.server_pid) || !state.desktop_connected) {
|
|
76
77
|
throw runtimeFailure("Shared App Server is not ready. Finish current tasks, quit Desktop, then run: codexteer desktop start", "RUNTIME_NOT_READY");
|
|
77
78
|
}
|
|
78
|
-
await
|
|
79
|
-
return { paths, state };
|
|
79
|
+
const endpoint = await inspectAppServerSocket(paths.socket);
|
|
80
|
+
return { paths: { ...paths, socket: endpoint.socket }, state };
|
|
80
81
|
} catch (error) {
|
|
82
|
+
if (error.code === "SOCKET_UNSAFE") throw runtimeFailure(error.message, "RUNTIME_UNSAFE");
|
|
81
83
|
if (error.code === "ENOENT") throw runtimeFailure("Shared App Server is unavailable. Finish current tasks, quit Desktop, then run: codexteer desktop start", "RUNTIME_UNAVAILABLE");
|
|
82
84
|
if (["EACCES", "EPERM"].includes(error.code)) throw runtimeFailure("Cannot inspect the shared App Server runtime. Check access permissions.", "PERMISSION_DENIED");
|
|
83
85
|
throw error;
|
|
@@ -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
|
+
}
|