@stravica-ai/digital-operator-pa 0.1.61 → 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.
@@ -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 "{ 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).
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.
@@ -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, plus the `bash -c "<inner>"`
375
- * wrapper string. Kept separate from the inner content so `verify` can
376
- * run the exact same inner script via `spawn('bash', ['-c', inner], ...)`
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
- * Shape (matches the real house pattern, e.g.
381
- * `bash -c ". $HOME/.nvm/nvm.sh && cd /home/thefoot/dave-v2 && pnpm exec
382
- * pa knowledge eval --format line >> /tmp/dave-knowledge-eval-daily.log
383
- * 2>&1" # dave-knowledge-eval-daily`):
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. No `$()` — cron passes
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 "${inner.replace(/"/g, '\\"')}"`;
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 { wrapped } = buildCronCommand(sched, workDir, env, opts);
469
- return `${sched.schedule} ${wrapped} # ${tag}`;
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 THAT via `bash -c` with an exact argv
254
- * array (never a re-split string). This is the "verification is honest
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 { inner } = buildCronCommand(sched, workDir, explicit, { hasNvm, logPath });
295
- run = await runVerifyArgv('bash', ['-c', inner], workDir, env, timeoutMs);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
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",