llm-cli-gateway 2.4.0 → 2.5.0

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.
@@ -0,0 +1,38 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Logger } from "./logger.js";
3
+ import { type RemoteOAuthConfig } from "./auth.js";
4
+ export interface OAuthServerOptions {
5
+ protectedPath: string;
6
+ config: RemoteOAuthConfig;
7
+ logger?: Logger;
8
+ }
9
+ export interface OAuthRequestContext {
10
+ req: IncomingMessage;
11
+ res: ServerResponse;
12
+ url: URL;
13
+ baseUrl: string;
14
+ }
15
+ export declare const OAUTH_CODE_TTL_MS: number;
16
+ export declare function generateSecret(bytes?: number): string;
17
+ export declare function hashSecret(secret: string): string;
18
+ export declare function isSecretHash(value: string): boolean;
19
+ export declare function verifySecret(secret: string, encodedHash: string): boolean;
20
+ export declare function redactSecret(value: string | null | undefined): string | null;
21
+ export declare function isLocalHost(host: string): boolean;
22
+ export declare function oauthBaseUrlFromRequest(req: IncomingMessage, config: RemoteOAuthConfig): string | null;
23
+ export declare class OAuthServer {
24
+ private readonly opts;
25
+ private readonly codes;
26
+ private readonly clients;
27
+ constructor(opts: OAuthServerOptions);
28
+ resourceMetadataUrl(baseUrl: string): string;
29
+ isOAuthPath(pathname: string): boolean;
30
+ handle(ctx: OAuthRequestContext): Promise<boolean>;
31
+ private protectedResourceMetadata;
32
+ private authorizationServerMetadata;
33
+ private registrationAllowedByPolicy;
34
+ private handleRegister;
35
+ private handleAuthorize;
36
+ private handleToken;
37
+ private pruneExpiredCodes;
38
+ }
package/dist/oauth.js ADDED
@@ -0,0 +1,441 @@
1
+ import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
+ import { URLSearchParams } from "node:url";
3
+ import { issueOAuthAccessToken, timingSafeStringEqual, } from "./auth.js";
4
+ export const OAUTH_CODE_TTL_MS = 5 * 60 * 1000;
5
+ const GENERATED_SECRET_BYTES = 32;
6
+ const SCRYPT_N = 32768;
7
+ const SCRYPT_R = 8;
8
+ const SCRYPT_P = 1;
9
+ const SCRYPT_KEYLEN = 32;
10
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024;
11
+ export function generateSecret(bytes = GENERATED_SECRET_BYTES) {
12
+ return randomBytes(bytes).toString("base64url");
13
+ }
14
+ export function hashSecret(secret) {
15
+ const salt = randomBytes(16);
16
+ const hash = scryptSync(secret, salt, SCRYPT_KEYLEN, {
17
+ N: SCRYPT_N,
18
+ r: SCRYPT_R,
19
+ p: SCRYPT_P,
20
+ maxmem: SCRYPT_MAXMEM,
21
+ });
22
+ return `scrypt:N=${SCRYPT_N},r=${SCRYPT_R},p=${SCRYPT_P}:${salt.toString("base64url")}:${hash.toString("base64url")}`;
23
+ }
24
+ export function isSecretHash(value) {
25
+ return /^scrypt:N=\d+,r=\d+,p=\d+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(value);
26
+ }
27
+ export function verifySecret(secret, encodedHash) {
28
+ const parts = encodedHash.split(":");
29
+ if (parts.length !== 4 || parts[0] !== "scrypt")
30
+ return false;
31
+ const params = Object.fromEntries(parts[1].split(",").map(entry => {
32
+ const [key, value] = entry.split("=");
33
+ return [key, Number(value)];
34
+ }));
35
+ if (!params.N || !params.r || !params.p)
36
+ return false;
37
+ const salt = Buffer.from(parts[2], "base64url");
38
+ const expected = Buffer.from(parts[3], "base64url");
39
+ const actual = scryptSync(secret, salt, expected.length, {
40
+ N: params.N,
41
+ r: params.r,
42
+ p: params.p,
43
+ maxmem: SCRYPT_MAXMEM,
44
+ });
45
+ if (actual.length !== expected.length)
46
+ return false;
47
+ return timingSafeEqual(actual, expected);
48
+ }
49
+ export function redactSecret(value) {
50
+ return value ? "<redacted>" : null;
51
+ }
52
+ function firstHeader(value) {
53
+ return Array.isArray(value) ? value[0] : value;
54
+ }
55
+ function methodNotAllowed(res) {
56
+ res.writeHead(405, { allow: "GET, POST", "content-type": "application/json" });
57
+ res.end(JSON.stringify({ error: "Method not allowed" }));
58
+ }
59
+ function jsonResponse(res, status, body) {
60
+ res.writeHead(status, { "content-type": "application/json" });
61
+ res.end(JSON.stringify(body));
62
+ }
63
+ function isHttpsOrLoopbackUrl(value) {
64
+ try {
65
+ const url = new URL(value);
66
+ if (url.protocol === "https:")
67
+ return true;
68
+ if (url.protocol !== "http:")
69
+ return false;
70
+ return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ export function isLocalHost(host) {
77
+ const hostname = host.split(":")[0]?.toLowerCase() ?? "";
78
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
79
+ }
80
+ export function oauthBaseUrlFromRequest(req, config) {
81
+ if (config.issuer && config.issuer !== "auto") {
82
+ try {
83
+ return new URL(config.issuer).origin;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ const configured = process.env.LLM_GATEWAY_PUBLIC_URL;
90
+ if (configured) {
91
+ try {
92
+ return new URL(configured).origin;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ const host = firstHeader(req.headers.host) ?? "127.0.0.1:3333";
99
+ if (!isLocalHost(host))
100
+ return null;
101
+ return `http://${host}`;
102
+ }
103
+ function extractStringArray(value, params, key) {
104
+ const values = Array.isArray(value) ? value : params.getAll(key);
105
+ return values.filter((item) => typeof item === "string" && item.length > 0);
106
+ }
107
+ async function readRawBody(req) {
108
+ return new Promise((resolve, reject) => {
109
+ const chunks = [];
110
+ req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
111
+ req.on("error", reject);
112
+ req.on("end", () => resolve(chunks.length ? Buffer.concat(chunks).toString("utf8") : ""));
113
+ });
114
+ }
115
+ async function readOAuthBody(req) {
116
+ const raw = await readRawBody(req);
117
+ const contentType = firstHeader(req.headers["content-type"]) ?? "";
118
+ if (contentType.includes("application/json")) {
119
+ const parsed = JSON.parse(raw || "{}");
120
+ const params = new URLSearchParams();
121
+ for (const [key, value] of Object.entries(parsed)) {
122
+ if (typeof value === "string")
123
+ params.set(key, value);
124
+ else if (Array.isArray(value)) {
125
+ for (const item of value) {
126
+ if (typeof item === "string")
127
+ params.append(key, item);
128
+ }
129
+ }
130
+ }
131
+ return { params, json: parsed };
132
+ }
133
+ return { params: new URLSearchParams(raw), json: {} };
134
+ }
135
+ function basicClientCredentials(req) {
136
+ const authorization = firstHeader(req.headers.authorization);
137
+ if (!authorization?.startsWith("Basic "))
138
+ return null;
139
+ const raw = Buffer.from(authorization.slice("Basic ".length), "base64").toString("utf8");
140
+ const separator = raw.indexOf(":");
141
+ if (separator < 0)
142
+ return null;
143
+ return {
144
+ clientId: decodeURIComponent(raw.slice(0, separator)),
145
+ clientSecret: decodeURIComponent(raw.slice(separator + 1)),
146
+ };
147
+ }
148
+ function oauthClientSecret(req, params) {
149
+ return params.get("client_secret") ?? basicClientCredentials(req)?.clientSecret ?? null;
150
+ }
151
+ function oauthClientId(req, params) {
152
+ return params.get("client_id") ?? basicClientCredentials(req)?.clientId ?? null;
153
+ }
154
+ function validPkceVerifier(verifier, challenge, method) {
155
+ if (!challenge)
156
+ return true;
157
+ if (!verifier)
158
+ return false;
159
+ if (method === "S256") {
160
+ const digest = createHash("sha256").update(verifier).digest("base64url");
161
+ return timingSafeStringEqual(digest, challenge);
162
+ }
163
+ if (!method || method === "plain") {
164
+ return timingSafeStringEqual(verifier, challenge);
165
+ }
166
+ return false;
167
+ }
168
+ function oauthErrorRedirect(redirectUri, error, state) {
169
+ const target = new URL(redirectUri);
170
+ target.searchParams.set("error", error);
171
+ if (state)
172
+ target.searchParams.set("state", state);
173
+ return target.toString();
174
+ }
175
+ function normalizeScopes(scope) {
176
+ const scopes = (scope ?? "mcp")
177
+ .split(/\s+/)
178
+ .map(item => item.trim())
179
+ .filter(Boolean);
180
+ return [...new Set(scopes.length ? scopes : ["mcp"])];
181
+ }
182
+ function scopesAllowed(requested, client) {
183
+ return requested.every(scope => client.scopes.has(scope));
184
+ }
185
+ function toRuntimeClient(client, allowPublicClients) {
186
+ return {
187
+ clientId: client.clientId,
188
+ clientSecretHash: client.clientSecretHash ?? null,
189
+ redirectUris: new Set(client.allowedRedirectUris),
190
+ scopes: new Set(client.scopes.length ? client.scopes : ["mcp"]),
191
+ issuedAt: Math.floor(Date.now() / 1000),
192
+ publicClient: allowPublicClients && !client.clientSecretHash,
193
+ };
194
+ }
195
+ export class OAuthServer {
196
+ opts;
197
+ codes = new Map();
198
+ clients = new Map();
199
+ constructor(opts) {
200
+ this.opts = opts;
201
+ for (const client of opts.config.clients) {
202
+ this.clients.set(client.clientId, toRuntimeClient(client, opts.config.allowPublicClients));
203
+ }
204
+ }
205
+ resourceMetadataUrl(baseUrl) {
206
+ return `${baseUrl}/.well-known/oauth-protected-resource`;
207
+ }
208
+ isOAuthPath(pathname) {
209
+ return (pathname.startsWith("/.well-known/oauth-protected-resource") ||
210
+ pathname.startsWith("/.well-known/oauth-authorization-server") ||
211
+ pathname === "/.well-known/openid-configuration" ||
212
+ pathname.startsWith("/oauth/"));
213
+ }
214
+ async handle(ctx) {
215
+ const { req, res, url, baseUrl } = ctx;
216
+ if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
217
+ if (req.method !== "GET") {
218
+ methodNotAllowed(res);
219
+ return true;
220
+ }
221
+ jsonResponse(res, 200, this.protectedResourceMetadata(baseUrl));
222
+ return true;
223
+ }
224
+ if (url.pathname.startsWith("/.well-known/oauth-authorization-server") ||
225
+ url.pathname === "/.well-known/openid-configuration") {
226
+ if (req.method !== "GET") {
227
+ methodNotAllowed(res);
228
+ return true;
229
+ }
230
+ jsonResponse(res, 200, this.authorizationServerMetadata(baseUrl));
231
+ return true;
232
+ }
233
+ if (url.pathname === "/oauth/register") {
234
+ await this.handleRegister(req, res);
235
+ return true;
236
+ }
237
+ if (url.pathname === "/oauth/authorize") {
238
+ await this.handleAuthorize(req, res);
239
+ return true;
240
+ }
241
+ if (url.pathname === "/oauth/token") {
242
+ await this.handleToken(req, res);
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+ protectedResourceMetadata(baseUrl) {
248
+ return {
249
+ resource: `${baseUrl}${this.opts.protectedPath}`,
250
+ authorization_servers: [baseUrl],
251
+ scopes_supported: ["mcp", "workspace:admin"],
252
+ bearer_methods_supported: ["header"],
253
+ };
254
+ }
255
+ authorizationServerMetadata(baseUrl) {
256
+ return {
257
+ issuer: baseUrl,
258
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
259
+ token_endpoint: `${baseUrl}/oauth/token`,
260
+ registration_endpoint: `${baseUrl}/oauth/register`,
261
+ response_types_supported: ["code"],
262
+ grant_types_supported: ["authorization_code"],
263
+ token_endpoint_auth_methods_supported: this.opts.config.allowPublicClients
264
+ ? ["client_secret_post", "client_secret_basic", "none"]
265
+ : ["client_secret_post", "client_secret_basic"],
266
+ code_challenge_methods_supported: this.opts.config.allowPlainPkce
267
+ ? ["S256", "plain"]
268
+ : ["S256"],
269
+ scopes_supported: ["mcp", "workspace:admin"],
270
+ };
271
+ }
272
+ registrationAllowedByPolicy(req, params) {
273
+ const policy = this.opts.config.registrationPolicy;
274
+ if (policy === "open_dev") {
275
+ const host = firstHeader(req.headers.host) ?? "";
276
+ return isLocalHost(host) || process.env.LLM_GATEWAY_OAUTH_OPEN_DEV === "1";
277
+ }
278
+ if (policy === "static_clients")
279
+ return false;
280
+ const supplied = params.get("shared_secret") ?? params.get("registration_secret");
281
+ if (!supplied || supplied.includes("?"))
282
+ return false;
283
+ const hash = this.opts.config.sharedSecret?.enabled
284
+ ? this.opts.config.sharedSecret.secretHash
285
+ : null;
286
+ return Boolean(hash && verifySecret(supplied, hash));
287
+ }
288
+ async handleRegister(req, res) {
289
+ if (req.method !== "POST") {
290
+ methodNotAllowed(res);
291
+ return;
292
+ }
293
+ const { params, json } = await readOAuthBody(req);
294
+ if (new URL(req.url ?? "/", "http://localhost").searchParams.has("shared_secret")) {
295
+ jsonResponse(res, 400, { error: "invalid_request" });
296
+ return;
297
+ }
298
+ if (!this.registrationAllowedByPolicy(req, params)) {
299
+ jsonResponse(res, 403, { error: "invalid_client" });
300
+ return;
301
+ }
302
+ const redirectUris = extractStringArray(json.redirect_uris, params, "redirect_uris");
303
+ if (redirectUris.length === 0 || redirectUris.some(uri => !isHttpsOrLoopbackUrl(uri))) {
304
+ jsonResponse(res, 400, { error: "invalid_redirect_uri" });
305
+ return;
306
+ }
307
+ const clientId = `llm-cli-gateway-${randomUUID()}`;
308
+ const clientSecret = this.opts.config.allowPublicClients ? null : generateSecret();
309
+ const issuedAt = Math.floor(Date.now() / 1000);
310
+ this.clients.set(clientId, {
311
+ clientId,
312
+ clientSecretHash: clientSecret ? hashSecret(clientSecret) : null,
313
+ redirectUris: new Set(redirectUris),
314
+ scopes: new Set(["mcp"]),
315
+ issuedAt,
316
+ publicClient: !clientSecret,
317
+ });
318
+ jsonResponse(res, 201, {
319
+ client_id: clientId,
320
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
321
+ client_id_issued_at: issuedAt,
322
+ grant_types: ["authorization_code"],
323
+ response_types: ["code"],
324
+ redirect_uris: redirectUris,
325
+ token_endpoint_auth_method: clientSecret ? "client_secret_post" : "none",
326
+ scope: "mcp",
327
+ });
328
+ }
329
+ async handleAuthorize(req, res) {
330
+ if (req.method !== "GET" && req.method !== "POST") {
331
+ methodNotAllowed(res);
332
+ return;
333
+ }
334
+ const params = req.method === "POST"
335
+ ? (await readOAuthBody(req)).params
336
+ : new URL(req.url ?? "/", "http://localhost").searchParams;
337
+ if (params.has("shared_secret")) {
338
+ jsonResponse(res, 400, { error: "invalid_request" });
339
+ return;
340
+ }
341
+ const responseType = params.get("response_type");
342
+ const clientId = params.get("client_id") ?? "";
343
+ const redirectUri = params.get("redirect_uri");
344
+ const state = params.get("state");
345
+ if (!redirectUri) {
346
+ jsonResponse(res, 400, { error: "invalid_request" });
347
+ return;
348
+ }
349
+ const client = this.clients.get(clientId);
350
+ if (!client || !client.redirectUris.has(redirectUri)) {
351
+ jsonResponse(res, 400, { error: "invalid_request" });
352
+ return;
353
+ }
354
+ const method = params.get("code_challenge_method");
355
+ const codeChallenge = params.get("code_challenge");
356
+ if (responseType !== "code" ||
357
+ (this.opts.config.requirePkce && !codeChallenge) ||
358
+ (codeChallenge &&
359
+ method !== "S256" &&
360
+ !(this.opts.config.allowPlainPkce && method === "plain"))) {
361
+ res.writeHead(302, {
362
+ location: oauthErrorRedirect(redirectUri, "invalid_request", state),
363
+ });
364
+ res.end();
365
+ return;
366
+ }
367
+ const requestedScopes = normalizeScopes(params.get("scope"));
368
+ if (!scopesAllowed(requestedScopes, client)) {
369
+ res.writeHead(302, {
370
+ location: oauthErrorRedirect(redirectUri, "invalid_scope", state),
371
+ });
372
+ res.end();
373
+ return;
374
+ }
375
+ this.pruneExpiredCodes();
376
+ const code = randomUUID();
377
+ this.codes.set(code, {
378
+ clientId,
379
+ redirectUri,
380
+ scope: requestedScopes.join(" "),
381
+ codeChallenge,
382
+ codeChallengeMethod: method,
383
+ expiresAt: Date.now() + OAUTH_CODE_TTL_MS,
384
+ });
385
+ const target = new URL(redirectUri);
386
+ target.searchParams.set("code", code);
387
+ if (state)
388
+ target.searchParams.set("state", state);
389
+ res.writeHead(302, { location: target.toString() });
390
+ res.end();
391
+ }
392
+ async handleToken(req, res) {
393
+ if (req.method !== "POST") {
394
+ methodNotAllowed(res);
395
+ return;
396
+ }
397
+ if (new URL(req.url ?? "/", "http://localhost").searchParams.has("client_secret")) {
398
+ jsonResponse(res, 400, { error: "invalid_request" });
399
+ return;
400
+ }
401
+ const { params } = await readOAuthBody(req);
402
+ const code = params.get("code") ?? "";
403
+ const entry = this.codes.get(code);
404
+ const clientId = oauthClientId(req, params);
405
+ const client = clientId ? this.clients.get(clientId) : undefined;
406
+ const clientSecret = oauthClientSecret(req, params);
407
+ const secretOk = client?.publicClient ||
408
+ Boolean(client?.clientSecretHash &&
409
+ clientSecret &&
410
+ verifySecret(clientSecret, client.clientSecretHash));
411
+ if (params.get("grant_type") !== "authorization_code" ||
412
+ !entry ||
413
+ entry.expiresAt < Date.now() ||
414
+ !client ||
415
+ client.clientId !== entry.clientId ||
416
+ !secretOk ||
417
+ params.get("redirect_uri") !== entry.redirectUri ||
418
+ !validPkceVerifier(params.get("code_verifier"), entry.codeChallenge, entry.codeChallengeMethod)) {
419
+ jsonResponse(res, 400, { error: "invalid_grant" });
420
+ return;
421
+ }
422
+ this.codes.delete(code);
423
+ const token = issueOAuthAccessToken({
424
+ clientId: client.clientId,
425
+ scopes: normalizeScopes(entry.scope),
426
+ ttlSeconds: this.opts.config.tokenTtlSeconds,
427
+ });
428
+ jsonResponse(res, 200, {
429
+ access_token: token.accessToken,
430
+ token_type: "Bearer",
431
+ expires_in: token.expiresIn,
432
+ scope: token.scope,
433
+ });
434
+ }
435
+ pruneExpiredCodes(now = Date.now()) {
436
+ for (const [code, entry] of this.codes) {
437
+ if (entry.expiresAt < now)
438
+ this.codes.delete(code);
439
+ }
440
+ }
441
+ }
@@ -0,0 +1,7 @@
1
+ export interface GatewayRequestContext {
2
+ authKind?: "disabled" | "gateway_bearer" | "oauth";
3
+ authScopes: string[];
4
+ authClientId?: string;
5
+ }
6
+ export declare function runWithRequestContext<T>(context: GatewayRequestContext, callback: () => T | Promise<T>): T | Promise<T>;
7
+ export declare function getRequestContext(): GatewayRequestContext | undefined;
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const requestContext = new AsyncLocalStorage();
3
+ export function runWithRequestContext(context, callback) {
4
+ return requestContext.run(context, callback);
5
+ }
6
+ export function getRequestContext() {
7
+ return requestContext.getStore();
8
+ }
@@ -386,7 +386,42 @@ export const UPSTREAM_CLI_CONTRACTS = {
386
386
  "--skip-git-repo-check",
387
387
  "--strict-config",
388
388
  "--version",
389
- ]),
389
+ ], {
390
+ children: {
391
+ resume: subcommand(["exec", "resume"], "Resume Codex sessions from the interactive CLI.", "executes_agent", [
392
+ "--add-dir",
393
+ "--all",
394
+ "--cd",
395
+ "--config",
396
+ "--dangerously-bypass-approvals-and-sandbox",
397
+ "--dangerously-bypass-hook-trust",
398
+ "--disable",
399
+ "--enable",
400
+ "--image",
401
+ "--include-non-interactive",
402
+ "--last",
403
+ "--local-provider",
404
+ "--model",
405
+ "--no-alt-screen",
406
+ "--oss",
407
+ "--profile",
408
+ "--remote",
409
+ "--remote-auth-token-env",
410
+ "--sandbox",
411
+ "--strict-config",
412
+ ]),
413
+ review: subcommand(["exec", "review"], "Run Codex code review workflows.", "executes_agent", [
414
+ "--base",
415
+ "--commit",
416
+ "--config",
417
+ "--disable",
418
+ "--enable",
419
+ "--strict-config",
420
+ "--title",
421
+ "--uncommitted",
422
+ ]),
423
+ },
424
+ }),
390
425
  review: subcommand(["review"], "Run Codex code review workflows.", "executes_agent", [
391
426
  "--base",
392
427
  "--commit",
@@ -457,31 +492,6 @@ export const UPSTREAM_CLI_CONTRACTS = {
457
492
  ], { exposure: "not_exposed" }),
458
493
  debug: subcommand(["debug"], "Run Codex debugging utilities.", "read_only", ["--config", "--disable", "--enable"], { tier: "diagnostic" }),
459
494
  apply: subcommand(["apply"], "Apply a Codex patch to the workspace.", "destructive", ["--config", "--disable", "--enable"], { exposure: "not_exposed" }),
460
- resume: subcommand(["resume"], "Resume Codex sessions from the interactive CLI.", "executes_agent", [
461
- "--add-dir",
462
- "--all",
463
- "--ask-for-approval",
464
- "--cd",
465
- "--config",
466
- "--dangerously-bypass-approvals-and-sandbox",
467
- "--dangerously-bypass-hook-trust",
468
- "--disable",
469
- "--enable",
470
- "--image",
471
- "--include-non-interactive",
472
- "--last",
473
- "--local-provider",
474
- "--model",
475
- "--no-alt-screen",
476
- "--oss",
477
- "--profile",
478
- "--remote",
479
- "--remote-auth-token-env",
480
- "--sandbox",
481
- "--search",
482
- "--strict-config",
483
- "--version",
484
- ]),
485
495
  archive: subcommand(["archive"], "Archive Codex session state.", "writes_local_config", [
486
496
  "--add-dir",
487
497
  "--cd",
@@ -1253,12 +1263,18 @@ export const UPSTREAM_CLI_CONTRACTS = {
1253
1263
  "--prompt-json",
1254
1264
  "[]",
1255
1265
  "--restore-code",
1266
+ "--leader-socket",
1267
+ "/tmp/leader.sock",
1256
1268
  "--single",
1257
1269
  "single prompt",
1258
1270
  "--todo-gate",
1259
1271
  "--verbatim",
1260
1272
  "--version",
1261
1273
  "--worktree",
1274
+ "--compaction-mode",
1275
+ "summary",
1276
+ "--compaction-detail",
1277
+ "balanced",
1262
1278
  ],
1263
1279
  expect: "pass",
1264
1280
  },
@@ -0,0 +1,63 @@
1
+ import { type CliType } from "./session-manager.js";
2
+ import type { Logger } from "./logger.js";
3
+ export interface WorkspaceRepo {
4
+ alias: string;
5
+ path: string;
6
+ providers: CliType[];
7
+ allowWorktree: boolean;
8
+ allowAddDir: boolean;
9
+ kind: "git" | "folder";
10
+ operatorEntry: boolean;
11
+ }
12
+ export interface WorkspaceAllowedRoot {
13
+ alias: string;
14
+ path: string;
15
+ allowRegisterExistingGitRepos: boolean;
16
+ allowCreateDirectories: boolean;
17
+ allowInitGitRepos: boolean;
18
+ maxCreateDepth: number;
19
+ }
20
+ export interface WorkspaceRegistry {
21
+ enabled: boolean;
22
+ defaultAlias: string | null;
23
+ allowUnregisteredWorkingDir: boolean;
24
+ repos: WorkspaceRepo[];
25
+ allowedRoots: WorkspaceAllowedRoot[];
26
+ sources: {
27
+ configFile: string | null;
28
+ };
29
+ }
30
+ export interface EffectiveWorkspace {
31
+ alias: string;
32
+ root: string;
33
+ cwd: string;
34
+ worktreePath?: string;
35
+ repo: WorkspaceRepo;
36
+ }
37
+ export interface CreateWorkspaceInput {
38
+ alias: string;
39
+ rootAlias: string;
40
+ slug: string;
41
+ kind: "folder" | "git";
42
+ setDefault?: boolean;
43
+ configPath?: string;
44
+ logger?: Logger;
45
+ }
46
+ export declare class WorkspaceRegistryError extends Error {
47
+ constructor(message: string);
48
+ }
49
+ export declare function validateWorkspaceAlias(alias: string): string;
50
+ export declare function loadWorkspaceRegistry(logger?: Logger, configPath?: string): WorkspaceRegistry;
51
+ export declare function getWorkspace(registry: WorkspaceRegistry, alias: string): WorkspaceRepo;
52
+ export declare function resolveWorkspaceForProvider(registry: WorkspaceRegistry, provider: CliType, requestedAlias?: string, sessionMetadata?: Record<string, unknown>): EffectiveWorkspace;
53
+ export declare function validatePathInsideWorkspace(workspace: EffectiveWorkspace, candidate: string, policy: "workingDir" | "addDir"): string;
54
+ export declare function createWorkspace(input: CreateWorkspaceInput): WorkspaceRepo;
55
+ export declare function registerExistingWorkspace(input: {
56
+ alias: string;
57
+ repoPath: string;
58
+ setDefault?: boolean;
59
+ configPath?: string;
60
+ logger?: Logger;
61
+ }): WorkspaceRepo;
62
+ export declare function createTempWorkspaceConfig(contents: string): string;
63
+ export declare function describeWorkspace(repo: WorkspaceRepo): Record<string, unknown>;