@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { typedText } from './shell-passthrough.js';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { OFFLINE_ROUTE } from './provider-config.js';
|
|
4
|
+
export function parseCodeStart(flags) {
|
|
5
|
+
const chosen = ['continue', 'new', 'resume'].filter((f) => flags.includes('--' + f));
|
|
6
|
+
if (chosen.length > 1)
|
|
7
|
+
throw new Error('Choose one of --continue, --new or --resume.');
|
|
8
|
+
return chosen[0] ?? 'continue';
|
|
9
|
+
}
|
|
10
|
+
const folder = (path) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
|
|
11
|
+
/** The most recently created session in this folder; sessions come oldest first. */
|
|
12
|
+
export function latestSessionFor(sessions, cwd) {
|
|
13
|
+
const want = folder(cwd);
|
|
14
|
+
return [...sessions].reverse().find((s) => folder(s.cwd) === want)?.id;
|
|
15
|
+
}
|
|
16
|
+
/** Whether a new `salaam code` session should use Anthropic: only when nothing was chosen, the
|
|
17
|
+
* running service defaults to offline (as a TUI-started one does) and a key is stored. */
|
|
18
|
+
export function deskUsesAnthropic(chosen, serviceProvider, hasAnthropicKey) {
|
|
19
|
+
return (!chosen &&
|
|
20
|
+
(serviceProvider ?? OFFLINE_ROUTE.provider) === OFFLINE_ROUTE.provider &&
|
|
21
|
+
hasAnthropicKey);
|
|
22
|
+
}
|
|
23
|
+
/** `salaam -c` and `salaam --continue` are short for `salaam code --continue`. */
|
|
24
|
+
export function continueArgs(args) {
|
|
25
|
+
if (args[0] !== '-c' && args[0] !== '--continue')
|
|
26
|
+
return;
|
|
27
|
+
return ['code', '--continue', ...args.slice(1)];
|
|
28
|
+
}
|
|
29
|
+
const activeAt = (s) => s.last_active_at ? Date.parse(s.last_active_at) : -Infinity;
|
|
30
|
+
/** This folder's sessions first, then every other one; newest first within each. */
|
|
31
|
+
export function pickerOrder(sessions, cwd) {
|
|
32
|
+
const want = folder(cwd);
|
|
33
|
+
const newest = (a, b) => activeAt(b) - activeAt(a);
|
|
34
|
+
const here = sessions.filter((s) => folder(s.cwd) === want).sort(newest);
|
|
35
|
+
return [...here, ...sessions.filter((s) => folder(s.cwd) !== want).sort(newest)];
|
|
36
|
+
}
|
|
37
|
+
export function relativeTime(at, now = Date.now()) {
|
|
38
|
+
if (!at)
|
|
39
|
+
return '';
|
|
40
|
+
const s = Math.max(0, (now - Date.parse(at)) / 1000);
|
|
41
|
+
if (s < 60)
|
|
42
|
+
return 'just now';
|
|
43
|
+
if (s < 3600)
|
|
44
|
+
return `${Math.floor(s / 60)} min ago`;
|
|
45
|
+
if (s < 86400)
|
|
46
|
+
return `${Math.floor(s / 3600)} h ago`;
|
|
47
|
+
if (s < 2 * 86400)
|
|
48
|
+
return 'yesterday';
|
|
49
|
+
return `${Math.floor(s / 86400)} days ago`;
|
|
50
|
+
}
|
|
51
|
+
/** One picker row: what you last asked (one short line), when, and where. */
|
|
52
|
+
export function sessionDescription(s, now = Date.now()) {
|
|
53
|
+
const prompt = s.last_prompt?.replace(/\s+/g, ' ').trim();
|
|
54
|
+
const quoted = prompt ? `“${prompt.length > 60 ? prompt.slice(0, 59) + '…' : prompt}”` : '';
|
|
55
|
+
return [quoted || s.state, relativeTime(s.last_active_at, now), s.cwd]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(' · ');
|
|
58
|
+
}
|
|
59
|
+
/** What the picker shows for a desk session: its newest prompt (at most 200 characters) and when
|
|
60
|
+
* its transcript last changed. Board and station sessions get nothing extra. */
|
|
61
|
+
export function pickerSummary(session, store) {
|
|
62
|
+
if (!session.capabilities.includes('local-coding-reviewed'))
|
|
63
|
+
return {};
|
|
64
|
+
const prompt = store.lastPrompt(session.id);
|
|
65
|
+
let at;
|
|
66
|
+
try {
|
|
67
|
+
if (session.pi_session_file)
|
|
68
|
+
at = statSync(session.pi_session_file).mtime.toISOString();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* no transcript yet */
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...(prompt ? { last_prompt: typedText(prompt).slice(0, 200) } : {}),
|
|
75
|
+
...(at ? { last_active_at: at } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!command` in a desk session (trial polish #12, 2026-10-31). The terminal never runs the command
|
|
3
|
+
* itself: it asks the model to run exactly that text through salaam_local. The service reads the
|
|
4
|
+
* same fixed prompt back from the turn's submission and binds the turn to the typed command, so a
|
|
5
|
+
* model that runs anything else is refused, whatever the session's mode.
|
|
6
|
+
*/
|
|
7
|
+
const LEAD = 'I want to run this shell command myself. Run exactly this command with salaam_local';
|
|
8
|
+
export function shellPassthrough(text) {
|
|
9
|
+
const t = text.trim();
|
|
10
|
+
if (!t.startsWith('!') || t.startsWith('!!'))
|
|
11
|
+
return undefined;
|
|
12
|
+
const command = t.slice(1).trim();
|
|
13
|
+
if (command === '' || command.includes('\n') || command.includes('```'))
|
|
14
|
+
return undefined;
|
|
15
|
+
return [
|
|
16
|
+
LEAD,
|
|
17
|
+
'(action "command"), unchanged: no edits, no extra flags, no other commands. Then show me its',
|
|
18
|
+
'output and exit status, and do nothing else unless I ask.',
|
|
19
|
+
'',
|
|
20
|
+
'```sh',
|
|
21
|
+
command,
|
|
22
|
+
'```',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
/** The command a turn's prompt binds it to, when the prompt is exactly a `!command` request. */
|
|
26
|
+
export function boundShellCommand(prompt) {
|
|
27
|
+
if (!prompt.startsWith(LEAD + '\n'))
|
|
28
|
+
return undefined;
|
|
29
|
+
const lines = prompt.split('\n');
|
|
30
|
+
if (lines.length !== 7 || lines[4] !== '```sh' || lines[6] !== '```')
|
|
31
|
+
return undefined;
|
|
32
|
+
const command = lines[5];
|
|
33
|
+
return shellPassthrough('!' + command) === prompt ? command : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** What the person typed, for display: `! COMMAND` for a `!command` prompt, else the text. */
|
|
36
|
+
export function typedText(prompt) {
|
|
37
|
+
const command = boundShellCommand(prompt);
|
|
38
|
+
return command === undefined ? prompt : '! ' + command;
|
|
39
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { digest, ServiceError } from './protocol.js';
|
|
2
|
+
export class StationCheckpoints {
|
|
3
|
+
store;
|
|
4
|
+
constructor(store) {
|
|
5
|
+
this.store = store;
|
|
6
|
+
}
|
|
7
|
+
patches(session) {
|
|
8
|
+
return this.store.db
|
|
9
|
+
.prepare("SELECT rowid,args_json,result_json,attempt_id FROM station_calls WHERE session_id=? AND kind='patch' AND state='returned' ORDER BY rowid")
|
|
10
|
+
.all(session)
|
|
11
|
+
.map((r) => {
|
|
12
|
+
const args = JSON.parse(String(r.args_json)), result = JSON.parse(String(r.result_json));
|
|
13
|
+
return {
|
|
14
|
+
rowid: Number(r.rowid),
|
|
15
|
+
path: String(args.path),
|
|
16
|
+
content: typeof args.content === 'string' ? args.content : null,
|
|
17
|
+
expected_hash: typeof args.expected_hash === 'string' ? args.expected_hash : null,
|
|
18
|
+
before_tree: String(result.before_tree_digest),
|
|
19
|
+
tree: String(result.tree_digest),
|
|
20
|
+
sha256: typeof result.sha256 === 'string' ? result.sha256 : null,
|
|
21
|
+
attempt: String(r.attempt_id),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
list(task) {
|
|
26
|
+
const patches = this.patches(task.session_id);
|
|
27
|
+
const attemptsOf = (step) => new Set(this.store.db
|
|
28
|
+
.prepare('SELECT attempt_id FROM task_step_attempts WHERE step_id=?')
|
|
29
|
+
.all(step)
|
|
30
|
+
.map((r) => String(r.attempt_id)));
|
|
31
|
+
const out = [];
|
|
32
|
+
let at = 0, tree = patches[0]?.before_tree ?? '';
|
|
33
|
+
for (const step of task.steps.filter((s) => s.state === 'completed')) {
|
|
34
|
+
const mine = patches.filter((p) => attemptsOf(step.id).has(p.attempt));
|
|
35
|
+
if (mine.length) {
|
|
36
|
+
at = Math.max(at, ...mine.map((p) => p.rowid));
|
|
37
|
+
tree = patches.find((p) => p.rowid === at).tree;
|
|
38
|
+
}
|
|
39
|
+
out.push({
|
|
40
|
+
id: digest({ task: task.id, plan_revision: task.plan_revision, step: step.id, at, tree }),
|
|
41
|
+
step_id: step.id,
|
|
42
|
+
step_key: step.key,
|
|
43
|
+
tree_digest: tree,
|
|
44
|
+
at,
|
|
45
|
+
files_changed: [...new Set(mine.map((p) => p.path))].sort(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
get(task, id) {
|
|
51
|
+
const found = this.list(task).find((c) => c.id === id);
|
|
52
|
+
if (!found)
|
|
53
|
+
throw new ServiceError('not_found', 'No such checkpoint for this task', 404);
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/** A path's content at a checkpoint, from the run's own patches only. */
|
|
57
|
+
fileAt(task, at, path) {
|
|
58
|
+
const patches = this.patches(task.session_id).filter((p) => p.path === path);
|
|
59
|
+
const before = patches.filter((p) => p.rowid <= at).at(-1);
|
|
60
|
+
if (before)
|
|
61
|
+
return before.content === null
|
|
62
|
+
? { state: 'absent' }
|
|
63
|
+
: { state: 'content', content: before.content };
|
|
64
|
+
const first = patches.find((p) => p.rowid > at);
|
|
65
|
+
return first && first.expected_hash === null ? { state: 'absent' } : { state: 'unknown' };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What restoring this checkpoint would do, against the LIVE workspace (never the run's own
|
|
69
|
+
* receipts alone): every path this run wrote is read as it is now, so a change made outside the
|
|
70
|
+
* run since (an edit, a deletion, a file put back) shows as what the restore would undo. The
|
|
71
|
+
* restore digest binds the live tree, so a restore refuses a workspace that moved after review.
|
|
72
|
+
* Files this run never wrote are left as they are; `drift` says when the tree moved outside it.
|
|
73
|
+
*/
|
|
74
|
+
async diff(task, id, live) {
|
|
75
|
+
const checkpoint = this.get(task, id);
|
|
76
|
+
const patches = this.patches(task.session_id);
|
|
77
|
+
const recorded = patches.at(-1)?.tree ?? checkpoint.tree_digest;
|
|
78
|
+
const paths = [...new Set(patches.map((p) => p.path))].sort();
|
|
79
|
+
const files = [];
|
|
80
|
+
for (const path of paths) {
|
|
81
|
+
const now = await live.file(path);
|
|
82
|
+
const then = this.fileAt(task, checkpoint.at, path);
|
|
83
|
+
const change = now.state === 'unreadable' || then.state === 'unknown'
|
|
84
|
+
? 'not_restorable'
|
|
85
|
+
: then.state === 'absent'
|
|
86
|
+
? now.state === 'absent'
|
|
87
|
+
? 'unchanged'
|
|
88
|
+
: 'delete'
|
|
89
|
+
: now.state === 'absent'
|
|
90
|
+
? 'bring_back'
|
|
91
|
+
: now.content === then.content
|
|
92
|
+
? 'unchanged'
|
|
93
|
+
: 'revert';
|
|
94
|
+
files.push({
|
|
95
|
+
path,
|
|
96
|
+
current: now.state === 'content' ? now.content : null,
|
|
97
|
+
current_state: now.state,
|
|
98
|
+
at_checkpoint: then,
|
|
99
|
+
change,
|
|
100
|
+
expected_hash: now.state === 'content' ? now.sha256 : null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const shown = files.filter((f) => f.change !== 'unchanged');
|
|
104
|
+
const restore_digest = digest({
|
|
105
|
+
task: task.id,
|
|
106
|
+
plan_revision: task.plan_revision,
|
|
107
|
+
checkpoint: checkpoint.id,
|
|
108
|
+
checkpoint_tree: checkpoint.tree_digest,
|
|
109
|
+
recorded_tree: recorded,
|
|
110
|
+
current_tree: live.tree,
|
|
111
|
+
files: shown.map((f) => ({
|
|
112
|
+
path: f.path,
|
|
113
|
+
change: f.change,
|
|
114
|
+
target: f.at_checkpoint,
|
|
115
|
+
expected: f.expected_hash,
|
|
116
|
+
})),
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
checkpoint,
|
|
120
|
+
current_tree: live.tree,
|
|
121
|
+
recorded_tree: recorded,
|
|
122
|
+
drift: live.tree !== recorded,
|
|
123
|
+
restore_digest,
|
|
124
|
+
files: shown.map(({ expected_hash: _, ...f }) => f),
|
|
125
|
+
writes: shown
|
|
126
|
+
.filter((f) => ['revert', 'delete', 'bring_back'].includes(f.change))
|
|
127
|
+
.map((f) => ({
|
|
128
|
+
path: f.path,
|
|
129
|
+
content: f.at_checkpoint.state === 'content' ? f.at_checkpoint.content : null,
|
|
130
|
+
expected_hash: f.expected_hash,
|
|
131
|
+
})),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Item 11b: exactly what a restore of this checkpoint writes, and the digest that binds it. */
|
|
135
|
+
async restorePlan(task, id, live) {
|
|
136
|
+
const { restore_digest, current_tree, writes, checkpoint } = await this.diff(task, id, live);
|
|
137
|
+
return { restore_digest, current_tree, writes, checkpoint };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -106,8 +106,14 @@ export function stationRuntimeFingerprint(root) {
|
|
|
106
106
|
export function readStationConfig(path, workspace, expected) {
|
|
107
107
|
try {
|
|
108
108
|
const v = object(JSON.parse(stationPrivateFile(path, 16384).toString('utf8')));
|
|
109
|
-
|
|
109
|
+
const keys = Object.keys(v)
|
|
110
|
+
.filter((k) => k !== 'tools_user')
|
|
111
|
+
.sort()
|
|
112
|
+
.join(',');
|
|
113
|
+
if (keys !==
|
|
110
114
|
'files,kind,runtime_fingerprint,runtime_image_digest,runtime_root,systemctl,systemd_run,version,workspace,workspace_identity' ||
|
|
115
|
+
(Object.hasOwn(v, 'tools_user') &&
|
|
116
|
+
(typeof v.tools_user !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(v.tools_user))) ||
|
|
111
117
|
v.version !== 1 ||
|
|
112
118
|
v.kind !== 'linux-systemd-station-v1' ||
|
|
113
119
|
v.workspace !== workspace ||
|
|
@@ -129,6 +135,7 @@ export function readStationConfig(path, workspace, expected) {
|
|
|
129
135
|
runtime_root: v.runtime_root,
|
|
130
136
|
runtime_fingerprint: v.runtime_fingerprint,
|
|
131
137
|
runtime_image_digest: v.runtime_image_digest,
|
|
138
|
+
...(Object.hasOwn(v, 'tools_user') ? { tools_user: v.tools_user } : {}),
|
|
132
139
|
};
|
|
133
140
|
if (workspace
|
|
134
141
|
.split(sep)
|
|
@@ -18,8 +18,12 @@ export function stationFileResult(request, value) {
|
|
|
18
18
|
const fields = keys[String(request.action)];
|
|
19
19
|
if (!fields)
|
|
20
20
|
bad();
|
|
21
|
+
// A new file's missing parent directories, created beneath the root, are part of its effect.
|
|
22
|
+
const extra = request.action === 'patch' && 'created_directories' in v ? ['created_directories'] : [];
|
|
21
23
|
if (Object.keys(v).sort().join(',') !==
|
|
22
|
-
['version', 'ok', 'tree_digest', 'files', 'root_identity', ...fields
|
|
24
|
+
['version', 'ok', 'tree_digest', 'files', 'root_identity', ...fields, ...extra]
|
|
25
|
+
.sort()
|
|
26
|
+
.join(',') ||
|
|
23
27
|
v.version !== 1 ||
|
|
24
28
|
v.ok !== true ||
|
|
25
29
|
v.root_identity !== request.root_identity ||
|
|
@@ -39,6 +43,8 @@ export function stationFileResult(request, value) {
|
|
|
39
43
|
v.sha256 !== stationHash(Buffer.from(v.content)))
|
|
40
44
|
bad();
|
|
41
45
|
}
|
|
46
|
+
if (extra.length && !createdParents(String(request.path), v.created_directories, request.content))
|
|
47
|
+
bad();
|
|
42
48
|
if (request.action === 'patch') {
|
|
43
49
|
if (v.path !== request.path ||
|
|
44
50
|
v.before_tree_digest !== request.expected_tree ||
|
|
@@ -82,3 +88,16 @@ export function stationFileResult(request, value) {
|
|
|
82
88
|
}
|
|
83
89
|
return v;
|
|
84
90
|
}
|
|
91
|
+
/** The created directories must be a non-empty, in-order run of the path's own
|
|
92
|
+
* ancestors ending at its parent: each one beneath the last, nothing else. Only a
|
|
93
|
+
* new file creates them. */
|
|
94
|
+
function createdParents(path, dirs, content) {
|
|
95
|
+
if (typeof content !== 'string' || !Array.isArray(dirs) || dirs.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
const parts = path.split('/');
|
|
98
|
+
if (parts.length < 2 || parts.some((p) => p === '' || p === '.' || p === '..'))
|
|
99
|
+
return false;
|
|
100
|
+
const ancestors = parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
|
|
101
|
+
const tail = ancestors.slice(ancestors.length - dirs.length);
|
|
102
|
+
return dirs.length <= ancestors.length && dirs.every((d, i) => d === tail[i]);
|
|
103
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requiredChecks } from './task-policy.js';
|
|
1
2
|
import { canonical, digest, ServiceError } from './protocol.js';
|
|
2
3
|
/** Protected station backend for the existing task_checks/completion controller.
|
|
3
4
|
* It never changes task success. The controller must await withFresh and commit
|
|
@@ -122,7 +123,7 @@ export class StationTaskVerification {
|
|
|
122
123
|
}
|
|
123
124
|
async dispatchOwned(id) {
|
|
124
125
|
const receipt = this.verification.get(id), task = this.tasks.get(receipt.task_id);
|
|
125
|
-
const configured = task.
|
|
126
|
+
const configured = requiredChecks(task).find((c) => c.key === receipt.check_key);
|
|
126
127
|
if (!configured || configured.cwd !== '.')
|
|
127
128
|
this.fail();
|
|
128
129
|
const binding = this.station.ready(task.session_id);
|
|
@@ -212,6 +213,7 @@ export class StationTaskVerification {
|
|
|
212
213
|
objective: task.objective,
|
|
213
214
|
constraints: task.constraints,
|
|
214
215
|
acceptance: task.acceptance,
|
|
216
|
+
...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
|
|
215
217
|
deliverables: task.deliverables,
|
|
216
218
|
limits: task.limits,
|
|
217
219
|
steps: task.steps.map(({ key, title, instruction, depends_on, checks }) => ({
|
|
@@ -247,7 +249,7 @@ export class StationTaskVerification {
|
|
|
247
249
|
});
|
|
248
250
|
}
|
|
249
251
|
matches(check, task, call) {
|
|
250
|
-
const configured = task.
|
|
252
|
+
const configured = requiredChecks(task).find((c) => c.key === check.check_key);
|
|
251
253
|
const args = JSON.parse(call.args_json);
|
|
252
254
|
if (check.task_id !== task.id ||
|
|
253
255
|
check.plan_revision !== task.plan_revision ||
|
|
@@ -432,7 +434,8 @@ export class StationTaskVerification {
|
|
|
432
434
|
result.tree_digest !== tree ||
|
|
433
435
|
result.root_identity !== this.station.config.workspace_identity)
|
|
434
436
|
this.fail();
|
|
435
|
-
|
|
437
|
+
// The verified bytes travel with the digest; completion keeps them as an artifact.
|
|
438
|
+
deliverables.push({ path, sha256: result.sha256, content: result.content });
|
|
436
439
|
}
|
|
437
440
|
const final = await this.observe(evidence[0].call_id, this.tasks.get(prepared[0].bound.task_id).session_id, validate);
|
|
438
441
|
return this.store.transaction(() => {
|