@commonlyai/cli 0.1.182 → 0.1.184
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/node_modules/@commonlyai/seats/src/record/format.js +7 -1
- package/node_modules/@commonlyai/seats/src/trace/events.js +2 -2
- package/node_modules/@commonlyai/seats/src/valet/env.js +43 -11
- package/node_modules/@commonlyai/seats/src/valet/grant.js +9 -4
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +229 -60
- package/package.json +1 -1
- package/src/lib/adapters/dsh.js +158 -20
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/instance-proxy.js +15 -6
- package/src/lib/sandbox/seatbelt.js +11 -1
|
@@ -371,7 +371,13 @@ const CHECKS = {
|
|
|
371
371
|
optionalArgsDigest(data, 'tool.call');
|
|
372
372
|
return data;
|
|
373
373
|
},
|
|
374
|
-
'credential.granted': (data) =>
|
|
374
|
+
'credential.granted': (data) => {
|
|
375
|
+
const names = envNames(data.names ?? [], 'credential.granted.names');
|
|
376
|
+
if (data.injected === undefined) return { ...data, names };
|
|
377
|
+
const injected = envNames(data.injected, 'credential.granted.injected');
|
|
378
|
+
if (!injected.every((name) => names.includes(name))) fail('credential.granted.injected must be a subset of names');
|
|
379
|
+
return { ...data, names, injected };
|
|
380
|
+
},
|
|
375
381
|
'credential.denied': (data) => {
|
|
376
382
|
envNames([data.name], 'credential.denied.name');
|
|
377
383
|
return data;
|
|
@@ -23,8 +23,8 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
23
23
|
'usage', // { inputTokens?, outputTokens?, totalTokens?, cost? } null-not-zero when unknown
|
|
24
24
|
'liveness.stalled', // { idleMs, windowMs }
|
|
25
25
|
'liveness.recovery', // { action: 'cancel'|'kill'|'retry', attempt }
|
|
26
|
-
'credential.granted', // { names: [...] } names only, never values
|
|
27
|
-
'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short' }
|
|
26
|
+
'credential.granted', // { names: [...], injected?: [...] } names only, never values
|
|
27
|
+
'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short'|'no_upstream' }
|
|
28
28
|
'process.exited', // { code, signal }
|
|
29
29
|
// Record kinds with no earlier trace name (docs/record-v1.md has the full data).
|
|
30
30
|
'task.claimed', // { leaseUntil? } mirrors the board's claim; the board stays the source of truth
|
|
@@ -19,6 +19,7 @@ import { traceOf } from '../trace/events.js';
|
|
|
19
19
|
import { runFailure } from '../failures.js';
|
|
20
20
|
import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js';
|
|
21
21
|
import { validateGrant } from './grant.js';
|
|
22
|
+
import { startProviderProxy } from '../wire/provider-proxy.js';
|
|
22
23
|
|
|
23
24
|
// Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust
|
|
24
25
|
// only. USER/LOGNAME are left out on purpose; they name the account whose
|
|
@@ -104,8 +105,8 @@ const grantIssued = (meta) => {
|
|
|
104
105
|
};
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
* prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta? })
|
|
108
|
-
* → { env, home, cleanup, granted: [names], grantId? }
|
|
108
|
+
* prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta?, upstreams? })
|
|
109
|
+
* → { env, home, cleanup, granted: [names], injected: { NAME: { baseURL, wire } }, grantId? }
|
|
109
110
|
*
|
|
110
111
|
* `require` lists credential names the adapter cannot run without; one the
|
|
111
112
|
* grant does not name fails the run as credential_denied before any spawn.
|
|
@@ -114,14 +115,25 @@ const grantIssued = (meta) => {
|
|
|
114
115
|
* silently lacks its key fails later and less legibly, and a value that short
|
|
115
116
|
* would slip past the refusal-text reader's redaction.
|
|
116
117
|
*
|
|
118
|
+
* A credential the grant marks `inject: true` never reaches the env. The host
|
|
119
|
+
* names its provider in `upstreams[NAME]` ({ baseURL, wire }, wire defaulting
|
|
120
|
+
* to openai-chat); the run gets a loopback provider proxy holding the value in
|
|
121
|
+
* memory, env[NAME] becomes that proxy's token, and `injected[NAME].baseURL`
|
|
122
|
+
* is where the host points the CLI. cleanup() closes the proxies. One with no
|
|
123
|
+
* upstream fails as credential_denied (no_upstream) before it is resolved.
|
|
124
|
+
* The proxy listens on 127.0.0.1, so a run without the host's network
|
|
125
|
+
* (bwrap --unshare-net) cannot reach it yet.
|
|
126
|
+
*
|
|
117
127
|
* Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta,
|
|
118
|
-
* first), then credential.granted {names} or credential.denied
|
|
128
|
+
* first), then credential.granted {names, injected?} or credential.denied
|
|
129
|
+
* {name, reason}. `injected` lists the names held by a proxy; the rest of
|
|
130
|
+
* `names` are held by the run.
|
|
119
131
|
* Values never enter an event or a message, and a hook that offers
|
|
120
|
-
* redact(values) is handed the resolved values
|
|
121
|
-
* out of agent text too.
|
|
132
|
+
* redact(values) is handed the resolved values and each proxy token, so the
|
|
133
|
+
* record can scrub them out of agent text too.
|
|
122
134
|
*/
|
|
123
135
|
export const prepareRunEnv = async ({
|
|
124
|
-
grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta,
|
|
136
|
+
grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta, upstreams = {},
|
|
125
137
|
} = {}) => {
|
|
126
138
|
const valid = validateGrant(grant);
|
|
127
139
|
const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx);
|
|
@@ -132,6 +144,7 @@ export const prepareRunEnv = async ({
|
|
|
132
144
|
not_granted: 'is not in the run grant',
|
|
133
145
|
unresolved: 'could not be resolved',
|
|
134
146
|
too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`,
|
|
147
|
+
no_upstream: 'is marked inject, but the host named no upstream for it',
|
|
135
148
|
};
|
|
136
149
|
const deny = (name, reason, values) => {
|
|
137
150
|
hook.emit('credential.denied', { name, reason });
|
|
@@ -143,11 +156,18 @@ export const prepareRunEnv = async ({
|
|
|
143
156
|
if (missing) throw deny(missing, 'not_granted', []);
|
|
144
157
|
|
|
145
158
|
const { home, dirs } = await makeHome(root);
|
|
146
|
-
const
|
|
159
|
+
const proxies = [];
|
|
160
|
+
const cleanup = async () => {
|
|
161
|
+
await Promise.all(proxies.splice(0).map((proxy) => proxy.close()));
|
|
162
|
+
await rm(home, { recursive: true, force: true });
|
|
163
|
+
};
|
|
147
164
|
const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs };
|
|
148
165
|
const values = [];
|
|
166
|
+
const injected = {};
|
|
149
167
|
try {
|
|
150
168
|
for (const spec of valid.credentials) {
|
|
169
|
+
const upstream = spec.inject ? upstreams[spec.name] : undefined;
|
|
170
|
+
if (spec.inject && !upstream) throw deny(spec.name, 'no_upstream', values);
|
|
151
171
|
let value;
|
|
152
172
|
// A resolver's error text may quote the secret it failed on; drop it.
|
|
153
173
|
try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; }
|
|
@@ -156,14 +176,26 @@ export const prepareRunEnv = async ({
|
|
|
156
176
|
// refusal-text reader would not redact it. Refuse it here, before spawn.
|
|
157
177
|
if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values);
|
|
158
178
|
values.push(value);
|
|
159
|
-
|
|
179
|
+
if (upstream) {
|
|
180
|
+
const wire = upstream.wire ?? 'openai-chat';
|
|
181
|
+
const proxy = await startProviderProxy({
|
|
182
|
+
provider: { baseURL: upstream.baseURL }, keyMaterial: value, wire, adapterLabel: 'Valet',
|
|
183
|
+
});
|
|
184
|
+
proxies.push(proxy);
|
|
185
|
+
env[spec.name] = proxy.token;
|
|
186
|
+
injected[spec.name] = { baseURL: proxy.baseURL, wire };
|
|
187
|
+
} else {
|
|
188
|
+
env[spec.name] = value;
|
|
189
|
+
}
|
|
160
190
|
}
|
|
161
191
|
} catch (error) {
|
|
162
192
|
await cleanup();
|
|
163
193
|
throw error;
|
|
164
194
|
}
|
|
165
|
-
|
|
195
|
+
// A proxy token authorizes provider calls until cleanup, so the record scrubs it too.
|
|
196
|
+
if (typeof hook.redact === 'function') hook.redact([...values, ...proxies.map((proxy) => proxy.token)]);
|
|
166
197
|
const names = valid.credentials.map((c) => c.name);
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
const proxied = Object.keys(injected);
|
|
199
|
+
hook.emit('credential.granted', proxied.length ? { names, injected: proxied } : { names });
|
|
200
|
+
return { env, home, cleanup, granted: names, injected, ...(issued ? { grantId: issued.grantId } : {}) };
|
|
169
201
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and which tool calls it may make without asking, only after asking, or never.
|
|
4
4
|
*
|
|
5
5
|
* {
|
|
6
|
-
* credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref? }],
|
|
6
|
+
* credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref?, inject? }],
|
|
7
7
|
* tools: { allow: [pattern], ask: [pattern], deny: [pattern] },
|
|
8
8
|
* }
|
|
9
9
|
*
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* path in `ref`; `from: 'resolver'` asks the host's resolveSecret(name, spec),
|
|
12
12
|
* with `ref` as an opaque hint for it.
|
|
13
13
|
*
|
|
14
|
+
* `inject: true` keeps the value out of the run: its env gets a per-run proxy
|
|
15
|
+
* token under the name instead, and a loopback provider proxy adds the real
|
|
16
|
+
* key to outbound requests. Default false: the run holds the value.
|
|
17
|
+
*
|
|
14
18
|
* A tool pattern is `<kind>` or `<kind>:<title glob>`, where kind is one of
|
|
15
19
|
* ACP's ToolKind values or `*`. `execute` matches every execute call;
|
|
16
20
|
* `execute:git status*` matches execute calls whose title starts that way;
|
|
@@ -61,8 +65,8 @@ const parsePattern = (pattern, where) => {
|
|
|
61
65
|
const validateCredential = (credential, index, seen) => {
|
|
62
66
|
const where = `credentials[${index}]`;
|
|
63
67
|
if (!isPlainObject(credential)) fail(`${where} must be an object`);
|
|
64
|
-
refuseUnknownKeys(credential, ['name', 'from', 'ref'], `${where}.`);
|
|
65
|
-
const { name, from, ref } = credential;
|
|
68
|
+
refuseUnknownKeys(credential, ['name', 'from', 'ref', 'inject'], `${where}.`);
|
|
69
|
+
const { name, from, ref, inject } = credential;
|
|
66
70
|
if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`);
|
|
67
71
|
if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`);
|
|
68
72
|
if (seen.has(name)) fail(`${where}.name ${name} is granted twice`);
|
|
@@ -71,7 +75,8 @@ const validateCredential = (credential, index, seen) => {
|
|
|
71
75
|
if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`);
|
|
72
76
|
if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`);
|
|
73
77
|
if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`);
|
|
74
|
-
|
|
78
|
+
if (inject !== undefined && typeof inject !== 'boolean') fail(`${where}.inject must be true or false`);
|
|
79
|
+
return Object.freeze({ name, from, ...(ref === undefined ? {} : { ref }), ...(inject ? { inject } : {}) });
|
|
75
80
|
};
|
|
76
81
|
|
|
77
82
|
/** Validate and freeze a grant. Throws on the first problem; never repairs one. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Transform } from 'node:stream';
|
|
2
|
-
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
/** The per-spawn loopback proxy that holds a provider key for a CLI (OpenAI chat, Anthropic Messages, OpenAI Responses). */
|
|
3
4
|
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
4
5
|
import { constants as fsConstants, realpathSync } from 'fs';
|
|
5
6
|
import { createServer, request as httpRequest } from 'http';
|
|
@@ -40,6 +41,71 @@ export const redactProviderMaterial = (material) => {
|
|
|
40
41
|
});
|
|
41
42
|
};
|
|
42
43
|
|
|
44
|
+
const MAX_PINNED_REQUEST_BYTES = 8 * 1024 * 1024;
|
|
45
|
+
const MAX_PINNED_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Checks that every completion an upstream serves names the pinned model.
|
|
49
|
+
* SSE is checked line by line, and nothing is released until a line has named
|
|
50
|
+
* the pinned model; after that only checked lines are released, so a chunk
|
|
51
|
+
* naming another model never reaches the CLI. Any other body is held whole and
|
|
52
|
+
* released only after its `model` is checked. A body that names no model is
|
|
53
|
+
* unverified, which counts as a failure: the pin is only as good as the check.
|
|
54
|
+
*/
|
|
55
|
+
const servedModelCheck = ({ pinned, stream, onServed }) => {
|
|
56
|
+
// A chunk boundary can fall inside a multi-byte character; the decoder
|
|
57
|
+
// carries the partial bytes into the next chunk instead of mangling both.
|
|
58
|
+
const decoder = new StringDecoder('utf8');
|
|
59
|
+
let pending = '';
|
|
60
|
+
let held = '';
|
|
61
|
+
let named = false;
|
|
62
|
+
let size = 0;
|
|
63
|
+
const check = (value) => {
|
|
64
|
+
if (typeof value !== 'string') return;
|
|
65
|
+
named = true;
|
|
66
|
+
onServed(value);
|
|
67
|
+
if (value !== pinned) throw new Error(`served model ${JSON.stringify(value)} is not the pinned ${JSON.stringify(pinned)}`);
|
|
68
|
+
};
|
|
69
|
+
const checkLine = (line) => {
|
|
70
|
+
const data = /^data:\s?(.*)$/.exec(line.replace(/\r$/, ''))?.[1];
|
|
71
|
+
if (data === undefined || data.trim() === '[DONE]' || !data.trim()) return;
|
|
72
|
+
let parsed;
|
|
73
|
+
try { parsed = JSON.parse(data); } catch { return; }
|
|
74
|
+
check(parsed?.model);
|
|
75
|
+
};
|
|
76
|
+
return new Transform({
|
|
77
|
+
transform(chunk, _encoding, done) {
|
|
78
|
+
size += chunk.length;
|
|
79
|
+
if (size > MAX_PINNED_RESPONSE_BYTES) { done(new Error('provider response exceeded the pinned-model check limit')); return; }
|
|
80
|
+
pending += decoder.write(chunk);
|
|
81
|
+
if (!stream) { done(); return; }
|
|
82
|
+
const end = pending.lastIndexOf('\n');
|
|
83
|
+
if (end < 0) { done(); return; }
|
|
84
|
+
const complete = pending.slice(0, end + 1);
|
|
85
|
+
pending = pending.slice(end + 1);
|
|
86
|
+
try { complete.split('\n').forEach(checkLine); } catch (error) { done(error); return; }
|
|
87
|
+
held += complete;
|
|
88
|
+
if (!named) { done(); return; }
|
|
89
|
+
const release = held;
|
|
90
|
+
held = '';
|
|
91
|
+
done(null, release);
|
|
92
|
+
},
|
|
93
|
+
flush(done) {
|
|
94
|
+
pending += decoder.end();
|
|
95
|
+
try {
|
|
96
|
+
if (stream) checkLine(pending);
|
|
97
|
+
else {
|
|
98
|
+
let parsed;
|
|
99
|
+
try { parsed = JSON.parse(pending); } catch { parsed = null; }
|
|
100
|
+
check(parsed?.model);
|
|
101
|
+
}
|
|
102
|
+
if (!named) throw new Error(`provider response named no model; the pinned ${JSON.stringify(pinned)} cannot be verified`);
|
|
103
|
+
} catch (error) { done(error); return; }
|
|
104
|
+
done(null, held + pending);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
43
109
|
const isPathWithin = (parent, candidate) => {
|
|
44
110
|
const fromParent = relative(parent, candidate);
|
|
45
111
|
return fromParent === '' || (fromParent !== '..'
|
|
@@ -101,9 +167,34 @@ const readProviderKey = async (keyFile, adapterLabel) => {
|
|
|
101
167
|
}
|
|
102
168
|
};
|
|
103
169
|
|
|
170
|
+
// The routes a CLI on each wire may call, relative to the configured base
|
|
171
|
+
// path, and the header the real key goes out in. Anything else is refused.
|
|
172
|
+
const WIRES = Object.freeze({
|
|
173
|
+
'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' },
|
|
174
|
+
'anthropic-messages': {
|
|
175
|
+
routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']],
|
|
176
|
+
auth: 'x-api-key',
|
|
177
|
+
},
|
|
178
|
+
'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' },
|
|
179
|
+
});
|
|
180
|
+
export const PROVIDER_WIRES = Object.freeze(Object.keys(WIRES));
|
|
181
|
+
|
|
182
|
+
const presents = (value, expected) => {
|
|
183
|
+
const supplied = Buffer.from(typeof value === 'string' ? value : '');
|
|
184
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
185
|
+
};
|
|
186
|
+
|
|
104
187
|
export const startProviderProxy = async ({
|
|
105
|
-
provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode',
|
|
188
|
+
provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', wire = 'openai-chat',
|
|
189
|
+
// When set, every completion must request this model and be served by it.
|
|
190
|
+
pinnedModel,
|
|
106
191
|
}) => {
|
|
192
|
+
const wireSpec = WIRES[wire];
|
|
193
|
+
if (!wireSpec) throw new Error(`${adapterLabel} provider wire must be one of ${PROVIDER_WIRES.join(', ')}`);
|
|
194
|
+
if (pinnedModel !== undefined && (typeof pinnedModel !== 'string' || !pinnedModel.trim())) {
|
|
195
|
+
throw new Error(`${adapterLabel} pinnedModel must be a non-empty string`);
|
|
196
|
+
}
|
|
197
|
+
const modelState = { requested: new Set(), served: new Set(), refused: 0, failures: [] };
|
|
107
198
|
const upstream = new URL(provider.baseURL);
|
|
108
199
|
if (socketPath && (!isAbsolute(socketPath)
|
|
109
200
|
|| !Number.isInteger(isolatedPort) || isolatedPort < 1 || isolatedPort > 65535)) {
|
|
@@ -117,8 +208,13 @@ export const startProviderProxy = async ({
|
|
|
117
208
|
const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial;
|
|
118
209
|
if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key');
|
|
119
210
|
const proxyToken = randomBytes(32).toString('base64url');
|
|
120
|
-
|
|
121
|
-
|
|
211
|
+
// A caller that hands the token to its child in memory needs no file.
|
|
212
|
+
if (tokenFile) {
|
|
213
|
+
await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 });
|
|
214
|
+
await chmod(tokenFile, 0o600);
|
|
215
|
+
}
|
|
216
|
+
const bearerToken = Buffer.from(`Bearer ${proxyToken}`);
|
|
217
|
+
const bareToken = Buffer.from(proxyToken);
|
|
122
218
|
|
|
123
219
|
const logRefusedRequest = (method, requestPath) => {
|
|
124
220
|
let normalizedPath = '/';
|
|
@@ -132,29 +228,34 @@ export const startProviderProxy = async ({
|
|
|
132
228
|
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused ${safeMethod} ${normalizedPath}\n`);
|
|
133
229
|
};
|
|
134
230
|
|
|
231
|
+
// A refusal answers before the request body has been read. A keep-alive
|
|
232
|
+
// client that reuses that socket can have its next request reset, so every
|
|
233
|
+
// refusal on every wire also closes the connection.
|
|
234
|
+
const refuse = (incoming, outgoing, status, headers = {}, body = undefined) => {
|
|
235
|
+
incoming.resume();
|
|
236
|
+
outgoing.writeHead(status, { ...headers, connection: 'close' });
|
|
237
|
+
outgoing.end(body);
|
|
238
|
+
};
|
|
239
|
+
|
|
135
240
|
const server = createServer((incoming, outgoing) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
incoming
|
|
140
|
-
outgoing.writeHead(401).end();
|
|
241
|
+
// Anthropic clients send their key as x-api-key, the others as a Bearer.
|
|
242
|
+
if (!presents(incoming.headers.authorization, bearerToken)
|
|
243
|
+
&& !presents(incoming.headers['x-api-key'], bareToken)) {
|
|
244
|
+
refuse(incoming, outgoing, 401);
|
|
141
245
|
return;
|
|
142
246
|
}
|
|
143
247
|
if (!['GET', 'POST'].includes(incoming.method || '')) {
|
|
144
|
-
incoming
|
|
145
|
-
outgoing.writeHead(405).end();
|
|
248
|
+
refuse(incoming, outgoing, 405);
|
|
146
249
|
return;
|
|
147
250
|
}
|
|
148
251
|
const rejectRoute = (path) => {
|
|
149
252
|
logRefusedRequest(incoming.method, path);
|
|
150
|
-
incoming
|
|
151
|
-
|
|
152
|
-
outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
|
|
253
|
+
refuse(incoming, outgoing, 403, { 'content-type': 'text/plain; charset=utf-8' },
|
|
254
|
+
`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`);
|
|
153
255
|
};
|
|
154
256
|
const rejectBadRequest = () => {
|
|
155
257
|
logRefusedRequest(incoming.method, incoming.url);
|
|
156
|
-
incoming
|
|
157
|
-
outgoing.writeHead(400).end();
|
|
258
|
+
refuse(incoming, outgoing, 400);
|
|
158
259
|
};
|
|
159
260
|
|
|
160
261
|
let target;
|
|
@@ -169,8 +270,8 @@ export const startProviderProxy = async ({
|
|
|
169
270
|
return;
|
|
170
271
|
}
|
|
171
272
|
// The CLI owns this local request path, so never use it as an outbound
|
|
172
|
-
// URL.
|
|
173
|
-
//
|
|
273
|
+
// URL. Each wire needs only the fixed routes in WIRES; parse the
|
|
274
|
+
// incoming URL against a loopback sentinel, then select a
|
|
174
275
|
// constant endpoint after matching the configured base path exactly.
|
|
175
276
|
// This keeps a CLI request from turning the proxy into an arbitrary
|
|
176
277
|
// path/URL fetcher (including paths on a local provider host).
|
|
@@ -180,17 +281,13 @@ export const startProviderProxy = async ({
|
|
|
180
281
|
return;
|
|
181
282
|
}
|
|
182
283
|
const configuredPath = basePath === '/' ? '' : basePath;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
endpoint = 'chat/completions';
|
|
187
|
-
} else if (incoming.method === 'GET'
|
|
188
|
-
&& requested.pathname === `${configuredPath}/models`) {
|
|
189
|
-
endpoint = 'models';
|
|
190
|
-
} else {
|
|
284
|
+
const route = wireSpec.routes.find(([method, path]) => incoming.method === method
|
|
285
|
+
&& requested.pathname === `${configuredPath}/${path}`);
|
|
286
|
+
if (!route) {
|
|
191
287
|
rejectRoute(requested.pathname);
|
|
192
288
|
return;
|
|
193
289
|
}
|
|
290
|
+
const endpoint = route[1];
|
|
194
291
|
target = {
|
|
195
292
|
path: `${configuredPath}/${endpoint}`,
|
|
196
293
|
protocol: upstream.protocol,
|
|
@@ -209,45 +306,108 @@ export const startProviderProxy = async ({
|
|
|
209
306
|
'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
210
307
|
'x-api-key',
|
|
211
308
|
].includes(name.toLowerCase())));
|
|
212
|
-
|
|
309
|
+
if (wireSpec.auth === 'x-api-key') headers['x-api-key'] = upstreamKey;
|
|
310
|
+
else headers.authorization = `Bearer ${upstreamKey}`;
|
|
213
311
|
headers.host = target.host;
|
|
214
312
|
headers['accept-encoding'] = 'identity';
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
headers,
|
|
222
|
-
}, (response) => {
|
|
223
|
-
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
224
|
-
response.resume();
|
|
225
|
-
outgoing.writeHead(502).end('Provider redirects are not supported');
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
|
|
229
|
-
response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
|
|
230
|
-
}
|
|
231
|
-
const responseHeaders = Object.fromEntries(Object.entries(response.headers)
|
|
232
|
-
.filter(([name]) => ![
|
|
233
|
-
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
234
|
-
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
235
|
-
].includes(name.toLowerCase())));
|
|
236
|
-
delete responseHeaders['content-length'];
|
|
237
|
-
for (const [name, value] of Object.entries(responseHeaders)) {
|
|
238
|
-
if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
|
|
239
|
-
else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
|
|
313
|
+
const pinned = pinnedModel !== undefined && target.path.endsWith('/chat/completions');
|
|
314
|
+
const forward = (body) => {
|
|
315
|
+
let streamed = false;
|
|
316
|
+
if (body) {
|
|
317
|
+
headers['content-length'] = String(body.length);
|
|
318
|
+
try { streamed = JSON.parse(body.toString('utf8'))?.stream === true; } catch { /* checked before forward */ }
|
|
240
319
|
}
|
|
241
|
-
|
|
242
|
-
|
|
320
|
+
const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)({
|
|
321
|
+
protocol: target.protocol,
|
|
322
|
+
hostname: target.hostname,
|
|
323
|
+
port: target.port,
|
|
324
|
+
path: target.path,
|
|
325
|
+
method: incoming.method,
|
|
326
|
+
headers,
|
|
327
|
+
}, (response) => {
|
|
328
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
329
|
+
response.resume();
|
|
330
|
+
outgoing.writeHead(502).end('Provider redirects are not supported');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity') {
|
|
334
|
+
response.resume(); outgoing.writeHead(502).end('Compressed provider responses are not supported'); return;
|
|
335
|
+
}
|
|
336
|
+
const responseHeaders = Object.fromEntries(Object.entries(response.headers)
|
|
337
|
+
.filter(([name]) => ![
|
|
338
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
339
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
340
|
+
].includes(name.toLowerCase())));
|
|
341
|
+
delete responseHeaders['content-length'];
|
|
342
|
+
for (const [name, value] of Object.entries(responseHeaders)) {
|
|
343
|
+
if (typeof value === 'string') responseHeaders[name] = value.split(upstreamKey).join('[redacted]');
|
|
344
|
+
else if (Array.isArray(value)) responseHeaders[name] = value.map((entry) => entry.split(upstreamKey).join('[redacted]'));
|
|
345
|
+
}
|
|
346
|
+
const ok = response.statusCode >= 200 && response.statusCode < 300;
|
|
347
|
+
if (!pinned || !ok) {
|
|
348
|
+
outgoing.writeHead(response.statusCode || 502, responseHeaders);
|
|
349
|
+
response.pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const sse = streamed || /text\/event-stream/i.test(String(response.headers['content-type'] || ''));
|
|
353
|
+
const check = servedModelCheck({
|
|
354
|
+
pinned: pinnedModel, stream: sse, onServed: (served) => modelState.served.add(served),
|
|
355
|
+
});
|
|
356
|
+
const fail = (error) => {
|
|
357
|
+
modelState.failures.push(error.message);
|
|
358
|
+
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy: ${error.message}\n`);
|
|
359
|
+
response.destroy();
|
|
360
|
+
if (!outgoing.headersSent) outgoing.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }).end('Served model check failed');
|
|
361
|
+
else outgoing.destroy();
|
|
362
|
+
};
|
|
363
|
+
check.on('error', fail);
|
|
364
|
+
// A stream's headers go out at once; a whole body waits for its check.
|
|
365
|
+
if (sse) outgoing.writeHead(response.statusCode, responseHeaders);
|
|
366
|
+
else check.once('data', () => { if (!outgoing.headersSent) outgoing.writeHead(response.statusCode, responseHeaders); });
|
|
367
|
+
response.pipe(check).pipe(redactProviderMaterial(upstreamKey)).pipe(outgoing);
|
|
368
|
+
});
|
|
369
|
+
request.on('error', () => {
|
|
370
|
+
if (!outgoing.headersSent) outgoing.writeHead(502);
|
|
371
|
+
outgoing.end('Provider request failed');
|
|
372
|
+
});
|
|
373
|
+
outgoing.on('close', () => request.destroy());
|
|
374
|
+
incoming.on('aborted', () => request.destroy());
|
|
375
|
+
if (body) request.end(body);
|
|
376
|
+
else incoming.pipe(request);
|
|
377
|
+
};
|
|
378
|
+
if (!pinned) {
|
|
379
|
+
forward(null);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// A pinned proxy reads the request first: a completion for any other model
|
|
383
|
+
// is refused here, before the provider bills it.
|
|
384
|
+
const chunks = [];
|
|
385
|
+
let size = 0;
|
|
386
|
+
incoming.on('data', (chunk) => {
|
|
387
|
+
size += chunk.length;
|
|
388
|
+
if (size <= MAX_PINNED_REQUEST_BYTES) chunks.push(chunk);
|
|
243
389
|
});
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
390
|
+
incoming.on('end', () => {
|
|
391
|
+
if (size > MAX_PINNED_REQUEST_BYTES) { outgoing.writeHead(413).end(); return; }
|
|
392
|
+
const body = Buffer.concat(chunks);
|
|
393
|
+
let requested;
|
|
394
|
+
try { requested = JSON.parse(body.toString('utf8'))?.model; } catch { requested = undefined; }
|
|
395
|
+
if (requested !== pinnedModel) {
|
|
396
|
+
modelState.refused += 1;
|
|
397
|
+
process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused a completion for an unpinned model\n`);
|
|
398
|
+
outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
399
|
+
outgoing.end(`This seat is pinned to ${pinnedModel}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
modelState.requested.add(requested);
|
|
403
|
+
forward(body);
|
|
247
404
|
});
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// A WebSocket client (codex on the Responses wire) falls back to HTTP SSE on
|
|
408
|
+
// 426. Nothing is relayed over an upgrade, so no token check is needed first.
|
|
409
|
+
server.on('upgrade', (_incoming, socket) => {
|
|
410
|
+
socket.end('HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
|
|
251
411
|
});
|
|
252
412
|
|
|
253
413
|
await new Promise((resolve, reject) => {
|
|
@@ -271,9 +431,18 @@ export const startProviderProxy = async ({
|
|
|
271
431
|
const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString();
|
|
272
432
|
return {
|
|
273
433
|
baseURL: proxyBaseURL,
|
|
434
|
+
token: proxyToken,
|
|
274
435
|
tokenFile,
|
|
275
436
|
socketPath,
|
|
276
437
|
port,
|
|
438
|
+
/** The pin's verdict so far: what was asked for, what was served, and what failed. */
|
|
439
|
+
modelCheck: () => ({
|
|
440
|
+
pinned: pinnedModel ?? null,
|
|
441
|
+
requested: [...modelState.requested],
|
|
442
|
+
served: [...modelState.served],
|
|
443
|
+
refused: modelState.refused,
|
|
444
|
+
failures: [...modelState.failures],
|
|
445
|
+
}),
|
|
277
446
|
close: () => new Promise((resolve) => {
|
|
278
447
|
server.close(async () => {
|
|
279
448
|
if (socketPath) await unlink(socketPath).catch(() => {});
|
package/package.json
CHANGED
package/src/lib/adapters/dsh.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import { observeSpawn } from '../runtime-observation.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* DeepSeek Harness 0.2.1-rc.2, private seats only. Configuration is per spawn.
|
|
4
|
+
*
|
|
5
|
+
* A seat runs plain, or confined: `sandbox: { mode: 'workspace', network:
|
|
6
|
+
* { policy: 'restricted' } }` runs dsh inside one Seatbelt profile that writes
|
|
7
|
+
* only the workspace and the seat's home, reaches only three loopback ports
|
|
8
|
+
* (the provider proxy, the Commonly instance relay and the GitHub egress
|
|
9
|
+
* proxy), and cannot reach the Security services the keychain sits behind.
|
|
10
|
+
* The provider proxy is pinned to the seat's model and checks the model each
|
|
11
|
+
* response names. dsh's own sandbox is turned off inside it, because a second
|
|
12
|
+
* Seatbelt cannot be applied under the first.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
|
|
3
15
|
import { requireSeatModel } from './seat-model.js';
|
|
4
16
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
5
17
|
import { createHash } from 'crypto';
|
|
6
|
-
import { existsSync } from 'fs';
|
|
18
|
+
import { existsSync, realpathSync } from 'fs';
|
|
7
19
|
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
|
|
8
20
|
import { homedir, tmpdir } from 'os';
|
|
9
|
-
import { basename, join } from 'path';
|
|
21
|
+
import { basename, delimiter, isAbsolute, join, sep } from 'path';
|
|
22
|
+
import { startEgressProxy } from '../egress-proxy.js';
|
|
23
|
+
import { startInstanceProxy } from '../instance-proxy.js';
|
|
24
|
+
import { prepareMcpSpawn, selectedMcpVersionDir } from '../mcp-home.js';
|
|
25
|
+
import { detectSeatbelt, wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
|
|
10
26
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
11
27
|
import { commonlyMcpServer } from '../default-environment.js';
|
|
12
28
|
import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
|
|
@@ -21,15 +37,56 @@ const PROXY_KEY_ENV = 'COMMONLY_DSH_PROXY_KEY';
|
|
|
21
37
|
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
22
38
|
const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
|
|
23
39
|
|
|
24
|
-
|
|
40
|
+
/** True for the one confined shape this adapter enforces; throws on any other sandbox. */
|
|
41
|
+
export const isConfinedDsh = (environment = {}) => {
|
|
25
42
|
const sandbox = environment?.sandbox || {};
|
|
26
43
|
refuseWorkspaceTrust(sandbox, 'dsh');
|
|
27
44
|
if (effectiveSandboxTrust(sandbox.trust) === 'public') {
|
|
28
45
|
throw new Error('dsh adapter: public-trust seats are not supported; nested Seatbelt fails closed');
|
|
29
46
|
}
|
|
30
|
-
if (sandbox.mode
|
|
31
|
-
|
|
47
|
+
if (sandbox.mode === undefined) return false;
|
|
48
|
+
const { mode, network, ...rest } = sandbox;
|
|
49
|
+
const networkKeys = Object.keys(network || {});
|
|
50
|
+
if (mode === 'workspace' && network?.policy === 'restricted'
|
|
51
|
+
&& networkKeys.length === 1 && Object.keys(rest).length === 0) return true;
|
|
52
|
+
throw new Error(`dsh adapter: sandbox.mode=${mode} cannot be enforced by this adapter; `
|
|
53
|
+
+ "the one confined shape is { mode: 'workspace', network: { policy: 'restricted' } }");
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const assertPrivateDsh = (environment = {}) => { isConfinedDsh(environment); };
|
|
57
|
+
|
|
58
|
+
const onPath = (name, env) => {
|
|
59
|
+
if (isAbsolute(name)) return existsSync(name) ? name : null;
|
|
60
|
+
for (const dir of String(env?.PATH || '').split(delimiter)) {
|
|
61
|
+
if (dir && existsSync(join(dir, name))) return join(dir, name);
|
|
32
62
|
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The script a dsh executable runs, and the outermost node_modules holding it:
|
|
68
|
+
* a confined run starts that script with this node, and reads that tree only.
|
|
69
|
+
*/
|
|
70
|
+
export const resolveDshEntry = (binary, env) => {
|
|
71
|
+
const found = onPath(binary, env);
|
|
72
|
+
if (!found) throw new Error(`dsh adapter: a confined seat could not find ${binary} on PATH`);
|
|
73
|
+
const entry = realpathSync(found);
|
|
74
|
+
const parts = entry.split(sep);
|
|
75
|
+
const root = parts.indexOf('node_modules');
|
|
76
|
+
if (root < 1 || !/\.(?:c|m)?js$/.test(entry)) {
|
|
77
|
+
throw new Error(`dsh adapter: a confined seat runs dsh from its node package; ${found} resolves to ${entry}`);
|
|
78
|
+
}
|
|
79
|
+
return { entry, packages: parts.slice(0, root + 1).join(sep) };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Fail a confined turn whose provider traffic broke the model pin. */
|
|
83
|
+
export const assertServedModel = (check, credentials) => {
|
|
84
|
+
if (!check.refused && !check.failures.length) return;
|
|
85
|
+
const parts = [
|
|
86
|
+
...(check.refused ? [`${check.refused} request(s) for a model other than ${check.pinned} refused`] : []),
|
|
87
|
+
...check.failures,
|
|
88
|
+
];
|
|
89
|
+
throw adapterFailure('dsh', `served model check failed: ${parts.join('; ')}`, { credentials, limit: 500 });
|
|
33
90
|
};
|
|
34
91
|
|
|
35
92
|
/** A stable home per agent and Commonly instance, never the operator's ~/.dsh. */
|
|
@@ -71,9 +128,17 @@ export const resolveCommonlyMcp = (declared, ctx) => {
|
|
|
71
128
|
if (servers.length !== 1 || servers[0]?.name !== 'commonly') {
|
|
72
129
|
throw new Error('dsh adapter: PR 1 supports only the Commonly MCP server');
|
|
73
130
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
131
|
+
const isCommonly = (entry) => (entry.transport || 'stdio') === 'stdio'
|
|
132
|
+
&& isCommonlyCommand(entry.command) && !!describeMcpCommand(entry.command)?.isCommonly;
|
|
133
|
+
if (!isCommonly(servers[0])) {
|
|
134
|
+
throw new Error('dsh adapter: Commonly MCP must be a recognized stdio @commonlyai/mcp command');
|
|
135
|
+
}
|
|
136
|
+
// A confined seat runs the warmed build it can read, never a package runner,
|
|
137
|
+
// and that build is held to the same check as the declaration.
|
|
138
|
+
const server = ctx.prepareCommand
|
|
139
|
+
? { ...servers[0], command: ctx.prepareCommand(servers[0].command) }
|
|
140
|
+
: servers[0];
|
|
141
|
+
if (!isCommonly(server)) {
|
|
77
142
|
throw new Error('dsh adapter: Commonly MCP must be a recognized stdio @commonlyai/mcp command');
|
|
78
143
|
}
|
|
79
144
|
const delivery = deliverSeatCredential(server, {
|
|
@@ -101,7 +166,9 @@ export const resolveCommonlyMcp = (declared, ctx) => {
|
|
|
101
166
|
};
|
|
102
167
|
|
|
103
168
|
/** JSON is valid YAML; a generated patch has no expression or quoting ambiguity. */
|
|
104
|
-
export const buildPatch = ({
|
|
169
|
+
export const buildPatch = ({
|
|
170
|
+
proxyBaseURL, model, cwd, mcp, effort, deepseekOfficial = false, confined = false,
|
|
171
|
+
}) => ([
|
|
105
172
|
{ id: 'llm-pi-ai', config: { providers: {
|
|
106
173
|
commonly: {
|
|
107
174
|
apiKeyEnv: PROXY_KEY_ENV, api: 'openai-completions', baseURL: proxyBaseURL,
|
|
@@ -124,7 +191,9 @@ export const buildPatch = ({ proxyBaseURL, model, cwd, mcp, effort, deepseekOffi
|
|
|
124
191
|
{ id: 'llm-deepseek', disabled: true },
|
|
125
192
|
{ id: 'llm-deepseek-account', disabled: true },
|
|
126
193
|
{ id: 'session-log-deepseek', config: { enabled: false } },
|
|
127
|
-
|
|
194
|
+
// Confined, the outer Seatbelt is the sandbox: dsh's own would nest a second
|
|
195
|
+
// one, which macOS refuses, so it is off.
|
|
196
|
+
{ id: 'sandbox-policy', config: { mode: confined ? 'danger-full-access' : 'workspace-write', workspaceRoot: cwd } },
|
|
128
197
|
{ insert: [mcp] },
|
|
129
198
|
]);
|
|
130
199
|
|
|
@@ -169,8 +238,10 @@ const parseEvent = (line, state) => {
|
|
|
169
238
|
}
|
|
170
239
|
};
|
|
171
240
|
|
|
172
|
-
export const runDsh = ({
|
|
173
|
-
|
|
241
|
+
export const runDsh = ({
|
|
242
|
+
binary, args, cwd, env, timeoutMs, credentials, spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
243
|
+
}) => new Promise((resolve, reject) => {
|
|
244
|
+
const proc = spawnInOwnGroup(spawnImpl, binary, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
|
|
174
245
|
observeSpawn(proc, onSpawn, controls);
|
|
175
246
|
const state = { sessionId: null, final: null, error: null, buffer: '' };
|
|
176
247
|
let stderr = '';
|
|
@@ -252,7 +323,14 @@ export default {
|
|
|
252
323
|
|
|
253
324
|
async spawn(prompt, ctx = {}) {
|
|
254
325
|
const seatModel = requireSeatModel(ctx.environment, 'dsh', true);
|
|
255
|
-
|
|
326
|
+
const confined = isConfinedDsh(ctx.environment);
|
|
327
|
+
if (confined) {
|
|
328
|
+
if ((ctx._platform || process.platform) !== 'darwin') {
|
|
329
|
+
throw new Error('dsh adapter: a confined seat needs Seatbelt, which is macOS only');
|
|
330
|
+
}
|
|
331
|
+
const seatbelt = (ctx._detectSeatbelt || detectSeatbelt)();
|
|
332
|
+
if (!seatbelt.available) throw new Error(`dsh adapter: a confined seat needs Seatbelt: ${seatbelt.error}`);
|
|
333
|
+
}
|
|
256
334
|
if (!ctx.environment?.provider || !ctx.environment?.model) {
|
|
257
335
|
throw new Error('dsh requires environment.provider and environment.model');
|
|
258
336
|
}
|
|
@@ -270,19 +348,39 @@ export default {
|
|
|
270
348
|
const tempDir = await mkdtemp(join(tmpdir(), 'commonly-dsh-'));
|
|
271
349
|
await chmod(tempDir, 0o700);
|
|
272
350
|
let proxy;
|
|
351
|
+
let relay;
|
|
352
|
+
let egress;
|
|
273
353
|
try {
|
|
274
354
|
const credential = writeCredentialFile(ctx.runtimeToken, { agentName: 'commonly', root: tempDir });
|
|
275
355
|
proxy = await startProviderProxy({
|
|
276
356
|
provider: ctx.environment.provider, keyFile,
|
|
277
357
|
tokenFile: join(tempDir, 'provider-proxy-token'), adapterLabel: 'dsh',
|
|
358
|
+
...(confined ? { pinnedModel: seatModel } : {}),
|
|
278
359
|
});
|
|
279
360
|
const bearer = (await readFile(proxy.tokenFile, 'utf8')).trim();
|
|
361
|
+
const mcpDirectories = [];
|
|
362
|
+
if (confined) {
|
|
363
|
+
relay = await startInstanceProxy({ instanceUrl: ctx.instanceUrl });
|
|
364
|
+
egress = await (ctx._startEgressProxy || startEgressProxy)();
|
|
365
|
+
}
|
|
280
366
|
const mcp = resolveCommonlyMcp(ctx.environment?.mcp, {
|
|
281
|
-
credentialFile: credential.path,
|
|
367
|
+
credentialFile: credential.path,
|
|
368
|
+
instanceUrl: confined ? relay.url : ctx.instanceUrl,
|
|
369
|
+
...(confined ? {
|
|
370
|
+
prepareCommand: (command) => {
|
|
371
|
+
const warmed = (ctx._prepareMcpSpawn || prepareMcpSpawn)(command, { apiUrl: ctx.instanceUrl, publicSeat: true });
|
|
372
|
+
const directory = warmed && (ctx._selectedMcpVersionDir || selectedMcpVersionDir)(warmed);
|
|
373
|
+
if (!directory) {
|
|
374
|
+
throw new Error('dsh adapter: a confined seat runs only a warmed Commonly MCP build, and none is ready on this host');
|
|
375
|
+
}
|
|
376
|
+
mcpDirectories.push(directory);
|
|
377
|
+
return [process.execPath, warmed[1]];
|
|
378
|
+
},
|
|
379
|
+
} : {}),
|
|
282
380
|
});
|
|
283
381
|
const patchData = buildPatch({
|
|
284
382
|
proxyBaseURL: proxy.baseURL, model: seatModel,
|
|
285
|
-
cwd: ctx.cwd, mcp, effort: ctx.environment.effort,
|
|
383
|
+
cwd: ctx.cwd, mcp, effort: ctx.environment.effort, confined,
|
|
286
384
|
deepseekOfficial: new URL(ctx.environment.provider.baseURL).hostname === 'api.deepseek.com',
|
|
287
385
|
});
|
|
288
386
|
const patch = JSON.stringify(patchData, null, 2);
|
|
@@ -300,11 +398,51 @@ export default {
|
|
|
300
398
|
const requested = Number(ctx.timeoutMs);
|
|
301
399
|
const timeoutMs = Number.isFinite(requested) && requested > 0
|
|
302
400
|
? Math.min(requested, MAX_TIMEOUT_MS) : MAX_TIMEOUT_MS;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
401
|
+
let command = binary;
|
|
402
|
+
let commandArgs = args;
|
|
403
|
+
if (confined) {
|
|
404
|
+
const { entry, packages } = resolveDshEntry(binary, sourceEnv);
|
|
405
|
+
const tempWork = join(home, 'tmp');
|
|
406
|
+
await mkdir(tempWork, { recursive: true, mode: 0o700 });
|
|
407
|
+
env.TMPDIR = tempWork;
|
|
408
|
+
for (const name of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) env[name] = egress.url;
|
|
409
|
+
env.NO_PROXY = '127.0.0.1,localhost';
|
|
410
|
+
env.no_proxy = env.NO_PROXY;
|
|
411
|
+
[command, ...commandArgs] = (ctx._wrapArgvWithSeatbelt || wrapArgvWithSeatbelt)([process.execPath, entry, ...args], {
|
|
412
|
+
workspacePath: ctx.cwd,
|
|
413
|
+
workspaceAccess: 'write',
|
|
414
|
+
executablePath: process.execPath,
|
|
415
|
+
statePath: home,
|
|
416
|
+
mcpConfigDir: tempDir,
|
|
417
|
+
network: 'loopback',
|
|
418
|
+
loopbackNetworkPorts: [proxy.port, relay.port, egress.port],
|
|
419
|
+
readOnlyDirectories: [packages, ...mcpDirectories],
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const credentials = [...spawnCredentials(ctx, []), bearer];
|
|
423
|
+
let result;
|
|
424
|
+
let failure;
|
|
425
|
+
try {
|
|
426
|
+
result = await runDsh({
|
|
427
|
+
binary: command, args: commandArgs, cwd: ctx.cwd, env, timeoutMs, credentials,
|
|
428
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, ownGroup: wantsOwnProcessGroup(ctx),
|
|
429
|
+
controls: { model: passedModel || null, effort: passedProvider.reasoning || null },
|
|
430
|
+
});
|
|
431
|
+
} catch (error) {
|
|
432
|
+
failure = error;
|
|
433
|
+
}
|
|
434
|
+
// A broken pin is the reason a turn failed, whatever dsh said about it.
|
|
435
|
+
if (confined) {
|
|
436
|
+
const { refusals } = egress.stats();
|
|
437
|
+
// eslint-disable-next-line no-console
|
|
438
|
+
if (refusals.length) console.warn(`[dsh] egress refused: ${refusals.map((r) => `${r.target} (${r.reason})`).join(', ')}`);
|
|
439
|
+
assertServedModel(proxy.modelCheck(), credentials);
|
|
440
|
+
}
|
|
441
|
+
if (failure) throw failure;
|
|
442
|
+
return result;
|
|
307
443
|
} finally {
|
|
444
|
+
try { await egress?.close(); } catch { /* best effort */ }
|
|
445
|
+
try { await relay?.close(); } catch { /* best effort */ }
|
|
308
446
|
try { await proxy?.close(); } catch { /* best effort */ }
|
|
309
447
|
await rm(tempDir, { recursive: true, force: true });
|
|
310
448
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-spawn loopback CONNECT proxy that reaches only a fixed list of hosts.
|
|
3
|
+
*
|
|
4
|
+
* A confined run gets no outbound network of its own (Seatbelt `network:
|
|
5
|
+
* 'loopback'`); HTTPS_PROXY points it here. Only CONNECT to an allowlisted
|
|
6
|
+
* name on an allowlisted port is opened, and the name is resolved here and
|
|
7
|
+
* the connection made to the address resolved, so a name that resolves to
|
|
8
|
+
* loopback or a private network is refused rather than followed. Plain HTTP
|
|
9
|
+
* requests are refused: the proxy never sees the content of what it carries.
|
|
10
|
+
*
|
|
11
|
+
* There is no proxy credential. Everything the proxy reaches, any process on
|
|
12
|
+
* the host already reaches; it gives the confined run a route, not authority.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'http';
|
|
15
|
+
import { BlockList, connect, isIP } from 'net';
|
|
16
|
+
import { lookup } from 'dns/promises';
|
|
17
|
+
|
|
18
|
+
// GitHub's download hosts only. github.com and api.github.com accept writes
|
|
19
|
+
// (git push, gists, issues) made with any token a run is handed, and a tunnel
|
|
20
|
+
// cannot tell a read from a write, so they would be an outbound route for
|
|
21
|
+
// anything the run can read. These hosts only serve content.
|
|
22
|
+
export const GITHUB_CONTENT_HOSTS = Object.freeze([
|
|
23
|
+
'codeload.github.com',
|
|
24
|
+
'raw.githubusercontent.com',
|
|
25
|
+
'objects.githubusercontent.com',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
29
|
+
const MAX_REFUSALS_KEPT = 20;
|
|
30
|
+
|
|
31
|
+
const nonPublic = new BlockList();
|
|
32
|
+
for (const [address, prefix] of [
|
|
33
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
34
|
+
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16],
|
|
35
|
+
['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
36
|
+
]) nonPublic.addSubnet(address, prefix, 'ipv4');
|
|
37
|
+
for (const [address, prefix] of [
|
|
38
|
+
['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['64:ff9b::', 96],
|
|
39
|
+
]) nonPublic.addSubnet(address, prefix, 'ipv6');
|
|
40
|
+
|
|
41
|
+
/** True for an address no public host name should resolve to. */
|
|
42
|
+
export const isNonPublicAddress = (address) => {
|
|
43
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
44
|
+
if (mapped) return nonPublic.check(mapped[1], 'ipv4');
|
|
45
|
+
const family = isIP(address);
|
|
46
|
+
if (!family) return true;
|
|
47
|
+
return nonPublic.check(address, family === 6 ? 'ipv6' : 'ipv4');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** `host:port` from a CONNECT target, or null when it is not one. */
|
|
51
|
+
const parseTarget = (target) => {
|
|
52
|
+
const match = /^([A-Za-z0-9.-]+):(\d{1,5})$/.exec(target || '');
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const port = Number(match[2]);
|
|
55
|
+
if (port < 1 || port > 65535) return null;
|
|
56
|
+
return { host: match[1].toLowerCase().replace(/\.$/, ''), port };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* startEgressProxy({ allowHosts?, allowPorts? }) → { port, url, refusals, close }
|
|
61
|
+
*
|
|
62
|
+
* Listens on 127.0.0.1 at an ephemeral port. `_resolve` and `_allowPrivate`
|
|
63
|
+
* exist for tests only, so a test can reach a loopback upstream.
|
|
64
|
+
*/
|
|
65
|
+
export const startEgressProxy = async ({
|
|
66
|
+
allowHosts = GITHUB_CONTENT_HOSTS,
|
|
67
|
+
allowPorts = [443],
|
|
68
|
+
_resolve = (host) => lookup(host, { all: true, verbatim: true }),
|
|
69
|
+
_allowPrivate = false,
|
|
70
|
+
} = {}) => {
|
|
71
|
+
const hosts = new Set(allowHosts.map((host) => String(host).toLowerCase()));
|
|
72
|
+
const ports = new Set(allowPorts);
|
|
73
|
+
if (!hosts.size || [...ports].some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
74
|
+
throw new Error('Egress proxy needs at least one host and valid TCP ports');
|
|
75
|
+
}
|
|
76
|
+
const refusals = [];
|
|
77
|
+
let opened = 0;
|
|
78
|
+
const refuse = (socket, status, target, reason) => {
|
|
79
|
+
refusals.push({ target, reason });
|
|
80
|
+
if (refusals.length > MAX_REFUSALS_KEPT) refusals.shift();
|
|
81
|
+
if (!socket.destroyed) socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
82
|
+
};
|
|
83
|
+
const sockets = new Set();
|
|
84
|
+
const track = (socket) => {
|
|
85
|
+
sockets.add(socket);
|
|
86
|
+
socket.on('close', () => sockets.delete(socket));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const server = createServer((incoming, outgoing) => {
|
|
90
|
+
incoming.resume();
|
|
91
|
+
refusals.push({ target: String(incoming.url || '').slice(0, 200), reason: 'not CONNECT' });
|
|
92
|
+
if (refusals.length > MAX_REFUSALS_KEPT) refusals.shift();
|
|
93
|
+
outgoing.writeHead(405, { Connection: 'close' }).end();
|
|
94
|
+
});
|
|
95
|
+
server.on('connection', track);
|
|
96
|
+
server.on('connect', async (request, client, head) => {
|
|
97
|
+
client.on('error', () => {});
|
|
98
|
+
const target = parseTarget(request.url);
|
|
99
|
+
const label = String(request.url || '').slice(0, 200);
|
|
100
|
+
if (!target) { refuse(client, '400 Bad Request', label, 'malformed target'); return; }
|
|
101
|
+
if (isIP(target.host)) { refuse(client, '403 Forbidden', label, 'address literal'); return; }
|
|
102
|
+
if (!hosts.has(target.host) || !ports.has(target.port)) {
|
|
103
|
+
refuse(client, '403 Forbidden', label, 'not allowlisted');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let addresses;
|
|
107
|
+
try {
|
|
108
|
+
addresses = (await _resolve(target.host)).map((entry) => entry.address);
|
|
109
|
+
} catch {
|
|
110
|
+
refuse(client, '502 Bad Gateway', label, 'did not resolve');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Every address must be public, not just the one used: a name that
|
|
114
|
+
// resolves to both is not one this proxy was asked to reach.
|
|
115
|
+
if (!addresses.length || (!_allowPrivate && addresses.some(isNonPublicAddress))) {
|
|
116
|
+
refuse(client, '403 Forbidden', label, 'resolves to a non-public address');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (client.destroyed) return;
|
|
120
|
+
const upstream = connect({ host: addresses[0], port: target.port });
|
|
121
|
+
track(upstream);
|
|
122
|
+
let established = false;
|
|
123
|
+
client.on('close', () => upstream.destroy());
|
|
124
|
+
upstream.setTimeout(CONNECT_TIMEOUT_MS, () => upstream.destroy());
|
|
125
|
+
upstream.on('error', () => {});
|
|
126
|
+
upstream.on('close', () => {
|
|
127
|
+
if (established) client.destroy();
|
|
128
|
+
else refuse(client, '502 Bad Gateway', label, 'upstream unreachable');
|
|
129
|
+
});
|
|
130
|
+
upstream.once('connect', () => {
|
|
131
|
+
upstream.setTimeout(0);
|
|
132
|
+
established = true;
|
|
133
|
+
opened += 1;
|
|
134
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
135
|
+
if (head?.length) upstream.write(head);
|
|
136
|
+
upstream.pipe(client);
|
|
137
|
+
client.pipe(upstream);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await new Promise((resolve, reject) => {
|
|
142
|
+
server.once('error', reject);
|
|
143
|
+
server.listen(0, '127.0.0.1', () => {
|
|
144
|
+
server.removeListener('error', reject);
|
|
145
|
+
resolve();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
const { port } = server.address();
|
|
149
|
+
return {
|
|
150
|
+
port,
|
|
151
|
+
url: `http://127.0.0.1:${port}`,
|
|
152
|
+
/** { opened, refusals: [{ target, reason }] } — the last 20 refusals. */
|
|
153
|
+
stats: () => ({ opened, refusals: refusals.map((entry) => ({ ...entry })) }),
|
|
154
|
+
close: async () => {
|
|
155
|
+
const closed = new Promise((resolve) => server.close(resolve));
|
|
156
|
+
for (const socket of sockets) socket.destroy();
|
|
157
|
+
await closed;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A per-spawn relay to one declared Commonly instance origin, on a Unix
|
|
3
|
+
* socket, or on an ephemeral loopback TCP port when no socketPath is given
|
|
4
|
+
* (for a Seatbelt run whose only network is loopback).
|
|
5
|
+
*/
|
|
2
6
|
import { createServer, request as httpRequest } from 'http';
|
|
3
7
|
import { request as httpsRequest } from 'https';
|
|
4
8
|
import { chmod, unlink } from 'fs/promises';
|
|
@@ -48,20 +52,25 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => {
|
|
|
48
52
|
});
|
|
49
53
|
await new Promise((resolve, reject) => {
|
|
50
54
|
server.once('error', reject);
|
|
51
|
-
|
|
55
|
+
const listening = () => {
|
|
52
56
|
server.removeListener('error', reject);
|
|
53
57
|
resolve();
|
|
54
|
-
}
|
|
58
|
+
};
|
|
59
|
+
if (socketPath) server.listen(socketPath, listening);
|
|
60
|
+
else server.listen(0, '127.0.0.1', listening);
|
|
55
61
|
});
|
|
56
|
-
await chmod(socketPath, 0o600);
|
|
62
|
+
if (socketPath) await chmod(socketPath, 0o600);
|
|
63
|
+
const port = socketPath ? null : server.address().port;
|
|
57
64
|
return {
|
|
58
|
-
socketPath,
|
|
65
|
+
socketPath: socketPath || null,
|
|
66
|
+
port,
|
|
67
|
+
url: port ? `http://127.0.0.1:${port}` : null,
|
|
59
68
|
close: async () => {
|
|
60
69
|
const closed = new Promise((resolve) => server.close(resolve));
|
|
61
70
|
for (const upstream of upstreams) upstream.destroy();
|
|
62
71
|
server.closeAllConnections();
|
|
63
72
|
await closed;
|
|
64
|
-
await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; });
|
|
73
|
+
if (socketPath) await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; });
|
|
65
74
|
},
|
|
66
75
|
};
|
|
67
76
|
};
|
|
@@ -274,7 +274,12 @@ const PLATFORM_BASELINE = String.raw`
|
|
|
274
274
|
; Apple's App Sandbox grants every app this same domain.
|
|
275
275
|
(allow user-preference-read
|
|
276
276
|
(preference-domain "kCFPreferencesAnyApplication"))
|
|
277
|
+
`;
|
|
277
278
|
|
|
279
|
+
// Open network, the tail of the default baseline. A `network: 'loopback'`
|
|
280
|
+
// profile leaves it out: no outbound except the loopback ports it lists, and
|
|
281
|
+
// none of the Security framework services the keychain is reached through.
|
|
282
|
+
const OPEN_NETWORK = String.raw`
|
|
278
283
|
; The selected provider and declared MCP transports need outbound network. The model's
|
|
279
284
|
; WebSearch/WebFetch/Bash tools are denied independently in the adapter argv.
|
|
280
285
|
(allow network-outbound)
|
|
@@ -367,6 +372,8 @@ export const buildSeatbeltProfile = ({
|
|
|
367
372
|
readOnlyDirectories = [],
|
|
368
373
|
loopbackNetworkPorts = [],
|
|
369
374
|
allowClaudeRuntimeAccess = false,
|
|
375
|
+
// 'open' (the default) or 'loopback': only the listed loopback ports.
|
|
376
|
+
network = 'open',
|
|
370
377
|
// Names directly inside a writable workspace that stay read-only. A
|
|
371
378
|
// workspace seat granted write keeps its project's .git read-only, so every
|
|
372
379
|
// change it makes is one `git status` shows the person (TASK-294).
|
|
@@ -387,6 +394,9 @@ export const buildSeatbeltProfile = ({
|
|
|
387
394
|
throw new Error('Seatbelt loopbackNetworkPorts entries must be valid TCP ports');
|
|
388
395
|
}
|
|
389
396
|
}
|
|
397
|
+
if (!['open', 'loopback'].includes(network)) {
|
|
398
|
+
throw new Error('Seatbelt network must be open or loopback');
|
|
399
|
+
}
|
|
390
400
|
if (!['read', 'write'].includes(workspaceAccess)) {
|
|
391
401
|
throw new Error('Seatbelt workspaceAccess must be read or write');
|
|
392
402
|
}
|
|
@@ -456,7 +466,7 @@ export const buildSeatbeltProfile = ({
|
|
|
456
466
|
})() : [];
|
|
457
467
|
|
|
458
468
|
return [
|
|
459
|
-
PLATFORM_BASELINE.trim(),
|
|
469
|
+
(network === 'open' ? `${PLATFORM_BASELINE}${OPEN_NETWORK}` : PLATFORM_BASELINE).trim(),
|
|
460
470
|
executableRules,
|
|
461
471
|
executableSymlinkRules,
|
|
462
472
|
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(resolvedWorkspace)}"))`,
|