@stravica-ai/digital-operator-pa 0.1.60 → 0.1.62
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/dist/engineering-pa-cli/src/secrets/commands/backup.mjs +127 -14
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +9 -5
- package/dist/engineering-pa-core/docs/tools/distill.md +7 -5
- package/dist/engineering-pa-core/docs/tools/scheduler.md +3 -1
- package/dist/engineering-pa-core/src/distill/prompt.mjs +7 -4
- package/dist/engineering-pa-core/src/scheduler/install.mjs +43 -18
- package/dist/engineering-pa-core/src/scheduler/verify.mjs +13 -6
- package/package.json +1 -1
|
@@ -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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
@@ -108,10 +108,12 @@ The failure mode is silent: launchd records exit code 78 (`EX_CONFIG`), the Stan
|
|
|
108
108
|
|
|
109
109
|
Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process provides a small fixed environment (`SHELL`, a narrow system `PATH`; `/usr/bin:/bin`; `HOME`, `LOGNAME`/`USER`), NOT a login shell's env. A schedule whose `command` relies on `pnpm`/`node` being on PATH via nvm silently fails to spawn once actually installed, even if it "worked" when tested from an interactive terminal.
|
|
110
110
|
|
|
111
|
-
The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c
|
|
111
|
+
The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c '{ date -u; . $HOME/.nvm/nvm.sh && cd <workDir> && <command>; } >> /tmp/<agent>-<id>.log 2>&1'`; the `{ ...; }` braces are load-bearing: without them the `>> log 2>&1` binds only to `<command>` and any earlier link failing (nvm-source, cd) sends its stderr to cron mail, which hosts with no MTA silently discard (w-2026-09-13-dave-042; 0.1.39 fix, smarthome01 crons fired but wrote no log 2026-09-09 -> 13). The leading `date -u` prints an ISO timestamp so a run that dies mid-chain still leaves dated evidence. If no nvm install is found, the preamble is simply omitted; nothing to do for schedules that only need system binaries. `environment.*` values are emitted as `export VAR='val';` statements ahead of the command (not a bare `VAR='val' cd ...` prefix, which in POSIX shell only scopes to the `cd` builtin and never actually reaches the command after `&&`; a real, silent bug the old generator had).
|
|
112
112
|
|
|
113
113
|
`scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron.
|
|
114
114
|
|
|
115
|
+
**Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The systemd backend does not escape `%` in `ExecStart`, so systemd still reads `%` as a unit specifier there.
|
|
116
|
+
|
|
115
117
|
### Verify your schedule before walking away
|
|
116
118
|
|
|
117
119
|
After installing a schedule, run `verify.mjs` to confirm the command actually launches under the installed env. The verify deliberately uses the same env-derivation logic as the installer, so it catches the env-mismatch class of bugs (narrow PATH, missing HOME, etc.) before the job's first scheduled fire; instead of finding out two weeks later from a stale audit log.
|
|
@@ -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
|
-
//
|
|
86
|
-
//
|
|
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-
|
|
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;
|
|
@@ -371,16 +371,24 @@ export function defaultCronLogPath(sched) {
|
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
373
|
* Build the INNER shell script a cron entry (or a `scheduler verify` run
|
|
374
|
-
* simulating one) actually executes,
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* — an argv array, never a re-parsed/re-split string — while `install`
|
|
378
|
-
* embeds the wrapper form into the crontab line.
|
|
374
|
+
* simulating one) actually executes, the `bash -c '<inner>'` wrapper
|
|
375
|
+
* string, and the crontab command field (the wrapper with every `%`
|
|
376
|
+
* escaped as `\%`).
|
|
379
377
|
*
|
|
380
|
-
*
|
|
381
|
-
* `
|
|
382
|
-
*
|
|
383
|
-
*
|
|
378
|
+
* The wrapper is single-quoted, embedded single quotes rendered as
|
|
379
|
+
* `'\''`, exactly like the systemd backend's ExecStart. cron hands the
|
|
380
|
+
* command field to `/bin/sh -c`; inside the old double-quoted form sh
|
|
381
|
+
* expanded every `$(...)`, `$N` and `${VAR}` in the cron user's HOME
|
|
382
|
+
* before bash ever ran (smarthome01 workspace-refresh, w-2026-10-17-dave-006).
|
|
383
|
+
* Single quotes hand the inner script to bash untouched.
|
|
384
|
+
*
|
|
385
|
+
* cron turns an unescaped `%` into a newline and feeds the rest of the
|
|
386
|
+
* line to stdin, so `cronField` escapes every `%` as `\%`; cron strips
|
|
387
|
+
* the backslash before sh sees the line. `wrapped` is the string sh
|
|
388
|
+
* receives after that unescaping, which is what `verify` runs.
|
|
389
|
+
*
|
|
390
|
+
* Shape: `bash -c '{ date -u; [. $HOME/.nvm/nvm.sh && ]cd <workDir> &&
|
|
391
|
+
* <command>; } >> <log> 2>&1'`:
|
|
384
392
|
*
|
|
385
393
|
* [. $HOME/.nvm/nvm.sh && ]<export VAR='val'; >*cd <workDir> && <command> >> <logPath> 2>&1
|
|
386
394
|
*
|
|
@@ -397,7 +405,7 @@ export function defaultCronLogPath(sched) {
|
|
|
397
405
|
* @param {object} [opts]
|
|
398
406
|
* @param {boolean} [opts.hasNvm]
|
|
399
407
|
* @param {string} [opts.logPath]
|
|
400
|
-
* @returns {{ inner: string, wrapped: string }}
|
|
408
|
+
* @returns {{ inner: string, wrapped: string, cronField: string }}
|
|
401
409
|
*/
|
|
402
410
|
/**
|
|
403
411
|
* When the schedule's argv[0] is a bare `node`, swap in the absolute
|
|
@@ -444,14 +452,31 @@ export function buildCronCommand(sched, workDir, env, opts = {}) {
|
|
|
444
452
|
// file empty — the exact silent-failure class w-2026-09-13-dave-030 tracks
|
|
445
453
|
// (smarthome01 crons fired but never wrote a log line, 2026-09-09 -> 13).
|
|
446
454
|
// A leading `date -u` inside the group prints an ISO timestamp so a run
|
|
447
|
-
// that dies mid-chain still leaves dated evidence.
|
|
448
|
-
// the crontab line through /bin/sh -c '...' which parses the double-quoted
|
|
449
|
-
// bash-c argument and would eagerly expand $(...) at dispatch time, not
|
|
450
|
-
// at run time.
|
|
455
|
+
// that dies mid-chain still leaves dated evidence.
|
|
451
456
|
const chain = `date -u; ${preamble}${exportStmts}cd ${workDir} && ${resolvedCommand}`;
|
|
452
457
|
const inner = `{ ${chain}; } >> ${logPath} 2>&1`;
|
|
453
|
-
const wrapped = `bash -c
|
|
454
|
-
return { inner, wrapped };
|
|
458
|
+
const wrapped = `bash -c '${inner.replace(/'/g, `'\\''`)}'`;
|
|
459
|
+
return { inner, wrapped, cronField: escapeCronPercent(wrapped) };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Escape every `%` as `\%` for a crontab command field. cron otherwise
|
|
464
|
+
* treats `%` as a newline and sends the remainder to the command's stdin.
|
|
465
|
+
* @param {string} s
|
|
466
|
+
* @returns {string}
|
|
467
|
+
*/
|
|
468
|
+
export function escapeCronPercent(s) {
|
|
469
|
+
return s.replace(/%/g, '\\%');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Undo cron's own `\%` unescaping, yielding the string cron passes to
|
|
474
|
+
* `/bin/sh -c`. Inverse of escapeCronPercent.
|
|
475
|
+
* @param {string} s
|
|
476
|
+
* @returns {string}
|
|
477
|
+
*/
|
|
478
|
+
export function unescapeCronPercent(s) {
|
|
479
|
+
return s.replace(/\\%/g, '%');
|
|
455
480
|
}
|
|
456
481
|
|
|
457
482
|
/**
|
|
@@ -465,8 +490,8 @@ export function buildCronCommand(sched, workDir, env, opts = {}) {
|
|
|
465
490
|
*/
|
|
466
491
|
export function buildCronLine(sched, workDir, env, opts = {}) {
|
|
467
492
|
const tag = `${schedAgent(sched)}-${sched.id}`;
|
|
468
|
-
const {
|
|
469
|
-
return `${sched.schedule} ${
|
|
493
|
+
const { cronField } = buildCronCommand(sched, workDir, env, opts);
|
|
494
|
+
return `${sched.schedule} ${cronField} # ${tag}`;
|
|
470
495
|
}
|
|
471
496
|
|
|
472
497
|
function installCron(sched, workDir) {
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
resolveEnvironment,
|
|
37
37
|
withLaunchdDefaults,
|
|
38
38
|
buildCronCommand,
|
|
39
|
+
unescapeCronPercent,
|
|
39
40
|
detectNvm,
|
|
40
41
|
schedAgent,
|
|
41
42
|
} from './install.mjs';
|
|
@@ -250,8 +251,12 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
250
251
|
* For backend === 'cron', this does NOT run `sched.command` directly — it
|
|
251
252
|
* builds the exact GENERATED inner cron script (via install.mjs's
|
|
252
253
|
* `buildCronCommand`, the same function `scheduler install` uses to write
|
|
253
|
-
* the actual crontab line) and runs
|
|
254
|
-
*
|
|
254
|
+
* the actual crontab line) and runs it the way cron does: the crontab
|
|
255
|
+
* command field (`bash -c '...'`, `%` escaped as `\%`) has cron's own
|
|
256
|
+
* `\%` unescaping undone and is handed to `/bin/sh -c` as one argv
|
|
257
|
+
* element, from the working directory. Running the inner script under
|
|
258
|
+
* bash directly would skip the sh pass that cron performs, so quoting or
|
|
259
|
+
* `%` defects in the wrapper could never show (w-2026-10-17-dave-006). This is the "verification is honest
|
|
255
260
|
* about cron reality" fix: verifying `sched.command` alone, under an env
|
|
256
261
|
* that inherits the verifying process's PATH, never exercised the nvm
|
|
257
262
|
* preamble or the minimal-PATH reality a real cron invocation faces —
|
|
@@ -269,7 +274,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
269
274
|
* `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
|
|
270
275
|
* schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
|
|
271
276
|
* clobbers/interleaves with real cron output).
|
|
272
|
-
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, workDir: string, run: object }>}
|
|
277
|
+
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, workDir: string, run: object, argv: string[] | null }>}
|
|
273
278
|
*/
|
|
274
279
|
export async function verify(scheduleFile, opts = {}) {
|
|
275
280
|
const sched = readSchedule(scheduleFile);
|
|
@@ -287,12 +292,14 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
287
292
|
const env = computeBackendEnv(sched, backend);
|
|
288
293
|
|
|
289
294
|
let run;
|
|
295
|
+
let argv = null;
|
|
290
296
|
if (backend === 'cron') {
|
|
291
297
|
const explicit = resolveEnvironment(sched);
|
|
292
298
|
const hasNvm = opts.hasNvm ?? detectNvm();
|
|
293
299
|
const logPath = opts.cronLogPath ?? `/tmp/${schedAgent(sched)}-${sched.id}-verify.log`;
|
|
294
|
-
const {
|
|
295
|
-
|
|
300
|
+
const { cronField } = buildCronCommand(sched, workDir, explicit, { hasNvm, logPath });
|
|
301
|
+
argv = ['/bin/sh', '-c', unescapeCronPercent(cronField)];
|
|
302
|
+
run = await runVerifyArgv(argv[0], argv.slice(1), workDir, env, timeoutMs);
|
|
296
303
|
} else {
|
|
297
304
|
run = await runVerify(sched.command, workDir, env, timeoutMs);
|
|
298
305
|
}
|
|
@@ -313,7 +320,7 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
313
320
|
exitCode = 2;
|
|
314
321
|
}
|
|
315
322
|
|
|
316
|
-
return { result, exitCode, sched, backend, env, workDir, run };
|
|
323
|
+
return { result, exitCode, sched, backend, env, workDir, run, argv };
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
// ── CLI entry point ──────────────────────────────────────────────────
|
package/package.json
CHANGED