@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
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
writeFileSync,
|
|
9
9
|
fsyncSync,
|
|
10
10
|
realpathSync,
|
|
11
|
+
mkdirSync,
|
|
11
12
|
} from 'node:fs';
|
|
12
13
|
import { join } from 'node:path';
|
|
13
14
|
import type { Store } from './store.js';
|
|
14
|
-
import { canonical, digest, ServiceError, object, integer } from './protocol.js';
|
|
15
|
+
import { canonical, digest, ServiceError, object, integer, type Session } from './protocol.js';
|
|
16
|
+
import { forkPiSession } from './pi-sessions.js';
|
|
15
17
|
import {
|
|
16
18
|
agentpodBinding,
|
|
17
19
|
AGENTPOD_LEASE_MS,
|
|
@@ -80,10 +82,45 @@ export function readAgentPodPins(configPath: string, state: string): AgentPodPin
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/** A local node admission, not an Accounts token issuer or a provider login. */
|
|
85
|
+
/** An agent's standing "Always allow" rule (approval scopes). v1 knows one scope: workspace
|
|
86
|
+
* writes, never deletes, hidden paths, commands, network or anything outside the workspace. */
|
|
87
|
+
export interface ApprovalRule {
|
|
88
|
+
id: string;
|
|
89
|
+
scope: 'workspace-write';
|
|
90
|
+
set_by: string;
|
|
91
|
+
}
|
|
92
|
+
const ruleId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
93
|
+
/** Closed: 1 to 16 rules, exact keys, a known scope, unique ids. Absent means none. */
|
|
94
|
+
export function approvalRules(value: unknown): ApprovalRule[] {
|
|
95
|
+
if (value === undefined) return [];
|
|
96
|
+
const bad = () => {
|
|
97
|
+
throw new ServiceError('invalid_approval_rules', 'Malformed approval rules', 400);
|
|
98
|
+
};
|
|
99
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 16) bad();
|
|
100
|
+
const rules = (value as unknown[]).map((r) => {
|
|
101
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) bad();
|
|
102
|
+
const v = r as Record<string, unknown>;
|
|
103
|
+
if (
|
|
104
|
+
Object.keys(v).sort().join(',') !== 'id,scope,set_by' ||
|
|
105
|
+
typeof v.id !== 'string' ||
|
|
106
|
+
!ruleId.test(v.id) ||
|
|
107
|
+
v.scope !== 'workspace-write' ||
|
|
108
|
+
typeof v.set_by !== 'string' ||
|
|
109
|
+
v.set_by.length < 1 ||
|
|
110
|
+
v.set_by.length > 128 ||
|
|
111
|
+
/[\u0000-\u001f\u007f]/.test(v.set_by)
|
|
112
|
+
)
|
|
113
|
+
bad();
|
|
114
|
+
return { id: v.id as string, scope: 'workspace-write' as const, set_by: v.set_by as string };
|
|
115
|
+
});
|
|
116
|
+
if (new Set(rules.map((r) => r.id)).size !== rules.length) bad();
|
|
117
|
+
return rules.sort((a, b) => a.id.localeCompare(b.id));
|
|
118
|
+
}
|
|
83
119
|
export class AgentPodAuthority {
|
|
84
120
|
readonly boot = randomUUID();
|
|
85
121
|
readonly pins: AgentPodPins;
|
|
86
122
|
private key: Buffer;
|
|
123
|
+
private readonly stateDir: string;
|
|
87
124
|
constructor(
|
|
88
125
|
readonly store: Store,
|
|
89
126
|
state: string,
|
|
@@ -92,6 +129,7 @@ export class AgentPodAuthority {
|
|
|
92
129
|
readonly ownedResourcesStopped: (session: string) => boolean = () => true,
|
|
93
130
|
) {
|
|
94
131
|
this.pins = readAgentPodPins(configPath, state);
|
|
132
|
+
this.stateDir = state;
|
|
95
133
|
const keyPath = join(state, 'agentpod-control-key');
|
|
96
134
|
try {
|
|
97
135
|
const fd = openSync(keyPath, 'wx', 0o600);
|
|
@@ -112,6 +150,9 @@ export class AgentPodAuthority {
|
|
|
112
150
|
}
|
|
113
151
|
this.key = privateBytes(keyPath);
|
|
114
152
|
if (this.key.length !== 32) throw Error('Invalid AgentPod control key');
|
|
153
|
+
store.db.exec(
|
|
154
|
+
'CREATE TABLE IF NOT EXISTS agentpod_approval_rules(admission_id TEXT NOT NULL,rule_id TEXT NOT NULL,scope TEXT NOT NULL,set_by TEXT NOT NULL,revoked_at TEXT,PRIMARY KEY(admission_id,rule_id))',
|
|
155
|
+
);
|
|
115
156
|
store.agentpodAuthorityCheck = (session) => this.assert(this.bySession(session));
|
|
116
157
|
store.requireAgentPodAuthority = true;
|
|
117
158
|
}
|
|
@@ -158,6 +199,19 @@ export class AgentPodAuthority {
|
|
|
158
199
|
);
|
|
159
200
|
return remaining;
|
|
160
201
|
}
|
|
202
|
+
/** The session's pinned approval rules that no renewal has revoked. */
|
|
203
|
+
approvalRules(session: string): ApprovalRule[] {
|
|
204
|
+
return this.store.db
|
|
205
|
+
.prepare(
|
|
206
|
+
'SELECT r.rule_id AS id,r.scope,r.set_by FROM agentpod_approval_rules r JOIN agentpod_admissions a ON a.id=r.admission_id WHERE a.session_id=? AND r.revoked_at IS NULL ORDER BY r.rule_id',
|
|
207
|
+
)
|
|
208
|
+
.all(session)
|
|
209
|
+
.map((r) => ({
|
|
210
|
+
id: String(r.id),
|
|
211
|
+
scope: 'workspace-write' as const,
|
|
212
|
+
set_by: String(r.set_by),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
161
215
|
private checked(input: unknown): AgentPodBinding {
|
|
162
216
|
const b = agentpodBinding(input);
|
|
163
217
|
for (const k of Object.keys(this.pins) as (keyof AgentPodPins)[])
|
|
@@ -247,12 +301,30 @@ export class AgentPodAuthority {
|
|
|
247
301
|
this.assert(row);
|
|
248
302
|
return { session_id: row.session_id, binding: JSON.parse(row.binding) };
|
|
249
303
|
}
|
|
250
|
-
|
|
304
|
+
/**
|
|
305
|
+
* `continueFrom` names an earlier interactive admission (its hub session id) whose conversation
|
|
306
|
+
* this new admission carries on. The earlier execution stays revoked: the new admission gets a
|
|
307
|
+
* NEW session, its own lease and its own authority, seeded with a branch of the old Pi
|
|
308
|
+
* transcript and its entries. Only a revoked, stopped and reconciled interactive admission for
|
|
309
|
+
* the same organization, station, occupant and workspace can be continued, and only once.
|
|
310
|
+
*/
|
|
311
|
+
admit(input: unknown, request: string, continueFrom?: string, rulesInput?: unknown) {
|
|
251
312
|
const b = this.checked(input);
|
|
313
|
+
const rules = approvalRules(rulesInput);
|
|
314
|
+
if (
|
|
315
|
+
continueFrom !== undefined &&
|
|
316
|
+
(typeof continueFrom !== 'string' || !continueFrom || continueFrom.length > 128)
|
|
317
|
+
)
|
|
318
|
+
throw new ServiceError('invalid_continuation', 'continue_from must be a hub session id', 400);
|
|
252
319
|
const admitted = this.store.request(
|
|
253
320
|
'agentpod-node',
|
|
254
321
|
request,
|
|
255
|
-
{
|
|
322
|
+
{
|
|
323
|
+
kind: 'admit',
|
|
324
|
+
binding: b,
|
|
325
|
+
...(continueFrom === undefined ? {} : { continue_from: continueFrom }),
|
|
326
|
+
...(rules.length ? { approval_rules: rules } : {}),
|
|
327
|
+
},
|
|
256
328
|
() => {
|
|
257
329
|
const old = this.store.db
|
|
258
330
|
.prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
|
|
@@ -303,17 +375,56 @@ export class AgentPodAuthority {
|
|
|
303
375
|
'INSERT INTO agentpod_station_epochs VALUES (?,?,?) ON CONFLICT(station_id) DO UPDATE SET occupancy_epoch=excluded.occupancy_epoch,principal_id=excluded.principal_id',
|
|
304
376
|
)
|
|
305
377
|
.run(b.station_id, b.occupancy_epoch, b.principal_id);
|
|
378
|
+
const prior = continueFrom === undefined ? undefined : this.continuable(b, continueFrom);
|
|
306
379
|
const session = randomUUID(),
|
|
307
380
|
id = randomUUID();
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
381
|
+
if (prior) {
|
|
382
|
+
const source = this.store.session(prior.session_id);
|
|
383
|
+
const file = source.pi_session_file
|
|
384
|
+
? (() => {
|
|
385
|
+
const dir = join(this.stateDir, 'workers', session, 'sessions');
|
|
386
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
387
|
+
return forkPiSession(source.pi_session_file, source.cwd, dir, session);
|
|
388
|
+
})()
|
|
389
|
+
: null;
|
|
390
|
+
this.store.db
|
|
391
|
+
.prepare(
|
|
392
|
+
'INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES (?,?,?,?,?,?)',
|
|
393
|
+
)
|
|
394
|
+
.run(session, b.workspace, session, file, source.pi_cursor, source.pi_leaf);
|
|
395
|
+
this.store.event(
|
|
396
|
+
session,
|
|
397
|
+
'session.created',
|
|
398
|
+
{ cwd: b.workspace, authority: 'agentpod-v1', forked_from: prior.session_id },
|
|
399
|
+
request,
|
|
400
|
+
);
|
|
401
|
+
this.store.db
|
|
402
|
+
.prepare(
|
|
403
|
+
'INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?',
|
|
404
|
+
)
|
|
405
|
+
.run(session, prior.session_id);
|
|
406
|
+
this.store.event(
|
|
407
|
+
session,
|
|
408
|
+
'session.forked',
|
|
409
|
+
{
|
|
410
|
+
parent: prior.session_id,
|
|
411
|
+
parent_revision: source.revision,
|
|
412
|
+
leaf: source.pi_leaf,
|
|
413
|
+
cursor: source.pi_cursor,
|
|
414
|
+
},
|
|
415
|
+
request,
|
|
416
|
+
);
|
|
417
|
+
} else {
|
|
418
|
+
this.store.db
|
|
419
|
+
.prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
|
|
420
|
+
.run(session, b.workspace, session);
|
|
421
|
+
this.store.event(
|
|
422
|
+
session,
|
|
423
|
+
'session.created',
|
|
424
|
+
{ cwd: b.workspace, authority: 'agentpod-v1' },
|
|
425
|
+
request,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
317
428
|
this.store.db
|
|
318
429
|
.prepare(
|
|
319
430
|
'INSERT INTO agentpod_admissions(id,session_id,hub_session_id,run_key,binding,digest,pins_digest,expires_at) VALUES (?,?,?,?,?,?,?,?)',
|
|
@@ -328,10 +439,20 @@ export class AgentPodAuthority {
|
|
|
328
439
|
digest(this.pins),
|
|
329
440
|
Date.now() + AGENTPOD_LEASE_MS,
|
|
330
441
|
);
|
|
442
|
+
// The agent's standing rules, pinned for this run; a renewal may add or revoke one.
|
|
443
|
+
for (const r of rules)
|
|
444
|
+
this.store.db
|
|
445
|
+
.prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
|
|
446
|
+
.run(id, r.id, r.scope, r.set_by);
|
|
331
447
|
this.store.event(
|
|
332
448
|
session,
|
|
333
449
|
'agentpod.admitted',
|
|
334
|
-
{
|
|
450
|
+
{
|
|
451
|
+
admission_id: id,
|
|
452
|
+
binding_digest: digest(b),
|
|
453
|
+
binding: b,
|
|
454
|
+
...(prior ? { continued_from: continueFrom, continued_admission: prior.id } : {}),
|
|
455
|
+
},
|
|
335
456
|
request,
|
|
336
457
|
);
|
|
337
458
|
return { admission_id: id };
|
|
@@ -340,13 +461,14 @@ export class AgentPodAuthority {
|
|
|
340
461
|
const row = this.row(admitted.admission_id);
|
|
341
462
|
return { ...this.status(row), token: this.token(row.id) };
|
|
342
463
|
}
|
|
343
|
-
renew(input: unknown, request: string, sequence: number) {
|
|
464
|
+
renew(input: unknown, request: string, sequence: number, rulesInput?: unknown) {
|
|
344
465
|
const b = this.checked(input);
|
|
345
466
|
integer(sequence, 'lease_sequence');
|
|
467
|
+
const rules = approvalRules(rulesInput);
|
|
346
468
|
return this.store.request(
|
|
347
469
|
'agentpod-node',
|
|
348
470
|
request,
|
|
349
|
-
{ kind: 'renew', binding: b, sequence },
|
|
471
|
+
{ kind: 'renew', binding: b, sequence, ...(rules.length ? { approval_rules: rules } : {}) },
|
|
350
472
|
() => {
|
|
351
473
|
const row = this.bound(b);
|
|
352
474
|
if (
|
|
@@ -375,6 +497,44 @@ export class AgentPodAuthority {
|
|
|
375
497
|
performance.now() + AGENTPOD_LEASE_MS,
|
|
376
498
|
row.id,
|
|
377
499
|
);
|
|
500
|
+
// Revocation is immediate: a pinned rule the hub no longer lists ends now, for good.
|
|
501
|
+
const listed = new Set(rules.map((r) => r.id));
|
|
502
|
+
for (const pinned of this.store.db
|
|
503
|
+
.prepare(
|
|
504
|
+
'SELECT rule_id FROM agentpod_approval_rules WHERE admission_id=? AND revoked_at IS NULL',
|
|
505
|
+
)
|
|
506
|
+
.all(row.id)) {
|
|
507
|
+
if (listed.has(String(pinned.rule_id))) continue;
|
|
508
|
+
this.store.db
|
|
509
|
+
.prepare(
|
|
510
|
+
'UPDATE agentpod_approval_rules SET revoked_at=? WHERE admission_id=? AND rule_id=?',
|
|
511
|
+
)
|
|
512
|
+
.run(new Date().toISOString(), row.id, String(pinned.rule_id));
|
|
513
|
+
this.store.event(
|
|
514
|
+
row.session_id,
|
|
515
|
+
'approval_rule.revoked',
|
|
516
|
+
{ admission_id: row.id, rule_id: pinned.rule_id },
|
|
517
|
+
request,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
// A rule the hub newly lists joins the run now (operator ruling 2026-10-28: a person who
|
|
521
|
+
// chose "Always allow" mid-run is not asked again). One this run already holds, or held
|
|
522
|
+
// and lost to revocation, is left as it is: revocation stays final for the run.
|
|
523
|
+
for (const r of rules) {
|
|
524
|
+
const known = this.store.db
|
|
525
|
+
.prepare('SELECT 1 FROM agentpod_approval_rules WHERE admission_id=? AND rule_id=?')
|
|
526
|
+
.get(row.id, r.id);
|
|
527
|
+
if (known) continue;
|
|
528
|
+
this.store.db
|
|
529
|
+
.prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
|
|
530
|
+
.run(row.id, r.id, r.scope, r.set_by);
|
|
531
|
+
this.store.event(
|
|
532
|
+
row.session_id,
|
|
533
|
+
'approval_rule.added',
|
|
534
|
+
{ admission_id: row.id, rule_id: r.id, scope: r.scope, set_by: r.set_by },
|
|
535
|
+
request,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
378
538
|
this.store.event(
|
|
379
539
|
row.session_id,
|
|
380
540
|
'agentpod.renewed',
|
|
@@ -385,6 +545,59 @@ export class AgentPodAuthority {
|
|
|
385
545
|
},
|
|
386
546
|
);
|
|
387
547
|
}
|
|
548
|
+
/** The earlier admission a new interactive admission may continue, or a refusal. */
|
|
549
|
+
private continuable(b: AgentPodBinding, continueFrom: string): Admission {
|
|
550
|
+
if (b.run.source !== 'agentpod')
|
|
551
|
+
throw new ServiceError(
|
|
552
|
+
'continuation_refused',
|
|
553
|
+
'Only an interactive admission can continue an earlier conversation',
|
|
554
|
+
);
|
|
555
|
+
const row = this.store.db
|
|
556
|
+
.prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
|
|
557
|
+
.get(continueFrom);
|
|
558
|
+
if (!row)
|
|
559
|
+
throw new ServiceError(
|
|
560
|
+
'admission_not_found',
|
|
561
|
+
'The session to continue has no admission',
|
|
562
|
+
404,
|
|
563
|
+
);
|
|
564
|
+
const prior = this.row(String(row.id));
|
|
565
|
+
const before = JSON.parse(prior.binding) as AgentPodBinding;
|
|
566
|
+
if (
|
|
567
|
+
prior.binding !== canonical(before) ||
|
|
568
|
+
prior.digest !== digest(before) ||
|
|
569
|
+
before.run.source !== 'agentpod' ||
|
|
570
|
+
before.organization_id !== b.organization_id ||
|
|
571
|
+
before.station_id !== b.station_id ||
|
|
572
|
+
before.principal_id !== b.principal_id ||
|
|
573
|
+
before.workspace !== b.workspace ||
|
|
574
|
+
before.hub_session_id === b.hub_session_id
|
|
575
|
+
)
|
|
576
|
+
throw new ServiceError(
|
|
577
|
+
'continuation_refused',
|
|
578
|
+
'Only the same station, occupant and workspace can continue this conversation',
|
|
579
|
+
);
|
|
580
|
+
const status = this.status(prior);
|
|
581
|
+
if (
|
|
582
|
+
prior.state !== 'revoked' ||
|
|
583
|
+
!status.revoked ||
|
|
584
|
+
!status.owned_execution_stopped ||
|
|
585
|
+
status.unresolved_operations.length
|
|
586
|
+
)
|
|
587
|
+
throw new ServiceError(
|
|
588
|
+
'execution_unconfirmed',
|
|
589
|
+
'The conversation to continue must be revoked, stopped and reconciled first',
|
|
590
|
+
);
|
|
591
|
+
if (
|
|
592
|
+
this.store.db
|
|
593
|
+
.prepare(
|
|
594
|
+
"SELECT 1 FROM events WHERE kind='agentpod.admitted' AND json_extract(payload,'$.continued_admission')=? LIMIT 1",
|
|
595
|
+
)
|
|
596
|
+
.get(prior.id)
|
|
597
|
+
)
|
|
598
|
+
throw new ServiceError('already_continued', 'This conversation was already continued');
|
|
599
|
+
return prior;
|
|
600
|
+
}
|
|
388
601
|
revoke(input: unknown, request: string, reason: string) {
|
|
389
602
|
const b = this.checked(input);
|
|
390
603
|
this.store.request('agentpod-node', request, { kind: 'revoke', binding: b, reason }, () => {
|
|
@@ -482,9 +695,15 @@ export class AgentPodAuthority {
|
|
|
482
695
|
this.assert(row);
|
|
483
696
|
authorized = true;
|
|
484
697
|
} catch {}
|
|
698
|
+
const continued = this.store.db
|
|
699
|
+
.prepare(
|
|
700
|
+
"SELECT json_extract(payload,'$.continued_from') AS source FROM events WHERE session_id=? AND kind='agentpod.admitted' LIMIT 1",
|
|
701
|
+
)
|
|
702
|
+
.get(row.session_id) as { source: string | null } | undefined;
|
|
485
703
|
return {
|
|
486
704
|
admission_id: row.id,
|
|
487
705
|
session_id: row.session_id,
|
|
706
|
+
continued_from: continued?.source ?? null,
|
|
488
707
|
binding: JSON.parse(row.binding) as AgentPodBinding,
|
|
489
708
|
binding_digest: row.digest,
|
|
490
709
|
lease_sequence: row.lease_sequence,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The AgentPod pins a node operator drops for this user, used when
|
|
6
|
+
* `service start` names no --agentpod-config. Without it a daemon an AgentPod
|
|
7
|
+
* node expects to bind starts unbound and the node never detects a station.
|
|
8
|
+
*/
|
|
9
|
+
export function defaultAgentPodConfig(
|
|
10
|
+
env: NodeJS.ProcessEnv,
|
|
11
|
+
exists: (path: string) => boolean,
|
|
12
|
+
): string | undefined {
|
|
13
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
|
|
14
|
+
const path = join(base, 'salaam', 'agentpod-pins.json');
|
|
15
|
+
return exists(path) ? path : undefined;
|
|
16
|
+
}
|
|
@@ -12,7 +12,27 @@ export interface WorkDecision {
|
|
|
12
12
|
approval_revision: number;
|
|
13
13
|
expires_at: number;
|
|
14
14
|
question: string;
|
|
15
|
-
|
|
15
|
+
/** Readable approvals: what is being approved, as structured fields. */
|
|
16
|
+
detail?: WorkDecisionDetail;
|
|
17
|
+
/** approve, [approve-run], [always], deny, in that order. "Always allow" (operator ruling
|
|
18
|
+
* 2026-10-28) is a workspace owner's or admin's choice only: `requires` says so to the board, and
|
|
19
|
+
* the hub checks the answerer's role and sets the standing rule before it forwards the answer. */
|
|
20
|
+
options: WorkDecisionOption[];
|
|
21
|
+
}
|
|
22
|
+
export type WorkDecisionOption =
|
|
23
|
+
| { id: 'approve' | 'approve-run' | 'deny'; title: string }
|
|
24
|
+
| { id: 'always'; title: string; requires: 'workspace-admin' };
|
|
25
|
+
export interface WorkDecisionDetail {
|
|
26
|
+
version: 1;
|
|
27
|
+
summary: string;
|
|
28
|
+
reason: string | null;
|
|
29
|
+
files: {
|
|
30
|
+
path: string;
|
|
31
|
+
change: 'create' | 'edit' | 'delete';
|
|
32
|
+
bytes: number | null;
|
|
33
|
+
preview: string | null;
|
|
34
|
+
}[];
|
|
35
|
+
digest: string;
|
|
16
36
|
}
|
|
17
37
|
export interface WorkDecisionAnswer {
|
|
18
38
|
version: 1;
|
|
@@ -20,7 +40,7 @@ export interface WorkDecisionAnswer {
|
|
|
20
40
|
decision: WorkDecision;
|
|
21
41
|
decision_digest: string;
|
|
22
42
|
elicitation_id: string;
|
|
23
|
-
option_id: 'approve' | 'deny';
|
|
43
|
+
option_id: 'approve' | 'approve-run' | 'always' | 'deny';
|
|
24
44
|
answered_by: string;
|
|
25
45
|
answered_at: string;
|
|
26
46
|
}
|
|
@@ -57,7 +77,9 @@ export function workDecision(value: unknown): WorkDecision {
|
|
|
57
77
|
'expires_at',
|
|
58
78
|
'question',
|
|
59
79
|
'options',
|
|
80
|
+
...('detail' in v ? ['detail'] : []),
|
|
60
81
|
]);
|
|
82
|
+
if ('detail' in v) workDecisionDetail(v.detail);
|
|
61
83
|
if (
|
|
62
84
|
v.version !== 1 ||
|
|
63
85
|
!['request_id', 'work_request_id', 'task_id', 'operation_id', 'approval_id'].every((k) =>
|
|
@@ -71,13 +93,25 @@ export function workDecision(value: unknown): WorkDecision {
|
|
|
71
93
|
) ||
|
|
72
94
|
!text(v.question, 8192) ||
|
|
73
95
|
!Array.isArray(v.options) ||
|
|
74
|
-
v.options.length
|
|
96
|
+
![2, 3, 4].includes(v.options.length)
|
|
97
|
+
)
|
|
98
|
+
fail();
|
|
99
|
+
// approve, [approve-run], [always], deny. Nothing else, in that order.
|
|
100
|
+
const ids = v.options.map((o) => String(object(o).id));
|
|
101
|
+
if (
|
|
102
|
+
![
|
|
103
|
+
'approve,deny',
|
|
104
|
+
'approve,approve-run,deny',
|
|
105
|
+
'approve,always,deny',
|
|
106
|
+
'approve,approve-run,always,deny',
|
|
107
|
+
].includes(ids.join(','))
|
|
75
108
|
)
|
|
76
109
|
fail();
|
|
77
|
-
for (
|
|
78
|
-
const opt = object(
|
|
79
|
-
keys(opt, ['id', 'title']);
|
|
80
|
-
if (opt.id
|
|
110
|
+
for (const value of v.options) {
|
|
111
|
+
const opt = object(value);
|
|
112
|
+
keys(opt, opt.id === 'always' ? ['id', 'title', 'requires'] : ['id', 'title']);
|
|
113
|
+
if (!text(opt.title, 256) || (opt.id === 'always' && opt.requires !== 'workspace-admin'))
|
|
114
|
+
fail();
|
|
81
115
|
}
|
|
82
116
|
return v as unknown as WorkDecision;
|
|
83
117
|
}
|
|
@@ -101,7 +135,7 @@ export function workDecisionAnswer(value: unknown): WorkDecisionAnswer {
|
|
|
101
135
|
!/^[a-f0-9]{64}$/.test(v.decision_digest) ||
|
|
102
136
|
!text(v.elicitation_id, 128) ||
|
|
103
137
|
!/^elc_[A-Za-z0-9]{6,}$/.test(v.elicitation_id) ||
|
|
104
|
-
!['approve', 'deny'].includes(String(v.option_id)) ||
|
|
138
|
+
!['approve', 'approve-run', 'always', 'deny'].includes(String(v.option_id)) ||
|
|
105
139
|
!text(v.answered_by, 128) ||
|
|
106
140
|
typeof v.answered_at !== 'string' ||
|
|
107
141
|
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(v.answered_at) ||
|
|
@@ -111,3 +145,30 @@ export function workDecisionAnswer(value: unknown): WorkDecisionAnswer {
|
|
|
111
145
|
fail();
|
|
112
146
|
return v as unknown as WorkDecisionAnswer;
|
|
113
147
|
}
|
|
148
|
+
|
|
149
|
+
function workDecisionDetail(value: unknown): WorkDecisionDetail {
|
|
150
|
+
const d = object(value);
|
|
151
|
+
keys(d, ['version', 'summary', 'reason', 'files', 'digest']);
|
|
152
|
+
if (
|
|
153
|
+
d.version !== 1 ||
|
|
154
|
+
!text(d.summary, 200) ||
|
|
155
|
+
(d.reason !== null && !text(d.reason, 300)) ||
|
|
156
|
+
typeof d.digest !== 'string' ||
|
|
157
|
+
!/^[a-f0-9]{64}$/.test(d.digest) ||
|
|
158
|
+
!Array.isArray(d.files) ||
|
|
159
|
+
d.files.length > 16
|
|
160
|
+
)
|
|
161
|
+
fail();
|
|
162
|
+
for (const f of d.files as unknown[]) {
|
|
163
|
+
const file = object(f);
|
|
164
|
+
keys(file, ['path', 'change', 'bytes', 'preview']);
|
|
165
|
+
if (
|
|
166
|
+
!text(file.path, 512) ||
|
|
167
|
+
!['create', 'edit', 'delete'].includes(String(file.change)) ||
|
|
168
|
+
(file.bytes !== null && (!Number.isSafeInteger(file.bytes) || Number(file.bytes) < 0)) ||
|
|
169
|
+
(file.preview !== null && (typeof file.preview !== 'string' || file.preview.length > 1200))
|
|
170
|
+
)
|
|
171
|
+
fail();
|
|
172
|
+
}
|
|
173
|
+
return d as unknown as WorkDecisionDetail;
|
|
174
|
+
}
|
|
@@ -2,14 +2,43 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import type { AgentPodWork } from './agentpod-work.js';
|
|
3
3
|
import type { ManagedWork } from './managed.js';
|
|
4
4
|
import { canonical, digest, ServiceError } from './protocol.js';
|
|
5
|
-
import {
|
|
5
|
+
import { type RunGrants, runGrantCovers, runGrantPrefix } from './run-grants.js';
|
|
6
|
+
import {
|
|
7
|
+
workDecisionAnswer,
|
|
8
|
+
type WorkDecision,
|
|
9
|
+
type WorkDecisionDetail,
|
|
10
|
+
} from './agentpod-work-decision-protocol.js';
|
|
6
11
|
/** Private authenticated Hub control only. Answers choose an existing approval;
|
|
7
12
|
* they never execute an operation or supply a second effect grant. */
|
|
8
13
|
export class AgentPodWorkDecisions {
|
|
9
14
|
constructor(
|
|
10
15
|
readonly work: AgentPodWork,
|
|
11
16
|
readonly managed: ManagedWork,
|
|
17
|
+
readonly grants?: RunGrants,
|
|
12
18
|
) {}
|
|
19
|
+
/** The run directory a grant for this operation would cover, when it is a covered write. */
|
|
20
|
+
private grantable(binding: unknown, op: { kind: string; value: string }): string | null {
|
|
21
|
+
if (!this.grants || op.kind !== 'workspace') return null;
|
|
22
|
+
const run = (binding as { run?: { id?: unknown } } | null)?.run;
|
|
23
|
+
const prefix = runGrantPrefix(typeof run?.id === 'string' ? run.id : '');
|
|
24
|
+
let args: Record<string, unknown> = {};
|
|
25
|
+
try {
|
|
26
|
+
args = JSON.parse(op.value) as Record<string, unknown>;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return prefix && runGrantCovers(prefix, args) ? prefix : null;
|
|
31
|
+
}
|
|
32
|
+
/** "Always allow" covers exactly what the standing workspace-write rule answers: a workspace
|
|
33
|
+
* write anywhere in the workspace, never a delete or a hidden path. */
|
|
34
|
+
private alwaysAllowable(op: { kind: string; value: string }): boolean {
|
|
35
|
+
if (op.kind !== 'workspace') return false;
|
|
36
|
+
try {
|
|
37
|
+
return runGrantCovers('', JSON.parse(op.value) as Record<string, unknown>);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
private owner(binding: unknown) {
|
|
14
43
|
const root = this.work.authority.inspect(binding);
|
|
15
44
|
const row = this.work.store.db
|
|
@@ -51,10 +80,23 @@ export class AgentPodWorkDecisions {
|
|
|
51
80
|
version: 1,
|
|
52
81
|
request_id: randomUUID(),
|
|
53
82
|
...identity,
|
|
54
|
-
question:
|
|
83
|
+
question: approvalDetail(op).summary + '?',
|
|
84
|
+
detail: approvalDetail(op),
|
|
55
85
|
options: [
|
|
56
|
-
{ id: 'approve', title: 'Approve once' },
|
|
57
|
-
|
|
86
|
+
{ id: 'approve' as const, title: 'Approve once' },
|
|
87
|
+
...(this.grantable(root.binding, op)
|
|
88
|
+
? [{ id: 'approve-run' as const, title: 'Approve for this run' }]
|
|
89
|
+
: []),
|
|
90
|
+
...(this.alwaysAllowable(op)
|
|
91
|
+
? [
|
|
92
|
+
{
|
|
93
|
+
id: 'always' as const,
|
|
94
|
+
title: 'Always allow this agent to write in its workspace',
|
|
95
|
+
requires: 'workspace-admin' as const,
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
99
|
+
{ id: 'deny' as const, title: 'Deny' },
|
|
58
100
|
],
|
|
59
101
|
}),
|
|
60
102
|
);
|
|
@@ -97,13 +139,45 @@ export class AgentPodWorkDecisions {
|
|
|
97
139
|
approval.expires_at !== offered.expires_at
|
|
98
140
|
)
|
|
99
141
|
throw new ServiceError('approval_mismatch', 'Approval ownership changed');
|
|
142
|
+
// "Approve for this run" must have been offered, and still be grantable, to be chosen.
|
|
143
|
+
const prefix =
|
|
144
|
+
answer.option_id === 'approve-run'
|
|
145
|
+
? this.grantable(root.binding, this.managed.operation(offered.operation_id))
|
|
146
|
+
: null;
|
|
147
|
+
if (
|
|
148
|
+
answer.option_id === 'approve-run' &&
|
|
149
|
+
(!prefix || !offered.options.some((o) => o.id === 'approve-run'))
|
|
150
|
+
)
|
|
151
|
+
throw new ServiceError('approval_mismatch', 'This approval cannot cover the run');
|
|
152
|
+
// "Always allow" approves this write; the standing rule is the hub's, set before it forwards.
|
|
153
|
+
if (
|
|
154
|
+
answer.option_id === 'always' &&
|
|
155
|
+
(!offered.options.some((o) => o.id === 'always') ||
|
|
156
|
+
!this.alwaysAllowable(this.managed.operation(offered.operation_id)))
|
|
157
|
+
)
|
|
158
|
+
throw new ServiceError(
|
|
159
|
+
'approval_mismatch',
|
|
160
|
+
'Always allow was not offered for this write',
|
|
161
|
+
);
|
|
100
162
|
this.managed.decide(
|
|
101
163
|
offered.approval_id,
|
|
102
164
|
answer.request_id,
|
|
103
165
|
offered.approval_revision,
|
|
104
166
|
offered.operation_digest,
|
|
105
|
-
answer.option_id,
|
|
167
|
+
answer.option_id === 'deny' ? 'deny' : 'approve',
|
|
106
168
|
);
|
|
169
|
+
const grant =
|
|
170
|
+
answer.option_id === 'approve-run'
|
|
171
|
+
? this.grants!.createInTransaction({
|
|
172
|
+
session_id: root.session_id,
|
|
173
|
+
task_id: task.id,
|
|
174
|
+
plan_revision: offered.plan_revision,
|
|
175
|
+
binding_digest: offered.binding_digest,
|
|
176
|
+
path_prefix: prefix!,
|
|
177
|
+
approval_id: offered.approval_id,
|
|
178
|
+
granted_by: answer.answered_by,
|
|
179
|
+
})
|
|
180
|
+
: null;
|
|
107
181
|
this.work.store.execution.assert(root.session_id);
|
|
108
182
|
return {
|
|
109
183
|
version: 1 as const,
|
|
@@ -111,8 +185,52 @@ export class AgentPodWorkDecisions {
|
|
|
111
185
|
decision_digest: answer.decision_digest,
|
|
112
186
|
approval_id: offered.approval_id,
|
|
113
187
|
decision: answer.option_id,
|
|
188
|
+
...(grant ? { grant_id: grant.id } : {}),
|
|
114
189
|
};
|
|
115
190
|
},
|
|
116
191
|
);
|
|
117
192
|
}
|
|
118
193
|
}
|
|
194
|
+
|
|
195
|
+
/** Readable approvals: what a person approves, from the operation's own canonical arguments only.
|
|
196
|
+
* A workspace write names its file, the change, its size, a short preview and the agent's one-line
|
|
197
|
+
* reason; anything else names its kind and target. The digest still binds the exact operation. */
|
|
198
|
+
export function approvalDetail(op: {
|
|
199
|
+
kind: string;
|
|
200
|
+
action_key: string;
|
|
201
|
+
target: string;
|
|
202
|
+
value: string;
|
|
203
|
+
operation_digest: string;
|
|
204
|
+
}): WorkDecisionDetail {
|
|
205
|
+
const base = { version: 1 as const, reason: null, files: [], digest: op.operation_digest };
|
|
206
|
+
if (op.kind === 'review') return { ...base, summary: 'Accept this step for review' };
|
|
207
|
+
if (op.kind !== 'workspace')
|
|
208
|
+
return { ...base, summary: ('Run ' + op.kind + ' ' + op.target).slice(0, 200) };
|
|
209
|
+
let args: Record<string, unknown> = {};
|
|
210
|
+
try {
|
|
211
|
+
args = JSON.parse(op.value) as Record<string, unknown>;
|
|
212
|
+
} catch {
|
|
213
|
+
/* not a patch: the generic form below */
|
|
214
|
+
}
|
|
215
|
+
const path = typeof args.path === 'string' ? args.path : op.target.replace(/^station:/, '');
|
|
216
|
+
const content = typeof args.content === 'string' ? args.content : null;
|
|
217
|
+
const change = args.content === null ? 'delete' : args.expected_hash === null ? 'create' : 'edit';
|
|
218
|
+
const verb = { create: 'Create', edit: 'Edit', delete: 'Delete' }[change];
|
|
219
|
+
const reason =
|
|
220
|
+
typeof args.reason === 'string' && args.reason.trim()
|
|
221
|
+
? args.reason.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
222
|
+
: null;
|
|
223
|
+
return {
|
|
224
|
+
...base,
|
|
225
|
+
summary: (verb + ' ' + path + ' in the workspace').slice(0, 200),
|
|
226
|
+
reason,
|
|
227
|
+
files: [
|
|
228
|
+
{
|
|
229
|
+
path: path.slice(0, 512),
|
|
230
|
+
change,
|
|
231
|
+
bytes: content === null ? null : Buffer.byteLength(content),
|
|
232
|
+
preview: content === null ? null : content.slice(0, 1200),
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|