codexteer 0.14.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.
- package/LICENSE +21 -0
- package/README.md +519 -0
- package/assets/send-pururu.wav +0 -0
- package/bin/codexteer-wrapper.mjs +10 -0
- package/bin/codexteer.mjs +5 -0
- package/docs/distribution.md +186 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +22 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/node_modules/ws/lib/receiver.js +743 -0
- package/node_modules/ws/lib/sender.js +607 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +562 -0
- package/node_modules/ws/lib/websocket.js +1407 -0
- package/node_modules/ws/package.json +74 -0
- package/node_modules/ws/wrapper.mjs +21 -0
- package/package.json +41 -0
- package/scripts/accessibility.applescript +43 -0
- package/scripts/inspect.applescript +72 -0
- package/scripts/send.applescript +45 -0
- package/src/app-server.mjs +117 -0
- package/src/bridge.mjs +79 -0
- package/src/checkpoint.mjs +128 -0
- package/src/cli.mjs +358 -0
- package/src/compatibility.mjs +115 -0
- package/src/desktop.mjs +207 -0
- package/src/directive.mjs +39 -0
- package/src/distribution.mjs +192 -0
- package/src/evidence.mjs +33 -0
- package/src/help.mjs +145 -0
- package/src/journal.mjs +120 -0
- package/src/launcher.mjs +145 -0
- package/src/monitor.mjs +123 -0
- package/src/observe.mjs +185 -0
- package/src/paged-read.mjs +228 -0
- package/src/prompt.mjs +51 -0
- package/src/resource.mjs +70 -0
- package/src/rpc.mjs +162 -0
- package/src/runner.mjs +32 -0
- package/src/runtime.mjs +130 -0
- package/src/settings.mjs +49 -0
- package/src/sound.mjs +16 -0
- package/src/store.mjs +78 -0
- package/src/subscription.mjs +118 -0
- package/src/supervise.mjs +57 -0
- package/src/thread-id.mjs +35 -0
- package/src/thread-store.mjs +72 -0
- package/src/version.mjs +19 -0
- package/src/wrapper.mjs +149 -0
package/src/desktop.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { threadComposerDeepLink, threadDeepLink } from "./thread-id.mjs";
|
|
5
|
+
import { readDesktopSettings } from "./settings.mjs";
|
|
6
|
+
|
|
7
|
+
const APP_PATH = "/Applications/ChatGPT.app";
|
|
8
|
+
const APP_BUNDLE_ID = "com.openai.codex";
|
|
9
|
+
const OPEN = "/usr/bin/open";
|
|
10
|
+
const OSASCRIPT = "/usr/bin/osascript";
|
|
11
|
+
const SEND_SCRIPT = fileURLToPath(new URL("../scripts/send.applescript", import.meta.url));
|
|
12
|
+
const INSPECT_SCRIPT = fileURLToPath(new URL("../scripts/inspect.applescript", import.meta.url));
|
|
13
|
+
const ACCESSIBILITY_SCRIPT = fileURLToPath(new URL("../scripts/accessibility.applescript", import.meta.url));
|
|
14
|
+
|
|
15
|
+
function run(command, args, options = {}) {
|
|
16
|
+
return spawnSync(command, args, {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
maxBuffer: 1024 * 1024,
|
|
19
|
+
...options,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function desktopDoctor() {
|
|
24
|
+
const desktopSettings = readDesktopSettings();
|
|
25
|
+
const checks = {
|
|
26
|
+
macos: process.platform === "darwin",
|
|
27
|
+
chatgpt_app: existsSync(APP_PATH),
|
|
28
|
+
open_command: existsSync(OPEN),
|
|
29
|
+
osascript_command: existsSync(OSASCRIPT),
|
|
30
|
+
send_script: existsSync(SEND_SCRIPT),
|
|
31
|
+
inspect_script: existsSync(INSPECT_SCRIPT),
|
|
32
|
+
accessibility_script: existsSync(ACCESSIBILITY_SCRIPT),
|
|
33
|
+
accessibility: false,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (checks.macos && checks.osascript_command) {
|
|
37
|
+
const result = run(OSASCRIPT, [
|
|
38
|
+
"-e",
|
|
39
|
+
'tell application "System Events" to return UI elements enabled',
|
|
40
|
+
], { timeout: 3000 });
|
|
41
|
+
checks.accessibility = result.status === 0 && result.stdout.trim() === "true";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ready: Object.values(checks).every(Boolean),
|
|
46
|
+
backend: "desktop-ui",
|
|
47
|
+
desktop_settings: desktopSettings,
|
|
48
|
+
checks,
|
|
49
|
+
remediation: checks.accessibility
|
|
50
|
+
? null
|
|
51
|
+
: "Enable Accessibility for your terminal app in System Settings > Privacy & Security > Accessibility.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildOpenArgs(deepLink, { background = false } = {}) {
|
|
56
|
+
return [...(background ? ["-g"] : []), "-b", APP_BUNDLE_ID, deepLink];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function openDesktopThread(threadId, { focusComposer = false, prompt, background = false } = {}) {
|
|
60
|
+
const deepLink = focusComposer ? threadComposerDeepLink(threadId, prompt) : threadDeepLink(threadId);
|
|
61
|
+
const result = run(OPEN, buildOpenArgs(deepLink, { background }));
|
|
62
|
+
if (result.status !== 0) {
|
|
63
|
+
throw new Error(result.stderr.trim() || `Failed to open ${deepLink}`);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
thread_id: threadId,
|
|
67
|
+
deep_link: threadDeepLink(threadId),
|
|
68
|
+
backend: "desktop-ui",
|
|
69
|
+
focus_composer: focusComposer,
|
|
70
|
+
background,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateWaitMs(waitMs) {
|
|
75
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000) {
|
|
76
|
+
throw new Error("--wait-ms must be an integer between 0 and 30000.");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function requireReadyDesktop() {
|
|
81
|
+
const doctor = desktopDoctor();
|
|
82
|
+
if (doctor.ready) return;
|
|
83
|
+
const missing = Object.entries(doctor.checks)
|
|
84
|
+
.filter(([, ok]) => !ok)
|
|
85
|
+
.map(([name]) => name)
|
|
86
|
+
.join(", ");
|
|
87
|
+
throw new Error(`Desktop backend is not ready: ${missing}. ${doctor.remediation ?? ""}`.trim());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function frontmostProcessId() {
|
|
91
|
+
const result = run(OSASCRIPT, [
|
|
92
|
+
"-e",
|
|
93
|
+
'tell application "System Events" to return unix id of first application process whose frontmost is true',
|
|
94
|
+
], { timeout: 3000 });
|
|
95
|
+
if (result.error) throw result.error;
|
|
96
|
+
const processId = Number.parseInt(result.stdout.trim(), 10);
|
|
97
|
+
if (result.status !== 0 || !Number.isInteger(processId) || processId < 1) {
|
|
98
|
+
throw new Error(result.stderr.trim() || "Could not identify the previously focused application.");
|
|
99
|
+
}
|
|
100
|
+
return processId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function enableDesktopAccessibility() {
|
|
104
|
+
const result = run(OSASCRIPT, [ACCESSIBILITY_SCRIPT, "enable"], { timeout: 5000 });
|
|
105
|
+
if (result.error) throw result.error;
|
|
106
|
+
if (result.status !== 0) {
|
|
107
|
+
throw new Error(result.stderr.trim() || "Could not enable the Codex accessibility tree.");
|
|
108
|
+
}
|
|
109
|
+
return result.stdout.trim() || "unchanged";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function restoreDesktopAccessibility(mode) {
|
|
113
|
+
if (mode !== "manual" && mode !== "enhanced") return;
|
|
114
|
+
const result = run(OSASCRIPT, [ACCESSIBILITY_SCRIPT, "restore", mode], { timeout: 5000 });
|
|
115
|
+
if (result.error) throw result.error;
|
|
116
|
+
if (result.status !== 0) {
|
|
117
|
+
throw new Error(result.stderr.trim() || "Could not restore the Codex accessibility state.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function inspectDesktopUi(threadId, { waitMs = 1500 } = {}) {
|
|
122
|
+
validateWaitMs(waitMs);
|
|
123
|
+
requireReadyDesktop();
|
|
124
|
+
const accessibilityMode = enableDesktopAccessibility();
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
openDesktopThread(threadId, { focusComposer: true });
|
|
128
|
+
result = run(OSASCRIPT, [INSPECT_SCRIPT, String(waitMs)], {
|
|
129
|
+
timeout: waitMs + 15000,
|
|
130
|
+
});
|
|
131
|
+
} finally {
|
|
132
|
+
restoreDesktopAccessibility(accessibilityMode);
|
|
133
|
+
}
|
|
134
|
+
if (result.error) throw result.error;
|
|
135
|
+
if (result.status !== 0) {
|
|
136
|
+
throw new Error(result.stderr.trim() || "Could not inspect the Codex Desktop UI.");
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
thread_id: threadId,
|
|
140
|
+
backend: "desktop-ui",
|
|
141
|
+
accessibility_mode: accessibilityMode,
|
|
142
|
+
diagnostic: result.stdout.trim(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function newTurnShortcut(message, composerEnterBehavior) {
|
|
147
|
+
if (composerEnterBehavior === "cmdAlways") return "command";
|
|
148
|
+
if (composerEnterBehavior === "cmdIfMultiline" && message.includes("\n")) return "command";
|
|
149
|
+
return "plain";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function buildSendPlan(
|
|
153
|
+
threadId,
|
|
154
|
+
message,
|
|
155
|
+
waitMs = 1500,
|
|
156
|
+
desktopSettings = readDesktopSettings(),
|
|
157
|
+
{ newTurn = false, keepFocus = false } = {},
|
|
158
|
+
) {
|
|
159
|
+
const submitShortcut = newTurn
|
|
160
|
+
? newTurnShortcut(message, desktopSettings.composer_enter_behavior)
|
|
161
|
+
: desktopSettings.submit_shortcut;
|
|
162
|
+
return {
|
|
163
|
+
thread_id: threadId,
|
|
164
|
+
deep_link: threadDeepLink(threadId),
|
|
165
|
+
backend: "desktop-ui",
|
|
166
|
+
delivery_strategy: "deep-link-prefill",
|
|
167
|
+
delivery_action: newTurn ? "new-turn" : "steer",
|
|
168
|
+
focus_policy: keepFocus ? "keep-codex-focused" : "restore-previous-app",
|
|
169
|
+
wait_ms: waitMs,
|
|
170
|
+
follow_up_mode: desktopSettings.follow_up_mode,
|
|
171
|
+
composer_enter_behavior: desktopSettings.composer_enter_behavior,
|
|
172
|
+
submit_shortcut: submitShortcut,
|
|
173
|
+
message_characters: [...message].length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sendDesktopMessage(
|
|
178
|
+
threadId,
|
|
179
|
+
message,
|
|
180
|
+
{ dryRun = false, waitMs = 1500, newTurn = false, keepFocus = false } = {},
|
|
181
|
+
) {
|
|
182
|
+
if (message.trim() === "") throw new Error("Message must not be empty.");
|
|
183
|
+
validateWaitMs(waitMs);
|
|
184
|
+
|
|
185
|
+
const desktopSettings = readDesktopSettings();
|
|
186
|
+
const plan = buildSendPlan(threadId, message, waitMs, desktopSettings, { newTurn, keepFocus });
|
|
187
|
+
if (dryRun) return { ...plan, dry_run: true, sent: false };
|
|
188
|
+
|
|
189
|
+
requireReadyDesktop();
|
|
190
|
+
const previousProcessId = keepFocus ? 0 : frontmostProcessId();
|
|
191
|
+
openDesktopThread(threadId, { focusComposer: true, prompt: message, background: true });
|
|
192
|
+
const result = run(OSASCRIPT, [
|
|
193
|
+
SEND_SCRIPT,
|
|
194
|
+
String(waitMs),
|
|
195
|
+
plan.submit_shortcut,
|
|
196
|
+
keepFocus ? "keep" : "restore",
|
|
197
|
+
String(previousProcessId),
|
|
198
|
+
], {
|
|
199
|
+
timeout: waitMs + 15000,
|
|
200
|
+
});
|
|
201
|
+
if (result.error) throw result.error;
|
|
202
|
+
if (result.status !== 0) {
|
|
203
|
+
throw new Error(result.stderr.trim() || "Codex Desktop did not accept the steering message.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { ...plan, dry_run: false, sent: null, delivery_status: "submitted_unverified" };
|
|
207
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { collectEvidence, verifyEvidence } from "./evidence.mjs";
|
|
2
|
+
import { decodeCursor, threadSnapshot, fetchThread } from "./observe.mjs";
|
|
3
|
+
import { getCheckpoint, verifyCheckpoint } from "./checkpoint.mjs";
|
|
4
|
+
import { verifyPagedFreshness } from "./paged-read.mjs";
|
|
5
|
+
|
|
6
|
+
export const kindLabels = { decision: "ユーザー決定の伝達", review: "レビュー", hypothesis: "仮説(未確定)", suggestion: "任意提案" };
|
|
7
|
+
|
|
8
|
+
export async function prepareDirective(threadId, body, options, id, storage = {}) {
|
|
9
|
+
const { source, kind, evidence = [], basedOn, supersedes, retracts, expiresAt, checkpoint } = options;
|
|
10
|
+
if (!source && !kind && !evidence.length && !basedOn && !supersedes && !retracts && !expiresAt && !checkpoint) return { wireText: body, metadata: null };
|
|
11
|
+
if (checkpoint) await getCheckpoint(threadId, checkpoint, storage);
|
|
12
|
+
if (supersedes && retracts) throw new Error("Use either supersedes or retracts, not both.");
|
|
13
|
+
if (expiresAt && (!/(Z|[+-]\d{2}:\d{2})$/.test(expiresAt) || !Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) throw new Error("--expires-at must be a future ISO timestamp with timezone.");
|
|
14
|
+
if (kind && !Object.hasOwn(kindLabels, kind)) throw new Error("--kind must be decision, review, hypothesis, or suggestion.");
|
|
15
|
+
if (source != null && (!source.trim() || source.length > 80 || /[\r\n\x00-\x1f]/.test(source))) throw new Error("--source must be a non-empty single line (up to 80 characters).");
|
|
16
|
+
const observed = basedOn ? decodeCursor(basedOn, threadId) : null;
|
|
17
|
+
const refs = await collectEvidence(evidence);
|
|
18
|
+
const metadata = { source: source ?? "external", kind: kind ?? "review", evidence: refs, based_on: basedOn ?? null, supersedes: supersedes ?? null, retracts: retracts ?? null, expires_at: expiresAt ?? null, checkpoint_id: checkpoint ?? null };
|
|
19
|
+
const lines = [`[codexteer ${id}]`, `送信者: ${metadata.source}`, `種類: ${kindLabels[metadata.kind]}`];
|
|
20
|
+
for (const ref of refs) lines.push(`根拠: ${JSON.stringify(ref.ref)}${ref.sha256 ? ` (sha256:${ref.sha256})` : " (参照URL)"}`);
|
|
21
|
+
if (observed) lines.push(`観測対象ターン: ${observed.active_turn_id ?? "停止中"}`);
|
|
22
|
+
if (supersedes) lines.push(`置き換える指示: ${supersedes}。この指示を以後の方針として扱ってください。`);
|
|
23
|
+
if (retracts) lines.push(`撤回する指示: ${retracts}。以後の方針から除外してください。`);
|
|
24
|
+
if (expiresAt) lines.push(`有効期限: ${expiresAt}`);
|
|
25
|
+
if (checkpoint) lines.push(`検証チェックポイント: ${checkpoint}`);
|
|
26
|
+
return { metadata, wireText: lines.join("\n") + "\n\n" + body, async beforeSend(thread, client) {
|
|
27
|
+
if (expiresAt && Date.parse(expiresAt) <= Date.now()) throw Object.assign(new Error("Directive expired before sending."), { code: "EXPIRED_DIRECTIVE" });
|
|
28
|
+
if (observed) {
|
|
29
|
+
if (observed.v === 2) await verifyPagedFreshness(client, threadId, basedOn);
|
|
30
|
+
else {
|
|
31
|
+
if (thread.turns?.some(t => t.itemsView && t.itemsView !== "full") || !thread.turns?.length) thread = await fetchThread(client, threadId);
|
|
32
|
+
const current = threadSnapshot(thread);
|
|
33
|
+
if (current.user_revision !== observed.user_revision || current.state.active_turn_id !== observed.active_turn_id) throw Object.assign(new Error("New user input or a different turn was observed. Read the task again before sending."), { code: "STALE_OBSERVATION" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await verifyEvidence(refs);
|
|
37
|
+
if (checkpoint && !(await verifyCheckpoint(threadId, checkpoint, storage)).valid) throw Object.assign(new Error("Checkpoint is no longer valid. Verify the inputs and results before sending."), { code: "STALE_CHECKPOINT" });
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_ROOT = fileURLToPath(new URL("../", import.meta.url));
|
|
9
|
+
// Preserve the deployed record format and state namespace across the rename.
|
|
10
|
+
const RECORD = ".codex-steer-runtime.json";
|
|
11
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
12
|
+
const failure = (message, code = "DEPLOYMENT_UNSAFE") => Object.assign(new Error(message), { code });
|
|
13
|
+
const safeName = name => /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) && name !== "." && name !== "..";
|
|
14
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
15
|
+
|
|
16
|
+
function check(info, directory, privateMode) {
|
|
17
|
+
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())
|
|
18
|
+
|| (privateMode ? info.uid !== process.getuid() || (info.mode & 0o7077) : ![0, process.getuid()].includes(info.uid) || (info.mode & 0o7022))
|
|
19
|
+
|| (!directory && (info.nlink !== 1 || info.size > 10 * 1024 * 1024))) {
|
|
20
|
+
throw failure("Distribution has unsafe ownership, permissions, links, size, or file type.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function ownedDirectory(directory, create = false) {
|
|
25
|
+
if (create) await mkdir(directory, { mode: 0o700 }).catch(e => { if (e.code !== "EEXIST") throw e; });
|
|
26
|
+
const info = await lstat(directory); check(info, true, true); return info;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function checkAncestors(directory) {
|
|
30
|
+
// A private leaf is not enough if somebody else can rename one of its
|
|
31
|
+
// parents. Root-owned sticky temporary directories are the sole exception.
|
|
32
|
+
for (let current = directory; ; current = path.dirname(current)) {
|
|
33
|
+
const info = await lstat(current);
|
|
34
|
+
const stickyTemp = info.uid === 0 && (info.mode & 0o1000) !== 0;
|
|
35
|
+
if (!info.isDirectory() || info.isSymbolicLink() || ![0, process.getuid()].includes(info.uid)
|
|
36
|
+
|| (info.mode & 0o6000) || ((info.mode & 0o022) && !stickyTemp)) throw failure("A distribution ancestor can be replaced by another user.");
|
|
37
|
+
if (path.dirname(current) === current) break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function resolveHome(home) {
|
|
42
|
+
let existing = path.resolve(home), canonical;
|
|
43
|
+
const missing = [];
|
|
44
|
+
while (true) {
|
|
45
|
+
try { canonical = await realpath(existing); break; }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code !== "ENOENT" || path.dirname(existing) === existing) throw error;
|
|
48
|
+
missing.unshift(path.basename(existing)); existing = path.dirname(existing);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await checkAncestors(canonical);
|
|
52
|
+
for (const part of missing) {
|
|
53
|
+
canonical = path.join(canonical, part);
|
|
54
|
+
await ownedDirectory(canonical, true);
|
|
55
|
+
}
|
|
56
|
+
return canonical;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readSafe(file, privateMode) {
|
|
60
|
+
const before = await lstat(file); check(before, false, privateMode);
|
|
61
|
+
// Do not follow a substituted leaf symlink or block on a substituted FIFO.
|
|
62
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
63
|
+
try {
|
|
64
|
+
const info = await handle.stat(); check(info, false, privateMode);
|
|
65
|
+
if (!sameFile(before, info)) throw failure("Distribution changed while being read.");
|
|
66
|
+
const buffer = Buffer.alloc(info.size + 1); let length = 0;
|
|
67
|
+
while (length < buffer.length) {
|
|
68
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
69
|
+
if (!bytesRead) break;
|
|
70
|
+
length += bytesRead;
|
|
71
|
+
}
|
|
72
|
+
const bytes = buffer.subarray(0, length), after = await handle.stat();
|
|
73
|
+
if (bytes.length !== info.size || after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) throw failure("Distribution changed while being read.");
|
|
74
|
+
return { bytes, info };
|
|
75
|
+
} finally { await handle.close(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function writeExclusive(file, bytes, mode) {
|
|
79
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
|
|
80
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The source is the installed package currently executing, never a path or
|
|
84
|
+
// manifest taken from task history, runtime state, or a remote response.
|
|
85
|
+
export async function describeDistribution(root = PACKAGE_ROOT) {
|
|
86
|
+
root = await realpath(root);
|
|
87
|
+
await checkAncestors(root);
|
|
88
|
+
check(await lstat(root), true, false);
|
|
89
|
+
const names = ["package.json", "LICENSE", "bin/codexteer.mjs", "bin/codexteer-wrapper.mjs", "assets/send-pururu.wav",
|
|
90
|
+
"scripts/send.applescript", "scripts/inspect.applescript", "scripts/accessibility.applescript"];
|
|
91
|
+
for (const directory of ["bin", "assets", "scripts", "src", "node_modules", "node_modules/ws", "node_modules/ws/lib"]) {
|
|
92
|
+
check(await lstat(path.join(root, directory)), true, false);
|
|
93
|
+
}
|
|
94
|
+
for (const name of await readdir(path.join(root, "src"))) {
|
|
95
|
+
if (safeName(name) && name.endsWith(".mjs")) names.push(`src/${name}`);
|
|
96
|
+
}
|
|
97
|
+
names.push(...["package.json", "LICENSE", "README.md", "index.js", "browser.js", "wrapper.mjs"].map(name => `node_modules/ws/${name}`));
|
|
98
|
+
for (const name of await readdir(path.join(root, "node_modules/ws/lib"))) {
|
|
99
|
+
if (!safeName(name) || !name.endsWith(".js")) throw failure("Unexpected file in the bundled ws dependency.");
|
|
100
|
+
names.push(`node_modules/ws/lib/${name}`);
|
|
101
|
+
}
|
|
102
|
+
if (names.length > 256) throw failure("Distribution contains too many files.");
|
|
103
|
+
const files = [], contents = new Map(); let total = 0;
|
|
104
|
+
for (const name of names.sort()) {
|
|
105
|
+
const { bytes, info } = await readSafe(path.join(root, name), false);
|
|
106
|
+
const mode = name.startsWith("bin/") ? 0o700 : 0o600;
|
|
107
|
+
if (mode === 0o700 && !(info.mode & 0o100)) throw failure("A distribution executable is missing its executable permission.");
|
|
108
|
+
total += bytes.length;
|
|
109
|
+
if (total > 32 * 1024 * 1024) throw failure("Distribution is too large.");
|
|
110
|
+
files.push({ path: name, size: bytes.length, sha256: hash(bytes), mode }); contents.set(name, bytes);
|
|
111
|
+
}
|
|
112
|
+
const pkg = JSON.parse(contents.get("package.json"));
|
|
113
|
+
if (typeof pkg.version !== "string" || pkg.version.length > 100 || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(pkg.version)) throw failure("Invalid distribution version.");
|
|
114
|
+
const ws = JSON.parse(contents.get("node_modules/ws/package.json"));
|
|
115
|
+
if (ws.name !== "ws" || ws.version !== pkg.dependencies?.ws || Object.keys(ws.dependencies ?? {}).length) throw failure("Bundled dependencies do not match the distribution.");
|
|
116
|
+
const manifest = { schema: 1, package: pkg.name, version: pkg.version, files };
|
|
117
|
+
const sha256 = hash(JSON.stringify(manifest));
|
|
118
|
+
return { root, manifest, sha256, id: `${pkg.version}-${sha256}`, contents };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function verifyDeployment(directory, distribution) {
|
|
122
|
+
await ownedDirectory(directory);
|
|
123
|
+
const expected = new Map(distribution.manifest.files.map(file => [file.path, file]));
|
|
124
|
+
const record = Buffer.from(JSON.stringify({ manifest: distribution.manifest, sha256: distribution.sha256 }) + "\n");
|
|
125
|
+
expected.set(RECORD, { size: record.length, sha256: hash(record), mode: 0o600 });
|
|
126
|
+
const found = new Set();
|
|
127
|
+
async function visit(relative = "") {
|
|
128
|
+
const current = path.join(directory, relative); await ownedDirectory(current);
|
|
129
|
+
for (const name of await readdir(current)) {
|
|
130
|
+
if (name !== RECORD && !safeName(name)) throw failure("Unexpected deployment path.");
|
|
131
|
+
const child = relative ? `${relative}/${name}` : name, file = path.join(directory, child);
|
|
132
|
+
const info = await lstat(file);
|
|
133
|
+
if (info.isDirectory()) {
|
|
134
|
+
if (![...expected.keys()].some(key => key.startsWith(child + "/"))) throw failure("Unexpected deployment directory.");
|
|
135
|
+
await visit(child); continue;
|
|
136
|
+
}
|
|
137
|
+
const entry = expected.get(child);
|
|
138
|
+
if (!entry) throw failure("Unexpected deployment file.");
|
|
139
|
+
const { bytes, info: opened } = await readSafe(file, true);
|
|
140
|
+
if ((opened.mode & 0o777) !== entry.mode || bytes.length !== entry.size || hash(bytes) !== entry.sha256) throw failure("Deployed files differ from the installed distribution.", "DEPLOYMENT_MODIFIED");
|
|
141
|
+
found.add(child);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await visit();
|
|
145
|
+
if (found.size !== expected.size) throw failure("Deployment is incomplete.", "DEPLOYMENT_MODIFIED");
|
|
146
|
+
return { directory, wrapper_path: path.join(directory, "bin/codexteer-wrapper.mjs"), version: distribution.manifest.version, sha256: distribution.sha256 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function prepareDeployment(home, { sourceRoot = PACKAGE_ROOT } = {}) {
|
|
150
|
+
const distribution = await describeDistribution(sourceRoot);
|
|
151
|
+
// CODEX_HOME may itself be a user-configured symlink; only its canonical
|
|
152
|
+
// owner-writable directory is accepted. Never follow links below that root.
|
|
153
|
+
const canonicalHome = await resolveHome(home);
|
|
154
|
+
const homeInfo = await lstat(canonicalHome);
|
|
155
|
+
if (!homeInfo.isDirectory() || homeInfo.uid !== process.getuid() || (homeInfo.mode & 0o7022)) throw failure("CODEX_HOME must be an owner-controlled directory.");
|
|
156
|
+
const stateRoot = path.join(canonicalHome, "codex-steer"), root = path.join(stateRoot, "runtimes");
|
|
157
|
+
await ownedDirectory(stateRoot, true); await ownedDirectory(root, true);
|
|
158
|
+
const target = path.join(root, distribution.id), lock = path.join(root, `.install-${distribution.id}`);
|
|
159
|
+
let lockInfo;
|
|
160
|
+
// An abandoned lock is never stolen. A concurrent caller can reuse the
|
|
161
|
+
// completed distribution after its installer releases this lock.
|
|
162
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
163
|
+
try { await mkdir(lock, { mode: 0o700 }); lockInfo = await ownedDirectory(lock); break; }
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error.code !== "EEXIST") throw error;
|
|
166
|
+
await ownedDirectory(lock).catch(e => { if (e.code !== "ENOENT") throw e; });
|
|
167
|
+
await delay(50);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!lockInfo) throw failure("Runtime placement is busy or an earlier installation was interrupted. Inspect the installation lock before retrying.", "DEPLOYMENT_BUSY");
|
|
171
|
+
let stage, stageInfo;
|
|
172
|
+
try {
|
|
173
|
+
const existing = await lstat(target).catch(e => { if (e.code !== "ENOENT") throw e; return null; });
|
|
174
|
+
if (existing) return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: true };
|
|
175
|
+
stage = await mkdtemp(path.join(root, ".stage-")); stageInfo = await ownedDirectory(stage);
|
|
176
|
+
for (const file of distribution.manifest.files) {
|
|
177
|
+
const parts = file.path.split("/"); parts.pop(); let parent = stage;
|
|
178
|
+
for (const part of parts) { parent = path.join(parent, part); await ownedDirectory(parent, true); }
|
|
179
|
+
await writeExclusive(path.join(stage, file.path), distribution.contents.get(file.path), file.mode);
|
|
180
|
+
}
|
|
181
|
+
await writeExclusive(path.join(stage, RECORD), JSON.stringify({ manifest: distribution.manifest, sha256: distribution.sha256 }) + "\n", 0o600);
|
|
182
|
+
await verifyDeployment(stage, distribution);
|
|
183
|
+
// All cooperating installers hold the exclusive lock, and the parent is
|
|
184
|
+
// private. Refuse even an empty existing directory rather than replacing it.
|
|
185
|
+
if (await lstat(target).catch(e => { if (e.code !== "ENOENT") throw e; return null; })) throw failure("Deployment destination appeared during placement.");
|
|
186
|
+
await rename(stage, target); stage = null;
|
|
187
|
+
return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: false };
|
|
188
|
+
} finally {
|
|
189
|
+
if (stage && sameFile(stageInfo, await lstat(stage).catch(() => ({})))) await rm(stage, { recursive: true });
|
|
190
|
+
if (sameFile(lockInfo, await lstat(lock).catch(() => ({})))) await rm(lock, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/evidence.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { stat, realpath } from "node:fs/promises";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export async function fingerprintFile(input) {
|
|
7
|
+
const ref = path.resolve(input), canonical = await realpath(ref), before = await stat(canonical);
|
|
8
|
+
if (!before.isFile()) throw new Error("Evidence must be a file or an http(s)/codex URL.");
|
|
9
|
+
const hash = createHash("sha256");
|
|
10
|
+
for await (const data of createReadStream(canonical)) hash.update(data);
|
|
11
|
+
const after = await stat(canonical);
|
|
12
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || (await realpath(ref)) !== canonical) throw new Error("File changed while being fingerprinted. Try again after editing finishes.");
|
|
13
|
+
return { type: "file", ref, path: canonical, size: after.size, mode: after.mode & 0o777, sha256: hash.digest("hex") };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function collectEvidence(refs = []) {
|
|
17
|
+
return Promise.all(refs.map(async ref => {
|
|
18
|
+
if (/^(https?|codex):\/\//i.test(ref)) {
|
|
19
|
+
const url = new URL(ref);
|
|
20
|
+
if (url.username || url.password) throw new Error("Evidence URLs must not contain credentials.");
|
|
21
|
+
return { type: "url", ref: url.href, verification: "reference_only" };
|
|
22
|
+
}
|
|
23
|
+
return fingerprintFile(ref);
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function verifyEvidence(evidence = []) {
|
|
28
|
+
for (const old of evidence.filter(e => e.type === "file")) {
|
|
29
|
+
let current;
|
|
30
|
+
try { current = await fingerprintFile(old.ref); } catch { throw Object.assign(new Error("Evidence file is missing or changed. Read it again before sending."), { code: "STALE_EVIDENCE" }); }
|
|
31
|
+
if (["path", "size", "mode", "sha256"].some(k => old[k] !== current[k])) throw Object.assign(new Error("Evidence file changed. Read it again before sending."), { code: "STALE_EVIDENCE" });
|
|
32
|
+
}
|
|
33
|
+
}
|