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,72 @@
|
|
|
1
|
+
import { readFile, lstat, mkdir, writeFile, rename, rm, chmod } from "node:fs/promises";
|
|
2
|
+
import { createHash, randomUUID, verify } from "node:crypto";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { deviceStore, validateDeviceKey } from "./remote-device-store.mjs";
|
|
8
|
+
import { connectionPayload, controlFailure } from "./remote-control-auth.mjs";
|
|
9
|
+
import { enrollmentPayload } from "./remote-device-protocol.mjs";
|
|
10
|
+
|
|
11
|
+
const execute = promisify(execFile), hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
12
|
+
async function owned(file, directory = false) {
|
|
13
|
+
const info = await lstat(file);
|
|
14
|
+
if (info.uid !== process.getuid() || info.isSymbolicLink() || (info.mode & 0o077) || !(directory ? info.isDirectory() : info.isFile())) throw controlFailure("Unsafe dedicated key helper runtime.", "REMOTE_DEVICE_STORE_UNSAFE");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function prepareKeychainRuntime(home, identity) {
|
|
18
|
+
const { file } = await deviceStore(home, identity, { create: true }), root = path.join(path.dirname(file), "key-helpers");
|
|
19
|
+
await mkdir(root, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST") throw error; }); await owned(root, true);
|
|
20
|
+
const source = await readFile(new URL("../assets/remote-device-key.swift", import.meta.url)), sourceHash = hash(Buffer.concat([source, Buffer.from(process.arch)]));
|
|
21
|
+
const directory = path.join(root, sourceHash), binary = path.join(directory, "device-key");
|
|
22
|
+
try { await owned(directory, true); await owned(path.join(directory, "manifest.json")); const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
|
|
23
|
+
const runtime = { source: sourceHash, sha256: manifest.sha256 }; await keychainBinary(home, identity, runtime); return runtime;
|
|
24
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
25
|
+
const staging = path.join(root, `.build-${randomUUID()}`); await mkdir(staging, { mode: 0o700 });
|
|
26
|
+
try {
|
|
27
|
+
const swift = path.join(staging, "device-key.swift"), target = path.join(staging, "device-key"); await writeFile(swift, source, { mode: 0o600 });
|
|
28
|
+
try {
|
|
29
|
+
await execute("/usr/bin/xcrun", ["swiftc", swift, "-O", "-framework", "Security", "-framework", "Foundation", "-module-cache-path", path.join(staging, "module-cache"), "-o", target], { timeout: 120000, maxBuffer: 65536 });
|
|
30
|
+
await chmod(target, 0o700);
|
|
31
|
+
// Ad-hoc sign our own new helper only. No Desktop binary or privileged
|
|
32
|
+
// entitlement is modified; no user's signing certificate is accessed.
|
|
33
|
+
await execute("/usr/bin/codesign", ["--sign", "-", "--identifier", "dev.codexteer.device-key", target], { timeout: 10000, maxBuffer: 16384 });
|
|
34
|
+
} catch { throw controlFailure("Cannot build the dedicated Keychain helper. Xcode command-line tools are required; no key was created.", "REMOTE_KEY_HELPER_BUILD_FAILED"); }
|
|
35
|
+
const runtime = { source: sourceHash, sha256: hash(await readFile(target)) };
|
|
36
|
+
await writeFile(path.join(staging, "manifest.json"), JSON.stringify(runtime), { mode: 0o600 });
|
|
37
|
+
await rm(path.join(staging, "module-cache"), { recursive: true, force: true }); await rm(swift);
|
|
38
|
+
await rename(staging, directory); return runtime;
|
|
39
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function keychainBinary(home, identity, runtime) {
|
|
43
|
+
if (!/^[a-f0-9]{64}$/.test(runtime?.source ?? "") || !/^[a-f0-9]{64}$/.test(runtime?.sha256 ?? "")) throw controlFailure("Invalid pinned key helper.", "REMOTE_DEVICE_STORE_UNSAFE");
|
|
44
|
+
const { file } = await deviceStore(home, identity), root = path.join(path.dirname(file), "key-helpers"), directory = path.join(root, runtime.source), binary = path.join(directory, "device-key");
|
|
45
|
+
await owned(root, true); await owned(directory, true); await owned(binary);
|
|
46
|
+
if (hash(await readFile(binary)) !== runtime.sha256) throw controlFailure("Pinned key helper changed. No signing was attempted.", "REMOTE_DEVICE_STORE_UNSAFE");
|
|
47
|
+
await execute("/usr/bin/codesign", ["--verify", "--strict", binary], { timeout: 10000, maxBuffer: 16384 }); return binary;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function keychainOperation(operation, input) {
|
|
51
|
+
const { home, identity, runtime } = input, binary = await keychainBinary(home, identity, runtime);
|
|
52
|
+
const { file: record } = await deviceStore(home, identity), request = path.join(path.dirname(record), `.request-${randomUUID()}.json`);
|
|
53
|
+
let payload;
|
|
54
|
+
if (operation === "connection-proof") payload = connectionPayload(input.challenge, input.enrollment);
|
|
55
|
+
else if (operation === "enrollment-proof") payload = enrollmentPayload(input.challenge, input.enrollment, input.path);
|
|
56
|
+
const bytes = payload ? Buffer.from(JSON.stringify({ domain: "codex-device-key-sign-payload/v1", payload })) : null;
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(request, JSON.stringify({ operation: operation === "create" ? "create" : ["inspect", "recover"].includes(operation) ? "inspect" : "sign",
|
|
59
|
+
keyId: input.enrollment?.keyId ?? input.keyId, ...(bytes ? { signedPayloadBase64: bytes.toString("base64") } : {}) }), { mode: 0o600, flag: "wx" });
|
|
60
|
+
let response;
|
|
61
|
+
try { response = JSON.parse((await execute(binary, [request], { timeout: 10000, maxBuffer: 32768 })).stdout); }
|
|
62
|
+
catch { throw controlFailure("Dedicated Keychain helper lost its acknowledgement. Inspect the saved phase before retrying.", "REMOTE_DEDICATED_KEY_UNAVAILABLE"); }
|
|
63
|
+
if (response.ok !== true) throw controlFailure(`Dedicated Keychain operation failed at ${["input", "create", "validate", "public-key", "payload", "sign"].includes(response.stage) ? response.stage : "worker"} (${Number.isSafeInteger(response.os_status) ? response.os_status : "UNVERIFIED"}).`, "REMOTE_DEDICATED_KEY_UNAVAILABLE");
|
|
64
|
+
const key = validateDeviceKey(response.key);
|
|
65
|
+
if (["create", "recover"].includes(operation)) return key;
|
|
66
|
+
for (const field of Object.keys(key)) if (key[field] !== input.enrollment[field]) throw controlFailure("Dedicated Keychain public key changed.", "REMOTE_DEVICE_KEY_INVALID");
|
|
67
|
+
if (operation === "inspect") return;
|
|
68
|
+
if (!verify("sha256", bytes, { key: Buffer.from(key.publicKeySpkiDerBase64, "base64"), type: "spki", format: "der" }, Buffer.from(response.signatureDerBase64, "base64"))) throw controlFailure("Dedicated signature did not match the enrolled key.", "REMOTE_AUTH_PROOF_INVALID");
|
|
69
|
+
return operation === "connection-proof" ? { type: "device_key_proof", keyId: key.keyId, algorithm: key.algorithm, signatureDerBase64: response.signatureDerBase64, signedPayloadBase64: bytes.toString("base64") }
|
|
70
|
+
: { challenge_token: input.challenge.challenge_token, key_id: key.keyId, algorithm: key.algorithm, signature_der_base64: response.signatureDerBase64, signed_payload_base64: bytes.toString("base64") };
|
|
71
|
+
} finally { await rm(request, { force: true }); }
|
|
72
|
+
}
|
|
@@ -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;
|
|
@@ -10,12 +10,13 @@ import { fetchThread } from "./observe.mjs";
|
|
|
10
10
|
import { ensureDesktopSubscription } from "./subscription.mjs";
|
|
11
11
|
import { discoverNativeEndpoint, inspectNativeEndpoint, nativeToolJson } from "./native-rpc.mjs";
|
|
12
12
|
import { nativeOnAppServer, inspectNativeCommandProtocol, mutationOutcomeUnknown } from "./native-command.mjs";
|
|
13
|
-
import { desktopMembership, initialInstructionMatches } from "./creation-verification.mjs";
|
|
13
|
+
import { desktopMembership, initialInstructionMatches, validateMembershipCatalog } from "./creation-verification.mjs";
|
|
14
14
|
import { writeRecord, storePath } from "./store.mjs";
|
|
15
15
|
|
|
16
16
|
const failure = (message, code = "CREATION_FAILED") => Object.assign(new Error(message), { code });
|
|
17
17
|
|
|
18
18
|
export function validateStandaloneCreationCatalog(catalog) {
|
|
19
|
+
const membership = validateMembershipCatalog(catalog);
|
|
19
20
|
const schema = catalog?.tools?.find(tool => tool.name === "move_thread_to_project" && tool.namespace === "codex_app")?.inputSchema;
|
|
20
21
|
const list = catalog?.tools?.find(tool => tool.name === "list_projects" && tool.namespace === "codex_app")?.inputSchema;
|
|
21
22
|
const project = schema?.properties?.projectId;
|
|
@@ -24,7 +25,7 @@ export function validateStandaloneCreationCatalog(catalog) {
|
|
|
24
25
|
|| !(project?.type === "string" || project?.anyOf?.some(type => type.type === "string"))) {
|
|
25
26
|
throw failure("Desktop native tools do not expose local project assignment.", "NATIVE_CREATION_UNSUPPORTED");
|
|
26
27
|
}
|
|
27
|
-
return { status: "verified", source: "desktop-native-assignment-schema", schema_sha256: createHash("sha256").update(JSON.stringify([list, schema])).digest("hex") };
|
|
28
|
+
return { status: "verified", source: "desktop-native-assignment-schema", schema_sha256: createHash("sha256").update(JSON.stringify([list, schema, membership])).digest("hex") };
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// No caller chat is required. The initial instruction starts exactly one real
|
|
@@ -114,7 +115,7 @@ export async function createStandaloneProjectThread(selector, prompt, settings,
|
|
|
114
115
|
return thread?.id === threadId && thread.projectId === project.id && thread.cwd === scope.cwd
|
|
115
116
|
&& Array.isArray(roots) && roots.length === scope.runtimeWorkspaceRoots.length && scope.runtimeWorkspaceRoots.every(root => roots.includes(root))
|
|
116
117
|
&& thread.name === title
|
|
117
|
-
&& await membership(
|
|
118
|
+
&& await membership(invokeNative, nativeEndpoint, threadId, desktopId, context, nativeOptions) && initialInstructionMatches(await history(client, threadId), prompt);
|
|
118
119
|
};
|
|
119
120
|
let verified = await verify();
|
|
120
121
|
for (let retry = 0; retry < 20 && !verified; retry++) { await sleep(250); verified = await verify(); }
|
package/src/store.mjs
CHANGED
|
@@ -2,12 +2,14 @@ import { lstat, mkdir, readFile, writeFile, rename, rm, readdir, link } from "no
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { codexHome, isAlive } from "./runtime.mjs";
|
|
5
|
+
import { hostNamespace, hostFields } from "./target.mjs";
|
|
5
6
|
|
|
6
7
|
const valid = value => typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(value);
|
|
7
8
|
// The persisted namespace predates the codexteer rename; keep existing records.
|
|
8
9
|
export function storePath(bucket, id, home = codexHome()) {
|
|
9
10
|
if (!valid(bucket) || (id != null && !valid(id))) throw new Error("Invalid local record identifier.");
|
|
10
|
-
|
|
11
|
+
const namespace = hostNamespace();
|
|
12
|
+
return path.join(home, "codex-steer", ...(namespace ? ["hosts", namespace] : []), bucket, ...(id == null ? [] : [`${id}.json`]));
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
async function owned(file, directory = false) {
|
|
@@ -17,7 +19,8 @@ async function owned(file, directory = false) {
|
|
|
17
19
|
|
|
18
20
|
async function directory(bucket, home, create = false) {
|
|
19
21
|
const root = path.join(home, "codex-steer"), target = storePath(bucket, null, home);
|
|
20
|
-
|
|
22
|
+
const namespace = hostNamespace();
|
|
23
|
+
for (const p of [root, ...(namespace ? [path.join(root, "hosts"), path.join(root, "hosts", namespace)] : []), target]) {
|
|
21
24
|
if (create) await mkdir(p, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
|
|
22
25
|
await owned(p, true);
|
|
23
26
|
}
|
|
@@ -36,7 +39,7 @@ export async function writeRecord(bucket, id, data, { home = codexHome() } = {})
|
|
|
36
39
|
await directory(bucket, home, true);
|
|
37
40
|
const target = storePath(bucket, id, home), temp = `${target}.${randomUUID()}.tmp`;
|
|
38
41
|
await owned(target).catch(e => { if (e.code !== "ENOENT") throw e; });
|
|
39
|
-
try { await writeFile(temp, JSON.stringify(data) + "\n", { mode: 0o600, flag: "wx" }); await rename(temp, target); }
|
|
42
|
+
try { await writeFile(temp, JSON.stringify({ ...data, ...hostFields() }) + "\n", { mode: 0o600, flag: "wx" }); await rename(temp, target); }
|
|
40
43
|
finally { await rm(temp, { force: true }); }
|
|
41
44
|
}
|
|
42
45
|
|