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,178 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { cloudApi, cloudCredentials } from "./cloud-api.mjs";
|
|
3
|
+
import { cloudTaskId } from "./cloud-id.mjs";
|
|
4
|
+
import { readRecord, storePath, withStoreLock, writeRecord } from "./store.mjs";
|
|
5
|
+
|
|
6
|
+
const failure = (code, message) => Object.assign(new Error(message), { code, execution_environment: "cloud" });
|
|
7
|
+
const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
8
|
+
const validTurn = (task, value) => typeof value === "string" && value.startsWith(`${task}~`) && /^(?:usertrn|assttrn)_[A-Za-z0-9_-]{1,200}$/.test(value.slice(task.length + 1));
|
|
9
|
+
export const cloudUserRevision = snapshot => digest(snapshot.turns.filter(turn => turn.role === "user").map(turn => ({ id: turn.turn_id, parent: turn.parent_turn_id, messages: turn.messages })));
|
|
10
|
+
|
|
11
|
+
function texts(items) {
|
|
12
|
+
return (Array.isArray(items) ? items : []).filter(item => item?.type === "message").map(item => ({
|
|
13
|
+
role: typeof item.role === "string" ? item.role : null,
|
|
14
|
+
text: (Array.isArray(item.content) ? item.content : []).flatMap(part => typeof part === "string" ? [part]
|
|
15
|
+
: part?.content_type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n"),
|
|
16
|
+
})).filter(item => item.text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function publicProgress(items) {
|
|
20
|
+
return (Array.isArray(items) ? items : []).flatMap(item => {
|
|
21
|
+
const role = item?.role ?? item?.author?.role;
|
|
22
|
+
if (role !== "assistant" || ![undefined, null, "commentary"].includes(item.channel)
|
|
23
|
+
|| item.type === "reasoning" || (item.recipient != null && item.recipient !== "all") || item.metadata?.is_visually_hidden_from_conversation === true
|
|
24
|
+
|| (item.metadata?.reasoning_status != null && item.metadata?.is_thinking_preamble_message !== true)) return [];
|
|
25
|
+
let text;
|
|
26
|
+
if (Array.isArray(item.content)) text = texts([{ ...item, type: "message" }])[0]?.text;
|
|
27
|
+
else if (["text", "multimodal_text"].includes(item.content?.content_type) && Array.isArray(item.content.parts)) text = item.content.parts.filter(part => typeof part === "string").join("");
|
|
28
|
+
if (!text) return [];
|
|
29
|
+
return [{ ...(typeof item.id === "string" && item.id.length <= 512 && !/[\x00-\x1f]/u.test(item.id) ? { id: item.id } : {}), role: "assistant", text }];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function progress(turn) {
|
|
34
|
+
const items = publicProgress(turn.worklog?.messages), seen = new Set(items.flatMap(item => item.id === undefined ? [] : [item.id]));
|
|
35
|
+
const latest = new Map();
|
|
36
|
+
for (const event of [...(turn.thread_events?.events ?? []), ...(turn.app_server_events?.events ?? [])]) {
|
|
37
|
+
const item = event?.params?.item;
|
|
38
|
+
if (!["item/started", "item/completed"].includes(event?.method) || item?.type !== "agentMessage" || item.phase !== "commentary"
|
|
39
|
+
|| typeof item.id !== "string" || !item.id || item.id.length > 512 || /[\x00-\x1f]/u.test(item.id) || typeof item.text !== "string" || !item.text) continue;
|
|
40
|
+
latest.set(item.id, { id: item.id, role: "assistant", text: item.text });
|
|
41
|
+
}
|
|
42
|
+
return [...items, ...[...latest.values()].filter(item => !seen.has(item.id))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commands(turn) {
|
|
46
|
+
const latest = new Map();
|
|
47
|
+
for (const event of [...(turn.thread_events?.events ?? []), ...(turn.app_server_events?.events ?? [])]) {
|
|
48
|
+
const item = event?.params?.item;
|
|
49
|
+
if (!["item/started", "item/completed"].includes(event?.method) || item?.type !== "commandExecution" || typeof item.id !== "string") continue;
|
|
50
|
+
latest.set(item.id, { id: item.id, status: typeof item.status === "string" ? item.status : "unknown",
|
|
51
|
+
command: typeof item.command === "string" ? item.command : null, output: typeof item.aggregatedOutput === "string" ? item.aggregatedOutput : null,
|
|
52
|
+
exit_code: Number.isInteger(item.exitCode) ? item.exitCode : null });
|
|
53
|
+
}
|
|
54
|
+
return [...latest.values()];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function readCloudTask(target, settings = {}, { request = cloudApi } = {}) {
|
|
58
|
+
const taskId = cloudTaskId(target);
|
|
59
|
+
if (settings.timeoutMs !== undefined && (!Number.isSafeInteger(settings.timeoutMs) || settings.timeoutMs < 1000 || settings.timeoutMs > 600000)) throw failure("CLOUD_OPTIONS_INVALID", "--timeout-ms must be 1000..600000.");
|
|
60
|
+
const timeoutMs = settings.timeoutMs ?? 120000, deadline = Date.now() + timeoutMs;
|
|
61
|
+
const details = await request(`tasks/${taskId}`, { timeoutMs, ...(settings.signal ? { signal: settings.signal } : {}) });
|
|
62
|
+
if (deadline - Date.now() < 1) throw failure("CLOUD_TIMEOUT", "Cloud history deadline expired.");
|
|
63
|
+
const history = await request(`tasks/${taskId}/turns`, { timeoutMs: deadline - Date.now(), ...(settings.signal ? { signal: settings.signal } : {}) });
|
|
64
|
+
const current = details.current_assistant_turn;
|
|
65
|
+
if (details.task?.id !== taskId || current?.role !== "assistant" || !validTurn(taskId, current?.id) || !current.id.startsWith(`${taskId}~assttrn_`) || !history.turn_mapping || typeof history.turn_mapping !== "object" || Array.isArray(history.turn_mapping)) {
|
|
66
|
+
throw failure("CLOUD_PROTOCOL_UNVERIFIED", "Cloud did not confirm the requested task and its turn history.");
|
|
67
|
+
}
|
|
68
|
+
if (details.task.current_turn_id !== history.current_turn_id || !history.turn_mapping[current.id]) throw failure("CLOUD_STATE_CHANGED", "Cloud changed while reading. Read again before sending.");
|
|
69
|
+
const nodes = Object.entries(history.turn_mapping);
|
|
70
|
+
if (nodes.length > 10000 || nodes.some(([id, node]) => !validTurn(taskId, id) || node?.turn?.id !== id || (node.parent != null && !validTurn(taskId, node.parent)))) {
|
|
71
|
+
throw failure("CLOUD_PROTOCOL_UNVERIFIED", "Cloud history contains an unrecognized turn identity.");
|
|
72
|
+
}
|
|
73
|
+
const turns = nodes.map(([id, node]) => ({ turn_id: id, parent_turn_id: node.parent ?? null,
|
|
74
|
+
role: node.turn.role, status: node.turn.turn_status ?? null, created_at: node.turn.created_at ?? null,
|
|
75
|
+
messages: texts(node.turn.input_items ?? node.turn.output_items),
|
|
76
|
+
progress: progress(node.turn),
|
|
77
|
+
commands: commands(node.turn),
|
|
78
|
+
})).sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0) || a.turn_id.localeCompare(b.turn_id));
|
|
79
|
+
const snapshot = { task_id: taskId, turn_id: current.id, current_turn_id: history.current_turn_id, status: current.turn_status ?? "unknown",
|
|
80
|
+
title: typeof details.task.title === "string" && details.task.title.length <= 2000 ? details.task.title : null, turns };
|
|
81
|
+
return { backend: "codex-api", execution_environment: "cloud", ...snapshot,
|
|
82
|
+
history_scope: "all-returned-turns", url: `https://chatgpt.com/codex/tasks/${taskId}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function sendCloudTask(target, expectedTurn, prompt, settings = {}, dependencies = {}) {
|
|
86
|
+
const taskId = cloudTaskId(target);
|
|
87
|
+
if (settings.timeoutMs !== undefined && (!Number.isSafeInteger(settings.timeoutMs) || settings.timeoutMs < 1000 || settings.timeoutMs > 600000)) throw failure("CLOUD_OPTIONS_INVALID", "--timeout-ms must be 1000..600000.");
|
|
88
|
+
if (!validTurn(taskId, expectedTurn) || typeof settings.expectedUserRevision !== "string" || !/^[a-f0-9]{64}$/.test(settings.expectedUserRevision) || !["steer", "new-turn"].includes(settings.deliveryMode)) throw failure("CLOUD_OPTIONS_INVALID", "Cloud delivery requires the exact parent turn, user-input revision and delivery mode.");
|
|
89
|
+
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || Buffer.byteLength(prompt) > 1024 * 1024) throw failure("CLOUD_OPTIONS_INVALID", "A non-empty instruction of at most 1 MiB is required.");
|
|
90
|
+
const request = dependencies.request ?? cloudApi, read = dependencies.read ?? readCloudTask;
|
|
91
|
+
const write = dependencies.write ?? writeRecord, load = dependencies.load ?? readRecord, lock = dependencies.lock ?? withStoreLock;
|
|
92
|
+
// Pin one official account for the read/compare/send sequence. Never refresh
|
|
93
|
+
// or automatically retry an unknown submission against a different account.
|
|
94
|
+
const auth = dependencies.request ? null : await cloudCredentials();
|
|
95
|
+
const api = (route, options = {}) => request(route, { ...options, ...(auth ? { credentials: async () => auth } : {}) });
|
|
96
|
+
try { return await lock(`cloud-send-${taskId}`, async () => {
|
|
97
|
+
const observation = await read(taskId, { timeoutMs: settings.timeoutMs }, { request: api });
|
|
98
|
+
if (observation.turn_id !== expectedTurn || cloudUserRevision(observation) !== settings.expectedUserRevision) throw failure("CLOUD_STATE_CHANGED", "Cloud task or user input changed after observation. Read again; no message was sent.");
|
|
99
|
+
const active = ["in_progress", "pending", "queued", "running"].includes(observation.status);
|
|
100
|
+
if (settings.deliveryMode === "steer" ? !active : !["completed", "failed", "cancelled"].includes(observation.status)) {
|
|
101
|
+
throw failure(active ? "CLOUD_TASK_BUSY" : "CLOUD_TASK_IDLE", settings.deliveryMode === "steer"
|
|
102
|
+
? "The task has no active turn. Use --new-turn to continue an idle cloud task."
|
|
103
|
+
: "A new turn requires a finished cloud turn. Nothing was sent.");
|
|
104
|
+
}
|
|
105
|
+
const previous = await load("cloud-send-heads", taskId);
|
|
106
|
+
const safelyUnsent = previous?.submission_status === "not_sent" && previous.dispatch_started === false;
|
|
107
|
+
if (previous && !safelyUnsent && (previous.submission_status !== "accepted" || previous.parent_turn_id === expectedTurn)) throw Object.assign(failure("CLOUD_DUPLICATE_SEND", "An accepted or uncertain send needs inspection before another send. Use history check <TASK> --attempt <ATTEMPT_ID>; do not resend."), { existing_attempt_id: previous.attempt_id, existing_receipt_path: previous.receipt_path });
|
|
108
|
+
const attemptId = randomUUID();
|
|
109
|
+
const receipt = { execution_environment: "cloud", backend: "codex-api", task_id: taskId, parent_turn_id: expectedTurn,
|
|
110
|
+
attempt_id: attemptId, receipt_path: storePath("cloud-messages", attemptId), user_revision: settings.expectedUserRevision,
|
|
111
|
+
prompt_sha256: createHash("sha256").update(prompt).digest("hex"), prompt_hash_encoding: "utf8", message_characters: [...prompt].length,
|
|
112
|
+
delivery_action: active ? "interrupt-and-follow-up" : "new-turn", parent_status: observation.status, submission_status: "not_sent", dry_run: !!settings.dryRun };
|
|
113
|
+
await settings.beforeSend?.(observation);
|
|
114
|
+
if (settings.dryRun) return receipt;
|
|
115
|
+
const dispatched = { ...receipt, submission_status: "dispatching" };
|
|
116
|
+
try {
|
|
117
|
+
await write("cloud-messages", attemptId, receipt);
|
|
118
|
+
// Finish receipt persistence before reserving the head. There must be no
|
|
119
|
+
// fallible persistence between the reservation and the single POST.
|
|
120
|
+
await write("cloud-messages", attemptId, dispatched);
|
|
121
|
+
await write("cloud-send-heads", taskId, dispatched);
|
|
122
|
+
} catch {
|
|
123
|
+
const unsent = { ...receipt, dispatch_started: false, error_code: "CLOUD_PERSISTENCE_FAILED" };
|
|
124
|
+
try { await write("cloud-send-heads", taskId, previous ?? unsent); }
|
|
125
|
+
catch { unsent.reservation_cleanup_required = true; unsent.receipt_update_required = true; }
|
|
126
|
+
try { await write("cloud-messages", attemptId, unsent); }
|
|
127
|
+
catch { unsent.receipt_update_required = true; }
|
|
128
|
+
throw Object.assign(failure("CLOUD_PERSISTENCE_FAILED", "Cloud send records could not be prepared. No request was sent. Inspect the receipt and reservation before an explicit retry."), unsent, { uncertain: false });
|
|
129
|
+
}
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await api("tasks", { timeoutMs: settings.timeoutMs, body: {
|
|
133
|
+
follow_up: { task_id: taskId, turn_id: expectedTurn, environment_mode: "code" },
|
|
134
|
+
input_items: [{ type: "message", role: "user", content: [{ content_type: "text", text: prompt }] }],
|
|
135
|
+
} });
|
|
136
|
+
if ((response.task?.id ?? response.id) !== taskId) throw failure("CLOUD_PROTOCOL_UNVERIFIED", "Cloud did not confirm the same task after follow-up.");
|
|
137
|
+
receipt.submission_status = "accepted";
|
|
138
|
+
} catch (error) {
|
|
139
|
+
receipt.submission_status = "unknown";
|
|
140
|
+
receipt.error_code = error.code ?? "CLOUD_SEND_FAILED";
|
|
141
|
+
try { await write("cloud-messages", attemptId, receipt); } catch { receipt.receipt_update_required = true; }
|
|
142
|
+
throw Object.assign(failure(receipt.error_code, "Cloud follow-up outcome is unknown. Read this task and inspect the receipt before retrying; no automatic retry was made."), receipt, { uncertain: true });
|
|
143
|
+
}
|
|
144
|
+
try { await write("cloud-messages", attemptId, receipt); await write("cloud-send-heads", taskId, receipt); } catch { receipt.receipt_update_required = true; }
|
|
145
|
+
return receipt;
|
|
146
|
+
}); } finally { if (auth) auth.token = ""; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function cloudSendReceipt(attemptId, { load = readRecord, taskId } = {}) {
|
|
150
|
+
if (typeof attemptId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(attemptId)) throw failure("CLOUD_OPTIONS_INVALID", "A cloud send attempt UUID is required.");
|
|
151
|
+
const receipt = await load("cloud-messages", attemptId);
|
|
152
|
+
if (!receipt) throw failure("CLOUD_RECEIPT_NOT_FOUND", "Cloud send receipt was not found.");
|
|
153
|
+
if (taskId !== undefined && cloudTaskId(receipt.task_id) !== cloudTaskId(taskId)) throw failure("CLOUD_TARGET_CONFLICT", "The send attempt belongs to another cloud task.");
|
|
154
|
+
return receipt;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function reconcileCloudSend(attemptId, { read = readCloudTask, load = readRecord, write = writeRecord, lock = withStoreLock, taskId: expectedTask, expectedPromptSha256, expectedParentTurnId } = {}) {
|
|
158
|
+
const receipt = await cloudSendReceipt(attemptId, { load, taskId: expectedTask });
|
|
159
|
+
const taskId = cloudTaskId(receipt.task_id);
|
|
160
|
+
if ((expectedPromptSha256 !== undefined && receipt.prompt_sha256 !== expectedPromptSha256)
|
|
161
|
+
|| (expectedParentTurnId !== undefined && receipt.parent_turn_id !== expectedParentTurnId)) {
|
|
162
|
+
return { ...receipt, delivery_verification: "conflict", retry_safe: false };
|
|
163
|
+
}
|
|
164
|
+
return lock(`cloud-send-${taskId}`, async () => {
|
|
165
|
+
if (receipt.submission_status === "not_sent" && receipt.dispatch_started === false) {
|
|
166
|
+
if ((await load("cloud-send-heads", taskId))?.attempt_id === attemptId) await write("cloud-send-heads", taskId, receipt);
|
|
167
|
+
return { ...receipt, delivery_verification: "not_sent", retry_safe: true, reservation_cleanup_required: false };
|
|
168
|
+
}
|
|
169
|
+
const snapshot = await read(taskId);
|
|
170
|
+
const matches = snapshot.turns.filter(turn => turn.role === "user" && turn.parent_turn_id === receipt.parent_turn_id
|
|
171
|
+
&& turn.messages.some(message => createHash("sha256").update(message.text).digest("hex") === receipt.prompt_sha256));
|
|
172
|
+
if (matches.length !== 1) return { ...receipt, delivery_verification: "not_observed", retry_safe: false };
|
|
173
|
+
Object.assign(receipt, { submission_status: "accepted", delivery_verification: "stored", user_turn_id: matches[0].turn_id });
|
|
174
|
+
await write("cloud-messages", attemptId, receipt);
|
|
175
|
+
if ((await load("cloud-send-heads", taskId))?.attempt_id === attemptId) await write("cloud-send-heads", taskId, receipt);
|
|
176
|
+
return receipt;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cloudTaskId } from "./cloud-id.mjs";
|
|
2
|
+
import { cloudThread, CloudReadClient } from "./cloud-connection.mjs";
|
|
3
|
+
import { readCloudTask, sendCloudTask, cloudUserRevision } from "./cloud-observe.mjs";
|
|
4
|
+
|
|
5
|
+
export async function sendCloudInput(target, message, options = {}, dependencies = {}) {
|
|
6
|
+
const taskId = cloudTaskId(target), read = dependencies.read ?? readCloudTask, send = dependencies.send ?? sendCloudTask;
|
|
7
|
+
if (typeof message !== "string" || !message.trim() || message.includes("\0") || Buffer.byteLength(message) > 1024 * 1024) throw Object.assign(new Error("A non-empty cloud instruction of at most 1 MiB is required."), { code: "CLOUD_OPTIONS_INVALID" });
|
|
8
|
+
if (options.dryRun) return { thread_id: taskId, task_id: taskId, backend: "codex-api", execution_environment: "cloud", delivery_action: options.newTurn ? "new-turn" : "interrupt-and-follow-up",
|
|
9
|
+
message_characters: [...message].length, dry_run: true, sent: false, delivery_status: "not_sent", freshness_checked: false };
|
|
10
|
+
const snapshot = await read(taskId);
|
|
11
|
+
const active = cloudThread(snapshot).turns.filter(turn => turn.status === "inProgress");
|
|
12
|
+
if (options.newTurn ? active.length > 0 : active.length !== 1 || active[0]?.id !== snapshot.turn_id) {
|
|
13
|
+
throw Object.assign(new Error(options.newTurn ? "--new-turn requires an idle cloud task." : "The cloud task has no unambiguous selected active turn. Read it again before sending."), { code: options.newTurn ? "CLOUD_TASK_BUSY" : "CLOUD_TURN_AMBIGUOUS" });
|
|
14
|
+
}
|
|
15
|
+
const client = new CloudReadClient({ read });
|
|
16
|
+
try {
|
|
17
|
+
const receipt = await send(taskId, snapshot.turn_id, message, {
|
|
18
|
+
dryRun: !!options.dryRun, deliveryMode: options.newTurn ? "new-turn" : "steer",
|
|
19
|
+
expectedUserRevision: cloudUserRevision(snapshot),
|
|
20
|
+
beforeSend: current => options.beforeSend?.(cloudThread(current), client),
|
|
21
|
+
});
|
|
22
|
+
return { thread_id: taskId, task_id: taskId, backend: "codex-api", execution_environment: "cloud",
|
|
23
|
+
message_characters: [...message].length, dry_run: !!options.dryRun, sent: !options.dryRun,
|
|
24
|
+
delivery_status: options.dryRun ? "not_sent" : "accepted", delivery_action: receipt.delivery_action,
|
|
25
|
+
cloud_attempt_id: receipt.attempt_id, cloud_parent_turn_id: receipt.parent_turn_id, client_message_id: options.clientMessageId,
|
|
26
|
+
turn_id: null, receipt_path: receipt.receipt_path, receipt_update_required: receipt.receipt_update_required,
|
|
27
|
+
turn_identity: "follow-up; read/history-check confirms the new turn", freshness_atomic: false };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
error.cloud_attempt_id = error.attempt_id; error.cloud_parent_turn_id = error.parent_turn_id;
|
|
30
|
+
error.delivery_status = error.uncertain ? "unknown" : "not_sent"; error.sent = error.uncertain ? null : false;
|
|
31
|
+
throw error;
|
|
32
|
+
} finally { client.close(); }
|
|
33
|
+
}
|
package/src/cloud.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readRecord, storePath, writeRecord } from "./store.mjs";
|
|
7
|
+
import { cloudApi } from "./cloud-api.mjs";
|
|
8
|
+
import { cloudTaskId } from "./cloud-id.mjs";
|
|
9
|
+
|
|
10
|
+
const failure = (message, code = "CLOUD_OPTIONS_INVALID") => Object.assign(new Error(message), { code });
|
|
11
|
+
const taskUrl = id => `https://chatgpt.com/codex/tasks/${id}`;
|
|
12
|
+
|
|
13
|
+
function options(settings = {}) {
|
|
14
|
+
const timeoutMs = settings.timeoutMs ?? 120000;
|
|
15
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) throw failure("--timeout-ms must be 1000..600000.");
|
|
16
|
+
return { timeoutMs };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// No shell, authentication parsing, automatic installation, or Desktop fallback.
|
|
20
|
+
// Prompt input goes through stdin, never the child argv or an on-disk prompt file.
|
|
21
|
+
export async function runCloudCli(args, { input = "", timeoutMs = 120000, maxBytes = 16 * 1024 * 1024 } = {}) {
|
|
22
|
+
let directory;
|
|
23
|
+
try { directory = await mkdtemp(path.join(os.tmpdir(), "codexteer-cloud-cli-")); }
|
|
24
|
+
catch { return { started: false, launchError: "PRIVATE_DIRECTORY_UNAVAILABLE" }; }
|
|
25
|
+
// The official cloud CLI can write error.log in cwd, including backend data.
|
|
26
|
+
// Do not place it in a user's checkout or package. Remove the private cwd.
|
|
27
|
+
const environment = { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0", CLICOLOR: "0", TERM: "dumb" };
|
|
28
|
+
delete environment.CODEX_STARTING_DIFF;
|
|
29
|
+
try { return await new Promise(resolve => {
|
|
30
|
+
let child, started = false, stdout = "", bytes = 0, timedOut = false, overflow = false, ioError = false, launchError, killTimer;
|
|
31
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"], handlers = new Map();
|
|
32
|
+
try {
|
|
33
|
+
child = spawn("codex", ["cloud", ...args], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, cwd: directory, env: environment });
|
|
34
|
+
} catch (error) { resolve({ started, launchError: error.code }); return; }
|
|
35
|
+
child.once("spawn", () => { started = true; });
|
|
36
|
+
child.once("error", error => { launchError = error.code ?? "SPAWN_ERROR"; });
|
|
37
|
+
const stop = signal => { child.kill(signal); killTimer ??= setTimeout(() => child.kill("SIGKILL"), 500); };
|
|
38
|
+
child.stdout.setEncoding("utf8");
|
|
39
|
+
child.stdout.on("data", chunk => { bytes += Buffer.byteLength(chunk); if (bytes <= maxBytes) stdout += chunk; else { overflow = true; stop(); } });
|
|
40
|
+
// Drain but never expose or persist raw diagnostics (which may contain secrets).
|
|
41
|
+
child.stderr.on("data", chunk => { bytes += chunk.length; if (bytes > maxBytes) { overflow = true; stop(); } });
|
|
42
|
+
child.stdin.on("error", error => { if (error.code !== "EPIPE") ioError = true; });
|
|
43
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
|
|
44
|
+
for (const signal of signals) {
|
|
45
|
+
const handler = () => stop(signal); handlers.set(signal, handler); process.once(signal, handler);
|
|
46
|
+
}
|
|
47
|
+
child.once("close", (exitCode, signal) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
clearTimeout(killTimer);
|
|
50
|
+
for (const [name, handler] of handlers) process.removeListener(name, handler);
|
|
51
|
+
resolve({ started, stdout, exitCode, signal, timedOut, overflow, ioError, launchError });
|
|
52
|
+
});
|
|
53
|
+
child.stdin.end(input);
|
|
54
|
+
}); } finally { await rm(directory, { recursive: true, force: true }); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function completed(result) { return !result.launchError && !result.signal && !result.timedOut && !result.overflow && !result.ioError; }
|
|
58
|
+
function cliFailure(result) {
|
|
59
|
+
const code = !result.started && result.launchError ? "CLOUD_CLI_UNAVAILABLE" : result.timedOut ? "CLOUD_TIMEOUT" : result.overflow ? "CLOUD_OUTPUT_LIMIT" : "CLOUD_CLI_FAILED";
|
|
60
|
+
return Object.assign(failure(code === "CLOUD_CLI_UNAVAILABLE" ? "Cannot start codex from PATH. Install the official Codex CLI; no fallback was attempted."
|
|
61
|
+
: "Codex Cloud command failed. Check the official CLI login and environment access. Raw diagnostics are omitted to avoid exposing private data.", code),
|
|
62
|
+
{ cli_exit_code: result.exitCode, cli_signal: result.signal });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function preflight(action, run) {
|
|
66
|
+
const result = await run([action, "--help"], { timeoutMs: 10000 });
|
|
67
|
+
if (!completed(result) || result.exitCode !== 0) throw cliFailure(result);
|
|
68
|
+
const required = action === "exec" ? ["codex cloud exec", "--env", "--branch", "--attempts"] : [`codex cloud ${action}`, ...(action === "diff" ? ["--attempt"] : [])];
|
|
69
|
+
if (!required.every(text => result.stdout.includes(text))) throw failure("The installed Codex CLI does not expose the required cloud command. No task was submitted.", "CLOUD_CLI_UNSUPPORTED");
|
|
70
|
+
// '-' must mean stdin, not a literal initial instruction. Older, unverified
|
|
71
|
+
// CLI versions are refused before a mutation even if their flags look alike.
|
|
72
|
+
const version = await run(["--version"], { timeoutMs: 10000 });
|
|
73
|
+
if (!completed(version) || version.exitCode !== 0) throw cliFailure(version);
|
|
74
|
+
const match = version.stdout?.trim().match(/^codex-cli-cloud (\d+)\.(\d+)\.(\d+)(?:[-+][\w.-]+)?$/);
|
|
75
|
+
if (!match || !(Number(match[1]) > 0 || Number(match[2]) > 157 || (Number(match[2]) === 157 && Number(match[3]) >= 1))) throw failure("Codex Cloud requires the official CLI 0.157.1 or newer with verified stdin semantics. No task was submitted.", "CLOUD_CLI_UNSUPPORTED");
|
|
76
|
+
return match[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function createCloudTask(environment, branch, prompt, settings = {}, dependencies = {}) {
|
|
80
|
+
const limits = options(settings);
|
|
81
|
+
if (typeof environment !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/.test(environment)) throw failure("--env requires a cloud environment ID or simple unique label.");
|
|
82
|
+
// Require an explicit Git branch instead of inheriting the active local branch.
|
|
83
|
+
if (typeof branch !== "string" || !branch || branch.length > 255 || branch.startsWith("-") || branch.startsWith("/") || branch.endsWith("/")
|
|
84
|
+
|| /[\s\x00-\x1f\x7f~^:?*\[\\]/u.test(branch) || branch.includes("..") || branch.includes("@{") || branch.includes("//")
|
|
85
|
+
|| branch.split("/").some(part => part.startsWith(".") || part.endsWith(".lock") || part.endsWith("."))) throw failure("--branch requires an explicit valid Git branch name.");
|
|
86
|
+
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || Buffer.byteLength(prompt) > 1024 * 1024) throw failure("A non-empty initial instruction of at most 1 MiB is required.");
|
|
87
|
+
const run = dependencies.run ?? runCloudCli, write = dependencies.write ?? writeRecord;
|
|
88
|
+
const attemptId = randomUUID();
|
|
89
|
+
const receipt = { schema: 1, backend: "codex-cli", execution_environment: "cloud", attempt_id: attemptId,
|
|
90
|
+
environment_selector: environment, branch, creation_status: "not_created", submission_status: "not_sent",
|
|
91
|
+
prompt_sha256: createHash("sha256").update(prompt).digest("hex"), message_characters: [...prompt].length, dry_run: !!settings.dryRun };
|
|
92
|
+
if (settings.dryRun) return receipt;
|
|
93
|
+
receipt.cli_version = await preflight("exec", run);
|
|
94
|
+
receipt.receipt_path = storePath("cloud-creations", attemptId);
|
|
95
|
+
await write("cloud-creations", attemptId, receipt);
|
|
96
|
+
let dispatched = false;
|
|
97
|
+
try {
|
|
98
|
+
await write("cloud-creations", attemptId, { ...receipt, creation_status: "dispatching", submission_status: "dispatching" });
|
|
99
|
+
dispatched = true;
|
|
100
|
+
const result = await run(["exec", "--env", environment, "--branch", branch, "--attempts", "1", "-"], { ...limits, input: prompt });
|
|
101
|
+
if (!result.started && result.launchError) { dispatched = false; throw cliFailure(result); }
|
|
102
|
+
// The official CLI prints exactly the created task URL. No inferred IDs.
|
|
103
|
+
let id;
|
|
104
|
+
const acknowledgement = result.stdout?.trim();
|
|
105
|
+
if (typeof acknowledgement === "string" && /^https:\/\/chatgpt\.com\/codex\/tasks\/task_[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/.test(acknowledgement)) id = cloudTaskId(acknowledgement);
|
|
106
|
+
if (id) Object.assign(receipt, { task_id: id, url: taskUrl(id), creation_status: "created" });
|
|
107
|
+
if (!completed(result) || result.exitCode !== 0) throw cliFailure(result);
|
|
108
|
+
if (!id) throw failure("No unique cloud task ID was confirmed. Inspect the receipt and cloud task list; do not submit again automatically.", "CLOUD_CREATION_UNKNOWN");
|
|
109
|
+
receipt.submission_status = "accepted";
|
|
110
|
+
try { await write("cloud-creations", attemptId, receipt); } catch { receipt.receipt_update_required = true; }
|
|
111
|
+
return receipt;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
Object.assign(receipt, { creation_status: receipt.task_id ? "created" : dispatched ? "unknown" : "not_created",
|
|
114
|
+
submission_status: dispatched ? "unknown" : "not_sent", error_code: error.code ?? "CLOUD_CLI_FAILED" });
|
|
115
|
+
try { await write("cloud-creations", attemptId, receipt); } catch { receipt.receipt_update_required = true; }
|
|
116
|
+
// Never propagate a child-process Error: its message can embed the prompt.
|
|
117
|
+
throw Object.assign(failure("Cloud task submission was not fully confirmed. Inspect the receipt and existing tasks before retrying; no automatic retry was made.", receipt.error_code),
|
|
118
|
+
{ ...receipt, uncertain: dispatched, cli_exit_code: error.cli_exit_code, cli_signal: error.cli_signal });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function cloudTaskDiff(target, settings = {}, { run = runCloudCli, request = cloudApi } = {}) {
|
|
123
|
+
const taskId = cloudTaskId(target), limits = options(settings);
|
|
124
|
+
if (settings.attempt !== undefined && (!Number.isInteger(settings.attempt) || settings.attempt < 1 || settings.attempt > 4)) throw failure("--attempt must be 1..4.");
|
|
125
|
+
await preflight("diff", run);
|
|
126
|
+
const result = await run(["diff", taskId, ...(settings.attempt === undefined ? [] : ["--attempt", String(settings.attempt)])], limits);
|
|
127
|
+
if (completed(result) && result.exitCode === 1 && settings.attempt === undefined) {
|
|
128
|
+
// A finished task may have commands/files but no recorded patch. Verify
|
|
129
|
+
// this through the same task's API; never turn stderr into a diff or infer
|
|
130
|
+
// that no files changed from the absence of a server-provided patch.
|
|
131
|
+
const details = await request(`tasks/${taskId}`, limits);
|
|
132
|
+
const current = details.current_assistant_turn;
|
|
133
|
+
if (details.task?.id === taskId && current?.role === "assistant" && ["completed", "failed", "cancelled"].includes(current.turn_status)
|
|
134
|
+
&& details.current_diff_task_turn == null && Array.isArray(current.output_items) && !current.output_items.some(item => ["output_diff", "pr"].includes(item?.type))) {
|
|
135
|
+
return { backend: "codex-api", execution_environment: "cloud", task_id: taskId, url: taskUrl(taskId), attempt: null,
|
|
136
|
+
attempt_selection: "official-cli-default", diff: null, diff_available: false, reason: "no-server-recorded-patch", local_changes: "unknown" };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!completed(result) || result.exitCode !== 0) throw cliFailure(result);
|
|
140
|
+
return { backend: "codex-cli", execution_environment: "cloud", task_id: taskId, url: taskUrl(taskId), attempt: settings.attempt ?? null,
|
|
141
|
+
attempt_selection: settings.attempt === undefined ? "official-cli-default" : "explicit", diff: result.stdout, diff_available: true };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function cloudCreationReceipt(attemptId) {
|
|
145
|
+
if (typeof attemptId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(attemptId)) throw failure("A cloud creation attempt UUID is required.");
|
|
146
|
+
const receipt = await readRecord("cloud-creations", attemptId);
|
|
147
|
+
if (!receipt) throw failure("Cloud creation receipt was not found.", "CLOUD_RECEIPT_NOT_FOUND");
|
|
148
|
+
return receipt;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function listCloudTasks({ limit = 20, cursor, environment } = {}, { run = runCloudCli } = {}) {
|
|
152
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500 || (cursor !== undefined && (typeof cursor !== "string" || !cursor || cursor.length > 4096 || /[\x00-\x1f]/u.test(cursor)))
|
|
153
|
+
|| (environment !== undefined && (typeof environment !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(environment)))) throw failure("Invalid cloud task-list limit, cursor, or environment.");
|
|
154
|
+
await preflight("list", run);
|
|
155
|
+
const collected = new Map(), seen = new Set(); let next = cursor;
|
|
156
|
+
for (let page = 0; page < 100; page++) {
|
|
157
|
+
const size = Math.min(20, limit - collected.size);
|
|
158
|
+
const result = await run(["list", "--json", "--limit", String(size), ...(next ? ["--cursor", next] : []), ...(environment ? ["--env", environment] : [])]);
|
|
159
|
+
if (!completed(result) || result.exitCode !== 0) throw cliFailure(result);
|
|
160
|
+
let data; try { data = JSON.parse(result.stdout); } catch { throw failure("Invalid official cloud task list.", "CLOUD_PROTOCOL_UNVERIFIED"); }
|
|
161
|
+
if (!Array.isArray(data.tasks) || data.tasks.length > size || (data.cursor != null && (typeof data.cursor !== "string" || !data.cursor || data.cursor.length > 4096 || /[\x00-\x1f]/u.test(data.cursor) || seen.has(data.cursor)))) throw failure("Invalid official cloud task list.", "CLOUD_PROTOCOL_UNVERIFIED");
|
|
162
|
+
for (const task of data.tasks) {
|
|
163
|
+
const id = cloudTaskId(task.id);
|
|
164
|
+
collected.set(id, { thread_id: id, task_id: id, title: typeof task.title === "string" ? task.title : null,
|
|
165
|
+
status: typeof task.status === "string" ? task.status : "unknown", updated_at: typeof task.updated_at === "string" || typeof task.updated_at === "number" ? task.updated_at : null, execution_environment: "cloud", url: taskUrl(id) });
|
|
166
|
+
}
|
|
167
|
+
next = data.cursor ?? null;
|
|
168
|
+
if (!next || collected.size >= limit) return { threads: [...collected.values()], count: collected.size, cursor: next, has_more: !!next, execution_environment: "cloud" };
|
|
169
|
+
seen.add(next);
|
|
170
|
+
}
|
|
171
|
+
throw failure("Cloud task pagination exceeded its safety limit.", "CLOUD_PROTOCOL_UNVERIFIED");
|
|
172
|
+
}
|
package/src/connection.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { desktopCloudConnection } from "./desktop-cloud-api.mjs";
|
|
2
|
+
import { cloudConnection } from "./cloud-connection.mjs";
|
|
1
3
|
import { lstat, realpath } from "node:fs/promises";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { codexHome } from "./runtime.mjs";
|
|
4
6
|
import { RpcClient } from "./rpc.mjs";
|
|
7
|
+
import { inspectAppServerSocket } from "./socket-endpoint.mjs";
|
|
8
|
+
import { targetHost, remoteTransport, isControlTransport } from "./target.mjs";
|
|
9
|
+
import { remoteObservationConnection } from "./remote.mjs";
|
|
10
|
+
import { remoteControlConnection } from "./remote-control.mjs";
|
|
5
11
|
|
|
6
12
|
const failure = (message, code) => Object.assign(new Error(message), { code });
|
|
7
13
|
const reads = new Set(["thread/loaded/list", "thread/read", "thread/turns/list", "thread/items/list"]);
|
|
8
14
|
export function connectionMode(value = "shared") {
|
|
9
|
-
if (!["shared", "desktop"].includes(value)) throw new Error("--connection must be shared or desktop.");
|
|
15
|
+
if (!["shared", "desktop", "cloud", "desktop-cloud"].includes(value)) throw new Error("--connection must be shared, desktop, cloud, or desktop-cloud.");
|
|
10
16
|
return value;
|
|
11
17
|
}
|
|
12
18
|
export function desktopReadOnly() {
|
|
@@ -16,16 +22,22 @@ export function desktopReadOnly() {
|
|
|
16
22
|
async function identity(home) {
|
|
17
23
|
const directory = path.join(home, "app-server-control"), socket = path.join(directory, "app-server-control.sock");
|
|
18
24
|
const fingerprint = [];
|
|
19
|
-
for (const [file, type] of [[home, "home"], [directory, "directory"]
|
|
25
|
+
for (const [file, type] of [[home, "home"], [directory, "directory"]]) {
|
|
20
26
|
const info = await lstat(file);
|
|
21
27
|
if (info.uid !== process.getuid() || info.isSymbolicLink() || (info.mode & (type === "home" ? 0o022 : 0o077)) !== 0
|
|
22
|
-
||
|
|
28
|
+
|| !info.isDirectory()) {
|
|
23
29
|
throw failure("Desktop control endpoint has unsafe ownership, permissions, or file type.", "DESKTOP_CONNECTION_UNSAFE");
|
|
24
30
|
}
|
|
25
31
|
// Home directory timestamps change for unrelated profile activity.
|
|
26
|
-
fingerprint.push([info.dev, info.ino
|
|
32
|
+
fingerprint.push([info.dev, info.ino]);
|
|
27
33
|
}
|
|
28
|
-
|
|
34
|
+
let endpoint;
|
|
35
|
+
try { endpoint = await inspectAppServerSocket(socket); }
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === "SOCKET_UNSAFE") throw failure(error.message, "DESKTOP_CONNECTION_UNSAFE");
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return { home, directory, socket: endpoint.socket, fingerprint: JSON.stringify([...fingerprint, ...endpoint.fingerprint]) };
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export async function discoverDesktopRuntime(home = codexHome()) {
|
|
@@ -89,7 +101,13 @@ export async function connectDesktop(paths, options, connect = RpcClient.connect
|
|
|
89
101
|
// Each operation owns its discovery result. No implicit fallback to another
|
|
90
102
|
// profile, daemon, or wrapper, including after connection failures.
|
|
91
103
|
export function observationConnection(mode) {
|
|
104
|
+
if (targetHost() !== "local") {
|
|
105
|
+
if (connectionMode(mode) !== "shared") throw failure("Remote observation uses the selected Desktop native host route and cannot use --connection desktop.", "HOST_CONNECTION_MISMATCH");
|
|
106
|
+
return isControlTransport() ? remoteControlConnection() : remoteObservationConnection();
|
|
107
|
+
}
|
|
92
108
|
if (connectionMode(mode) === "shared") return {};
|
|
109
|
+
if (mode === "desktop-cloud") return desktopCloudConnection();
|
|
110
|
+
if (mode === "cloud") return cloudConnection();
|
|
93
111
|
let selected;
|
|
94
112
|
return {
|
|
95
113
|
async discover(home) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { nativeToolJson } from "./native-rpc.mjs";
|
|
2
|
+
|
|
3
|
+
export function validateMembershipCatalog(catalog) {
|
|
4
|
+
const schema = catalog?.tools?.find(tool => tool.name === "list_threads" && tool.namespace === "codex_app")?.inputSchema;
|
|
5
|
+
const limit = schema?.properties?.limit;
|
|
6
|
+
if (schema?.type !== "object" || !["number", "integer"].includes(limit?.type)
|
|
7
|
+
|| (limit.minimum ?? 1) > 50 || (limit.maximum ?? 50) < 50) {
|
|
8
|
+
throw Object.assign(new Error("Desktop native tools do not expose the membership snapshot schema."), { code: "NATIVE_CREATION_UNSUPPORTED" });
|
|
9
|
+
}
|
|
10
|
+
return schema;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The persisted legacy assignment map is not Desktop's live membership view.
|
|
14
|
+
// Use the same native projection as the sidebar, and fail closed if the task
|
|
15
|
+
// is outside this bounded snapshot. Canonical membership is checked separately.
|
|
16
|
+
export async function desktopMembership(native, endpoint, threadId, projectId, context, options) {
|
|
17
|
+
const snapshot = nativeToolJson(await native(endpoint, "list_threads", { limit: 50 }, context, options));
|
|
18
|
+
if (!Array.isArray(snapshot.threads) || !Array.isArray(snapshot.pinnedThreads)) {
|
|
19
|
+
throw Object.assign(new Error("Invalid Desktop membership snapshot."), { code: "NATIVE_PROTOCOL_ERROR" });
|
|
20
|
+
}
|
|
21
|
+
const matches = [...snapshot.pinnedThreads, ...snapshot.threads].filter(thread => thread?.id === threadId);
|
|
22
|
+
return matches.length === 1 && matches[0].kind === "codex" && matches[0].hostId === "local" && matches[0].projectId === projectId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function initialInstructionText(history, sourceId) {
|
|
26
|
+
const item = history.turns?.[0]?.items?.find(item => item.type === "userMessage"
|
|
27
|
+
|| (item.type === "functionCallOutput" && item.namespace === "codex_app" && item.name === "create_thread"));
|
|
28
|
+
if (item?.type === "userMessage") return item.content?.filter(part => part.type === "text").map(part => part.text).join("\n");
|
|
29
|
+
if (typeof sourceId !== "string" || typeof item?.output !== "string") return undefined;
|
|
30
|
+
const prefix = `<codex_delegation>\n <source_thread_id>${sourceId}</source_thread_id>\n <input>`, suffix = "</input>\n</codex_delegation>";
|
|
31
|
+
return item.output.startsWith(prefix) && item.output.endsWith(suffix) ? item.output.slice(prefix.length, -suffix.length) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function initialInstructionMatches(history, prompt, sourceId) {
|
|
35
|
+
return initialInstructionText(history, sourceId) === prompt;
|
|
36
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { cloudCredentials } from "./cloud-api.mjs";
|
|
3
|
+
import { RpcClient, RpcFailure } from "./rpc.mjs";
|
|
4
|
+
import { VERSION } from "./version.mjs";
|
|
5
|
+
import { codexHome } from "./runtime.mjs";
|
|
6
|
+
import { desktopCloudId, desktopCloudUuid } from "./desktop-cloud-id.mjs";
|
|
7
|
+
const origin = "https://codex-cloud-backend.chatgpt.com", limit = 16 * 1024 * 1024;
|
|
8
|
+
const failure = (message, code = "DESKTOP_CLOUD_PROTOCOL_ERROR") => Object.assign(new Error(message), { code });
|
|
9
|
+
const headers = auth => ({ "ChatGPT-Account-ID": auth.accountId, "X-OpenAI-Product-Sku": "codex" });
|
|
10
|
+
export async function desktopCloudGet(route, { signal, timeoutMs = 30000 } = {}, { credentials = cloudCredentials, fetchRequest = fetch } = {}) {
|
|
11
|
+
const url = new URL(route, origin);
|
|
12
|
+
if (!route.startsWith("/v1/environment-configs?") || url.pathname !== "/v1/environment-configs" || url.origin !== origin) throw failure("Invalid Desktop Cloud catalog route.");
|
|
13
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
|
+
const cancel = () => controller.abort(); signal?.addEventListener("abort", cancel, { once: true }); if (signal?.aborted) cancel();
|
|
15
|
+
const combined = controller.signal;
|
|
16
|
+
let auth;
|
|
17
|
+
try {
|
|
18
|
+
auth = await credentials({ signal: combined, timeoutMs });
|
|
19
|
+
const response = await fetchRequest(url, { method: "GET", redirect: "error", signal: combined, headers: { ...headers(auth), Authorization: `Bearer ${auth.token}`, Accept: "application/json" } });
|
|
20
|
+
if (!response.ok) { await response.body?.cancel(); throw failure(`Desktop Cloud catalog failed (HTTP ${response.status}).`, response.status === 401 ? "CLOUD_LOGIN_REQUIRED" : "DESKTOP_CLOUD_HTTP_FAILED"); }
|
|
21
|
+
const chunks = []; let size = 0;
|
|
22
|
+
for await (const part of response.body) { size += part.length; if (size > limit) throw failure("Desktop Cloud catalog exceeded its receive limit."); chunks.push(part); }
|
|
23
|
+
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw failure("Desktop Cloud returned invalid catalog JSON."); }
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (/^(DESKTOP_CLOUD_|CLOUD_)/.test(error.code ?? "")) throw error;
|
|
26
|
+
throw failure("Desktop Cloud catalog did not complete.", signal?.aborted ? "CANCELLED" : controller.signal.aborted ? "TIMEOUT" : "CONNECTION_FAILED");
|
|
27
|
+
} finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); if (auth) auth.token = ""; }
|
|
28
|
+
}
|
|
29
|
+
export function configId(value) {
|
|
30
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_~-]{0,399}$/.test(value)) throw failure("Invalid environment configuration ID.");
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export async function desktopCloudEnvironments(options = {}, { get = desktopCloudGet } = {}) {
|
|
34
|
+
const rows = new Map();
|
|
35
|
+
for (const scope of ["user", "workspace"]) {
|
|
36
|
+
let cursor; const seen = new Set();
|
|
37
|
+
for (let page = 0; page < 100; page++) {
|
|
38
|
+
const query = new URLSearchParams({ scope, limit: "100", omitDraft: "true", view: "picker", ...(cursor ? { cursor } : {}) });
|
|
39
|
+
const data = await get(`/v1/environment-configs?${query}`, options);
|
|
40
|
+
if (!Array.isArray(data?.data)) throw failure("Invalid Desktop Cloud environment inventory.");
|
|
41
|
+
for (const row of data.data) {
|
|
42
|
+
const id = configId(row?.id);
|
|
43
|
+
if (typeof row.name !== "string" || !row.name.trim() || row.name.length > 1000) throw failure("Invalid environment name.");
|
|
44
|
+
if (rows.has(id) && rows.get(id).name !== row.name) throw failure("Conflicting environment entries.");
|
|
45
|
+
rows.set(id, { id, name: row.name, scope, repository_ids: Array.isArray(row.repository_ids) ? row.repository_ids.filter(x => typeof x === "string") : [] });
|
|
46
|
+
}
|
|
47
|
+
cursor = data.next_cursor; if (cursor == null) break;
|
|
48
|
+
if (typeof cursor !== "string" || !cursor || seen.has(cursor) || page === 99) throw failure("Invalid environment pagination.");
|
|
49
|
+
seen.add(cursor);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { environments: [...rows.values()], source: "desktop-cloud-configurations", execution_environment: "cloud", connection_mode: "desktop-cloud" };
|
|
53
|
+
}
|
|
54
|
+
export async function connectDesktopCloud(options = {}, { credentials = cloudCredentials, Socket = WebSocket } = {}) {
|
|
55
|
+
const timeoutMs = options.timeoutMs ?? 30000; let auth, socket, client;
|
|
56
|
+
try {
|
|
57
|
+
auth = await credentials({ timeoutMs, signal: options.signal });
|
|
58
|
+
if (options.signal?.aborted) throw failure("Connection cancelled.", "CANCELLED");
|
|
59
|
+
socket = new Socket("wss://codex-cloud-backend.chatgpt.com/", ["codex-app-server", "codex-client.desktop", `openai-bearer.${auth.token}`], {
|
|
60
|
+
headers: headers(auth), followRedirects: false, perMessageDeflate: false, maxPayload: limit, handshakeTimeout: timeoutMs,
|
|
61
|
+
});
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
const cancel = () => finish(failure("Desktop Cloud connection cancelled.", "CANCELLED"));
|
|
65
|
+
const timer = setTimeout(() => finish(failure("Desktop Cloud connection timed out.", "TIMEOUT")), timeoutMs);
|
|
66
|
+
const finish = error => { if (settled) return; settled = true; clearTimeout(timer); options.signal?.removeEventListener("abort", cancel); error ? reject(error) : resolve(); };
|
|
67
|
+
socket.on("error", () => finish(failure("Desktop Cloud connection failed.", "CONNECTION_FAILED")));
|
|
68
|
+
socket.once("close", () => finish(failure("Desktop Cloud connection closed.", "CONNECTION_FAILED")));
|
|
69
|
+
socket.once("open", () => finish()); options.signal?.addEventListener("abort", cancel, { once: true }); if (options.signal?.aborted) cancel();
|
|
70
|
+
});
|
|
71
|
+
client = new RpcClient(socket, { timeoutMs });
|
|
72
|
+
const cancelInitialization = () => client.abort("Desktop Cloud initialization cancelled.", "CANCELLED");
|
|
73
|
+
options.signal?.addEventListener("abort", cancelInitialization, { once: true });
|
|
74
|
+
if (options.signal?.aborted) cancelInitialization();
|
|
75
|
+
try { await client.request("initialize", { clientInfo: { name: "codexteer", version: VERSION }, capabilities: { experimentalApi: true } }); } finally { options.signal?.removeEventListener("abort", cancelInitialization); }
|
|
76
|
+
client.notify("initialized", {});
|
|
77
|
+
return new DesktopCloudClient(client);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
client?.close(); socket?.terminate();
|
|
80
|
+
// ws errors can quote the bearer subprotocol; never expose arbitrary errors.
|
|
81
|
+
if (error instanceof RpcFailure || ["TIMEOUT", "CANCELLED", "CONNECTION_FAILED"].includes(error.code) || /^CLOUD_/.test(error.code ?? "")) throw error;
|
|
82
|
+
throw failure("Could not initialize Desktop Cloud.", "CONNECTION_FAILED");
|
|
83
|
+
} finally { if (auth) auth.token = ""; }
|
|
84
|
+
}
|
|
85
|
+
export class DesktopCloudClient {
|
|
86
|
+
constructor(client) { this.client = client; }
|
|
87
|
+
close() { this.client.close(); }
|
|
88
|
+
async history(threadId) {
|
|
89
|
+
const turns = [], seen = new Set(); let cursor, bytes = 0;
|
|
90
|
+
for (let page = 0; page < 100; page++) {
|
|
91
|
+
const data = await this.client.request("thread/turns/list", { threadId, itemsView: "full", sortDirection: "asc", limit: 100, ...(cursor ? { cursor } : {}) });
|
|
92
|
+
if (!Array.isArray(data?.data) || data.data.some(turn => typeof turn.id !== "string" || !Array.isArray(turn.items) || turn.itemsView !== "full") || new Set([...turns, ...data.data].map(turn => turn.id)).size !== turns.length + data.data.length) throw failure("Invalid complete Cloud history.");
|
|
93
|
+
bytes += Buffer.byteLength(JSON.stringify(data.data)); if (bytes > 32 * 1024 * 1024) throw failure("Complete Cloud history exceeded its memory budget.");
|
|
94
|
+
turns.push(...data.data); cursor = data.nextCursor;
|
|
95
|
+
if (cursor == null) return turns;
|
|
96
|
+
if (typeof cursor !== "string" || !cursor || seen.has(cursor) || page === 99) throw failure("Cloud history exceeded its pagination limit.");
|
|
97
|
+
seen.add(cursor);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async request(method, params = {}, options = {}) {
|
|
101
|
+
if (!["thread/read", "thread/turns/list", "thread/items/list", "thread/list", "model/list", "thread/start", "thread/name/set", "turn/start", "turn/steer"].includes(method)) throw failure("Unsupported Desktop Cloud operation.", "CAPABILITY_UNSUPPORTED");
|
|
102
|
+
const input = { ...params }; if (input.threadId) input.threadId = desktopCloudUuid(input.threadId); if (method === "thread/read") delete input.includeTurns;
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.client.request(method, input, options);
|
|
105
|
+
if (result?.thread) {
|
|
106
|
+
if (params.threadId && result.thread.id !== desktopCloudUuid(params.threadId)) throw new RpcFailure("Desktop Cloud returned another task.", { code: "PROTOCOL_ERROR", uncertain: !!options.mutation });
|
|
107
|
+
return { ...result, thread: { ...result.thread, id: desktopCloudId(`durable_${result.thread.id}`), historyMode: "aggregate", turns: method === "thread/read" && params.includeTurns ? await this.history(input.threadId) : [] } };
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
} catch (error) { if (options.mutation && Number.isSafeInteger(error.rpcCode) && ![-32601, -32602].includes(error.rpcCode)) error.uncertain = true; throw error; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function desktopCloudConnection() {
|
|
114
|
+
return {
|
|
115
|
+
async discover(home = codexHome()) { return { paths: { home, socket: "desktop-cloud" }, state: { codex_home: home, codex_steer_protocol: 1,
|
|
116
|
+
codex_steer_capabilities: { thread_read: [1], history_pagination: [1], turn_steer: [], turn_start: [], desktop_subscribe: [] } } }; },
|
|
117
|
+
async connect(endpoint, options) { if (endpoint !== "desktop-cloud") throw failure("Invalid Desktop Cloud endpoint."); return connectDesktopCloud(options); },
|
|
118
|
+
};
|
|
119
|
+
}
|