@superjackfruitlabs/salaam 0.0.1 → 0.0.2
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 +15 -0
- package/README.md +25 -17
- package/dist/src/a2a-backend.js +225 -0
- package/dist/src/a2a-inbound.js +487 -0
- package/dist/src/a2a-peer.js +370 -0
- package/dist/src/a2a-protocol.js +250 -0
- package/dist/src/agent-profiles.js +289 -0
- package/dist/src/children.js +132 -26
- package/dist/src/claude-provider.js +2 -179
- package/dist/src/cli-help.js +107 -6
- package/dist/src/cli.js +477 -13
- package/dist/src/config.js +26 -3
- package/dist/src/credentials.js +7 -2
- package/dist/src/external-runtime.js +1 -0
- package/dist/src/external-work.js +583 -0
- package/dist/src/harness-backend.js +466 -0
- package/dist/src/harness-container.js +450 -0
- package/dist/src/harness-model-gateway.js +104 -0
- package/dist/src/harness-prepare.js +88 -0
- package/dist/src/harness-probe.js +111 -0
- package/dist/src/harness-protocol.js +325 -0
- package/dist/src/harness-readiness.js +50 -0
- package/dist/src/harness-rpc.js +206 -0
- package/dist/src/harness-tool-identity.js +87 -0
- package/dist/src/harness-wire-bridge.js +387 -0
- package/dist/src/host.js +6 -1
- package/dist/src/house-extension.js +147 -64
- package/dist/src/live-provider.js +301 -0
- package/dist/src/managed.js +33 -3
- package/dist/src/protocol.js +9 -5
- package/dist/src/provider-auth.js +300 -0
- package/dist/src/provider-config.js +65 -17
- package/dist/src/provider-model.js +78 -0
- package/dist/src/provider-payload.js +264 -0
- package/dist/src/provider-transport.js +36 -0
- package/dist/src/provider-usage.js +59 -0
- package/dist/src/repository-snapshots.js +219 -0
- package/dist/src/service.js +274 -9
- package/dist/src/store.js +44 -2
- package/dist/src/supervisor.js +246 -35
- package/dist/src/task-budget.js +17 -8
- package/dist/src/task-context.js +15 -0
- package/dist/src/task-driver.js +38 -14
- package/dist/src/task-policy.js +20 -1
- package/dist/src/task-verification.js +3 -0
- package/dist/src/task-work.js +31 -0
- package/dist/src/team-work.js +378 -0
- package/dist/src/tui-view.js +65 -10
- package/dist/src/tui.js +4 -2
- package/dist/src/usage.js +18 -0
- package/dist/src/worker-boundary.js +298 -0
- package/dist/src/worker-workspaces.js +274 -0
- package/dist/src/workspaces.js +6 -2
- package/harness-runtime/codex-pins.json +10 -0
- package/harness-runtime/package-lock.json +560 -0
- package/harness-runtime/package.json +9 -0
- package/npm-shrinkwrap.json +57 -2
- package/package.json +2 -1
- package/src/a2a-backend.ts +254 -0
- package/src/a2a-inbound.ts +585 -0
- package/src/a2a-peer.ts +441 -0
- package/src/a2a-protocol.ts +286 -0
- package/src/agent-profiles.ts +379 -0
- package/src/children.ts +168 -27
- package/src/claude-provider.ts +2 -189
- package/src/cli-help.ts +107 -6
- package/src/cli.ts +602 -12
- package/src/config.ts +34 -3
- package/src/credentials.ts +9 -2
- package/src/external-runtime.ts +54 -0
- package/src/external-work.ts +663 -0
- package/src/harness-backend.ts +557 -0
- package/src/harness-container.ts +543 -0
- package/src/harness-model-gateway.ts +138 -0
- package/src/harness-prepare.ts +113 -0
- package/src/harness-probe.ts +150 -0
- package/src/harness-protocol.ts +388 -0
- package/src/harness-readiness.ts +71 -0
- package/src/harness-rpc.ts +244 -0
- package/src/harness-tool-identity.ts +91 -0
- package/src/harness-wire-bridge.ts +418 -0
- package/src/host.ts +11 -6
- package/src/house-extension.ts +165 -66
- package/src/live-provider.ts +347 -0
- package/src/managed.ts +43 -4
- package/src/protocol.ts +36 -8
- package/src/provider-auth.ts +336 -0
- package/src/provider-config.ts +89 -18
- package/src/provider-model.ts +86 -0
- package/src/provider-payload.ts +263 -0
- package/src/provider-transport.ts +51 -0
- package/src/provider-usage.ts +64 -0
- package/src/repository-snapshots.ts +259 -0
- package/src/service.ts +446 -11
- package/src/store.ts +60 -4
- package/src/supervisor.ts +294 -41
- package/src/task-budget.ts +24 -11
- package/src/task-context.ts +17 -0
- package/src/task-driver.ts +51 -15
- package/src/task-policy.ts +23 -3
- package/src/task-verification.ts +2 -0
- package/src/task-work.ts +46 -1
- package/src/team-work.ts +554 -0
- package/src/tui-client.ts +5 -0
- package/src/tui-view.ts +164 -12
- package/src/tui.ts +5 -2
- package/src/usage.ts +17 -0
- package/src/worker-boundary.ts +395 -0
- package/src/worker-workspaces.ts +390 -0
- package/src/workspaces.ts +4 -2
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { realpathSync, lstatSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { engineJson, type EngineRequest } from './execution-boundary.js';
|
|
6
|
+
import { digest, ServiceError } from './protocol.js';
|
|
7
|
+
import type { HarnessLaunch } from './harness-rpc.js';
|
|
8
|
+
|
|
9
|
+
const OWNER = 'salaam.harness.run',
|
|
10
|
+
PROFILE = 'salaam.harness.profile';
|
|
11
|
+
// Gemini's pinned Linux shell tool invokes Bash. Prepare this image outside model turns.
|
|
12
|
+
export const HARNESS_IMAGE =
|
|
13
|
+
'docker.io/library/node@sha256:4db36457f406501e6f608802e5da617e5fbd0e80b75901b6a09de1ae5a667d32';
|
|
14
|
+
export const HARNESS_CONTAINER_PROFILE = {
|
|
15
|
+
version: 2,
|
|
16
|
+
image: HARNESS_IMAGE,
|
|
17
|
+
user: 'daemon-owner-nonroot',
|
|
18
|
+
network: 'gateway-loopback-only',
|
|
19
|
+
read_only: true,
|
|
20
|
+
capabilities: [],
|
|
21
|
+
no_new_privileges: true,
|
|
22
|
+
pids: 96,
|
|
23
|
+
memory: 768 * 1024 * 1024,
|
|
24
|
+
tmp_bytes: 64 * 1024 * 1024,
|
|
25
|
+
deadline_ms: 300000,
|
|
26
|
+
request_bytes: 2 * 1024 * 1024,
|
|
27
|
+
response_bytes: 8 * 1024 * 1024,
|
|
28
|
+
} as const;
|
|
29
|
+
/** Fixed relay: neither this container nor the harness has an external network. Real keys stay in the host callback. */
|
|
30
|
+
const GATEWAY = String.raw`const http=require('node:http'),rl=require('node:readline');let serial=0;const pending=new Map();const server=http.createServer(async(req,res)=>{if(pending.size>=8){res.writeHead(429);res.end();return}let size=0,chunks=[];try{for await(const chunk of req){size+=chunk.length;if(size>2097152)throw Error('body limit');chunks.push(chunk)}const id=++serial;pending.set(id,res);res.on('close',()=>pending.delete(id));const frame=JSON.stringify({id,method:req.method,path:req.url,headers:{'content-type':req.headers['content-type'],'x-api-key':req.headers['x-api-key'],authorization:req.headers.authorization,'anthropic-beta':req.headers['anthropic-beta']},body:Buffer.concat(chunks).toString('base64')})+'\n';if(process.stdout.writableLength+frame.length>4194304)throw Error('relay backpressure');process.stdout.write(frame);}catch{res.destroy()}});rl.createInterface({input:process.stdin}).on('line',line=>{try{if(line.length>4194304)throw Error('frame limit');const m=JSON.parse(line),res=pending.get(m.id);if(!res)return;if(m.status!==undefined)res.writeHead(m.status,{'content-type':m.content_type??'application/json'});if(m.chunk)res.write(Buffer.from(m.chunk,'base64'));if(m.end){res.end();pending.delete(m.id)}}catch{process.exit(2)}}).on('close',()=>{for(const res of pending.values())res.destroy();server.close();process.exit(0)});server.listen(47123,'127.0.0.1',()=>process.stdout.write(JSON.stringify({ready:true})+'\n'));setTimeout(()=>process.exit(3),300000);`;
|
|
31
|
+
// PID 1 owns the wall clock and exits with the harness, killing detached descendants.
|
|
32
|
+
const BOOT = `const {spawn}=require('node:child_process');const child=spawn(process.argv[1],process.argv.slice(2),{stdio:'inherit',env:process.env});setTimeout(()=>process.exit(124),300000);child.on('error',()=>process.exit(126));child.on('exit',code=>process.exit(code??125));`;
|
|
33
|
+
export interface GatewayRequest {
|
|
34
|
+
method: string;
|
|
35
|
+
path: string;
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
body: Buffer;
|
|
38
|
+
}
|
|
39
|
+
export interface GatewayResponse {
|
|
40
|
+
status: number;
|
|
41
|
+
content_type?: string;
|
|
42
|
+
body: AsyncIterable<Uint8Array> | Buffer;
|
|
43
|
+
}
|
|
44
|
+
export interface HarnessContainers {
|
|
45
|
+
run_id: string;
|
|
46
|
+
gateway: string;
|
|
47
|
+
harness: string;
|
|
48
|
+
profile_digest: string;
|
|
49
|
+
engine_id: string;
|
|
50
|
+
image_id: string;
|
|
51
|
+
gateway_config: string;
|
|
52
|
+
harness_config: string;
|
|
53
|
+
}
|
|
54
|
+
export class DockerHarnessBoundary {
|
|
55
|
+
readonly user: string;
|
|
56
|
+
readonly profileDigest: string;
|
|
57
|
+
private imageEnv: string[] = [];
|
|
58
|
+
private active = new Map<string, ChildProcessWithoutNullStreams>();
|
|
59
|
+
constructor(
|
|
60
|
+
readonly engine: EngineRequest,
|
|
61
|
+
readonly engineId: string,
|
|
62
|
+
readonly docker: { executable: string; digest: string; socket: string },
|
|
63
|
+
) {
|
|
64
|
+
const uid = process.getuid?.(),
|
|
65
|
+
gid = process.getgid?.();
|
|
66
|
+
if (!uid || gid === undefined || gid === 0)
|
|
67
|
+
throw new ServiceError('harness_user_unavailable', 'Require a non-root daemon owner');
|
|
68
|
+
this.user = uid + ':' + gid;
|
|
69
|
+
this.profileDigest = digest({
|
|
70
|
+
profile: HARNESS_CONTAINER_PROFILE,
|
|
71
|
+
user: this.user,
|
|
72
|
+
gateway: GATEWAY,
|
|
73
|
+
boot: BOOT,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
private configuration(c: any): string {
|
|
77
|
+
const config = c.Config ?? c,
|
|
78
|
+
host = c.HostConfig;
|
|
79
|
+
const fields = [
|
|
80
|
+
'NetworkMode',
|
|
81
|
+
'ReadonlyRootfs',
|
|
82
|
+
'CapDrop',
|
|
83
|
+
'CapAdd',
|
|
84
|
+
'SecurityOpt',
|
|
85
|
+
'Privileged',
|
|
86
|
+
'PidMode',
|
|
87
|
+
'IpcMode',
|
|
88
|
+
'CgroupnsMode',
|
|
89
|
+
'PidsLimit',
|
|
90
|
+
'Memory',
|
|
91
|
+
'MemorySwap',
|
|
92
|
+
'NanoCpus',
|
|
93
|
+
'ShmSize',
|
|
94
|
+
'Tmpfs',
|
|
95
|
+
'Ulimits',
|
|
96
|
+
'RestartPolicy',
|
|
97
|
+
];
|
|
98
|
+
const effective = Object.fromEntries(fields.map((k) => [k, host[k] ?? null]));
|
|
99
|
+
effective.CapAdd = host.CapAdd ?? [];
|
|
100
|
+
effective.SecurityOpt = (host.SecurityOpt ?? [])
|
|
101
|
+
.map((v: string) => v.replace(':true', ''))
|
|
102
|
+
.sort();
|
|
103
|
+
effective.RestartPolicy = { Name: host.RestartPolicy?.Name };
|
|
104
|
+
const mounts = c.Mounts
|
|
105
|
+
? c.Mounts.map((m: any) => ({
|
|
106
|
+
source: m.Source,
|
|
107
|
+
target: m.Destination,
|
|
108
|
+
read_only: !m.RW,
|
|
109
|
+
type: m.Type,
|
|
110
|
+
propagation: m.Propagation,
|
|
111
|
+
}))
|
|
112
|
+
: host.Mounts.map((m: any) => ({
|
|
113
|
+
source: m.Source,
|
|
114
|
+
target: m.Target,
|
|
115
|
+
read_only: m.ReadOnly,
|
|
116
|
+
type: m.Type,
|
|
117
|
+
propagation: m.BindOptions.Propagation,
|
|
118
|
+
}));
|
|
119
|
+
return digest({
|
|
120
|
+
image: config.Image,
|
|
121
|
+
user: config.User,
|
|
122
|
+
env: [...config.Env].sort(),
|
|
123
|
+
entrypoint: config.Entrypoint ?? [],
|
|
124
|
+
args: config.Cmd,
|
|
125
|
+
cwd: config.WorkingDir,
|
|
126
|
+
tty: config.Tty,
|
|
127
|
+
stdin: config.OpenStdin,
|
|
128
|
+
stdin_once: config.StdinOnce,
|
|
129
|
+
labels: config.Labels,
|
|
130
|
+
host: effective,
|
|
131
|
+
mounts: mounts.sort((a: any, b: any) => a.target.localeCompare(b.target)),
|
|
132
|
+
logs: host.LogConfig?.Type,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
private mount(source: string, target: string, readOnly: boolean) {
|
|
136
|
+
if (
|
|
137
|
+
!isAbsolute(source) ||
|
|
138
|
+
/[\0\r\n,]/.test(source) ||
|
|
139
|
+
realpathSync(source) !== source ||
|
|
140
|
+
!lstatSync(source).isDirectory()
|
|
141
|
+
)
|
|
142
|
+
throw new ServiceError('harness_mount_denied', 'Require a canonical prepared directory');
|
|
143
|
+
return {
|
|
144
|
+
Type: 'bind',
|
|
145
|
+
Source: source,
|
|
146
|
+
Target: target,
|
|
147
|
+
ReadOnly: readOnly,
|
|
148
|
+
BindOptions: { Propagation: 'rprivate' },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
private config(
|
|
152
|
+
run: string,
|
|
153
|
+
gateway: boolean,
|
|
154
|
+
mounts: unknown[],
|
|
155
|
+
args: string[],
|
|
156
|
+
env: string[],
|
|
157
|
+
network: string,
|
|
158
|
+
): any {
|
|
159
|
+
return {
|
|
160
|
+
Image: HARNESS_IMAGE,
|
|
161
|
+
User: this.user,
|
|
162
|
+
Env: [
|
|
163
|
+
...new Map(
|
|
164
|
+
[
|
|
165
|
+
...this.imageEnv,
|
|
166
|
+
'HOME=/home/agent',
|
|
167
|
+
'TMPDIR=/tmp',
|
|
168
|
+
'PATH=/usr/local/bin:/usr/bin:/bin',
|
|
169
|
+
...env,
|
|
170
|
+
].map((v) => [v.slice(0, v.indexOf('=')), v]),
|
|
171
|
+
).values(),
|
|
172
|
+
],
|
|
173
|
+
Entrypoint: ['/usr/local/bin/node'],
|
|
174
|
+
Cmd: gateway ? args : ['-e', BOOT, '--', ...args],
|
|
175
|
+
WorkingDir: gateway ? '/tmp' : '/workspace',
|
|
176
|
+
Hostname: gateway ? 'salaam-harness' : '',
|
|
177
|
+
Tty: false,
|
|
178
|
+
OpenStdin: true,
|
|
179
|
+
StdinOnce: true,
|
|
180
|
+
AttachStdin: true,
|
|
181
|
+
AttachStdout: true,
|
|
182
|
+
AttachStderr: true,
|
|
183
|
+
Labels: {
|
|
184
|
+
[OWNER]: run,
|
|
185
|
+
[PROFILE]: this.profileDigest,
|
|
186
|
+
'salaam.harness.kind': gateway ? 'gateway' : 'worker',
|
|
187
|
+
},
|
|
188
|
+
HostConfig: {
|
|
189
|
+
NetworkMode: network,
|
|
190
|
+
ReadonlyRootfs: true,
|
|
191
|
+
CapDrop: ['ALL'],
|
|
192
|
+
SecurityOpt: ['no-new-privileges:true'],
|
|
193
|
+
Privileged: false,
|
|
194
|
+
PidMode: '',
|
|
195
|
+
IpcMode: 'private',
|
|
196
|
+
CgroupnsMode: 'private',
|
|
197
|
+
PidsLimit: HARNESS_CONTAINER_PROFILE.pids,
|
|
198
|
+
Memory: gateway ? 128 * 1024 * 1024 : HARNESS_CONTAINER_PROFILE.memory,
|
|
199
|
+
MemorySwap: gateway ? 128 * 1024 * 1024 : HARNESS_CONTAINER_PROFILE.memory,
|
|
200
|
+
NanoCpus: 1000000000,
|
|
201
|
+
ShmSize: 8 * 1024 * 1024,
|
|
202
|
+
Mounts: mounts,
|
|
203
|
+
Tmpfs: {
|
|
204
|
+
'/tmp': `rw,nosuid,nodev,size=64m,uid=${this.user.split(':')[0]},gid=${this.user.split(':')[1]},mode=700`,
|
|
205
|
+
},
|
|
206
|
+
Ulimits: [
|
|
207
|
+
{ Name: 'nofile', Soft: 256, Hard: 256 },
|
|
208
|
+
{ Name: 'fsize', Soft: 16 * 1024 * 1024, Hard: 16 * 1024 * 1024 },
|
|
209
|
+
],
|
|
210
|
+
RestartPolicy: { Name: 'no' },
|
|
211
|
+
LogConfig: { Type: 'none' },
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async prepare(
|
|
216
|
+
run: string,
|
|
217
|
+
workspace: string,
|
|
218
|
+
runtime: string,
|
|
219
|
+
home: string,
|
|
220
|
+
args: string[],
|
|
221
|
+
env: Record<string, string> = {},
|
|
222
|
+
): Promise<HarnessContainers> {
|
|
223
|
+
if (
|
|
224
|
+
!/^[0-9a-f-]{36}$/.test(run) ||
|
|
225
|
+
!args.length ||
|
|
226
|
+
args.some((a) => typeof a !== 'string' || a.length > 8192) ||
|
|
227
|
+
Object.keys(env).some(
|
|
228
|
+
(k) =>
|
|
229
|
+
![
|
|
230
|
+
'ANTHROPIC_BASE_URL',
|
|
231
|
+
'ANTHROPIC_API_KEY',
|
|
232
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
233
|
+
'ANTHROPIC_MODEL',
|
|
234
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
235
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
236
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
237
|
+
'GEMINI_API_KEY',
|
|
238
|
+
'GOOGLE_GEMINI_BASE_URL',
|
|
239
|
+
'GEMINI_CLI_NO_RELAUNCH',
|
|
240
|
+
'CODEX_HOME',
|
|
241
|
+
'DISABLE_TELEMETRY',
|
|
242
|
+
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
243
|
+
'CLAUDE_CONFIG_DIR',
|
|
244
|
+
].includes(k),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
throw new ServiceError('harness_launch_denied', 'Unsupported prepared harness launch');
|
|
248
|
+
const info = engineJson(await this.engine('GET', '/info'));
|
|
249
|
+
if (
|
|
250
|
+
info.ID !== this.engineId ||
|
|
251
|
+
info.CgroupVersion !== '2' ||
|
|
252
|
+
!info.SecurityOptions?.includes('name=seccomp,profile=builtin')
|
|
253
|
+
)
|
|
254
|
+
throw new ServiceError('harness_engine_changed', 'Proven engine changed');
|
|
255
|
+
const image = engineJson(
|
|
256
|
+
await this.engine('GET', `/images/${encodeURIComponent(HARNESS_IMAGE)}/json`),
|
|
257
|
+
);
|
|
258
|
+
if (
|
|
259
|
+
!image.RepoDigests?.some((r: string) =>
|
|
260
|
+
[
|
|
261
|
+
HARNESS_IMAGE,
|
|
262
|
+
HARNESS_IMAGE.replace('docker.io/', ''),
|
|
263
|
+
HARNESS_IMAGE.replace('docker.io/library/', ''),
|
|
264
|
+
].includes(r),
|
|
265
|
+
) ||
|
|
266
|
+
!/^sha256:[a-f0-9]{64}$/.test(image.Id)
|
|
267
|
+
)
|
|
268
|
+
throw new ServiceError('harness_image_changed', 'Pinned harness image is unavailable');
|
|
269
|
+
this.imageEnv = image.Config.Env;
|
|
270
|
+
const gatewayConfig = this.config(run, true, [], ['-e', GATEWAY], [], 'none');
|
|
271
|
+
const gateway = String(
|
|
272
|
+
engineJson(
|
|
273
|
+
await this.engine(
|
|
274
|
+
'POST',
|
|
275
|
+
'/containers/create?name=salaam-harness-' + run + '-gateway',
|
|
276
|
+
gatewayConfig,
|
|
277
|
+
),
|
|
278
|
+
[201],
|
|
279
|
+
).Id,
|
|
280
|
+
);
|
|
281
|
+
let harness: string | undefined;
|
|
282
|
+
try {
|
|
283
|
+
const config = this.config(
|
|
284
|
+
run,
|
|
285
|
+
false,
|
|
286
|
+
[
|
|
287
|
+
this.mount(workspace, '/workspace', false),
|
|
288
|
+
this.mount(runtime, '/runtime', true),
|
|
289
|
+
this.mount(home, '/home/agent', false),
|
|
290
|
+
],
|
|
291
|
+
args,
|
|
292
|
+
Object.entries(env).map(([k, v]) => k + '=' + v),
|
|
293
|
+
'container:' + gateway,
|
|
294
|
+
);
|
|
295
|
+
harness = String(
|
|
296
|
+
engineJson(
|
|
297
|
+
await this.engine(
|
|
298
|
+
'POST',
|
|
299
|
+
'/containers/create?name=salaam-harness-' + run + '-worker',
|
|
300
|
+
config,
|
|
301
|
+
),
|
|
302
|
+
[201],
|
|
303
|
+
).Id,
|
|
304
|
+
);
|
|
305
|
+
const receipt = {
|
|
306
|
+
run_id: run,
|
|
307
|
+
gateway,
|
|
308
|
+
harness,
|
|
309
|
+
profile_digest: this.profileDigest,
|
|
310
|
+
engine_id: this.engineId,
|
|
311
|
+
image_id: image.Id as string,
|
|
312
|
+
gateway_config: this.configuration(gatewayConfig),
|
|
313
|
+
harness_config: this.configuration(config),
|
|
314
|
+
};
|
|
315
|
+
await this.assert(receipt);
|
|
316
|
+
return receipt;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (harness) await this.engine('DELETE', `/containers/${harness}?force=true`);
|
|
319
|
+
await this.engine('DELETE', `/containers/${gateway}?force=true`);
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
private async owned(id: string, run: string, kind: string): Promise<any> {
|
|
324
|
+
if (!/^[a-f0-9]{64}$/.test(id))
|
|
325
|
+
throw new ServiceError('harness_container_changed', 'Invalid container identity');
|
|
326
|
+
const c = engineJson(await this.engine('GET', `/containers/${id}/json`));
|
|
327
|
+
const h = c.HostConfig;
|
|
328
|
+
if (
|
|
329
|
+
c.Config.Labels?.[OWNER] !== run ||
|
|
330
|
+
c.Config.Labels?.[PROFILE] !== this.profileDigest ||
|
|
331
|
+
c.Config.Labels?.['salaam.harness.kind'] !== kind ||
|
|
332
|
+
c.Config.User !== this.user ||
|
|
333
|
+
h.Privileged ||
|
|
334
|
+
!h.ReadonlyRootfs ||
|
|
335
|
+
digest(h.CapDrop) !== digest(['ALL']) ||
|
|
336
|
+
(!h.SecurityOpt?.includes('no-new-privileges') &&
|
|
337
|
+
!h.SecurityOpt?.includes('no-new-privileges:true')) ||
|
|
338
|
+
h.PidsLimit !== HARNESS_CONTAINER_PROFILE.pids ||
|
|
339
|
+
h.RestartPolicy?.Name !== 'no'
|
|
340
|
+
)
|
|
341
|
+
throw new ServiceError('harness_container_changed', 'Owned harness configuration changed');
|
|
342
|
+
return c;
|
|
343
|
+
}
|
|
344
|
+
async assert(receipt: HarnessContainers): Promise<void> {
|
|
345
|
+
const info = engineJson(await this.engine('GET', '/info'));
|
|
346
|
+
if (
|
|
347
|
+
info.ID !== this.engineId ||
|
|
348
|
+
info.CgroupVersion !== '2' ||
|
|
349
|
+
!info.SecurityOptions?.includes('name=seccomp,profile=builtin')
|
|
350
|
+
)
|
|
351
|
+
throw new ServiceError('harness_engine_changed', 'Proven engine changed before dispatch');
|
|
352
|
+
if (receipt.engine_id !== this.engineId || receipt.profile_digest !== this.profileDigest)
|
|
353
|
+
throw new ServiceError('harness_container_changed', 'Boundary receipt changed');
|
|
354
|
+
const [g, h] = await Promise.all([
|
|
355
|
+
this.owned(receipt.gateway, receipt.run_id, 'gateway'),
|
|
356
|
+
this.owned(receipt.harness, receipt.run_id, 'worker'),
|
|
357
|
+
]);
|
|
358
|
+
if (
|
|
359
|
+
g.Image !== receipt.image_id ||
|
|
360
|
+
h.Image !== receipt.image_id ||
|
|
361
|
+
this.configuration(g) !== receipt.gateway_config ||
|
|
362
|
+
this.configuration(h) !== receipt.harness_config ||
|
|
363
|
+
g.HostConfig.NetworkMode !== 'none' ||
|
|
364
|
+
h.HostConfig.NetworkMode !== 'container:' + receipt.gateway ||
|
|
365
|
+
g.Mounts.length !== 0 ||
|
|
366
|
+
h.Mounts.length !== 3 ||
|
|
367
|
+
h.Mounts.some(
|
|
368
|
+
(m: any) =>
|
|
369
|
+
!['/workspace', '/runtime', '/home/agent'].includes(m.Destination) ||
|
|
370
|
+
m.Type !== 'bind' ||
|
|
371
|
+
(m.Destination === '/runtime' && m.RW),
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
throw new ServiceError('harness_container_changed', 'Harness network or mounts changed');
|
|
375
|
+
}
|
|
376
|
+
private launch(id: string, dialect: 'acp' | 'codex'): HarnessLaunch {
|
|
377
|
+
if (
|
|
378
|
+
createHash('sha256').update(readFileSync(this.docker.executable)).digest('hex') !==
|
|
379
|
+
this.docker.digest
|
|
380
|
+
)
|
|
381
|
+
throw new ServiceError('harness_docker_changed', 'Operator Docker executable changed');
|
|
382
|
+
return {
|
|
383
|
+
executable: this.docker.executable,
|
|
384
|
+
args: ['--host', 'unix://' + this.docker.socket, 'start', '--attach', '--interactive', id],
|
|
385
|
+
cwd: '/',
|
|
386
|
+
env: { HOME: '/nonexistent', PATH: '/usr/bin:/bin' },
|
|
387
|
+
dialect,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async gateway(
|
|
391
|
+
receipt: HarnessContainers,
|
|
392
|
+
handler: (request: GatewayRequest) => Promise<GatewayResponse>,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
await this.assert(receipt);
|
|
395
|
+
const launch = this.launch(receipt.gateway, 'acp'),
|
|
396
|
+
process = spawn(launch.executable, launch.args, {
|
|
397
|
+
cwd: launch.cwd,
|
|
398
|
+
env: launch.env,
|
|
399
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
400
|
+
});
|
|
401
|
+
this.active.set(receipt.run_id, process);
|
|
402
|
+
let buffer = '',
|
|
403
|
+
total = 0,
|
|
404
|
+
ready = false,
|
|
405
|
+
inflight = 0;
|
|
406
|
+
const ids = new Set<number>();
|
|
407
|
+
const send = (message: unknown) => {
|
|
408
|
+
const frame = JSON.stringify(message) + '\n';
|
|
409
|
+
if (process.stdin.writableLength + frame.length > 4 * 1024 * 1024)
|
|
410
|
+
throw Error('Gateway backpressure');
|
|
411
|
+
process.stdin.write(frame);
|
|
412
|
+
};
|
|
413
|
+
const started = new Promise<void>((resolve, reject) => {
|
|
414
|
+
const timer = setTimeout(() => {
|
|
415
|
+
reject(Error('Gateway start deadline'));
|
|
416
|
+
process.kill('SIGKILL');
|
|
417
|
+
}, 10000);
|
|
418
|
+
process.on('error', reject);
|
|
419
|
+
process.stdin.on('error', () => process.kill('SIGKILL'));
|
|
420
|
+
process.stderr.on('data', (bytes: Buffer) => {
|
|
421
|
+
total += bytes.length;
|
|
422
|
+
if (total > 8 * 1024 * 1024) process.kill('SIGKILL');
|
|
423
|
+
});
|
|
424
|
+
process.stdout.on('data', (bytes: Buffer) => {
|
|
425
|
+
try {
|
|
426
|
+
total += bytes.length;
|
|
427
|
+
if (total > 32 * 1024 * 1024) throw Error('Gateway output bound');
|
|
428
|
+
buffer += bytes.toString();
|
|
429
|
+
let end;
|
|
430
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
431
|
+
const line = buffer.slice(0, end);
|
|
432
|
+
buffer = buffer.slice(end + 1);
|
|
433
|
+
if (line.length > 3 * 1024 * 1024) throw Error('Gateway frame bound');
|
|
434
|
+
const m = JSON.parse(line);
|
|
435
|
+
if (m.ready === true && !ready) {
|
|
436
|
+
ready = true;
|
|
437
|
+
clearTimeout(timer);
|
|
438
|
+
resolve();
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (
|
|
442
|
+
!ready ||
|
|
443
|
+
!Number.isSafeInteger(m.id) ||
|
|
444
|
+
ids.has(m.id) ||
|
|
445
|
+
ids.size >= 256 ||
|
|
446
|
+
inflight >= 8 ||
|
|
447
|
+
typeof m.body !== 'string'
|
|
448
|
+
)
|
|
449
|
+
throw Error('Invalid gateway request');
|
|
450
|
+
ids.add(m.id);
|
|
451
|
+
inflight++;
|
|
452
|
+
void (async () => {
|
|
453
|
+
try {
|
|
454
|
+
const reply = await handler({
|
|
455
|
+
method: m.method,
|
|
456
|
+
path: m.path,
|
|
457
|
+
headers: m.headers,
|
|
458
|
+
body: Buffer.from(m.body, 'base64'),
|
|
459
|
+
});
|
|
460
|
+
send({ id: m.id, status: reply.status, content_type: reply.content_type });
|
|
461
|
+
let bytes = 0;
|
|
462
|
+
const chunks = Buffer.isBuffer(reply.body) ? [reply.body] : reply.body;
|
|
463
|
+
for await (const chunk of chunks) {
|
|
464
|
+
bytes += chunk.length;
|
|
465
|
+
if (bytes > HARNESS_CONTAINER_PROFILE.response_bytes)
|
|
466
|
+
throw Error('Gateway response bound');
|
|
467
|
+
send({ id: m.id, chunk: Buffer.from(chunk).toString('base64') });
|
|
468
|
+
}
|
|
469
|
+
send({ id: m.id, end: true });
|
|
470
|
+
} catch {
|
|
471
|
+
send({
|
|
472
|
+
id: m.id,
|
|
473
|
+
status: 503,
|
|
474
|
+
chunk: Buffer.from('{"error":"Gateway request denied"}').toString('base64'),
|
|
475
|
+
end: true,
|
|
476
|
+
});
|
|
477
|
+
} finally {
|
|
478
|
+
inflight--;
|
|
479
|
+
}
|
|
480
|
+
})().catch(() => process.kill('SIGKILL'));
|
|
481
|
+
}
|
|
482
|
+
if (buffer.length > 3 * 1024 * 1024) throw Error('Gateway frame bound');
|
|
483
|
+
} catch (error) {
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
reject(error);
|
|
486
|
+
process.kill('SIGKILL');
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
process.on('close', () => {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
this.active.delete(receipt.run_id);
|
|
492
|
+
if (!ready) reject(Error('Gateway exited before readiness'));
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
await started;
|
|
496
|
+
}
|
|
497
|
+
async launchHarness(
|
|
498
|
+
receipt: HarnessContainers,
|
|
499
|
+
dialect: 'acp' | 'codex',
|
|
500
|
+
): Promise<HarnessLaunch> {
|
|
501
|
+
await this.assert(receipt);
|
|
502
|
+
return this.launch(receipt.harness, dialect);
|
|
503
|
+
}
|
|
504
|
+
async cleanup(receipt: HarnessContainers): Promise<void> {
|
|
505
|
+
const info = engineJson(await this.engine('GET', '/info'));
|
|
506
|
+
if (info.ID !== this.engineId)
|
|
507
|
+
throw new ServiceError(
|
|
508
|
+
'harness_engine_changed',
|
|
509
|
+
'Cannot confirm ownership on a changed engine',
|
|
510
|
+
);
|
|
511
|
+
for (const [id, kind] of [
|
|
512
|
+
[receipt.harness, 'worker'],
|
|
513
|
+
[receipt.gateway, 'gateway'],
|
|
514
|
+
] as const) {
|
|
515
|
+
const found = await this.engine('GET', `/containers/${id}/json`);
|
|
516
|
+
if (found.status === 404) continue;
|
|
517
|
+
await this.owned(id, receipt.run_id, kind);
|
|
518
|
+
const deleted = await this.engine('DELETE', `/containers/${id}?force=true`);
|
|
519
|
+
if (![204, 404].includes(deleted.status)) throw Error('Harness cleanup unconfirmed');
|
|
520
|
+
}
|
|
521
|
+
this.active.get(receipt.run_id)?.kill('SIGKILL');
|
|
522
|
+
this.active.delete(receipt.run_id);
|
|
523
|
+
}
|
|
524
|
+
/** Lost create replies are reconciled by deterministic names and exact owner/profile labels. */
|
|
525
|
+
async cleanupRun(run: string): Promise<void> {
|
|
526
|
+
if (!/^[0-9a-f-]{36}$/.test(run)) throw Error('Invalid owned harness run');
|
|
527
|
+
const info = engineJson(await this.engine('GET', '/info'));
|
|
528
|
+
if (info.ID !== this.engineId)
|
|
529
|
+
throw new ServiceError('harness_engine_changed', 'Cannot reconcile on a changed engine');
|
|
530
|
+
for (const kind of ['worker', 'gateway'] as const) {
|
|
531
|
+
const name = 'salaam-harness-' + run + '-' + kind;
|
|
532
|
+
const found = await this.engine('GET', '/containers/' + name + '/json');
|
|
533
|
+
if (found.status === 404) continue;
|
|
534
|
+
const data = engineJson(found);
|
|
535
|
+
await this.owned(String(data.Id), run, kind);
|
|
536
|
+
const removed = await this.engine('DELETE', '/containers/' + data.Id + '?force=true');
|
|
537
|
+
if (![204, 404].includes(removed.status))
|
|
538
|
+
throw Error('Owned harness reconciliation remains uncertain');
|
|
539
|
+
}
|
|
540
|
+
this.active.get(run)?.kill('SIGKILL');
|
|
541
|
+
this.active.delete(run);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { GatewayRequest, GatewayResponse } from './harness-container.js';
|
|
3
|
+
import { ServiceError } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
export interface AnthropicHarnessPolicy {
|
|
6
|
+
model: string;
|
|
7
|
+
output: number;
|
|
8
|
+
token: string;
|
|
9
|
+
credential: () => Promise<string>;
|
|
10
|
+
admit: (id: string, inputBytes: number, output: number) => void;
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
/** Text/custom-tool-only Anthropic gateway. The caller owns durable root admission and usage reconciliation. */
|
|
14
|
+
export class AnthropicHarnessGateway {
|
|
15
|
+
constructor(readonly policy: AnthropicHarnessPolicy) {
|
|
16
|
+
if (
|
|
17
|
+
!policy.model ||
|
|
18
|
+
!Number.isSafeInteger(policy.output) ||
|
|
19
|
+
policy.output < 1 ||
|
|
20
|
+
policy.output > 8192 ||
|
|
21
|
+
policy.token.length < 32
|
|
22
|
+
)
|
|
23
|
+
throw new Error('Invalid harness model policy');
|
|
24
|
+
}
|
|
25
|
+
async request(request: GatewayRequest): Promise<GatewayResponse> {
|
|
26
|
+
const supplied =
|
|
27
|
+
request.headers['x-api-key'] ?? request.headers.authorization?.replace(/^Bearer /, '') ?? '';
|
|
28
|
+
if (
|
|
29
|
+
typeof supplied !== 'string' ||
|
|
30
|
+
Buffer.byteLength(supplied) !== Buffer.byteLength(this.policy.token) ||
|
|
31
|
+
!timingSafeEqual(Buffer.from(supplied), Buffer.from(this.policy.token))
|
|
32
|
+
)
|
|
33
|
+
throw new ServiceError('harness_gateway_auth', 'Unowned gateway request');
|
|
34
|
+
if (
|
|
35
|
+
request.method !== 'POST' ||
|
|
36
|
+
!['/v1/messages', '/v1/messages?beta=true'].includes(request.path) ||
|
|
37
|
+
request.body.length > 2 * 1024 * 1024
|
|
38
|
+
)
|
|
39
|
+
throw new ServiceError(
|
|
40
|
+
'harness_gateway_scope',
|
|
41
|
+
'Gateway route is outside its admitted scope',
|
|
42
|
+
);
|
|
43
|
+
let body: Record<string, any>;
|
|
44
|
+
try {
|
|
45
|
+
body = JSON.parse(request.body.toString('utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
throw new ServiceError('harness_gateway_payload', 'Invalid model payload');
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
!body ||
|
|
51
|
+
Array.isArray(body) ||
|
|
52
|
+
body.model !== this.policy.model ||
|
|
53
|
+
!Number.isSafeInteger(body.max_tokens) ||
|
|
54
|
+
body.max_tokens < 1 ||
|
|
55
|
+
!Array.isArray(body.messages) ||
|
|
56
|
+
body.messages.length > 256 ||
|
|
57
|
+
body.mcp_servers ||
|
|
58
|
+
body.container ||
|
|
59
|
+
body.tools?.some(
|
|
60
|
+
(t: any) => !t || t.type !== undefined || typeof t.name !== 'string' || !t.input_schema,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
throw new ServiceError(
|
|
64
|
+
'harness_gateway_payload',
|
|
65
|
+
'Model route or tool authority differs from its admission',
|
|
66
|
+
);
|
|
67
|
+
const content = (blocks: unknown, role: string): boolean =>
|
|
68
|
+
typeof blocks === 'string' ||
|
|
69
|
+
(Array.isArray(blocks) &&
|
|
70
|
+
blocks.every(
|
|
71
|
+
(b: any) =>
|
|
72
|
+
b &&
|
|
73
|
+
typeof b === 'object' &&
|
|
74
|
+
((b.type === 'text' && typeof b.text === 'string') ||
|
|
75
|
+
(role === 'assistant' &&
|
|
76
|
+
(b.type === 'thinking' ||
|
|
77
|
+
b.type === 'redacted_thinking' ||
|
|
78
|
+
b.type === 'tool_use')) ||
|
|
79
|
+
(role === 'user' && b.type === 'tool_result' && content(b.content ?? '', 'tool'))),
|
|
80
|
+
));
|
|
81
|
+
if (
|
|
82
|
+
body.messages.some(
|
|
83
|
+
(m: any) => !m || !['user', 'assistant'].includes(m.role) || !content(m.content, m.role),
|
|
84
|
+
) ||
|
|
85
|
+
(body.system !== undefined && !content(body.system, 'system'))
|
|
86
|
+
)
|
|
87
|
+
throw new ServiceError(
|
|
88
|
+
'harness_gateway_payload',
|
|
89
|
+
'Only admitted text and custom tool data may leave the workspace',
|
|
90
|
+
);
|
|
91
|
+
body.max_tokens = Math.min(body.max_tokens, this.policy.output);
|
|
92
|
+
if (body.thinking?.type === 'enabled' && body.thinking.budget_tokens >= body.max_tokens)
|
|
93
|
+
throw new ServiceError(
|
|
94
|
+
'harness_gateway_payload',
|
|
95
|
+
'Harness thinking budget exceeds its admitted output cap',
|
|
96
|
+
);
|
|
97
|
+
const credential = await this.policy.credential();
|
|
98
|
+
if (!credential) throw new Error('Harness provider credential unavailable');
|
|
99
|
+
const id = 'harness-model-request:' + randomUUID();
|
|
100
|
+
this.policy.admit(id, request.body.length, body.max_tokens);
|
|
101
|
+
const response = await (this.policy.fetch ?? fetch)('https://api.anthropic.com/v1/messages', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
redirect: 'error',
|
|
104
|
+
signal: AbortSignal.timeout(60000),
|
|
105
|
+
headers: {
|
|
106
|
+
'content-type': 'application/json',
|
|
107
|
+
'anthropic-version': '2023-06-01',
|
|
108
|
+
'x-api-key': credential,
|
|
109
|
+
...(request.headers['anthropic-beta']
|
|
110
|
+
? { 'anthropic-beta': request.headers['anthropic-beta'] }
|
|
111
|
+
: {}),
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
const reader = response.body?.getReader();
|
|
116
|
+
const chunks = async function* () {
|
|
117
|
+
let size = 0;
|
|
118
|
+
try {
|
|
119
|
+
if (!reader) return;
|
|
120
|
+
while (true) {
|
|
121
|
+
const chunk = await reader.read();
|
|
122
|
+
if (chunk.done) break;
|
|
123
|
+
size += chunk.value.length;
|
|
124
|
+
if (size > 8 * 1024 * 1024)
|
|
125
|
+
throw new Error('Harness provider response exceeded its bound');
|
|
126
|
+
yield chunk.value;
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
await reader?.cancel();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
status: response.status,
|
|
134
|
+
content_type: response.headers.get('content-type') ?? 'application/json',
|
|
135
|
+
body: chunks(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|