@north-light/crouter 0.3.301 → 0.3.303
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/api/dto/reports.d.ts +8 -8
- package/dist/clients/attach/viewer.js +394 -394
- package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
- package/dist/commands/memory/read.js +12 -14
- package/dist/commands/node/lifecycle.js +7 -6
- package/dist/commands/push.js +4 -4
- package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/integration/revive.test.js +3 -3
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
- package/dist/core/__tests__/stop-guard.test.js +64 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/canvas/crons.d.ts +20 -4
- package/dist/core/canvas/crons.js +54 -7
- package/dist/core/memory-resolver.d.ts +3 -1
- package/dist/core/memory-resolver.js +3 -3
- package/dist/core/nested-stores.d.ts +1 -1
- package/dist/core/nested-stores.js +3 -3
- package/dist/core/runtime/bearings-render.js +1 -1
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +66 -6
- package/dist/core/runtime/close.js +8 -6
- package/dist/core/runtime/stop-guard.js +6 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/core/worktree.js +3 -6
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +2 -2
- package/dist/daemon/cron/sinks.d.ts +1 -1
- package/dist/daemon/cron/sinks.js +2 -2
- package/dist/daemon/reconcilers/live-obligation.js +2 -2
- package/dist/pi-extensions/canvas-bash-valve.d.ts +0 -3
- package/dist/pi-extensions/canvas-bash-valve.js +2 -34
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
- package/dist/daemon/cron-sink.d.ts +0 -26
- package/dist/daemon/cron-sink.js +0 -43
|
@@ -22,6 +22,7 @@ import assert from 'node:assert/strict';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
|
|
25
|
+
import { acquireCronRunLease, armCron, setCronState } from '../../canvas/crons.js';
|
|
25
26
|
import { registerWarmSpare, subscribe, subscriptionsOf, updateNode } from '../../canvas/index.js';
|
|
26
27
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
27
28
|
import { reviveNode } from '../../runtime/revive.js';
|
|
@@ -45,6 +46,47 @@ async function awaitReleased(nodeId) {
|
|
|
45
46
|
return node?.status === 'idle' && node.intent === 'idle-release' && typeof node.pi_pid === 'number' && !isPidAlive(node.pi_pid);
|
|
46
47
|
}, { label: `idle-release broker exit for ${nodeId}` });
|
|
47
48
|
}
|
|
49
|
+
async function assertCronWakeReleases(nodeId, running) {
|
|
50
|
+
const cronId = `${nodeId}-cron`;
|
|
51
|
+
const target = h.fabricateBrokerNode({ id: nodeId, parent: root, status: 'active' });
|
|
52
|
+
const now = new Date();
|
|
53
|
+
armCron({
|
|
54
|
+
cron_id: cronId,
|
|
55
|
+
name: cronId,
|
|
56
|
+
created_by: null,
|
|
57
|
+
command: 'printf wake',
|
|
58
|
+
fire_at: new Date(now.getTime() + 3_600_000).toISOString(),
|
|
59
|
+
recur: null,
|
|
60
|
+
tz: null,
|
|
61
|
+
expires_at: null,
|
|
62
|
+
anchor_node: nodeId,
|
|
63
|
+
cancel_on_wake: false,
|
|
64
|
+
cwd: h.home,
|
|
65
|
+
env_json: null,
|
|
66
|
+
profile: null,
|
|
67
|
+
scope: 'profile',
|
|
68
|
+
run_timeout_s: 30,
|
|
69
|
+
overlap: 'skip',
|
|
70
|
+
on_output: 'always',
|
|
71
|
+
sink: JSON.stringify({ node: nodeId }),
|
|
72
|
+
tier: 'normal',
|
|
73
|
+
});
|
|
74
|
+
if (running) {
|
|
75
|
+
assert.equal(acquireCronRunLease(cronId, 'seam-fixture', process.pid, now.toISOString(), false), true);
|
|
76
|
+
setCronState(cronId, 'paused');
|
|
77
|
+
}
|
|
78
|
+
updateNode(target, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
79
|
+
await reviveNode(target, { resume: true, capacity: 'freeze' });
|
|
80
|
+
await h.awaitBoot(target);
|
|
81
|
+
await h.stop(target);
|
|
82
|
+
await awaitReleased(target);
|
|
83
|
+
await h.awaitFleetExit(target);
|
|
84
|
+
assert.equal(h.node(target)?.status, 'idle', `${running ? 'running' : 'scheduled'} cron did not trigger a stop-guard reprompt`);
|
|
85
|
+
}
|
|
86
|
+
test('an anchored node-sink cron releases a terminal broker when scheduled or in flight', { timeout: 60_000 }, async () => {
|
|
87
|
+
await assertCronWakeReleases('scheduled-cron-wake', false);
|
|
88
|
+
await assertCronWakeReleases('running-cron-wake', true);
|
|
89
|
+
});
|
|
48
90
|
test('idle-release settles its real watcher before exit; daemon revives exactly once for a later row', { timeout: 30_000 }, async () => {
|
|
49
91
|
// Reproduce the observed #353/#362 edge: real pi reported
|
|
50
92
|
// ctx.hasPendingMessages() === true at the sole agent_settled even though the
|
|
@@ -9,7 +9,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { createNode, deliverAndSettleMessageWait, getNode, subscribe, setMessageWait, setStatus } from '../canvas/canvas.js';
|
|
12
|
+
import { armCron, cancelCron, setCronState } from '../canvas/crons.js';
|
|
12
13
|
import { closeDb, openDb } from '../canvas/db.js';
|
|
14
|
+
import { hasLiveObligation } from '../../daemon/reconcilers/live-obligation.js';
|
|
13
15
|
import { contextDir } from '../canvas/paths.js';
|
|
14
16
|
import { bashJobPaths } from '../bash-jobs.js';
|
|
15
17
|
import { evaluateStop } from '../runtime/stop-guard.js';
|
|
@@ -29,6 +31,29 @@ function node(id, over = {}) {
|
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
const noSignals = { pushedFinal: false, requestedHumanReply: false };
|
|
34
|
+
function arm(overrides) {
|
|
35
|
+
armCron({
|
|
36
|
+
name: overrides.cron_id,
|
|
37
|
+
created_by: null,
|
|
38
|
+
command: 'printf cron',
|
|
39
|
+
fire_at: new Date(Date.now() + 3_600_000).toISOString(),
|
|
40
|
+
recur: null,
|
|
41
|
+
tz: null,
|
|
42
|
+
expires_at: null,
|
|
43
|
+
anchor_node: null,
|
|
44
|
+
cancel_on_wake: false,
|
|
45
|
+
cwd: home,
|
|
46
|
+
env_json: null,
|
|
47
|
+
profile: null,
|
|
48
|
+
scope: 'profile',
|
|
49
|
+
run_timeout_s: 30,
|
|
50
|
+
overlap: 'skip',
|
|
51
|
+
on_output: 'always',
|
|
52
|
+
sink: '{}',
|
|
53
|
+
tier: 'normal',
|
|
54
|
+
...overrides,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
32
57
|
before(() => {
|
|
33
58
|
home = mkdtempSync(join(tmpdir(), 'crtr-stopguard-'));
|
|
34
59
|
process.env['CRTR_HOME'] = home;
|
|
@@ -82,6 +107,45 @@ test('a TERMINAL node awaiting a background bash job may dorm until its completi
|
|
|
82
107
|
assert.equal(completed.action, 'reprompt');
|
|
83
108
|
assert.equal(completed.reason, 'stalled');
|
|
84
109
|
});
|
|
110
|
+
test('a pending node-sink cron wake releases terminal nodes and holds unattended nodes', () => {
|
|
111
|
+
createNode(node('target'));
|
|
112
|
+
const assertsWake = (id) => {
|
|
113
|
+
assert.deepEqual(evaluateStop('target', noSignals), { action: 'allow', reason: 'scheduled' }, id);
|
|
114
|
+
assert.equal(hasLiveObligation('target'), true, `${id} holds the unattended node`);
|
|
115
|
+
};
|
|
116
|
+
const assertsNoWake = (id) => {
|
|
117
|
+
assert.equal(evaluateStop('target', noSignals).action, 'reprompt', id);
|
|
118
|
+
assert.equal(hasLiveObligation('target'), false, `${id} does not hold the unattended node`);
|
|
119
|
+
};
|
|
120
|
+
arm({ cron_id: 'scheduled', anchor_node: 'target', sink: JSON.stringify({ node: 'target' }) });
|
|
121
|
+
assertsWake('an anchored scheduled always sink');
|
|
122
|
+
cancelCron('scheduled');
|
|
123
|
+
arm({ cron_id: 'running', anchor_node: 'target', sink: JSON.stringify({ node: 'target' }) });
|
|
124
|
+
openDb().prepare("UPDATE crons SET run_state = 'running' WHERE cron_id = ?").run('running');
|
|
125
|
+
setCronState('running', 'paused');
|
|
126
|
+
assertsWake('a paused in-flight sink');
|
|
127
|
+
cancelCron('running');
|
|
128
|
+
arm({ cron_id: 'unanchored-change', on_output: 'on-change', sink: JSON.stringify({ node: 'target' }) });
|
|
129
|
+
assertsWake('an unanchored on-change sink');
|
|
130
|
+
cancelCron('unanchored-change');
|
|
131
|
+
arm({ cron_id: 'other-target', sink: JSON.stringify({ node: 'other' }) });
|
|
132
|
+
assertsNoWake('a sink for another node');
|
|
133
|
+
cancelCron('other-target');
|
|
134
|
+
arm({ cron_id: 'silent', on_output: 'silent', sink: JSON.stringify({ node: 'target' }) });
|
|
135
|
+
assertsNoWake('a silent cron');
|
|
136
|
+
cancelCron('silent');
|
|
137
|
+
arm({ cron_id: 'on-failure', on_output: 'on-failure', sink: JSON.stringify({ node: 'target' }) });
|
|
138
|
+
assertsNoWake('an on-failure cron');
|
|
139
|
+
cancelCron('on-failure');
|
|
140
|
+
arm({ cron_id: 'expired', expires_at: new Date(Date.now() - 1_000).toISOString(), sink: JSON.stringify({ node: 'target' }) });
|
|
141
|
+
assertsNoWake('an expired idle cron');
|
|
142
|
+
cancelCron('expired');
|
|
143
|
+
arm({ cron_id: 'canceled', sink: JSON.stringify({ node: 'target' }) });
|
|
144
|
+
cancelCron('canceled');
|
|
145
|
+
assertsNoWake('a canceled cron');
|
|
146
|
+
arm({ cron_id: 'deadline', anchor_node: 'target', cancel_on_wake: true, on_output: 'on-failure' });
|
|
147
|
+
assertsWake('an active deadline');
|
|
148
|
+
});
|
|
85
149
|
test('only an explicit live controller wait permits dormancy; ancestry alone does not', () => {
|
|
86
150
|
createNode(node('parent', { lifecycle: 'terminal', status: 'active' }));
|
|
87
151
|
createNode(node('child', { parent: 'parent' }));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
2
|
+
* not open with a sleep (or its duration isn't statically knowable). */
|
|
3
|
+
export declare function leadingSleepSeconds(command: string): number | null;
|
|
4
|
+
/** The refusal this command earns before it runs, or null when it may run.
|
|
5
|
+
* `cwd` is the directory the command will execute in. */
|
|
6
|
+
export declare function bashCommandRefusal(command: string, cwd: string, home?: string): string | null;
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// bash-guard.ts — the refusals the bash valve applies to a command's TEXT
|
|
2
|
+
// before any of it runs. Pure: command text and the node's cwd in, a refusal
|
|
3
|
+
// message or null out.
|
|
4
|
+
//
|
|
5
|
+
// Two refusals live here, for the same reason: both are things no correct
|
|
6
|
+
// agent command ever does, and both are cheaper to stop than to recover from.
|
|
7
|
+
//
|
|
8
|
+
// - A long leading `sleep` burns a live window on a clock the runtime already
|
|
9
|
+
// owns a cheaper primitive for.
|
|
10
|
+
// - A destructive command aimed at the home directory, a system root, or a
|
|
11
|
+
// directory above the node's own working directory is unrecoverable.
|
|
12
|
+
//
|
|
13
|
+
// The second exists because of a real incident. A node ran:
|
|
14
|
+
//
|
|
15
|
+
// cd star-theory-2 && HOME="$(mktemp -d …)" godot … ; rm -rf "$HOME" …
|
|
16
|
+
//
|
|
17
|
+
// A `VAR=value cmd` prefix applies only to that ONE command, so the trailing
|
|
18
|
+
// `rm -rf "$HOME"` used the real home directory and deleted for 108 seconds
|
|
19
|
+
// before another node killed it. A memory doc telling agents not to do this
|
|
20
|
+
// already exists; it is advisory and did not survive contact. This is the
|
|
21
|
+
// block.
|
|
22
|
+
//
|
|
23
|
+
// What this is NOT: a sandbox. Command text is read best-effort — expansions,
|
|
24
|
+
// subshells, `eval`, and a script the command invokes are all out of reach. It
|
|
25
|
+
// stops the plausible mistake a competent agent makes, not an agent trying to
|
|
26
|
+
// get around it. Anything it cannot resolve, it allows: a guard that guesses
|
|
27
|
+
// would refuse ordinary work, and an agent that learns the guard is noisy
|
|
28
|
+
// starts routing around it.
|
|
29
|
+
import { homedir, userInfo } from 'node:os';
|
|
30
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
31
|
+
import { commandSegments, stripHeredocs } from './shell-segments.js';
|
|
32
|
+
// Long-sleep guard. A command that opens with `sleep <long>` is a node burning
|
|
33
|
+
// a live window to wait on a clock — the runtime already has waiting primitives
|
|
34
|
+
// that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
|
|
35
|
+
// for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
|
|
36
|
+
const MAX_INLINE_SLEEP_SECONDS = 10;
|
|
37
|
+
const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
38
|
+
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
39
|
+
* not open with a sleep (or its duration isn't statically knowable). */
|
|
40
|
+
export function leadingSleepSeconds(command) {
|
|
41
|
+
const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
|
|
42
|
+
if (!match)
|
|
43
|
+
return null;
|
|
44
|
+
// `sleep 1 2m 3` sums its operands.
|
|
45
|
+
let total = 0;
|
|
46
|
+
for (const operand of match[1].trim().split(/\s+/)) {
|
|
47
|
+
const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
|
|
48
|
+
if (!parsed)
|
|
49
|
+
return null; // variable/expansion/flag — not statically knowable
|
|
50
|
+
total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
function longSleepRefusal(command) {
|
|
55
|
+
const seconds = leadingSleepSeconds(command);
|
|
56
|
+
if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
|
|
57
|
+
return null;
|
|
58
|
+
return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
|
|
59
|
+
`Holding a live window on a clock is never how you wait here. Instead:\n` +
|
|
60
|
+
` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
|
|
61
|
+
` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
|
|
62
|
+
` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
|
|
63
|
+
`If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
|
|
64
|
+
}
|
|
65
|
+
// Protected paths
|
|
66
|
+
/** System directories whose removal, relocation, or recursive permission
|
|
67
|
+
* change breaks the machine rather than the task. Matched EXACTLY after
|
|
68
|
+
* resolution — `/tmp` is protected, `/tmp/build-1234` is ordinary work. */
|
|
69
|
+
const PROTECTED_SYSTEM_PATHS = [
|
|
70
|
+
'/',
|
|
71
|
+
'/Applications',
|
|
72
|
+
'/bin',
|
|
73
|
+
'/dev',
|
|
74
|
+
'/etc',
|
|
75
|
+
'/home',
|
|
76
|
+
'/Library',
|
|
77
|
+
'/opt',
|
|
78
|
+
'/private',
|
|
79
|
+
'/private/etc',
|
|
80
|
+
'/private/tmp',
|
|
81
|
+
'/private/var',
|
|
82
|
+
'/sbin',
|
|
83
|
+
'/System',
|
|
84
|
+
'/tmp',
|
|
85
|
+
'/usr',
|
|
86
|
+
'/Users',
|
|
87
|
+
'/var',
|
|
88
|
+
'/Volumes',
|
|
89
|
+
];
|
|
90
|
+
/** Why a resolved target is protected, in the words the refusal uses. `null`
|
|
91
|
+
* when the target is ordinary and the command may run. */
|
|
92
|
+
function protectedReason(target, home, cwd) {
|
|
93
|
+
if (PROTECTED_SYSTEM_PATHS.includes(target))
|
|
94
|
+
return 'a system directory';
|
|
95
|
+
if (target === home)
|
|
96
|
+
return 'your home directory';
|
|
97
|
+
if (target === `${home}/.crouter`)
|
|
98
|
+
return 'the canvas home — every node\u2019s conversation and state';
|
|
99
|
+
// Strict ancestors only: a node may delete its OWN working directory (a
|
|
100
|
+
// merged worktree is a real case), just nothing that contains it.
|
|
101
|
+
if (cwd === target || !cwd.startsWith(target.endsWith(sep) ? target : target + sep))
|
|
102
|
+
return null;
|
|
103
|
+
return `a directory containing your working directory (${cwd})`;
|
|
104
|
+
}
|
|
105
|
+
function initialVariables(home, cwd) {
|
|
106
|
+
const user = userInfo().username;
|
|
107
|
+
return new Map([['HOME', home], ['PWD', cwd], ['USER', user], ['LOGNAME', user]]);
|
|
108
|
+
}
|
|
109
|
+
/** The text with `~` and every known `$VAR` substituted, or null when what is
|
|
110
|
+
* left still depends on something the guard cannot see. */
|
|
111
|
+
function expandText(text, vars) {
|
|
112
|
+
let t = text;
|
|
113
|
+
if (t === '~' || t.startsWith('~/')) {
|
|
114
|
+
const home = vars.get('HOME');
|
|
115
|
+
if (home === undefined)
|
|
116
|
+
return null;
|
|
117
|
+
t = home + t.slice(1);
|
|
118
|
+
}
|
|
119
|
+
else if (t.startsWith('~')) {
|
|
120
|
+
return null; // ~otheruser — not ours to resolve
|
|
121
|
+
}
|
|
122
|
+
t = t.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced, bare) => vars.get(braced ?? bare) ?? whole);
|
|
123
|
+
// Anything still carrying an expansion or a glob is not statically knowable.
|
|
124
|
+
return /[$`*?[]/.test(t) ? null : t;
|
|
125
|
+
}
|
|
126
|
+
/** The absolute path an operand names, or null when it is not statically
|
|
127
|
+
* knowable. A trailing `/*` or `/.` is stripped first: `rm -rf ~/*` empties
|
|
128
|
+
* the same tree as `rm -rf ~`. A relative operand resolves against `base` —
|
|
129
|
+
* null when an earlier `cd` in the same line went somewhere the guard cannot
|
|
130
|
+
* follow. */
|
|
131
|
+
function resolveTarget(text, vars, base) {
|
|
132
|
+
const expanded = expandText(text.replace(/\/+\*?\.?$/, '') || '/', vars);
|
|
133
|
+
if (expanded === null)
|
|
134
|
+
return null;
|
|
135
|
+
if (isAbsolute(expanded))
|
|
136
|
+
return resolve(expanded);
|
|
137
|
+
return base === null ? null : resolve(base, expanded);
|
|
138
|
+
}
|
|
139
|
+
/** Fold `VAR=value` assignments into a variable map. A value the guard cannot
|
|
140
|
+
* expand deletes the variable rather than leaving a stale one behind: reading
|
|
141
|
+
* a later `$HOME` as the real home when the line reassigned it is exactly the
|
|
142
|
+
* false refusal that would teach agents to route around this guard. */
|
|
143
|
+
function applyAssignments(vars, assignments) {
|
|
144
|
+
for (const assignment of assignments) {
|
|
145
|
+
const eq = assignment.indexOf('=');
|
|
146
|
+
if (eq <= 0)
|
|
147
|
+
continue;
|
|
148
|
+
const name = assignment.slice(0, eq);
|
|
149
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
150
|
+
continue;
|
|
151
|
+
const value = expandText(assignment.slice(eq + 1), vars);
|
|
152
|
+
if (value === null)
|
|
153
|
+
vars.delete(name);
|
|
154
|
+
else
|
|
155
|
+
vars.set(name, value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// The destructive operations
|
|
159
|
+
/** A redirection, whole (`2>/dev/null`) or as its bare operator (`>`), which
|
|
160
|
+
* takes the next word as its file. Neither is an operand of the command. */
|
|
161
|
+
const REDIRECTION = /^[0-9]*(&?>>?|<<?|>&|<&)/;
|
|
162
|
+
/** Operands of a segment, minus flags, the `--` end-of-flags marker, and
|
|
163
|
+
* redirections. */
|
|
164
|
+
function operands(tokens) {
|
|
165
|
+
const out = [];
|
|
166
|
+
let flagsEnded = false;
|
|
167
|
+
let skipNext = false;
|
|
168
|
+
for (const token of tokens.slice(1)) {
|
|
169
|
+
if (skipNext) {
|
|
170
|
+
skipNext = false;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const redirection = REDIRECTION.exec(token.text);
|
|
174
|
+
if (redirection !== null && !token.opaque) {
|
|
175
|
+
skipNext = token.text === redirection[0]; // bare operator — its file is the next word
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!flagsEnded && token.text === '--') {
|
|
179
|
+
flagsEnded = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!flagsEnded && token.text.startsWith('-') && token.text !== '-')
|
|
183
|
+
continue;
|
|
184
|
+
out.push(token.text);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** Short and long flags of a segment, short combinations split into letters. */
|
|
189
|
+
function flagLetters(tokens) {
|
|
190
|
+
const letters = new Set();
|
|
191
|
+
const long = new Set();
|
|
192
|
+
for (const token of tokens.slice(1)) {
|
|
193
|
+
if (token.text === '--')
|
|
194
|
+
break;
|
|
195
|
+
if (token.text.startsWith('--'))
|
|
196
|
+
long.add(token.text.slice(2));
|
|
197
|
+
else if (token.text.startsWith('-') && token.text.length > 1)
|
|
198
|
+
for (const ch of token.text.slice(1))
|
|
199
|
+
letters.add(ch);
|
|
200
|
+
}
|
|
201
|
+
return { letters, long };
|
|
202
|
+
}
|
|
203
|
+
function isRecursive(tokens) {
|
|
204
|
+
const { letters, long } = flagLetters(tokens);
|
|
205
|
+
return letters.has('r') || letters.has('R') || long.has('recursive');
|
|
206
|
+
}
|
|
207
|
+
/** A block device holding a filesystem. `/dev/null`, `/dev/stdout`, and
|
|
208
|
+
* `/dev/zero` are ordinary output; a disk node is not. */
|
|
209
|
+
const RAW_DISK_DEVICE = /^\/dev\/r?(disk|sd[a-z]|nvme|hd[a-z]|vd[a-z]|mmcblk|md)/;
|
|
210
|
+
/** `find`'s start paths: the words between its leading global options and the
|
|
211
|
+
* first predicate of the expression. Those options are why the paths cannot
|
|
212
|
+
* simply be "everything before the first `-` word" — `find -H ~ -delete` and
|
|
213
|
+
* `find -- ~ -delete` both walk the home tree. */
|
|
214
|
+
function findPaths(tokens) {
|
|
215
|
+
const paths = [];
|
|
216
|
+
let i = 1;
|
|
217
|
+
while (i < tokens.length) {
|
|
218
|
+
const text = tokens[i].text;
|
|
219
|
+
if (text === '--' || /^-[HLPEdsx]$/.test(text))
|
|
220
|
+
i += 1;
|
|
221
|
+
else if (text === '-D' || text === '-O')
|
|
222
|
+
i += 2; // GNU debug/optimisation, each with a value
|
|
223
|
+
else if (text === '-f') { // BSD's explicit start path
|
|
224
|
+
if (i + 1 < tokens.length)
|
|
225
|
+
paths.push(tokens[i + 1].text);
|
|
226
|
+
i += 2;
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
for (; i < tokens.length; i += 1) {
|
|
232
|
+
if (tokens[i].text.startsWith('-'))
|
|
233
|
+
break;
|
|
234
|
+
paths.push(tokens[i].text);
|
|
235
|
+
}
|
|
236
|
+
return paths;
|
|
237
|
+
}
|
|
238
|
+
/** Command names that stand in front of the real command and must be stepped
|
|
239
|
+
* over before it can be read. Their own flags are skipped with them. */
|
|
240
|
+
const COMMAND_PREFIXES = new Set(['sudo', 'doas', 'command', 'env', 'nice', 'nohup', 'time', 'stdbuf', 'xargs', 'exec', 'builtin']);
|
|
241
|
+
/** Strip every leading prefix command and its flags, leaving the real command
|
|
242
|
+
* as `tokens[0]`. `sudo -E nice -n 10 rm -rf ~` reads as `rm -rf ~`. */
|
|
243
|
+
function stripCommandPrefixes(tokens) {
|
|
244
|
+
let rest = tokens;
|
|
245
|
+
while (rest.length > 0 && !rest[0].opaque && COMMAND_PREFIXES.has(rest[0].text)) {
|
|
246
|
+
let i = 1;
|
|
247
|
+
while (i < rest.length && rest[i].text.startsWith('-')) {
|
|
248
|
+
// `nice -n 10` and `env -u VAR` take a value; a lone flag does not.
|
|
249
|
+
const takesValue = /^-[nu]$/.test(rest[i].text);
|
|
250
|
+
i += takesValue ? 2 : 1;
|
|
251
|
+
}
|
|
252
|
+
// `env FOO=bar cmd` — the assignments belong to env, not to the command.
|
|
253
|
+
while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i].text))
|
|
254
|
+
i += 1;
|
|
255
|
+
if (i >= rest.length)
|
|
256
|
+
return [];
|
|
257
|
+
rest = rest.slice(i);
|
|
258
|
+
}
|
|
259
|
+
return rest;
|
|
260
|
+
}
|
|
261
|
+
/** Targets this segment would destroy: the operands whose loss the guard
|
|
262
|
+
* cares about, or `[]` when the segment is not a destructive operation. A
|
|
263
|
+
* verb appears here only when ONE invocation of it can take out a whole tree.
|
|
264
|
+
* A device or subcommand named directly (`mkfs.ext4`, `diskutil eraseDisk`)
|
|
265
|
+
* has no path operand to resolve, so it reports itself. */
|
|
266
|
+
function destructiveTargets(tokens) {
|
|
267
|
+
const none = { targets: [], named: null };
|
|
268
|
+
const head = tokens[0];
|
|
269
|
+
if (head === undefined || head.opaque)
|
|
270
|
+
return none;
|
|
271
|
+
const name = head.text.slice(head.text.lastIndexOf('/') + 1);
|
|
272
|
+
const args = operands(tokens);
|
|
273
|
+
switch (name) {
|
|
274
|
+
// `rm` without a recursive flag cannot remove a directory at all.
|
|
275
|
+
case 'rm':
|
|
276
|
+
return isRecursive(tokens) ? { targets: args, named: null } : none;
|
|
277
|
+
// Every operand but the destination is a source that stops existing.
|
|
278
|
+
case 'mv':
|
|
279
|
+
return args.length >= 2 ? { targets: args.slice(0, -1), named: null } : none;
|
|
280
|
+
// A recursive mode or ownership sweep over a system tree is as final as a
|
|
281
|
+
// deletion. The first operand is the mode/owner, not a path.
|
|
282
|
+
case 'chmod':
|
|
283
|
+
case 'chown':
|
|
284
|
+
case 'chgrp':
|
|
285
|
+
return isRecursive(tokens) && args.length >= 2 ? { targets: args.slice(1), named: null } : none;
|
|
286
|
+
// `find <path> … -delete` and `-exec` walk the tree and act on every hit.
|
|
287
|
+
case 'find': {
|
|
288
|
+
const acts = tokens.some((t) => !t.opaque && (t.text === '-delete' || t.text === '-exec' || t.text === '-execdir'));
|
|
289
|
+
return acts ? { targets: findPaths(tokens), named: null } : none;
|
|
290
|
+
}
|
|
291
|
+
// `dd of=…` overwrites its output in place; a raw disk is unrecoverable,
|
|
292
|
+
// while `of=/dev/null` and friends are ordinary output redirection.
|
|
293
|
+
case 'dd': {
|
|
294
|
+
const of = tokens.find((t) => t.text.startsWith('of='));
|
|
295
|
+
if (of === undefined)
|
|
296
|
+
return none;
|
|
297
|
+
const path = of.text.slice(3);
|
|
298
|
+
if (RAW_DISK_DEVICE.test(path))
|
|
299
|
+
return { targets: [], named: path };
|
|
300
|
+
return path.startsWith('/dev/') ? none : { targets: [path], named: null };
|
|
301
|
+
}
|
|
302
|
+
default:
|
|
303
|
+
// Filesystem creation and disk erasure destroy a volume outright; there
|
|
304
|
+
// is no safe target for them inside an agent's task.
|
|
305
|
+
if (/^(mkfs|newfs)([.-].+)?$/.test(name))
|
|
306
|
+
return { targets: [], named: name };
|
|
307
|
+
if (name === 'diskutil') {
|
|
308
|
+
const verb = args[0];
|
|
309
|
+
if (verb !== undefined && /^(erase|reformat|partition|zeroDisk|randomDisk|secureErase)/i.test(verb)) {
|
|
310
|
+
return { targets: [], named: `diskutil ${verb}` };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return none;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Walk the line segment by segment, carrying the variable values and working
|
|
317
|
+
* directory the shell itself would carry, and refuse the first destructive
|
|
318
|
+
* segment aimed at a protected path.
|
|
319
|
+
*
|
|
320
|
+
* Carrying that state IS the guard's core distinction. `export HOME=/tmp/x`
|
|
321
|
+
* as its own command changes `$HOME` for everything after it, so a later
|
|
322
|
+
* `rm -rf "$HOME"` is a scratch directory and runs. A `HOME=/tmp/x cmd`
|
|
323
|
+
* PREFIX applies to that one command only, so a later `rm -rf "$HOME"` is the
|
|
324
|
+
* real home — which is precisely the mistake that destroyed one. */
|
|
325
|
+
function destructiveCommandRefusal(command, cwd, home) {
|
|
326
|
+
const vars = initialVariables(home, cwd);
|
|
327
|
+
// Every directory a relative operand might be resolved from. A `cd` ADDS its
|
|
328
|
+
// destination rather than replacing the previous one, because the guard
|
|
329
|
+
// cannot know whether the shell's `cd` succeeded: after `cd missing-dir`,
|
|
330
|
+
// bash stays put and `rm -rf ..` really is the ancestor. Refusing when ANY
|
|
331
|
+
// candidate is protected keeps that case caught; the refusal names the
|
|
332
|
+
// resolved path, and an absolute operand is never ambiguous.
|
|
333
|
+
let bases = [cwd];
|
|
334
|
+
for (const segment of commandSegments(stripHeredocs(command))) {
|
|
335
|
+
const tokens = stripCommandPrefixes(segment.tokens);
|
|
336
|
+
const head = tokens[0];
|
|
337
|
+
if (head !== undefined) {
|
|
338
|
+
// This segment's own prefix assignments apply to this command only.
|
|
339
|
+
const local = new Map(vars);
|
|
340
|
+
applyAssignments(local, segment.assignments);
|
|
341
|
+
const innermost = bases[bases.length - 1];
|
|
342
|
+
if (innermost !== undefined)
|
|
343
|
+
local.set('PWD', innermost);
|
|
344
|
+
const { targets, named } = destructiveTargets(tokens);
|
|
345
|
+
if (named !== null)
|
|
346
|
+
return refusalMessage(segment.text, named, 'a whole disk or volume — never a task boundary');
|
|
347
|
+
for (const target of targets) {
|
|
348
|
+
for (const base of bases.length === 0 ? [null] : bases) {
|
|
349
|
+
const resolved = resolveTarget(target, local, base);
|
|
350
|
+
if (resolved === null)
|
|
351
|
+
continue;
|
|
352
|
+
const reason = protectedReason(resolved, home, cwd);
|
|
353
|
+
if (reason !== null)
|
|
354
|
+
return refusalMessage(segment.text, resolved, reason);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Then carry this segment's lasting effects into the rest of the line. A
|
|
359
|
+
// backgrounded segment runs in a subshell, so it has none.
|
|
360
|
+
if (segment.background)
|
|
361
|
+
continue;
|
|
362
|
+
if (head === undefined)
|
|
363
|
+
applyAssignments(vars, segment.assignments);
|
|
364
|
+
else if (!head.opaque && SHELL_VARIABLE_BUILTINS.has(head.text)) {
|
|
365
|
+
// A prefix assignment to a special builtin persists in the shell.
|
|
366
|
+
applyAssignments(vars, segment.assignments);
|
|
367
|
+
applyAssignments(vars, operands(tokens));
|
|
368
|
+
}
|
|
369
|
+
else if (!head.opaque && head.text === 'cd') {
|
|
370
|
+
const destination = operands(tokens)[0];
|
|
371
|
+
const next = destination === undefined ? vars.get('HOME') ?? null : resolveTarget(destination, vars, bases[bases.length - 1] ?? null);
|
|
372
|
+
// An unfollowable `cd` leaves no knowable base at all; a relative operand
|
|
373
|
+
// after it is unresolvable, and unresolvable always means allowed.
|
|
374
|
+
bases = next === null ? [] : [...bases, next];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** Builtins that set shell variables for everything after them. */
|
|
380
|
+
const SHELL_VARIABLE_BUILTINS = new Set(['export', 'declare', 'typeset', 'readonly']);
|
|
381
|
+
function refusalMessage(segment, target, reason) {
|
|
382
|
+
return (`Refused: \`${segment}\` targets ${target}, which is ${reason}.\n` +
|
|
383
|
+
`Nothing an agent is asked to do requires destroying that path, and it cannot be undone.\n` +
|
|
384
|
+
`If you meant somewhere else, name that path explicitly and run the command again. Two traps to check first:\n` +
|
|
385
|
+
` - a \`VAR=value cmd\` prefix applies ONLY to that one command, so a later \`$VAR\` in the same line is the SHELL's value — this is exactly how a node deleted a home directory.\n` +
|
|
386
|
+
` - \`~\` and \`$HOME\` are the real home directory, never a scratch dir; build scratch paths with mktemp -d and hold them in a variable of your own.\n` +
|
|
387
|
+
`If you believe this path genuinely must be destroyed, ask the user — do not work around this refusal.`);
|
|
388
|
+
}
|
|
389
|
+
/** The refusal this command earns before it runs, or null when it may run.
|
|
390
|
+
* `cwd` is the directory the command will execute in. */
|
|
391
|
+
export function bashCommandRefusal(command, cwd, home = homedir()) {
|
|
392
|
+
return longSleepRefusal(command) ?? destructiveCommandRefusal(command, cwd, home);
|
|
393
|
+
}
|
|
@@ -3,6 +3,16 @@ export type CronOverlap = 'skip' | 'queue' | 'replace';
|
|
|
3
3
|
export type CronOnOutput = 'silent' | 'on-failure' | 'always' | 'on-change';
|
|
4
4
|
export type CronState = 'active' | 'paused';
|
|
5
5
|
export type CronRunState = 'idle' | 'running';
|
|
6
|
+
/** The decoded delivery target of a stored cron sink. */
|
|
7
|
+
export type ParsedCronSink = {
|
|
8
|
+
kind: 'node';
|
|
9
|
+
node: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'spawn';
|
|
12
|
+
spawn: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'human';
|
|
15
|
+
} | null;
|
|
6
16
|
/** A hydrated `crons` row. */
|
|
7
17
|
export interface Cron {
|
|
8
18
|
cron_id: string;
|
|
@@ -96,6 +106,10 @@ export interface CronRunRecord {
|
|
|
96
106
|
/** Ring bound for `cron_runs`: recordCronRun prunes each cron's history to
|
|
97
107
|
* its most recent this-many rows. */
|
|
98
108
|
export declare const CRON_RUN_RING = 20;
|
|
109
|
+
/** Decode the stored cron sink JSON. A malformed or unrecognised value has no sink. */
|
|
110
|
+
export declare function parseCronSink(sinkJson: string): ParsedCronSink;
|
|
111
|
+
/** Render a stored cron sink as the arm-time CLI spec, or null when it has no destination. */
|
|
112
|
+
export declare function cronSinkDisplay(sinkJson: string): string | null;
|
|
99
113
|
/** The ONLY sanctioned way to signal a cron run's process group from a stored
|
|
100
114
|
* row. Re-captures the CURRENT identity of `run_pid` and signals only when it
|
|
101
115
|
* MATCHES the fingerprint recorded at lease acquisition. Returns whether the
|
|
@@ -180,10 +194,12 @@ export declare function setCronState(cron_id: string, state: CronState): boolean
|
|
|
180
194
|
* previous run's stdout. Written after EVERY settled run of an on-change
|
|
181
195
|
* cron, delivered or not. */
|
|
182
196
|
export declare function setCronLastOutputHash(cron_id: string, hash: string): void;
|
|
183
|
-
/** True when
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
|
|
197
|
+
/** True when a cron can still wake this node. An active deadline is a wake
|
|
198
|
+
* source by its anchor; output delivery is a wake source by its decoded node
|
|
199
|
+
* sink. Scheduled rows must remain active and unexpired, while a leased run
|
|
200
|
+
* remains eligible through settlement even if it has since been paused or
|
|
201
|
+
* expired. */
|
|
202
|
+
export declare function hasPendingCronWake(nodeId: string): boolean;
|
|
187
203
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
188
204
|
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
189
205
|
* fallback, in reviveNode; recovery replacement is not a wake. Terminal
|
|
@@ -60,6 +60,36 @@ function cronFrom(r) {
|
|
|
60
60
|
updated: r['updated'],
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
/** Decode the stored cron sink JSON. A malformed or unrecognised value has no sink. */
|
|
64
|
+
export function parseCronSink(sinkJson) {
|
|
65
|
+
try {
|
|
66
|
+
const sink = JSON.parse(sinkJson);
|
|
67
|
+
if (typeof sink['node'] === 'string')
|
|
68
|
+
return { kind: 'node', node: sink['node'] };
|
|
69
|
+
if (typeof sink['spawn'] === 'string')
|
|
70
|
+
return { kind: 'spawn', spawn: sink['spawn'] };
|
|
71
|
+
if (sink['human'] === true)
|
|
72
|
+
return { kind: 'human' };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A malformed stored value cannot deliver but remains listable and runnable.
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** Render a stored cron sink as the arm-time CLI spec, or null when it has no destination. */
|
|
80
|
+
export function cronSinkDisplay(sinkJson) {
|
|
81
|
+
const sink = parseCronSink(sinkJson);
|
|
82
|
+
if (sink === null)
|
|
83
|
+
return null;
|
|
84
|
+
switch (sink.kind) {
|
|
85
|
+
case 'node':
|
|
86
|
+
return `node:${sink.node}`;
|
|
87
|
+
case 'spawn':
|
|
88
|
+
return `spawn:${sink.spawn}`;
|
|
89
|
+
case 'human':
|
|
90
|
+
return 'human';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
// Signal safety
|
|
64
94
|
/** The ONLY sanctioned way to signal a cron run's process group from a stored
|
|
65
95
|
* row. Re-captures the CURRENT identity of `run_pid` and signals only when it
|
|
@@ -261,13 +291,30 @@ export function setCronLastOutputHash(cron_id, hash) {
|
|
|
261
291
|
.prepare('UPDATE crons SET last_output_hash = ?, updated = ? WHERE cron_id = ?')
|
|
262
292
|
.run(hash, new Date().toISOString(), cron_id);
|
|
263
293
|
}
|
|
264
|
-
/** True when
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
294
|
+
/** True when a cron can still wake this node. An active deadline is a wake
|
|
295
|
+
* source by its anchor; output delivery is a wake source by its decoded node
|
|
296
|
+
* sink. Scheduled rows must remain active and unexpired, while a leased run
|
|
297
|
+
* remains eligible through settlement even if it has since been paused or
|
|
298
|
+
* expired. */
|
|
299
|
+
export function hasPendingCronWake(nodeId) {
|
|
300
|
+
const rows = openDb().prepare(`SELECT anchor_node, cancel_on_wake, sink
|
|
301
|
+
FROM crons
|
|
302
|
+
WHERE (anchor_node = ? AND cancel_on_wake = 1 AND state = 'active')
|
|
303
|
+
OR (
|
|
304
|
+
on_output IN ('always', 'on-change')
|
|
305
|
+
AND (
|
|
306
|
+
run_state = 'running'
|
|
307
|
+
OR (run_state = 'idle' AND state = 'active' AND (expires_at IS NULL OR expires_at > ?))
|
|
308
|
+
)
|
|
309
|
+
)`).all(nodeId, new Date().toISOString());
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
if (row['anchor_node'] === nodeId && Number(row['cancel_on_wake']) !== 0)
|
|
312
|
+
return true;
|
|
313
|
+
const sink = parseCronSink(row['sink']);
|
|
314
|
+
if (sink?.kind === 'node' && sink.node === nodeId)
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
271
318
|
}
|
|
272
319
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
273
320
|
* A real wake consumes the wait at its delivery boundary or, as a dormant
|