@codedology/code-forge 0.9.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +29 -8
  3. package/docs/reference/blocks.json +385 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +8 -3
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +11 -4
  14. package/skill/references/security.md +4 -4
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +2 -0
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +164 -24
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/spawn.mjs +44 -3
  23. package/src/config/validate.mjs +320 -5
  24. package/src/doctor/index.mjs +2 -1
  25. package/src/doctor/local.mjs +27 -6
  26. package/src/doctor/probes.mjs +38 -0
  27. package/src/engines/builders/codex.mjs +44 -4
  28. package/src/engines/sandbox-dirs.mjs +257 -0
  29. package/src/keys/store.mjs +128 -21
  30. package/src/proof/junit.mjs +530 -0
  31. package/src/proof/red-green.mjs +57 -9
  32. package/src/proof/red-parse.mjs +2 -0
  33. package/src/proof/runners.mjs +472 -0
  34. package/src/proof/xcresult.mjs +285 -0
  35. package/src/review/engine.mjs +82 -30
  36. package/src/review/fixloop.mjs +45 -13
  37. package/src/review/gate-check.mjs +3 -2
  38. package/src/review/lenses/A.md +1 -0
  39. package/src/review/lenses/B.md +1 -0
  40. package/src/review/lenses/full.md +1 -0
  41. package/src/review/lenses/quick.md +1 -0
  42. package/src/review/lenses/recheck.md +1 -0
  43. package/src/review/reopen.mjs +4 -6
  44. package/src/review/route.mjs +232 -0
  45. package/src/review/schema-fallback.mjs +77 -22
  46. package/src/review/session-retry.mjs +44 -9
  47. package/src/review/test-quality.mjs +201 -0
  48. package/src/review/verb-forms.mjs +39 -0
  49. package/src/session/spawn.mjs +39 -2
  50. package/src/state/block.mjs +13 -3
  51. package/src/state/config-snapshot.mjs +39 -0
  52. package/src/state/run.mjs +4 -0
  53. package/src/util/forbidden.mjs +2 -2
  54. package/src/worker/loop.mjs +79 -14
  55. package/src/worker/ticket.mjs +9 -1
@@ -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
+ }
@@ -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` (default 8)
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 backend that fails to read is skipped with a
12
- * non-secret reason (backend name + error code) in `errors`; the chain never aborts on it.
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 writePrivateFile(indexFile, JSON.stringify(index, null, 2));
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`. A cache
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
- * @returns {Promise<Resolution>}
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 = DEFAULT_CACHE_HOURS }) {
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
- const cached = await store.read(storeName, now, errors);
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
  }