codexteer 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/README.md +127 -14
  3. package/assets/remote-device-key.swift +94 -0
  4. package/bin/codexteer-update.mjs +3 -0
  5. package/bin/codexteer.mjs +6 -2
  6. package/docs/cloud.md +113 -0
  7. package/docs/desktop-cloud.md +36 -0
  8. package/docs/distribution.md +15 -20
  9. package/docs/orchestration.md +120 -0
  10. package/docs/remote-cli-validation.json +139 -0
  11. package/docs/remote-control.md +65 -0
  12. package/docs/remote-device-enrollment.md +91 -0
  13. package/docs/remote-observation.md +57 -0
  14. package/docs/remote-operations.md +89 -0
  15. package/docs/thread-create.md +20 -45
  16. package/package.json +11 -2
  17. package/scripts/update-desktop.applescript +51 -0
  18. package/src/agent-launch.mjs +27 -0
  19. package/src/app-server.mjs +34 -10
  20. package/src/checkpoint.mjs +52 -16
  21. package/src/cli.mjs +201 -23
  22. package/src/cloud-api.mjs +118 -0
  23. package/src/cloud-cli.mjs +319 -0
  24. package/src/cloud-connection.mjs +64 -0
  25. package/src/cloud-id.mjs +16 -0
  26. package/src/cloud-observe.mjs +178 -0
  27. package/src/cloud-send.mjs +33 -0
  28. package/src/cloud.mjs +172 -0
  29. package/src/connection.mjs +12 -1
  30. package/src/creation-verification.mjs +30 -10
  31. package/src/desktop-cloud-api.mjs +119 -0
  32. package/src/desktop-cloud-id.mjs +18 -0
  33. package/src/desktop-cloud-project.mjs +148 -0
  34. package/src/desktop-cloud.mjs +98 -0
  35. package/src/desktop.mjs +3 -1
  36. package/src/directive.mjs +5 -2
  37. package/src/distribution.mjs +2 -2
  38. package/src/evidence.mjs +3 -0
  39. package/src/findings.mjs +7 -7
  40. package/src/help.mjs +140 -28
  41. package/src/journal.mjs +37 -9
  42. package/src/launcher.mjs +16 -9
  43. package/src/monitor.mjs +18 -6
  44. package/src/native-command.mjs +10 -2
  45. package/src/native-rpc.mjs +15 -2
  46. package/src/observe.mjs +21 -7
  47. package/src/orchestrate-prompt.mjs +96 -0
  48. package/src/orchestrate.mjs +29 -0
  49. package/src/paged-read.mjs +7 -1
  50. package/src/projects.mjs +27 -2
  51. package/src/prompt-command.mjs +25 -0
  52. package/src/prompt.mjs +25 -8
  53. package/src/remote-client-info.mjs +16 -0
  54. package/src/remote-control-auth.mjs +109 -0
  55. package/src/remote-control-socket.mjs +137 -0
  56. package/src/remote-control.mjs +134 -0
  57. package/src/remote-device-key.mjs +41 -0
  58. package/src/remote-device-login.mjs +76 -0
  59. package/src/remote-device-protocol.mjs +72 -0
  60. package/src/remote-device-store.mjs +71 -0
  61. package/src/remote-device-worker.mjs +48 -0
  62. package/src/remote-device.mjs +202 -0
  63. package/src/remote-io.mjs +163 -0
  64. package/src/remote-key-access.mjs +76 -0
  65. package/src/remote-key-worker.mjs +38 -0
  66. package/src/remote-keychain.mjs +72 -0
  67. package/src/remote-management.mjs +234 -0
  68. package/src/remote.mjs +132 -0
  69. package/src/resource.mjs +8 -6
  70. package/src/runner.mjs +6 -1
  71. package/src/standalone-create.mjs +4 -3
  72. package/src/store.mjs +25 -13
  73. package/src/supervise.mjs +25 -53
  74. package/src/supervision.mjs +41 -19
  75. package/src/target.mjs +25 -0
  76. package/src/task-id.mjs +23 -0
  77. package/src/thread-create.mjs +4 -3
  78. package/src/thread-id.mjs +21 -6
  79. package/src/thread-verify.mjs +108 -0
  80. package/src/turn-repair.mjs +228 -0
  81. package/src/update.mjs +158 -0
  82. package/docs/thread-create-native-investigation.md +0 -136
@@ -0,0 +1,109 @@
1
+ import { lstat, readFile, realpath, mkdtemp, writeFile, rm } from "node:fs/promises";
2
+ import { createHash, verify } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { BUNDLED_NODE } from "./wrapper.mjs";
7
+
8
+ export const CONTROL_URL = "wss://chatgpt.com/backend-api/codex/remote/control/client";
9
+ export const CONTROL_SCOPE = "remote_control_controller_websocket";
10
+ export const CONTROL_AUDIENCE = "remote_control_client_websocket";
11
+ export const controlFailure = (message, code = "REMOTE_CONTROL_UNVERIFIED") => Object.assign(new Error(message), { code });
12
+
13
+ export function authIdentity(status) {
14
+ try {
15
+ if (!["chatgpt", "chatgptAuthTokens"].includes(status.authMethod) || typeof status.authToken !== "string") throw new Error();
16
+ const jwt = JSON.parse(Buffer.from(status.authToken.split(".")[1], "base64url")), auth = jwt["https://api.openai.com/auth"];
17
+ const accountId = auth.chatgpt_account_id ?? auth.account_id, accountUserId = auth.chatgpt_account_user_id ?? auth.account_user_id;
18
+ if (typeof accountId !== "string" || !accountId || typeof accountUserId !== "string" || !accountUserId
19
+ || !Number.isSafeInteger(jwt.exp) || jwt.exp <= Date.now() / 1000 || auth.is_fedramp === true) throw new Error();
20
+ const authUserId = typeof auth.user_id === "string" && auth.user_id && auth.user_id !== accountUserId ? auth.user_id : undefined;
21
+ return { accountId, accountUserId, ...(authUserId ? { authUserId } : {}) };
22
+ } catch { throw controlFailure("A current standard ChatGPT account token is required for the explicitly selected Remote Control route.", "REMOTE_AUTH_UNAVAILABLE"); }
23
+ }
24
+
25
+ export async function readEnrollment(home, identity) {
26
+ const canonical = await realpath(home), file = path.join(canonical, ".codex-global-state.json"), info = await lstat(file);
27
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o022) || info.size > 32 * 1024 * 1024) throw controlFailure("Unsafe Desktop enrollment registry.", "REMOTE_ENROLLMENT_UNSAFE");
28
+ const state = JSON.parse(await readFile(file, "utf8"));
29
+ // Desktop also accepts the auth user alias from this same token when the
30
+ // account header matches its account claim. Our header is pinned to accountId.
31
+ const candidates = [...new Set([identity.accountUserId, identity.authUserId].filter(Boolean))];
32
+ let record;
33
+ for (const candidate of candidates) {
34
+ const key = ["Codex Desktop", "http://localhost:8000/api", "https://chatgpt.com/backend-api", "after-first-unlock-device-key-v1", candidate].join("\n");
35
+ const entry = state["electron-remote-control-client-enrollments"]?.[key];
36
+ if (entry?.accountUserId === candidate) { record = entry; break; }
37
+ }
38
+ if (!record || record.algorithm !== "ecdsa_p256_sha256"
39
+ || !["hardware_secure_enclave", "os_protected_nonextractable"].includes(record.protectionClass)
40
+ || ![record.clientId, record.keyId, record.publicKeySpkiDerBase64].every(x => typeof x === "string" && x.length > 0 && x.length < 8192)) {
41
+ throw controlFailure("No matching existing Desktop device-key enrollment. Enroll through Desktop settings; codexteer does not create keys or change authorization.", "REMOTE_ENROLLMENT_REQUIRED");
42
+ }
43
+ return { accountUserId: record.accountUserId, algorithm: record.algorithm, clientId: record.clientId, keyId: record.keyId,
44
+ protectionClass: record.protectionClass, publicKeySpkiDerBase64: record.publicKeySpkiDerBase64 };
45
+ }
46
+
47
+ export const enrollmentHash = record => createHash("sha256").update(JSON.stringify(record)).digest("hex");
48
+
49
+ export function connectionPayload(challenge, enrollment, now = Date.now()) {
50
+ const url = new URL(CONTROL_URL), hash = x => typeof x === "string" && /^[A-Za-z0-9_-]{43}$/.test(x);
51
+ if (challenge?.type !== "device_key_challenge" || challenge.purpose !== CONTROL_AUDIENCE || challenge.audience !== CONTROL_AUDIENCE
52
+ || challenge.targetOrigin !== "https://chatgpt.com" || challenge.targetPath !== url.pathname
53
+ || challenge.clientId !== enrollment.clientId || challenge.accountUserId !== enrollment.accountUserId
54
+ || !Number.isSafeInteger(challenge.tokenExpiresAt) || challenge.tokenExpiresAt <= Math.floor(now / 1000)
55
+ || !Array.isArray(challenge.scopes) || challenge.scopes.length !== 1 || challenge.scopes[0] !== CONTROL_SCOPE
56
+ || typeof challenge.nonce !== "string" || !/^[A-Za-z0-9_-]{43,512}$/.test(challenge.nonce)
57
+ || typeof challenge.sessionId !== "string" || !challenge.sessionId || challenge.sessionId.length > 512 || !hash(challenge.tokenSha256Base64url)) {
58
+ throw controlFailure("Remote device-key challenge does not match the selected account, client, official destination or allowed scope.", "REMOTE_AUTH_CHALLENGE_MISMATCH");
59
+ }
60
+ // Desktop canonicalizes the payload before signing (Qdt), independently of
61
+ // the order in which its WebSocket challenge handler constructs the object.
62
+ return { accountUserId: challenge.accountUserId, audience: challenge.audience, clientId: challenge.clientId, nonce: challenge.nonce,
63
+ scopes: challenge.scopes, sessionId: challenge.sessionId, targetOrigin: challenge.targetOrigin, targetPath: challenge.targetPath,
64
+ tokenExpiresAt: challenge.tokenExpiresAt, tokenSha256Base64url: challenge.tokenSha256Base64url, type: "remoteControlClientConnection" };
65
+ }
66
+
67
+ export function verifyDeviceProof(proof, challenge, enrollment) {
68
+ const payload = Buffer.from(JSON.stringify({ domain: "codex-device-key-sign-payload/v1", payload: connectionPayload(challenge, enrollment) }));
69
+ if (proof?.type !== "device_key_proof" || proof.keyId !== enrollment.keyId || proof.algorithm !== enrollment.algorithm
70
+ || proof.signedPayloadBase64 !== payload.toString("base64") || typeof proof.signatureDerBase64 !== "string"
71
+ || !verify("sha256", payload, { key: Buffer.from(enrollment.publicKeySpkiDerBase64, "base64"), format: "der", type: "spki" }, Buffer.from(proof.signatureDerBase64, "base64"))) {
72
+ throw controlFailure("Device-key proof does not match the enrolled public key or validated connection challenge.", "REMOTE_AUTH_PROOF_INVALID");
73
+ }
74
+ return proof;
75
+ }
76
+
77
+ // Only the fixed signing worker runs on Desktop's signed Node. No bearer token
78
+ // is placed on disk, in argv, in command output, or in the worker's input.
79
+ export async function signConnectionChallenge(local, home, identity, enrollment, challenge) {
80
+ connectionPayload(challenge, enrollment);
81
+ const output = await keyWorker(local, home, identity, enrollment, { operation: "sign", challenge });
82
+ return verifyDeviceProof(output.proof, challenge, enrollment);
83
+ }
84
+
85
+ export async function inspectConnectionKey(local, home, identity, enrollment) {
86
+ const output = await keyWorker(local, home, identity, enrollment, { operation: "inspect" });
87
+ if (output.operation !== "inspect" || output.device_key_access !== "verified" || output.proof !== undefined) {
88
+ throw controlFailure("Device-key inspection did not confirm access without signing.", "REMOTE_AUTH_SIGNING_FAILED");
89
+ }
90
+ }
91
+
92
+ async function keyWorker(local, home, identity, enrollment, operation) {
93
+ const directory = await mkdtemp(path.join(os.tmpdir(), "codexteer-device-proof-"));
94
+ try {
95
+ const file = path.join(directory, "challenge.json");
96
+ await writeFile(file, JSON.stringify({ home, identity, enrollmentHash: enrollmentHash(enrollment), ...operation }), { mode: 0o600, flag: "wx" });
97
+ const result = await local.request("command/exec", { command: [BUNDLED_NODE, fileURLToPath(new URL("./remote-key-worker.mjs", import.meta.url)), file],
98
+ cwd: fileURLToPath(new URL("..", import.meta.url)), timeoutMs: 10000, outputBytesCap: 16384, sandboxPolicy: { type: "readOnly", networkAccess: false } });
99
+ let output;
100
+ try { output = JSON.parse(result.stdout); } catch { throw controlFailure("Device-key worker did not return a proof.", "REMOTE_AUTH_SIGNING_FAILED"); }
101
+ if (result.exitCode !== 0 || output.ok !== true) {
102
+ const stage = ["input", "enrollment", "addon", "public-key", "public-key-match", "sign", "verify"].includes(output.stage) ? output.stage : "worker";
103
+ const code = typeof output.native_code === "number" || /^[A-Z][A-Z0-9_:-]{0,79}$/.test(output.native_code ?? "") ? output.native_code : null;
104
+ throw Object.assign(controlFailure(`Device-key signing failed at ${stage}${code == null ? "" : ` (${code})`}. No alternative key was used.`, "REMOTE_AUTH_SIGNING_FAILED"),
105
+ { key_stage: stage, key_error: code });
106
+ }
107
+ return output;
108
+ } finally { await rm(directory, { recursive: true, force: true }); }
109
+ }
@@ -0,0 +1,137 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomUUID } from "node:crypto";
3
+ import WebSocket from "ws";
4
+ import { CONTROL_URL, connectionPayload, controlFailure, verifyDeviceProof } from "./remote-control-auth.mjs";
5
+ import { REMOTE_CLIENT_INFO } from "./remote-client-info.mjs";
6
+
7
+ export const MAX_CONTROL_REQUEST = 65536;
8
+ const MAX_FRAME = 153600, MAX_MESSAGE = 64 * 1024 * 1024;
9
+
10
+ // Deliberately no reconnect, envelope replay or mutation retry. A new read/watch
11
+ // connection may be opened by the existing monitor after it reports an outage.
12
+ export class RemoteControlSocket extends EventEmitter {
13
+ constructor(wire, { enrollment, environmentId, streamId = randomUUID(), signal }) {
14
+ super();
15
+ Object.assign(this, { wire, enrollment, environmentId, streamId, signal, readyState: WebSocket.OPEN, sequence: 0, lastServerSequence: null, assembly: null });
16
+ this.on("error", () => {});
17
+ this.cancel = () => this.terminate();
18
+ signal?.addEventListener("abort", this.cancel, { once: true });
19
+ wire.on("message", (data, binary) => {
20
+ try { this.receive(data, binary); }
21
+ catch { this.emit("error", controlFailure("Invalid, oversized or mismatched Remote Control envelope.", "REMOTE_CONTROL_PROTOCOL_ERROR")); this.terminate(); }
22
+ });
23
+ wire.on("error", () => this.emit("error", controlFailure("Remote Control connection failed.", "CONNECTION_FAILED")));
24
+ wire.on("close", () => { if (this.readyState !== WebSocket.CLOSED) { this.readyState = WebSocket.CLOSED; this.cleanup(); this.emit("close"); } });
25
+ this.ping = setInterval(() => {
26
+ if (this.readyState === WebSocket.OPEN) this.envelope("ping", { state: "foreground", skip_history: true });
27
+ }, 30000);
28
+ this.ping.unref();
29
+ }
30
+
31
+ static async connect({ identity, token, enrollment, environmentId, sign, signal, timeoutMs = 10000, createWire = (url, options) => new WebSocket(url, options) }) {
32
+ if (signal?.aborted) throw controlFailure("Remote Control connection cancelled.", "CANCELLED");
33
+ const wire = createWire(CONTROL_URL, { headers: { Authorization: `Bearer ${token}`, "ChatGPT-Account-Id": identity.accountId,
34
+ originator: "codexteer", "User-Agent": REMOTE_CLIENT_INFO.user_agent, "x-codex-client-id": enrollment.clientId, "x-codex-protocol-version": "3", "x-codex-client-auth": "socket-refresh-v1" },
35
+ perMessageDeflate: false, maxPayload: MAX_FRAME, followRedirects: false, handshakeTimeout: timeoutMs });
36
+ wire.on("error", () => {});
37
+ return new Promise((resolve, reject) => {
38
+ let settled = false, challenged = false, proofSent = false;
39
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); wire.off("message", message); wire.off("close", closed); wire.off("error", failed); };
40
+ const stop = error => { if (settled) return; settled = true; cleanup(); wire.terminate(); reject(error); };
41
+ const cancel = () => stop(controlFailure("Remote Control authentication cancelled.", "CANCELLED"));
42
+ const failed = () => stop(controlFailure("Remote Control authentication connection failed.", "REMOTE_AUTH_CONNECTION_FAILED"));
43
+ const closed = () => stop(controlFailure("Remote Control closed before authentication completed.", "REMOTE_AUTH_CONNECTION_FAILED"));
44
+ const timer = setTimeout(() => stop(controlFailure("Remote Control authentication timed out.", "TIMEOUT")), timeoutMs);
45
+ const message = async (bytes, binary) => {
46
+ try {
47
+ if (binary || bytes.length > MAX_FRAME) throw new Error();
48
+ const value = JSON.parse(String(bytes));
49
+ if (value.type === "device_key_authenticated") {
50
+ if (!proofSent || value.clientId !== enrollment.clientId || settled) throw new Error();
51
+ settled = true; cleanup();
52
+ resolve(new RemoteControlSocket(wire, { enrollment, environmentId, signal }));
53
+ return;
54
+ }
55
+ if (challenged || settled) throw new Error();
56
+ challenged = true; connectionPayload(value, enrollment);
57
+ const proof = verifyDeviceProof(await sign(value), value, enrollment);
58
+ if (settled || signal?.aborted) return;
59
+ proofSent = true; wire.send(JSON.stringify(proof), error => { if (error) failed(); });
60
+ } catch (error) {
61
+ const code = /^REMOTE_[A-Z_]+$/.test(error?.code ?? "") ? error.code : "REMOTE_AUTH_CHALLENGE_MISMATCH";
62
+ const message = code === "REMOTE_AUTH_SIGNING_FAILED" && /^Device-key signing failed at (input|enrollment|addon|public-key|public-key-match|sign|verify|worker)( \([A-Z0-9_:-]+\))?\. No alternative key was used\.$/.test(error.message)
63
+ ? error.message : "Remote Control device challenge or proof was rejected.";
64
+ stop(controlFailure(message, code));
65
+ }
66
+ };
67
+ wire.on("message", message); wire.once("close", closed); wire.once("error", failed);
68
+ wire.once("unexpected-response", (_request, response) => {
69
+ response.resume();
70
+ stop(controlFailure(`Remote Control authentication HTTP ${response.statusCode}; no credential or connection fallback was attempted.`, response.statusCode === 400 ? "REMOTE_AUTH_UNSUPPORTED" : "REMOTE_AUTH_REJECTED"));
71
+ });
72
+ signal?.addEventListener("abort", cancel, { once: true });
73
+ if (signal?.aborted) cancel();
74
+ });
75
+ }
76
+
77
+ envelope(type, extra = {}, callback) {
78
+ if (this.readyState !== WebSocket.OPEN) { callback?.(new Error("closed")); return; }
79
+ this.wire.send(JSON.stringify({ type, client_id: this.enrollment.clientId, env_id: this.environmentId, stream_id: this.streamId,
80
+ seq_id: this.sequence++, ...extra }), error => { if (error) this.terminate(); callback?.(error); });
81
+ }
82
+
83
+ send(text, callback) {
84
+ if (Buffer.byteLength(text) > MAX_CONTROL_REQUEST) { callback?.(new Error("Remote Control request exceeds 64 KiB.")); return; }
85
+ let message;
86
+ try { message = JSON.parse(text); } catch { callback?.(new Error("invalid JSON")); return; }
87
+ this.envelope("client_message", { skip_history: false, message }, callback);
88
+ }
89
+
90
+ receive(bytes, binary) {
91
+ if (binary || bytes.length > MAX_FRAME) throw new Error();
92
+ const value = JSON.parse(String(bytes));
93
+ if (value.client_id !== this.enrollment.clientId) throw new Error();
94
+ // Other streams of the same enrolled Desktop client are never exposed.
95
+ if (value.stream_id !== this.streamId) return;
96
+ if (value.env_id !== this.environmentId || !Number.isSafeInteger(value.seq_id) || value.seq_id < 0) throw new Error();
97
+ if (value.type === "ack") return; // Transport acknowledgement is not RPC acceptance.
98
+ if (!["server_message", "server_message_chunk", "pong"].includes(value.type)) throw new Error();
99
+ if (this.lastServerSequence !== null && value.seq_id <= this.lastServerSequence) return;
100
+ if (this.lastServerSequence !== null && value.seq_id !== this.lastServerSequence + 1) throw new Error();
101
+ if (value.type === "pong") {
102
+ if (value.status !== "active" || this.assembly) throw new Error();
103
+ this.lastServerSequence = value.seq_id; return;
104
+ }
105
+ let message = value.message;
106
+ if (value.type === "server_message_chunk") {
107
+ if (!Number.isInteger(value.segment_count) || value.segment_count < 2 || value.segment_count > 1024
108
+ || !Number.isInteger(value.segment_id) || !Number.isSafeInteger(value.message_size_bytes) || value.message_size_bytes < 1 || value.message_size_bytes > MAX_MESSAGE
109
+ || typeof value.message_chunk_base64 !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.message_chunk_base64)) throw new Error();
110
+ this.assembly ??= { sequence: value.seq_id, size: value.message_size_bytes, count: value.segment_count, chunks: [], bytes: 0 };
111
+ const assembly = this.assembly;
112
+ if (assembly.sequence !== value.seq_id || assembly.count !== value.segment_count || assembly.size !== value.message_size_bytes
113
+ || value.segment_id !== assembly.chunks.length) throw new Error();
114
+ const chunk = Buffer.from(value.message_chunk_base64, "base64");
115
+ assembly.bytes += chunk.length;
116
+ if (assembly.bytes > assembly.size) throw new Error();
117
+ assembly.chunks.push(chunk);
118
+ if (assembly.chunks.length !== assembly.count) return;
119
+ if (assembly.bytes !== assembly.size) throw new Error();
120
+ message = JSON.parse(Buffer.concat(assembly.chunks).toString("utf8")); this.assembly = null;
121
+ } else if (this.assembly) throw new Error();
122
+ if (!message || typeof message !== "object" || Array.isArray(message) || !["method", "result", "error"].some(field => Object.hasOwn(message, field))) throw new Error();
123
+ this.lastServerSequence = value.seq_id;
124
+ this.emit("message", Buffer.from(JSON.stringify(message)), false);
125
+ }
126
+
127
+ pause() { this.wire.pause(); }
128
+ resume() { this.wire.resume(); }
129
+ cleanup() { clearInterval(this.ping); this.signal?.removeEventListener("abort", this.cancel); this.assembly = null; }
130
+ close() {
131
+ if (this.readyState !== WebSocket.OPEN) return;
132
+ this.envelope("client_closed", {}, () => this.wire.close());
133
+ if (this.readyState !== WebSocket.CLOSED) this.readyState = WebSocket.CLOSING;
134
+ this.cleanup();
135
+ }
136
+ terminate() { this.cleanup(); this.wire.terminate(); }
137
+ }
@@ -0,0 +1,134 @@
1
+ import { RpcClient } from "./rpc.mjs";
2
+ import { discoverRuntime } from "./runtime.mjs";
3
+ import { assertRuntimeOperation } from "./compatibility.mjs";
4
+ import { inspectNativeCommandProtocol, mutationOutcomeUnknown } from "./native-command.mjs";
5
+ import { targetHost, remoteTransport } from "./target.mjs";
6
+ import { VERSION } from "./version.mjs";
7
+ import { authIdentity, readEnrollment, enrollmentHash, signConnectionChallenge, inspectConnectionKey, controlFailure } from "./remote-control-auth.mjs";
8
+ import { RemoteControlSocket, MAX_CONTROL_REQUEST } from "./remote-control-socket.mjs";
9
+ import { inspectKeychainIdentities } from "./remote-key-access.mjs";
10
+ import { readDevice, dedicatedEnrollment } from "./remote-device-store.mjs";
11
+ import { deviceKey, dedicatedConnectionProof } from "./remote-device-key.mjs";
12
+
13
+ const methods = new Set(["thread/read", "thread/loaded/list", "thread/turns/list", "thread/items/list", "turn/steer"]);
14
+ const managementReads = new Set(["thread/list", "project/list", "project/read", "fs/readFile", "fs/getMetadata", "fs/readDirectory", "fs/watch", "fs/unwatch"]);
15
+ const managementWrites = new Set(["turn/start", "thread/metadata/update", "command/exec", "command/exec/terminate", "thread/shellCommand"]);
16
+ const executionWrites = new Set(["command/exec", "command/exec/terminate", "thread/shellCommand"]);
17
+ const capabilities = { thread_read: [1], history_pagination: [1], turn_steer: [1], turn_start: [], desktop_subscribe: [] };
18
+ export const remoteNewTurnUnsupported = () => Object.assign(controlFailure("Remote Control supports steering an existing active turn only. --new-turn remains unavailable because this route does not establish Desktop approval ownership.", "REMOTE_NEW_TURN_UNSUPPORTED"), { sent: false, delivery_status: "not_sent" });
19
+
20
+ export function validateControlRequest(method, params, options = {}) {
21
+ if (Buffer.byteLength(JSON.stringify({ id: "0".repeat(36), method, params })) > MAX_CONTROL_REQUEST) throw controlFailure("Remote Control requests are limited to 64 KiB before dispatch.", "REMOTE_REQUEST_TOO_LARGE");
22
+ if ((method === "turn/start" && !options.management) || method === "thread/resume") throw remoteNewTurnUnsupported();
23
+ if (!methods.has(method) && !(options.management && (managementReads.has(method) || managementWrites.has(method)))
24
+ || (options.mutation && method !== "turn/steer" && !(options.management && managementWrites.has(method)))) throw controlFailure("Unsupported Remote Control operation.", "REMOTE_METHOD_UNSUPPORTED");
25
+ if (method === "turn/start" && (typeof params.threadId !== "string" || !params.threadId || typeof params.clientUserMessageId !== "string" || !params.clientUserMessageId
26
+ || !Array.isArray(params.input) || !params.input.length || params.input.some(item => item.type !== "text" || typeof item.text !== "string")
27
+ || Object.keys(params).some(key => !["threadId", "input", "clientUserMessageId"].includes(key)))) throw controlFailure("Remote new-turn requires a task, text input and client message ID.", "REMOTE_STEER_INVALID");
28
+ if (method === "thread/metadata/update" && (typeof params.threadId !== "string" || !params.threadId || typeof params.projectId !== "string" || !params.projectId
29
+ || Object.keys(params).some(key => !["threadId", "projectId"].includes(key)))) throw controlFailure("Remote project assignment requires an exact task and project.", "REMOTE_PROJECT_INVALID");
30
+ if (method === "command/exec" && (!Array.isArray(params.command) || !params.command.length || params.command.some(arg => typeof arg !== "string")
31
+ || Object.keys(params).some(key => !["command", "cwd", "timeoutMs", "outputBytesCap", "processId"].includes(key)))) throw controlFailure("Remote command execution preserves the host's configured permissions; overrides are unsupported.", "REMOTE_COMMAND_INVALID");
32
+ if (method === "thread/shellCommand" && (typeof params.threadId !== "string" || !params.threadId || typeof params.command !== "string" || !params.command.trim()
33
+ || Object.keys(params).some(key => !["threadId", "command", "timeoutMs"].includes(key)))) throw controlFailure("Remote shell commands require an exact task and explicit command.", "REMOTE_COMMAND_INVALID");
34
+ if (method === "turn/steer" && (typeof params.threadId !== "string" || typeof params.expectedTurnId !== "string" || !params.expectedTurnId
35
+ || !Array.isArray(params.input) || !params.input.length || params.input.some(input => input.type !== "text" || typeof input.text !== "string")
36
+ || typeof params.clientUserMessageId !== "string" || !params.clientUserMessageId
37
+ || Object.keys(params).some(key => !["threadId", "expectedTurnId", "input", "clientUserMessageId"].includes(key)))) {
38
+ throw controlFailure("Remote steering requires the exact thread, expectedTurnId, text input and client message ID.", "REMOTE_STEER_INVALID");
39
+ }
40
+ }
41
+
42
+ export function remoteControlConnection({ hostId = targetHost(), transport = remoteTransport() === "paired" ? "paired" : "control" } = {}, dependencies = {}) {
43
+ const paired = transport === "paired";
44
+ const loadDedicated = async (home, identity) => {
45
+ const record = await readDevice(home, identity), enrollment = dedicatedEnrollment(record, identity);
46
+ if (record.pairing_unknown || !record.pairings.some(pair => pair.host_id === hostId)) throw controlFailure("Pair this dedicated client with the selected host using remote pair, then remote verify.", "REMOTE_PAIR_REQUIRED");
47
+ return enrollment;
48
+ };
49
+ const { discover = discoverRuntime, connect = RpcClient.connect, inspectCommand = inspectNativeCommandProtocol,
50
+ enrollment = paired ? loadDedicated : readEnrollment,
51
+ sign = paired ? async (_local, home, identity, enrolled, challenge) => dedicatedConnectionProof(enrolled, challenge, { home, identity }) : signConnectionChallenge,
52
+ open = RemoteControlSocket.connect,
53
+ inspectKey = paired ? async (_local, home, identity, enrolled) => deviceKey("inspect", { enrollment: enrolled, home, identity }) : inspectConnectionKey,
54
+ inspectIdentities = inspectKeychainIdentities } = dependencies;
55
+ if (!/^remote-control:env_[A-Za-z0-9_-]+$/.test(hostId)) throw controlFailure("The control transport requires a remote-control:env_... host ID; SSH and cloud hosts are not reinterpreted.", "REMOTE_HOST_UNSUPPORTED");
56
+ let selected;
57
+ return {
58
+ async discover(home) {
59
+ const runtime = await discover(home);
60
+ assertRuntimeOperation(runtime.state, "read");
61
+ if (selected && (selected.paths.socket !== runtime.paths.socket || selected.state.instance !== runtime.state.instance)) throw controlFailure("Shared runtime changed during Remote Control operation.", "RUNTIME_CHANGED");
62
+ selected = runtime;
63
+ return { paths: runtime.paths, state: { codex_steer_protocol: 1, codex_steer_capabilities: paired ? { ...capabilities, turn_start: [1], desktop_subscribe: [1] } : capabilities,
64
+ remote_host_id: hostId, remote_transport: transport, cli_version: runtime.state.cli_version,
65
+ instance: runtime.state.instance, node_path: runtime.state.node_path, node_version: runtime.state.node_version } };
66
+ },
67
+ async connect(socket, options = {}) {
68
+ if (!selected || selected.paths.socket !== socket) throw controlFailure("Discover the selected shared runtime first.");
69
+ const local = await connect(socket, { ...options, timeoutMs: 15000 });
70
+ let remote, wire, cancelled = false;
71
+ const close = () => { if (cancelled) return; cancelled = true; options.signal?.removeEventListener("abort", close); remote?.close(); if (!remote) wire?.terminate(); local.close(); };
72
+ options.signal?.addEventListener("abort", close, { once: true });
73
+ try {
74
+ if (options.signal?.aborted) throw controlFailure("Remote Control cancelled.", "CANCELLED");
75
+ const protocol = await inspectCommand(selected.state, { remoteControl: true });
76
+ const status = await local.request("getAuthStatus", { includeToken: true, refreshToken: false }), identity = authIdentity(status);
77
+ let registered;
78
+ try { registered = await enrollment(selected.paths.home, identity); }
79
+ catch (error) {
80
+ if (paired && ["REMOTE_PAIR_REQUIRED", "REMOTE_DEDICATED_ENROLLMENT_REQUIRED"].includes(error.code)) error.remote_control_auth = {
81
+ stage: error.code === "REMOTE_PAIR_REQUIRED" ? "host-pairing" : "dedicated-registration", credential_source: "codexteer-dedicated", gateway_attempted: false, proof_created: false };
82
+ throw error;
83
+ }
84
+ const pinned = enrollmentHash(registered);
85
+ // Verify public-key access before transmitting the token to a gateway.
86
+ // This does not sign, enroll, resume, or borrow Desktop's credentials.
87
+ try { await inspectKey(local, selected.paths.home, identity, registered); }
88
+ catch (error) {
89
+ if (!["REMOTE_AUTH_SIGNING_FAILED", "REMOTE_DEDICATED_KEY_UNAVAILABLE"].includes(error.code)) throw error;
90
+ const diagnosis = { stage: "public-key-preflight", credential_source: paired ? "codexteer-dedicated" : "desktop", gateway_attempted: false, proof_created: false,
91
+ key_access: { status: "unavailable", stage: error.key_stage ?? "worker", reason: error.key_error ?? "UNVERIFIED" },
92
+ keychain_identity: await inspectIdentities(),
93
+ app_server_delegation: { status: "unverified", source: protocol.source ?? "unverified", methods: protocol.remote_control_methods ?? [] } };
94
+ throw Object.assign(controlFailure("The selected enrolled key is unavailable to its signing helper. No gateway connection or device-key proof was attempted. Inspect doctor.remote_control_auth; native read-only observation remains a separately selected route.", "REMOTE_DEVICE_KEY_UNAVAILABLE"),
95
+ { remote_control_auth: diagnosis });
96
+ }
97
+ const check = async () => {
98
+ if (cancelled) throw controlFailure("Remote Control connection closed.", "CONNECTION_FAILED");
99
+ const current = await discover(selected.paths.home);
100
+ if (current.paths.socket !== selected.paths.socket || current.state.instance !== selected.state.instance) throw controlFailure("Runtime changed before Remote Control request.", "RUNTIME_CHANGED");
101
+ const now = authIdentity(await local.request("getAuthStatus", { includeToken: true, refreshToken: false }));
102
+ if (now.accountId !== identity.accountId || now.accountUserId !== identity.accountUserId || now.authUserId !== identity.authUserId
103
+ || enrollmentHash(await enrollment(selected.paths.home, now)) !== pinned) throw controlFailure("Account or enrolled device changed. No request was sent to another account.", "REMOTE_IDENTITY_CHANGED");
104
+ };
105
+ wire = await open({ identity, token: status.authToken, enrollment: registered, environmentId: hostId.slice("remote-control:".length), signal: options.signal,
106
+ timeoutMs: Math.min(options.timeoutMs ?? 10000, 15000), sign: async challenge => { await check(); return sign(local, selected.paths.home, identity, registered, challenge); } });
107
+ await check();
108
+ remote = new RpcClient(wire, options);
109
+ remote.initialization = await remote.request("initialize", { clientInfo: { name: "codexteer", title: "Codexteer", version: VERSION }, capabilities: { experimentalApi: true } });
110
+ if (!remote.initialization || typeof remote.initialization !== "object" || Array.isArray(remote.initialization)) throw controlFailure("Invalid remote App Server initialization.", "REMOTE_CONTROL_PROTOCOL_ERROR");
111
+ remote.notify("initialized", {});
112
+ return {
113
+ close,
114
+ on: (event, listener) => remote.on(event, listener),
115
+ off: (event, listener) => remote.off(event, listener),
116
+ isOpen: () => wire.readyState === 1,
117
+ async request(method, params = {}, requestOptions = {}) {
118
+ validateControlRequest(method, params, { ...requestOptions, management: paired });
119
+ await check();
120
+ // The server, not a Desktop view or a fresh inferred turn, performs
121
+ // the atomic expectedTurnId comparison. Never resume or retry here.
122
+ const mutation = method === "turn/steer" || managementWrites.has(method);
123
+ try { return await remote.request(method, params, { ...requestOptions, mutation }); }
124
+ catch (error) {
125
+ // An RPC error is not proof that the host rolled a mutation back.
126
+ if (executionWrites.has(method) && error.code === "RPC_REJECTED") error.uncertain = mutationOutcomeUnknown(error);
127
+ throw error;
128
+ }
129
+ },
130
+ };
131
+ } catch (error) { close(); throw error; }
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { BUNDLED_NODE } from "./wrapper.mjs";
8
+ import { controlFailure, verifyDeviceProof } from "./remote-control-auth.mjs";
9
+ import { validateDeviceKey } from "./remote-device-store.mjs";
10
+ import { keychainOperation } from "./remote-keychain.mjs";
11
+
12
+ const execute = promisify(execFile);
13
+ export async function deviceKey(operation, input = {}, { run = execute } = {}) {
14
+ if (!["create", "inspect", "enrollment-proof", "connection-proof"].includes(operation)) throw controlFailure("Unsupported dedicated key operation.");
15
+ if (input.provider === "keychain" || input.enrollment?.keyProvider === "keychain") return keychainOperation(operation, { ...input, runtime: input.runtime ?? input.enrollment?.keyRuntime });
16
+ const directory = await mkdtemp(path.join(os.tmpdir(), "codexteer-dedicated-key-"));
17
+ try {
18
+ const file = path.join(directory, "input.json");
19
+ await writeFile(file, JSON.stringify({ operation, ...input }), { mode: 0o600, flag: "wx" });
20
+ let response;
21
+ try {
22
+ const result = await run(BUNDLED_NODE, [fileURLToPath(new URL("./remote-device-worker.mjs", import.meta.url)), file], {
23
+ env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "", ELECTRON_RUN_AS_NODE: "" }, timeout: 10000, maxBuffer: 32768, windowsHide: true });
24
+ response = JSON.parse(result.stdout);
25
+ } catch { throw controlFailure("Dedicated key helper failed. No software key or Desktop key fallback was attempted.", "REMOTE_DEDICATED_KEY_UNAVAILABLE"); }
26
+ if (response.ok !== true) {
27
+ const stage = ["input", "addon", "create", "validate", "public-key", "sign"].includes(response.stage) ? response.stage : "worker";
28
+ const reason = ["KEYCHAIN_UNAVAILABLE", "DEVICE_KEY_NOT_FOUND", "MISSING_ENTITLEMENT", "OS_INTERACTION_REQUIRED", "OS_KEY_OPERATION_FAILED"].includes(response.reason) ? response.reason : "UNAVAILABLE";
29
+ const codes = Array.isArray(response.os_statuses) && response.os_statuses.every(Number.isSafeInteger) ? response.os_statuses.slice(0, 3) : [];
30
+ throw Object.assign(controlFailure(`Dedicated OS key operation failed at ${stage} (${reason}${codes.length ? `: ${codes.join(",")}` : ""}).`, "REMOTE_DEDICATED_KEY_UNAVAILABLE"),
31
+ { key_creation_rejected: stage === "create" && codes.includes(-34018) });
32
+ }
33
+ if (operation === "create") return validateDeviceKey(response.key);
34
+ if (operation === "inspect") { if (response.verified !== true || response.proof) throw controlFailure("Invalid dedicated key inspection receipt."); return; }
35
+ return response.proof;
36
+ } finally { await rm(directory, { recursive: true, force: true }); }
37
+ }
38
+
39
+ export async function dedicatedConnectionProof(enrollment, challenge, context = {}) {
40
+ return verifyDeviceProof(await deviceKey("connection-proof", { ...context, enrollment, challenge }), challenge, enrollment);
41
+ }
@@ -0,0 +1,76 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { controlFailure } from "./remote-control-auth.mjs";
6
+
7
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann", ISSUER = "https://auth.openai.com", SCOPE = "codex.remote_control.enroll";
8
+ const open = promisify(execFile);
9
+
10
+ export function validateStepUpToken(token, identity, now = Date.now()) {
11
+ try {
12
+ if (typeof token !== "string" || token.length > 32768) throw new Error();
13
+ const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url")), account = claims["https://api.openai.com/auth"];
14
+ const scopes = [...new Set([...(claims.scope?.split(/\s+/) ?? []), ...(claims.scp ?? [])].filter(Boolean))];
15
+ if ((account.chatgpt_account_user_id ?? account.account_user_id) !== identity.accountUserId
16
+ || ((account.chatgpt_account_id ?? account.account_id) != null && (account.chatgpt_account_id ?? account.account_id) !== identity.accountId)
17
+ || !Number.isSafeInteger(claims.iat) || now / 1000 - claims.iat > 300 || claims.iat > now / 1000 + 60
18
+ || !Number.isSafeInteger(claims.exp) || claims.exp <= now / 1000
19
+ || !Number.isSafeInteger(claims.pwd_auth_time) || now - claims.pwd_auth_time > 300000 || claims.pwd_auth_time > now + 60000
20
+ || scopes.length !== 1 || scopes[0] !== SCOPE) throw new Error();
21
+ return token;
22
+ } catch { throw controlFailure("Complete a fresh official browser authentication for this account and remote-control enrollment scope.", "REMOTE_STEP_UP_REQUIRED"); }
23
+ }
24
+
25
+ export function stepUpUrl(identity, redirectUri, verifier, state) {
26
+ const url = new URL("/oauth/authorize", ISSUER);
27
+ url.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: redirectUri, scope: SCOPE,
28
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", state,
29
+ originator: "codexteer", reauth: "remote_control", max_age: "0", codex_cli_simplified_flow: "true",
30
+ allowed_workspace_id: identity.accountId, current_workspace_id: identity.accountId }).toString();
31
+ return url.toString();
32
+ }
33
+
34
+ export async function stepUpLogin(identity, { fetch: request = globalThis.fetch, openBrowser = url => open("/usr/bin/open", [url]),
35
+ ports = [1455, 1457], timeoutMs = 600000, progress = () => {} } = {}) {
36
+ const state = randomBytes(32).toString("base64url"), verifier = randomBytes(32).toString("base64url");
37
+ let server, port, timer, resolveCode, rejectCode, settled = false;
38
+ const code = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; }); code.catch(() => {});
39
+ const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); error ? rejectCode(error) : resolveCode(value); };
40
+ const cancel = () => finish(controlFailure("Official enrollment login was cancelled.", "REMOTE_STEP_UP_REQUIRED"));
41
+ process.once("SIGINT", cancel); process.once("SIGTERM", cancel);
42
+ try {
43
+ for (const candidate of ports) {
44
+ const listener = createServer((req, res) => {
45
+ const respond = (status, text) => { res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", Connection: "close", "Referrer-Policy": "no-referrer" }); res.end(text); };
46
+ try {
47
+ if (req.method !== "GET" || req.url?.length > 16384 || ![`localhost:${port}`, `127.0.0.1:${port}`].includes(req.headers.host)) return respond(400, "Invalid callback");
48
+ const url = new URL(req.url, `http://localhost:${port}`);
49
+ if (url.pathname !== "/auth/callback") return respond(404, "Not found");
50
+ if (settled || url.searchParams.getAll("state").length !== 1 || url.searchParams.get("state") !== state) return respond(400, "State mismatch");
51
+ if (url.searchParams.has("error")) { respond(400, "Authorization failed"); finish(controlFailure("Official browser authorization was not completed.", "REMOTE_STEP_UP_REQUIRED")); return; }
52
+ const value = url.searchParams.get("code");
53
+ if (!value || url.searchParams.getAll("code").length !== 1) return respond(400, "Missing code");
54
+ respond(200, "Authorization received. Return to the codexteer terminal to check registration."); finish(null, value);
55
+ } catch { respond(400, "Invalid callback"); }
56
+ });
57
+ try { await new Promise((resolve, reject) => { listener.once("error", reject); listener.listen(candidate, "127.0.0.1", resolve); }); server = listener; port = listener.address().port; break; }
58
+ catch (error) { listener.close(); if (error.code !== "EADDRINUSE") throw error; }
59
+ }
60
+ if (!server) throw controlFailure("Official callback ports 1455 and 1457 are unavailable. Nothing was enrolled.", "REMOTE_CALLBACK_UNAVAILABLE");
61
+ const redirectUri = `http://localhost:${port}/auth/callback`;
62
+ timer = setTimeout(() => finish(controlFailure("Browser authentication timed out. No enrollment was sent.", "REMOTE_STEP_UP_REQUIRED")), timeoutMs);
63
+ progress("Complete fresh account authentication in the official browser window. Server registration has not been sent.");
64
+ try { await openBrowser(stepUpUrl(identity, redirectUri, verifier, state)); }
65
+ catch { throw controlFailure("Could not open the official browser authentication window. No registration was sent.", "REMOTE_BROWSER_OPEN_FAILED"); }
66
+ const authorizationCode = await code;
67
+ let response;
68
+ try { response = await request(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
69
+ body: new URLSearchParams({ grant_type: "authorization_code", code: authorizationCode, redirect_uri: redirectUri, client_id: CLIENT_ID, code_verifier: verifier }).toString(), redirect: "error", signal: AbortSignal.timeout(15000) }); }
70
+ catch { throw controlFailure("Official enrollment token exchange failed.", "REMOTE_STEP_UP_REQUIRED"); }
71
+ if (!response.ok) { await response.body?.cancel(); throw controlFailure(`Official enrollment token exchange returned HTTP ${response.status}.`, "REMOTE_STEP_UP_REQUIRED"); }
72
+ const text = await response.text(); if (text.length > 65536) throw controlFailure("Invalid official enrollment token response.", "REMOTE_STEP_UP_REQUIRED");
73
+ let token; try { token = JSON.parse(text).access_token; } catch { throw controlFailure("Invalid official enrollment token response.", "REMOTE_STEP_UP_REQUIRED"); }
74
+ return validateStepUpToken(token, identity);
75
+ } finally { process.removeListener("SIGINT", cancel); process.removeListener("SIGTERM", cancel); clearTimeout(timer); if (!settled) finish(controlFailure("Browser authentication cancelled.", "REMOTE_STEP_UP_REQUIRED")); if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } }
76
+ }
@@ -0,0 +1,72 @@
1
+ import { createHash, verify } from "node:crypto";
2
+ import { controlFailure, CONTROL_SCOPE } from "./remote-control-auth.mjs";
3
+ import { REMOTE_CLIENT_INFO } from "./remote-client-info.mjs";
4
+
5
+ export const ENROLL_START = "/codex/remote/control/client/enroll/start";
6
+ export const ENROLL_FINISH = "/codex/remote/control/client/enroll/finish";
7
+ export const PAIR_PATH = "/wham/remote/control/client/pair";
8
+ export const deviceIdentity = key => ({ key_id: key.keyId, public_key_spki_der_base64: key.publicKeySpkiDerBase64, algorithm: key.algorithm, protection_class: key.protectionClass });
9
+ export const deviceIdentityHash = key => createHash("sha256").update(JSON.stringify({ algorithm: key.algorithm, keyId: key.keyId, protectionClass: key.protectionClass, publicKeySpkiDerBase64: key.publicKeySpkiDerBase64 })).digest("base64url");
10
+ const expires = value => Number.isSafeInteger(value) ? value * 1000 : typeof value === "string" ? Date.parse(value) : NaN;
11
+
12
+ export function enrollmentPayload(challenge, enrollment, targetPath, now = Date.now()) {
13
+ const hash = deviceIdentityHash(enrollment);
14
+ if (![ENROLL_FINISH, "/codex/remote/control/client/refresh/finish"].includes(targetPath)
15
+ || challenge?.purpose !== "remote_control_client_enrollment" || challenge.audience !== "remote_control_client_enrollment"
16
+ || challenge.target_origin !== "https://chatgpt.com" || challenge.target_path !== `/backend-api${targetPath}`
17
+ || challenge.account_user_id !== enrollment.accountUserId || challenge.client_id !== enrollment.clientId
18
+ || typeof challenge.nonce !== "string" || !/^[A-Za-z0-9_-]{43,512}$/.test(challenge.nonce)
19
+ || !Number.isFinite(expires(challenge.challenge_expires_at)) || expires(challenge.challenge_expires_at) <= now
20
+ || typeof challenge.challenge_id !== "string" || !challenge.challenge_id || challenge.challenge_id.length > 512
21
+ || typeof challenge.challenge_token !== "string" || !challenge.challenge_token || challenge.challenge_token.length > 16384
22
+ || (challenge.device_identity_hash != null && challenge.device_identity_hash !== hash)
23
+ || (targetPath.endsWith("/refresh/finish") && challenge.device_identity_hash == null)) {
24
+ throw controlFailure("Enrollment challenge does not match this dedicated key, account or official destination.", "REMOTE_ENROLLMENT_CHALLENGE_INVALID");
25
+ }
26
+ // Canonical order from Desktop Qdt, including the hash of our new public key.
27
+ return { accountUserId: enrollment.accountUserId, audience: challenge.audience, challengeExpiresAt: challenge.challenge_expires_at,
28
+ challengeId: challenge.challenge_id, clientId: enrollment.clientId, deviceIdentitySha256Base64url: hash, nonce: challenge.nonce,
29
+ targetOrigin: challenge.target_origin, targetPath: challenge.target_path, type: "remoteControlClientEnrollment" };
30
+ }
31
+
32
+ export function validateRegistration(result, enrollment) {
33
+ if (result?.client_id !== enrollment.clientId || result.account_user_id !== enrollment.accountUserId
34
+ || expires(result.expires_at) <= Date.now() || !Number.isFinite(expires(result.expires_at))
35
+ || !Array.isArray(result.scopes) || result.scopes.length !== 1 || result.scopes[0] !== CONTROL_SCOPE
36
+ || typeof result.remote_control_token !== "string" || !result.remote_control_token) {
37
+ throw controlFailure("Enrollment response was not confirmed for this dedicated client. Verify before retrying.", "REMOTE_ENROLLMENT_UNKNOWN");
38
+ }
39
+ }
40
+
41
+ export function verifyEnrollmentProof(proof, challenge, enrollment, path = ENROLL_FINISH) {
42
+ const bytes = Buffer.from(JSON.stringify({ domain: "codex-device-key-sign-payload/v1", payload: enrollmentPayload(challenge, enrollment, path) }));
43
+ if (proof?.challenge_token !== challenge.challenge_token || proof.key_id !== enrollment.keyId || proof.algorithm !== enrollment.algorithm
44
+ || proof.signed_payload_base64 !== bytes.toString("base64") || typeof proof.signature_der_base64 !== "string"
45
+ || !verify("sha256", bytes, { key: Buffer.from(enrollment.publicKeySpkiDerBase64, "base64"), type: "spki", format: "der" }, Buffer.from(proof.signature_der_base64, "base64"))) {
46
+ throw controlFailure("Dedicated enrollment proof does not match its challenge and public key.", "REMOTE_AUTH_PROOF_INVALID");
47
+ }
48
+ return proof;
49
+ }
50
+
51
+ // Fixed official origin and a small operation allowlist. No overrides, redirects,
52
+ // raw error bodies, request retries or credential-bearing debug output.
53
+ export async function officialDeviceRequest(auth, route, { method = "POST", body, fetch: request = globalThis.fetch } = {}) {
54
+ const allowed = method === "POST" ? [ENROLL_START, ENROLL_FINISH, PAIR_PATH].includes(route)
55
+ : method === "GET" && /^(?:\/codex\/remote\/control\/clients\/[A-Za-z0-9_-]+\/environments|\/wham\/remote\/control\/clients)\?limit=100(?:&cursor=[A-Za-z0-9%_.~-]+)?$/.test(route);
56
+ if (!allowed) throw controlFailure("Unsupported official device endpoint.", "REMOTE_DEVICE_ENDPOINT_INVALID");
57
+ let response;
58
+ try {
59
+ response = await request(`https://chatgpt.com/backend-api${route}`, { method, headers: { Authorization: `Bearer ${auth.token}`,
60
+ "ChatGPT-Account-Id": auth.identity.accountId, originator: "codexteer", "User-Agent": REMOTE_CLIENT_INFO.user_agent, "Content-Type": "application/json" },
61
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), redirect: "error", signal: AbortSignal.timeout(15000) });
62
+ } catch { throw controlFailure("Official device request lost its acknowledgement. Inspect the registration before retrying.", "REMOTE_ENROLLMENT_UNKNOWN"); }
63
+ if (!response.ok) {
64
+ await response.body?.cancel();
65
+ throw controlFailure(`Official device request returned HTTP ${response.status}. No authentication fallback was attempted.`, response.status >= 500 ? "REMOTE_ENROLLMENT_UNKNOWN" : "REMOTE_ENROLLMENT_REJECTED");
66
+ }
67
+ try {
68
+ const reader = response.body.getReader(); let size = 0; const chunks = [];
69
+ for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 1024 * 1024) { await reader.cancel(); throw new Error(); } chunks.push(Buffer.from(value)); }
70
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
71
+ } catch { throw controlFailure("Official device response was incomplete. Verify before retrying.", "REMOTE_ENROLLMENT_UNKNOWN"); }
72
+ }