@cabane/companion 0.6.215 → 0.6.216
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/README.md +18 -0
- package/dist/cli.js +46 -6
- package/dist/credential-broker.js +653 -0
- package/dist/launch.js +4 -1
- package/dist/pairing-config.js +4 -1
- package/dist/runtime.js +43 -5
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -152,6 +152,24 @@ What your command can rely on, and can set:
|
|
|
152
152
|
|
|
153
153
|
These names are a contract: changing one would break your command, so none changes without a note in the release.
|
|
154
154
|
|
|
155
|
+
### Keeping provider credentials outside a sandbox
|
|
156
|
+
|
|
157
|
+
An operator-managed sandbox can use the host-side `@cabane/companion/credential-broker` module. This is an embedding API for a trusted host adapter, not a proxy that becomes safe by listening on localhost. The adapter must authenticate the accepted socket as one live sandbox instance and revoke its grants when that instance is stopped. A shared UID, a copied placeholder, a socket filename or an HTTP header is not an instance identity. An unresolved, ambiguous or changed identity must return `null` from `resolveIdentity(socket)`.
|
|
158
|
+
|
|
159
|
+
Run the broker and its credential files under a host identity outside every sandbox UID mapping. Hold an exclusive process lock for the entire service lifetime, including shutdown: there must be exactly one renewal owner for each account file. The parent of an account file must be owned by that identity with mode `0700`; the file must be a regular, single-link `0600` file. Stop the service before importing or changing login state. `fileCredentialStore(path)` and `openBrokerCredentials(store)` read that state; share the resulting owner between every grant using the account. Renewal writes and fsyncs a replacement before releasing it. A failed persistence write refuses further use until the operator recovers the stored account.
|
|
160
|
+
|
|
161
|
+
The account format is `{ mode, accessToken, refreshToken?, idToken?, expiresAt?, accountId? }`. `mode` is `claude-setup`, `claude-oauth`, `codex-oauth` or `github`. OAuth modes require a refresh token and an expiry in epoch milliseconds. A setup token never renews. Normal Claude OAuth renews inside the broker before expiry, or on one upstream 401; Codex renewal returns fresh placeholders to its client. Never place the real account file, a credential-store bind, or an alternate harness login inside the sandbox. Real OAuth compatibility must be verified with the installed harness and login mode; a successful setup-token turn proves nothing about OAuth renewal.
|
|
162
|
+
|
|
163
|
+
Create the broker with `createCredentialBroker({ resolveIdentity, audit? })`. Its `server` handles model and git routes; bind it only to host loopback. Git's HTTP proxy requests are accepted only for the exact `http://github.com/` authority, then forwarded to fixed HTTPS GitHub upstreams. Arbitrary proxy destinations and CONNECT tunnels are refused. Its `githubServer` handles gh's `http_unix_socket` transport; authenticate that socket's peer too. Neither exposes a management endpoint. Call `grant({ identity, credentials, expiresAt })` only from the trusted host adapter. `credentials` maps `claude`, `codex` and `github` to their corresponding credential owners. The returned `token` is disposable; `revoke()` aborts active requests and removes its aliases. Teardown calls `revokeIdentity(identity)`; service shutdown calls `revokeAll()` and closes both servers. A restart creates fresh grants, so old placeholders stop working.
|
|
164
|
+
|
|
165
|
+
Scope the GitHub token on GitHub's side; the broker imposes no repository, branch, PR-action or GraphQL-shape policy.
|
|
166
|
+
|
|
167
|
+
Set `runner.brokeredCredentials: true` beside `runner.command` in the Companion config. This removes provider and GitHub credential variables from the launcher's environment, secret-store payload and device-grant payload before the wrapper receives them. The wrapper supplies `brokerClientConfiguration({ baseUrl, placeholder, githubSocket, codexConfig })`'s environment and generated configuration to the isolated home, and writes `await grant.codexAuth()` as its placeholder-only Codex `auth.json`. The helper preserves the supplied Codex settings while selecting the broker provider and HTTP Responses transport. Include its git configuration in the sandbox's global config and use its gh configuration there. For a GitHub-enabled box, provide `githubSocket`. The helper sets `GH_TOKEN` and preserves `github.com` in git's expanded remote so gh can resolve a tracked branch, without setting `GH_REPO`. The generated git config rewrites HTTPS URLs to an HTTP GitHub URL carrying only a placeholder, and sends that origin through the loopback broker using Git's per-origin `http.proxy`; the broker's upstream hop remains HTTPS. Install the entire generated config, replacing the previous generated file, rather than retaining an older rewrite. Do not bypass this proxy with `NO_PROXY` or a per-remote proxy override. For a model-only box, omit `githubSocket` or pass `null`; no GitHub environment is emitted and both git/gh config strings are empty, so an adapter can clear any previous generated routing. The wrapper must remove old credential mounts and alternate authentication helpers; this switch cannot inspect or secure an operator's filesystem mounts.
|
|
168
|
+
|
|
169
|
+
The fixed upstream hosts are `api.anthropic.com`, `chatgpt.com`, `api.github.com` and `github.com`. Paths and request bodies pass through without a workflow policy, except for known credential-minting, credential-registering or disclosure endpoints: Claude CLI API-key creation and OAuth token exchange, ChatGPT session/token disclosure, and GitHub app-manifest conversion, installation/application token endpoints, legacy authorizations, SSH user/deploy-key registration, runner registration/removal/JIT credentials and OAuth/device token endpoints. These return new secrets the echo filter cannot know in advance, or register box-chosen credentials that would outlive the grant. This deny-list must be revisited when providers add credential-returning or credential-registering routes; it is not a guarantee against unknown endpoints or arbitrary encodings. Redirects are refused and responses are filtered for known credential echoes. Credential-free injection is restricted to Codex's designated `/backend-api/ps/mcp` route. Each upstream exchange is capped at ten minutes, and request bodies at 64 MiB.
|
|
170
|
+
|
|
171
|
+
Arbitrary room secrets and other MCP credentials still follow their existing delivery contract. This API does not protect SSH keys or offer general secret injection into arbitrary HTTP destinations. Claude's feature flags fall back to defaults when their direct requests use placeholders. Before enabling a sandbox deployment, verify actual socket ownership, PID/namespace reuse, service UID separation, grant revocation, concurrent renewal and restart, required client operations, and a canary extraction attempt from inside the real sandbox. A successful unit test or package installation does not establish that host boundary.
|
|
172
|
+
|
|
155
173
|
### Harness permissions
|
|
156
174
|
|
|
157
175
|
The Companion approves Claude Code's tools through a hook and approves OpenCode permission requests; no turn waits for a tool-approval dialog. For Codex it disables approvals and, when your configuration sets a top-level `sandbox_mode`, passes no sandbox flag; without that setting it uses `danger-full-access`. Choose where agents run using [Agent access](https://cabane.ai/docs/agent-access), and configure the tools through [Harness sandboxes](https://cabane.ai/docs/harness-sandboxes).
|
package/dist/cli.js
CHANGED
|
@@ -778,7 +778,10 @@ var init_config = __esm({
|
|
|
778
778
|
// credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
|
|
779
779
|
// what a wrapper can rely on.
|
|
780
780
|
runner: z2.object({
|
|
781
|
-
command: z2.array(z2.string().min(1)).min(1)
|
|
781
|
+
command: z2.array(z2.string().min(1)).min(1),
|
|
782
|
+
// The external wrapper supplies only broker placeholders and removes
|
|
783
|
+
// credential mounts. Do not hand provider logins to that runner on stdin.
|
|
784
|
+
brokeredCredentials: z2.boolean().optional()
|
|
782
785
|
}).strict().optional()
|
|
783
786
|
});
|
|
784
787
|
RETIRED_ROW_KEYS = ["sandbox"];
|
|
@@ -8464,7 +8467,7 @@ var init_build_options = __esm({
|
|
|
8464
8467
|
import { readFile as readFile2 } from "fs/promises";
|
|
8465
8468
|
import { homedir as homedir7 } from "os";
|
|
8466
8469
|
import { join as join16 } from "path";
|
|
8467
|
-
import { parse } from "smol-toml";
|
|
8470
|
+
import { parse as parse2 } from "smol-toml";
|
|
8468
8471
|
function codexConfigPath(env = process.env, home = homedir7()) {
|
|
8469
8472
|
return join16(env.CODEX_HOME || join16(home, ".codex"), "config.toml");
|
|
8470
8473
|
}
|
|
@@ -8479,7 +8482,7 @@ async function readCodexConfigSetsSandboxMode(path = codexConfigPath()) {
|
|
|
8479
8482
|
}
|
|
8480
8483
|
function configSetsSandboxMode(text) {
|
|
8481
8484
|
try {
|
|
8482
|
-
return
|
|
8485
|
+
return parse2(text).sandbox_mode !== void 0;
|
|
8483
8486
|
} catch {
|
|
8484
8487
|
return false;
|
|
8485
8488
|
}
|
|
@@ -13255,6 +13258,32 @@ init_boot_id();
|
|
|
13255
13258
|
init_turn_containment();
|
|
13256
13259
|
init_protocol();
|
|
13257
13260
|
import { createInterface } from "readline";
|
|
13261
|
+
|
|
13262
|
+
// src/credential-broker/client-config.ts
|
|
13263
|
+
import { parse, stringify } from "smol-toml";
|
|
13264
|
+
var BROKERED_CREDENTIAL_NAMES = /* @__PURE__ */ new Set([
|
|
13265
|
+
"ANTHROPIC_API_KEY",
|
|
13266
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
13267
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
13268
|
+
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
|
13269
|
+
"CLAUDE_CODE_SESSION_ACCESS_TOKEN",
|
|
13270
|
+
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
|
13271
|
+
"CLAUDE_CODE_HOST_CREDS_FILE",
|
|
13272
|
+
"CODEX_API_KEY",
|
|
13273
|
+
"OPENAI_API_KEY",
|
|
13274
|
+
"OPENAI_ACCESS_TOKEN",
|
|
13275
|
+
"GH_TOKEN",
|
|
13276
|
+
"GITHUB_TOKEN",
|
|
13277
|
+
"GH_ENTERPRISE_TOKEN",
|
|
13278
|
+
"GITHUB_ENTERPRISE_TOKEN"
|
|
13279
|
+
]);
|
|
13280
|
+
function withoutBrokeredCredentials(values) {
|
|
13281
|
+
return Object.fromEntries(
|
|
13282
|
+
Object.entries(values).filter(([name]) => !BROKERED_CREDENTIAL_NAMES.has(name))
|
|
13283
|
+
);
|
|
13284
|
+
}
|
|
13285
|
+
|
|
13286
|
+
// src/runner/launch.ts
|
|
13258
13287
|
function gzipBodiesSignal() {
|
|
13259
13288
|
let on = false;
|
|
13260
13289
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -13517,12 +13546,22 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
|
|
|
13517
13546
|
}
|
|
13518
13547
|
try {
|
|
13519
13548
|
const wrapped = launch.wrapped === true;
|
|
13520
|
-
|
|
13521
|
-
|
|
13549
|
+
if (launch.brokeredCredentials && !wrapped)
|
|
13550
|
+
throw new Error("credential broker requires a wrapped runner");
|
|
13551
|
+
const inherited = runnerExecEnv(launch.env ?? process.env, wrapped);
|
|
13552
|
+
const env = launch.brokeredCredentials ? withoutBrokeredCredentials(inherited) : inherited;
|
|
13553
|
+
const directGrants = minted.deviceGrants.length > 0 ? launch.grants?.(minted.deviceGrants) ?? {} : {};
|
|
13554
|
+
const grants = launch.brokeredCredentials ? Object.fromEntries(
|
|
13555
|
+
Object.entries(directGrants).map(([key, values]) => [
|
|
13556
|
+
key,
|
|
13557
|
+
withoutBrokeredCredentials(values)
|
|
13558
|
+
])
|
|
13559
|
+
) : directGrants;
|
|
13560
|
+
const secrets = wrapped ? launch.secrets?.() ?? {} : void 0;
|
|
13522
13561
|
const input = {
|
|
13523
13562
|
baseUrl: opts.baseUrl,
|
|
13524
13563
|
credential: minted.credential,
|
|
13525
|
-
...
|
|
13564
|
+
...secrets ? { secrets: launch.brokeredCredentials ? withoutBrokeredCredentials(secrets) : secrets } : {},
|
|
13526
13565
|
...Object.keys(grants).length > 0 ? { grants } : {},
|
|
13527
13566
|
dispatch: payload,
|
|
13528
13567
|
turnId,
|
|
@@ -15526,6 +15565,7 @@ var CompanionSupervisor = class {
|
|
|
15526
15565
|
),
|
|
15527
15566
|
...this.config.runner?.command ? {
|
|
15528
15567
|
wrapped: true,
|
|
15568
|
+
brokeredCredentials: this.config.runner.brokeredCredentials,
|
|
15529
15569
|
secrets: () => loadSecretValuesTolerant(
|
|
15530
15570
|
(m) => this.log.warn({ err: m }, "Could not read the secrets file for a wrapped turn.")
|
|
15531
15571
|
)
|
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// src/credential-broker/broker.ts
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { createServer } from "http";
|
|
4
|
+
import { Readable } from "stream";
|
|
5
|
+
import { pipeline } from "stream/promises";
|
|
6
|
+
|
|
7
|
+
// src/credential-broker/policy.ts
|
|
8
|
+
function createsOrRevealsCredential(origin, path) {
|
|
9
|
+
if (origin === "https://api.anthropic.com")
|
|
10
|
+
return /^\/api\/oauth\/claude_cli\/create_api_key(?:\/|$)/.test(path) || /^\/(?:v1\/)?oauth\/token(?:\/|$)/.test(path);
|
|
11
|
+
if (origin === "https://chatgpt.com")
|
|
12
|
+
return /^\/api\/auth\/session(?:\/|$)/.test(path) || /^\/(?:v1\/)?oauth\/token(?:\/|$)/.test(path);
|
|
13
|
+
if (origin === "https://github.com")
|
|
14
|
+
return /^\/login\/(?:oauth\/access_token|device\/code)(?:\/|$)/.test(path);
|
|
15
|
+
path = path.replace(/^\/repositories\/[^/]+(?=\/|$)/, "/repos/_/_");
|
|
16
|
+
return /^\/user\/keys(?:\/|$)/.test(path) || /^\/repos\/[^/]+\/[^/]+\/keys(?:\/|$)/.test(path) || /^\/authorizations(?:\/|$)/.test(path) || /^\/app-manifests\/[^/]+\/conversions(?:\/|$)/.test(path) || /^\/app\/installations\/[^/]+\/access_tokens(?:\/|$)/.test(path) || /^\/applications\/[^/]+\/token(?:\/|$)/.test(path) || /^\/(?:repos\/[^/]+\/[^/]+|orgs\/[^/]+|enterprises\/[^/]+)\/actions\/runners\/(?:registration-token|remove-token|generate-jitconfig)(?:\/|$)/.test(
|
|
17
|
+
path
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
function brokerRoute(method, target) {
|
|
21
|
+
if (method === "CONNECT") return null;
|
|
22
|
+
if (target.startsWith("http://github.com/"))
|
|
23
|
+
target = "/git/" + target.slice("http://github.com/".length);
|
|
24
|
+
if (!target.startsWith("/") || target.startsWith("//") || /[\\#]/.test(target) || [...target].some((char) => char.charCodeAt(0) <= 32))
|
|
25
|
+
return null;
|
|
26
|
+
const path = target.split("?")[0];
|
|
27
|
+
let decoded;
|
|
28
|
+
try {
|
|
29
|
+
decoded = decodeURIComponent(path);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (/[\\%;]/.test(decoded) || /%2f/i.test(path) || decoded.split("/").some((part) => part === "." || part === "..") || [...decoded].some((char) => char.charCodeAt(0) <= 32))
|
|
34
|
+
return null;
|
|
35
|
+
const url = new URL(target, "http://broker.invalid");
|
|
36
|
+
if (path === "/codex/refresh" && method === "POST" && !url.search)
|
|
37
|
+
return { origin: "https://chatgpt.com", path, service: "codex", refresh: true };
|
|
38
|
+
const destinations = [
|
|
39
|
+
["/claude", "https://api.anthropic.com", "claude"],
|
|
40
|
+
["/codex", "https://chatgpt.com", "codex"],
|
|
41
|
+
["/github", "https://api.github.com", "github"],
|
|
42
|
+
["/git", "https://github.com", "github"]
|
|
43
|
+
];
|
|
44
|
+
for (const [prefix, origin, service] of destinations) {
|
|
45
|
+
if (!path.startsWith(prefix + "/")) continue;
|
|
46
|
+
const upstreamPath = path.slice(prefix.length);
|
|
47
|
+
const canonical = decoded.slice(prefix.length).replace(/\/{2,}/g, "/").toLowerCase();
|
|
48
|
+
if (createsOrRevealsCredential(origin, canonical)) return null;
|
|
49
|
+
const operation = upstreamPath.endsWith("/info/refs") ? url.searchParams.get("service") : upstreamPath.split("/").at(-1);
|
|
50
|
+
const git = origin === "https://github.com" && (operation === "git-upload-pack" || operation === "git-receive-pack");
|
|
51
|
+
return {
|
|
52
|
+
origin,
|
|
53
|
+
path: upstreamPath + url.search,
|
|
54
|
+
service,
|
|
55
|
+
...git ? { git: operation === "git-upload-pack" ? "upload" : "receive" } : {},
|
|
56
|
+
...origin === "https://chatgpt.com" && upstreamPath === "/backend-api/ps/mcp" && ["GET", "POST", "DELETE"].includes(method) ? { identityOnly: true } : {}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/credential-broker/response-filter.ts
|
|
63
|
+
import { Transform } from "stream";
|
|
64
|
+
function credentialEchoes(secrets) {
|
|
65
|
+
return [
|
|
66
|
+
...new Set(
|
|
67
|
+
secrets.flatMap((secret) => [secret, `Bearer ${secret}`, `x-access-token:${secret}`]).flatMap((value) => [
|
|
68
|
+
value,
|
|
69
|
+
encodeURIComponent(value),
|
|
70
|
+
Buffer.from(value).toString("base64"),
|
|
71
|
+
Buffer.from(value).toString("base64url"),
|
|
72
|
+
JSON.stringify(value).slice(1, -1)
|
|
73
|
+
])
|
|
74
|
+
)
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
function secretResponseFilter(secrets) {
|
|
78
|
+
const needles = credentialEchoes(secrets).map((value) => Buffer.from(value));
|
|
79
|
+
const keep = Math.max(1, ...needles.map((needle) => needle.length)) - 1;
|
|
80
|
+
let pending = Buffer.alloc(0);
|
|
81
|
+
return new Transform({
|
|
82
|
+
transform(chunk, _encoding, done) {
|
|
83
|
+
const bytes = Buffer.concat([pending, chunk]);
|
|
84
|
+
if (needles.some((needle) => bytes.includes(needle))) {
|
|
85
|
+
done(new Error("credential_echo"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const length = Math.max(0, bytes.length - keep);
|
|
89
|
+
if (length) this.push(bytes.subarray(0, length));
|
|
90
|
+
pending = bytes.subarray(length);
|
|
91
|
+
done();
|
|
92
|
+
},
|
|
93
|
+
flush(done) {
|
|
94
|
+
if (pending.length) this.push(pending);
|
|
95
|
+
done();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/credential-broker/broker.ts
|
|
101
|
+
var REQUEST_HEADERS = [
|
|
102
|
+
"accept",
|
|
103
|
+
"content-type",
|
|
104
|
+
"content-encoding",
|
|
105
|
+
"user-agent",
|
|
106
|
+
"anthropic-version",
|
|
107
|
+
"anthropic-beta",
|
|
108
|
+
"x-app",
|
|
109
|
+
"originator",
|
|
110
|
+
"version",
|
|
111
|
+
"session_id",
|
|
112
|
+
"conversation_id",
|
|
113
|
+
"openai-beta",
|
|
114
|
+
"git-protocol",
|
|
115
|
+
"mcp-protocol-version",
|
|
116
|
+
"mcp-session-id"
|
|
117
|
+
];
|
|
118
|
+
var RESPONSE_HEADERS = [
|
|
119
|
+
"link",
|
|
120
|
+
"content-type",
|
|
121
|
+
"retry-after",
|
|
122
|
+
"x-request-id",
|
|
123
|
+
"request-id",
|
|
124
|
+
"x-ratelimit-limit",
|
|
125
|
+
"x-ratelimit-remaining",
|
|
126
|
+
"x-ratelimit-reset",
|
|
127
|
+
"mcp-session-id"
|
|
128
|
+
];
|
|
129
|
+
async function readBody(req, limit = 64 * 1024 * 1024) {
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let length = 0;
|
|
132
|
+
for await (const chunk of req) {
|
|
133
|
+
length += chunk.length;
|
|
134
|
+
if (length > limit) throw new Error("body_limit");
|
|
135
|
+
chunks.push(chunk);
|
|
136
|
+
}
|
|
137
|
+
return Buffer.concat(chunks);
|
|
138
|
+
}
|
|
139
|
+
function requestToken(req) {
|
|
140
|
+
const auth = req.headers.authorization;
|
|
141
|
+
const apiKey = req.headers["x-api-key"];
|
|
142
|
+
const names = req.rawHeaders.filter((_, index) => index % 2 === 0).map((name) => name.toLowerCase());
|
|
143
|
+
if (names.filter((name) => ["authorization", "x-api-key"].includes(name)).length > 1 || req.headers["proxy-authorization"] !== void 0)
|
|
144
|
+
throw new Error("conflicting_credentials");
|
|
145
|
+
if (apiKey !== void 0) return typeof apiKey === "string" ? apiKey : null;
|
|
146
|
+
if (auth === void 0) return null;
|
|
147
|
+
const bearer = auth.match(/^(?:Bearer|token) ([A-Za-z0-9_.-]+)$/i);
|
|
148
|
+
if (bearer) return bearer[1];
|
|
149
|
+
const basic = auth.match(/^Basic ([A-Za-z0-9+/=]+)$/i);
|
|
150
|
+
if (basic) {
|
|
151
|
+
const value = Buffer.from(basic[1], "base64").toString("utf8");
|
|
152
|
+
const parts = value.split(":");
|
|
153
|
+
if (parts.length === 2 && ["x-access-token", "oauth2"].includes(parts[0])) return parts[1];
|
|
154
|
+
}
|
|
155
|
+
throw new Error("invalid_credentials");
|
|
156
|
+
}
|
|
157
|
+
function refuse(res, route, body, status = 403) {
|
|
158
|
+
if (res.headersSent) {
|
|
159
|
+
res.destroy();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (route?.git === "receive") {
|
|
163
|
+
const capabilities = body.subarray(0, 1048576).toString().split("\0")[1]?.split("\n")[0]?.split(" ") ?? [];
|
|
164
|
+
const sideband = capabilities.includes("side-band-64k") || capabilities.includes("side-band");
|
|
165
|
+
const line = `${sideband ? "" : ""}ERR credential broker refused this operation
|
|
166
|
+
`;
|
|
167
|
+
res.writeHead(route.path.endsWith("/git-receive-pack") ? 200 : status, {
|
|
168
|
+
"content-type": "application/x-git-receive-pack-result"
|
|
169
|
+
});
|
|
170
|
+
res.end((Buffer.byteLength(line) + 4).toString(16).padStart(4, "0") + line + "0000");
|
|
171
|
+
} else {
|
|
172
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
173
|
+
res.end('{"error":"credential_broker_refused"}\n');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function createCredentialBroker(options) {
|
|
177
|
+
const now = options.now ?? Date.now;
|
|
178
|
+
const upstream = options.fetch ?? fetch;
|
|
179
|
+
const grants = /* @__PURE__ */ new Set();
|
|
180
|
+
const byToken = /* @__PURE__ */ new Map();
|
|
181
|
+
function revoke(grant) {
|
|
182
|
+
grant.revoked = true;
|
|
183
|
+
for (const alias of grant.aliases) byToken.delete(alias);
|
|
184
|
+
for (const active of grant.active) active.abort();
|
|
185
|
+
grants.delete(grant);
|
|
186
|
+
}
|
|
187
|
+
async function handle(req, res, githubSocket = false) {
|
|
188
|
+
const route = brokerRoute(req.method ?? "", (githubSocket ? "/github" : "") + (req.url ?? ""));
|
|
189
|
+
let grant;
|
|
190
|
+
let abort;
|
|
191
|
+
let upstreamStarted = false;
|
|
192
|
+
let requestBody = Buffer.alloc(0);
|
|
193
|
+
try {
|
|
194
|
+
for (const item of grants) if (item.expiresAt <= now()) revoke(item);
|
|
195
|
+
if (!route || githubSocket && req.headers.host !== "api.github.com") throw new Error();
|
|
196
|
+
const identity = await options.resolveIdentity(req.socket);
|
|
197
|
+
if (!identity) throw new Error();
|
|
198
|
+
let token2 = requestToken(req);
|
|
199
|
+
if (route.git && token2 === null) {
|
|
200
|
+
res.writeHead(401, {
|
|
201
|
+
"www-authenticate": 'Basic realm="credential-broker"',
|
|
202
|
+
"cache-control": "no-store"
|
|
203
|
+
});
|
|
204
|
+
res.end();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let body;
|
|
208
|
+
if (route.refresh) {
|
|
209
|
+
body = await readBody(req, 16384);
|
|
210
|
+
const input = JSON.parse(body.toString());
|
|
211
|
+
if (input.grant_type !== "refresh_token" || typeof input.refresh_token !== "string" || token2 !== null && token2 !== input.refresh_token)
|
|
212
|
+
throw new Error();
|
|
213
|
+
token2 = input.refresh_token;
|
|
214
|
+
}
|
|
215
|
+
if (token2 !== null) grant = byToken.get(token2);
|
|
216
|
+
else if (route.identityOnly) {
|
|
217
|
+
const matches = [...grants].filter(
|
|
218
|
+
(item) => item.identity === identity && !item.revoked && item.expiresAt > now() && item.credentials[route.service]
|
|
219
|
+
);
|
|
220
|
+
if (matches.length === 1) grant = matches[0];
|
|
221
|
+
}
|
|
222
|
+
if (!grant || grant.revoked || grant.identity !== identity || grant.expiresAt <= now())
|
|
223
|
+
throw new Error();
|
|
224
|
+
const credential = grant.credentials[route.service];
|
|
225
|
+
if (!credential) throw new Error();
|
|
226
|
+
if (grant.active.size >= (grant.concurrentRequests ?? 16)) throw new Error();
|
|
227
|
+
if (now() - grant.windowStart >= 6e4) {
|
|
228
|
+
grant.windowStart = now();
|
|
229
|
+
grant.requests = 0;
|
|
230
|
+
}
|
|
231
|
+
if (++grant.requests > (grant.requestsPerMinute ?? 120)) throw new Error();
|
|
232
|
+
abort = new AbortController();
|
|
233
|
+
grant.active.add(abort);
|
|
234
|
+
const signal = AbortSignal.any([
|
|
235
|
+
abort.signal,
|
|
236
|
+
AbortSignal.timeout(Math.max(1, Math.min(6e5, grant.expiresAt - now())))
|
|
237
|
+
]);
|
|
238
|
+
res.once("close", () => abort?.abort());
|
|
239
|
+
body ??= await readBody(req);
|
|
240
|
+
requestBody = body;
|
|
241
|
+
if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
|
|
242
|
+
throw new Error();
|
|
243
|
+
let account = await credential.current();
|
|
244
|
+
if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
|
|
245
|
+
throw new Error();
|
|
246
|
+
if (route.refresh) {
|
|
247
|
+
if (account.mode !== "codex-oauth") throw new Error();
|
|
248
|
+
const access = placeholderJwt(
|
|
249
|
+
grant,
|
|
250
|
+
account.accountId,
|
|
251
|
+
account.expiresAt ?? now() + 36e5
|
|
252
|
+
);
|
|
253
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
254
|
+
res.end(
|
|
255
|
+
JSON.stringify({
|
|
256
|
+
access_token: access,
|
|
257
|
+
refresh_token: grant.token,
|
|
258
|
+
id_token: access,
|
|
259
|
+
token_type: "Bearer",
|
|
260
|
+
expires_in: Math.max(1, Math.floor(((account.expiresAt ?? now()) - now()) / 1e3))
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
options.audit?.("allowed");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const headers = new Headers();
|
|
267
|
+
for (const name of REQUEST_HEADERS) {
|
|
268
|
+
const value = req.headers[name];
|
|
269
|
+
if (typeof value === "string") headers.set(name, value);
|
|
270
|
+
}
|
|
271
|
+
headers.set("accept-encoding", "identity");
|
|
272
|
+
headers.set(
|
|
273
|
+
"authorization",
|
|
274
|
+
route.git ? `Basic ${Buffer.from(`x-access-token:${account.accessToken}`).toString("base64")}` : `Bearer ${account.accessToken}`
|
|
275
|
+
);
|
|
276
|
+
if (route.service === "codex" && account.accountId)
|
|
277
|
+
headers.set("chatgpt-account-id", account.accountId);
|
|
278
|
+
const forward = () => {
|
|
279
|
+
upstreamStarted = true;
|
|
280
|
+
return upstream(route.origin + route.path, {
|
|
281
|
+
method: req.method,
|
|
282
|
+
headers,
|
|
283
|
+
redirect: "error",
|
|
284
|
+
signal,
|
|
285
|
+
...body.length ? { body: new Uint8Array(body) } : {}
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
let response = await forward();
|
|
289
|
+
if (response.status === 401 && account.mode.endsWith("-oauth")) {
|
|
290
|
+
await response.body?.cancel();
|
|
291
|
+
if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
|
|
292
|
+
throw new Error();
|
|
293
|
+
account = await credential.current(account.accessToken);
|
|
294
|
+
if (grant.revoked || grant.expiresAt <= now() || signal.aborted || await options.resolveIdentity(req.socket) !== identity)
|
|
295
|
+
throw new Error();
|
|
296
|
+
headers.set("authorization", `Bearer ${account.accessToken}`);
|
|
297
|
+
response = await forward();
|
|
298
|
+
}
|
|
299
|
+
if (response.status >= 300 && response.status < 400 || response.headers.has("content-encoding") && response.headers.get("content-encoding") !== "identity") {
|
|
300
|
+
await response.body?.cancel();
|
|
301
|
+
throw new Error();
|
|
302
|
+
}
|
|
303
|
+
if (grant.revoked || grant.expiresAt <= now() || signal.aborted) {
|
|
304
|
+
await response.body?.cancel();
|
|
305
|
+
throw new Error();
|
|
306
|
+
}
|
|
307
|
+
const forbidden = credential.secrets();
|
|
308
|
+
const echoes = credentialEchoes(forbidden);
|
|
309
|
+
for (const value of response.headers.values()) {
|
|
310
|
+
if (echoes.some((secret) => value.includes(secret))) {
|
|
311
|
+
await response.body?.cancel();
|
|
312
|
+
throw new Error();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const name of RESPONSE_HEADERS) {
|
|
316
|
+
const value = response.headers.get(name);
|
|
317
|
+
if (value) res.setHeader(name, value);
|
|
318
|
+
}
|
|
319
|
+
res.setHeader("cache-control", "no-store");
|
|
320
|
+
res.statusCode = response.status;
|
|
321
|
+
if (response.body) {
|
|
322
|
+
await pipeline(
|
|
323
|
+
Readable.fromWeb(response.body),
|
|
324
|
+
secretResponseFilter(forbidden),
|
|
325
|
+
res,
|
|
326
|
+
{ signal }
|
|
327
|
+
);
|
|
328
|
+
} else res.end();
|
|
329
|
+
options.audit?.("allowed");
|
|
330
|
+
} catch {
|
|
331
|
+
options.audit?.(upstreamStarted ? "upstream_failed" : "refused");
|
|
332
|
+
refuse(res, route, requestBody);
|
|
333
|
+
} finally {
|
|
334
|
+
if (grant && abort) grant.active.delete(abort);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function placeholderJwt(grant, accountId, expiresAt) {
|
|
338
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
339
|
+
const token2 = `${encode({ alg: "none", typ: "JWT" })}.${encode({
|
|
340
|
+
exp: Math.floor(expiresAt / 1e3),
|
|
341
|
+
sub: grant.token,
|
|
342
|
+
"https://api.openai.com/auth": { chatgpt_account_id: accountId ?? "broker" }
|
|
343
|
+
})}.${grant.token}`;
|
|
344
|
+
grant.aliases.add(token2);
|
|
345
|
+
byToken.set(token2, grant);
|
|
346
|
+
return token2;
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
server: createServer((req, res) => {
|
|
350
|
+
void handle(req, res);
|
|
351
|
+
}),
|
|
352
|
+
// gh speaks HTTP to its configured Unix socket with api.github.com as Host.
|
|
353
|
+
// The adapter MUST still authenticate the peer; the socket path is no proof.
|
|
354
|
+
githubServer: createServer((req, res) => {
|
|
355
|
+
void handle(req, res, true);
|
|
356
|
+
}),
|
|
357
|
+
grant(input) {
|
|
358
|
+
for (const item of grants) if (item.expiresAt <= now()) revoke(item);
|
|
359
|
+
if (grants.size >= 1e4) throw new Error("grant_limit");
|
|
360
|
+
if (!input.identity || input.expiresAt <= now()) throw new Error("invalid_grant");
|
|
361
|
+
if (input.credentials.claude && !["claude-setup", "claude-oauth"].includes(input.credentials.claude.mode) || input.credentials.codex && input.credentials.codex.mode !== "codex-oauth" || input.credentials.github && input.credentials.github.mode !== "github")
|
|
362
|
+
throw new Error("invalid_grant");
|
|
363
|
+
const grant = {
|
|
364
|
+
...input,
|
|
365
|
+
credentials: { ...input.credentials },
|
|
366
|
+
token: `cbg_${randomBytes(32).toString("hex")}`,
|
|
367
|
+
aliases: /* @__PURE__ */ new Set(),
|
|
368
|
+
revoked: false,
|
|
369
|
+
active: /* @__PURE__ */ new Set(),
|
|
370
|
+
windowStart: now(),
|
|
371
|
+
requests: 0
|
|
372
|
+
};
|
|
373
|
+
grant.aliases.add(grant.token);
|
|
374
|
+
grants.add(grant);
|
|
375
|
+
byToken.set(grant.token, grant);
|
|
376
|
+
return {
|
|
377
|
+
token: grant.token,
|
|
378
|
+
revoke: () => revoke(grant),
|
|
379
|
+
async codexAuth() {
|
|
380
|
+
if (grant.revoked || grant.expiresAt <= now() || !grant.credentials.codex)
|
|
381
|
+
throw new Error("invalid_grant");
|
|
382
|
+
const account = await grant.credentials.codex.current();
|
|
383
|
+
if (account.mode !== "codex-oauth" || grant.revoked) throw new Error("invalid_grant");
|
|
384
|
+
const token2 = placeholderJwt(grant, account.accountId, account.expiresAt ?? now());
|
|
385
|
+
return {
|
|
386
|
+
auth_mode: "chatgpt",
|
|
387
|
+
OPENAI_API_KEY: null,
|
|
388
|
+
tokens: {
|
|
389
|
+
access_token: token2,
|
|
390
|
+
refresh_token: grant.token,
|
|
391
|
+
id_token: token2,
|
|
392
|
+
account_id: account.accountId ?? "broker"
|
|
393
|
+
},
|
|
394
|
+
last_refresh: new Date(now()).toISOString()
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
},
|
|
399
|
+
revokeIdentity(identity) {
|
|
400
|
+
for (const grant of grants) if (grant.identity === identity) revoke(grant);
|
|
401
|
+
},
|
|
402
|
+
revokeAll() {
|
|
403
|
+
for (const grant of grants) revoke(grant);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/credential-broker/credentials.ts
|
|
409
|
+
import { randomUUID } from "crypto";
|
|
410
|
+
import { constants } from "fs";
|
|
411
|
+
import { open, rename, unlink } from "fs/promises";
|
|
412
|
+
import { dirname } from "path";
|
|
413
|
+
import { z } from "zod";
|
|
414
|
+
var token = z.string().min(8).max(32768).regex(/^[\x21-\x7e]+$/);
|
|
415
|
+
var accountSchema = z.object({
|
|
416
|
+
mode: z.enum(["claude-setup", "claude-oauth", "codex-oauth", "github"]),
|
|
417
|
+
accessToken: token,
|
|
418
|
+
refreshToken: token.optional(),
|
|
419
|
+
idToken: token.optional(),
|
|
420
|
+
expiresAt: z.number().finite().optional(),
|
|
421
|
+
accountId: z.string().min(1).optional()
|
|
422
|
+
}).strict().superRefine((value, ctx) => {
|
|
423
|
+
if (value.mode.endsWith("-oauth") && (!value.refreshToken || value.expiresAt === void 0)) {
|
|
424
|
+
ctx.addIssue({ code: "custom", message: "OAuth requires renewal state" });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
var BrokerError = class extends Error {
|
|
428
|
+
constructor(code) {
|
|
429
|
+
super(code);
|
|
430
|
+
this.code = code;
|
|
431
|
+
}
|
|
432
|
+
code;
|
|
433
|
+
};
|
|
434
|
+
function fileCredentialStore(path) {
|
|
435
|
+
return {
|
|
436
|
+
async read() {
|
|
437
|
+
let file;
|
|
438
|
+
try {
|
|
439
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
440
|
+
const stat = await file.stat();
|
|
441
|
+
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 63) !== 0 || stat.uid !== process.getuid?.() || stat.size > 131072)
|
|
442
|
+
throw new Error();
|
|
443
|
+
return accountSchema.parse(JSON.parse(await file.readFile("utf8")));
|
|
444
|
+
} catch {
|
|
445
|
+
throw new BrokerError("credential_unavailable");
|
|
446
|
+
} finally {
|
|
447
|
+
await file?.close();
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
async write(account) {
|
|
451
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
452
|
+
try {
|
|
453
|
+
const validated = accountSchema.parse(account);
|
|
454
|
+
const file = await open(tmp, "wx", 384);
|
|
455
|
+
try {
|
|
456
|
+
await file.writeFile(JSON.stringify(validated) + "\n");
|
|
457
|
+
await file.sync();
|
|
458
|
+
} finally {
|
|
459
|
+
await file.close();
|
|
460
|
+
}
|
|
461
|
+
await rename(tmp, path);
|
|
462
|
+
const dir = await open(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
463
|
+
try {
|
|
464
|
+
await dir.sync();
|
|
465
|
+
} finally {
|
|
466
|
+
await dir.close();
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
throw new BrokerError("persistence_failed");
|
|
470
|
+
} finally {
|
|
471
|
+
await unlink(tmp).catch(() => {
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
var renewalSchema = z.object({
|
|
478
|
+
access_token: token,
|
|
479
|
+
refresh_token: token.optional(),
|
|
480
|
+
id_token: token.optional(),
|
|
481
|
+
expires_in: z.number().positive().finite().max(31536e3).optional()
|
|
482
|
+
});
|
|
483
|
+
var OAUTH = {
|
|
484
|
+
"claude-oauth": {
|
|
485
|
+
url: "https://platform.claude.com/v1/oauth/token",
|
|
486
|
+
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
487
|
+
},
|
|
488
|
+
"codex-oauth": {
|
|
489
|
+
url: "https://auth.openai.com/oauth/token",
|
|
490
|
+
clientId: "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
async function openBrokerCredentials(store, ports = {}) {
|
|
494
|
+
let account;
|
|
495
|
+
try {
|
|
496
|
+
account = accountSchema.parse(await store.read());
|
|
497
|
+
} catch {
|
|
498
|
+
throw new BrokerError("credential_unavailable");
|
|
499
|
+
}
|
|
500
|
+
const fetchUpstream = ports.fetch ?? fetch;
|
|
501
|
+
const now = ports.now ?? Date.now;
|
|
502
|
+
let pending;
|
|
503
|
+
let poisoned = false;
|
|
504
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
505
|
+
function remember(value) {
|
|
506
|
+
for (const secret of [value.accessToken, value.refreshToken, value.idToken]) {
|
|
507
|
+
if (secret) secrets.add(secret);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
remember(account);
|
|
511
|
+
async function renew() {
|
|
512
|
+
const mode = account.mode;
|
|
513
|
+
if (mode !== "claude-oauth" && mode !== "codex-oauth") return account;
|
|
514
|
+
try {
|
|
515
|
+
const oauth = OAUTH[mode];
|
|
516
|
+
const response = await fetchUpstream(oauth.url, {
|
|
517
|
+
method: "POST",
|
|
518
|
+
redirect: "error",
|
|
519
|
+
signal: AbortSignal.timeout(3e4),
|
|
520
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
grant_type: "refresh_token",
|
|
523
|
+
client_id: oauth.clientId,
|
|
524
|
+
refresh_token: account.refreshToken
|
|
525
|
+
})
|
|
526
|
+
});
|
|
527
|
+
if (!response.ok) {
|
|
528
|
+
await response.body?.cancel();
|
|
529
|
+
throw new Error();
|
|
530
|
+
}
|
|
531
|
+
const reader = response.body?.getReader();
|
|
532
|
+
if (!reader) throw new Error();
|
|
533
|
+
const chunks = [];
|
|
534
|
+
let size = 0;
|
|
535
|
+
try {
|
|
536
|
+
while (true) {
|
|
537
|
+
const chunk = await reader.read();
|
|
538
|
+
if (chunk.done) break;
|
|
539
|
+
size += chunk.value.length;
|
|
540
|
+
if (size > 131072) throw new Error();
|
|
541
|
+
chunks.push(chunk.value);
|
|
542
|
+
}
|
|
543
|
+
} finally {
|
|
544
|
+
await reader.cancel().catch(() => {
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const renewed = renewalSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
548
|
+
let lifetime = renewed.expires_in;
|
|
549
|
+
if (lifetime === void 0 && mode === "codex-oauth") {
|
|
550
|
+
const payload = JSON.parse(
|
|
551
|
+
Buffer.from(renewed.access_token.split(".")[1] ?? "", "base64url").toString()
|
|
552
|
+
);
|
|
553
|
+
if (typeof payload.exp === "number") lifetime = payload.exp - now() / 1e3;
|
|
554
|
+
}
|
|
555
|
+
if (!lifetime || !Number.isFinite(lifetime) || lifetime <= 0) throw new Error();
|
|
556
|
+
const next = accountSchema.parse({
|
|
557
|
+
...account,
|
|
558
|
+
accessToken: renewed.access_token,
|
|
559
|
+
refreshToken: renewed.refresh_token ?? account.refreshToken,
|
|
560
|
+
idToken: renewed.id_token ?? account.idToken,
|
|
561
|
+
expiresAt: now() + lifetime * 1e3
|
|
562
|
+
});
|
|
563
|
+
remember(next);
|
|
564
|
+
try {
|
|
565
|
+
await store.write(next);
|
|
566
|
+
} catch {
|
|
567
|
+
poisoned = true;
|
|
568
|
+
throw new BrokerError("persistence_failed");
|
|
569
|
+
}
|
|
570
|
+
account = next;
|
|
571
|
+
return account;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (error instanceof BrokerError) throw error;
|
|
574
|
+
throw new BrokerError("refresh_failed");
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
mode: account.mode,
|
|
579
|
+
// A snapshot used only by the response filter, inside the trusted process.
|
|
580
|
+
secrets: () => [...secrets],
|
|
581
|
+
async current(rejectedAccessToken) {
|
|
582
|
+
if (poisoned) throw new BrokerError("persistence_failed");
|
|
583
|
+
if (pending) return pending;
|
|
584
|
+
if (!account.mode.endsWith("-oauth") || rejectedAccessToken !== void 0 && rejectedAccessToken !== account.accessToken || rejectedAccessToken === void 0 && (account.expiresAt ?? 0) > now() + 6e4) {
|
|
585
|
+
return account;
|
|
586
|
+
}
|
|
587
|
+
pending = renew();
|
|
588
|
+
try {
|
|
589
|
+
return await pending;
|
|
590
|
+
} finally {
|
|
591
|
+
pending = void 0;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/credential-broker/client-config.ts
|
|
598
|
+
import { parse, stringify } from "smol-toml";
|
|
599
|
+
function brokerClientConfiguration(input) {
|
|
600
|
+
const base = new URL(input.baseUrl);
|
|
601
|
+
if (base.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(base.hostname) || base.username || base.password || base.pathname !== "/" || base.search || base.hash || !/^cbg_[a-f0-9]{64}$/.test(input.placeholder) || input.githubSocket != null && (!input.githubSocket.startsWith("/") || [...input.githubSocket].some((char) => char.charCodeAt(0) < 32)))
|
|
602
|
+
throw new Error("invalid_broker_configuration");
|
|
603
|
+
const origin = base.origin;
|
|
604
|
+
const config = parse(input.codexConfig);
|
|
605
|
+
const providers = config.model_providers;
|
|
606
|
+
if (providers !== void 0 && (!providers || typeof providers !== "object" || Array.isArray(providers) || providers instanceof Date)) {
|
|
607
|
+
throw new Error("invalid_broker_configuration");
|
|
608
|
+
}
|
|
609
|
+
config.model_provider = "credential_broker";
|
|
610
|
+
config.chatgpt_base_url = `${origin}/codex/backend-api/`;
|
|
611
|
+
config.model_providers = {
|
|
612
|
+
...providers,
|
|
613
|
+
credential_broker: {
|
|
614
|
+
name: "Credential broker",
|
|
615
|
+
base_url: `${origin}/codex/backend-api/codex`,
|
|
616
|
+
wire_api: "responses",
|
|
617
|
+
requires_openai_auth: true,
|
|
618
|
+
supports_websockets: false
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const git = new URL("http://github.com/");
|
|
622
|
+
git.username = "x-access-token";
|
|
623
|
+
git.password = input.placeholder;
|
|
624
|
+
const githubEnv = input.githubSocket == null ? {} : {
|
|
625
|
+
GH_TOKEN: input.placeholder
|
|
626
|
+
};
|
|
627
|
+
return {
|
|
628
|
+
env: {
|
|
629
|
+
ANTHROPIC_BASE_URL: `${origin}/claude`,
|
|
630
|
+
CLAUDE_CODE_OAUTH_TOKEN: input.placeholder,
|
|
631
|
+
CODEX_REFRESH_TOKEN_URL_OVERRIDE: `${origin}/codex/refresh`,
|
|
632
|
+
...githubEnv
|
|
633
|
+
},
|
|
634
|
+
codexConfig: stringify(config),
|
|
635
|
+
// Include from the sandbox's global config. The stored remote stays HTTPS.
|
|
636
|
+
// No generic forward proxy, CONNECT tunnel or arbitrary host is enabled.
|
|
637
|
+
gitConfig: input.githubSocket == null ? "" : `[url ${JSON.stringify(git.href)}]
|
|
638
|
+
insteadOf = https://github.com/
|
|
639
|
+
[http "http://github.com/"]
|
|
640
|
+
proxy = ${JSON.stringify(origin)}
|
|
641
|
+
followRedirects = false
|
|
642
|
+
`,
|
|
643
|
+
// YAML accepts a JSON string scalar, including quotes/backslashes in paths.
|
|
644
|
+
ghConfig: input.githubSocket == null ? "" : `http_unix_socket: ${JSON.stringify(input.githubSocket)}
|
|
645
|
+
`
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
export {
|
|
649
|
+
brokerClientConfiguration,
|
|
650
|
+
createCredentialBroker,
|
|
651
|
+
fileCredentialStore,
|
|
652
|
+
openBrokerCredentials
|
|
653
|
+
};
|
package/dist/launch.js
CHANGED
|
@@ -185,7 +185,10 @@ var companionConfigSchema = z2.object({
|
|
|
185
185
|
// credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
|
|
186
186
|
// what a wrapper can rely on.
|
|
187
187
|
runner: z2.object({
|
|
188
|
-
command: z2.array(z2.string().min(1)).min(1)
|
|
188
|
+
command: z2.array(z2.string().min(1)).min(1),
|
|
189
|
+
// The external wrapper supplies only broker placeholders and removes
|
|
190
|
+
// credential mounts. Do not hand provider logins to that runner on stdin.
|
|
191
|
+
brokeredCredentials: z2.boolean().optional()
|
|
189
192
|
}).strict().optional()
|
|
190
193
|
});
|
|
191
194
|
var legacyOfferSchema = z2.object({
|
package/dist/pairing-config.js
CHANGED
|
@@ -210,7 +210,10 @@ var companionConfigSchema = z2.object({
|
|
|
210
210
|
// credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
|
|
211
211
|
// what a wrapper can rely on.
|
|
212
212
|
runner: z2.object({
|
|
213
|
-
command: z2.array(z2.string().min(1)).min(1)
|
|
213
|
+
command: z2.array(z2.string().min(1)).min(1),
|
|
214
|
+
// The external wrapper supplies only broker placeholders and removes
|
|
215
|
+
// credential mounts. Do not hand provider logins to that runner on stdin.
|
|
216
|
+
brokeredCredentials: z2.boolean().optional()
|
|
214
217
|
}).strict().optional()
|
|
215
218
|
});
|
|
216
219
|
var RETIRED_ROW_KEYS = ["sandbox"];
|
package/dist/runtime.js
CHANGED
|
@@ -294,7 +294,10 @@ var companionConfigSchema = z2.object({
|
|
|
294
294
|
// credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
|
|
295
295
|
// what a wrapper can rely on.
|
|
296
296
|
runner: z2.object({
|
|
297
|
-
command: z2.array(z2.string().min(1)).min(1)
|
|
297
|
+
command: z2.array(z2.string().min(1)).min(1),
|
|
298
|
+
// The external wrapper supplies only broker placeholders and removes
|
|
299
|
+
// credential mounts. Do not hand provider logins to that runner on stdin.
|
|
300
|
+
brokeredCredentials: z2.boolean().optional()
|
|
298
301
|
}).strict().optional()
|
|
299
302
|
});
|
|
300
303
|
function workRoot(cfg) {
|
|
@@ -7801,6 +7804,30 @@ function decodeRunnerEvent(line) {
|
|
|
7801
7804
|
return value;
|
|
7802
7805
|
}
|
|
7803
7806
|
|
|
7807
|
+
// src/credential-broker/client-config.ts
|
|
7808
|
+
import { parse, stringify } from "smol-toml";
|
|
7809
|
+
var BROKERED_CREDENTIAL_NAMES = /* @__PURE__ */ new Set([
|
|
7810
|
+
"ANTHROPIC_API_KEY",
|
|
7811
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
7812
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
7813
|
+
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
|
7814
|
+
"CLAUDE_CODE_SESSION_ACCESS_TOKEN",
|
|
7815
|
+
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
|
7816
|
+
"CLAUDE_CODE_HOST_CREDS_FILE",
|
|
7817
|
+
"CODEX_API_KEY",
|
|
7818
|
+
"OPENAI_API_KEY",
|
|
7819
|
+
"OPENAI_ACCESS_TOKEN",
|
|
7820
|
+
"GH_TOKEN",
|
|
7821
|
+
"GITHUB_TOKEN",
|
|
7822
|
+
"GH_ENTERPRISE_TOKEN",
|
|
7823
|
+
"GITHUB_ENTERPRISE_TOKEN"
|
|
7824
|
+
]);
|
|
7825
|
+
function withoutBrokeredCredentials(values) {
|
|
7826
|
+
return Object.fromEntries(
|
|
7827
|
+
Object.entries(values).filter(([name]) => !BROKERED_CREDENTIAL_NAMES.has(name))
|
|
7828
|
+
);
|
|
7829
|
+
}
|
|
7830
|
+
|
|
7804
7831
|
// src/runner/launch.ts
|
|
7805
7832
|
function gzipBodiesSignal() {
|
|
7806
7833
|
let on = false;
|
|
@@ -8064,12 +8091,22 @@ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
|
|
|
8064
8091
|
}
|
|
8065
8092
|
try {
|
|
8066
8093
|
const wrapped = launch.wrapped === true;
|
|
8067
|
-
|
|
8068
|
-
|
|
8094
|
+
if (launch.brokeredCredentials && !wrapped)
|
|
8095
|
+
throw new Error("credential broker requires a wrapped runner");
|
|
8096
|
+
const inherited = runnerExecEnv(launch.env ?? process.env, wrapped);
|
|
8097
|
+
const env = launch.brokeredCredentials ? withoutBrokeredCredentials(inherited) : inherited;
|
|
8098
|
+
const directGrants = minted.deviceGrants.length > 0 ? launch.grants?.(minted.deviceGrants) ?? {} : {};
|
|
8099
|
+
const grants = launch.brokeredCredentials ? Object.fromEntries(
|
|
8100
|
+
Object.entries(directGrants).map(([key, values]) => [
|
|
8101
|
+
key,
|
|
8102
|
+
withoutBrokeredCredentials(values)
|
|
8103
|
+
])
|
|
8104
|
+
) : directGrants;
|
|
8105
|
+
const secrets = wrapped ? launch.secrets?.() ?? {} : void 0;
|
|
8069
8106
|
const input = {
|
|
8070
8107
|
baseUrl: opts.baseUrl,
|
|
8071
8108
|
credential: minted.credential,
|
|
8072
|
-
...
|
|
8109
|
+
...secrets ? { secrets: launch.brokeredCredentials ? withoutBrokeredCredentials(secrets) : secrets } : {},
|
|
8073
8110
|
...Object.keys(grants).length > 0 ? { grants } : {},
|
|
8074
8111
|
dispatch: payload,
|
|
8075
8112
|
turnId,
|
|
@@ -8319,7 +8356,7 @@ var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_T
|
|
|
8319
8356
|
import { readFile as readFile2 } from "fs/promises";
|
|
8320
8357
|
import { homedir as homedir6 } from "os";
|
|
8321
8358
|
import { join as join15 } from "path";
|
|
8322
|
-
import { parse } from "smol-toml";
|
|
8359
|
+
import { parse as parse2 } from "smol-toml";
|
|
8323
8360
|
|
|
8324
8361
|
// src/codex-images.ts
|
|
8325
8362
|
import { mkdtemp, rm, writeFile } from "fs/promises";
|
|
@@ -10761,6 +10798,7 @@ var CompanionSupervisor = class {
|
|
|
10761
10798
|
),
|
|
10762
10799
|
...this.config.runner?.command ? {
|
|
10763
10800
|
wrapped: true,
|
|
10801
|
+
brokeredCredentials: this.config.runner.brokeredCredentials,
|
|
10764
10802
|
secrets: () => loadSecretValuesTolerant(
|
|
10765
10803
|
(m) => this.log.warn({ err: m }, "Could not read the secrets file for a wrapped turn.")
|
|
10766
10804
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cabane/companion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.216",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Cabane Companion (headless): connect an agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
"cabane-companion": "./dist/bin.js"
|
|
15
15
|
},
|
|
16
16
|
"main": "./dist/cli.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/cli.js",
|
|
19
|
+
"./runtime": "./dist/runtime.js",
|
|
20
|
+
"./pairing": "./dist/pairing-config.js",
|
|
21
|
+
"./credential-broker": "./dist/credential-broker.js",
|
|
22
|
+
"./package.json": "./package.json",
|
|
23
|
+
"./*": "./*"
|
|
24
|
+
},
|
|
17
25
|
"files": [
|
|
18
26
|
"dist"
|
|
19
27
|
],
|
|
@@ -37,6 +45,7 @@
|
|
|
37
45
|
"commander": "12.1.0",
|
|
38
46
|
"eventsource-parser": "3.0.8",
|
|
39
47
|
"hono": "4.12.18",
|
|
48
|
+
"graphql": "16.11.0",
|
|
40
49
|
"picocolors": "1.1.1",
|
|
41
50
|
"pino": "10.3.1",
|
|
42
51
|
"smol-toml": "1.8.0",
|