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

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.
@@ -82,6 +82,8 @@ The runner spawns the command **directly** — no shell, no expansion, no operat
82
82
 
83
83
  If the command contains `'` or `"`, `task-loader` rejects it at load time with exit code 2 (a `--dry-run` also fails). This is a deliberate guard: silently splitting `bash -c "a && b"` on whitespace shreds it to `spawn('bash', ['-c', '"a', '&&', 'b"'])`, bash exits 2 with `unexpected EOF`, and cron mails the operator every interval until someone notices.
84
84
 
85
+ Shell operators are refused the same way, at load time and in the runner: a command with `;`, `&&`, `||`, `|`, `>`, `<`, `$(`, a backtick or `&` exits 2 with a message naming the schedule id and the offending token. Without a shell the operator would reach the program as a literal argument (`pnpm exec estate-sync run; node ...` handed estate-sync the subcommand `run;`).
86
+
85
87
  If you need shell features, you have two options:
86
88
 
87
89
  1. **Wrap the work in a script file** and put the script path in `command`: `command: "/path/to/wrapper.sh"`. The runner spawns the script; the script does whatever it likes.
@@ -43,6 +43,7 @@ function resolveWorkdir(workdir, repoRoot) {
43
43
  * expressions. The string is split on whitespace and passed to spawn()
44
44
  * WITHOUT a shell — no quoting, globbing, expansion, or operators are
45
45
  * honoured. Any single (') or double (") quote character in the command
46
+ * or shell operator (; && || | > < $( ` &)
46
47
  * is a configuration error: rejected via assertNoShellQuoting with
47
48
  * exitCode 2 (task-loader also asserts this at load time so --dry-run
48
49
  * catches it; the check here is the chokepoint guarantee for any caller
@@ -58,8 +59,8 @@ function resolveWorkdir(workdir, repoRoot) {
58
59
  * @returns {{ bin: string, args: string[] }}
59
60
  * @throws {Error & { exitCode: 2 }} on quoted or empty commands
60
61
  */
61
- function parseCommand(command) {
62
- assertNoShellQuoting(command);
62
+ function parseCommand(command, scheduleId) {
63
+ assertNoShellQuoting(command, scheduleId);
63
64
  const parts = command.trim().split(/\s+/);
64
65
  if (!parts[0]) {
65
66
  const e = new Error('command is empty');
@@ -70,7 +71,7 @@ function parseCommand(command) {
70
71
  }
71
72
 
72
73
  async function defaultSpawner(plan) {
73
- const { bin, args } = parseCommand(plan.task.command);
74
+ const { bin, args } = parseCommand(plan.task.command, plan.task.id);
74
75
  const cwd = resolveWorkdir(plan.task.workingDirectory, plan.repoRoot);
75
76
  return new Promise((resolve, reject) => {
76
77
  const child = spawn(bin, args, { cwd, stdio: 'inherit' });
@@ -16,11 +16,17 @@ const REQUIRED_FIELDS = ['id', 'command'];
16
16
  * re-asserted by runner.parseCommand as a chokepoint guarantee for
17
17
  * programmatic callers.
18
18
  *
19
+ * Shell operators (`;`, `&&`, `||`, `|`, `>`, `<`, `$(`, backticks, `&`)
20
+ * are refused for the same reason: without a shell they reach the program
21
+ * as literal args (`pnpm exec estate-sync run; node ...` handed
22
+ * estate-sync the subcommand `run;` and failed 34 runs in a row).
23
+ *
19
24
  * @param {string} command
25
+ * @param {string} [scheduleId] named in the operator refusal message
20
26
  * @throws {Error & { exitCode: 2 }} if command is not a non-empty string,
21
- * or contains a single (') or double (") quote character
27
+ * or contains a single (') or double (") quote character, or a shell operator
22
28
  */
23
- export function assertNoShellQuoting(command) {
29
+ export function assertNoShellQuoting(command, scheduleId) {
24
30
  if (typeof command !== 'string') {
25
31
  const e = new Error('command must be a string');
26
32
  e.exitCode = 2;
@@ -38,6 +44,34 @@ export function assertNoShellQuoting(command) {
38
44
  e.exitCode = 2;
39
45
  throw e;
40
46
  }
47
+ const op = findShellOperator(command);
48
+ if (op) {
49
+ const where = scheduleId ? `schedule "${scheduleId}"` : 'task command';
50
+ const e = new Error(
51
+ `${where} contains shell operator ${JSON.stringify(op.operator)} in token ` +
52
+ `${JSON.stringify(op.token)}: launch runs commands without a shell, so the operator ` +
53
+ 'would reach the program as a literal argument. Wrap the chain in a script file ' +
54
+ 'and invoke the script, or split it into separate schedules.'
55
+ );
56
+ e.exitCode = 2;
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ // Multi-char operators first so `&&` is reported as `&&`, not `&`.
62
+ const SHELL_OPERATORS = ['$(', '&&', '||', ';', '|', '>', '<', '`', '&'];
63
+
64
+ /**
65
+ * @param {string} command
66
+ * @returns {{ operator: string, token: string } | null}
67
+ */
68
+ export function findShellOperator(command) {
69
+ for (const token of command.trim().split(/\s+/)) {
70
+ for (const operator of SHELL_OPERATORS) {
71
+ if (token.includes(operator)) return { operator, token };
72
+ }
73
+ }
74
+ return null;
41
75
  }
42
76
 
43
77
  export async function loadTask(repoRoot, taskId) {
@@ -74,7 +108,7 @@ export async function loadTask(repoRoot, taskId) {
74
108
  }
75
109
 
76
110
  try {
77
- assertNoShellQuoting(data.command);
111
+ assertNoShellQuoting(data.command, data.id ?? taskId);
78
112
  } catch (err) {
79
113
  const e = new Error(`task "${taskId}": ${err.message}`);
80
114
  e.exitCode = err.exitCode ?? 2;
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
16
16
  // rewrites this string to a relative path inside the single-bundled
17
17
  // tarball.
18
18
  import { detect, install, list, remove, verify } from '../../../engineering-pa-core/src/scheduler/index.mjs';
19
+ import { parseTimeoutSeconds } from '../../../engineering-pa-core/src/scheduler/verify.mjs';
19
20
 
20
21
  const USAGE = `scheduler — operator-facing scheduling primitives
21
22
 
@@ -32,7 +33,9 @@ Usage:
32
33
  field wins (OS-native detect is the fallback).
33
34
  The label form the list verb prints as a
34
35
  secondary line is accepted here directly.
35
- scheduler verify <schedule.json> Verify a schedule fires correctly
36
+ scheduler verify <schedule.json> [--backend N] [--timeout <seconds>]
37
+ Verify a schedule fires correctly.
38
+ --timeout is in seconds (default 120).
36
39
  scheduler -h, --help Show this help
37
40
 
38
41
  Schedule files live at <repoRoot>/schedules/<id>.json. The canonical
@@ -189,7 +192,14 @@ export async function run(argv) {
189
192
  const schedulePath = resolve(positionals[0]);
190
193
  const opts = {};
191
194
  if (values.backend) opts.backend = values.backend;
192
- if (values.timeout) opts.timeoutMs = parseInt(values.timeout, 10);
195
+ if (values.timeout !== undefined) {
196
+ const ms = parseTimeoutSeconds(values.timeout);
197
+ if (ms === null) {
198
+ process.stderr.write(`[scheduler] verify: --timeout requires a positive number of seconds, got: ${values.timeout}\n`);
199
+ return 1;
200
+ }
201
+ opts.timeoutMs = ms;
202
+ }
193
203
  const result = await verify(schedulePath, opts);
194
204
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
195
205
  return result.exitCode ?? (result.ok ? 0 : 2);
@@ -108,9 +108,11 @@ 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
- `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.
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. The cron verify PATH is cron's base `/usr/bin:/bin`, then `/usr/local/bin`, then the PATH entries of `/etc/environment` when that file exists, because Debian cron reads `/etc/environment` through pam_env (w-2026-10-17-dave-007). A `PATH` in the schedule's own `environment` still replaces it. The result JSON carries the PATH used as `path`.
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 tag must end the line (w-2026-10-17-dave-007), so an id that is a prefix of another (`dave-foo` against `dave-foo-bar`) matches only its own line. When a schedule's `agent` field changes, `install` also removes the line with the same id under the old agent prefix and prints one line naming both tags. The systemd backend escapes `%` as `%%` and `$` as `$$` in `ExecStart` (w-2026-10-17-dave-007), because systemd resolves `%` specifiers and expands `$VAR` and `${VAR}` itself, inside single quotes too, before bash sees the command; bash then receives the command exactly as written.
114
116
 
115
117
  ### Verify your schedule before walking away
116
118
 
@@ -154,7 +156,7 @@ After installing, run the schedule's command once under the same environment the
154
156
  node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
155
157
  ```
156
158
 
157
- If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 30 seconds; override with `--timeout`:
159
+ If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 120 seconds; override with `--timeout`, which is in seconds on both `pa scheduler verify` and `verify.mjs` (w-2026-10-17-dave-007; the `pa` CLI used to read it as milliseconds):
158
160
 
159
161
  ```bash
160
162
  node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
@@ -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,46 @@ 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}`;
495
+ }
496
+
497
+ /**
498
+ * True when a crontab line carries `tag` (`# <agent>-<id>`) as its trailing
499
+ * comment. Anchored to the end of the line so `# dave-foo` does not also
500
+ * match `# dave-foo-bar` (w-2026-10-17-dave-007 item 2).
501
+ * @param {string} line
502
+ * @param {string} tag
503
+ * @returns {boolean}
504
+ */
505
+ export function cronLineHasTag(line, tag) {
506
+ return line.trimEnd().endsWith(tag);
507
+ }
508
+
509
+ /**
510
+ * True when a crontab line ends in any `# <agent>-<suffix>` tag for `agent`.
511
+ * @param {string} line
512
+ * @param {string} agent
513
+ * @returns {boolean}
514
+ */
515
+ export function cronLineHasAgentTag(line, agent) {
516
+ return /# (\S+)$/.exec(line.trimEnd())?.[1].startsWith(`${agent}-`) ?? false;
517
+ }
518
+
519
+ /**
520
+ * The tag of a crontab line installed for the same schedule id under a
521
+ * different agent (`# sam-<id>` when installing `# dave-<id>`), or null.
522
+ * The other agent prefix must be a single hyphen-free word so `# dave-foo-bar`
523
+ * is never read as agent `dave-foo` with id `bar` (w-2026-10-17-dave-007 item 5).
524
+ * @param {string} line
525
+ * @param {string} agent
526
+ * @param {string} id
527
+ * @returns {string | null}
528
+ */
529
+ export function otherAgentCronTag(line, agent, id) {
530
+ const m = /# ([A-Za-z0-9_]+)-(\S+)$/.exec(line.trimEnd());
531
+ if (!m || m[2] !== id || m[1] === agent) return null;
532
+ return `${m[1]}-${m[2]}`;
470
533
  }
471
534
 
472
535
  function installCron(sched, workDir) {
@@ -483,10 +546,17 @@ function installCron(sched, workDir) {
483
546
  // no crontab yet
484
547
  }
485
548
 
486
- // Remove any existing entry for this schedule
549
+ // Remove any existing entry for this schedule, including one left under
550
+ // another agent prefix when the schedule's `agent` field changed.
487
551
  const filtered = existing
488
552
  .split('\n')
489
- .filter(line => !line.includes(tag))
553
+ .filter((line) => {
554
+ if (cronLineHasTag(line, tag)) return false;
555
+ const other = otherAgentCronTag(line, agent, sched.id);
556
+ if (!other) return true;
557
+ console.log(`Removed cron line tagged ${other}; replaced by ${agent}-${sched.id} (agent changed)`);
558
+ return false;
559
+ })
490
560
  .join('\n')
491
561
  .replace(/^\n+|\n+$/g, '');
492
562
 
@@ -818,6 +888,23 @@ function installLaunchd(sched, workDir, cron) {
818
888
 
819
889
  // ── Backend: systemd ─────────────────────────────────────────────────
820
890
 
891
+ /**
892
+ * Escape a command for systemd's ExecStart parser. systemd resolves `%`
893
+ * specifiers when the unit is loaded and expands `$VAR` / `${VAR}` itself,
894
+ * inside single quotes too, before bash ever sees the line. `%%` is a
895
+ * literal percent and `$$` a literal dollar (systemd.unit(5) Specifiers,
896
+ * systemd.service(5) Command lines). The single quote is escaped for the
897
+ * `bash -c '...'` wrapper.
898
+ * @param {string} command
899
+ * @returns {string}
900
+ */
901
+ export function escapeSystemdExecCommand(command) {
902
+ return command
903
+ .replace(/%/g, '%%')
904
+ .replace(/\$/g, '$$$$')
905
+ .replace(/'/g, `'\\''`);
906
+ }
907
+
821
908
  /**
822
909
  * Build the .service unit contents.
823
910
  * @param {object} sched
@@ -837,7 +924,7 @@ Description=${description}
837
924
  [Service]
838
925
  Type=oneshot
839
926
  WorkingDirectory=${workDir}
840
- ${envBlock}ExecStart=/usr/bin/env bash -c '${sched.command.replace(/'/g, `'\\''`)}'
927
+ ${envBlock}ExecStart=/usr/bin/env bash -c '${escapeSystemdExecCommand(sched.command)}'
841
928
 
842
929
  [Install]
843
930
  WantedBy=default.target
@@ -24,7 +24,7 @@ import { resolveHome } from '../home/resolve.mjs';
24
24
  import { execSync } from 'node:child_process';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { detect } from './detect.mjs';
27
- import { launchdLabelPrefix } from './install.mjs';
27
+ import { launchdLabelPrefix, cronLineHasAgentTag } from './install.mjs';
28
28
  import { resolveAgent } from './resolve-agent.mjs';
29
29
 
30
30
  /**
@@ -70,12 +70,10 @@ export function list(backend, opts = {}) {
70
70
  function listCron(agent) {
71
71
  const lines = [`${headerName(agent)} schedules (cron):`, '─'.repeat(37)];
72
72
  let found = false;
73
- const tag = `# ${agent}-`;
74
-
75
73
  try {
76
74
  const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
77
75
  for (const line of crontab.split('\n')) {
78
- if (line.includes(tag)) {
76
+ if (cronLineHasAgentTag(line, agent)) {
79
77
  lines.push(` ${line}`);
80
78
  found = true;
81
79
  }
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path';
23
23
  import { resolveHome } from '../home/resolve.mjs';
24
24
  import { execSync } from 'node:child_process';
25
25
  import { fileURLToPath } from 'node:url';
26
- import { launchdLabelPrefix } from './install.mjs';
26
+ import { launchdLabelPrefix, cronLineHasTag } from './install.mjs';
27
27
  import { findRepoRootSync, walkUpForMarker } from '../instance-root/find.mjs';
28
28
  import { resolveAgent } from './resolve-agent.mjs';
29
29
  import { detect } from './detect.mjs';
@@ -44,7 +44,7 @@ function removeCron(schedId, agent) {
44
44
  }
45
45
 
46
46
  const lines = existing.split('\n');
47
- const filtered = lines.filter(line => !line.includes(tag)).join('\n').replace(/^\n+|\n+$/g, '');
47
+ const filtered = lines.filter(line => !cronLineHasTag(line, tag)).join('\n').replace(/^\n+|\n+$/g, '');
48
48
 
49
49
  if (filtered === existing.replace(/^\n+|\n+$/g, '')) {
50
50
  console.log(`No cron entry found for ${agent}-${schedId}`);
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * The verify deliberately does not require the schedule to be installed already —
13
13
  * it derives the env the same way install.mjs does and runs the command once. A
14
- * timeout (default 30s) bounds the run; verify is meant for "does this schedule
14
+ * timeout (default 120s) bounds the run; verify is meant for "does this schedule
15
15
  * even start?" not "does it complete its full work?".
16
16
  *
17
17
  * Backend defaults to the auto-detected backend for the host (launchd on macOS,
@@ -25,7 +25,7 @@
25
25
  * 4 timeout — command did not finish within the timeout
26
26
  */
27
27
 
28
- import { readFileSync, realpathSync } from 'node:fs';
28
+ import { readFileSync, realpathSync, existsSync } from 'node:fs';
29
29
  import { spawn } from 'node:child_process';
30
30
  import { resolve, dirname, join } from 'node:path';
31
31
  import { resolveHome } from '../home/resolve.mjs';
@@ -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';
@@ -67,6 +68,54 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
67
68
  };
68
69
  }
69
70
 
71
+ /** Default `scheduler verify --timeout`, in seconds (both CLI paths). */
72
+ export const DEFAULT_VERIFY_TIMEOUT_SECONDS = 120;
73
+
74
+ /**
75
+ * Parse a `--timeout` value given in seconds into milliseconds. Both the
76
+ * `pa scheduler verify` CLI and this module's own CLI read seconds
77
+ * (w-2026-10-17-dave-007 item 3).
78
+ * @param {string | number} value
79
+ * @returns {number | null} milliseconds, or null when not a positive number
80
+ */
81
+ export function parseTimeoutSeconds(value) {
82
+ const n = Number(value);
83
+ if (value === '' || value == null || !Number.isFinite(n) || n <= 0) return null;
84
+ return Math.round(n * 1000);
85
+ }
86
+
87
+ /**
88
+ * Read the PATH entries from an `/etc/environment`-format file (pam_env
89
+ * hands this to cron jobs on Debian). Returns [] when the file is absent
90
+ * or carries no PATH line.
91
+ * @param {string} file
92
+ * @returns {string[]}
93
+ */
94
+ export function readEtcEnvironmentPath(file) {
95
+ if (!existsSync(file)) return [];
96
+ let pathValue = null;
97
+ for (const raw of readFileSync(file, 'utf8').split('\n')) {
98
+ const m = /^\s*(?:export\s+)?PATH\s*=\s*(.*?)\s*$/.exec(raw);
99
+ if (m) pathValue = m[1].replace(/^(["'])(.*)\1$/, '$2');
100
+ }
101
+ return pathValue ? pathValue.split(':').filter(Boolean) : [];
102
+ }
103
+
104
+ /**
105
+ * The PATH verify uses for the cron backend: cron's minimal base, then
106
+ * `/usr/local/bin`, then the PATH entries of `/etc/environment` when it
107
+ * exists, without duplicates (w-2026-10-17-dave-007 item 4). Real cron on
108
+ * Debian gets `/etc/environment` through pam_env, so the bare base alone
109
+ * was stricter than the runtime.
110
+ * @param {string} basePath
111
+ * @param {string} [etcEnvironmentFile]
112
+ * @returns {string}
113
+ */
114
+ export function composeCronVerifyPath(basePath, etcEnvironmentFile = '/etc/environment') {
115
+ const entries = [...basePath.split(':'), '/usr/local/bin', ...readEtcEnvironmentPath(etcEnvironmentFile)];
116
+ return [...new Set(entries.filter(Boolean))].join(':');
117
+ }
118
+
70
119
  /**
71
120
  * Compute the env the named backend would install for this schedule.
72
121
  *
@@ -87,9 +136,11 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
87
136
  *
88
137
  * @param {object} sched
89
138
  * @param {string} backend
139
+ * @param {object} [opts]
140
+ * @param {string} [opts.etcEnvironmentFile] - cron only; defaults to /etc/environment
90
141
  * @returns {Record<string,string>}
91
142
  */
92
- export function computeBackendEnv(sched, backend) {
143
+ export function computeBackendEnv(sched, backend, opts = {}) {
93
144
  const explicit = resolveEnvironment(sched);
94
145
  if (backend === 'launchd') {
95
146
  // launchd builds the agent env from EnvironmentVariables alone. Verify
@@ -97,7 +148,9 @@ export function computeBackendEnv(sched, backend) {
97
148
  return withLaunchdDefaults(explicit);
98
149
  }
99
150
  if (backend === 'cron') {
100
- return { ...minimalCronEnv(), ...explicit };
151
+ const base = minimalCronEnv();
152
+ base.PATH = composeCronVerifyPath(base.PATH, opts.etcEnvironmentFile);
153
+ return { ...base, ...explicit };
101
154
  }
102
155
  // systemd/schtasks: "parent env then explicit overrides". Replicate by
103
156
  // layering explicit over process.env, dropping any keys the parent has
@@ -250,8 +303,12 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
250
303
  * For backend === 'cron', this does NOT run `sched.command` directly — it
251
304
  * builds the exact GENERATED inner cron script (via install.mjs's
252
305
  * `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
306
+ * the actual crontab line) and runs it the way cron does: the crontab
307
+ * command field (`bash -c '...'`, `%` escaped as `\%`) has cron's own
308
+ * `\%` unescaping undone and is handed to `/bin/sh -c` as one argv
309
+ * element, from the working directory. Running the inner script under
310
+ * bash directly would skip the sh pass that cron performs, so quoting or
311
+ * `%` defects in the wrapper could never show (w-2026-10-17-dave-006). This is the "verification is honest
255
312
  * about cron reality" fix: verifying `sched.command` alone, under an env
256
313
  * that inherits the verifying process's PATH, never exercised the nvm
257
314
  * preamble or the minimal-PATH reality a real cron invocation faces —
@@ -261,7 +318,9 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
261
318
  * @param {string} scheduleFile - Path to the schedule JSON
262
319
  * @param {object} [opts]
263
320
  * @param {string} [opts.backend] - Override backend (default: auto-detect)
264
- * @param {number} [opts.timeoutMs] - Timeout in ms (default: 30000)
321
+ * @param {number} [opts.timeoutMs] - Timeout in ms (default: 120000)
322
+ * @param {string} [opts.etcEnvironmentFile] - Test-only override for the
323
+ * cron backend's `/etc/environment` path.
265
324
  * @param {boolean} [opts.hasNvm] - Test-only override; defaults to a real
266
325
  * filesystem check of $HOME/.nvm/nvm.sh, matching install-time detection.
267
326
  * @param {string} [opts.cronLogPath] - Test-only override for the cron
@@ -269,7 +328,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
269
328
  * `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
270
329
  * schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
271
330
  * 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 }>}
331
+ * @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, path: string | null, workDir: string, run: object, argv: string[] | null }>}
273
332
  */
274
333
  export async function verify(scheduleFile, opts = {}) {
275
334
  const sched = readSchedule(scheduleFile);
@@ -281,18 +340,20 @@ export async function verify(scheduleFile, opts = {}) {
281
340
  }
282
341
 
283
342
  const backend = opts.backend || sched.backend || detectDefaultBackend();
284
- const timeoutMs = opts.timeoutMs ?? 30000;
343
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_SECONDS * 1000;
285
344
 
286
345
  const workDir = resolveWorkDir(sched.workingDirectory);
287
- const env = computeBackendEnv(sched, backend);
346
+ const env = computeBackendEnv(sched, backend, { etcEnvironmentFile: opts.etcEnvironmentFile });
288
347
 
289
348
  let run;
349
+ let argv = null;
290
350
  if (backend === 'cron') {
291
351
  const explicit = resolveEnvironment(sched);
292
352
  const hasNvm = opts.hasNvm ?? detectNvm();
293
353
  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);
354
+ const { cronField } = buildCronCommand(sched, workDir, explicit, { hasNvm, logPath });
355
+ argv = ['/bin/sh', '-c', unescapeCronPercent(cronField)];
356
+ run = await runVerifyArgv(argv[0], argv.slice(1), workDir, env, timeoutMs);
296
357
  } else {
297
358
  run = await runVerify(sched.command, workDir, env, timeoutMs);
298
359
  }
@@ -313,7 +374,7 @@ export async function verify(scheduleFile, opts = {}) {
313
374
  exitCode = 2;
314
375
  }
315
376
 
316
- return { result, exitCode, sched, backend, env, workDir, run };
377
+ return { result, exitCode, sched, backend, env, path: env.PATH ?? null, workDir, run, argv };
317
378
  }
318
379
 
319
380
  // ── CLI entry point ──────────────────────────────────────────────────
@@ -332,7 +393,7 @@ Arguments:
332
393
  systemd, schtasks). Defaults to auto-detected backend.
333
394
 
334
395
  Flags:
335
- --timeout <seconds> Kill the verify run after this many seconds (default: 30).
396
+ --timeout <seconds> Kill the verify run after this many seconds (default: 120).
336
397
  -h, --help Print this message and exit 0.
337
398
 
338
399
  Exit codes:
@@ -368,7 +429,7 @@ if (isDirectlyInvoked()) {
368
429
  // Parse: positional <schedule.json>, optional positional <backend>, optional --timeout N.
369
430
  let scheduleFile = null;
370
431
  let backend = null;
371
- let timeoutSeconds = 30;
432
+ let timeoutSeconds = DEFAULT_VERIFY_TIMEOUT_SECONDS;
372
433
 
373
434
  for (let i = 0; i < args.length; i++) {
374
435
  const a = args[i];
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.63",
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",