@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/task-policy.ts
CHANGED
|
@@ -17,11 +17,41 @@ export interface TaskLimits {
|
|
|
17
17
|
output_token_bound: number | null;
|
|
18
18
|
verification_runs: number;
|
|
19
19
|
}
|
|
20
|
+
/** Item 8: checks required at completion, next to the acceptance checks. The critic is not yet
|
|
21
|
+
* admitted, so it is always null. */
|
|
22
|
+
export interface CompletionGate {
|
|
23
|
+
/** The built-in reviewer judges the verified deliverables against this rubric. */
|
|
24
|
+
critic: { rubric: string } | null;
|
|
25
|
+
hooks: TaskCheck[];
|
|
26
|
+
}
|
|
20
27
|
export interface TaskPolicy {
|
|
21
28
|
acceptance: TaskCheck[];
|
|
22
29
|
deliverables: string[];
|
|
23
30
|
limits: TaskLimits;
|
|
31
|
+
/** Present only when the plan declares one, so a plan without it keeps its exact shape. */
|
|
32
|
+
completion_gate?: CompletionGate;
|
|
33
|
+
}
|
|
34
|
+
/** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
|
|
35
|
+
export function requiredChecks(
|
|
36
|
+
policy: Pick<TaskPolicy, 'acceptance' | 'completion_gate'>,
|
|
37
|
+
): TaskCheck[] {
|
|
38
|
+
return [
|
|
39
|
+
...policy.acceptance,
|
|
40
|
+
...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
|
|
41
|
+
];
|
|
24
42
|
}
|
|
43
|
+
const check = (v: unknown, keys: Set<string>, what: string): TaskCheck => {
|
|
44
|
+
const c = object(v),
|
|
45
|
+
key = string(c.key, what + ' key', 64);
|
|
46
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
47
|
+
throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
|
|
48
|
+
keys.add(key);
|
|
49
|
+
return {
|
|
50
|
+
key,
|
|
51
|
+
command: string(c.command, what + ' command', 4096),
|
|
52
|
+
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
25
55
|
export const DEFAULT_TASK_LIMITS: TaskLimits = {
|
|
26
56
|
requests: 64,
|
|
27
57
|
runtime_ms: 900000,
|
|
@@ -36,18 +66,7 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
|
|
|
36
66
|
if (!Array.isArray(raw) || raw.length > 8)
|
|
37
67
|
throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
|
|
38
68
|
const keys = new Set<string>();
|
|
39
|
-
const acceptance = raw.map((v) =>
|
|
40
|
-
const c = object(v),
|
|
41
|
-
key = string(c.key, 'check key', 64);
|
|
42
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
43
|
-
throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
|
|
44
|
-
keys.add(key);
|
|
45
|
-
return {
|
|
46
|
-
key,
|
|
47
|
-
command: string(c.command, 'check command', 4096),
|
|
48
|
-
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
49
|
-
};
|
|
50
|
-
});
|
|
69
|
+
const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
|
|
51
70
|
const paths = value.deliverables ?? [];
|
|
52
71
|
if (!Array.isArray(paths) || paths.length > 32)
|
|
53
72
|
throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
|
|
@@ -88,5 +107,38 @@ export function taskPolicy(value: Record<string, unknown>): TaskPolicy {
|
|
|
88
107
|
throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
|
|
89
108
|
limits.invocations = n;
|
|
90
109
|
}
|
|
91
|
-
|
|
110
|
+
let completion_gate: CompletionGate | undefined;
|
|
111
|
+
if (value.completion_gate !== undefined) {
|
|
112
|
+
const g = object(value.completion_gate);
|
|
113
|
+
const hooks = g.hooks;
|
|
114
|
+
const c = g.critic;
|
|
115
|
+
const critic =
|
|
116
|
+
c === null
|
|
117
|
+
? null
|
|
118
|
+
: (() => {
|
|
119
|
+
const v = object(c);
|
|
120
|
+
if (Object.keys(v).join(',') !== 'rubric')
|
|
121
|
+
throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
|
|
122
|
+
return { rubric: string(v.rubric, 'critic rubric', 4096) };
|
|
123
|
+
})();
|
|
124
|
+
if (
|
|
125
|
+
Object.keys(g).sort().join(',') !== 'critic,hooks' ||
|
|
126
|
+
!Array.isArray(hooks) ||
|
|
127
|
+
hooks.length > 8 ||
|
|
128
|
+
(!critic && hooks.length < 1)
|
|
129
|
+
)
|
|
130
|
+
throw new ServiceError(
|
|
131
|
+
'invalid_plan',
|
|
132
|
+
'A completion gate has a critic or 1–8 hooks, and at most 8 hooks',
|
|
133
|
+
400,
|
|
134
|
+
);
|
|
135
|
+
const hookKeys = new Set<string>();
|
|
136
|
+
completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
|
|
137
|
+
if (
|
|
138
|
+
requiredChecks({ acceptance, completion_gate }).length !==
|
|
139
|
+
new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size
|
|
140
|
+
)
|
|
141
|
+
throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
|
|
142
|
+
}
|
|
143
|
+
return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
|
|
92
144
|
}
|
package/src/task-verification.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TaskCritic, CriticVerdict } from './task-critic.js';
|
|
2
|
+
import { requiredChecks } from './task-policy.js';
|
|
1
3
|
import { StationTaskVerification, type StationCheckEvidence } from './station-task-verification.js';
|
|
2
4
|
import type { StationWork } from './station-work.js';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -63,7 +65,7 @@ export class TaskVerification {
|
|
|
63
65
|
}
|
|
64
66
|
assertIdle(task: WorkTask, stationInspection?: string): void {
|
|
65
67
|
if (
|
|
66
|
-
this.budget.active(task) > 0 ||
|
|
68
|
+
this.budget.active(task, { verification: true }) > 0 ||
|
|
67
69
|
this.store.db
|
|
68
70
|
.prepare("SELECT 1 FROM compactions WHERE session_id=? AND state='running'")
|
|
69
71
|
.get(task.session_id)
|
|
@@ -156,7 +158,7 @@ export class TaskVerification {
|
|
|
156
158
|
throw new ServiceError('task_settled', 'Revise a cancelled task before completing it');
|
|
157
159
|
this.assertIdle(task);
|
|
158
160
|
if (!this.station) this.coding.assertSafe(task.session_id);
|
|
159
|
-
if (!task.
|
|
161
|
+
if (!requiredChecks(task).some((c) => c.key === key))
|
|
160
162
|
throw new ServiceError('invalid_check', 'Check is not in this task plan');
|
|
161
163
|
if (
|
|
162
164
|
step &&
|
|
@@ -215,7 +217,7 @@ export class TaskVerification {
|
|
|
215
217
|
return;
|
|
216
218
|
}
|
|
217
219
|
const task = this.tasks.get(receipt.task_id),
|
|
218
|
-
check = task.
|
|
220
|
+
check = requiredChecks(task).find((c) => c.key === receipt.check_key);
|
|
219
221
|
try {
|
|
220
222
|
const existing = this.store.db
|
|
221
223
|
.prepare('SELECT id FROM command_runs WHERE session_id=? AND step_key=?')
|
|
@@ -485,6 +487,32 @@ export class TaskVerification {
|
|
|
485
487
|
);
|
|
486
488
|
return this.finishRecorded(id, request, expected, exception, authorize);
|
|
487
489
|
}
|
|
490
|
+
/** Item 8: the completion critic, when the service has one. */
|
|
491
|
+
critic?: TaskCritic;
|
|
492
|
+
/** The critic's verdict on the current fresh verified evidence, starting it the first time.
|
|
493
|
+
* Reads the evidence outside the finishing transaction, so starting the reviewer can never be
|
|
494
|
+
* rolled back with a refused finish; the finish itself re-checks the verdict against its own
|
|
495
|
+
* evidence. */
|
|
496
|
+
async criticGateAsync(task: WorkTask): Promise<CriticVerdict> {
|
|
497
|
+
if (!task.completion_gate?.critic) return { state: 'pass' };
|
|
498
|
+
if (!this.critic || !this.station)
|
|
499
|
+
return { state: 'failed', reason: 'A critic needs the station verification boundary' };
|
|
500
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
501
|
+
if (checks.some((c) => !c || c.state !== 'passed')) return { state: 'pending' };
|
|
502
|
+
const fresh = await this.station.withFreshMany(
|
|
503
|
+
checks.map((c) => c!.id),
|
|
504
|
+
task.deliverables,
|
|
505
|
+
(evidence, deliverables) => ({ evidence, deliverables }),
|
|
506
|
+
);
|
|
507
|
+
return this.critic.gate(
|
|
508
|
+
task,
|
|
509
|
+
digest(fresh.evidence),
|
|
510
|
+
(fresh.deliverables as { path: string; content: string }[]).map(({ path, content }) => ({
|
|
511
|
+
path,
|
|
512
|
+
content,
|
|
513
|
+
})),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
488
516
|
async finishAsync(
|
|
489
517
|
id: string,
|
|
490
518
|
request: string,
|
|
@@ -521,7 +549,7 @@ export class TaskVerification {
|
|
|
521
549
|
'verification_required',
|
|
522
550
|
'Configured checks and deliverables required',
|
|
523
551
|
);
|
|
524
|
-
const checks = task.
|
|
552
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
525
553
|
if (checks.some((c) => !c || c.state !== 'passed'))
|
|
526
554
|
throw new ServiceError('verification_required', 'Passed configured checks required');
|
|
527
555
|
return this.station.withFreshMany(
|
|
@@ -541,7 +569,7 @@ export class TaskVerification {
|
|
|
541
569
|
if (view.completion?.state === 'succeeded') {
|
|
542
570
|
try {
|
|
543
571
|
const evidence = JSON.parse(this.artifacts.read(String(view.completion.artifact_id)));
|
|
544
|
-
const checks = task.
|
|
572
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
545
573
|
if (checks.some((c) => !c || c.state !== 'passed' || !evidence.checks.includes(c.id)))
|
|
546
574
|
throw Error('Changed checks');
|
|
547
575
|
view.completion.fresh = await this.station.withFreshMany(
|
|
@@ -578,7 +606,7 @@ export class TaskVerification {
|
|
|
578
606
|
this.assertIdle(task);
|
|
579
607
|
if (task.steps.some((s) => s.state !== 'completed'))
|
|
580
608
|
throw new ServiceError('verification_required', 'Every step must be reviewed or verified');
|
|
581
|
-
const checks = task.
|
|
609
|
+
const checks = requiredChecks(task).map((c) => this.latest(task, c.key, null));
|
|
582
610
|
const missing: string[] = [];
|
|
583
611
|
if (!task.acceptance.length) missing.push('No configured acceptance checks');
|
|
584
612
|
if (!task.deliverables.length) missing.push('No configured deliverable paths');
|
|
@@ -589,7 +617,7 @@ export class TaskVerification {
|
|
|
589
617
|
? !protectedResult.evidence.some((e) => e.check_id === checks[n]!.id)
|
|
590
618
|
: !this.fresh(checks[n]!))
|
|
591
619
|
)
|
|
592
|
-
missing.push('Fresh check required: ' + task
|
|
620
|
+
missing.push('Fresh check required: ' + requiredChecks(task)[n]!.key);
|
|
593
621
|
let workspace: { id: string; revision: number; digest: string } | null = null,
|
|
594
622
|
deliverables: unknown[] = [],
|
|
595
623
|
diff: unknown = null;
|
|
@@ -597,7 +625,17 @@ export class TaskVerification {
|
|
|
597
625
|
if (protectedResult) {
|
|
598
626
|
const e = protectedResult.evidence[0]!;
|
|
599
627
|
workspace = { id: 'station:' + e.root_digest, revision: 0, digest: e.tree_digest };
|
|
600
|
-
|
|
628
|
+
// Keep each deliverable's verified bytes as a session artifact, committed with the
|
|
629
|
+
// completion, so publication reads the verified revision and never the live workspace.
|
|
630
|
+
deliverables = (
|
|
631
|
+
protectedResult.deliverables as { path: string; sha256: string; content: string }[]
|
|
632
|
+
).map(({ path, sha256, content }) => {
|
|
633
|
+
if (typeof content !== 'string')
|
|
634
|
+
throw new ServiceError('verification_required', 'Deliverable bytes unavailable');
|
|
635
|
+
const artifact = this.artifacts.prepare(task.session_id, content);
|
|
636
|
+
artifact.commit();
|
|
637
|
+
return { path, sha256, artifact_id: artifact.id };
|
|
638
|
+
});
|
|
601
639
|
diff = {
|
|
602
640
|
backend: 'linux-systemd-station-v1',
|
|
603
641
|
root_digest: e.root_digest,
|
|
@@ -617,6 +655,12 @@ export class TaskVerification {
|
|
|
617
655
|
} catch (error) {
|
|
618
656
|
missing.push('Workspace verification unavailable: ' + (error as Error).message);
|
|
619
657
|
}
|
|
658
|
+
// Item 8: a declared critic must have passed this exact verified evidence.
|
|
659
|
+
if (
|
|
660
|
+
task.completion_gate?.critic &&
|
|
661
|
+
!(protectedResult && this.critic?.passed(task, digest(protectedResult.evidence)))
|
|
662
|
+
)
|
|
663
|
+
missing.push('Critic review required');
|
|
620
664
|
if (missing.length && !exception)
|
|
621
665
|
throw new ServiceError('verification_required', missing.join('; '));
|
|
622
666
|
if (exception) {
|
|
@@ -702,7 +746,7 @@ export class TaskVerification {
|
|
|
702
746
|
if (completion.state === 'succeeded')
|
|
703
747
|
fresh =
|
|
704
748
|
fresh &&
|
|
705
|
-
task.
|
|
749
|
+
requiredChecks(task).every((check) => {
|
|
706
750
|
const latest = this.latest(task, check.key, null);
|
|
707
751
|
return !!latest && evidence.checks.includes(latest.id) && this.fresh(latest);
|
|
708
752
|
});
|
package/src/task-work.ts
CHANGED
|
@@ -98,6 +98,80 @@ export class TaskWork {
|
|
|
98
98
|
.prepare('UPDATE task_runtime SET remaining_steps=remaining_steps-1 WHERE task_id=?')
|
|
99
99
|
.run(task.id);
|
|
100
100
|
}
|
|
101
|
+
/** Item 8: re-run the last completed step with the critic's findings added to its instruction,
|
|
102
|
+
* under its own re-prepared progression grant and a request of its own. */
|
|
103
|
+
reopenForCritic(
|
|
104
|
+
taskId: string,
|
|
105
|
+
stepId: string,
|
|
106
|
+
round: number,
|
|
107
|
+
epoch: number,
|
|
108
|
+
findings: string,
|
|
109
|
+
): unknown {
|
|
110
|
+
const task = this.get(taskId);
|
|
111
|
+
const step = task.steps.find((s) => s.id === stepId);
|
|
112
|
+
const grant = this.store.db
|
|
113
|
+
.prepare(
|
|
114
|
+
"SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'",
|
|
115
|
+
)
|
|
116
|
+
.get(task.id, stepId, task.plan_revision, epoch) as { id: string } | undefined;
|
|
117
|
+
if (!grant || !step || step.state !== 'completed' || task.steps.at(-1)?.id !== stepId)
|
|
118
|
+
throw new ServiceError('task_authority', 'Only the completed last step can be reopened');
|
|
119
|
+
this.store.transaction(() => {
|
|
120
|
+
this.store.db.prepare("UPDATE task_steps SET state='pending' WHERE id=?").run(stepId);
|
|
121
|
+
this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
|
|
122
|
+
this.store.event(task.session_id, 'work_task.step_reopened', {
|
|
123
|
+
task_id: task.id,
|
|
124
|
+
step_id: stepId,
|
|
125
|
+
grant_id: grant.id,
|
|
126
|
+
round,
|
|
127
|
+
reason: 'Critic requested changes',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
return this.run(
|
|
131
|
+
task.id,
|
|
132
|
+
stepId,
|
|
133
|
+
'task-grant:' + grant.id + ':critic-reopen:' + round,
|
|
134
|
+
this.get(task.id).revision,
|
|
135
|
+
step.instruction +
|
|
136
|
+
'\n\nBefore this work is accepted, a reviewer asked for these changes:\n' +
|
|
137
|
+
findings,
|
|
138
|
+
null,
|
|
139
|
+
null,
|
|
140
|
+
grant.id,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
/** Re-run a step the provider rate-limited, as a fresh attempt under its own progression grant
|
|
144
|
+
* (re-prepared, with a request of its own, so it is never a replay of the refused attempt). */
|
|
145
|
+
retryStep(taskId: string, stepId: string, n: number, epoch: number): unknown {
|
|
146
|
+
const task = this.get(taskId);
|
|
147
|
+
const grant = this.store.db
|
|
148
|
+
.prepare(
|
|
149
|
+
"SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'",
|
|
150
|
+
)
|
|
151
|
+
.get(task.id, stepId, task.plan_revision, epoch) as { id: string } | undefined;
|
|
152
|
+
if (!grant || task.steps.find((s) => s.id === stepId)?.state !== 'failed')
|
|
153
|
+
throw new ServiceError('task_authority', 'No settled grant for this rate-limited step');
|
|
154
|
+
this.store.transaction(() => {
|
|
155
|
+
this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
|
|
156
|
+
this.store.event(task.session_id, 'work_task.step_retried', {
|
|
157
|
+
task_id: task.id,
|
|
158
|
+
step_id: stepId,
|
|
159
|
+
grant_id: grant.id,
|
|
160
|
+
retry: n,
|
|
161
|
+
reason: 'Provider rate limit',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
return this.run(
|
|
165
|
+
task.id,
|
|
166
|
+
stepId,
|
|
167
|
+
'task-grant:' + grant.id + ':rate-limit-retry:' + n,
|
|
168
|
+
task.revision,
|
|
169
|
+
null,
|
|
170
|
+
null,
|
|
171
|
+
null,
|
|
172
|
+
grant.id,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
101
175
|
advance(grantId: string): unknown {
|
|
102
176
|
const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId) as
|
|
103
177
|
{ task_id: string; step_id: string; expected_revision: number } | undefined;
|
|
@@ -693,6 +767,7 @@ export class TaskWork {
|
|
|
693
767
|
objective: task.objective,
|
|
694
768
|
constraints: task.constraints,
|
|
695
769
|
acceptance: task.acceptance,
|
|
770
|
+
...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
|
|
696
771
|
deliverables: task.deliverables,
|
|
697
772
|
limits: task.limits,
|
|
698
773
|
active_step_id: task.steps.find((s) => s.state === 'running')?.id ?? null,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { ServiceError } from './protocol.js';
|
|
7
|
+
import type { LocalEdit } from './local-edits.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The OS identity that agent-initiated local commands and file effects run as.
|
|
11
|
+
* `service-user` is the single-user default; `tools-user` switches through a
|
|
12
|
+
* runas-restricted `sudo -n -u <user>` rule so the daemon needs no capability.
|
|
13
|
+
*/
|
|
14
|
+
export interface ToolIdentity {
|
|
15
|
+
mode: 'service-user' | 'tools-user';
|
|
16
|
+
user: string;
|
|
17
|
+
uid: number;
|
|
18
|
+
/** argv prefix that runs a program as the tool identity; empty for service-user. */
|
|
19
|
+
prefix: string[];
|
|
20
|
+
warning: string | null;
|
|
21
|
+
}
|
|
22
|
+
export const sharedUserWarning = (user: string) =>
|
|
23
|
+
`Tool execution runs as ${user}, the same OS user that holds the provider credential. Set --tools-user to separate them.`;
|
|
24
|
+
|
|
25
|
+
export function serviceToolIdentity(): ToolIdentity {
|
|
26
|
+
const user = userInfo().username;
|
|
27
|
+
return {
|
|
28
|
+
mode: 'service-user',
|
|
29
|
+
user,
|
|
30
|
+
uid: process.getuid?.() ?? -1,
|
|
31
|
+
prefix: [],
|
|
32
|
+
warning: sharedUserWarning(user),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const run = (argv: string[], input?: string) =>
|
|
37
|
+
spawnSync(argv[0]!, argv.slice(1), {
|
|
38
|
+
env: { PATH: '/usr/local/bin:/usr/bin:/bin', LANG: 'C' },
|
|
39
|
+
input,
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
timeout: 10000,
|
|
42
|
+
killSignal: 'SIGKILL',
|
|
43
|
+
maxBuffer: 1 << 20,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Fail closed unless the tools user exists, is reachable without a password, and cannot read state. */
|
|
47
|
+
export function probeToolsUser(
|
|
48
|
+
user: string,
|
|
49
|
+
stateDir: string,
|
|
50
|
+
prefix = ['sudo', '-n', '-u', user, '--'],
|
|
51
|
+
): ToolIdentity {
|
|
52
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(user))
|
|
53
|
+
throw new Error('--tools-user must be a plain Unix user name');
|
|
54
|
+
if (process.platform !== 'linux' && prefix[0] === 'sudo')
|
|
55
|
+
throw new Error('--tools-user is supported on Linux hosts only');
|
|
56
|
+
const id = run([...prefix, '/usr/bin/id', '-u']);
|
|
57
|
+
const uid = Number(String(id.stdout).trim());
|
|
58
|
+
if (id.status !== 0 || !Number.isSafeInteger(uid))
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Cannot run as tools user ${user} (sudo -n -u ${user} failed); see docs/security.md host setup`,
|
|
61
|
+
);
|
|
62
|
+
if (uid === 0 || uid === process.getuid?.())
|
|
63
|
+
throw new Error('The tools user must be an unprivileged user distinct from the service user');
|
|
64
|
+
if (run([...prefix, '/usr/bin/test', '-r', stateDir]).status === 0)
|
|
65
|
+
throw new Error(`Tools user ${user} can read the Salaam state directory; restrict it to 0700`);
|
|
66
|
+
return { mode: 'tools-user', user, uid, prefix, warning: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function toolArgv(identity: ToolIdentity, argv: string[]): string[] {
|
|
70
|
+
return [...identity.prefix, ...argv];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Kill a tool process group owned by the tools user; the daemon cannot signal another uid. */
|
|
74
|
+
export function killToolGroup(identity: ToolIdentity, pgid: number): void {
|
|
75
|
+
if (identity.mode !== 'tools-user' || !Number.isSafeInteger(pgid) || pgid < 2) return;
|
|
76
|
+
run(toolArgv(identity, ['/bin/kill', '-KILL', '--', '-' + pgid]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const here = fileURLToPath(import.meta.url);
|
|
80
|
+
/** Source runs (tests) use the built helper; packages ship it beside this file. */
|
|
81
|
+
export const EDIT_HELPER = here.endsWith('.ts')
|
|
82
|
+
? join(dirname(here), '..', 'dist/src/local-edit-helper.js')
|
|
83
|
+
: join(dirname(here), 'local-edit-helper.js');
|
|
84
|
+
|
|
85
|
+
export interface ToolEdit extends LocalEdit {
|
|
86
|
+
remove?: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** Apply reviewed edits as the tools user. Reports each applied path; any failure throws. */
|
|
89
|
+
export function applyEditsAsTools(
|
|
90
|
+
identity: ToolIdentity,
|
|
91
|
+
root: string,
|
|
92
|
+
edits: ToolEdit[],
|
|
93
|
+
applied: (path: string) => void,
|
|
94
|
+
): void {
|
|
95
|
+
if (!existsSync(EDIT_HELPER)) throw new ServiceError('tool_identity', 'Edit helper is missing');
|
|
96
|
+
const result = run(
|
|
97
|
+
toolArgv(identity, [process.execPath, EDIT_HELPER, root]),
|
|
98
|
+
JSON.stringify(edits),
|
|
99
|
+
);
|
|
100
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
101
|
+
if (line.startsWith('applied ')) applied(JSON.parse(line.slice(8)) as string);
|
|
102
|
+
}
|
|
103
|
+
if (result.status !== 0)
|
|
104
|
+
throw new ServiceError(
|
|
105
|
+
'tool_identity',
|
|
106
|
+
`Edit as ${identity.user} failed: ${String(result.stderr ?? '')
|
|
107
|
+
.trim()
|
|
108
|
+
.slice(0, 400)}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -31,7 +31,7 @@ export class UsageLedger {
|
|
|
31
31
|
this.store.execution.assert(session);
|
|
32
32
|
this.store.taskModelAdmission?.(session, attempt, bounds?.input ?? 0, bounds?.output ?? 0);
|
|
33
33
|
if (attempt) {
|
|
34
|
-
this.budget(attempt);
|
|
34
|
+
this.budget(attempt, this.store.taskAttemptRequestLimit?.(session) ?? 16);
|
|
35
35
|
const changed = this.store.db
|
|
36
36
|
.prepare(
|
|
37
37
|
'UPDATE attempt_budgets SET spent_requests=spent_requests+1 WHERE attempt_id=? AND spent_requests+reserved_requests<limit_requests',
|