@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
package/src/agent-profiles.ts
CHANGED
|
@@ -208,6 +208,54 @@ export class AgentProfiles {
|
|
|
208
208
|
if (!profile) throw new ServiceError('not_found', 'Agent profile not found', 404);
|
|
209
209
|
return profile;
|
|
210
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* The task critic's grant (item 8): always the built-in salaam/reviewer, never another profile,
|
|
213
|
+
* with no facts. It is granted under the work admission, not by an operator controller, and
|
|
214
|
+
* only while the caller's authorize holds (the admitted plan declares a critic). The reviewer
|
|
215
|
+
* reads only the immutable snapshot it is given and can only report findings.
|
|
216
|
+
*/
|
|
217
|
+
async admitCritic(session: string, request: string, authorize: () => void): Promise<AgentGrant> {
|
|
218
|
+
const profileId = 'salaam/reviewer';
|
|
219
|
+
const profile = this.inspect(session, profileId);
|
|
220
|
+
const expectedDigest = digest(profile);
|
|
221
|
+
const input = { kind: 'critic-admit', session, profileId, expectedDigest };
|
|
222
|
+
const saved = this.store.cachedRequest<AgentGrant>('task-critic', request, input);
|
|
223
|
+
if (saved) return saved;
|
|
224
|
+
if (!BUILTIN_PROFILES.some((p) => p.id === profileId && digest(p) === expectedDigest))
|
|
225
|
+
throw new ServiceError('profile_changed', 'The critic must be the built-in reviewer');
|
|
226
|
+
const prepared = await this.prepareRoute(this.route, session);
|
|
227
|
+
return this.store.request('task-critic', request, input, () => {
|
|
228
|
+
authorize();
|
|
229
|
+
const grant: AgentGrant = {
|
|
230
|
+
schema_version: 1,
|
|
231
|
+
id: randomUUID(),
|
|
232
|
+
owner_session: session,
|
|
233
|
+
profile,
|
|
234
|
+
profile_digest: expectedDigest,
|
|
235
|
+
route: prepared.route,
|
|
236
|
+
output_cap: prepared.output_cap,
|
|
237
|
+
facts: [],
|
|
238
|
+
};
|
|
239
|
+
this.store.db
|
|
240
|
+
.prepare(
|
|
241
|
+
'INSERT INTO agent_grants(id,owner_session,profile_id,document,digest,state) VALUES (?,?,?,?,?,?)',
|
|
242
|
+
)
|
|
243
|
+
.run(grant.id, session, profileId, canonical(grant), digest(grant), 'active');
|
|
244
|
+
this.store.db
|
|
245
|
+
.prepare(
|
|
246
|
+
'INSERT OR IGNORE INTO worker_profiles(id,revision,digest,document) VALUES (?,?,?,?)',
|
|
247
|
+
)
|
|
248
|
+
.run(profile.id, profile.revision, expectedDigest, canonical(profile));
|
|
249
|
+
this.store.event(session, 'agent.admitted', {
|
|
250
|
+
grant_id: grant.id,
|
|
251
|
+
profile_id: profileId,
|
|
252
|
+
profile_digest: expectedDigest,
|
|
253
|
+
route: prepared.route,
|
|
254
|
+
critic: true,
|
|
255
|
+
});
|
|
256
|
+
return grant;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
211
259
|
async admit(
|
|
212
260
|
session: string,
|
|
213
261
|
request: string,
|
|
@@ -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
|
+
}
|