@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MCP AUTH PROXY — extracted from 20x `src/main/mcp-auth-proxy.ts`.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS, unchanged from the original: ACP, OpenCode and Claude Code all
|
|
6
|
+
* bake MCP server headers at SESSION start and none of them refresh those
|
|
7
|
+
* headers mid-session. A one-hour token therefore expires during a long agent
|
|
8
|
+
* run, and every MCP tool call after that point returns 401 with no recovery
|
|
9
|
+
* path — the session is alive, the model keeps trying, and every tool fails
|
|
10
|
+
* until a human notices. This proxy decouples token lifetime from session
|
|
11
|
+
* lifetime by fetching a fresh token PER REQUEST.
|
|
12
|
+
*
|
|
13
|
+
* AND IT MATTERS EVEN MORE ON A RUNNER. The agent is pointed at
|
|
14
|
+
* `127.0.0.1:<port>/<id>`, so the real gateway URL and the real token never
|
|
15
|
+
* appear in an MCP config file, in a backend's session state, or in anything
|
|
16
|
+
* the agent can read. The token is in this process's memory and nowhere else.
|
|
17
|
+
*
|
|
18
|
+
* WHAT CHANGED IN THE EXTRACTION
|
|
19
|
+
* - `enterpriseAuth.getJwt()` became the `TokenInjector` port, so the desktop
|
|
20
|
+
* passes its enterprise JWT and a runner passes the session-scoped MCP token
|
|
21
|
+
* from the lease.
|
|
22
|
+
* - `new Notification(...)` on a token failure became the `Notifier` port, a
|
|
23
|
+
* no-op on a server.
|
|
24
|
+
* - Module-level singletons (`let server`, `let port`, three Maps and a
|
|
25
|
+
* counter) became instance state. The original could only ever have ONE proxy
|
|
26
|
+
* per process, which is correct for a desktop with one signed-in user and
|
|
27
|
+
* wrong for a runner that may hold several sessions with DIFFERENT tokens.
|
|
28
|
+
* Sharing one proxy across sessions there would let session A's agent reach
|
|
29
|
+
* session B's targets by guessing a small integer id.
|
|
30
|
+
* - Ids are random, not `nextId++`. See `registerTarget`.
|
|
31
|
+
*
|
|
32
|
+
* ON "THE HARNESS MUST NEVER OPEN A LISTENING PORT": this binds 127.0.0.1 ONLY,
|
|
33
|
+
* exactly as the health check does. The property that rule protects is that
|
|
34
|
+
* nothing outside the host can initiate a connection to the harness, so a
|
|
35
|
+
* customer VM behind NAT needs no tunnel. A loopback socket is not reachable
|
|
36
|
+
* from the network and does not weaken it. Binding 0.0.0.0 here WOULD, and the
|
|
37
|
+
* test suite asserts the bind address.
|
|
38
|
+
*/
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.McpAuthProxy = void 0;
|
|
41
|
+
const node_http_1 = require("node:http");
|
|
42
|
+
const node_https_1 = require("node:https");
|
|
43
|
+
const node_crypto_1 = require("node:crypto");
|
|
44
|
+
class McpAuthProxy {
|
|
45
|
+
options;
|
|
46
|
+
server = null;
|
|
47
|
+
port = null;
|
|
48
|
+
targets = new Map();
|
|
49
|
+
/** Reverse lookup, so re-registering the same URL does not leak ids. */
|
|
50
|
+
idsByUrl = new Map();
|
|
51
|
+
/** One notification per integration per process, not one per failed call. */
|
|
52
|
+
notifiedFailures = new Set();
|
|
53
|
+
logger;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.logger = options.logger ?? console;
|
|
57
|
+
}
|
|
58
|
+
getPort() {
|
|
59
|
+
return this.port;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Register a real MCP URL and get back a loopback URL that injects a fresh
|
|
63
|
+
* token on every forwarded request. Returns null when the proxy is not running.
|
|
64
|
+
*
|
|
65
|
+
* IDS ARE RANDOM, where the 20x original used `String(nextId++)`. A
|
|
66
|
+
* monotonic counter is fine for a desktop with a single user, but on a runner
|
|
67
|
+
* holding several sessions it means session A can reach session B's targets by
|
|
68
|
+
* requesting `/1`, `/2`, `/3` — and the proxy would dutifully attach B's
|
|
69
|
+
* token. 16 random bytes makes that unguessable. It costs nothing.
|
|
70
|
+
*/
|
|
71
|
+
registerTarget(targetUrl, label) {
|
|
72
|
+
if (!this.port) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const existingId = this.idsByUrl.get(targetUrl);
|
|
76
|
+
if (existingId) {
|
|
77
|
+
if (label) {
|
|
78
|
+
const target = this.targets.get(existingId);
|
|
79
|
+
if (target) {
|
|
80
|
+
target.label = label;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `http://127.0.0.1:${this.port}/${existingId}`;
|
|
84
|
+
}
|
|
85
|
+
const id = (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
86
|
+
this.targets.set(id, { url: targetUrl, label: label || `target ${id}` });
|
|
87
|
+
this.idsByUrl.set(targetUrl, id);
|
|
88
|
+
return `http://127.0.0.1:${this.port}/${id}`;
|
|
89
|
+
}
|
|
90
|
+
unregisterTarget(proxyUrl) {
|
|
91
|
+
try {
|
|
92
|
+
const id = new URL(proxyUrl).pathname.split('/')[1];
|
|
93
|
+
const target = id ? this.targets.get(id) : undefined;
|
|
94
|
+
if (id && target) {
|
|
95
|
+
this.targets.delete(id);
|
|
96
|
+
this.idsByUrl.delete(target.url);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// A malformed URL means there is nothing to unregister.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
start() {
|
|
104
|
+
if (this.server && this.port) {
|
|
105
|
+
return Promise.resolve(this.port);
|
|
106
|
+
}
|
|
107
|
+
return new Promise((resolvePort, reject) => {
|
|
108
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
109
|
+
void this.handleRequest(req, res);
|
|
110
|
+
});
|
|
111
|
+
this.server = server;
|
|
112
|
+
server.once('error', reject);
|
|
113
|
+
// LOOPBACK ONLY. Never 0.0.0.0, never a fixed port.
|
|
114
|
+
server.listen(0, '127.0.0.1', () => {
|
|
115
|
+
const address = server.address();
|
|
116
|
+
if (typeof address === 'object' && address) {
|
|
117
|
+
this.port = address.port;
|
|
118
|
+
resolvePort(address.port);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
reject(new Error('MCP auth proxy could not determine its address'));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async stop() {
|
|
127
|
+
const server = this.server;
|
|
128
|
+
this.server = null;
|
|
129
|
+
this.port = null;
|
|
130
|
+
this.targets.clear();
|
|
131
|
+
this.idsByUrl.clear();
|
|
132
|
+
this.notifiedFailures.clear();
|
|
133
|
+
if (!server) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await new Promise((resolveClose) => {
|
|
137
|
+
server.close(() => resolveClose());
|
|
138
|
+
// A live SSE response holds the server open; MCP streams are long-lived,
|
|
139
|
+
// so a shutdown that waited for them would hang for ever.
|
|
140
|
+
server.closeAllConnections?.();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async handleRequest(req, res) {
|
|
144
|
+
try {
|
|
145
|
+
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
146
|
+
const pathParts = reqUrl.pathname.split('/').filter(Boolean);
|
|
147
|
+
const id = pathParts[0];
|
|
148
|
+
const target = id ? this.targets.get(id) : undefined;
|
|
149
|
+
if (!target) {
|
|
150
|
+
/*
|
|
151
|
+
* A TERMINAL ROUTING FAILURE, WORDED SO NOTHING MISREADS IT.
|
|
152
|
+
*
|
|
153
|
+
* This id is not registered with THIS proxy instance, so the MCP server
|
|
154
|
+
* is not routable for this session. It is NOT an auth challenge and NOT
|
|
155
|
+
* an allowlist denial (that is the gateway's `server_not_allowed`, a 403
|
|
156
|
+
* further upstream) — it is a registration/lifetime mismatch, and no
|
|
157
|
+
* amount of retrying resolves an id this proxy never issued.
|
|
158
|
+
*
|
|
159
|
+
* THREE THINGS THIS RESPONSE GETS RIGHT, each a bug it replaces:
|
|
160
|
+
* 1. A JSON body, not the bare text `Unknown proxy target`. OpenCode's
|
|
161
|
+
* MCP client treats a non-2xx during connect as a possible OAuth
|
|
162
|
+
* challenge and `JSON.parse`s the body; a plaintext body threw
|
|
163
|
+
* `SyntaxError: Unexpected identifier "Unknown"`, which is the
|
|
164
|
+
* "Invalid OAuth error response" a human then spent hours chasing.
|
|
165
|
+
* Valid JSON removes the parse crash. (The client STILL should not
|
|
166
|
+
* route a 404 into OAuth at all — that is an upstream defect,
|
|
167
|
+
* filed separately.)
|
|
168
|
+
* 2. `terminal: true` and an explicit "do not retry", because the
|
|
169
|
+
* agent that hit this reasoned it was a transient connectivity
|
|
170
|
+
* problem and burned turns waiting. An unroutable target never
|
|
171
|
+
* becomes routable by waiting.
|
|
172
|
+
* 3. A distinct `reason` so a filtered-out server and an unknown
|
|
173
|
+
* target are not indistinguishable at this layer.
|
|
174
|
+
*/
|
|
175
|
+
res.writeHead(404, {
|
|
176
|
+
'Content-Type': 'application/json',
|
|
177
|
+
'Cache-Control': 'no-store'
|
|
178
|
+
});
|
|
179
|
+
res.end(JSON.stringify({
|
|
180
|
+
error: 'unknown_proxy_target',
|
|
181
|
+
reason: 'unknown_proxy_target',
|
|
182
|
+
terminal: true,
|
|
183
|
+
retryable: false,
|
|
184
|
+
message: 'MCP loopback proxy: no target is registered for this id, so ' +
|
|
185
|
+
'this server is not routable for this session. This is a ' +
|
|
186
|
+
'terminal routing failure (not authentication, not an allowlist ' +
|
|
187
|
+
'denial) — do not retry.'
|
|
188
|
+
}));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const targetUrl = new URL(target.url);
|
|
192
|
+
const subPath = pathParts.slice(1);
|
|
193
|
+
if (subPath.length > 0) {
|
|
194
|
+
targetUrl.pathname =
|
|
195
|
+
targetUrl.pathname.replace(/\/$/, '') + '/' + subPath.join('/');
|
|
196
|
+
}
|
|
197
|
+
targetUrl.search = reqUrl.search;
|
|
198
|
+
let token;
|
|
199
|
+
try {
|
|
200
|
+
token = await this.options.tokens.getToken(target.url);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
this.reportTokenFailure(id, target.label, error);
|
|
204
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
205
|
+
res.end(JSON.stringify({
|
|
206
|
+
error: `Failed to obtain auth token for "${target.label}"`
|
|
207
|
+
}));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const forwardHeaders = {};
|
|
211
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
212
|
+
const lower = key.toLowerCase();
|
|
213
|
+
// `host` is rewritten below. `connection` is hop-by-hop and must not be
|
|
214
|
+
// forwarded. `authorization` is DROPPED rather than passed through:
|
|
215
|
+
// whatever the agent put there is not the credential we intend to use,
|
|
216
|
+
// and forwarding it would let a backend's own stale header win.
|
|
217
|
+
if (lower === 'host' ||
|
|
218
|
+
lower === 'connection' ||
|
|
219
|
+
lower === 'authorization') {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
forwardHeaders[key] = value;
|
|
223
|
+
}
|
|
224
|
+
// A null token means "this endpoint needs no Authorization header",
|
|
225
|
+
// which is a different thing from a failure and must stay expressible.
|
|
226
|
+
if (token !== null) {
|
|
227
|
+
forwardHeaders['authorization'] = `Bearer ${token}`;
|
|
228
|
+
}
|
|
229
|
+
forwardHeaders['host'] = targetUrl.host;
|
|
230
|
+
const isHttps = targetUrl.protocol === 'https:';
|
|
231
|
+
const send = isHttps ? node_https_1.request : node_http_1.request;
|
|
232
|
+
const proxyReq = send(targetUrl.toString(), { method: req.method, headers: forwardHeaders }, (proxyRes) => {
|
|
233
|
+
const responseHeaders = { ...proxyRes.headers };
|
|
234
|
+
const contentType = String(proxyRes.headers['content-type'] || '');
|
|
235
|
+
const isSSE = contentType.includes('text/event-stream');
|
|
236
|
+
if (!isSSE) {
|
|
237
|
+
// Node re-chunks the proxy→client leg itself. Kept for SSE so
|
|
238
|
+
// events flush immediately instead of being buffered — a buffered
|
|
239
|
+
// MCP stream looks exactly like a hung tool call.
|
|
240
|
+
delete responseHeaders['transfer-encoding'];
|
|
241
|
+
}
|
|
242
|
+
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
243
|
+
if (isSSE) {
|
|
244
|
+
proxyRes.on('data', (chunk) => {
|
|
245
|
+
res.write(chunk);
|
|
246
|
+
});
|
|
247
|
+
proxyRes.on('end', () => res.end());
|
|
248
|
+
proxyRes.on('error', () => res.end());
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
proxyRes.pipe(res);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
proxyReq.on('error', (error) => {
|
|
255
|
+
this.logger.error(`[McpAuthProxy] Upstream error for "${target.label}": ${error.message}`);
|
|
256
|
+
if (!res.headersSent) {
|
|
257
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
258
|
+
res.end(JSON.stringify({ error: `Upstream error: ${error.message}` }));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
res.end();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
req.pipe(proxyReq);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
this.logger.error('[McpAuthProxy] Unexpected error:', error);
|
|
268
|
+
if (!res.headersSent) {
|
|
269
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
270
|
+
res.end('Internal proxy error');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* One notification per TARGET per process — keyed on the target id, not on
|
|
276
|
+
* the human-readable label.
|
|
277
|
+
*
|
|
278
|
+
* Two different servers can be registered under the same label (a tenant with
|
|
279
|
+
* two Jira instances, say). Keyed on the label, the second one's auth failure
|
|
280
|
+
* would be silently swallowed for ever because the first had already "used
|
|
281
|
+
* up" the notification. The id is unique by construction.
|
|
282
|
+
*/
|
|
283
|
+
reportTokenFailure(id, label, error) {
|
|
284
|
+
this.logger.error(`[McpAuthProxy] Failed to get a token for "${label}":`, error);
|
|
285
|
+
if (this.notifiedFailures.has(id)) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
this.notifiedFailures.add(id);
|
|
289
|
+
try {
|
|
290
|
+
this.options.notifier?.notify({
|
|
291
|
+
title: 'MCP Auth Failed',
|
|
292
|
+
body: `Could not authenticate "${label}" — please sign in again.`
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// A notifier must never be able to break a request path.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
exports.McpAuthProxy = McpAuthProxy;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PERMISSION-MODE MAPPING — defect G1's other half.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. Two vocabularies meet here and they are not the same size.
|
|
5
|
+
*
|
|
6
|
+
* The LEASE carries what the user actually chose in the agent editor, which
|
|
7
|
+
* is Claude Code's four-valued vocabulary:
|
|
8
|
+
* 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
|
|
9
|
+
*
|
|
10
|
+
* The shared ADAPTER contract (`SessionConfig.permissionMode`) is two-valued:
|
|
11
|
+
* 'ask' | 'allow'
|
|
12
|
+
* because it has to mean the same thing to Pi, Claude Code, Codex and
|
|
13
|
+
* ACP, and only one of those four backends has a native notion of a plan mode.
|
|
14
|
+
*
|
|
15
|
+
* Until this module existed the lease value was simply DROPPED on the runner:
|
|
16
|
+
* `buildSessionConfig` never set `permissionMode`, so every session fell to the
|
|
17
|
+
* adapter default. That is defect G1 — a Permission control that is recorded,
|
|
18
|
+
* served, rendered, and enforced nowhere.
|
|
19
|
+
*
|
|
20
|
+
* THE RULE THE MAPPING OBEYS: **a mode may map to something STRICTER than the
|
|
21
|
+
* user asked for, never to something looser.** Everything that is not an
|
|
22
|
+
* explicit "stop asking me" maps to `'ask'`. `'allow'` is reachable from
|
|
23
|
+
* exactly one input, `bypassPermissions`, and from nothing else — including
|
|
24
|
+
* from an unrecognised value, which fails closed onto `'ask'`.
|
|
25
|
+
*
|
|
26
|
+
* ---------------------------------------------------------------------------
|
|
27
|
+
* THE TABLE, AND WHAT IS AND IS NOT FAITHFUL
|
|
28
|
+
* ---------------------------------------------------------------------------
|
|
29
|
+
*
|
|
30
|
+
* lease mode | permissionMode | sandboxMode | faithful?
|
|
31
|
+
* ---------------------|----------------|--------------------|-----------
|
|
32
|
+
* 'default' | 'ask' | 'workspace-write' | YES
|
|
33
|
+
* 'plan' | 'ask' | 'read-only' | partly
|
|
34
|
+
* 'acceptEdits' | 'ask' | 'workspace-write' | partly
|
|
35
|
+
* 'bypassPermissions' | 'allow' | 'danger-full-access'| YES
|
|
36
|
+
*
|
|
37
|
+
* `sandboxMode` is NOT decoration. It is the existing second field on
|
|
38
|
+
* `SessionConfig` that carries the part of the user's intent `'ask' | 'allow'`
|
|
39
|
+
* cannot hold, and it is what makes the mapping lossless for the backends that
|
|
40
|
+
* read it.
|
|
41
|
+
*
|
|
42
|
+
* PER BACKEND, SAID PLAINLY — this is the "say so explicitly rather than
|
|
43
|
+
* quietly collapsing them" the brief asks for:
|
|
44
|
+
*
|
|
45
|
+
* CLAUDE CODE — THREE OF FOUR. Its SDK natively takes all four values, so
|
|
46
|
+
* `claudeCodePermissionMode()` below reconstructs `default`, `plan` and
|
|
47
|
+
* `bypassPermissions` exactly. A user who picks `plan` gets Claude Code's
|
|
48
|
+
* real plan mode.
|
|
49
|
+
*
|
|
50
|
+
* `acceptEdits` IS THE EXCEPTION, AND IT IS LOST HERE RATHER THAN IN A
|
|
51
|
+
* BACKEND. An earlier version of this comment claimed Claude Code was
|
|
52
|
+
* lossless and "nothing is collapsed". THAT WAS WRONG, and the round-trip
|
|
53
|
+
* test quietly omitted `acceptEdits` rather than failing — which is how a
|
|
54
|
+
* false claim survives review. Corrected after it was caught.
|
|
55
|
+
*
|
|
56
|
+
* The reason is structural, not an oversight: `acceptEdits` and `default`
|
|
57
|
+
* both need `permissionMode: 'ask'` with a writable workspace, and the pair
|
|
58
|
+
* (`'ask' | 'allow'`, three sandbox modes) has no third slot to tell them
|
|
59
|
+
* apart. Distinguishing them would mean WIDENING the shared adapter contract,
|
|
60
|
+
* which 20x also uses — a bigger change than this defect warrants, and one
|
|
61
|
+
* the brief explicitly scoped out.
|
|
62
|
+
*
|
|
63
|
+
* So `acceptEdits` resolves to `default` on EVERY backend: the user is asked
|
|
64
|
+
* about edits they said they would accept. Interrupted more than they asked,
|
|
65
|
+
* never less — the same safe direction as everything else in this table, and
|
|
66
|
+
* the UI says so rather than implying a distinction that does not exist.
|
|
67
|
+
*
|
|
68
|
+
* CODEX — PARTLY. It reads `sandboxMode` directly, so `plan` really is
|
|
69
|
+
* read-only there, and its approval policy is `'never'` only for `'allow'`.
|
|
70
|
+
*
|
|
71
|
+
* PI — COLLAPSED, AND THIS IS THE ONE THAT MATTERS, because Pi is
|
|
72
|
+
* the only backend wired into the cloud runner today. Pi has no plan
|
|
73
|
+
* mode and no accept-edits mode; it has a permission prompt or it has none.
|
|
74
|
+
* So on Pi `plan` and `acceptEdits` are INDISTINGUISHABLE FROM
|
|
75
|
+
* `default`: all three prompt for everything.
|
|
76
|
+
*
|
|
77
|
+
* That collapse is deliberately in the SAFE direction. `acceptEdits` promises
|
|
78
|
+
* "do not ask me about edits", and on Pi we ask anyway — the user is
|
|
79
|
+
* interrupted more than they asked to be, never less. The alternative
|
|
80
|
+
* (mapping `acceptEdits` to `'allow'`) would honour the label by granting
|
|
81
|
+
* silent write access the user never asked for, which is the same class of
|
|
82
|
+
* defect as G1 itself. The agent editor says this out loud rather than
|
|
83
|
+
* letting the picker imply a distinction the runtime does not make.
|
|
84
|
+
*/
|
|
85
|
+
/** What the lease carries — the value stored on the agent definition. */
|
|
86
|
+
export type LeasePermissionMode = 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions';
|
|
87
|
+
/** What the shared adapter contract accepts. */
|
|
88
|
+
export type AdapterPermissionMode = 'ask' | 'allow';
|
|
89
|
+
export type AdapterSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
90
|
+
export interface MappedPermission {
|
|
91
|
+
permissionMode: AdapterPermissionMode;
|
|
92
|
+
sandboxMode: AdapterSandboxMode;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* THE ONLY PLACE the four-valued lease mode becomes the two-valued adapter
|
|
96
|
+
* mode. Deliberately total: an unknown value — a newer control plane, a hand-
|
|
97
|
+
* edited row — is not an error, it is `'ask'`, because the failure mode of
|
|
98
|
+
* guessing wrong in the other direction is an unattended agent with no prompt.
|
|
99
|
+
*/
|
|
100
|
+
export declare function mapPermissionMode(mode: LeasePermissionMode | string | null | undefined): MappedPermission;
|
|
101
|
+
/**
|
|
102
|
+
* The inverse, for Claude Code only — it is the one backend whose own SDK takes
|
|
103
|
+
* the four-valued vocabulary, so the pair that `mapPermissionMode` produced can
|
|
104
|
+
* be turned back into exactly what the user chose.
|
|
105
|
+
*
|
|
106
|
+
* This is why the mapping is lossless there and lossy on Pi: the loss is
|
|
107
|
+
* in the BACKEND, not in the wire.
|
|
108
|
+
*
|
|
109
|
+
* Absent `permissionMode` means the caller never set one. That is the pre-G1
|
|
110
|
+
* state and it must NOT silently become `bypassPermissions` again, so it maps
|
|
111
|
+
* to `'default'` — Claude Code's own asking mode.
|
|
112
|
+
*/
|
|
113
|
+
export declare function claudeCodePermissionMode(config: {
|
|
114
|
+
permissionMode?: AdapterPermissionMode;
|
|
115
|
+
sandboxMode?: AdapterSandboxMode;
|
|
116
|
+
}): LeasePermissionMode;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* THE PERMISSION-MODE MAPPING — defect G1's other half.
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM. Two vocabularies meet here and they are not the same size.
|
|
6
|
+
*
|
|
7
|
+
* The LEASE carries what the user actually chose in the agent editor, which
|
|
8
|
+
* is Claude Code's four-valued vocabulary:
|
|
9
|
+
* 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
|
|
10
|
+
*
|
|
11
|
+
* The shared ADAPTER contract (`SessionConfig.permissionMode`) is two-valued:
|
|
12
|
+
* 'ask' | 'allow'
|
|
13
|
+
* because it has to mean the same thing to Pi, Claude Code, Codex and
|
|
14
|
+
* ACP, and only one of those four backends has a native notion of a plan mode.
|
|
15
|
+
*
|
|
16
|
+
* Until this module existed the lease value was simply DROPPED on the runner:
|
|
17
|
+
* `buildSessionConfig` never set `permissionMode`, so every session fell to the
|
|
18
|
+
* adapter default. That is defect G1 — a Permission control that is recorded,
|
|
19
|
+
* served, rendered, and enforced nowhere.
|
|
20
|
+
*
|
|
21
|
+
* THE RULE THE MAPPING OBEYS: **a mode may map to something STRICTER than the
|
|
22
|
+
* user asked for, never to something looser.** Everything that is not an
|
|
23
|
+
* explicit "stop asking me" maps to `'ask'`. `'allow'` is reachable from
|
|
24
|
+
* exactly one input, `bypassPermissions`, and from nothing else — including
|
|
25
|
+
* from an unrecognised value, which fails closed onto `'ask'`.
|
|
26
|
+
*
|
|
27
|
+
* ---------------------------------------------------------------------------
|
|
28
|
+
* THE TABLE, AND WHAT IS AND IS NOT FAITHFUL
|
|
29
|
+
* ---------------------------------------------------------------------------
|
|
30
|
+
*
|
|
31
|
+
* lease mode | permissionMode | sandboxMode | faithful?
|
|
32
|
+
* ---------------------|----------------|--------------------|-----------
|
|
33
|
+
* 'default' | 'ask' | 'workspace-write' | YES
|
|
34
|
+
* 'plan' | 'ask' | 'read-only' | partly
|
|
35
|
+
* 'acceptEdits' | 'ask' | 'workspace-write' | partly
|
|
36
|
+
* 'bypassPermissions' | 'allow' | 'danger-full-access'| YES
|
|
37
|
+
*
|
|
38
|
+
* `sandboxMode` is NOT decoration. It is the existing second field on
|
|
39
|
+
* `SessionConfig` that carries the part of the user's intent `'ask' | 'allow'`
|
|
40
|
+
* cannot hold, and it is what makes the mapping lossless for the backends that
|
|
41
|
+
* read it.
|
|
42
|
+
*
|
|
43
|
+
* PER BACKEND, SAID PLAINLY — this is the "say so explicitly rather than
|
|
44
|
+
* quietly collapsing them" the brief asks for:
|
|
45
|
+
*
|
|
46
|
+
* CLAUDE CODE — THREE OF FOUR. Its SDK natively takes all four values, so
|
|
47
|
+
* `claudeCodePermissionMode()` below reconstructs `default`, `plan` and
|
|
48
|
+
* `bypassPermissions` exactly. A user who picks `plan` gets Claude Code's
|
|
49
|
+
* real plan mode.
|
|
50
|
+
*
|
|
51
|
+
* `acceptEdits` IS THE EXCEPTION, AND IT IS LOST HERE RATHER THAN IN A
|
|
52
|
+
* BACKEND. An earlier version of this comment claimed Claude Code was
|
|
53
|
+
* lossless and "nothing is collapsed". THAT WAS WRONG, and the round-trip
|
|
54
|
+
* test quietly omitted `acceptEdits` rather than failing — which is how a
|
|
55
|
+
* false claim survives review. Corrected after it was caught.
|
|
56
|
+
*
|
|
57
|
+
* The reason is structural, not an oversight: `acceptEdits` and `default`
|
|
58
|
+
* both need `permissionMode: 'ask'` with a writable workspace, and the pair
|
|
59
|
+
* (`'ask' | 'allow'`, three sandbox modes) has no third slot to tell them
|
|
60
|
+
* apart. Distinguishing them would mean WIDENING the shared adapter contract,
|
|
61
|
+
* which 20x also uses — a bigger change than this defect warrants, and one
|
|
62
|
+
* the brief explicitly scoped out.
|
|
63
|
+
*
|
|
64
|
+
* So `acceptEdits` resolves to `default` on EVERY backend: the user is asked
|
|
65
|
+
* about edits they said they would accept. Interrupted more than they asked,
|
|
66
|
+
* never less — the same safe direction as everything else in this table, and
|
|
67
|
+
* the UI says so rather than implying a distinction that does not exist.
|
|
68
|
+
*
|
|
69
|
+
* CODEX — PARTLY. It reads `sandboxMode` directly, so `plan` really is
|
|
70
|
+
* read-only there, and its approval policy is `'never'` only for `'allow'`.
|
|
71
|
+
*
|
|
72
|
+
* PI — COLLAPSED, AND THIS IS THE ONE THAT MATTERS, because Pi is
|
|
73
|
+
* the only backend wired into the cloud runner today. Pi has no plan
|
|
74
|
+
* mode and no accept-edits mode; it has a permission prompt or it has none.
|
|
75
|
+
* So on Pi `plan` and `acceptEdits` are INDISTINGUISHABLE FROM
|
|
76
|
+
* `default`: all three prompt for everything.
|
|
77
|
+
*
|
|
78
|
+
* That collapse is deliberately in the SAFE direction. `acceptEdits` promises
|
|
79
|
+
* "do not ask me about edits", and on Pi we ask anyway — the user is
|
|
80
|
+
* interrupted more than they asked to be, never less. The alternative
|
|
81
|
+
* (mapping `acceptEdits` to `'allow'`) would honour the label by granting
|
|
82
|
+
* silent write access the user never asked for, which is the same class of
|
|
83
|
+
* defect as G1 itself. The agent editor says this out loud rather than
|
|
84
|
+
* letting the picker imply a distinction the runtime does not make.
|
|
85
|
+
*/
|
|
86
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
87
|
+
exports.mapPermissionMode = mapPermissionMode;
|
|
88
|
+
exports.claudeCodePermissionMode = claudeCodePermissionMode;
|
|
89
|
+
/**
|
|
90
|
+
* THE ONLY PLACE the four-valued lease mode becomes the two-valued adapter
|
|
91
|
+
* mode. Deliberately total: an unknown value — a newer control plane, a hand-
|
|
92
|
+
* edited row — is not an error, it is `'ask'`, because the failure mode of
|
|
93
|
+
* guessing wrong in the other direction is an unattended agent with no prompt.
|
|
94
|
+
*/
|
|
95
|
+
function mapPermissionMode(mode) {
|
|
96
|
+
switch (mode) {
|
|
97
|
+
case 'bypassPermissions':
|
|
98
|
+
return {
|
|
99
|
+
permissionMode: 'allow',
|
|
100
|
+
sandboxMode: 'danger-full-access'
|
|
101
|
+
};
|
|
102
|
+
case 'plan':
|
|
103
|
+
return { permissionMode: 'ask', sandboxMode: 'read-only' };
|
|
104
|
+
case 'acceptEdits':
|
|
105
|
+
return { permissionMode: 'ask', sandboxMode: 'workspace-write' };
|
|
106
|
+
case 'default':
|
|
107
|
+
return { permissionMode: 'ask', sandboxMode: 'workspace-write' };
|
|
108
|
+
default:
|
|
109
|
+
// Unrecognised, absent, null. Ask. See the rule above.
|
|
110
|
+
return { permissionMode: 'ask', sandboxMode: 'workspace-write' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The inverse, for Claude Code only — it is the one backend whose own SDK takes
|
|
115
|
+
* the four-valued vocabulary, so the pair that `mapPermissionMode` produced can
|
|
116
|
+
* be turned back into exactly what the user chose.
|
|
117
|
+
*
|
|
118
|
+
* This is why the mapping is lossless there and lossy on Pi: the loss is
|
|
119
|
+
* in the BACKEND, not in the wire.
|
|
120
|
+
*
|
|
121
|
+
* Absent `permissionMode` means the caller never set one. That is the pre-G1
|
|
122
|
+
* state and it must NOT silently become `bypassPermissions` again, so it maps
|
|
123
|
+
* to `'default'` — Claude Code's own asking mode.
|
|
124
|
+
*/
|
|
125
|
+
function claudeCodePermissionMode(config) {
|
|
126
|
+
if (config.permissionMode === 'allow') {
|
|
127
|
+
return 'bypassPermissions';
|
|
128
|
+
}
|
|
129
|
+
if (config.sandboxMode === 'read-only') {
|
|
130
|
+
return 'plan';
|
|
131
|
+
}
|
|
132
|
+
if (config.sandboxMode === 'workspace-write') {
|
|
133
|
+
/*
|
|
134
|
+
* `acceptEdits` AND `default` SHARE THIS PAIR, so this direction cannot
|
|
135
|
+
* tell them apart — which means `acceptEdits` is UNREACHABLE from here and
|
|
136
|
+
* no backend ever receives it. That is a real, deliberate loss, described
|
|
137
|
+
* in the header and surfaced to the user in the agent editor; it is not a
|
|
138
|
+
* gap someone forgot to close.
|
|
139
|
+
*
|
|
140
|
+
* `default` is the stricter of the two, so it is the right answer when the
|
|
141
|
+
* pair is ambiguous.
|
|
142
|
+
*/
|
|
143
|
+
return 'default';
|
|
144
|
+
}
|
|
145
|
+
return 'default';
|
|
146
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROJECTION — adapter message parts in, transcript parts out.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the single chokepoint in 20x `agent-manager.ts`
|
|
5
|
+
* (`persistTranscriptEvent`, :5269) through which every piece of live streaming
|
|
6
|
+
* output passes on its way to the durable log. Keeping it a chokepoint is the
|
|
7
|
+
* whole reason the 20x transcript is reliable, and it is reproduced here for the
|
|
8
|
+
* same reason: one function decides what a transcript part IS, so a new backend
|
|
9
|
+
* cannot invent a second answer.
|
|
10
|
+
*
|
|
11
|
+
* THREE FILTERS, IN ORDER, each of which exists because of a real defect:
|
|
12
|
+
*
|
|
13
|
+
* 1. NO ID, NO PART. The upsert is keyed on (sessionId, partId); a part with no
|
|
14
|
+
* id has no identity, so a second delta for it would insert a duplicate
|
|
15
|
+
* instead of updating the first.
|
|
16
|
+
*
|
|
17
|
+
* 2. THE EPHEMERAL THREE ARE DROPPED. 'step-start', 'step-finish' and
|
|
18
|
+
* 'system-status' are UI-only frame markers. Persisting them would put
|
|
19
|
+
* thousands of meaningless rows in every transcript and stream each one to
|
|
20
|
+
* every reader. Their actual information — duration, token counts — belongs
|
|
21
|
+
* in `TurnMetadata`, which is what closes defect G7.
|
|
22
|
+
*
|
|
23
|
+
* The check goes through the SHARED predicate `isEphemeralPartType`, which
|
|
24
|
+
* trims and lowercases. A backend emitting ' Step-Start ' must not slip past
|
|
25
|
+
* a case-sensitive comparison and corrupt the projection permanently.
|
|
26
|
+
*
|
|
27
|
+
* 3. AN EMPTY PART IS DROPPED. The opening frame of a text part that never
|
|
28
|
+
* received a delta is a stream artefact, and storing it puts a blank bubble
|
|
29
|
+
* in the UI for ever. A part with a payload and no content is KEPT — a tool
|
|
30
|
+
* call with arguments and no textual body is perfectly ordinary.
|
|
31
|
+
*/
|
|
32
|
+
import { StoredPartInput } from '../ports';
|
|
33
|
+
import { TranscriptPartInput } from '../types';
|
|
34
|
+
/**
|
|
35
|
+
* The adapter-side part shape.
|
|
36
|
+
*
|
|
37
|
+
* Structurally typed against `MessagePart` from the adapter interface rather
|
|
38
|
+
* than imported from it. The projection must accept a part from ANY backend,
|
|
39
|
+
* including one added later that this package has never seen, so it depends on
|
|
40
|
+
* the fields it reads and on nothing else.
|
|
41
|
+
*/
|
|
42
|
+
export interface ProjectableMessagePart {
|
|
43
|
+
id?: string;
|
|
44
|
+
role?: string;
|
|
45
|
+
type?: string;
|
|
46
|
+
text?: string;
|
|
47
|
+
content?: string;
|
|
48
|
+
partType?: string;
|
|
49
|
+
tool?: unknown;
|
|
50
|
+
questions?: unknown;
|
|
51
|
+
todos?: unknown;
|
|
52
|
+
taskProgress?: unknown;
|
|
53
|
+
/** Unix ms when the event was ORIGINALLY received. Preserved through replay. */
|
|
54
|
+
receivedAt?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Project a batch of adapter parts into transcript parts.
|
|
58
|
+
*
|
|
59
|
+
* Returns only the parts worth storing. An empty result is an ordinary outcome
|
|
60
|
+
* — a poll that saw nothing but step markers — and is NOT an error.
|
|
61
|
+
*/
|
|
62
|
+
export declare function projectParts(raw: ReadonlyArray<ProjectableMessagePart | null | undefined>): StoredPartInput[];
|
|
63
|
+
/**
|
|
64
|
+
* Convert a stored part into the wire shape for `POST /ingest`.
|
|
65
|
+
*
|
|
66
|
+
* `seq` and `rev` are dropped: they are SERVER-ALLOCATED, and a runner that
|
|
67
|
+
* could send them could fabricate an ordering or replay an old rev over a newer
|
|
68
|
+
* one. The contract's `TranscriptPartInput` has no field for either, so this is
|
|
69
|
+
* enforced by the type rather than by a convention someone must remember.
|
|
70
|
+
*/
|
|
71
|
+
export declare function toIngestPart(part: StoredPartInput): TranscriptPartInput;
|