@stravica-ai/digital-operator-pa 0.1.60 → 0.1.61

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.
@@ -1,13 +1,30 @@
1
1
  // backup — render an HTML file containing the master key as a QR code
2
- // and open it in the default browser. Cleans up the temp HTML after 30s.
2
+ // and open it in the default browser.
3
3
  //
4
4
  // The QR-generation JS is the same minified generator used in
5
5
  // onboarding.html, so the output looks identical. No browser automation,
6
6
  // no npm deps.
7
+ //
8
+ // The page holds the age secret key in plaintext, so the file lives on
9
+ // disk only until the browser has read it (w-2026-10-17-dave-004). The
10
+ // old cleanup was an unref()ed 30s timer, which never fired because
11
+ // main() calls process.exit straight after the command returns: every
12
+ // run left a `dave-key-backup-*` directory with the key in the OS temp
13
+ // root. Now the command waits for the operator to confirm the page is
14
+ // showing (Enter on a TTY), capped at BACKUP_FILE_MAX_LIFETIME_MS, then
15
+ // wipes the file synchronously: overwrite with zeros of its own length,
16
+ // fsync, unlink, remove the directory. The same wipe runs from the
17
+ // finally block, a process 'exit' listener and SIGINT/SIGTERM/SIGHUP
18
+ // handlers, so a throw, an early Ctrl+C or a process.exit anywhere in
19
+ // the flow cannot leave the key behind. Only SIGKILL or a crash of the
20
+ // host can.
7
21
 
8
- import { mkdtempSync, writeFileSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
22
+ import {
23
+ mkdtempSync, writeFileSync, existsSync, unlinkSync, readFileSync,
24
+ openSync, fstatSync, writeSync, fsyncSync, closeSync, rmSync,
25
+ } from 'node:fs';
9
26
  import { join } from 'node:path';
10
- import { tmpdir } from 'node:os';
27
+ import { tmpdir, constants as osConstants } from 'node:os';
11
28
  import { spawn } from 'node:child_process';
12
29
  import { defaultPaths, secretKeyFromKeyFile, VaultError } from '../vault.mjs';
13
30
  import { HTML_HEAD, QR_JS, HTML_TAIL } from './backup-template.mjs';
@@ -25,10 +42,105 @@ export function buildBackupHtml(secretKey, publicKey) {
25
42
  return HTML_HEAD + QR_JS + dataScript + HTML_TAIL;
26
43
  }
27
44
 
45
+ // Hard cap on how long backup.html stays on disk when nobody confirms
46
+ // the page is showing. The browser needs the file only for its first
47
+ // read; the loaded page keeps working once the file is gone.
48
+ export const BACKUP_FILE_MAX_LIFETIME_MS = 60_000;
49
+
50
+ const WIPE_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
51
+
52
+ /**
53
+ * Wipe the backup file and its directory, synchronously. Each step runs
54
+ * whatever the previous one did, so a missing file (a throw before the
55
+ * write) still removes the directory.
56
+ */
57
+ export function wipeBackupDir(dir, htmlPath) {
58
+ try {
59
+ const fd = openSync(htmlPath, 'r+');
60
+ try {
61
+ const { size } = fstatSync(fd);
62
+ if (size > 0) {
63
+ const zeros = Buffer.alloc(size);
64
+ let written = 0;
65
+ while (written < size) written += writeSync(fd, zeros, written, size - written, written);
66
+ fsyncSync(fd);
67
+ }
68
+ } finally {
69
+ closeSync(fd);
70
+ }
71
+ } catch { /* never written, or already gone */ }
72
+ try { unlinkSync(htmlPath); } catch { /* already gone */ }
73
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* already gone */ }
74
+ }
75
+
76
+ /**
77
+ * Register the wipe on every way out of the process and return it. The
78
+ * returned function is idempotent and deregisters the listeners, so the
79
+ * finally block, the 'exit' listener and a signal can all call it.
80
+ */
81
+ function armWipe(dir, htmlPath) {
82
+ let wiped = false;
83
+ const signalHandlers = new Map();
84
+ const wipe = () => {
85
+ if (wiped) return;
86
+ wiped = true;
87
+ process.removeListener('exit', wipe);
88
+ for (const [sig, handler] of signalHandlers) process.removeListener(sig, handler);
89
+ wipeBackupDir(dir, htmlPath);
90
+ };
91
+ process.on('exit', wipe);
92
+ for (const sig of WIPE_SIGNALS) {
93
+ const handler = () => {
94
+ wipe();
95
+ process.exit(128 + (osConstants.signals[sig] || 0));
96
+ };
97
+ signalHandlers.set(sig, handler);
98
+ process.on(sig, handler);
99
+ }
100
+ return wipe;
101
+ }
102
+
103
+ /**
104
+ * Hold the file until the operator says the page is showing (Enter, or
105
+ * end of input, on a TTY) or the cap passes. Without a TTY there is no
106
+ * one to confirm, so the cap alone applies. The timer is deliberately
107
+ * NOT unref()ed: the process must stay alive to run the wipe.
108
+ */
109
+ function defaultWaitForBrowser({ maxLifetimeMs, stdin = process.stdin }) {
110
+ const interactive = Boolean(stdin && stdin.isTTY);
111
+ const seconds = Math.ceil(maxLifetimeMs / 1000);
112
+ if (interactive) {
113
+ console.log(`Press Enter as soon as the page is showing. That wipes the file from disk; the open page keeps working (do not reload it). The file is wiped after ${seconds}s either way.`);
114
+ } else {
115
+ console.log(`The file is wiped from disk in ${seconds}s; the open page keeps working (do not reload it).`);
116
+ }
117
+ return new Promise((resolve) => {
118
+ let timer = null;
119
+ const finish = () => {
120
+ clearTimeout(timer);
121
+ if (interactive) {
122
+ stdin.removeListener('data', finish);
123
+ stdin.removeListener('end', finish);
124
+ stdin.pause();
125
+ }
126
+ resolve();
127
+ };
128
+ timer = setTimeout(finish, maxLifetimeMs);
129
+ if (interactive) {
130
+ stdin.once('data', finish);
131
+ stdin.once('end', finish);
132
+ stdin.resume();
133
+ }
134
+ });
135
+ }
136
+
28
137
  export async function runBackup(overrides = {}) {
29
138
  const paths = overrides.paths || defaultPaths(overrides);
30
139
  const opener = overrides.opener || defaultOpener;
31
- const scheduleCleanup = overrides.scheduleCleanup ?? true;
140
+ const waitForBrowser = overrides.waitForBrowser || defaultWaitForBrowser;
141
+ const maxLifetimeMs = overrides.maxLifetimeMs ?? BACKUP_FILE_MAX_LIFETIME_MS;
142
+ // process.stdin is only touched by the default waiter, and only then.
143
+ const stdin = overrides.stdin;
32
144
 
33
145
  if (!existsSync(paths.keyFile)) {
34
146
  // Missing key is an operational failure (exit 2), not an "uninitialised"
@@ -53,19 +165,20 @@ export async function runBackup(overrides = {}) {
53
165
  const html = buildBackupHtml(secretKey, publicKey);
54
166
  const dir = mkdtempSync(join(tmpdir(), 'dave-key-backup-'));
55
167
  const htmlPath = join(dir, 'backup.html');
56
- writeFileSync(htmlPath, html);
57
-
58
- console.log('Opening master key backup in browser...');
59
- console.log('IMPORTANT: Back up the key shown, then close the browser tab.');
168
+ // Armed before the first byte of key material reaches disk.
169
+ const wipe = armWipe(dir, htmlPath);
170
+ try {
171
+ writeFileSync(htmlPath, html, { mode: 0o600 });
60
172
 
61
- opener(htmlPath);
173
+ console.log('Opening master key backup in browser...');
174
+ console.log('IMPORTANT: Back up the key shown, then close the browser tab.');
62
175
 
63
- if (scheduleCleanup) {
64
- // Detach a lightweight cleaner so the CLI can exit.
65
- setTimeout(() => {
66
- try { unlinkSync(htmlPath); } catch { /* ignore */ }
67
- }, 30000).unref?.();
176
+ opener(htmlPath);
177
+ await waitForBrowser({ htmlPath, maxLifetimeMs, stdin });
178
+ } finally {
179
+ wipe();
68
180
  }
181
+ console.log('Backup file wiped from disk.');
69
182
 
70
183
  return { htmlPath, html };
71
184
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
13
  import { homedir } from 'node:os';
14
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs';
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
15
15
  import { join, dirname, resolve, basename, sep } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { mkdtempSync } from 'node:fs';
@@ -1155,8 +1155,8 @@ export async function sopsEncrypt(paths, pubKey, obj) {
1155
1155
  // the vault's own .sops.yaml matches (path_regex: \.enc\.json$). If the
1156
1156
  // suffix doesn't match, sops errors with "no matching creation rules".
1157
1157
  const tmpFile = join(tmp, 'secrets.enc.json');
1158
- writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
1159
1158
  try {
1159
+ writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
1160
1160
  // --config pins the sops config to the vault's own .sops.yaml so sops
1161
1161
  // doesn't walk up from CWD and hit an unrelated ambient .sops.yaml
1162
1162
  // (e.g. an older vault's config at a higher directory).
@@ -1168,7 +1168,10 @@ export async function sopsEncrypt(paths, pubKey, obj) {
1168
1168
  }
1169
1169
  writeFileSync(paths.secretsFile, stdout);
1170
1170
  } finally {
1171
- try { unlinkSync(tmpFile); } catch { /* ignore */ }
1171
+ // Remove the whole mkdtemp directory, not just the plaintext file:
1172
+ // unlinking the file alone left one empty vault-XXXXXX directory under
1173
+ // the OS temp root per encrypt (w-2026-10-15-dave-013).
1174
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
1172
1175
  }
1173
1176
  }
1174
1177
 
@@ -1224,8 +1227,8 @@ export async function decryptStoreForWrite(paths) {
1224
1227
  export async function sopsEncryptScoped(paths, obj) {
1225
1228
  const tmp = mkdtempSync(join(tmpdir(), 'vault-'));
1226
1229
  const tmpFile = join(tmp, basename(paths.secretsFile));
1227
- writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
1228
1230
  try {
1231
+ writeFileSync(tmpFile, JSON.stringify(obj, null, 2) + '\n');
1229
1232
  const { code, stdout, stderr } = await runCmd('sops', ['--config', paths.sopsConfig, '--encrypt', tmpFile], {
1230
1233
  env: { SOPS_AGE_KEY_FILE: paths.keyFile },
1231
1234
  });
@@ -1234,7 +1237,8 @@ export async function sopsEncryptScoped(paths, obj) {
1234
1237
  }
1235
1238
  writeFileSync(paths.secretsFile, stdout);
1236
1239
  } finally {
1237
- try { unlinkSync(tmpFile); } catch { /* ignore */ }
1240
+ // Same as sopsEncrypt: the directory goes too, on every path.
1241
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
1238
1242
  }
1239
1243
  }
1240
1244
 
@@ -56,14 +56,16 @@ any file changes distill produced.
56
56
 
57
57
  ### Model selection
58
58
 
59
- The sweep passes `--model claude-opus-4-7` explicitly (operator ruling
60
- 2026-07-29). Before the pin, no `--model` was passed and the Librarian
59
+ The sweep passes `--model claude-opus-5-5` explicitly (operator ruling
60
+ 2026-07-29 for the pin). Before the pin, no `--model` was passed and the Librarian
61
61
  inherited whatever the host CLI defaulted to -- so the model running the
62
62
  sweep was an accident of host configuration, and a host-side default bump
63
63
  silently changed sweep behaviour across the estate. The pin makes the
64
- choice deliberate. The reasoning behind 4.7 specifically: it follows
65
- briefs better than 4.8/5 in estate experience, and brief-following
66
- matters more for a scripted sweep than raw capability.
64
+ choice deliberate. The pinned model follows the estate model ladder:
65
+ `claude-opus-5-5` is the base model from the 2026-10-16 operator ruling.
66
+ It replaced `claude-opus-4-7` (now deprecated), which was first picked
67
+ because it followed briefs better than 4.8/5 in estate experience, and
68
+ brief-following matters more for a scripted sweep than raw capability.
67
69
 
68
70
  Override per host or per run with the `DISTILL_MODEL` environment
69
71
  variable:
@@ -81,13 +81,16 @@ export const DISTILL_PROMPT = buildDistillPrompt();
81
81
  // 2026-07-29). Before this pin, claudeArgs passed no `--model`, so the
82
82
  // Librarian inherited whatever the host CLI happened to default to -- the
83
83
  // model choice was an accident of host config, and a host-side default bump
84
- // silently changed sweep behaviour. The pin makes the choice deliberate:
85
- // claude-opus-4-7 follows briefs better than 4.8/5 in estate experience,
86
- // which matters more for a scripted sweep than raw capability.
84
+ // silently changed sweep behaviour. The pin makes the choice deliberate.
85
+ // The pinned model follows the estate model ladder: claude-opus-5-5 is the
86
+ // base model from the 2026-10-16 operator ruling. It replaced claude-opus-4-7
87
+ // (now deprecated), which was first picked because it followed briefs better
88
+ // than 4.8/5, and brief-following matters more for a scripted sweep than raw
89
+ // capability.
87
90
  //
88
91
  // Override per host or per run via the DISTILL_MODEL environment variable
89
92
  // (documented in docs/tools/distill.md).
90
- export const DEFAULT_DISTILL_MODEL = 'claude-opus-4-7';
93
+ export const DEFAULT_DISTILL_MODEL = 'claude-opus-5-5';
91
94
 
92
95
  export function resolveDistillModel(env = process.env) {
93
96
  const override = env.DISTILL_MODEL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
4
  "description": "Digital Operator PA: single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",