codex-model-router 1.1.0 → 1.2.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/MAINTAINERS.md +92 -0
- package/README.md +144 -78
- package/SECURITY.md +21 -0
- package/lib/manifest.js +95 -0
- package/lib/router-core.js +808 -217
- package/lib/router.js +1 -210
- package/lib/toml.js +11 -15
- package/package.json +16 -5
package/lib/router-core.js
CHANGED
|
@@ -1,138 +1,100 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
|
+
lstat,
|
|
4
5
|
mkdir,
|
|
5
6
|
open,
|
|
6
7
|
readFile,
|
|
8
|
+
realpath,
|
|
7
9
|
rename,
|
|
8
10
|
rm,
|
|
9
11
|
rmdir,
|
|
10
|
-
stat
|
|
12
|
+
stat,
|
|
13
|
+
writeFile
|
|
11
14
|
} from "node:fs/promises";
|
|
12
|
-
import { homedir } from "node:os";
|
|
15
|
+
import { homedir, hostname } from "node:os";
|
|
13
16
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
-
import { applyEdits, insertionEdit,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"""
|
|
35
|
-
`
|
|
36
|
-
},
|
|
37
|
-
sol: {
|
|
38
|
-
relative: ["agents", "sol.toml"],
|
|
39
|
-
content: `name = "sol"
|
|
40
|
-
description = "Read-only reviewer for security-sensitive or high-regression-risk logic."
|
|
41
|
-
model = "gpt-5.6-sol"
|
|
42
|
-
model_reasoning_effort = "medium"
|
|
43
|
-
sandbox_mode = "read-only"
|
|
44
|
-
developer_instructions = """
|
|
45
|
-
Review only. Focus on security, authentication, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state, and regression risk.
|
|
46
|
-
Report concrete findings to Terra; do not apply fixes.
|
|
47
|
-
"""
|
|
48
|
-
`
|
|
49
|
-
},
|
|
50
|
-
skill: {
|
|
51
|
-
content: `---
|
|
52
|
-
name: model-router
|
|
53
|
-
description: Route Codex work between Terra, Luna, and Sol with the fewest required agents.
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
Terra handles ordinary questions, coding, debugging, fixes, testing, and implementation. Never create a Terra subagent.
|
|
57
|
-
Use Luna only for deterministic low-risk repeated edits, bulk patterns, read-heavy searches, formatting, counting, extraction, or summaries; prefer Luna when the same clear operation repeats at least three times.
|
|
58
|
-
Use Sol only as a read-only reviewer for security, authentication, authorization, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state changes, high-regression-risk logic, or an explicit review. Terra applies fixes.
|
|
59
|
-
Do not spawn a subagent for a simple question. Use the minimum number of agents and run Luna with Sol only when both independent tasks are required.
|
|
60
|
-
`
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
function hash(content) {
|
|
65
|
-
return createHash("sha256").update(content).digest("hex");
|
|
17
|
+
import { applyEdits, insertionEdit, removalEdit, scanToml } from "./toml.js";
|
|
18
|
+
import {
|
|
19
|
+
AGENT_EXPECTATIONS,
|
|
20
|
+
DEFAULTS,
|
|
21
|
+
LEGACY_TEMPLATES,
|
|
22
|
+
TEMPLATES,
|
|
23
|
+
VERSION
|
|
24
|
+
} from "./manifest.js";
|
|
25
|
+
|
|
26
|
+
export { VERSION };
|
|
27
|
+
|
|
28
|
+
const STATE_VERSION = 3;
|
|
29
|
+
const LOCK_FILE = "model-router.lock";
|
|
30
|
+
const JOURNAL_FILE = "model-router-transaction.json";
|
|
31
|
+
const TRANSACTION_DIR = "model-router-transaction-data";
|
|
32
|
+
const TEST_CRASH_ENV = "CODEX_MODEL_ROUTER_TEST_CRASH_AFTER";
|
|
33
|
+
const TEST_HOLD_LOCK_ENV = "CODEX_MODEL_ROUTER_TEST_HOLD_LOCK_MS";
|
|
34
|
+
const TESTING_ENV = "CODEX_MODEL_ROUTER_TESTING";
|
|
35
|
+
|
|
36
|
+
function digest(value) {
|
|
37
|
+
return createHash("sha256").update(value).digest("hex");
|
|
66
38
|
}
|
|
67
39
|
|
|
68
40
|
function quoteToml(value) {
|
|
69
41
|
return JSON.stringify(value);
|
|
70
42
|
}
|
|
71
43
|
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
: join(projectRoot, ".agents", "skills");
|
|
81
|
-
return {
|
|
82
|
-
scope: global ? "global" : "project",
|
|
83
|
-
projectRoot,
|
|
84
|
-
userHome,
|
|
85
|
-
codexHome,
|
|
86
|
-
skillsHome,
|
|
87
|
-
skillsRoot: dirname(skillsHome),
|
|
88
|
-
config: join(codexHome, "config.toml"),
|
|
89
|
-
state: join(codexHome, "model-router-state.json"),
|
|
90
|
-
backup: join(codexHome, "config.toml.codex-model-router.bak"),
|
|
91
|
-
luna: join(codexHome, ...TEMPLATES.luna.relative),
|
|
92
|
-
sol: join(codexHome, ...TEMPLATES.sol.relative),
|
|
93
|
-
skill: join(skillsHome, "model-router", "SKILL.md")
|
|
94
|
-
};
|
|
44
|
+
function normalizeForCompare(path) {
|
|
45
|
+
const value = resolve(path);
|
|
46
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isWithin(path, root) {
|
|
50
|
+
const value = relative(resolve(root), resolve(path));
|
|
51
|
+
return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
|
|
95
52
|
}
|
|
96
53
|
|
|
97
54
|
async function pathExists(path) {
|
|
98
|
-
try {
|
|
55
|
+
try {
|
|
56
|
+
await access(path);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
99
59
|
if (error?.code === "ENOENT") return false;
|
|
100
60
|
throw error;
|
|
101
61
|
}
|
|
102
62
|
}
|
|
103
63
|
|
|
104
|
-
async function
|
|
64
|
+
async function readBytes(path) {
|
|
105
65
|
try {
|
|
106
|
-
|
|
107
|
-
const text = bytes.toString("utf8");
|
|
108
|
-
if (!Buffer.from(text, "utf8").equals(bytes)) {
|
|
109
|
-
const error = new Error(`${path} is not valid UTF-8`);
|
|
110
|
-
error.code = "INVALID_UTF8";
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
return { exists: true, text };
|
|
66
|
+
return { exists: true, bytes: await readFile(path) };
|
|
114
67
|
} catch (error) {
|
|
115
|
-
if (error?.code === "ENOENT") return { exists: false,
|
|
68
|
+
if (error?.code === "ENOENT") return { exists: false, bytes: null };
|
|
116
69
|
throw error;
|
|
117
70
|
}
|
|
118
71
|
}
|
|
119
72
|
|
|
120
|
-
async function
|
|
121
|
-
|
|
122
|
-
|
|
73
|
+
async function readText(path) {
|
|
74
|
+
const file = await readBytes(path);
|
|
75
|
+
if (!file.exists) return { exists: false, text: undefined };
|
|
76
|
+
const text = file.bytes.toString("utf8");
|
|
77
|
+
if (!Buffer.from(text, "utf8").equals(file.bytes)) {
|
|
78
|
+
const error = new Error(`${path} is not valid UTF-8`);
|
|
79
|
+
error.code = "INVALID_UTF8";
|
|
123
80
|
throw error;
|
|
124
81
|
}
|
|
82
|
+
return { exists: true, text };
|
|
125
83
|
}
|
|
126
84
|
|
|
127
|
-
async function
|
|
85
|
+
async function atomicWriteBytes(path, bytes) {
|
|
128
86
|
await mkdir(dirname(path), { recursive: true });
|
|
129
87
|
let mode = 0o600;
|
|
130
|
-
try {
|
|
88
|
+
try {
|
|
89
|
+
mode = (await stat(path)).mode;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code !== "ENOENT") throw error;
|
|
92
|
+
}
|
|
131
93
|
const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
132
94
|
let handle;
|
|
133
95
|
try {
|
|
134
96
|
handle = await open(temporary, "wx", mode);
|
|
135
|
-
await handle.writeFile(
|
|
97
|
+
await handle.writeFile(bytes);
|
|
136
98
|
await handle.sync();
|
|
137
99
|
await handle.close();
|
|
138
100
|
handle = undefined;
|
|
@@ -144,22 +106,175 @@ async function atomicWrite(path, content) {
|
|
|
144
106
|
}
|
|
145
107
|
}
|
|
146
108
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
109
|
+
async function atomicWrite(path, content) {
|
|
110
|
+
return atomicWriteBytes(path, Buffer.from(content, "utf8"));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function directoryExists(path) {
|
|
114
|
+
try {
|
|
115
|
+
return (await stat(path)).isDirectory();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error?.code === "ENOENT") return false;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function nearestExistingAncestor(path) {
|
|
123
|
+
let current = resolve(path);
|
|
124
|
+
while (true) {
|
|
125
|
+
try {
|
|
126
|
+
await lstat(current);
|
|
127
|
+
return current;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error?.code !== "ENOENT") throw error;
|
|
130
|
+
const parent = dirname(current);
|
|
131
|
+
if (parent === current) throw new Error(`no existing ancestor for ${path}`);
|
|
132
|
+
current = parent;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function canonicalizeRoot(path) {
|
|
138
|
+
const absolute = resolve(path);
|
|
139
|
+
const ancestor = await nearestExistingAncestor(absolute);
|
|
140
|
+
const physicalAncestor = await realpath(ancestor);
|
|
141
|
+
return resolve(physicalAncestor, relative(ancestor, absolute));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function canonicalizeScope(scope) {
|
|
145
|
+
return {
|
|
146
|
+
...scope,
|
|
147
|
+
cwd: await canonicalizeRoot(scope.cwd),
|
|
148
|
+
home: await canonicalizeRoot(scope.home ?? homedir())
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function assertNoIndirection(target, root, anchor, label) {
|
|
153
|
+
const absoluteTarget = resolve(target);
|
|
154
|
+
const absoluteRoot = resolve(root);
|
|
155
|
+
const absoluteAnchor = resolve(anchor);
|
|
156
|
+
if (!isWithin(absoluteTarget, absoluteRoot)) {
|
|
157
|
+
throw new Error(`unsafe path for ${label}: ${absoluteTarget} is outside ${absoluteRoot}`);
|
|
158
|
+
}
|
|
159
|
+
if (!isWithin(absoluteTarget, absoluteAnchor)) {
|
|
160
|
+
throw new Error(`unsafe path for ${label}: ${absoluteTarget} is outside anchor ${absoluteAnchor}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const rel = relative(absoluteAnchor, absoluteTarget);
|
|
164
|
+
const components = rel ? rel.split(sep) : [];
|
|
165
|
+
const physicalAnchor = await realpath(absoluteAnchor);
|
|
166
|
+
let current = absoluteAnchor;
|
|
167
|
+
const inspect = [current];
|
|
168
|
+
for (const component of components) {
|
|
169
|
+
current = join(current, component);
|
|
170
|
+
inspect.push(current);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (let index = 0; index < inspect.length; index += 1) {
|
|
174
|
+
const candidate = inspect[index];
|
|
175
|
+
let info;
|
|
176
|
+
try {
|
|
177
|
+
info = await lstat(candidate);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error?.code === "ENOENT") break;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
if (info.isSymbolicLink()) {
|
|
183
|
+
throw new Error(`unsafe path component for ${label}: ${candidate} is a symbolic link or junction`);
|
|
184
|
+
}
|
|
185
|
+
if (index < inspect.length - 1 && !info.isDirectory()) {
|
|
186
|
+
throw new Error(`unsafe path component for ${label}: ${candidate} is not a directory`);
|
|
187
|
+
}
|
|
188
|
+
const physical = await realpath(candidate);
|
|
189
|
+
const expectedPhysical = resolve(physicalAnchor, relative(absoluteAnchor, candidate));
|
|
190
|
+
if (normalizeForCompare(physical) !== normalizeForCompare(expectedPhysical)) {
|
|
191
|
+
throw new Error(`unsafe path component for ${label}: ${candidate} resolves to ${physical}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function expectedPaths({ cwd, home, global, env }) {
|
|
197
|
+
const userHome = resolve(home ?? homedir());
|
|
198
|
+
const projectRoot = resolve(cwd);
|
|
199
|
+
const codexHome = global
|
|
200
|
+
? resolve(env?.CODEX_HOME || join(userHome, ".codex"))
|
|
201
|
+
: join(projectRoot, ".codex");
|
|
202
|
+
const skillsHome = global
|
|
203
|
+
? join(userHome, ".agents", "skills")
|
|
204
|
+
: join(projectRoot, ".agents", "skills");
|
|
205
|
+
const skillsRoot = dirname(skillsHome);
|
|
206
|
+
return {
|
|
207
|
+
scope: global ? "global" : "project",
|
|
208
|
+
projectRoot,
|
|
209
|
+
userHome,
|
|
210
|
+
codexHome,
|
|
211
|
+
skillsHome,
|
|
212
|
+
skillsRoot,
|
|
213
|
+
config: join(codexHome, "config.toml"),
|
|
214
|
+
state: join(codexHome, "model-router-state.json"),
|
|
215
|
+
backup: join(codexHome, "config.toml.codex-model-router.bak"),
|
|
216
|
+
luna: join(codexHome, ...TEMPLATES.luna.relative),
|
|
217
|
+
sol: join(codexHome, ...TEMPLATES.sol.relative),
|
|
218
|
+
skill: join(skillsHome, "model-router", "SKILL.md"),
|
|
219
|
+
lock: join(codexHome, LOCK_FILE),
|
|
220
|
+
journal: join(codexHome, JOURNAL_FILE),
|
|
221
|
+
transactionData: join(codexHome, TRANSACTION_DIR)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function safetyAnchors(location) {
|
|
226
|
+
const codexAnchor = location.scope === "project"
|
|
227
|
+
? location.projectRoot
|
|
228
|
+
: await nearestExistingAncestor(location.codexHome);
|
|
229
|
+
const skillsAnchor = location.scope === "project"
|
|
230
|
+
? location.projectRoot
|
|
231
|
+
: location.userHome;
|
|
232
|
+
return { codexAnchor, skillsAnchor };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function validateLocationSafety(location) {
|
|
236
|
+
const { codexAnchor, skillsAnchor } = await safetyAnchors(location);
|
|
237
|
+
const codexTargets = [
|
|
238
|
+
location.codexHome,
|
|
239
|
+
location.config,
|
|
240
|
+
location.state,
|
|
241
|
+
location.backup,
|
|
242
|
+
location.luna,
|
|
243
|
+
location.sol,
|
|
244
|
+
location.lock,
|
|
245
|
+
location.journal,
|
|
246
|
+
location.transactionData
|
|
247
|
+
];
|
|
248
|
+
for (const target of codexTargets) {
|
|
249
|
+
await assertNoIndirection(target, location.codexHome, codexAnchor, target);
|
|
250
|
+
}
|
|
251
|
+
const skillsTargets = [location.skillsRoot, location.skillsHome, location.skill];
|
|
252
|
+
for (const target of skillsTargets) {
|
|
253
|
+
await assertNoIndirection(target, location.skillsRoot, skillsAnchor, target);
|
|
254
|
+
}
|
|
150
255
|
}
|
|
151
256
|
|
|
152
257
|
function assertExactPath(actual, expected, label) {
|
|
153
|
-
if (
|
|
258
|
+
if (normalizeForCompare(actual) !== normalizeForCompare(expected)) {
|
|
259
|
+
throw new Error(`unsafe state path for ${label}`);
|
|
260
|
+
}
|
|
154
261
|
}
|
|
155
262
|
|
|
156
263
|
function freshState(location) {
|
|
157
264
|
return {
|
|
158
|
-
version:
|
|
265
|
+
version: STATE_VERSION,
|
|
159
266
|
packageVersion: VERSION,
|
|
160
267
|
scope: location.scope,
|
|
161
|
-
roots: {
|
|
162
|
-
|
|
268
|
+
roots: {
|
|
269
|
+
codex: location.codexHome,
|
|
270
|
+
skills: location.skillsHome,
|
|
271
|
+
skillsRoot: location.skillsRoot
|
|
272
|
+
},
|
|
273
|
+
config: {
|
|
274
|
+
path: location.config,
|
|
275
|
+
createdFile: false,
|
|
276
|
+
values: {}
|
|
277
|
+
},
|
|
163
278
|
files: {},
|
|
164
279
|
backup: null,
|
|
165
280
|
createdDirs: []
|
|
@@ -170,9 +285,10 @@ function normalizeLegacyState(raw, location) {
|
|
|
170
285
|
const state = freshState(location);
|
|
171
286
|
if (!raw?.config || !raw?.files) throw new Error("invalid legacy state structure");
|
|
172
287
|
assertExactPath(raw.config.path, location.config, "config");
|
|
173
|
-
state.config.values = {};
|
|
174
288
|
for (const [key, item] of Object.entries(raw.config.values || {})) {
|
|
175
|
-
if (!Object.hasOwn(DEFAULTS, key) || item?.installed !== DEFAULTS[key])
|
|
289
|
+
if (!Object.hasOwn(DEFAULTS, key) || item?.installed !== DEFAULTS[key]) {
|
|
290
|
+
throw new Error(`invalid legacy state value: ${key}`);
|
|
291
|
+
}
|
|
176
292
|
state.config.values[key] = {
|
|
177
293
|
installed: item.installed,
|
|
178
294
|
previousRaw: item.previous == null ? null : quoteToml(String(item.previous)),
|
|
@@ -194,18 +310,26 @@ function normalizeLegacyState(raw, location) {
|
|
|
194
310
|
|
|
195
311
|
function validateState(raw, location) {
|
|
196
312
|
if (!raw || typeof raw !== "object") throw new Error("state is not an object");
|
|
197
|
-
|
|
198
|
-
if (
|
|
313
|
+
let state;
|
|
314
|
+
if (!raw.version) state = normalizeLegacyState(raw, location);
|
|
315
|
+
else if (raw.version === 2 || raw.version === STATE_VERSION) state = structuredClone(raw);
|
|
316
|
+
else throw new Error(`unsupported state version: ${raw.version}`);
|
|
317
|
+
|
|
318
|
+
state.version = STATE_VERSION;
|
|
199
319
|
if (state.scope !== location.scope) throw new Error("state scope does not match this command");
|
|
200
320
|
assertExactPath(state.roots?.codex, location.codexHome, "Codex root");
|
|
201
321
|
assertExactPath(state.roots?.skills, location.skillsHome, "skills root");
|
|
202
322
|
assertExactPath(state.roots?.skillsRoot ?? dirname(state.roots?.skills || ""), location.skillsRoot, "skills parent root");
|
|
203
323
|
assertExactPath(state.config?.path, location.config, "config");
|
|
204
|
-
if (typeof state.config.createdFile !== "boolean" || typeof state.config.values !== "object")
|
|
324
|
+
if (typeof state.config.createdFile !== "boolean" || typeof state.config.values !== "object") {
|
|
325
|
+
throw new Error("invalid config state");
|
|
326
|
+
}
|
|
205
327
|
for (const [key, item] of Object.entries(state.config.values)) {
|
|
206
328
|
if (!Object.hasOwn(DEFAULTS, key)) throw new Error(`unknown managed config key: ${key}`);
|
|
207
329
|
if (item?.installed !== DEFAULTS[key]) throw new Error(`invalid installed value for ${key}`);
|
|
208
|
-
if (!(item.previousRaw === null || typeof item.previousRaw === "string"))
|
|
330
|
+
if (!(item.previousRaw === null || typeof item.previousRaw === "string")) {
|
|
331
|
+
throw new Error(`invalid previous value for ${key}`);
|
|
332
|
+
}
|
|
209
333
|
}
|
|
210
334
|
state.files ||= {};
|
|
211
335
|
for (const [name, item] of Object.entries(state.files)) {
|
|
@@ -231,7 +355,11 @@ async function loadState(location) {
|
|
|
231
355
|
const file = await readText(location.state);
|
|
232
356
|
if (!file.exists) return { exists: false, state: freshState(location) };
|
|
233
357
|
let raw;
|
|
234
|
-
try {
|
|
358
|
+
try {
|
|
359
|
+
raw = JSON.parse(file.text);
|
|
360
|
+
} catch {
|
|
361
|
+
throw new Error("state file is not valid JSON");
|
|
362
|
+
}
|
|
235
363
|
return { exists: true, state: validateState(raw, location) };
|
|
236
364
|
}
|
|
237
365
|
|
|
@@ -256,7 +384,7 @@ function addMessage(plan, status, label, detail) {
|
|
|
256
384
|
}
|
|
257
385
|
|
|
258
386
|
function addWrite(plan, path, content, status, label) {
|
|
259
|
-
plan.operations.push({ kind: "write", path, content });
|
|
387
|
+
plan.operations.push({ kind: "write", path, content: Buffer.from(content, "utf8") });
|
|
260
388
|
addMessage(plan, status, label);
|
|
261
389
|
}
|
|
262
390
|
|
|
@@ -289,7 +417,9 @@ async function missingDirectories(location) {
|
|
|
289
417
|
];
|
|
290
418
|
const unique = [...new Set(candidates.map((value) => resolve(value)))].sort((a, b) => a.length - b.length);
|
|
291
419
|
const missing = [];
|
|
292
|
-
for (const directory of unique)
|
|
420
|
+
for (const directory of unique) {
|
|
421
|
+
if (!(await directoryExists(directory))) missing.push(directory);
|
|
422
|
+
}
|
|
293
423
|
return missing;
|
|
294
424
|
}
|
|
295
425
|
|
|
@@ -302,30 +432,32 @@ function patchInstallConfig(original, state, setDefault) {
|
|
|
302
432
|
for (const [key, installed] of Object.entries(DEFAULTS)) {
|
|
303
433
|
const assignment = scan.targets.get(key);
|
|
304
434
|
const tracked = nextValues[key];
|
|
305
|
-
if (assignment && assignment.parsedValue.kind !== "string")
|
|
435
|
+
if (assignment && assignment.parsedValue.kind !== "string") {
|
|
436
|
+
throw new Error(`${key} must be a TOML string`);
|
|
437
|
+
}
|
|
306
438
|
const current = semanticValue(assignment);
|
|
307
|
-
|
|
308
439
|
if (!assignment) {
|
|
309
440
|
additions.push(`${key} = ${quoteToml(installed)}`);
|
|
310
441
|
nextValues[key] = tracked || { installed, previousRaw: null, source: "inserted" };
|
|
311
442
|
continue;
|
|
312
443
|
}
|
|
313
|
-
|
|
314
|
-
if (!setDefault) {
|
|
315
|
-
if (tracked && current !== installed) {
|
|
316
|
-
// Keep ownership metadata so doctor can identify the later user edit.
|
|
317
|
-
}
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
|
|
444
|
+
if (!setDefault) continue;
|
|
321
445
|
if (current === installed) continue;
|
|
322
446
|
edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: quoteToml(installed) });
|
|
323
|
-
nextValues[key] = tracked || {
|
|
447
|
+
nextValues[key] = tracked || {
|
|
448
|
+
installed,
|
|
449
|
+
previousRaw: assignment.rawValue,
|
|
450
|
+
source: "replaced"
|
|
451
|
+
};
|
|
324
452
|
}
|
|
325
453
|
|
|
326
454
|
const insert = insertionEdit(original, scan, additions);
|
|
327
455
|
if (insert) edits.push(insert);
|
|
328
|
-
return {
|
|
456
|
+
return {
|
|
457
|
+
content: edits.length ? applyEdits(original, edits) : original,
|
|
458
|
+
values: nextValues,
|
|
459
|
+
changed: edits.length > 0
|
|
460
|
+
};
|
|
329
461
|
}
|
|
330
462
|
|
|
331
463
|
function patchUninstallConfig(original, stateValues) {
|
|
@@ -342,20 +474,33 @@ function patchUninstallConfig(original, stateValues) {
|
|
|
342
474
|
if (item.previousRaw === null) edits.push(removalEdit(assignment));
|
|
343
475
|
else edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: item.previousRaw });
|
|
344
476
|
}
|
|
345
|
-
return {
|
|
477
|
+
return {
|
|
478
|
+
content: edits.length ? applyEdits(original, edits) : original,
|
|
479
|
+
changed: edits.length > 0,
|
|
480
|
+
preserved
|
|
481
|
+
};
|
|
346
482
|
}
|
|
347
483
|
|
|
348
484
|
async function planInstall(scope, flags) {
|
|
349
485
|
const location = expectedPaths(scope);
|
|
350
486
|
const plan = { command: "install", location, operations: [], messages: [], failed: false };
|
|
351
487
|
let loaded;
|
|
352
|
-
try {
|
|
488
|
+
try {
|
|
489
|
+
loaded = await loadState(location);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
failPlan(plan, error.message);
|
|
492
|
+
return plan;
|
|
493
|
+
}
|
|
353
494
|
const state = loaded.state;
|
|
354
495
|
const config = await readText(location.config);
|
|
355
496
|
const original = config.exists ? config.text : "";
|
|
356
497
|
let patched;
|
|
357
|
-
try {
|
|
358
|
-
|
|
498
|
+
try {
|
|
499
|
+
patched = patchInstallConfig(original, state, flags.setDefault);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
failPlan(plan, error.message);
|
|
502
|
+
return plan;
|
|
503
|
+
}
|
|
359
504
|
|
|
360
505
|
const createdDirs = await missingDirectories(location);
|
|
361
506
|
plan.createdDirs = createdDirs;
|
|
@@ -370,28 +515,46 @@ async function planInstall(scope, flags) {
|
|
|
370
515
|
failPlan(plan, `untracked backup already exists: ${location.backup}`);
|
|
371
516
|
return plan;
|
|
372
517
|
}
|
|
373
|
-
state.backup = { path: location.backup, hash:
|
|
518
|
+
state.backup = { path: location.backup, hash: digest(Buffer.from(original, "utf8")) };
|
|
374
519
|
addWrite(plan, location.backup, original, "create", "config backup");
|
|
375
520
|
}
|
|
376
521
|
addWrite(plan, location.config, patched.content, config.exists ? "update" : "create", "config.toml");
|
|
377
522
|
} else {
|
|
378
|
-
addMessage(
|
|
523
|
+
addMessage(
|
|
524
|
+
plan,
|
|
525
|
+
"preserve",
|
|
526
|
+
"config.toml",
|
|
527
|
+
flags.setDefault ? "already matches or contains protected values" : "existing defaults preserved"
|
|
528
|
+
);
|
|
379
529
|
}
|
|
380
530
|
|
|
381
531
|
for (const name of ["luna", "sol", "skill"]) {
|
|
382
532
|
const current = await readText(location[name]);
|
|
383
533
|
const tracked = state.files[name];
|
|
384
534
|
const template = TEMPLATES[name].content;
|
|
535
|
+
|
|
385
536
|
if (!current.exists) {
|
|
386
|
-
state.files[name] = { path: location[name], hash:
|
|
537
|
+
state.files[name] = { path: location[name], hash: digest(Buffer.from(template, "utf8")) };
|
|
387
538
|
addWrite(plan, location[name], template, "create", name);
|
|
388
|
-
|
|
389
|
-
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const currentHash = digest(Buffer.from(current.text, "utf8"));
|
|
543
|
+
if (tracked && currentHash === tracked.hash) {
|
|
544
|
+
if (current.text === template) {
|
|
545
|
+
addMessage(plan, "skip", name, "already managed");
|
|
546
|
+
} else if ((LEGACY_TEMPLATES[name] || []).includes(current.text)) {
|
|
547
|
+
state.files[name].hash = digest(Buffer.from(template, "utf8"));
|
|
548
|
+
addWrite(plan, location[name], template, "update", `${name} migration`);
|
|
549
|
+
} else {
|
|
550
|
+
addMessage(plan, "preserve", name, "managed content is not a recognized package template");
|
|
551
|
+
}
|
|
390
552
|
} else {
|
|
391
553
|
addMessage(plan, "preserve", name, tracked ? "user-modified" : "pre-existing");
|
|
392
554
|
}
|
|
393
555
|
}
|
|
394
556
|
|
|
557
|
+
state.version = STATE_VERSION;
|
|
395
558
|
state.packageVersion = VERSION;
|
|
396
559
|
const existingState = await readText(location.state);
|
|
397
560
|
addWrite(plan, location.state, stateJson(state), existingState.exists ? "update" : "create", "state");
|
|
@@ -402,17 +565,27 @@ async function planUninstall(scope) {
|
|
|
402
565
|
const location = expectedPaths(scope);
|
|
403
566
|
const plan = { command: "uninstall", location, operations: [], messages: [], failed: false };
|
|
404
567
|
let loaded;
|
|
405
|
-
try {
|
|
568
|
+
try {
|
|
569
|
+
loaded = await loadState(location);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
failPlan(plan, error.message);
|
|
572
|
+
return plan;
|
|
573
|
+
}
|
|
406
574
|
if (!loaded.exists) {
|
|
407
575
|
addMessage(plan, "skip", "state", "not installed");
|
|
408
576
|
return plan;
|
|
409
577
|
}
|
|
410
578
|
const state = loaded.state;
|
|
411
579
|
const config = await readText(location.config);
|
|
580
|
+
|
|
412
581
|
if (Object.keys(state.config.values || {}).length && config.exists) {
|
|
413
582
|
let patched;
|
|
414
|
-
try {
|
|
415
|
-
|
|
583
|
+
try {
|
|
584
|
+
patched = patchUninstallConfig(config.text, state.config.values);
|
|
585
|
+
} catch (error) {
|
|
586
|
+
failPlan(plan, error.message);
|
|
587
|
+
return plan;
|
|
588
|
+
}
|
|
416
589
|
for (const key of patched.preserved) addMessage(plan, "preserve", key, "user-modified");
|
|
417
590
|
if (patched.changed) {
|
|
418
591
|
const empty = patched.content.replace(/^\uFEFF/, "").trim() === "";
|
|
@@ -430,69 +603,393 @@ async function planUninstall(scope) {
|
|
|
430
603
|
const tracked = state.files[name];
|
|
431
604
|
if (!tracked) continue;
|
|
432
605
|
const current = await readText(location[name]);
|
|
433
|
-
if (!current.exists)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
addDelete(plan, location[name], name);
|
|
437
|
-
} else {
|
|
438
|
-
addMessage(plan, "preserve", name, "user-modified");
|
|
439
|
-
}
|
|
606
|
+
if (!current.exists) addMessage(plan, "skip", name, "already missing");
|
|
607
|
+
else if (digest(Buffer.from(current.text, "utf8")) === tracked.hash) addDelete(plan, location[name], name);
|
|
608
|
+
else addMessage(plan, "preserve", name, "user-modified");
|
|
440
609
|
delete state.files[name];
|
|
441
610
|
}
|
|
442
611
|
|
|
443
612
|
if (state.backup) {
|
|
444
613
|
const backup = await readText(location.backup);
|
|
445
614
|
if (!backup.exists) addMessage(plan, "skip", "config backup", "already missing");
|
|
446
|
-
else if (
|
|
447
|
-
|
|
615
|
+
else if (digest(Buffer.from(backup.text, "utf8")) === state.backup.hash) {
|
|
616
|
+
addDelete(plan, location.backup, "config backup");
|
|
617
|
+
} else {
|
|
618
|
+
addMessage(plan, "preserve", "config backup", "user-modified");
|
|
619
|
+
}
|
|
448
620
|
state.backup = null;
|
|
449
621
|
}
|
|
450
622
|
|
|
451
623
|
addDelete(plan, location.state, "state");
|
|
452
|
-
for (const directory of [...state.createdDirs].sort((a, b) => b.length - a.length))
|
|
453
|
-
|
|
624
|
+
for (const directory of [...state.createdDirs].sort((a, b) => b.length - a.length)) {
|
|
625
|
+
addRemoveDirectory(plan, directory);
|
|
626
|
+
}
|
|
454
627
|
return plan;
|
|
455
628
|
}
|
|
456
629
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
630
|
+
function signatureFromBytes(file) {
|
|
631
|
+
return file.exists
|
|
632
|
+
? { exists: true, hash: digest(file.bytes) }
|
|
633
|
+
: { exists: false, hash: null };
|
|
460
634
|
}
|
|
461
635
|
|
|
462
|
-
async function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
636
|
+
async function signature(path) {
|
|
637
|
+
return signatureFromBytes(await readBytes(path));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function sameSignature(left, right) {
|
|
641
|
+
return Boolean(left?.exists) === Boolean(right?.exists) &&
|
|
642
|
+
(!left?.exists || left.hash === right.hash);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function inspectLock(location) {
|
|
646
|
+
const lock = await readText(location.lock);
|
|
647
|
+
if (!lock.exists) return { status: "none" };
|
|
648
|
+
let metadata;
|
|
467
649
|
try {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
650
|
+
metadata = JSON.parse(lock.text);
|
|
651
|
+
} catch {
|
|
652
|
+
return { status: "corrupt", detail: "lock file is not valid JSON" };
|
|
653
|
+
}
|
|
654
|
+
if (!Number.isInteger(metadata.pid) || metadata.pid <= 0 || typeof metadata.hostname !== "string") {
|
|
655
|
+
return { status: "corrupt", detail: "lock metadata is invalid" };
|
|
656
|
+
}
|
|
657
|
+
if (metadata.hostname !== hostname()) {
|
|
658
|
+
return { status: "active", metadata, detail: "lock belongs to another host" };
|
|
659
|
+
}
|
|
660
|
+
let alive = true;
|
|
661
|
+
try {
|
|
662
|
+
process.kill(metadata.pid, 0);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (["ESRCH", "EINVAL"].includes(error?.code)) alive = false;
|
|
665
|
+
else if (error?.code !== "EPERM") throw error;
|
|
666
|
+
}
|
|
667
|
+
return alive
|
|
668
|
+
? { status: "active", metadata }
|
|
669
|
+
: { status: "stale", metadata };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function acquireLock(location, command, output, env) {
|
|
673
|
+
let createdRoot = false;
|
|
674
|
+
if (!(await directoryExists(location.codexHome))) {
|
|
675
|
+
await mkdir(location.codexHome, { recursive: true });
|
|
676
|
+
createdRoot = true;
|
|
677
|
+
}
|
|
678
|
+
await validateLocationSafety(location);
|
|
679
|
+
|
|
680
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
681
|
+
const token = randomUUID();
|
|
682
|
+
let handle;
|
|
683
|
+
try {
|
|
684
|
+
handle = await open(location.lock, "wx", 0o600);
|
|
685
|
+
const metadata = {
|
|
686
|
+
version: 1,
|
|
687
|
+
token,
|
|
688
|
+
pid: process.pid,
|
|
689
|
+
hostname: hostname(),
|
|
690
|
+
command,
|
|
691
|
+
scope: location.scope,
|
|
692
|
+
startedAt: new Date().toISOString()
|
|
693
|
+
};
|
|
694
|
+
await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
695
|
+
await handle.sync();
|
|
696
|
+
await handle.close();
|
|
697
|
+
handle = undefined;
|
|
698
|
+
const hold = Number.parseInt(env?.[TEST_HOLD_LOCK_ENV] || "", 10);
|
|
699
|
+
if (env?.[TESTING_ENV] === "1" && Number.isFinite(hold) && hold > 0) {
|
|
700
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, hold));
|
|
701
|
+
}
|
|
702
|
+
return { token, createdRoot, command };
|
|
703
|
+
} catch (error) {
|
|
704
|
+
try { await handle?.close(); } catch {}
|
|
705
|
+
if (error?.code !== "EEXIST") throw error;
|
|
706
|
+
const lock = await inspectLock(location);
|
|
707
|
+
if (lock.status === "stale") {
|
|
708
|
+
const before = await readText(location.lock);
|
|
709
|
+
const again = await readText(location.lock);
|
|
710
|
+
if (before.exists && again.exists && before.text === again.text) {
|
|
711
|
+
await rm(location.lock, { force: true });
|
|
712
|
+
output?.(`recover: stale lock (${lock.metadata.command || "unknown"} pid ${lock.metadata.pid})`);
|
|
713
|
+
continue;
|
|
477
714
|
}
|
|
478
715
|
}
|
|
479
|
-
|
|
716
|
+
const detail = lock.metadata
|
|
717
|
+
? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid} since ${lock.metadata.startedAt || "unknown"}`
|
|
718
|
+
: lock.detail || "unknown lock owner";
|
|
719
|
+
throw new Error(`scope is locked by ${detail}`);
|
|
480
720
|
}
|
|
481
|
-
|
|
721
|
+
}
|
|
722
|
+
throw new Error("unable to acquire scope lock");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function releaseLock(location, lock) {
|
|
726
|
+
if (!lock) return;
|
|
727
|
+
try {
|
|
728
|
+
await validateLocationSafety(location);
|
|
729
|
+
const current = await readText(location.lock);
|
|
730
|
+
if (!current.exists) return;
|
|
731
|
+
const metadata = JSON.parse(current.text);
|
|
732
|
+
if (metadata.token === lock.token) await rm(location.lock, { force: true });
|
|
733
|
+
} catch {}
|
|
734
|
+
if (lock.createdRoot || lock.command === "uninstall") {
|
|
735
|
+
try { await rmdir(location.codexHome); } catch {}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function operationAfterSignature(operation) {
|
|
740
|
+
if (operation.kind === "write") {
|
|
741
|
+
return { exists: true, hash: digest(operation.content) };
|
|
742
|
+
}
|
|
743
|
+
if (operation.kind === "delete") {
|
|
744
|
+
return { exists: false, hash: null };
|
|
745
|
+
}
|
|
746
|
+
throw new Error(`unknown operation kind: ${operation.kind}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function validateJournal(raw, location) {
|
|
750
|
+
if (!raw || typeof raw !== "object" || raw.version !== 1) {
|
|
751
|
+
throw new Error("unsupported transaction journal");
|
|
752
|
+
}
|
|
753
|
+
if (raw.scope !== location.scope || typeof raw.id !== "string" || !Array.isArray(raw.operations)) {
|
|
754
|
+
throw new Error("invalid transaction journal structure");
|
|
755
|
+
}
|
|
756
|
+
if (!["preparing", "ready", "applying"].includes(raw.status)) {
|
|
757
|
+
throw new Error("invalid transaction status");
|
|
758
|
+
}
|
|
759
|
+
for (let index = 0; index < raw.operations.length; index += 1) {
|
|
760
|
+
const operation = raw.operations[index];
|
|
761
|
+
if (!["write", "delete"].includes(operation.kind)) {
|
|
762
|
+
throw new Error(`invalid transaction operation ${index}`);
|
|
763
|
+
}
|
|
764
|
+
if (typeof operation.path !== "string" || !isAbsolute(operation.path)) {
|
|
765
|
+
throw new Error(`invalid transaction path ${index}`);
|
|
766
|
+
}
|
|
767
|
+
const expected = isWithin(operation.path, location.skillsRoot)
|
|
768
|
+
? location.skillsRoot
|
|
769
|
+
: location.codexHome;
|
|
770
|
+
if (!isWithin(operation.path, expected)) throw new Error(`unsafe transaction path: ${operation.path}`);
|
|
771
|
+
if (!operation.before || typeof operation.before.exists !== "boolean") {
|
|
772
|
+
throw new Error(`invalid transaction snapshot ${index}`);
|
|
773
|
+
}
|
|
774
|
+
if (operation.before.exists && !/^[a-f0-9]{64}$/.test(operation.before.hash || "")) {
|
|
775
|
+
throw new Error(`invalid transaction hash ${index}`);
|
|
776
|
+
}
|
|
777
|
+
if (operation.before.exists && !operation.snapshot) {
|
|
778
|
+
throw new Error(`missing transaction snapshot ${index}`);
|
|
779
|
+
}
|
|
780
|
+
if (operation.snapshot) {
|
|
781
|
+
if (!/^[0-9]{4}\.snapshot$/.test(operation.snapshot)) {
|
|
782
|
+
throw new Error(`invalid transaction snapshot name ${index}`);
|
|
783
|
+
}
|
|
784
|
+
const snapshotRoot = join(location.transactionData, raw.id);
|
|
785
|
+
const snapshot = join(snapshotRoot, operation.snapshot);
|
|
786
|
+
if (!isWithin(snapshot, snapshotRoot)) throw new Error(`unsafe transaction snapshot ${index}`);
|
|
787
|
+
await assertNoIndirection(snapshot, snapshotRoot, location.codexHome, `transaction snapshot ${index}`);
|
|
788
|
+
const snapshotFile = await readBytes(snapshot);
|
|
789
|
+
if (!snapshotFile.exists || digest(snapshotFile.bytes) !== operation.before.hash) {
|
|
790
|
+
throw new Error(`corrupt transaction snapshot ${index}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (!operation.after || typeof operation.after.exists !== "boolean") {
|
|
794
|
+
throw new Error(`invalid transaction target ${index}`);
|
|
795
|
+
}
|
|
796
|
+
if (operation.after.exists && !/^[a-f0-9]{64}$/.test(operation.after.hash || "")) {
|
|
797
|
+
throw new Error(`invalid transaction target hash ${index}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return raw;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function readJournal(location) {
|
|
804
|
+
const file = await readText(location.journal);
|
|
805
|
+
if (!file.exists) return { exists: false };
|
|
806
|
+
let raw;
|
|
807
|
+
try {
|
|
808
|
+
raw = JSON.parse(file.text);
|
|
809
|
+
} catch {
|
|
810
|
+
return { exists: true, classification: "corrupt", detail: "journal is not valid JSON" };
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
const journal = await validateJournal(raw, location);
|
|
814
|
+
return { exists: true, journal };
|
|
482
815
|
} catch (error) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
816
|
+
return { exists: true, classification: "corrupt", detail: error.message };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function classifyJournal(location) {
|
|
821
|
+
const loaded = await readJournal(location);
|
|
822
|
+
if (!loaded.exists || loaded.classification === "corrupt") return loaded;
|
|
823
|
+
const journal = loaded.journal;
|
|
824
|
+
if (journal.status === "preparing") {
|
|
825
|
+
return {
|
|
826
|
+
exists: true,
|
|
827
|
+
classification: "unfinished",
|
|
828
|
+
detail: "preparation was interrupted before managed writes",
|
|
829
|
+
journal
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const states = [];
|
|
833
|
+
for (const operation of journal.operations) {
|
|
834
|
+
const current = await signature(operation.path);
|
|
835
|
+
if (sameSignature(current, operation.before)) states.push("before");
|
|
836
|
+
else if (sameSignature(current, operation.after)) states.push("after");
|
|
837
|
+
else states.push("conflict");
|
|
838
|
+
}
|
|
839
|
+
const conflictIndex = states.indexOf("conflict");
|
|
840
|
+
if (conflictIndex >= 0) {
|
|
841
|
+
return {
|
|
842
|
+
exists: true,
|
|
843
|
+
classification: "conflicting",
|
|
844
|
+
detail: `managed path changed after interruption: ${journal.operations[conflictIndex].path}`,
|
|
845
|
+
journal,
|
|
846
|
+
states
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
exists: true,
|
|
851
|
+
classification: "recoverable",
|
|
852
|
+
detail: `${states.filter((state) => state === "after").length} applied operation(s) can be rolled back`,
|
|
853
|
+
journal,
|
|
854
|
+
states
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function cleanupTransaction(location, journal) {
|
|
859
|
+
if (journal?.id) await rm(join(location.transactionData, journal.id), { recursive: true, force: true });
|
|
860
|
+
await rm(location.journal, { force: true });
|
|
861
|
+
try { await rmdir(location.transactionData); } catch {}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function recoverTransaction(location, output) {
|
|
865
|
+
const classified = await classifyJournal(location);
|
|
866
|
+
if (!classified.exists) return;
|
|
867
|
+
if (classified.classification === "corrupt") {
|
|
868
|
+
throw new Error(`corrupt transaction: ${classified.detail}`);
|
|
869
|
+
}
|
|
870
|
+
if (classified.classification === "conflicting") {
|
|
871
|
+
throw new Error(`conflicting transaction: ${classified.detail}`);
|
|
872
|
+
}
|
|
873
|
+
const journal = classified.journal;
|
|
874
|
+
if (classified.classification === "unfinished") {
|
|
875
|
+
await cleanupTransaction(location, journal);
|
|
876
|
+
output?.("recover: unfinished transaction (no managed writes were applied)");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
for (let index = journal.operations.length - 1; index >= 0; index -= 1) {
|
|
881
|
+
await validateLocationSafety(location);
|
|
882
|
+
const operation = journal.operations[index];
|
|
883
|
+
const current = await signature(operation.path);
|
|
884
|
+
if (sameSignature(current, operation.before)) continue;
|
|
885
|
+
if (!sameSignature(current, operation.after)) {
|
|
886
|
+
throw new Error(`conflicting transaction during recovery: ${operation.path}`);
|
|
887
|
+
}
|
|
888
|
+
if (!operation.before.exists) {
|
|
889
|
+
await rm(operation.path, { force: true });
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const snapshot = join(location.transactionData, journal.id, operation.snapshot);
|
|
893
|
+
const bytes = await readFile(snapshot);
|
|
894
|
+
if (digest(bytes) !== operation.before.hash) {
|
|
895
|
+
throw new Error(`corrupt transaction snapshot: ${operation.path}`);
|
|
896
|
+
}
|
|
897
|
+
await atomicWriteBytes(operation.path, bytes);
|
|
898
|
+
}
|
|
899
|
+
await cleanupTransaction(location, journal);
|
|
900
|
+
output?.("recover: interrupted transaction rolled back");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function prepareJournal(plan) {
|
|
904
|
+
const id = randomUUID();
|
|
905
|
+
const dataRoot = join(plan.location.transactionData, id);
|
|
906
|
+
const operations = [];
|
|
907
|
+
const journal = {
|
|
908
|
+
version: 1,
|
|
909
|
+
id,
|
|
910
|
+
scope: plan.location.scope,
|
|
911
|
+
command: plan.command,
|
|
912
|
+
createdAt: new Date().toISOString(),
|
|
913
|
+
status: "preparing",
|
|
914
|
+
recovery: "rollback",
|
|
915
|
+
operations
|
|
916
|
+
};
|
|
917
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
918
|
+
await mkdir(dataRoot, { recursive: true });
|
|
919
|
+
|
|
920
|
+
const transactionalOperations = plan.operations.filter((operation) => operation.kind !== "rmdir");
|
|
921
|
+
for (let index = 0; index < transactionalOperations.length; index += 1) {
|
|
922
|
+
const operation = transactionalOperations[index];
|
|
923
|
+
let before;
|
|
924
|
+
let snapshot = null;
|
|
925
|
+
const current = await readBytes(operation.path);
|
|
926
|
+
before = signatureFromBytes(current);
|
|
927
|
+
if (current.exists) {
|
|
928
|
+
snapshot = `${String(index).padStart(4, "0")}.snapshot`;
|
|
929
|
+
const snapshotPath = join(dataRoot, snapshot);
|
|
930
|
+
await writeFile(snapshotPath, current.bytes, { mode: 0o600, flag: "wx" });
|
|
931
|
+
}
|
|
932
|
+
operations.push({
|
|
933
|
+
kind: operation.kind,
|
|
934
|
+
path: operation.path,
|
|
935
|
+
label: operation.label || "",
|
|
936
|
+
before,
|
|
937
|
+
after: operationAfterSignature(operation),
|
|
938
|
+
snapshot,
|
|
939
|
+
done: false
|
|
940
|
+
});
|
|
941
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
942
|
+
}
|
|
943
|
+
journal.status = "ready";
|
|
944
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
945
|
+
return journal;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function applyOperation(operation) {
|
|
949
|
+
if (operation.kind === "write") await atomicWriteBytes(operation.path, operation.content);
|
|
950
|
+
else if (operation.kind === "delete") await rm(operation.path, { force: true });
|
|
951
|
+
else if (operation.kind === "rmdir") {
|
|
952
|
+
try {
|
|
953
|
+
await rmdir(operation.path);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
throw new Error(`unknown operation kind: ${operation.kind}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function executePlan(plan, env) {
|
|
963
|
+
if (plan.failed) return { ok: false };
|
|
964
|
+
if (!plan.operations.length) return { ok: true };
|
|
965
|
+
let journal;
|
|
966
|
+
try {
|
|
967
|
+
const transactionalOperations = plan.operations.filter((operation) => operation.kind !== "rmdir");
|
|
968
|
+
const directoryOperations = plan.operations.filter((operation) => operation.kind === "rmdir");
|
|
969
|
+
if (transactionalOperations.length) {
|
|
970
|
+
journal = await prepareJournal(plan);
|
|
971
|
+
journal.status = "applying";
|
|
972
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
973
|
+
for (let index = 0; index < transactionalOperations.length; index += 1) {
|
|
974
|
+
await validateLocationSafety(plan.location);
|
|
975
|
+
await applyOperation(transactionalOperations[index]);
|
|
976
|
+
journal.operations[index].done = true;
|
|
977
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
978
|
+
const crashAfter = Number.parseInt(env?.[TEST_CRASH_ENV] || "", 10);
|
|
979
|
+
if (env?.[TESTING_ENV] === "1" && crashAfter === index + 1) process.exit(91);
|
|
487
980
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
981
|
+
await cleanupTransaction(plan.location, journal);
|
|
982
|
+
}
|
|
983
|
+
for (const operation of directoryOperations) {
|
|
984
|
+
await validateLocationSafety(plan.location);
|
|
985
|
+
await applyOperation(operation);
|
|
493
986
|
}
|
|
494
|
-
|
|
495
|
-
|
|
987
|
+
return { ok: true };
|
|
988
|
+
} catch (error) {
|
|
989
|
+
try {
|
|
990
|
+
await recoverTransaction(plan.location);
|
|
991
|
+
} catch (recoveryError) {
|
|
992
|
+
return { ok: false, error: new Error(`${error.message}; recovery failed: ${recoveryError.message}`) };
|
|
496
993
|
}
|
|
497
994
|
return { ok: false, error };
|
|
498
995
|
}
|
|
@@ -510,8 +1007,7 @@ function printPlan(plan, output, dryRun) {
|
|
|
510
1007
|
}
|
|
511
1008
|
|
|
512
1009
|
function getAgentValues(content) {
|
|
513
|
-
|
|
514
|
-
return rootAssignments(scan);
|
|
1010
|
+
return rootAssignments(scanToml(content));
|
|
515
1011
|
}
|
|
516
1012
|
|
|
517
1013
|
function getString(assignments, key) {
|
|
@@ -526,7 +1022,7 @@ function validateSkill(content) {
|
|
|
526
1022
|
const body = match[2];
|
|
527
1023
|
if (!/^name:\s*model-router\s*$/m.test(front)) return "incorrect skill name";
|
|
528
1024
|
if (!/^description:\s*\S.+$/m.test(front)) return "missing skill description";
|
|
529
|
-
const requirements = [/Terra/i, /Luna/i, /Sol/i, /
|
|
1025
|
+
const requirements = [/Terra/i, /Luna/i, /Sol/i, /workspace-write/i, /simple question/i, /minimum number|fewest/i];
|
|
530
1026
|
if (requirements.some((pattern) => !pattern.test(body))) return "missing required routing rule";
|
|
531
1027
|
return null;
|
|
532
1028
|
}
|
|
@@ -540,9 +1036,47 @@ async function doctor(scope) {
|
|
|
540
1036
|
const location = expectedPaths(scope);
|
|
541
1037
|
const messages = [];
|
|
542
1038
|
let healthy = true;
|
|
1039
|
+
try {
|
|
1040
|
+
await validateLocationSafety(location);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
doctorStatus(messages, "unsafe-state", "path", error.message);
|
|
1043
|
+
return { healthy: false, messages };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const lock = await inspectLock(location);
|
|
1047
|
+
if (lock.status === "active") {
|
|
1048
|
+
healthy = doctorStatus(
|
|
1049
|
+
messages,
|
|
1050
|
+
"locked",
|
|
1051
|
+
"scope",
|
|
1052
|
+
`${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
|
|
1053
|
+
) && healthy;
|
|
1054
|
+
} else if (lock.status === "stale") {
|
|
1055
|
+
healthy = doctorStatus(messages, "stale-lock", "scope", `pid ${lock.metadata.pid}`) && healthy;
|
|
1056
|
+
} else if (lock.status === "corrupt") {
|
|
1057
|
+
healthy = doctorStatus(messages, "unsafe-state", "lock", lock.detail) && healthy;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const transaction = await classifyJournal(location);
|
|
1061
|
+
if (transaction.exists) {
|
|
1062
|
+
const mapping = {
|
|
1063
|
+
unfinished: "unfinished",
|
|
1064
|
+
recoverable: "recoverable",
|
|
1065
|
+
conflicting: "conflicting",
|
|
1066
|
+
corrupt: "corrupt"
|
|
1067
|
+
};
|
|
1068
|
+
healthy = doctorStatus(
|
|
1069
|
+
messages,
|
|
1070
|
+
mapping[transaction.classification] || "invalid",
|
|
1071
|
+
"transaction",
|
|
1072
|
+
transaction.detail
|
|
1073
|
+
) && healthy;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
543
1076
|
let loaded;
|
|
544
|
-
try {
|
|
545
|
-
|
|
1077
|
+
try {
|
|
1078
|
+
loaded = await loadState(location);
|
|
1079
|
+
} catch (error) {
|
|
546
1080
|
doctorStatus(messages, "unsafe-state", "state", error.message);
|
|
547
1081
|
return { healthy: false, messages };
|
|
548
1082
|
}
|
|
@@ -555,15 +1089,14 @@ async function doctor(scope) {
|
|
|
555
1089
|
|
|
556
1090
|
const config = await readText(location.config);
|
|
557
1091
|
if (!config.exists) {
|
|
558
|
-
healthy = doctorStatus(messages, "missing", "config.toml");
|
|
1092
|
+
healthy = doctorStatus(messages, "missing", "config.toml") && healthy;
|
|
559
1093
|
} else {
|
|
560
1094
|
try {
|
|
561
1095
|
const scan = scanToml(config.text);
|
|
562
1096
|
for (const [key, expected] of Object.entries(DEFAULTS)) {
|
|
563
1097
|
const assignment = scan.targets.get(key);
|
|
564
|
-
if (!assignment)
|
|
565
|
-
|
|
566
|
-
} else if (assignment.parsedValue.kind !== "string") {
|
|
1098
|
+
if (!assignment) healthy = doctorStatus(messages, "missing", key) && healthy;
|
|
1099
|
+
else if (assignment.parsedValue.kind !== "string") {
|
|
567
1100
|
healthy = doctorStatus(messages, "invalid", key, "must be a TOML string") && healthy;
|
|
568
1101
|
} else if (assignment.parsedValue.value !== expected) {
|
|
569
1102
|
const status = state.config.values[key] ? "user-modified" : "user-override";
|
|
@@ -585,11 +1118,9 @@ async function doctor(scope) {
|
|
|
585
1118
|
}
|
|
586
1119
|
try {
|
|
587
1120
|
const values = getAgentValues(current.text);
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const invalid = Object.entries(expected).find(([key, value]) => getString(values, key) !== value);
|
|
592
|
-
if (state.files[name] && hash(current.text) !== state.files[name].hash) {
|
|
1121
|
+
const invalid = Object.entries(AGENT_EXPECTATIONS[name])
|
|
1122
|
+
.find(([key, value]) => getString(values, key) !== value);
|
|
1123
|
+
if (state.files[name] && digest(Buffer.from(current.text, "utf8")) !== state.files[name].hash) {
|
|
593
1124
|
const detail = invalid ? `${invalid[0]} is no longer ${invalid[1]}` : "managed file hash changed";
|
|
594
1125
|
healthy = doctorStatus(messages, "user-modified", name, detail) && healthy;
|
|
595
1126
|
} else if (invalid) {
|
|
@@ -603,27 +1134,39 @@ async function doctor(scope) {
|
|
|
603
1134
|
}
|
|
604
1135
|
|
|
605
1136
|
const skill = await readText(location.skill);
|
|
606
|
-
if (!skill.exists)
|
|
607
|
-
|
|
1137
|
+
if (!skill.exists) {
|
|
1138
|
+
healthy = doctorStatus(messages, "missing", "skill") && healthy;
|
|
1139
|
+
} else {
|
|
608
1140
|
const invalid = validateSkill(skill.text);
|
|
609
|
-
if (state.files.skill &&
|
|
1141
|
+
if (state.files.skill && digest(Buffer.from(skill.text, "utf8")) !== state.files.skill.hash) {
|
|
610
1142
|
healthy = doctorStatus(messages, "user-modified", "skill", invalid || "managed file hash changed") && healthy;
|
|
611
|
-
} else if (invalid)
|
|
612
|
-
|
|
1143
|
+
} else if (invalid) {
|
|
1144
|
+
healthy = doctorStatus(messages, "invalid", "skill", invalid) && healthy;
|
|
1145
|
+
} else {
|
|
1146
|
+
doctorStatus(messages, "healthy", "skill");
|
|
1147
|
+
}
|
|
613
1148
|
}
|
|
614
1149
|
|
|
615
1150
|
if (state.backup) {
|
|
616
1151
|
const backup = await readText(location.backup);
|
|
617
1152
|
if (!backup.exists) healthy = doctorStatus(messages, "missing", "config backup") && healthy;
|
|
618
|
-
else if (
|
|
619
|
-
|
|
1153
|
+
else if (digest(Buffer.from(backup.text, "utf8")) !== state.backup.hash) {
|
|
1154
|
+
healthy = doctorStatus(messages, "user-modified", "config backup") && healthy;
|
|
1155
|
+
} else {
|
|
1156
|
+
doctorStatus(messages, "healthy", "config backup");
|
|
1157
|
+
}
|
|
620
1158
|
}
|
|
621
|
-
|
|
622
1159
|
return { healthy, messages };
|
|
623
1160
|
}
|
|
624
1161
|
|
|
625
1162
|
function usage() {
|
|
626
|
-
return `codex-model-router ${VERSION}
|
|
1163
|
+
return `codex-model-router ${VERSION}
|
|
1164
|
+
|
|
1165
|
+
Usage:
|
|
1166
|
+
codex-model-router install [--global] [--set-default] [--dry-run]
|
|
1167
|
+
codex-model-router uninstall [--global] [--dry-run]
|
|
1168
|
+
codex-model-router doctor [--global]
|
|
1169
|
+
codex-model-router --version`;
|
|
627
1170
|
}
|
|
628
1171
|
|
|
629
1172
|
function parseArgs(argv) {
|
|
@@ -641,37 +1184,85 @@ function parseArgs(argv) {
|
|
|
641
1184
|
return { command, flags };
|
|
642
1185
|
}
|
|
643
1186
|
|
|
1187
|
+
async function reportNonMutatingBlockers(location, output) {
|
|
1188
|
+
const lock = await inspectLock(location);
|
|
1189
|
+
if (lock.status !== "none") {
|
|
1190
|
+
const detail = lock.metadata
|
|
1191
|
+
? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
|
|
1192
|
+
: lock.detail;
|
|
1193
|
+
const status = lock.status === "stale"
|
|
1194
|
+
? "stale-lock"
|
|
1195
|
+
: lock.status === "corrupt" ? "unsafe-state" : "locked";
|
|
1196
|
+
output(`${status}: scope (${detail})`);
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
const transaction = await classifyJournal(location);
|
|
1200
|
+
if (transaction.exists) {
|
|
1201
|
+
output(`${transaction.classification}: transaction (${transaction.detail})`);
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
644
1207
|
export async function run(argv, options = {}) {
|
|
645
1208
|
const output = options.output ?? console.log;
|
|
646
1209
|
const parsed = parseArgs(argv);
|
|
647
|
-
if (parsed.version) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1210
|
+
if (parsed.version) {
|
|
1211
|
+
output(VERSION);
|
|
1212
|
+
return 0;
|
|
1213
|
+
}
|
|
1214
|
+
if (parsed.help) {
|
|
1215
|
+
output(usage());
|
|
1216
|
+
return 0;
|
|
1217
|
+
}
|
|
1218
|
+
if (parsed.error) {
|
|
1219
|
+
output(`${parsed.error}\n\n${usage()}`);
|
|
1220
|
+
return 1;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
try {
|
|
1224
|
+
const scope = await canonicalizeScope({
|
|
651
1225
|
cwd: options.cwd ?? process.cwd(),
|
|
652
1226
|
home: options.home,
|
|
653
1227
|
env: options.env ?? process.env,
|
|
654
1228
|
global: parsed.flags.global
|
|
655
|
-
};
|
|
656
|
-
|
|
657
|
-
|
|
1229
|
+
});
|
|
1230
|
+
const location = expectedPaths(scope);
|
|
1231
|
+
await validateLocationSafety(location);
|
|
658
1232
|
if (parsed.command === "doctor") {
|
|
659
1233
|
const result = await doctor(scope);
|
|
660
1234
|
for (const message of result.messages) output(formatMessage(message, false));
|
|
661
1235
|
return result.healthy ? 0 : 1;
|
|
662
1236
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1237
|
+
|
|
1238
|
+
if (parsed.flags.dryRun) {
|
|
1239
|
+
if (!(await reportNonMutatingBlockers(location, output))) return 1;
|
|
1240
|
+
const plan = parsed.command === "install"
|
|
1241
|
+
? await planInstall(scope, parsed.flags)
|
|
1242
|
+
: await planUninstall(scope);
|
|
1243
|
+
printPlan(plan, output, true);
|
|
1244
|
+
return plan.failed ? 1 : 0;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
let lock;
|
|
1248
|
+
try {
|
|
1249
|
+
lock = await acquireLock(location, parsed.command, output, scope.env);
|
|
1250
|
+
await validateLocationSafety(location);
|
|
1251
|
+
await recoverTransaction(location, output);
|
|
1252
|
+
const plan = parsed.command === "install"
|
|
1253
|
+
? await planInstall(scope, parsed.flags)
|
|
1254
|
+
: await planUninstall(scope);
|
|
1255
|
+
printPlan(plan, output, false);
|
|
1256
|
+
if (plan.failed) return 1;
|
|
1257
|
+
const result = await executePlan(plan, scope.env);
|
|
1258
|
+
if (!result.ok) {
|
|
1259
|
+
output(`fail: execution (${result.error?.message || "unknown error"})`);
|
|
1260
|
+
return 1;
|
|
1261
|
+
}
|
|
1262
|
+
return 0;
|
|
1263
|
+
} finally {
|
|
1264
|
+
await releaseLock(location, lock);
|
|
673
1265
|
}
|
|
674
|
-
return 0;
|
|
675
1266
|
} catch (error) {
|
|
676
1267
|
output(`fail: ${error.message}`);
|
|
677
1268
|
return 1;
|