@superjackfruitlabs/salaam 0.0.3 → 0.0.4
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 +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- 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/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- 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 +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- 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.js +22 -1
- package/dist/src/protocol.js +5 -2
- 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 +141 -6
- 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 +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- 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/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- 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/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- 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 +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- 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 +222 -6
- 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 +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- 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/tool-identity.ts +110 -0
- package/src/usage.ts +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID, randomBytes, createHmac, createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { constants, openSync, fstatSync, closeSync, readFileSync, writeFileSync, fsyncSync, realpathSync, } from 'node:fs';
|
|
2
|
+
import { constants, openSync, fstatSync, closeSync, readFileSync, writeFileSync, fsyncSync, realpathSync, mkdirSync, } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { canonical, digest, ServiceError, object, integer } from './protocol.js';
|
|
5
|
+
import { forkPiSession } from './pi-sessions.js';
|
|
5
6
|
import { agentpodBinding, AGENTPOD_LEASE_MS, } from './agentpod-protocol.js';
|
|
6
7
|
function privateBytes(path) {
|
|
7
8
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -41,7 +42,35 @@ export function readAgentPodPins(configPath, state) {
|
|
|
41
42
|
throw Error('AgentPod private daemon state must be outside the station workspace');
|
|
42
43
|
return pins;
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
+
const ruleId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
46
|
+
/** Closed: 1 to 16 rules, exact keys, a known scope, unique ids. Absent means none. */
|
|
47
|
+
export function approvalRules(value) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return [];
|
|
50
|
+
const bad = () => {
|
|
51
|
+
throw new ServiceError('invalid_approval_rules', 'Malformed approval rules', 400);
|
|
52
|
+
};
|
|
53
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 16)
|
|
54
|
+
bad();
|
|
55
|
+
const rules = value.map((r) => {
|
|
56
|
+
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
57
|
+
bad();
|
|
58
|
+
const v = r;
|
|
59
|
+
if (Object.keys(v).sort().join(',') !== 'id,scope,set_by' ||
|
|
60
|
+
typeof v.id !== 'string' ||
|
|
61
|
+
!ruleId.test(v.id) ||
|
|
62
|
+
v.scope !== 'workspace-write' ||
|
|
63
|
+
typeof v.set_by !== 'string' ||
|
|
64
|
+
v.set_by.length < 1 ||
|
|
65
|
+
v.set_by.length > 128 ||
|
|
66
|
+
/[\u0000-\u001f\u007f]/.test(v.set_by))
|
|
67
|
+
bad();
|
|
68
|
+
return { id: v.id, scope: 'workspace-write', set_by: v.set_by };
|
|
69
|
+
});
|
|
70
|
+
if (new Set(rules.map((r) => r.id)).size !== rules.length)
|
|
71
|
+
bad();
|
|
72
|
+
return rules.sort((a, b) => a.id.localeCompare(b.id));
|
|
73
|
+
}
|
|
45
74
|
export class AgentPodAuthority {
|
|
46
75
|
store;
|
|
47
76
|
configPath;
|
|
@@ -50,12 +79,14 @@ export class AgentPodAuthority {
|
|
|
50
79
|
boot = randomUUID();
|
|
51
80
|
pins;
|
|
52
81
|
key;
|
|
82
|
+
stateDir;
|
|
53
83
|
constructor(store, state, configPath, cancel, ownedResourcesStopped = () => true) {
|
|
54
84
|
this.store = store;
|
|
55
85
|
this.configPath = configPath;
|
|
56
86
|
this.cancel = cancel;
|
|
57
87
|
this.ownedResourcesStopped = ownedResourcesStopped;
|
|
58
88
|
this.pins = readAgentPodPins(configPath, state);
|
|
89
|
+
this.stateDir = state;
|
|
59
90
|
const keyPath = join(state, 'agentpod-control-key');
|
|
60
91
|
try {
|
|
61
92
|
const fd = openSync(keyPath, 'wx', 0o600);
|
|
@@ -81,6 +112,7 @@ export class AgentPodAuthority {
|
|
|
81
112
|
this.key = privateBytes(keyPath);
|
|
82
113
|
if (this.key.length !== 32)
|
|
83
114
|
throw Error('Invalid AgentPod control key');
|
|
115
|
+
store.db.exec('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))');
|
|
84
116
|
store.agentpodAuthorityCheck = (session) => this.assert(this.bySession(session));
|
|
85
117
|
store.requireAgentPodAuthority = true;
|
|
86
118
|
}
|
|
@@ -121,6 +153,17 @@ export class AgentPodAuthority {
|
|
|
121
153
|
throw new ServiceError('authority_expired', 'AgentPod authority lease has no remaining native grant window');
|
|
122
154
|
return remaining;
|
|
123
155
|
}
|
|
156
|
+
/** The session's pinned approval rules that no renewal has revoked. */
|
|
157
|
+
approvalRules(session) {
|
|
158
|
+
return this.store.db
|
|
159
|
+
.prepare('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')
|
|
160
|
+
.all(session)
|
|
161
|
+
.map((r) => ({
|
|
162
|
+
id: String(r.id),
|
|
163
|
+
scope: 'workspace-write',
|
|
164
|
+
set_by: String(r.set_by),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
124
167
|
checked(input) {
|
|
125
168
|
const b = agentpodBinding(input);
|
|
126
169
|
for (const k of Object.keys(this.pins))
|
|
@@ -191,9 +234,25 @@ export class AgentPodAuthority {
|
|
|
191
234
|
this.assert(row);
|
|
192
235
|
return { session_id: row.session_id, binding: JSON.parse(row.binding) };
|
|
193
236
|
}
|
|
194
|
-
|
|
237
|
+
/**
|
|
238
|
+
* `continueFrom` names an earlier interactive admission (its hub session id) whose conversation
|
|
239
|
+
* this new admission carries on. The earlier execution stays revoked: the new admission gets a
|
|
240
|
+
* NEW session, its own lease and its own authority, seeded with a branch of the old Pi
|
|
241
|
+
* transcript and its entries. Only a revoked, stopped and reconciled interactive admission for
|
|
242
|
+
* the same organization, station, occupant and workspace can be continued, and only once.
|
|
243
|
+
*/
|
|
244
|
+
admit(input, request, continueFrom, rulesInput) {
|
|
195
245
|
const b = this.checked(input);
|
|
196
|
-
const
|
|
246
|
+
const rules = approvalRules(rulesInput);
|
|
247
|
+
if (continueFrom !== undefined &&
|
|
248
|
+
(typeof continueFrom !== 'string' || !continueFrom || continueFrom.length > 128))
|
|
249
|
+
throw new ServiceError('invalid_continuation', 'continue_from must be a hub session id', 400);
|
|
250
|
+
const admitted = this.store.request('agentpod-node', request, {
|
|
251
|
+
kind: 'admit',
|
|
252
|
+
binding: b,
|
|
253
|
+
...(continueFrom === undefined ? {} : { continue_from: continueFrom }),
|
|
254
|
+
...(rules.length ? { approval_rules: rules } : {}),
|
|
255
|
+
}, () => {
|
|
197
256
|
const old = this.store.db
|
|
198
257
|
.prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
|
|
199
258
|
.get(b.hub_session_id);
|
|
@@ -226,24 +285,61 @@ export class AgentPodAuthority {
|
|
|
226
285
|
this.store.db
|
|
227
286
|
.prepare('INSERT INTO agentpod_station_epochs VALUES (?,?,?) ON CONFLICT(station_id) DO UPDATE SET occupancy_epoch=excluded.occupancy_epoch,principal_id=excluded.principal_id')
|
|
228
287
|
.run(b.station_id, b.occupancy_epoch, b.principal_id);
|
|
288
|
+
const prior = continueFrom === undefined ? undefined : this.continuable(b, continueFrom);
|
|
229
289
|
const session = randomUUID(), id = randomUUID();
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
290
|
+
if (prior) {
|
|
291
|
+
const source = this.store.session(prior.session_id);
|
|
292
|
+
const file = source.pi_session_file
|
|
293
|
+
? (() => {
|
|
294
|
+
const dir = join(this.stateDir, 'workers', session, 'sessions');
|
|
295
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
296
|
+
return forkPiSession(source.pi_session_file, source.cwd, dir, session);
|
|
297
|
+
})()
|
|
298
|
+
: null;
|
|
299
|
+
this.store.db
|
|
300
|
+
.prepare('INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES (?,?,?,?,?,?)')
|
|
301
|
+
.run(session, b.workspace, session, file, source.pi_cursor, source.pi_leaf);
|
|
302
|
+
this.store.event(session, 'session.created', { cwd: b.workspace, authority: 'agentpod-v1', forked_from: prior.session_id }, request);
|
|
303
|
+
this.store.db
|
|
304
|
+
.prepare('INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?')
|
|
305
|
+
.run(session, prior.session_id);
|
|
306
|
+
this.store.event(session, 'session.forked', {
|
|
307
|
+
parent: prior.session_id,
|
|
308
|
+
parent_revision: source.revision,
|
|
309
|
+
leaf: source.pi_leaf,
|
|
310
|
+
cursor: source.pi_cursor,
|
|
311
|
+
}, request);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
this.store.db
|
|
315
|
+
.prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
|
|
316
|
+
.run(session, b.workspace, session);
|
|
317
|
+
this.store.event(session, 'session.created', { cwd: b.workspace, authority: 'agentpod-v1' }, request);
|
|
318
|
+
}
|
|
234
319
|
this.store.db
|
|
235
320
|
.prepare('INSERT INTO agentpod_admissions(id,session_id,hub_session_id,run_key,binding,digest,pins_digest,expires_at) VALUES (?,?,?,?,?,?,?,?)')
|
|
236
321
|
.run(id, session, b.hub_session_id, canonical({ org: b.organization_id, run: b.run }), canonical(b), digest(b), digest(this.pins), Date.now() + AGENTPOD_LEASE_MS);
|
|
237
|
-
|
|
322
|
+
// The agent's standing rules, pinned for this run; a renewal may add or revoke one.
|
|
323
|
+
for (const r of rules)
|
|
324
|
+
this.store.db
|
|
325
|
+
.prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
|
|
326
|
+
.run(id, r.id, r.scope, r.set_by);
|
|
327
|
+
this.store.event(session, 'agentpod.admitted', {
|
|
328
|
+
admission_id: id,
|
|
329
|
+
binding_digest: digest(b),
|
|
330
|
+
binding: b,
|
|
331
|
+
...(prior ? { continued_from: continueFrom, continued_admission: prior.id } : {}),
|
|
332
|
+
}, request);
|
|
238
333
|
return { admission_id: id };
|
|
239
334
|
});
|
|
240
335
|
const row = this.row(admitted.admission_id);
|
|
241
336
|
return { ...this.status(row), token: this.token(row.id) };
|
|
242
337
|
}
|
|
243
|
-
renew(input, request, sequence) {
|
|
338
|
+
renew(input, request, sequence, rulesInput) {
|
|
244
339
|
const b = this.checked(input);
|
|
245
340
|
integer(sequence, 'lease_sequence');
|
|
246
|
-
|
|
341
|
+
const rules = approvalRules(rulesInput);
|
|
342
|
+
return this.store.request('agentpod-node', request, { kind: 'renew', binding: b, sequence, ...(rules.length ? { approval_rules: rules } : {}) }, () => {
|
|
247
343
|
const row = this.bound(b);
|
|
248
344
|
if (row.state === 'revoked' ||
|
|
249
345
|
this.store.execution.revocation(row.session_id) ||
|
|
@@ -258,10 +354,68 @@ export class AgentPodAuthority {
|
|
|
258
354
|
this.store.db
|
|
259
355
|
.prepare("UPDATE agentpod_admissions SET lease_sequence=?,expires_at=?,boot_id=?,monotonic_deadline=?,state='active' WHERE id=?")
|
|
260
356
|
.run(sequence, Date.now() + AGENTPOD_LEASE_MS, this.boot, performance.now() + AGENTPOD_LEASE_MS, row.id);
|
|
357
|
+
// Revocation is immediate: a pinned rule the hub no longer lists ends now, for good.
|
|
358
|
+
const listed = new Set(rules.map((r) => r.id));
|
|
359
|
+
for (const pinned of this.store.db
|
|
360
|
+
.prepare('SELECT rule_id FROM agentpod_approval_rules WHERE admission_id=? AND revoked_at IS NULL')
|
|
361
|
+
.all(row.id)) {
|
|
362
|
+
if (listed.has(String(pinned.rule_id)))
|
|
363
|
+
continue;
|
|
364
|
+
this.store.db
|
|
365
|
+
.prepare('UPDATE agentpod_approval_rules SET revoked_at=? WHERE admission_id=? AND rule_id=?')
|
|
366
|
+
.run(new Date().toISOString(), row.id, String(pinned.rule_id));
|
|
367
|
+
this.store.event(row.session_id, 'approval_rule.revoked', { admission_id: row.id, rule_id: pinned.rule_id }, request);
|
|
368
|
+
}
|
|
369
|
+
// A rule the hub newly lists joins the run now (operator ruling 2026-10-28: a person who
|
|
370
|
+
// chose "Always allow" mid-run is not asked again). One this run already holds, or held
|
|
371
|
+
// and lost to revocation, is left as it is: revocation stays final for the run.
|
|
372
|
+
for (const r of rules) {
|
|
373
|
+
const known = this.store.db
|
|
374
|
+
.prepare('SELECT 1 FROM agentpod_approval_rules WHERE admission_id=? AND rule_id=?')
|
|
375
|
+
.get(row.id, r.id);
|
|
376
|
+
if (known)
|
|
377
|
+
continue;
|
|
378
|
+
this.store.db
|
|
379
|
+
.prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
|
|
380
|
+
.run(row.id, r.id, r.scope, r.set_by);
|
|
381
|
+
this.store.event(row.session_id, 'approval_rule.added', { admission_id: row.id, rule_id: r.id, scope: r.scope, set_by: r.set_by }, request);
|
|
382
|
+
}
|
|
261
383
|
this.store.event(row.session_id, 'agentpod.renewed', { admission_id: row.id, lease_sequence: sequence }, request);
|
|
262
384
|
return this.status(this.row(row.id));
|
|
263
385
|
});
|
|
264
386
|
}
|
|
387
|
+
/** The earlier admission a new interactive admission may continue, or a refusal. */
|
|
388
|
+
continuable(b, continueFrom) {
|
|
389
|
+
if (b.run.source !== 'agentpod')
|
|
390
|
+
throw new ServiceError('continuation_refused', 'Only an interactive admission can continue an earlier conversation');
|
|
391
|
+
const row = this.store.db
|
|
392
|
+
.prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
|
|
393
|
+
.get(continueFrom);
|
|
394
|
+
if (!row)
|
|
395
|
+
throw new ServiceError('admission_not_found', 'The session to continue has no admission', 404);
|
|
396
|
+
const prior = this.row(String(row.id));
|
|
397
|
+
const before = JSON.parse(prior.binding);
|
|
398
|
+
if (prior.binding !== canonical(before) ||
|
|
399
|
+
prior.digest !== digest(before) ||
|
|
400
|
+
before.run.source !== 'agentpod' ||
|
|
401
|
+
before.organization_id !== b.organization_id ||
|
|
402
|
+
before.station_id !== b.station_id ||
|
|
403
|
+
before.principal_id !== b.principal_id ||
|
|
404
|
+
before.workspace !== b.workspace ||
|
|
405
|
+
before.hub_session_id === b.hub_session_id)
|
|
406
|
+
throw new ServiceError('continuation_refused', 'Only the same station, occupant and workspace can continue this conversation');
|
|
407
|
+
const status = this.status(prior);
|
|
408
|
+
if (prior.state !== 'revoked' ||
|
|
409
|
+
!status.revoked ||
|
|
410
|
+
!status.owned_execution_stopped ||
|
|
411
|
+
status.unresolved_operations.length)
|
|
412
|
+
throw new ServiceError('execution_unconfirmed', 'The conversation to continue must be revoked, stopped and reconciled first');
|
|
413
|
+
if (this.store.db
|
|
414
|
+
.prepare("SELECT 1 FROM events WHERE kind='agentpod.admitted' AND json_extract(payload,'$.continued_admission')=? LIMIT 1")
|
|
415
|
+
.get(prior.id))
|
|
416
|
+
throw new ServiceError('already_continued', 'This conversation was already continued');
|
|
417
|
+
return prior;
|
|
418
|
+
}
|
|
265
419
|
revoke(input, request, reason) {
|
|
266
420
|
const b = this.checked(input);
|
|
267
421
|
this.store.request('agentpod-node', request, { kind: 'revoke', binding: b, reason }, () => {
|
|
@@ -329,9 +483,13 @@ export class AgentPodAuthority {
|
|
|
329
483
|
authorized = true;
|
|
330
484
|
}
|
|
331
485
|
catch { }
|
|
486
|
+
const continued = this.store.db
|
|
487
|
+
.prepare("SELECT json_extract(payload,'$.continued_from') AS source FROM events WHERE session_id=? AND kind='agentpod.admitted' LIMIT 1")
|
|
488
|
+
.get(row.session_id);
|
|
332
489
|
return {
|
|
333
490
|
admission_id: row.id,
|
|
334
491
|
session_id: row.session_id,
|
|
492
|
+
continued_from: continued?.source ?? null,
|
|
335
493
|
binding: JSON.parse(row.binding),
|
|
336
494
|
binding_digest: row.digest,
|
|
337
495
|
lease_sequence: row.lease_sequence,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* The AgentPod pins a node operator drops for this user, used when
|
|
5
|
+
* `service start` names no --agentpod-config. Without it a daemon an AgentPod
|
|
6
|
+
* node expects to bind starts unbound and the node never detects a station.
|
|
7
|
+
*/
|
|
8
|
+
export function defaultAgentPodConfig(env, exists) {
|
|
9
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
|
|
10
|
+
const path = join(base, 'salaam', 'agentpod-pins.json');
|
|
11
|
+
return exists(path) ? path : undefined;
|
|
12
|
+
}
|
|
@@ -31,19 +31,31 @@ export function workDecision(value) {
|
|
|
31
31
|
'expires_at',
|
|
32
32
|
'question',
|
|
33
33
|
'options',
|
|
34
|
+
...('detail' in v ? ['detail'] : []),
|
|
34
35
|
]);
|
|
36
|
+
if ('detail' in v)
|
|
37
|
+
workDecisionDetail(v.detail);
|
|
35
38
|
if (v.version !== 1 ||
|
|
36
39
|
!['request_id', 'work_request_id', 'task_id', 'operation_id', 'approval_id'].every((k) => uuid(v[k])) ||
|
|
37
40
|
!['binding_digest', 'operation_digest'].every((k) => typeof v[k] === 'string' && /^[a-f0-9]{64}$/.test(v[k])) ||
|
|
38
41
|
!['plan_revision', 'approval_revision', 'expires_at'].every((k) => Number.isSafeInteger(v[k]) && Number(v[k]) > 0) ||
|
|
39
42
|
!text(v.question, 8192) ||
|
|
40
43
|
!Array.isArray(v.options) ||
|
|
41
|
-
v.options.length
|
|
44
|
+
![2, 3, 4].includes(v.options.length))
|
|
42
45
|
fail();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
// approve, [approve-run], [always], deny. Nothing else, in that order.
|
|
47
|
+
const ids = v.options.map((o) => String(object(o).id));
|
|
48
|
+
if (![
|
|
49
|
+
'approve,deny',
|
|
50
|
+
'approve,approve-run,deny',
|
|
51
|
+
'approve,always,deny',
|
|
52
|
+
'approve,approve-run,always,deny',
|
|
53
|
+
].includes(ids.join(',')))
|
|
54
|
+
fail();
|
|
55
|
+
for (const value of v.options) {
|
|
56
|
+
const opt = object(value);
|
|
57
|
+
keys(opt, opt.id === 'always' ? ['id', 'title', 'requires'] : ['id', 'title']);
|
|
58
|
+
if (!text(opt.title, 256) || (opt.id === 'always' && opt.requires !== 'workspace-admin'))
|
|
47
59
|
fail();
|
|
48
60
|
}
|
|
49
61
|
return v;
|
|
@@ -67,7 +79,7 @@ export function workDecisionAnswer(value) {
|
|
|
67
79
|
!/^[a-f0-9]{64}$/.test(v.decision_digest) ||
|
|
68
80
|
!text(v.elicitation_id, 128) ||
|
|
69
81
|
!/^elc_[A-Za-z0-9]{6,}$/.test(v.elicitation_id) ||
|
|
70
|
-
!['approve', 'deny'].includes(String(v.option_id)) ||
|
|
82
|
+
!['approve', 'approve-run', 'always', 'deny'].includes(String(v.option_id)) ||
|
|
71
83
|
!text(v.answered_by, 128) ||
|
|
72
84
|
typeof v.answered_at !== 'string' ||
|
|
73
85
|
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(v.answered_at) ||
|
|
@@ -76,3 +88,25 @@ export function workDecisionAnswer(value) {
|
|
|
76
88
|
fail();
|
|
77
89
|
return v;
|
|
78
90
|
}
|
|
91
|
+
function workDecisionDetail(value) {
|
|
92
|
+
const d = object(value);
|
|
93
|
+
keys(d, ['version', 'summary', 'reason', 'files', 'digest']);
|
|
94
|
+
if (d.version !== 1 ||
|
|
95
|
+
!text(d.summary, 200) ||
|
|
96
|
+
(d.reason !== null && !text(d.reason, 300)) ||
|
|
97
|
+
typeof d.digest !== 'string' ||
|
|
98
|
+
!/^[a-f0-9]{64}$/.test(d.digest) ||
|
|
99
|
+
!Array.isArray(d.files) ||
|
|
100
|
+
d.files.length > 16)
|
|
101
|
+
fail();
|
|
102
|
+
for (const f of d.files) {
|
|
103
|
+
const file = object(f);
|
|
104
|
+
keys(file, ['path', 'change', 'bytes', 'preview']);
|
|
105
|
+
if (!text(file.path, 512) ||
|
|
106
|
+
!['create', 'edit', 'delete'].includes(String(file.change)) ||
|
|
107
|
+
(file.bytes !== null && (!Number.isSafeInteger(file.bytes) || Number(file.bytes) < 0)) ||
|
|
108
|
+
(file.preview !== null && (typeof file.preview !== 'string' || file.preview.length > 1200)))
|
|
109
|
+
fail();
|
|
110
|
+
}
|
|
111
|
+
return d;
|
|
112
|
+
}
|
|
@@ -1,14 +1,44 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { canonical, digest, ServiceError } from './protocol.js';
|
|
3
|
-
import {
|
|
3
|
+
import { runGrantCovers, runGrantPrefix } from './run-grants.js';
|
|
4
|
+
import { workDecisionAnswer, } from './agentpod-work-decision-protocol.js';
|
|
4
5
|
/** Private authenticated Hub control only. Answers choose an existing approval;
|
|
5
6
|
* they never execute an operation or supply a second effect grant. */
|
|
6
7
|
export class AgentPodWorkDecisions {
|
|
7
8
|
work;
|
|
8
9
|
managed;
|
|
9
|
-
|
|
10
|
+
grants;
|
|
11
|
+
constructor(work, managed, grants) {
|
|
10
12
|
this.work = work;
|
|
11
13
|
this.managed = managed;
|
|
14
|
+
this.grants = grants;
|
|
15
|
+
}
|
|
16
|
+
/** The run directory a grant for this operation would cover, when it is a covered write. */
|
|
17
|
+
grantable(binding, op) {
|
|
18
|
+
if (!this.grants || op.kind !== 'workspace')
|
|
19
|
+
return null;
|
|
20
|
+
const run = binding?.run;
|
|
21
|
+
const prefix = runGrantPrefix(typeof run?.id === 'string' ? run.id : '');
|
|
22
|
+
let args = {};
|
|
23
|
+
try {
|
|
24
|
+
args = JSON.parse(op.value);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return prefix && runGrantCovers(prefix, args) ? prefix : null;
|
|
30
|
+
}
|
|
31
|
+
/** "Always allow" covers exactly what the standing workspace-write rule answers: a workspace
|
|
32
|
+
* write anywhere in the workspace, never a delete or a hidden path. */
|
|
33
|
+
alwaysAllowable(op) {
|
|
34
|
+
if (op.kind !== 'workspace')
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
return runGrantCovers('', JSON.parse(op.value));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
12
42
|
}
|
|
13
43
|
owner(binding) {
|
|
14
44
|
const root = this.work.authority.inspect(binding);
|
|
@@ -48,9 +78,22 @@ export class AgentPodWorkDecisions {
|
|
|
48
78
|
version: 1,
|
|
49
79
|
request_id: randomUUID(),
|
|
50
80
|
...identity,
|
|
51
|
-
question:
|
|
81
|
+
question: approvalDetail(op).summary + '?',
|
|
82
|
+
detail: approvalDetail(op),
|
|
52
83
|
options: [
|
|
53
84
|
{ id: 'approve', title: 'Approve once' },
|
|
85
|
+
...(this.grantable(root.binding, op)
|
|
86
|
+
? [{ id: 'approve-run', title: 'Approve for this run' }]
|
|
87
|
+
: []),
|
|
88
|
+
...(this.alwaysAllowable(op)
|
|
89
|
+
? [
|
|
90
|
+
{
|
|
91
|
+
id: 'always',
|
|
92
|
+
title: 'Always allow this agent to write in its workspace',
|
|
93
|
+
requires: 'workspace-admin',
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
: []),
|
|
54
97
|
{ id: 'deny', title: 'Deny' },
|
|
55
98
|
],
|
|
56
99
|
}));
|
|
@@ -81,7 +124,30 @@ export class AgentPodWorkDecisions {
|
|
|
81
124
|
approval.operation_id !== offered.operation_id ||
|
|
82
125
|
approval.expires_at !== offered.expires_at)
|
|
83
126
|
throw new ServiceError('approval_mismatch', 'Approval ownership changed');
|
|
84
|
-
this
|
|
127
|
+
// "Approve for this run" must have been offered, and still be grantable, to be chosen.
|
|
128
|
+
const prefix = answer.option_id === 'approve-run'
|
|
129
|
+
? this.grantable(root.binding, this.managed.operation(offered.operation_id))
|
|
130
|
+
: null;
|
|
131
|
+
if (answer.option_id === 'approve-run' &&
|
|
132
|
+
(!prefix || !offered.options.some((o) => o.id === 'approve-run')))
|
|
133
|
+
throw new ServiceError('approval_mismatch', 'This approval cannot cover the run');
|
|
134
|
+
// "Always allow" approves this write; the standing rule is the hub's, set before it forwards.
|
|
135
|
+
if (answer.option_id === 'always' &&
|
|
136
|
+
(!offered.options.some((o) => o.id === 'always') ||
|
|
137
|
+
!this.alwaysAllowable(this.managed.operation(offered.operation_id))))
|
|
138
|
+
throw new ServiceError('approval_mismatch', 'Always allow was not offered for this write');
|
|
139
|
+
this.managed.decide(offered.approval_id, answer.request_id, offered.approval_revision, offered.operation_digest, answer.option_id === 'deny' ? 'deny' : 'approve');
|
|
140
|
+
const grant = answer.option_id === 'approve-run'
|
|
141
|
+
? this.grants.createInTransaction({
|
|
142
|
+
session_id: root.session_id,
|
|
143
|
+
task_id: task.id,
|
|
144
|
+
plan_revision: offered.plan_revision,
|
|
145
|
+
binding_digest: offered.binding_digest,
|
|
146
|
+
path_prefix: prefix,
|
|
147
|
+
approval_id: offered.approval_id,
|
|
148
|
+
granted_by: answer.answered_by,
|
|
149
|
+
})
|
|
150
|
+
: null;
|
|
85
151
|
this.work.store.execution.assert(root.session_id);
|
|
86
152
|
return {
|
|
87
153
|
version: 1,
|
|
@@ -89,7 +155,45 @@ export class AgentPodWorkDecisions {
|
|
|
89
155
|
decision_digest: answer.decision_digest,
|
|
90
156
|
approval_id: offered.approval_id,
|
|
91
157
|
decision: answer.option_id,
|
|
158
|
+
...(grant ? { grant_id: grant.id } : {}),
|
|
92
159
|
};
|
|
93
160
|
});
|
|
94
161
|
}
|
|
95
162
|
}
|
|
163
|
+
/** Readable approvals: what a person approves, from the operation's own canonical arguments only.
|
|
164
|
+
* A workspace write names its file, the change, its size, a short preview and the agent's one-line
|
|
165
|
+
* reason; anything else names its kind and target. The digest still binds the exact operation. */
|
|
166
|
+
export function approvalDetail(op) {
|
|
167
|
+
const base = { version: 1, reason: null, files: [], digest: op.operation_digest };
|
|
168
|
+
if (op.kind === 'review')
|
|
169
|
+
return { ...base, summary: 'Accept this step for review' };
|
|
170
|
+
if (op.kind !== 'workspace')
|
|
171
|
+
return { ...base, summary: ('Run ' + op.kind + ' ' + op.target).slice(0, 200) };
|
|
172
|
+
let args = {};
|
|
173
|
+
try {
|
|
174
|
+
args = JSON.parse(op.value);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* not a patch: the generic form below */
|
|
178
|
+
}
|
|
179
|
+
const path = typeof args.path === 'string' ? args.path : op.target.replace(/^station:/, '');
|
|
180
|
+
const content = typeof args.content === 'string' ? args.content : null;
|
|
181
|
+
const change = args.content === null ? 'delete' : args.expected_hash === null ? 'create' : 'edit';
|
|
182
|
+
const verb = { create: 'Create', edit: 'Edit', delete: 'Delete' }[change];
|
|
183
|
+
const reason = typeof args.reason === 'string' && args.reason.trim()
|
|
184
|
+
? args.reason.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
185
|
+
: null;
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
summary: (verb + ' ' + path + ' in the workspace').slice(0, 200),
|
|
189
|
+
reason,
|
|
190
|
+
files: [
|
|
191
|
+
{
|
|
192
|
+
path: path.slice(0, 512),
|
|
193
|
+
change,
|
|
194
|
+
bytes: content === null ? null : Buffer.byteLength(content),
|
|
195
|
+
preview: content === null ? null : content.slice(0, 1200),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|