@ai-codebot/daemon 0.1.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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/broker/handoff.d.ts +57 -0
- package/dist/broker/handoff.js +87 -0
- package/dist/broker/handoff.js.map +1 -0
- package/dist/broker/server.d.ts +34 -0
- package/dist/broker/server.js +204 -0
- package/dist/broker/server.js.map +1 -0
- package/dist/broker/token.d.ts +36 -0
- package/dist/broker/token.js +48 -0
- package/dist/broker/token.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +120 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/frames.d.ts +311 -0
- package/dist/frames.js +137 -0
- package/dist/frames.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +40 -0
- package/dist/launch.js +163 -0
- package/dist/launch.js.map +1 -0
- package/dist/logging.d.ts +27 -0
- package/dist/logging.js +91 -0
- package/dist/logging.js.map +1 -0
- package/dist/spawn/cli-runner.d.ts +91 -0
- package/dist/spawn/cli-runner.js +180 -0
- package/dist/spawn/cli-runner.js.map +1 -0
- package/dist/spawn/detect.d.ts +18 -0
- package/dist/spawn/detect.js +46 -0
- package/dist/spawn/detect.js.map +1 -0
- package/dist/spawn/disallowed-tools.d.ts +15 -0
- package/dist/spawn/disallowed-tools.js +30 -0
- package/dist/spawn/disallowed-tools.js.map +1 -0
- package/dist/spawn/git.d.ts +19 -0
- package/dist/spawn/git.js +56 -0
- package/dist/spawn/git.js.map +1 -0
- package/dist/spawn/llm-client.d.ts +52 -0
- package/dist/spawn/llm-client.js +61 -0
- package/dist/spawn/llm-client.js.map +1 -0
- package/dist/spawn/n2-runner.d.ts +33 -0
- package/dist/spawn/n2-runner.js +176 -0
- package/dist/spawn/n2-runner.js.map +1 -0
- package/dist/spawn/n2-tools.d.ts +44 -0
- package/dist/spawn/n2-tools.js +374 -0
- package/dist/spawn/n2-tools.js.map +1 -0
- package/dist/spawn/result-map.d.ts +40 -0
- package/dist/spawn/result-map.js +68 -0
- package/dist/spawn/result-map.js.map +1 -0
- package/dist/tunnel.d.ts +61 -0
- package/dist/tunnel.js +205 -0
- package/dist/tunnel.js.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ai-codebot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @ai-codebot/daemon
|
|
2
|
+
|
|
3
|
+
The slock **local-runner** daemon (PR4). Dials the cloud relay (`/daemon/connect`)
|
|
4
|
+
over an outbound WSS tunnel, speaks the PR2 frame protocol, and on a `Launch` runs a
|
|
5
|
+
local coding-agent in `cwd=repo` — brokering the model credential through a
|
|
6
|
+
`127.0.0.1` per-launch proxy so the **real LLM key never reaches the agent**.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
npx @ai-codebot/daemon connect \
|
|
10
|
+
--server-url wss://<your-relay-host>/daemon/connect \
|
|
11
|
+
--api-key ck_machine_… \
|
|
12
|
+
[--repo <path>] [--model-url <url>] [--cli claude|codex|auto] [--once]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The machine key may also be supplied via `CODEBOT_MACHINE_KEY`. The key is never
|
|
16
|
+
logged.
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
- `src/frames.ts` — TS + zod mirror of the server's `daemon_frames.py` (snake_case,
|
|
21
|
+
`MAX_FRAME_BYTES = 256 KiB`). Inbound frames are zod-validated at the boundary.
|
|
22
|
+
- `src/tunnel.ts` — `ws` client. Bearer in the `Authorization` header (never the
|
|
23
|
+
query), Hello/HelloAck handshake, Pong-on-Ping, exp-backoff + full-jitter
|
|
24
|
+
reconnect. Close codes: 4401 unauth / 4409 superseded → no reconnect.
|
|
25
|
+
- `src/broker/` — the `127.0.0.1` capability broker (C4). A per-launch
|
|
26
|
+
`crypto.randomBytes(32)` token is handed to the child as `ANTHROPIC_API_KEY`; the
|
|
27
|
+
broker stream-pipes requests to the real model endpoint, **injecting the real key**.
|
|
28
|
+
The real key + the `ck_machine_` machine key are stripped from the child env.
|
|
29
|
+
- `src/spawn/` — PRIMARY = real CLI (fail-fast until the OQ-3 flag contract is
|
|
30
|
+
verified); FALLBACK = the N2 local tool-loop (read/write/replace/run-tests/finish).
|
|
31
|
+
`disallowed-tools.ts` is the single source of truth for the blacklist.
|
|
32
|
+
|
|
33
|
+
## Security invariants (Tier-A)
|
|
34
|
+
|
|
35
|
+
1. The real model key never reaches the agent (env/argv/token-file/logs).
|
|
36
|
+
2. Per-launch token isolation: wrong token → 401, non-model path → 403, broker dies
|
|
37
|
+
with the launch.
|
|
38
|
+
3. The `ck_machine_` key is confined to the tunnel layer.
|
|
39
|
+
4. Disallowed tools stripped before spawn; deadline kills child+broker; SIGINT tears
|
|
40
|
+
down all launches (no orphans / `0600` token files).
|
|
41
|
+
5. No silent fallback: model unreachable / no model / no CLI+no N2 → loud `failed`.
|
|
42
|
+
6. Exactly one `Result` per launch (零静默).
|
|
43
|
+
|
|
44
|
+
## Tests
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pnpm --filter @ai-codebot/daemon test # automated tsx --test suite (CI)
|
|
48
|
+
node verify-daemon.mjs --server-url ws://127.0.0.1:8001/daemon/connect … # MANUAL E2E (NOT CI)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Real CLIs + a real local model cannot run in CI; `verify-daemon.mjs` documents the
|
|
52
|
+
owner-run E2E against a SECOND agent-service on `:8001` (never `:8000`).
|
|
53
|
+
|
|
54
|
+
## OQ-3 (PRIMARY CLI flags — unverified)
|
|
55
|
+
|
|
56
|
+
The exact headless / JSON-output / disallowed-tools flag NAMES for the shipped
|
|
57
|
+
Claude Code / Codex CLIs are **unverified**. They live behind the `CLI_FLAG_CONTRACT`
|
|
58
|
+
constant in `src/spawn/cli-runner.ts` with `verified: false`; the runner **fails
|
|
59
|
+
fast** (`CliContractUnverifiedError`) rather than emit a possibly-wrong invocation,
|
|
60
|
+
and the launch falls back to the fully-tested N2 path. To enable a CLI: verify the
|
|
61
|
+
real flags, flip `verified: true`, and add an integration proof in `verify-daemon.mjs`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential handoff to the spawned child (design §5 — C4/C7).
|
|
3
|
+
*
|
|
4
|
+
* `buildSpawnEnv` is the env boundary. It is an **allowlist**, never a denylist: it
|
|
5
|
+
* starts from an EMPTY env, copies ONLY known-safe vars (PATH/HOME/… — the shared
|
|
6
|
+
* `ENV_ALLOWLIST` source of truth, also used by the N2 `run_tests` scrub), then sets
|
|
7
|
+
* the model creds to the per-launch capability token + the broker URL. A denylist of
|
|
8
|
+
* "known provider key vars" is fundamentally incomplete (AWS, AZURE, GOOGLE creds, …
|
|
9
|
+
* leak through), so the real LLM key, the `ck_machine_` machine key, and ANY other
|
|
10
|
+
* inherited credential NEVER reach the child's env or argv.
|
|
11
|
+
*
|
|
12
|
+
* `createTokenFile` writes the token to a `0600` file inside a fresh per-launch 0700
|
|
13
|
+
* private dir (`O_EXCL` atomic create — defends a shared `/tmp` against symlink
|
|
14
|
+
* pre-placement). `removeTokenDir` is the idempotent cleanup contract.
|
|
15
|
+
*/
|
|
16
|
+
import { ENV_ALLOWLIST } from "../spawn/n2-tools.js";
|
|
17
|
+
export declare const CAPABILITY_TOKEN_FILE_ENV = "CODEBOT_CAPABILITY_TOKEN_FILE";
|
|
18
|
+
export declare const AGENT_CAPABILITIES_ENV = "CODEBOT_AGENT_CAPABILITIES";
|
|
19
|
+
export { ENV_ALLOWLIST };
|
|
20
|
+
export interface SpawnEnvParams {
|
|
21
|
+
/** The localhost broker base URL (`ANTHROPIC_BASE_URL`). */
|
|
22
|
+
readonly brokerBaseUrl: string;
|
|
23
|
+
/** The per-launch capability token (`ANTHROPIC_API_KEY`, NOT the real key). */
|
|
24
|
+
readonly token: string;
|
|
25
|
+
/** Path to the `0600` token file (`CODEBOT_CAPABILITY_TOKEN_FILE`). */
|
|
26
|
+
readonly tokenFile: string;
|
|
27
|
+
/** Advisory capability list (the path allowlist is the real boundary). */
|
|
28
|
+
readonly capabilities: readonly string[];
|
|
29
|
+
/** Base env to start from (defaults to the daemon's own env). */
|
|
30
|
+
readonly baseEnv?: NodeJS.ProcessEnv;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the child env via the shared ALLOWLIST: start empty, copy only known-safe
|
|
34
|
+
* vars, then set the model creds to the capability token + broker URL. The real key,
|
|
35
|
+
* the `ck_machine_` machine key, and every other inherited credential are absent by
|
|
36
|
+
* construction (they were never copied).
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildSpawnEnv(params: SpawnEnvParams): Record<string, string>;
|
|
39
|
+
/** A created token file: its path plus the private dir to remove on teardown. */
|
|
40
|
+
export interface TokenFileHandle {
|
|
41
|
+
/** Absolute path to the `0600` token file. */
|
|
42
|
+
readonly path: string;
|
|
43
|
+
/** The per-launch 0700 private dir holding it (removed by `removeTokenDir`). */
|
|
44
|
+
readonly dir: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Atomically create the capability token file in a fresh per-launch private dir.
|
|
48
|
+
*
|
|
49
|
+
* - `mkdtempSync` makes a unique `codebot-launch-XXXXXX` dir (mode 0700) under the OS
|
|
50
|
+
* tmpdir — an attacker cannot guess the path to pre-place a symlink.
|
|
51
|
+
* - The token file is opened with `O_WRONLY|O_CREAT|O_EXCL, 0600`: create-or-FAIL.
|
|
52
|
+
* `O_EXCL` refuses to follow a pre-existing symlink, closing the shared-`/tmp`
|
|
53
|
+
* symlink-attack window.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createTokenFile(token: string): TokenFileHandle;
|
|
56
|
+
/** Idempotently remove the per-launch token dir (and the token file within it). */
|
|
57
|
+
export declare function removeTokenDir(dir: string): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential handoff to the spawned child (design §5 — C4/C7).
|
|
3
|
+
*
|
|
4
|
+
* `buildSpawnEnv` is the env boundary. It is an **allowlist**, never a denylist: it
|
|
5
|
+
* starts from an EMPTY env, copies ONLY known-safe vars (PATH/HOME/… — the shared
|
|
6
|
+
* `ENV_ALLOWLIST` source of truth, also used by the N2 `run_tests` scrub), then sets
|
|
7
|
+
* the model creds to the per-launch capability token + the broker URL. A denylist of
|
|
8
|
+
* "known provider key vars" is fundamentally incomplete (AWS, AZURE, GOOGLE creds, …
|
|
9
|
+
* leak through), so the real LLM key, the `ck_machine_` machine key, and ANY other
|
|
10
|
+
* inherited credential NEVER reach the child's env or argv.
|
|
11
|
+
*
|
|
12
|
+
* `createTokenFile` writes the token to a `0600` file inside a fresh per-launch 0700
|
|
13
|
+
* private dir (`O_EXCL` atomic create — defends a shared `/tmp` against symlink
|
|
14
|
+
* pre-placement). `removeTokenDir` is the idempotent cleanup contract.
|
|
15
|
+
*/
|
|
16
|
+
import { constants as fsConstants, chmodSync, closeSync, mkdtempSync, openSync, rmSync, writeSync, } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { ENV_ALLOWLIST, isAllowedEnvVar } from "../spawn/n2-tools.js";
|
|
20
|
+
import { log } from "../logging.js";
|
|
21
|
+
export const CAPABILITY_TOKEN_FILE_ENV = "CODEBOT_CAPABILITY_TOKEN_FILE";
|
|
22
|
+
export const AGENT_CAPABILITIES_ENV = "CODEBOT_AGENT_CAPABILITIES";
|
|
23
|
+
// Re-exported so `buildSpawnEnv` and the N2 `run_tests` scrub demonstrably share ONE
|
|
24
|
+
// allowlist — there is no second, drifting copy.
|
|
25
|
+
export { ENV_ALLOWLIST };
|
|
26
|
+
const TOKEN_FILE_NAME = "capability.token";
|
|
27
|
+
/**
|
|
28
|
+
* Build the child env via the shared ALLOWLIST: start empty, copy only known-safe
|
|
29
|
+
* vars, then set the model creds to the capability token + broker URL. The real key,
|
|
30
|
+
* the `ck_machine_` machine key, and every other inherited credential are absent by
|
|
31
|
+
* construction (they were never copied).
|
|
32
|
+
*/
|
|
33
|
+
export function buildSpawnEnv(params) {
|
|
34
|
+
const base = params.baseEnv ?? process.env;
|
|
35
|
+
// 1. Start from an EMPTY env and copy ONLY allowlisted, known-safe names.
|
|
36
|
+
const env = {};
|
|
37
|
+
for (const [k, v] of Object.entries(base)) {
|
|
38
|
+
if (v !== undefined && isAllowedEnvVar(k))
|
|
39
|
+
env[k] = v;
|
|
40
|
+
}
|
|
41
|
+
// 2. Set the model creds to the per-launch token + broker URL. The child talks to
|
|
42
|
+
// the broker (which holds the real key), never the provider directly.
|
|
43
|
+
env["ANTHROPIC_BASE_URL"] = params.brokerBaseUrl;
|
|
44
|
+
env["ANTHROPIC_API_KEY"] = params.token;
|
|
45
|
+
// OpenAI-compatible CLIs (codex) read these — point them at the broker too.
|
|
46
|
+
env["OPENAI_BASE_URL"] = params.brokerBaseUrl;
|
|
47
|
+
env["OPENAI_API_KEY"] = params.token;
|
|
48
|
+
// 3. Token file + advisory capabilities.
|
|
49
|
+
env[CAPABILITY_TOKEN_FILE_ENV] = params.tokenFile;
|
|
50
|
+
env[AGENT_CAPABILITIES_ENV] = params.capabilities.join(",");
|
|
51
|
+
return env;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Atomically create the capability token file in a fresh per-launch private dir.
|
|
55
|
+
*
|
|
56
|
+
* - `mkdtempSync` makes a unique `codebot-launch-XXXXXX` dir (mode 0700) under the OS
|
|
57
|
+
* tmpdir — an attacker cannot guess the path to pre-place a symlink.
|
|
58
|
+
* - The token file is opened with `O_WRONLY|O_CREAT|O_EXCL, 0600`: create-or-FAIL.
|
|
59
|
+
* `O_EXCL` refuses to follow a pre-existing symlink, closing the shared-`/tmp`
|
|
60
|
+
* symlink-attack window.
|
|
61
|
+
*/
|
|
62
|
+
export function createTokenFile(token) {
|
|
63
|
+
// mkdtemp creates the dir with mode 0700 (POSIX); chmod defensively regardless.
|
|
64
|
+
const dir = mkdtempSync(join(tmpdir(), "codebot-launch-"));
|
|
65
|
+
chmodSync(dir, 0o700);
|
|
66
|
+
const path = join(dir, TOKEN_FILE_NAME);
|
|
67
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
68
|
+
try {
|
|
69
|
+
writeSync(fd, token, null, "utf8");
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
return { path, dir };
|
|
75
|
+
}
|
|
76
|
+
/** Idempotently remove the per-launch token dir (and the token file within it). */
|
|
77
|
+
export function removeTokenDir(dir) {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
log.warn("handoff.token_dir_remove_failed", {
|
|
83
|
+
error: err.message,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=handoff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff.js","sourceRoot":"","sources":["../../src/broker/handoff.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 127.0.0.1 per-launch capability broker (design §5 — C4, Tier-A V2).
|
|
3
|
+
*
|
|
4
|
+
* Topology:
|
|
5
|
+
* CLI ──HTTP──► broker(127.0.0.1:<ephemeral>) ──HTTPS──► real model endpoint
|
|
6
|
+
*
|
|
7
|
+
* The spawned agent sees only the per-launch capability TOKEN (as a Bearer); the
|
|
8
|
+
* broker validates it, then **stream-pipes** the request to the real
|
|
9
|
+
* `model_override.api_url` with the real `api_key` injected. The real key never
|
|
10
|
+
* reaches the child (env/argv/token-file/logs).
|
|
11
|
+
*
|
|
12
|
+
* Hard rules:
|
|
13
|
+
* - Bind 127.0.0.1 only, ephemeral port (`listen(0, "127.0.0.1")`).
|
|
14
|
+
* - Bearer must equal the launch token → else 401.
|
|
15
|
+
* - Only the model-provider API paths proxy (e.g. `/v1/messages`,
|
|
16
|
+
* `/v1/chat/completions`) → other paths 403.
|
|
17
|
+
* - STREAM-PIPE, never buffer — SSE / long generations must flow through.
|
|
18
|
+
* - The real key is injected ONLY on the upstream request; never logged.
|
|
19
|
+
*/
|
|
20
|
+
import { type Server } from "node:http";
|
|
21
|
+
import type { GrantRegistry } from "./token.js";
|
|
22
|
+
export interface BrokerHandle {
|
|
23
|
+
/** The localhost base URL to hand the child as `ANTHROPIC_BASE_URL`. */
|
|
24
|
+
readonly baseUrl: string;
|
|
25
|
+
/** Idempotent shutdown — stops accepting, closes the listener. */
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Start the per-launch broker on an ephemeral 127.0.0.1 port. Resolves once the
|
|
30
|
+
* listener is bound and the base URL is known.
|
|
31
|
+
*/
|
|
32
|
+
export declare function startBroker(grants: GrantRegistry): Promise<{
|
|
33
|
+
server: Server;
|
|
34
|
+
} & BrokerHandle>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 127.0.0.1 per-launch capability broker (design §5 — C4, Tier-A V2).
|
|
3
|
+
*
|
|
4
|
+
* Topology:
|
|
5
|
+
* CLI ──HTTP──► broker(127.0.0.1:<ephemeral>) ──HTTPS──► real model endpoint
|
|
6
|
+
*
|
|
7
|
+
* The spawned agent sees only the per-launch capability TOKEN (as a Bearer); the
|
|
8
|
+
* broker validates it, then **stream-pipes** the request to the real
|
|
9
|
+
* `model_override.api_url` with the real `api_key` injected. The real key never
|
|
10
|
+
* reaches the child (env/argv/token-file/logs).
|
|
11
|
+
*
|
|
12
|
+
* Hard rules:
|
|
13
|
+
* - Bind 127.0.0.1 only, ephemeral port (`listen(0, "127.0.0.1")`).
|
|
14
|
+
* - Bearer must equal the launch token → else 401.
|
|
15
|
+
* - Only the model-provider API paths proxy (e.g. `/v1/messages`,
|
|
16
|
+
* `/v1/chat/completions`) → other paths 403.
|
|
17
|
+
* - STREAM-PIPE, never buffer — SSE / long generations must flow through.
|
|
18
|
+
* - The real key is injected ONLY on the upstream request; never logged.
|
|
19
|
+
*/
|
|
20
|
+
import { createServer, request as httpRequest, } from "node:http";
|
|
21
|
+
import { request as httpsRequest } from "node:https";
|
|
22
|
+
import { log } from "../logging.js";
|
|
23
|
+
const BEARER_PREFIX = "Bearer ";
|
|
24
|
+
// Allowlisted upstream API paths (the model-provider surface). The inbound path
|
|
25
|
+
// from the agent must EXACTLY equal one of these — anything else is rejected 403.
|
|
26
|
+
// The upstream base-path prefix is added separately in `buildUpstream`, so a
|
|
27
|
+
// prefix/suffix/`..`-traversal trick (e.g. `/attacker/v1/messages`) cannot smuggle
|
|
28
|
+
// the real key onto an arbitrary upstream path.
|
|
29
|
+
const ALLOWED_PATHS = new Set([
|
|
30
|
+
"/v1/messages",
|
|
31
|
+
"/v1/chat/completions",
|
|
32
|
+
"/v1/complete",
|
|
33
|
+
]);
|
|
34
|
+
// Deadline-bound socket guards: a slow-drip request/headers stream must not be able
|
|
35
|
+
// to pin a connection (and thus block launch teardown) indefinitely.
|
|
36
|
+
const REQUEST_TIMEOUT_MS = 10 * 60 * 1000; // whole-request ceiling (long generations)
|
|
37
|
+
const HEADERS_TIMEOUT_MS = 60 * 1000; // headers must arrive within a minute
|
|
38
|
+
function extractBearer(req) {
|
|
39
|
+
const header = req.headers["authorization"];
|
|
40
|
+
if (typeof header !== "string" || !header.startsWith(BEARER_PREFIX))
|
|
41
|
+
return null;
|
|
42
|
+
const token = header.slice(BEARER_PREFIX.length).trim();
|
|
43
|
+
return token || null;
|
|
44
|
+
}
|
|
45
|
+
function isAllowedPath(pathname) {
|
|
46
|
+
// EXACT match only — never `endsWith`. A prefix like `/attacker/v1/messages`
|
|
47
|
+
// or a `/v1/../x/v1/messages` traversal must NOT inject the real key upstream.
|
|
48
|
+
return ALLOWED_PATHS.has(pathname);
|
|
49
|
+
}
|
|
50
|
+
function sendJson(res, status, body) {
|
|
51
|
+
const payload = JSON.stringify(body);
|
|
52
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
53
|
+
res.end(payload);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the upstream request options from the grant's real upstream URL, merging
|
|
57
|
+
* the inbound path/query onto the upstream origin and injecting the real key.
|
|
58
|
+
*/
|
|
59
|
+
function buildUpstream(upstreamUrl, upstreamKey, req) {
|
|
60
|
+
const base = new URL(upstreamUrl);
|
|
61
|
+
const inbound = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
62
|
+
// Preserve any base path on the upstream URL, then append the inbound pathname.
|
|
63
|
+
const basePath = base.pathname.replace(/\/$/, "");
|
|
64
|
+
const path = `${basePath}${inbound.pathname}${inbound.search}`;
|
|
65
|
+
const isHttps = base.protocol === "https:";
|
|
66
|
+
const headers = {};
|
|
67
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
68
|
+
if (v === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const lower = k.toLowerCase();
|
|
71
|
+
// Strip hop-by-hop + the inbound (token) auth/host — we inject our own.
|
|
72
|
+
if (lower === "host" ||
|
|
73
|
+
lower === "authorization" ||
|
|
74
|
+
lower === "connection" ||
|
|
75
|
+
lower === "x-api-key") {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
headers[k] = v;
|
|
79
|
+
}
|
|
80
|
+
headers["host"] = base.host;
|
|
81
|
+
// Inject the REAL upstream credential. Both header styles cover Anthropic
|
|
82
|
+
// (`x-api-key`) and OpenAI-compatible (`Authorization: Bearer`) providers.
|
|
83
|
+
headers["authorization"] = `Bearer ${upstreamKey}`;
|
|
84
|
+
headers["x-api-key"] = upstreamKey;
|
|
85
|
+
return {
|
|
86
|
+
isHttps,
|
|
87
|
+
options: {
|
|
88
|
+
protocol: base.protocol,
|
|
89
|
+
hostname: base.hostname,
|
|
90
|
+
port: base.port ? Number(base.port) : isHttps ? 443 : 80,
|
|
91
|
+
method: req.method,
|
|
92
|
+
path,
|
|
93
|
+
headers,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function handleRequest(req, res, grants) {
|
|
98
|
+
const token = extractBearer(req);
|
|
99
|
+
if (token === null) {
|
|
100
|
+
sendJson(res, 401, { error: "missing bearer token" });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const grant = grants.resolve(token);
|
|
104
|
+
if (grant === null) {
|
|
105
|
+
// Wrong/expired token. Do NOT echo the token.
|
|
106
|
+
sendJson(res, 401, { error: "invalid token" });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
110
|
+
if (!isAllowedPath(pathname)) {
|
|
111
|
+
log.warn("broker.path_rejected", {
|
|
112
|
+
launch_id: grant.launchId,
|
|
113
|
+
path: pathname,
|
|
114
|
+
});
|
|
115
|
+
sendJson(res, 403, { error: "path not allowed" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const { isHttps, options } = buildUpstream(grant.upstreamUrl, grant.upstreamKey, req);
|
|
119
|
+
const doRequest = isHttps ? httpsRequest : httpRequest;
|
|
120
|
+
const upstream = doRequest(options, (upstreamRes) => {
|
|
121
|
+
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
122
|
+
// A mid-stream upstream TCP reset emits an `error` on the response body stream.
|
|
123
|
+
// Without a listener that throws as an unhandled EventEmitter error and CRASHES
|
|
124
|
+
// the daemon — so handle it: log + tear down the client response cleanly.
|
|
125
|
+
upstreamRes.on("error", (err) => {
|
|
126
|
+
log.warn("broker.upstream_stream_error", {
|
|
127
|
+
launch_id: grant.launchId,
|
|
128
|
+
error: err.message,
|
|
129
|
+
});
|
|
130
|
+
res.destroy();
|
|
131
|
+
});
|
|
132
|
+
// STREAM-PIPE the response — never buffer (SSE / long generations must flow).
|
|
133
|
+
upstreamRes.pipe(res);
|
|
134
|
+
});
|
|
135
|
+
upstream.on("error", (err) => {
|
|
136
|
+
log.warn("broker.upstream_error", {
|
|
137
|
+
launch_id: grant.launchId,
|
|
138
|
+
error: err.message,
|
|
139
|
+
});
|
|
140
|
+
if (!res.headersSent) {
|
|
141
|
+
sendJson(res, 502, { error: "upstream unreachable" });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
res.destroy();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
// Reverse direction: if the client connection errors mid-flight, abort upstream.
|
|
148
|
+
res.on("error", () => upstream.destroy());
|
|
149
|
+
// STREAM-PIPE the request body upstream — never buffer.
|
|
150
|
+
req.pipe(upstream);
|
|
151
|
+
req.on("error", () => upstream.destroy());
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Start the per-launch broker on an ephemeral 127.0.0.1 port. Resolves once the
|
|
155
|
+
* listener is bound and the base URL is known.
|
|
156
|
+
*/
|
|
157
|
+
export function startBroker(grants) {
|
|
158
|
+
return new Promise((resolvePromise, reject) => {
|
|
159
|
+
const server = createServer((req, res) => {
|
|
160
|
+
try {
|
|
161
|
+
handleRequest(req, res, grants);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
log.error("broker.handler_crash", { error: err.message });
|
|
165
|
+
if (!res.headersSent)
|
|
166
|
+
sendJson(res, 500, { error: "broker error" });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
server.on("error", reject);
|
|
170
|
+
// Deadline-bound socket guards so a slow-drip request can't pin a connection
|
|
171
|
+
// (and block launch teardown) forever.
|
|
172
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
173
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
174
|
+
// 127.0.0.1 only — never expose the broker beyond loopback.
|
|
175
|
+
server.listen(0, "127.0.0.1", () => {
|
|
176
|
+
const addr = server.address();
|
|
177
|
+
if (addr === null || typeof addr === "string") {
|
|
178
|
+
reject(new Error("broker failed to bind an ephemeral port"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
182
|
+
let closed = false;
|
|
183
|
+
const handle = {
|
|
184
|
+
server,
|
|
185
|
+
baseUrl,
|
|
186
|
+
close() {
|
|
187
|
+
if (closed)
|
|
188
|
+
return Promise.resolve();
|
|
189
|
+
closed = true;
|
|
190
|
+
return new Promise((resolveClose) => {
|
|
191
|
+
server.close(() => resolveClose());
|
|
192
|
+
// Drop idle keep-alive sockets AND kill active in-flight connections so
|
|
193
|
+
// close() resolves promptly — an agent must not be able to hold teardown
|
|
194
|
+
// open with a slow-drip / long-lived streaming connection (Node 18.2+).
|
|
195
|
+
server.closeIdleConnections?.();
|
|
196
|
+
server.closeAllConnections?.();
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
resolvePromise(handle);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/broker/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-launch capability token (design §5 — C4 security crux).
|
|
3
|
+
*
|
|
4
|
+
* The spawned coding-agent CLI runs with full shell access (`bypassPermissions`);
|
|
5
|
+
* if it held the real LLM key it could exfiltrate it. So the agent is handed only
|
|
6
|
+
* this opaque per-launch token. The broker (`broker/server.ts`) holds the mapping
|
|
7
|
+
* token -> real upstream credential; the real key NEVER leaves the daemon process.
|
|
8
|
+
*
|
|
9
|
+
* - 32 bytes of CSPRNG entropy, base64url-encoded.
|
|
10
|
+
* - In-memory, keyed by `launch_id`, fresh per launch.
|
|
11
|
+
* - NEVER logged.
|
|
12
|
+
*/
|
|
13
|
+
export interface CapabilityGrant {
|
|
14
|
+
readonly launchId: string;
|
|
15
|
+
readonly token: string;
|
|
16
|
+
/** Real upstream model endpoint base (e.g. `https://host/v1`). */
|
|
17
|
+
readonly upstreamUrl: string;
|
|
18
|
+
/** Real upstream credential — injected by the broker, never handed to the child. */
|
|
19
|
+
readonly upstreamKey: string;
|
|
20
|
+
}
|
|
21
|
+
/** Mint a fresh capability token (32-byte CSPRNG, base64url). */
|
|
22
|
+
export declare function mintToken(): string;
|
|
23
|
+
/**
|
|
24
|
+
* In-memory registry of live capability grants, keyed by token. One grant per
|
|
25
|
+
* launch; the broker validates an incoming Bearer against this and looks up the
|
|
26
|
+
* real upstream credential to inject. Cleared on launch teardown.
|
|
27
|
+
*/
|
|
28
|
+
export declare class GrantRegistry {
|
|
29
|
+
private readonly byToken;
|
|
30
|
+
create(launchId: string, upstreamUrl: string, upstreamKey: string): CapabilityGrant;
|
|
31
|
+
/** Resolve a Bearer token to its grant, or null if unknown/expired. */
|
|
32
|
+
resolve(token: string): CapabilityGrant | null;
|
|
33
|
+
/** Idempotently drop a grant (teardown). */
|
|
34
|
+
revoke(token: string): void;
|
|
35
|
+
get size(): number;
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-launch capability token (design §5 — C4 security crux).
|
|
3
|
+
*
|
|
4
|
+
* The spawned coding-agent CLI runs with full shell access (`bypassPermissions`);
|
|
5
|
+
* if it held the real LLM key it could exfiltrate it. So the agent is handed only
|
|
6
|
+
* this opaque per-launch token. The broker (`broker/server.ts`) holds the mapping
|
|
7
|
+
* token -> real upstream credential; the real key NEVER leaves the daemon process.
|
|
8
|
+
*
|
|
9
|
+
* - 32 bytes of CSPRNG entropy, base64url-encoded.
|
|
10
|
+
* - In-memory, keyed by `launch_id`, fresh per launch.
|
|
11
|
+
* - NEVER logged.
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
const TOKEN_BYTES = 32;
|
|
15
|
+
/** Mint a fresh capability token (32-byte CSPRNG, base64url). */
|
|
16
|
+
export function mintToken() {
|
|
17
|
+
return randomBytes(TOKEN_BYTES).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* In-memory registry of live capability grants, keyed by token. One grant per
|
|
21
|
+
* launch; the broker validates an incoming Bearer against this and looks up the
|
|
22
|
+
* real upstream credential to inject. Cleared on launch teardown.
|
|
23
|
+
*/
|
|
24
|
+
export class GrantRegistry {
|
|
25
|
+
byToken = new Map();
|
|
26
|
+
create(launchId, upstreamUrl, upstreamKey) {
|
|
27
|
+
const grant = {
|
|
28
|
+
launchId,
|
|
29
|
+
token: mintToken(),
|
|
30
|
+
upstreamUrl,
|
|
31
|
+
upstreamKey,
|
|
32
|
+
};
|
|
33
|
+
this.byToken.set(grant.token, grant);
|
|
34
|
+
return grant;
|
|
35
|
+
}
|
|
36
|
+
/** Resolve a Bearer token to its grant, or null if unknown/expired. */
|
|
37
|
+
resolve(token) {
|
|
38
|
+
return this.byToken.get(token) ?? null;
|
|
39
|
+
}
|
|
40
|
+
/** Idempotently drop a grant (teardown). */
|
|
41
|
+
revoke(token) {
|
|
42
|
+
this.byToken.delete(token);
|
|
43
|
+
}
|
|
44
|
+
get size() {
|
|
45
|
+
return this.byToken.size;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.js","sourceRoot":"","sources":["../../src/broker/token.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,MAAM,WAAW,GAAG,EAAE,CAAC;AAWvB,iEAAiE;AACjE,MAAM,UAAU,SAAS;IACvB,OAAO,WAAW,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AACxD,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,aAAa;IACP,OAAO,GAAG,IAAI,GAAG,EAA2B,CAAC;IAE9D,MAAM,CACJ,QAAgB,EAChB,WAAmB,EACnB,WAAmB;QAEnB,MAAM,KAAK,GAAoB;YAC7B,QAAQ;YACR,KAAK,EAAE,SAAS,EAAE;YAClB,WAAW;YACX,WAAW;SACZ,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QACrC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,uEAAuE;IACvE,OAAO,CAAC,KAAa;QACnB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC;IACzC,CAAC;IAED,4CAA4C;IAC5C,MAAM,CAAC,KAAa;QAClB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7B,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;IAC3B,CAAC;CACF"}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `codebot-daemon connect …` command wiring (design §3/§4).
|
|
3
|
+
*
|
|
4
|
+
* Parses + validates flags, wires the tunnel to the launch manager, and translates
|
|
5
|
+
* the terminal tunnel exit into a process exit code:
|
|
6
|
+
* - 4401 unauth → exit 1 (dead key — don't hammer).
|
|
7
|
+
* - 4409 superseded / `--once` done → exit 0 (clean).
|
|
8
|
+
*
|
|
9
|
+
* NEVER logs the api key. SIGINT/SIGTERM tears down all live launches first.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DAEMON_VERSION = "0.1.0";
|
|
12
|
+
/** Entry for the `connect` subcommand. Returns the process exit code. */
|
|
13
|
+
export declare function runConnect(argv: readonly string[]): Promise<number>;
|
|
14
|
+
/** Top-level CLI dispatch. */
|
|
15
|
+
export declare function main(argv: readonly string[]): Promise<number>;
|