@stravica-ai/digital-operator-pa 0.1.62 → 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);
@@ -110,9 +110,9 @@ Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process pro
110
110
 
111
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
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.
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.
116
116
 
117
117
  ### Verify your schedule before walking away
118
118
 
@@ -156,7 +156,7 @@ After installing, run the schedule's command once under the same environment the
156
156
  node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
157
157
  ```
158
158
 
159
- 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):
160
160
 
161
161
  ```bash
162
162
  node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
@@ -494,6 +494,44 @@ export function buildCronLine(sched, workDir, env, opts = {}) {
494
494
  return `${sched.schedule} ${cronField} # ${tag}`;
495
495
  }
496
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]}`;
533
+ }
534
+
497
535
  function installCron(sched, workDir) {
498
536
  const agent = schedAgent(sched);
499
537
  const env = resolveEnvironment(sched);
@@ -508,10 +546,17 @@ function installCron(sched, workDir) {
508
546
  // no crontab yet
509
547
  }
510
548
 
511
- // 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.
512
551
  const filtered = existing
513
552
  .split('\n')
514
- .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
+ })
515
560
  .join('\n')
516
561
  .replace(/^\n+|\n+$/g, '');
517
562
 
@@ -843,6 +888,23 @@ function installLaunchd(sched, workDir, cron) {
843
888
 
844
889
  // ── Backend: systemd ─────────────────────────────────────────────────
845
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
+
846
908
  /**
847
909
  * Build the .service unit contents.
848
910
  * @param {object} sched
@@ -862,7 +924,7 @@ Description=${description}
862
924
  [Service]
863
925
  Type=oneshot
864
926
  WorkingDirectory=${workDir}
865
- ${envBlock}ExecStart=/usr/bin/env bash -c '${sched.command.replace(/'/g, `'\\''`)}'
927
+ ${envBlock}ExecStart=/usr/bin/env bash -c '${escapeSystemdExecCommand(sched.command)}'
866
928
 
867
929
  [Install]
868
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';
@@ -68,6 +68,54 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
68
68
  };
69
69
  }
70
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
+
71
119
  /**
72
120
  * Compute the env the named backend would install for this schedule.
73
121
  *
@@ -88,9 +136,11 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
88
136
  *
89
137
  * @param {object} sched
90
138
  * @param {string} backend
139
+ * @param {object} [opts]
140
+ * @param {string} [opts.etcEnvironmentFile] - cron only; defaults to /etc/environment
91
141
  * @returns {Record<string,string>}
92
142
  */
93
- export function computeBackendEnv(sched, backend) {
143
+ export function computeBackendEnv(sched, backend, opts = {}) {
94
144
  const explicit = resolveEnvironment(sched);
95
145
  if (backend === 'launchd') {
96
146
  // launchd builds the agent env from EnvironmentVariables alone. Verify
@@ -98,7 +148,9 @@ export function computeBackendEnv(sched, backend) {
98
148
  return withLaunchdDefaults(explicit);
99
149
  }
100
150
  if (backend === 'cron') {
101
- return { ...minimalCronEnv(), ...explicit };
151
+ const base = minimalCronEnv();
152
+ base.PATH = composeCronVerifyPath(base.PATH, opts.etcEnvironmentFile);
153
+ return { ...base, ...explicit };
102
154
  }
103
155
  // systemd/schtasks: "parent env then explicit overrides". Replicate by
104
156
  // layering explicit over process.env, dropping any keys the parent has
@@ -266,7 +318,9 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
266
318
  * @param {string} scheduleFile - Path to the schedule JSON
267
319
  * @param {object} [opts]
268
320
  * @param {string} [opts.backend] - Override backend (default: auto-detect)
269
- * @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.
270
324
  * @param {boolean} [opts.hasNvm] - Test-only override; defaults to a real
271
325
  * filesystem check of $HOME/.nvm/nvm.sh, matching install-time detection.
272
326
  * @param {string} [opts.cronLogPath] - Test-only override for the cron
@@ -274,7 +328,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
274
328
  * `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
275
329
  * schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
276
330
  * clobbers/interleaves with real cron output).
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 }>}
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 }>}
278
332
  */
279
333
  export async function verify(scheduleFile, opts = {}) {
280
334
  const sched = readSchedule(scheduleFile);
@@ -286,10 +340,10 @@ export async function verify(scheduleFile, opts = {}) {
286
340
  }
287
341
 
288
342
  const backend = opts.backend || sched.backend || detectDefaultBackend();
289
- const timeoutMs = opts.timeoutMs ?? 30000;
343
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_SECONDS * 1000;
290
344
 
291
345
  const workDir = resolveWorkDir(sched.workingDirectory);
292
- const env = computeBackendEnv(sched, backend);
346
+ const env = computeBackendEnv(sched, backend, { etcEnvironmentFile: opts.etcEnvironmentFile });
293
347
 
294
348
  let run;
295
349
  let argv = null;
@@ -320,7 +374,7 @@ export async function verify(scheduleFile, opts = {}) {
320
374
  exitCode = 2;
321
375
  }
322
376
 
323
- return { result, exitCode, sched, backend, env, workDir, run, argv };
377
+ return { result, exitCode, sched, backend, env, path: env.PATH ?? null, workDir, run, argv };
324
378
  }
325
379
 
326
380
  // ── CLI entry point ──────────────────────────────────────────────────
@@ -339,7 +393,7 @@ Arguments:
339
393
  systemd, schtasks). Defaults to auto-detected backend.
340
394
 
341
395
  Flags:
342
- --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).
343
397
  -h, --help Print this message and exit 0.
344
398
 
345
399
  Exit codes:
@@ -375,7 +429,7 @@ if (isDirectlyInvoked()) {
375
429
  // Parse: positional <schedule.json>, optional positional <backend>, optional --timeout N.
376
430
  let scheduleFile = null;
377
431
  let backend = null;
378
- let timeoutSeconds = 30;
432
+ let timeoutSeconds = DEFAULT_VERIFY_TIMEOUT_SECONDS;
379
433
 
380
434
  for (let i = 0; i < args.length; i++) {
381
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.62",
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",