@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
@@ -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
- return { acceptance, deliverables, limits };
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
  }
@@ -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.acceptance.some((c) => c.key === key))
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.acceptance.find((c) => c.key === receipt.check_key);
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.acceptance.map((c) => this.latest(task, c.key, null));
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.acceptance.map((c) => this.latest(task, c.key, null));
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.acceptance.map((c) => this.latest(task, c.key, null));
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.acceptance[n]!.key);
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
- deliverables = protectedResult.deliverables;
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.acceptance.every((check) => {
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',