@codedology/code-forge 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -65,6 +65,8 @@ import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } f
|
|
|
65
65
|
import { hasAnyRenderedRule } from '../render-rules.mjs';
|
|
66
66
|
import { assertEffortForProvider, PROVIDER_EFFORTS } from '../efforts.mjs';
|
|
67
67
|
import { assertBaseParams } from './validate-params.mjs';
|
|
68
|
+
import { checkSandboxDirs, DEFAULT_SANDBOX_PROFILE, SandboxDirError, sandboxRefusalMessage, SANDBOX_PROFILES } from '../sandbox-dirs.mjs';
|
|
69
|
+
import { tmpBase } from '../../util/tmp.mjs';
|
|
68
70
|
import { CODEX_CLOSED_BOOK_REFUSAL, isNeverOpenBookRole, isNoToolRole } from '../../config/closed-book.mjs';
|
|
69
71
|
|
|
70
72
|
export const CLI = 'codex';
|
|
@@ -123,6 +125,11 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
|
|
|
123
125
|
* @property {string} [codexHome] - coder role only: the per-session Codex home to create (absolute,
|
|
124
126
|
* must not exist as the real `~/.codex` or inside it); defaults to `defaultCodexHome()` under the
|
|
125
127
|
* run temp root.
|
|
128
|
+
* @property {{profile?: string, add_dirs?: string[]}} [sandbox] - B93, coder role only: the
|
|
129
|
+
* `coder.sandbox` config value. Absent ⇒ today's argv. `toolchain` adds one `--add-dir <abs>`
|
|
130
|
+
* per checked `add_dirs` entry; `full` builds `-s danger-full-access`. A refused entry throws
|
|
131
|
+
* a `SandboxDirError` (code `sandbox-dir-refused`) naming the key path, never the value.
|
|
132
|
+
* @property {string} [runRoot] - B93, coder role only: the run temp root, refused as an `add_dirs` tree.
|
|
126
133
|
*/
|
|
127
134
|
|
|
128
135
|
/**
|
|
@@ -158,13 +165,23 @@ function assertValidEffort(effort) {
|
|
|
158
165
|
/**
|
|
159
166
|
* The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
|
|
160
167
|
* the doctor's flags probe builds it with placeholder values for its static conflict check.
|
|
161
|
-
*
|
|
168
|
+
* B93: `profile` `full` swaps `-s workspace-write` for `-s danger-full-access`; `addDirs` (already
|
|
169
|
+
* checked and resolved, `toolchain` only) follow `-s workspace-write` as `--add-dir <abs>` pairs.
|
|
170
|
+
* The tmp exclusions stay in every profile: under `danger-full-access` Codex ignores the
|
|
171
|
+
* `sandbox_workspace_write.*` keys, and one argv shape keeps the forbidden-list check the same.
|
|
172
|
+
* @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string, profile?: string, addDirs?: ReadonlyArray<string>}} p
|
|
162
173
|
* @returns {string[]}
|
|
163
174
|
*/
|
|
164
|
-
export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
|
|
175
|
+
export function coderFlagArgv({ model, effort, cwd, outPath, promptPath, profile = DEFAULT_SANDBOX_PROFILE, addDirs = [] }) {
|
|
176
|
+
if (!SANDBOX_PROFILES.includes(profile)) throw new TypeError(`coderFlagArgv: profile must be one of ${SANDBOX_PROFILES.join(', ')}`);
|
|
165
177
|
const argv = ['codex', 'exec', '-m', model];
|
|
166
178
|
if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
|
|
167
|
-
|
|
179
|
+
if (profile === 'full') {
|
|
180
|
+
argv.push('-s', 'danger-full-access');
|
|
181
|
+
} else {
|
|
182
|
+
argv.push('-s', 'workspace-write');
|
|
183
|
+
if (profile === 'toolchain') for (const dir of addDirs) argv.push('--add-dir', dir);
|
|
184
|
+
}
|
|
168
185
|
// INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
|
|
169
186
|
// sandbox must not make $TMPDIR or /tmp writable — the project cwd is then the only writable
|
|
170
187
|
// root, and a home inside the cwd is refused below. See `../codex-home.mjs`.
|
|
@@ -178,6 +195,26 @@ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
|
|
|
178
195
|
return argv;
|
|
179
196
|
}
|
|
180
197
|
|
|
198
|
+
/**
|
|
199
|
+
* B93: the checked sandbox of one coder build. Absent ⇒ the default profile with no extra dir.
|
|
200
|
+
* The `add_dirs` are checked here too (defence in depth: validation checks them first), against
|
|
201
|
+
* the protected home dirs plus `extra` (the session CODEX_HOME, the run temp root and base).
|
|
202
|
+
* @param {unknown} sandbox @param {ReadonlyArray<string | undefined>} extra
|
|
203
|
+
* @returns {{profile: string, addDirs: string[]}}
|
|
204
|
+
*/
|
|
205
|
+
function coderSandbox(sandbox, extra) {
|
|
206
|
+
if (sandbox === undefined || sandbox === null) return { profile: DEFAULT_SANDBOX_PROFILE, addDirs: [] };
|
|
207
|
+
if (typeof sandbox !== 'object' || Array.isArray(sandbox)) throw new TypeError('buildCodexArgv: coder.sandbox must be a mapping');
|
|
208
|
+
const s = /** @type {{profile?: unknown, add_dirs?: unknown}} */ (sandbox);
|
|
209
|
+
const profile = s.profile ?? DEFAULT_SANDBOX_PROFILE;
|
|
210
|
+
if (typeof profile !== 'string' || !SANDBOX_PROFILES.includes(profile)) {
|
|
211
|
+
throw new TypeError(`buildCodexArgv: coder.sandbox.profile must be one of ${SANDBOX_PROFILES.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
const checked = checkSandboxDirs(s.add_dirs, { extra });
|
|
214
|
+
if ('keyPath' in checked) throw new SandboxDirError(sandboxRefusalMessage(checked));
|
|
215
|
+
return { profile, addDirs: profile === 'toolchain' ? checked.dirs : [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
181
218
|
/**
|
|
182
219
|
* @param {CodexBuildParams} params
|
|
183
220
|
* @returns {{cli: "codex", role: "coder", argv: string[], cwd: string, outPath: string, env: {CODEX_HOME: string}, forbiddenRendered: ReadonlyArray<RenderedCodexEntry>, rulesFile: {fileName: string, path: string, content: string, count: number}}}
|
|
@@ -189,7 +226,6 @@ function buildCoderArgv(params) {
|
|
|
189
226
|
if (!hasAnyRenderedRule(renderedForbidden)) {
|
|
190
227
|
throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
|
|
191
228
|
}
|
|
192
|
-
const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath });
|
|
193
229
|
const codexHome = params.codexHome ?? defaultCodexHome();
|
|
194
230
|
// Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
|
|
195
231
|
// below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
|
|
@@ -202,6 +238,10 @@ function buildCoderArgv(params) {
|
|
|
202
238
|
if (isInside(codexHome, cwd)) {
|
|
203
239
|
throw new Error('buildCodexArgv: the session CODEX_HOME must not be inside the coder cwd (the sandbox can write there)');
|
|
204
240
|
}
|
|
241
|
+
// B93 (fix round 1): the sandbox dirs are checked only after the home is known to be absolute
|
|
242
|
+
// and outside the cwd, so the home they are checked against is the one the session will use.
|
|
243
|
+
const sandbox = coderSandbox(params.sandbox, [codexHome, params.runRoot, tmpBase()]);
|
|
244
|
+
const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath, ...sandbox });
|
|
205
245
|
const home = prepareCodexHome(codexHome, renderedForbidden);
|
|
206
246
|
return {
|
|
207
247
|
cli: CLI,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Codex coder sandbox profile (block B93): `coder.sandbox.{profile, add_dirs}`.
|
|
3
|
+
*
|
|
4
|
+
* Facts (Codex 0.155.1, `test/fixtures/help/codex-0.155.1.txt`): `--add-dir <DIR>` = "Additional
|
|
5
|
+
* directories that should be writable alongside the primary workspace"; `-s` takes `read-only`,
|
|
6
|
+
* `workspace-write` or `danger-full-access`.
|
|
7
|
+
*
|
|
8
|
+
* Profiles:
|
|
9
|
+
* - `workspace` (the default, and what no `coder` key means): today's argv, `-s workspace-write`
|
|
10
|
+
* with the project `-C <cwd>` as the only writable root.
|
|
11
|
+
* - `toolchain`: the same, plus one `--add-dir <abs>` per `add_dirs` entry (a build cache, a
|
|
12
|
+
* package store), right after `-s workspace-write`.
|
|
13
|
+
* - `full`: `-s danger-full-access`. Codex writes anywhere the user can; like the Claude coder
|
|
14
|
+
* (bypassPermissions), the forbidden list, the transcript grep and the gates are the limits.
|
|
15
|
+
*
|
|
16
|
+
* Every `add_dirs` entry is absolute or starts with `~/` (expanded against the process HOME). It is
|
|
17
|
+
* resolved component by component: each symlink is replaced by its target (bounded; a loop is
|
|
18
|
+
* refused), `..` steps up from the resolved dir, a missing tail is kept as written, and the
|
|
19
|
+
* existing part is the native realpath (true case). So a symlink, even a dangling one, is judged
|
|
20
|
+
* by its target. Matching folds case on macOS and Windows. An entry is REFUSED (`sandbox-dir-refused`) when it is `/`, the home dir itself, or is,
|
|
21
|
+
* contains or sits inside: `~/.code-forge`, `~/.codex` (and `$CODEX_HOME`), `~/.ssh`, `~/.gnupg`,
|
|
22
|
+
* `~/.aws`, `~/.config`, `~/Library/Keychains`, the run temp base / run temp root, or the session
|
|
23
|
+
* CODEX_HOME. A refusal names the key path (`coder.sandbox.add_dirs[<i>]`), never the value.
|
|
24
|
+
*
|
|
25
|
+
* Pure apart from `lstat`/`readlink`/`realpath`; it never creates, lists or writes a directory.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
/** The profiles, in the order the docs list them. */
|
|
33
|
+
export const SANDBOX_PROFILES = Object.freeze(['workspace', 'toolchain', 'full']);
|
|
34
|
+
|
|
35
|
+
/** The default profile: today's argv. */
|
|
36
|
+
export const DEFAULT_SANDBOX_PROFILE = 'workspace';
|
|
37
|
+
|
|
38
|
+
/** The error code a refused `add_dirs` entry carries (validation rule and builder). */
|
|
39
|
+
export const SANDBOX_DIR_REFUSED = 'sandbox-dir-refused';
|
|
40
|
+
|
|
41
|
+
/** Home-relative directories no coder sandbox may open (nor any dir that holds or sits in one). */
|
|
42
|
+
export const PROTECTED_HOME_DIRS = Object.freeze(['.code-forge', '.codex', '.ssh', '.gnupg', '.aws', '.config', path.join('Library', 'Keychains')]);
|
|
43
|
+
|
|
44
|
+
/** An `Error` with a `code` of {@link SANDBOX_DIR_REFUSED}. */
|
|
45
|
+
export class SandboxDirError extends Error {
|
|
46
|
+
/** @param {string} message */
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'SandboxDirError';
|
|
50
|
+
this.code = SANDBOX_DIR_REFUSED;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The most symlinks one entry may pass through before it is refused (as the kernel's ELOOP). */
|
|
55
|
+
const MAX_LINK_HOPS = 40;
|
|
56
|
+
|
|
57
|
+
/** A path whose symlinks cannot be followed (a loop, too many hops, an unreadable link). */
|
|
58
|
+
class Unresolvable extends Error {}
|
|
59
|
+
|
|
60
|
+
/** @param {string} p @returns {string} the native realpath (true case), or `p` when it fails. */
|
|
61
|
+
function native(p) {
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync.native(p);
|
|
64
|
+
} catch {
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve `p` the way the kernel would: walk it one component at a time with `lstat`; a symlink is
|
|
71
|
+
* replaced by its target (read with `readlink`, followed again, bounded); `..` steps up from the
|
|
72
|
+
* resolved dir, never lexically. A missing component ends the walk: the existing part is the
|
|
73
|
+
* native realpath, the rest is kept as written. So a dangling symlink is judged by its target.
|
|
74
|
+
* @param {string} p - absolute.
|
|
75
|
+
* @param {{n: number}} [hops]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
* @throws {Unresolvable} on a loop, too many hops, or a component that cannot be read.
|
|
78
|
+
*/
|
|
79
|
+
function resolvePath(p, hops = { n: 0 }) {
|
|
80
|
+
const { root } = path.parse(p);
|
|
81
|
+
const parts = p.slice(root.length).split(path.sep).filter((c) => c.length > 0 && c !== '.');
|
|
82
|
+
let cur = root;
|
|
83
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
84
|
+
if (parts[i] === '..') {
|
|
85
|
+
cur = path.dirname(cur);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const next = path.join(cur, parts[i]);
|
|
89
|
+
let st;
|
|
90
|
+
try {
|
|
91
|
+
st = lstatSync(next);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
94
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return path.join(native(cur), ...parts.slice(i));
|
|
95
|
+
throw new Unresolvable();
|
|
96
|
+
}
|
|
97
|
+
if (!st.isSymbolicLink()) {
|
|
98
|
+
cur = next;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
hops.n += 1;
|
|
102
|
+
if (hops.n > MAX_LINK_HOPS) throw new Unresolvable();
|
|
103
|
+
let target;
|
|
104
|
+
try {
|
|
105
|
+
target = readlinkSync(next);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Unresolvable();
|
|
108
|
+
}
|
|
109
|
+
cur = resolvePath(path.isAbsolute(target) ? target : `${cur}${path.sep}${target}`, hops);
|
|
110
|
+
}
|
|
111
|
+
return native(cur);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {string} p @returns {string} a refused tree, resolved; a tree that cannot be is kept as written. */
|
|
115
|
+
function resolveTree(p) {
|
|
116
|
+
try {
|
|
117
|
+
return resolvePath(path.resolve(p));
|
|
118
|
+
} catch {
|
|
119
|
+
return path.resolve(p);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Case-insensitive by default on macOS and Windows: compare folded there, so a missing tree is matched too. */
|
|
124
|
+
const FOLD_CASE = process.platform === 'darwin' || process.platform === 'win32';
|
|
125
|
+
|
|
126
|
+
/** @param {string} p @returns {string} */
|
|
127
|
+
function fold(p) {
|
|
128
|
+
return FOLD_CASE ? p.toLowerCase() : p;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {string} a @param {string} b @returns {boolean} `a` is `b` (both resolved; case folded where the FS is). */
|
|
132
|
+
function same(a, b) {
|
|
133
|
+
return fold(a) === fold(b);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {string} a @param {string} b @returns {boolean} `a` is `b` or inside it (both resolved). */
|
|
137
|
+
function within(a, b) {
|
|
138
|
+
const rel = path.relative(fold(b), fold(a));
|
|
139
|
+
if (rel === '') return true;
|
|
140
|
+
return !(rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @param {NodeJS.ProcessEnv} env @returns {string} */
|
|
144
|
+
function homeOf(env) {
|
|
145
|
+
return typeof env.HOME === 'string' && env.HOME.length > 0 ? env.HOME : os.homedir();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Expand one entry: absolute, or `~/…` against `home`. Anything else is `null`. `.` and `..` are
|
|
150
|
+
* kept as written: the resolver steps through them after following symlinks.
|
|
151
|
+
* @param {unknown} entry @param {string} home @returns {string | null}
|
|
152
|
+
*/
|
|
153
|
+
export function expandSandboxDir(entry, home) {
|
|
154
|
+
if (typeof entry !== 'string' || entry.length === 0 || entry.includes('\0')) return null;
|
|
155
|
+
if (entry.startsWith('~/')) return `${path.resolve(home)}${path.sep}${entry.slice(2)}`;
|
|
156
|
+
if (path.isAbsolute(entry)) return entry;
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @typedef {object} SandboxGuards
|
|
162
|
+
* @property {NodeJS.ProcessEnv} [env] - the env whose HOME / CODEX_HOME count (default `process.env`).
|
|
163
|
+
* @property {ReadonlyArray<string | null | undefined>} [extra] - more refused trees: the run temp
|
|
164
|
+
* base or root, the session CODEX_HOME (absolute paths; `null`/`undefined` are skipped).
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The refused trees for `guards`, each resolved once.
|
|
169
|
+
* @param {SandboxGuards} guards
|
|
170
|
+
* @returns {{home: string, realHome: string, protectedTrees: string[], runTrees: string[]}}
|
|
171
|
+
*/
|
|
172
|
+
function refusedTrees(guards) {
|
|
173
|
+
const env = guards.env ?? process.env;
|
|
174
|
+
const home = homeOf(env);
|
|
175
|
+
const protectedTrees = PROTECTED_HOME_DIRS.map((d) => resolveTree(path.join(home, d)));
|
|
176
|
+
if (typeof env.CODEX_HOME === 'string' && path.isAbsolute(env.CODEX_HOME)) protectedTrees.push(resolveTree(env.CODEX_HOME));
|
|
177
|
+
/** @type {string[]} */
|
|
178
|
+
const runTrees = [];
|
|
179
|
+
for (const p of guards.extra ?? []) {
|
|
180
|
+
if (typeof p === 'string' && path.isAbsolute(p)) runTrees.push(resolveTree(p));
|
|
181
|
+
}
|
|
182
|
+
return { home, realHome: resolveTree(home), protectedTrees, runTrees };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check one `add_dirs` entry against resolved trees.
|
|
187
|
+
* @param {unknown} entry @param {number} index @param {ReturnType<typeof refusedTrees>} trees
|
|
188
|
+
* @returns {{ok: true, dir: string} | {ok: false, keyPath: string, why: string}}
|
|
189
|
+
*/
|
|
190
|
+
function checkOne(entry, index, trees) {
|
|
191
|
+
const keyPath = `coder.sandbox.add_dirs[${index}]`;
|
|
192
|
+
const expanded = expandSandboxDir(entry, trees.home);
|
|
193
|
+
if (expanded === null) return { ok: false, keyPath, why: 'it must be an absolute path or start with ~/' };
|
|
194
|
+
let real;
|
|
195
|
+
try {
|
|
196
|
+
real = resolvePath(expanded);
|
|
197
|
+
} catch {
|
|
198
|
+
return { ok: false, keyPath, why: 'its symlinks cannot be resolved' };
|
|
199
|
+
}
|
|
200
|
+
if (real === path.parse(real).root) return { ok: false, keyPath, why: 'it is the filesystem root' };
|
|
201
|
+
if (same(real, trees.realHome)) return { ok: false, keyPath, why: 'it is the home directory' };
|
|
202
|
+
if (within(trees.realHome, real)) return { ok: false, keyPath, why: 'it holds the home directory' };
|
|
203
|
+
if (trees.protectedTrees.some((t) => within(real, t) || within(t, real))) {
|
|
204
|
+
return { ok: false, keyPath, why: 'it is, holds or sits inside a protected directory (~/.code-forge, ~/.codex, ~/.ssh, ~/.gnupg, ~/.aws, ~/.config, ~/Library/Keychains)' };
|
|
205
|
+
}
|
|
206
|
+
if (trees.runTrees.some((t) => within(real, t) || within(t, real))) {
|
|
207
|
+
return { ok: false, keyPath, why: 'it is, holds or sits inside the run temp root or the session CODEX_HOME' };
|
|
208
|
+
}
|
|
209
|
+
return { ok: true, dir: real };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Every refused `add_dirs` entry (validation reports each one).
|
|
214
|
+
* @param {unknown} dirs @param {SandboxGuards} [guards]
|
|
215
|
+
* @returns {Array<{keyPath: string, why: string}>}
|
|
216
|
+
*/
|
|
217
|
+
export function sandboxDirRefusals(dirs, guards = {}) {
|
|
218
|
+
if (dirs === undefined) return [];
|
|
219
|
+
if (!Array.isArray(dirs)) return [{ keyPath: 'coder.sandbox.add_dirs', why: 'it must be a list of directories' }];
|
|
220
|
+
const trees = refusedTrees(guards);
|
|
221
|
+
/** @type {Array<{keyPath: string, why: string}>} */
|
|
222
|
+
const out = [];
|
|
223
|
+
dirs.forEach((entry, i) => {
|
|
224
|
+
const one = checkOne(entry, i, trees);
|
|
225
|
+
if ('keyPath' in one) out.push({ keyPath: one.keyPath, why: one.why });
|
|
226
|
+
});
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Check `add_dirs` and resolve each entry; the first refusal wins (the builder fails closed).
|
|
232
|
+
* @param {unknown} dirs - the `coder.sandbox.add_dirs` value.
|
|
233
|
+
* @param {SandboxGuards} [guards]
|
|
234
|
+
* @returns {{ok: true, dirs: string[]} | {ok: false, keyPath: string, why: string}} `why` names a
|
|
235
|
+
* category, never the value.
|
|
236
|
+
*/
|
|
237
|
+
export function checkSandboxDirs(dirs, guards = {}) {
|
|
238
|
+
if (dirs === undefined) return { ok: true, dirs: [] };
|
|
239
|
+
if (!Array.isArray(dirs)) return { ok: false, keyPath: 'coder.sandbox.add_dirs', why: 'it must be a list of directories' };
|
|
240
|
+
const trees = refusedTrees(guards);
|
|
241
|
+
/** @type {string[]} */
|
|
242
|
+
const out = [];
|
|
243
|
+
for (let i = 0; i < dirs.length; i += 1) {
|
|
244
|
+
const one = checkOne(dirs[i], i, trees);
|
|
245
|
+
if ('keyPath' in one) return { ok: false, keyPath: one.keyPath, why: one.why };
|
|
246
|
+
out.push(one.dir);
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, dirs: out };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The message of a refusal: the key path and the category, never the value.
|
|
253
|
+
* @param {{keyPath: string, why: string}} refusal @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
export function sandboxRefusalMessage(refusal) {
|
|
256
|
+
return `${SANDBOX_DIR_REFUSED}: ${refusal.keyPath} is refused — ${refusal.why}`;
|
|
257
|
+
}
|
package/src/keys/store.mjs
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* 1. env var — `env:NAME` from the reference, else `CODE_FORGE_KEY_<NAME>`
|
|
6
6
|
* 2. OS keychain — this store (`@napi-rs/keyring` → macOS `security` CLI → 0600 file)
|
|
7
7
|
* 3. 1Password — `op read <op://…>` (retry once on a first-call timeout); a hit is cached
|
|
8
|
-
* into the store as `{"v", "exp"}` for `cacheHours` (
|
|
8
|
+
* into the store as `{"v", "exp"}` for `cacheHours` (whole hours 1–24,
|
|
9
|
+
* default 8; `keys.cache_hours` / `keys set --cache-hours`, B84)
|
|
9
10
|
* 4. ask — hidden input, only when the caller passes `ask` (setup only)
|
|
10
11
|
* An entry whose `exp` has passed is deleted on that first read and treated as absent, so the
|
|
11
|
-
* chain moves on to 1Password and refreshes it. A
|
|
12
|
-
*
|
|
12
|
+
* chain moves on to 1Password and refreshes it. A cached entry is never trusted for longer than
|
|
13
|
+
* the reader's hours (B84, D2): a read whose entry has `exp > now + hours` moves the expiry
|
|
14
|
+
* earlier to `now + hours` (entry and index row rewritten, value kept, 1Password not asked); an
|
|
15
|
+
* expiry is never moved later, and a read with no hours caps at `MAX_CACHE_HOURS`. A backend that
|
|
16
|
+
* fails to read is skipped with a non-secret reason (backend name + error code) in `errors`; the chain never aborts on it.
|
|
13
17
|
*
|
|
14
18
|
* `source` names where the value came from: `env`, the store backend that held it (`keychain`,
|
|
15
19
|
* `security-cli`, `file`), `op`, or `ask`.
|
|
@@ -18,7 +22,11 @@
|
|
|
18
22
|
* redacted output path (`util/log`, `util/redact`) masks it from then on. Nothing here prints.
|
|
19
23
|
*
|
|
20
24
|
* Alongside the secrets the store keeps `index.json` (0600, no values): name → source, backend,
|
|
21
|
-
* exp — what `keys list` shows
|
|
25
|
+
* exp, and (B85) the `op://` reference a 1Password read came from — what `keys list` shows and
|
|
26
|
+
* what its re-link hint names. A row written before B85 has no `ref` and still lists.
|
|
27
|
+
*
|
|
28
|
+
* An entry dropped as expired is reported to the reader (B85): `resolveKey` returns its old `exp`
|
|
29
|
+
* as `expired` when nothing else resolved, so `jev ask` can print the exact re-link command.
|
|
22
30
|
*
|
|
23
31
|
* @typedef {object} Backend
|
|
24
32
|
* @property {string} name
|
|
@@ -28,8 +36,8 @@
|
|
|
28
36
|
* @property {(name: string, value: string) => Promise<void>} set
|
|
29
37
|
* @property {(name: string) => Promise<boolean>} delete
|
|
30
38
|
*
|
|
31
|
-
* @typedef {{name: string, source: string, backend: string, exp: number|null}} IndexRow
|
|
32
|
-
* @typedef {{value: string|null, source: string|null, exp: number|null, errors: string[]}} Resolution
|
|
39
|
+
* @typedef {{name: string, source: string, backend: string, exp: number|null, ref?: string}} IndexRow
|
|
40
|
+
* @typedef {{value: string|null, source: string|null, exp: number|null, errors: string[], expired?: number|null}} Resolution
|
|
33
41
|
*/
|
|
34
42
|
|
|
35
43
|
import { readFile } from 'node:fs/promises';
|
|
@@ -42,6 +50,11 @@ import { createSecurityCliBackend } from './backends/security-cli.mjs';
|
|
|
42
50
|
import { isOpRef, opRead as realOpRead } from './onepassword.mjs';
|
|
43
51
|
|
|
44
52
|
export const DEFAULT_CACHE_HOURS = 8;
|
|
53
|
+
/** The bounds of `keys.cache_hours` / `--cache-hours`; the schema carries the same numbers. */
|
|
54
|
+
export const MIN_CACHE_HOURS = 1;
|
|
55
|
+
export const MAX_CACHE_HOURS = 24;
|
|
56
|
+
/** The one text for an out-of-range cache time; it never carries the value. */
|
|
57
|
+
export const CACHE_HOURS_RULE = `a whole number of hours from ${MIN_CACHE_HOURS} to ${MAX_CACHE_HOURS}`;
|
|
45
58
|
const HOUR_MS = 3_600_000;
|
|
46
59
|
const NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
47
60
|
|
|
@@ -74,6 +87,62 @@ export function parseRef(name, ref) {
|
|
|
74
87
|
return { envName, storeName, opRef: ref?.startsWith('op://') ? ref : null };
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
/**
|
|
91
|
+
* @param {unknown} exp
|
|
92
|
+
* @returns {exp is number} a time in ms that `new Date(exp)` can print: finite, |exp| <= 8.64e15.
|
|
93
|
+
*/
|
|
94
|
+
export function isTime(exp) {
|
|
95
|
+
return typeof exp === 'number' && Number.isFinite(exp) && Math.abs(exp) <= 8.64e15;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A reference as one POSIX shell word for a re-link command a human copies (B85): unchanged when
|
|
100
|
+
* every character is in `[A-Za-z0-9_./:@%+=-]`, else wrapped in single quotes with each `'` written
|
|
101
|
+
* as `'\''`.
|
|
102
|
+
* @param {string} ref
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function shellWord(ref) {
|
|
106
|
+
return /^[A-Za-z0-9_./:@%+=-]+$/.test(ref) ? ref : `'${ref.replace(/'/g, "'\\''")}'`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {unknown} hours
|
|
111
|
+
* @returns {hours is number} a whole number of hours within the bounds.
|
|
112
|
+
*/
|
|
113
|
+
export function isCacheHours(hours) {
|
|
114
|
+
return Number.isInteger(hours) && /** @type {number} */ (hours) >= MIN_CACHE_HOURS && /** @type {number} */ (hours) <= MAX_CACHE_HOURS;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {unknown} hours
|
|
119
|
+
* @returns {number} the hours, when they are within the bounds.
|
|
120
|
+
* @throws {RangeError} otherwise — no path may cache for longer than the maximum.
|
|
121
|
+
*/
|
|
122
|
+
function assertCacheHours(hours) {
|
|
123
|
+
if (!isCacheHours(hours)) {
|
|
124
|
+
throw new RangeError(`cache hours must be ${CACHE_HOURS_RULE}`);
|
|
125
|
+
}
|
|
126
|
+
return hours;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The cache time a project asks for (`keys.cache_hours`). Absent ⇒ the default; present and valid
|
|
131
|
+
* ⇒ that value; anything else ⇒ the default with a problem text (never the value itself).
|
|
132
|
+
* @param {Record<string, any> | null | undefined} cfg
|
|
133
|
+
* @returns {{hours: number, source: 'config'|'default', problem: string|null}}
|
|
134
|
+
*/
|
|
135
|
+
export function cacheHoursFrom(cfg) {
|
|
136
|
+
const keys = cfg?.keys;
|
|
137
|
+
if (keys === null || typeof keys !== 'object' || !Object.hasOwn(keys, 'cache_hours')) {
|
|
138
|
+
return { hours: DEFAULT_CACHE_HOURS, source: 'default', problem: null };
|
|
139
|
+
}
|
|
140
|
+
if (isCacheHours(keys.cache_hours)) {
|
|
141
|
+
return { hours: keys.cache_hours, source: 'config', problem: null };
|
|
142
|
+
}
|
|
143
|
+
return { hours: DEFAULT_CACHE_HOURS, source: 'default', problem: `keys.cache_hours must be ${CACHE_HOURS_RULE}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
/**
|
|
78
147
|
* @param {string} value
|
|
79
148
|
* @param {number|null} exp
|
|
@@ -122,8 +191,9 @@ export function codeForgeHome(env = process.env) {
|
|
|
122
191
|
* @param {object} opts
|
|
123
192
|
* @param {Backend[]} opts.backends - candidates, most preferred first; unavailable ones are dropped.
|
|
124
193
|
* @param {string} opts.dir - where `index.json` lives.
|
|
194
|
+
* @param {typeof writePrivateFile} [opts.writeIndexFile] - writes `index.json` (tests inject a failure).
|
|
125
195
|
*/
|
|
126
|
-
export async function createKeyStore({ backends, dir }) {
|
|
196
|
+
export async function createKeyStore({ backends, dir, writeIndexFile = writePrivateFile }) {
|
|
127
197
|
/** @type {Backend[]} */
|
|
128
198
|
const live = [];
|
|
129
199
|
for (const backend of backends) {
|
|
@@ -153,7 +223,7 @@ export async function createKeyStore({ backends, dir }) {
|
|
|
153
223
|
const updateIndex = async (change) => {
|
|
154
224
|
const index = await readIndex();
|
|
155
225
|
change(index);
|
|
156
|
-
await
|
|
226
|
+
await writeIndexFile(indexFile, JSON.stringify(index, null, 2));
|
|
157
227
|
};
|
|
158
228
|
|
|
159
229
|
return {
|
|
@@ -165,10 +235,14 @@ export async function createKeyStore({ backends, dir }) {
|
|
|
165
235
|
* @param {string} name
|
|
166
236
|
* @param {number} now
|
|
167
237
|
* @param {string[]} [errors] - receives one non-secret reason per backend that failed.
|
|
238
|
+
* @param {{maxHours?: number, onExpired?: (exp: number) => void}} [opts] - the reader's cache
|
|
239
|
+
* time; a longer `exp` is cut to `now + maxHours` (default `MAX_CACHE_HOURS`). `onExpired`
|
|
240
|
+
* gets the old `exp` of an entry this read dropped as expired (B85).
|
|
168
241
|
* @returns {Promise<{value: string, exp: number|null, backend: string}|null>}
|
|
169
242
|
*/
|
|
170
|
-
async read(name, now, errors = []) {
|
|
243
|
+
async read(name, now, errors = [], { maxHours = MAX_CACHE_HOURS, onExpired } = {}) {
|
|
171
244
|
assertKeyName(name);
|
|
245
|
+
const latest = now + assertCacheHours(maxHours) * HOUR_MS;
|
|
172
246
|
for (const backend of readOrder) {
|
|
173
247
|
let raw;
|
|
174
248
|
try {
|
|
@@ -186,11 +260,36 @@ export async function createKeyStore({ backends, dir }) {
|
|
|
186
260
|
await backend.delete(name).catch((err) => {
|
|
187
261
|
errors.push(`${backend.name}: delete of expired entry failed (${reasonOf(err)})`);
|
|
188
262
|
});
|
|
263
|
+
// reported first, and a failed index update is a reason, never a reject: the reader still
|
|
264
|
+
// learns the key expired
|
|
265
|
+
onExpired?.(entry.exp);
|
|
189
266
|
await updateIndex((index) => {
|
|
190
267
|
delete index[name];
|
|
268
|
+
}).catch((err) => {
|
|
269
|
+
errors.push(`index: removal of the expired entry failed (${reasonOf(err)})`);
|
|
191
270
|
});
|
|
192
271
|
return null;
|
|
193
272
|
}
|
|
273
|
+
if (entry.exp !== null && entry.exp > latest) {
|
|
274
|
+
// D2: the shorter expiry applies now; the value stays and 1Password is not asked
|
|
275
|
+
// a failed rewrite leaves the index alone (it still matches the stored entry);
|
|
276
|
+
// the index row is only adjusted when one exists, never invented
|
|
277
|
+
const rewritten = await backend.set(name, encodeEntry(entry.value, latest)).then(
|
|
278
|
+
() => true,
|
|
279
|
+
(err) => {
|
|
280
|
+
errors.push(`${backend.name}: rewrite of the shortened expiry failed (${reasonOf(err)})`);
|
|
281
|
+
return false;
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
if (rewritten) {
|
|
285
|
+
await updateIndex((index) => {
|
|
286
|
+
if (index[name]) index[name] = { ...index[name], backend: backend.name, exp: latest };
|
|
287
|
+
}).catch((err) => {
|
|
288
|
+
errors.push(`index: rewrite of the shortened expiry failed (${reasonOf(err)})`);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return { value: entry.value, exp: latest, backend: backend.name };
|
|
292
|
+
}
|
|
194
293
|
return { ...entry, backend: backend.name };
|
|
195
294
|
}
|
|
196
295
|
return null;
|
|
@@ -199,9 +298,10 @@ export async function createKeyStore({ backends, dir }) {
|
|
|
199
298
|
/**
|
|
200
299
|
* @param {string} name
|
|
201
300
|
* @param {string} value
|
|
202
|
-
* @param {{source: string, exp: number|null}} meta
|
|
301
|
+
* @param {{source: string, exp: number|null, ref?: string}} meta - `ref` (B85): the `op://`
|
|
302
|
+
* reference the value was read from; it goes to the index, never a value.
|
|
203
303
|
*/
|
|
204
|
-
async put(name, value, { source, exp }) {
|
|
304
|
+
async put(name, value, { source, exp, ref }) {
|
|
205
305
|
assertKeyName(name);
|
|
206
306
|
if (!writer) {
|
|
207
307
|
throw new Error('keys: no writable backend');
|
|
@@ -209,7 +309,7 @@ export async function createKeyStore({ backends, dir }) {
|
|
|
209
309
|
registerSecret(value);
|
|
210
310
|
await writer.set(name, encodeEntry(value, exp));
|
|
211
311
|
await updateIndex((index) => {
|
|
212
|
-
index[name] = { source, backend: writer.name, exp };
|
|
312
|
+
index[name] = { source, backend: writer.name, exp, ...(typeof ref === 'string' ? { ref } : {}) };
|
|
213
313
|
});
|
|
214
314
|
},
|
|
215
315
|
|
|
@@ -238,6 +338,7 @@ export async function createKeyStore({ backends, dir }) {
|
|
|
238
338
|
source: String(index[name].source),
|
|
239
339
|
backend: String(index[name].backend),
|
|
240
340
|
exp: index[name].exp ?? null,
|
|
341
|
+
...(typeof index[name].ref === 'string' ? { ref: index[name].ref } : {}),
|
|
241
342
|
}));
|
|
242
343
|
},
|
|
243
344
|
};
|
|
@@ -267,8 +368,8 @@ export function createDefaultKeyStore(
|
|
|
267
368
|
}
|
|
268
369
|
|
|
269
370
|
/**
|
|
270
|
-
* Read a key from 1Password and cache it into the store as `{v, exp}` for `cacheHours
|
|
271
|
-
* write failure is reported in `errors` but does not lose the value just read.
|
|
371
|
+
* Read a key from 1Password and cache it into the store as `{v, exp}` for `cacheHours`; the index
|
|
372
|
+
* row records `opRef` (B85). A cache write failure is reported in `errors` but does not lose the value just read.
|
|
272
373
|
*
|
|
273
374
|
* @param {string} storeName
|
|
274
375
|
* @param {string} opRef
|
|
@@ -280,6 +381,7 @@ export function createDefaultKeyStore(
|
|
|
280
381
|
* @returns {Promise<Resolution>}
|
|
281
382
|
*/
|
|
282
383
|
export async function readOpAndCache(storeName, opRef, { store, opRead = realOpRead, now = Date.now(), cacheHours = DEFAULT_CACHE_HOURS }) {
|
|
384
|
+
assertCacheHours(cacheHours);
|
|
283
385
|
const res = await opRead(opRef);
|
|
284
386
|
if (res.value === null) {
|
|
285
387
|
return { value: null, source: null, exp: null, errors: [res.error ?? 'op read failed'] };
|
|
@@ -288,7 +390,7 @@ export async function readOpAndCache(storeName, opRef, { store, opRead = realOpR
|
|
|
288
390
|
const exp = now + cacheHours * HOUR_MS;
|
|
289
391
|
/** @type {string[]} */
|
|
290
392
|
const errors = [];
|
|
291
|
-
await store.put(storeName, res.value, { source: 'op', exp }).catch((err) => {
|
|
393
|
+
await store.put(storeName, res.value, { source: 'op', exp, ref: opRef }).catch((err) => {
|
|
292
394
|
errors.push(`cache write failed (${reasonOf(err)})`);
|
|
293
395
|
});
|
|
294
396
|
return { value: res.value, source: 'op', exp, errors };
|
|
@@ -305,11 +407,14 @@ export async function readOpAndCache(storeName, opRef, { store, opRead = realOpR
|
|
|
305
407
|
* @param {typeof realOpRead} [deps.opRead]
|
|
306
408
|
* @param {(name: string) => Promise<string|null>} [deps.ask] - setup only; omitted everywhere else.
|
|
307
409
|
* @param {number} [deps.now]
|
|
308
|
-
* @param {number} [deps.cacheHours]
|
|
309
|
-
*
|
|
410
|
+
* @param {number} [deps.cacheHours] - how long a 1Password read is cached, and the longest a cached
|
|
411
|
+
* entry is trusted. Omitted: cached for `DEFAULT_CACHE_HOURS`, trusted up to `MAX_CACHE_HOURS`.
|
|
412
|
+
* @returns {Promise<Resolution>} when nothing resolved, `expired` is the old `exp` of a cached
|
|
413
|
+
* entry this call dropped as expired, else null (B85).
|
|
310
414
|
*/
|
|
311
|
-
export async function resolveKey(name, { store, ref, env = process.env, opRead = realOpRead, ask, now = Date.now(), cacheHours
|
|
415
|
+
export async function resolveKey(name, { store, ref, env = process.env, opRead = realOpRead, ask, now = Date.now(), cacheHours }) {
|
|
312
416
|
const { envName, storeName, opRef } = parseRef(name, ref);
|
|
417
|
+
const maxHours = cacheHours === undefined ? MAX_CACHE_HOURS : assertCacheHours(cacheHours);
|
|
313
418
|
/** @type {string[]} */
|
|
314
419
|
const errors = [];
|
|
315
420
|
|
|
@@ -319,13 +424,15 @@ export async function resolveKey(name, { store, ref, env = process.env, opRead =
|
|
|
319
424
|
return { value: fromEnv, source: 'env', exp: null, errors };
|
|
320
425
|
}
|
|
321
426
|
|
|
322
|
-
|
|
427
|
+
/** @type {number|null} */
|
|
428
|
+
let expired = null;
|
|
429
|
+
const cached = await store.read(storeName, now, errors, { maxHours, onExpired: (exp) => { expired = exp; } });
|
|
323
430
|
if (cached) {
|
|
324
431
|
return { value: cached.value, source: cached.backend, exp: cached.exp, errors };
|
|
325
432
|
}
|
|
326
433
|
|
|
327
434
|
if (opRef) {
|
|
328
|
-
const fromOp = await readOpAndCache(storeName, opRef, { store, opRead, now, cacheHours });
|
|
435
|
+
const fromOp = await readOpAndCache(storeName, opRef, { store, opRead, now, cacheHours: cacheHours ?? DEFAULT_CACHE_HOURS });
|
|
329
436
|
errors.push(...fromOp.errors);
|
|
330
437
|
if (fromOp.value !== null) {
|
|
331
438
|
return { ...fromOp, errors };
|
|
@@ -343,5 +450,5 @@ export async function resolveKey(name, { store, ref, env = process.env, opRead =
|
|
|
343
450
|
}
|
|
344
451
|
}
|
|
345
452
|
|
|
346
|
-
return { value: null, source: null, exp: null, errors };
|
|
453
|
+
return { value: null, source: null, exp: null, errors, expired };
|
|
347
454
|
}
|
package/src/ledger/write.mjs
CHANGED
|
@@ -53,9 +53,14 @@ function normalizeRow(row) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Claim `<base>.<n>.jsonl` for `n = 1, 2, ...` atomically via `link()` (hardlink the still-full
|
|
55
55
|
* file under the rotated name, then unlink the original) instead of `rename()`.
|
|
56
|
+
*
|
|
57
|
+
* `hooks.afterLink` is a test seam only: when given, it is awaited with the claimed candidate path
|
|
58
|
+
* right after a successful `link()` and before this call's `unlink()` of the source, so a test can
|
|
59
|
+
* force another racer's unlink into exactly that window. Production callers pass nothing.
|
|
56
60
|
* @param {string} file @param {string} dir @param {string} base
|
|
61
|
+
* @param {{ afterLink?: (candidate: string) => Promise<void> | void }} [hooks]
|
|
57
62
|
*/
|
|
58
|
-
export async function claimRotatedName(file, dir, base) {
|
|
63
|
+
export async function claimRotatedName(file, dir, base, hooks = {}) {
|
|
59
64
|
let n = 1;
|
|
60
65
|
for (;;) {
|
|
61
66
|
const candidate = path.join(dir, `${base}.${n}.jsonl`);
|
|
@@ -70,6 +75,7 @@ export async function claimRotatedName(file, dir, base) {
|
|
|
70
75
|
if (err.code === 'ENOENT') return; // `file` vanished before we could link it — a racer already rotated it out
|
|
71
76
|
throw err;
|
|
72
77
|
}
|
|
78
|
+
if (hooks.afterLink) await hooks.afterLink(candidate);
|
|
73
79
|
try {
|
|
74
80
|
await unlink(file);
|
|
75
81
|
return;
|