@basein/runner 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 +201 -0
- package/README.md +276 -0
- package/dist/auth/client.d.ts +85 -0
- package/dist/auth/client.js +284 -0
- package/dist/bin/bir-hooks.d.ts +48 -0
- package/dist/bin/bir-hooks.js +201 -0
- package/dist/bin/bir-proxy.d.ts +45 -0
- package/dist/bin/bir-proxy.js +207 -0
- package/dist/bin/bir-scenario.d.ts +24 -0
- package/dist/bin/bir-scenario.js +177 -0
- package/dist/bin/bir.d.ts +21 -0
- package/dist/bin/bir.js +876 -0
- package/dist/config/adapters/claude-code.d.ts +76 -0
- package/dist/config/adapters/claude-code.js +181 -0
- package/dist/config/adapters/generic.d.ts +17 -0
- package/dist/config/adapters/generic.js +36 -0
- package/dist/config/generate.d.ts +127 -0
- package/dist/config/generate.js +114 -0
- package/dist/config/resolve.d.ts +68 -0
- package/dist/config/resolve.js +132 -0
- package/dist/control/client.d.ts +56 -0
- package/dist/control/client.js +86 -0
- package/dist/control/correlation.d.ts +86 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/discovery.d.ts +50 -0
- package/dist/control/discovery.js +123 -0
- package/dist/control/ordering.d.ts +38 -0
- package/dist/control/ordering.js +44 -0
- package/dist/control/paths.d.ts +32 -0
- package/dist/control/paths.js +56 -0
- package/dist/control/server.d.ts +272 -0
- package/dist/control/server.js +1131 -0
- package/dist/control/transcript.d.ts +75 -0
- package/dist/control/transcript.js +241 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +32 -0
- package/dist/jsonrpc/framing.d.ts +49 -0
- package/dist/jsonrpc/framing.js +143 -0
- package/dist/jsonrpc/types.d.ts +52 -0
- package/dist/jsonrpc/types.js +46 -0
- package/dist/proxy/intercept.d.ts +55 -0
- package/dist/proxy/intercept.js +147 -0
- package/dist/proxy/relay.d.ts +97 -0
- package/dist/proxy/relay.js +166 -0
- package/dist/proxy/session.d.ts +116 -0
- package/dist/proxy/session.js +319 -0
- package/dist/record/housekeeping.d.ts +34 -0
- package/dist/record/housekeeping.js +39 -0
- package/dist/record/queue.d.ts +48 -0
- package/dist/record/queue.js +96 -0
- package/dist/record/recorder.d.ts +111 -0
- package/dist/record/recorder.js +39 -0
- package/dist/record/redact.d.ts +37 -0
- package/dist/record/redact.js +119 -0
- package/dist/record/remote-recorder.d.ts +110 -0
- package/dist/record/remote-recorder.js +301 -0
- package/dist/record/truncate.d.ts +36 -0
- package/dist/record/truncate.js +85 -0
- package/dist/replay/bundle.d.ts +36 -0
- package/dist/replay/bundle.js +89 -0
- package/dist/replay/controller.d.ts +300 -0
- package/dist/replay/controller.js +807 -0
- package/dist/replay/coverage.d.ts +41 -0
- package/dist/replay/coverage.js +56 -0
- package/dist/replay/derive.d.ts +58 -0
- package/dist/replay/derive.js +166 -0
- package/dist/replay/executor.d.ts +78 -0
- package/dist/replay/executor.js +233 -0
- package/dist/replay/logic.d.ts +31 -0
- package/dist/replay/logic.js +50 -0
- package/dist/replay/plan.d.ts +181 -0
- package/dist/replay/plan.js +397 -0
- package/dist/replay/pricing.d.ts +41 -0
- package/dist/replay/pricing.js +76 -0
- package/dist/replay/source-run.d.ts +50 -0
- package/dist/replay/source-run.js +98 -0
- package/dist/replay/tool-error.d.ts +22 -0
- package/dist/replay/tool-error.js +60 -0
- package/dist/replay/types.d.ts +116 -0
- package/dist/replay/types.js +35 -0
- package/dist/upstream/client.d.ts +78 -0
- package/dist/upstream/client.js +114 -0
- package/dist/upstream/http-client.d.ts +78 -0
- package/dist/upstream/http-client.js +261 -0
- package/dist/upstream/lazy-client.d.ts +31 -0
- package/dist/upstream/lazy-client.js +53 -0
- package/dist/upstream/stdio-client.d.ts +57 -0
- package/dist/upstream/stdio-client.js +203 -0
- package/dist/util/log.d.ts +27 -0
- package/dist/util/log.js +51 -0
- package/dist/util/version.d.ts +2 -0
- package/dist/util/version.js +40 -0
- package/docs/BaseInstRunner.md +621 -0
- package/docs/calculatedReplay.md +1185 -0
- package/docs/calculatedReplayGuide.md +448 -0
- package/docs/installRun.md +413 -0
- package/docs/mcpmark.md +752 -0
- package/docs/quickstart.md +201 -0
- package/docs/t-bench.md +394 -0
- package/package.json +56 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config/resolve — which MCP servers a client would actually use for a cwd.
|
|
3
|
+
*
|
|
4
|
+
* Ported from RRepeat's `mcp-registry.ts`, and extended to report **which scope
|
|
5
|
+
* won**, because that is what Phase 7.3 needs: rewriting a file whose entry is
|
|
6
|
+
* shadowed by a higher-precedence scope changes nothing at all, and the user is
|
|
7
|
+
* left with an install that never runs.
|
|
8
|
+
*
|
|
9
|
+
* Claude Code merges three scopes, highest precedence first. The whole winning
|
|
10
|
+
* entry is used — fields are never merged across scopes:
|
|
11
|
+
* 1. **Local** — `~/.claude.json` → `projects[<cwd>].mcpServers`
|
|
12
|
+
* 2. **Project** — `<cwd>/.mcp.json` → `mcpServers`
|
|
13
|
+
* 3. **User** — `~/.claude.json` → top-level `mcpServers`
|
|
14
|
+
*
|
|
15
|
+
* THE WINDOWS PATH TRAP (Phase 7.1). Claude Code stores the local-scope project
|
|
16
|
+
* key with forward slashes while `process.cwd()` returns backslashes, and it
|
|
17
|
+
* keeps one entry per spelling it has seen — so the backslash twin routinely
|
|
18
|
+
* exists carrying `mcpServers: {}` while the forward-slash one holds the real
|
|
19
|
+
* config. Matching the literal key misses every time; matching a *normalised*
|
|
20
|
+
* path, and refusing to stop at an empty twin, is what makes local scope work.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { normalizePath } from "../control/paths.js";
|
|
26
|
+
export function isRemote(config) {
|
|
27
|
+
return (config.type === "http" || config.type === "sse");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Strip a UTF-8 byte order mark.
|
|
31
|
+
*
|
|
32
|
+
* Notepad, VS Code's "UTF-8 with BOM", and PowerShell's `Out-File -Encoding utf8`
|
|
33
|
+
* all write one, so it is the normal shape of a config a Windows operator edited
|
|
34
|
+
* by hand. `readFileSync(path, "utf8")` decodes it to U+FEFF rather than
|
|
35
|
+
* consuming it, and `JSON.parse` then rejects the file as malformed — which
|
|
36
|
+
* surfaced as `bir install` reporting success while wrapping nothing.
|
|
37
|
+
*/
|
|
38
|
+
function stripBom(text) {
|
|
39
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
40
|
+
}
|
|
41
|
+
/** Parse a JSON file. `undefined` if absent; throws if present but malformed. */
|
|
42
|
+
export function parseJsonFile(path) {
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = readFileSync(path, "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined; // absent / unreadable — "not configured"
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(stripBom(raw));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new Error(`invalid JSON at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function readMcpServers(path, required) {
|
|
58
|
+
const parsed = parseJsonFile(path);
|
|
59
|
+
if (parsed === undefined) {
|
|
60
|
+
if (required)
|
|
61
|
+
throw new Error(`MCP config not readable at ${path}`);
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
return parsed.mcpServers ?? {};
|
|
65
|
+
}
|
|
66
|
+
export function claudeJsonPath(override) {
|
|
67
|
+
return override ?? process.env.BIR_CLAUDE_JSON ?? join(homedir(), ".claude.json");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The local scope's servers for `cwd`. The literal key is tried first, then
|
|
71
|
+
* every key is compared as a normalised path — and a key resolving to *no*
|
|
72
|
+
* servers does not end the search, because the empty twin is exactly the shape
|
|
73
|
+
* the Windows miss takes on disk.
|
|
74
|
+
*/
|
|
75
|
+
export function projectScope(projects, cwd) {
|
|
76
|
+
if (!projects)
|
|
77
|
+
return {};
|
|
78
|
+
const exact = projects[cwd]?.mcpServers;
|
|
79
|
+
if (exact && Object.keys(exact).length > 0)
|
|
80
|
+
return exact;
|
|
81
|
+
const want = normalizePath(cwd);
|
|
82
|
+
for (const [key, entry] of Object.entries(projects)) {
|
|
83
|
+
const servers = entry?.mcpServers;
|
|
84
|
+
if (servers && Object.keys(servers).length > 0 && normalizePath(key) === want)
|
|
85
|
+
return servers;
|
|
86
|
+
}
|
|
87
|
+
return exact ?? {};
|
|
88
|
+
}
|
|
89
|
+
/** The key under which Claude Code stores (or would store) this cwd's local scope. */
|
|
90
|
+
export function projectKeyFor(projects, cwd) {
|
|
91
|
+
if (!projects)
|
|
92
|
+
return cwd;
|
|
93
|
+
if (cwd in projects)
|
|
94
|
+
return cwd;
|
|
95
|
+
const want = normalizePath(cwd);
|
|
96
|
+
for (const key of Object.keys(projects)) {
|
|
97
|
+
if (normalizePath(key) === want)
|
|
98
|
+
return key;
|
|
99
|
+
}
|
|
100
|
+
return cwd;
|
|
101
|
+
}
|
|
102
|
+
/** Every MCP server that would be live for `cwd`, with the scope that won. */
|
|
103
|
+
export function resolveServers(cwd, opts = {}) {
|
|
104
|
+
if (opts.configPath) {
|
|
105
|
+
const servers = readMcpServers(opts.configPath, true);
|
|
106
|
+
return Object.entries(servers).map(([name, config]) => ({
|
|
107
|
+
name,
|
|
108
|
+
config,
|
|
109
|
+
scope: "explicit",
|
|
110
|
+
shadowed: [],
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const parsed = parseJsonFile(claudeJsonPath(opts.claudeJsonPath));
|
|
114
|
+
const byScope = [
|
|
115
|
+
["local", projectScope(parsed?.projects, cwd)],
|
|
116
|
+
["project", readMcpServers(join(cwd, ".mcp.json"), false)],
|
|
117
|
+
["user", parsed?.mcpServers ?? {}],
|
|
118
|
+
];
|
|
119
|
+
const winners = new Map();
|
|
120
|
+
for (const [scope, servers] of byScope) {
|
|
121
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
122
|
+
const existing = winners.get(name);
|
|
123
|
+
if (existing) {
|
|
124
|
+
existing.shadowed.push(scope);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
winners.set(name, { name, config, scope, shadowed: [] });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...winners.values()];
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control/client — the proxy's side of the control plane.
|
|
3
|
+
*
|
|
4
|
+
* A thin authenticated HTTP client. Every call is best-effort and returns rather
|
|
5
|
+
* than throws: the control server going away must downgrade a proxy to Tier 2,
|
|
6
|
+
* never break the tool call the host is waiting on (§10).
|
|
7
|
+
*/
|
|
8
|
+
import type { ProxyStepReport } from "./correlation.js";
|
|
9
|
+
export interface RegisterResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
runId?: string;
|
|
13
|
+
correlation?: "injected" | "fingerprint";
|
|
14
|
+
/** Whether calculated replay is on, i.e. whether to open the work loop. */
|
|
15
|
+
replay?: boolean;
|
|
16
|
+
pollHoldMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/** One `tools/call` the control server wants this proxy to run (§16.2). */
|
|
19
|
+
export interface ProxyWork {
|
|
20
|
+
workId: string;
|
|
21
|
+
/** Upstream-local name — `navigate_page`, not `mcp__server__navigate_page`. */
|
|
22
|
+
toolName: string;
|
|
23
|
+
arguments: unknown;
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class ControlClient {
|
|
27
|
+
private readonly url;
|
|
28
|
+
private readonly token;
|
|
29
|
+
private readonly timeoutMs;
|
|
30
|
+
constructor(url: string, token: string, timeoutMs?: number);
|
|
31
|
+
register(info: {
|
|
32
|
+
serverName: string;
|
|
33
|
+
pid: number;
|
|
34
|
+
cwd: string;
|
|
35
|
+
/** This proxy's package version, so the server can spot a half-upgraded machine. */
|
|
36
|
+
version?: string;
|
|
37
|
+
}): Promise<RegisterResult | undefined>;
|
|
38
|
+
/** Report one completed MCP call. Resolves false when the send was dropped. */
|
|
39
|
+
report(step: ProxyStepReport): Promise<boolean>;
|
|
40
|
+
health(): Promise<Record<string, unknown> | undefined>;
|
|
41
|
+
/**
|
|
42
|
+
* Park until the control server has a tool call for this proxy, or the hold
|
|
43
|
+
* elapses (docs/calculatedReplay.md §16.2).
|
|
44
|
+
*
|
|
45
|
+
* Its own timeout must exceed the server's hold, or every poll would look like
|
|
46
|
+
* a failure to the client that is deliberately waiting. `undefined` covers both
|
|
47
|
+
* "nothing to do" and "the server went away"; the caller re-polls either way,
|
|
48
|
+
* with a small delay so a dead server is not busy-looped.
|
|
49
|
+
*/
|
|
50
|
+
poll(serverName: string, holdMs: number): Promise<ProxyWork | undefined>;
|
|
51
|
+
/** Hand back one dispatched call's result, or the reason it could not run. */
|
|
52
|
+
result(workId: string, result?: unknown, error?: string): Promise<void>;
|
|
53
|
+
private post;
|
|
54
|
+
private request;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control/client — the proxy's side of the control plane.
|
|
3
|
+
*
|
|
4
|
+
* A thin authenticated HTTP client. Every call is best-effort and returns rather
|
|
5
|
+
* than throws: the control server going away must downgrade a proxy to Tier 2,
|
|
6
|
+
* never break the tool call the host is waiting on (§10).
|
|
7
|
+
*/
|
|
8
|
+
import { errText, logDetail } from "../util/log.js";
|
|
9
|
+
export class ControlClient {
|
|
10
|
+
url;
|
|
11
|
+
token;
|
|
12
|
+
timeoutMs;
|
|
13
|
+
constructor(url, token, timeoutMs = 5_000) {
|
|
14
|
+
this.url = url.replace(/\/+$/, "");
|
|
15
|
+
this.token = token;
|
|
16
|
+
this.timeoutMs = timeoutMs;
|
|
17
|
+
}
|
|
18
|
+
async register(info) {
|
|
19
|
+
const body = await this.post("/proxy/register", info);
|
|
20
|
+
return body;
|
|
21
|
+
}
|
|
22
|
+
/** Report one completed MCP call. Resolves false when the send was dropped. */
|
|
23
|
+
async report(step) {
|
|
24
|
+
return (await this.post("/proxy/step", step)) !== undefined;
|
|
25
|
+
}
|
|
26
|
+
async health() {
|
|
27
|
+
return (await this.request("GET", "/health"));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Park until the control server has a tool call for this proxy, or the hold
|
|
31
|
+
* elapses (docs/calculatedReplay.md §16.2).
|
|
32
|
+
*
|
|
33
|
+
* Its own timeout must exceed the server's hold, or every poll would look like
|
|
34
|
+
* a failure to the client that is deliberately waiting. `undefined` covers both
|
|
35
|
+
* "nothing to do" and "the server went away"; the caller re-polls either way,
|
|
36
|
+
* with a small delay so a dead server is not busy-looped.
|
|
37
|
+
*/
|
|
38
|
+
async poll(serverName, holdMs) {
|
|
39
|
+
const body = (await this.request("POST", "/proxy/poll", { serverName, holdMs }, holdMs + 10_000));
|
|
40
|
+
return body?.work;
|
|
41
|
+
}
|
|
42
|
+
/** Hand back one dispatched call's result, or the reason it could not run. */
|
|
43
|
+
async result(workId, result, error) {
|
|
44
|
+
await this.post("/proxy/result", { workId, result, error });
|
|
45
|
+
}
|
|
46
|
+
post(path, body) {
|
|
47
|
+
return this.request("POST", path, body);
|
|
48
|
+
}
|
|
49
|
+
async request(method, path, body, timeoutMs = this.timeoutMs) {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
52
|
+
timer.unref?.();
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`${this.url}${path}`, {
|
|
55
|
+
method,
|
|
56
|
+
headers: {
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
authorization: `Bearer ${this.token}`,
|
|
59
|
+
},
|
|
60
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
logDetail("control.http_error", { path, status: res.status });
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
if (!text)
|
|
69
|
+
return {};
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logDetail("control.unreachable", { path, error: errText(err) });
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* correlation — how one MCP call seen twice becomes one recorded step (Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* In Tier 1 a wrapped `tools/call` is observed by the hook at `PreToolUse` *and*
|
|
5
|
+
* by the proxy at `tools/call`. Without correlation you double-record, and the
|
|
6
|
+
* two halves each carry something the other lacks:
|
|
7
|
+
*
|
|
8
|
+
* - the **hook** contributes identity (`tool_use_id`), ordering position, the
|
|
9
|
+
* model's original arguments, and the reasoning text;
|
|
10
|
+
* - the **proxy** contributes the true upstream result, `isError`,
|
|
11
|
+
* `structuredContent` and real execution duration.
|
|
12
|
+
*
|
|
13
|
+
* MECHANISM. At `PreToolUse` the hook answers `permissionDecision: "allow"` with
|
|
14
|
+
* `updatedInput = { ...original, [BIR_CALL_ID]: id }`. Claude Code passes those
|
|
15
|
+
* arguments through as the call's `arguments`, the proxy strips the key before
|
|
16
|
+
* relaying, and echoes it on the step report. The control server joins on it.
|
|
17
|
+
*
|
|
18
|
+
* THE COST, INHERITED KNOWINGLY from RRepeat's sentinel: carrying an extra
|
|
19
|
+
* argument requires **relaxing the tool's schema** (`additionalProperties: true`
|
|
20
|
+
* plus the declared key), and that relaxation is visible to the model on every
|
|
21
|
+
* call, not only recorded ones. Hence {@link relaxSchema} is applied only when
|
|
22
|
+
* Tier 1 correlation is actually active, and only to wrapped servers.
|
|
23
|
+
*
|
|
24
|
+
* THE FALLBACK. `--no-correlation` matches on `(serverName, toolName, hash(args))`
|
|
25
|
+
* inside a 30 s window instead. It is lossy under identical concurrent calls —
|
|
26
|
+
* two parallel calls with the same arguments are genuinely indistinguishable
|
|
27
|
+
* without an id — and says so rather than pretending otherwise.
|
|
28
|
+
*/
|
|
29
|
+
/** The argument key that carries the call id from hook to proxy. */
|
|
30
|
+
export declare const BIR_CALL_ID = "__bir_call_id__";
|
|
31
|
+
/** Window in which a fingerprint may still match its hook-side twin. */
|
|
32
|
+
export declare const FINGERPRINT_WINDOW_MS = 30000;
|
|
33
|
+
/** The step a proxy reports to the control server (§5). */
|
|
34
|
+
export interface ProxyStepReport {
|
|
35
|
+
/** Present in Tier 1 when the hook injected one. */
|
|
36
|
+
callId?: string;
|
|
37
|
+
/** Config key, e.g. "chrome-devtools". */
|
|
38
|
+
serverName: string;
|
|
39
|
+
/** Upstream-local name, e.g. "navigate_page". */
|
|
40
|
+
toolName: string;
|
|
41
|
+
/** Client-rendered, e.g. "mcp__chrome-devtools__navigate_page". */
|
|
42
|
+
qualifiedName: string;
|
|
43
|
+
/** Post-redaction. */
|
|
44
|
+
args: unknown;
|
|
45
|
+
/** Whole CallToolResult, post-redaction and truncation. */
|
|
46
|
+
result?: unknown;
|
|
47
|
+
isError: boolean;
|
|
48
|
+
errorMessage?: string;
|
|
49
|
+
/** Epoch ms. */
|
|
50
|
+
startedAt: number;
|
|
51
|
+
durationMs: number;
|
|
52
|
+
}
|
|
53
|
+
export declare function newCallId(): string;
|
|
54
|
+
/**
|
|
55
|
+
* Pull the call id out of a `tools/call` argument object.
|
|
56
|
+
* Returns the id (if any) and the arguments as the upstream must see them —
|
|
57
|
+
* the proxy relays `cleaned`, never the original, so the upstream never sees a
|
|
58
|
+
* key its schema does not declare.
|
|
59
|
+
*/
|
|
60
|
+
export declare function extractCallId(args: unknown): {
|
|
61
|
+
callId?: string;
|
|
62
|
+
cleaned: unknown;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Relax a tool's `inputSchema` so a host that validates `updatedInput` accepts
|
|
66
|
+
* the extra key. Original properties are preserved, so ordinary calls validate
|
|
67
|
+
* exactly as before.
|
|
68
|
+
*/
|
|
69
|
+
export declare function relaxSchema(schema: unknown): Record<string, unknown>;
|
|
70
|
+
/** Apply {@link relaxSchema} to every tool in a `tools/list` result, in place-free form. */
|
|
71
|
+
export declare function relaxToolsListResult(result: unknown): unknown;
|
|
72
|
+
/**
|
|
73
|
+
* The `--no-correlation` join key. Stable across the hook/proxy boundary because
|
|
74
|
+
* both sides hash the *same* argument object — the model's — with sorted keys.
|
|
75
|
+
*/
|
|
76
|
+
export declare function fingerprint(serverName: string, toolName: string, args: unknown): string;
|
|
77
|
+
/** JSON with object keys sorted, so key order cannot change a fingerprint. */
|
|
78
|
+
export declare function stableStringify(value: unknown): string;
|
|
79
|
+
/** Claude Code's rendering of an MCP tool name. Other hosts differ (D8). */
|
|
80
|
+
export declare function qualifyToolName(serverName: string, toolName: string): string;
|
|
81
|
+
/** Split `mcp__<server>__<tool>` back into its parts, or undefined if it is not one. */
|
|
82
|
+
export declare function parseQualifiedName(name: string): {
|
|
83
|
+
serverName: string;
|
|
84
|
+
toolName: string;
|
|
85
|
+
} | undefined;
|
|
86
|
+
//# sourceMappingURL=correlation.d.ts.map
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discovery — how a proxy finds the control server for its project (Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* The control server writes `~/.baseinstrunner/control/<key>.json`, where `<key>`
|
|
5
|
+
* is `sha256(normalised cwd)[0..16]`. A proxy resolves its control server in this
|
|
6
|
+
* order:
|
|
7
|
+
*
|
|
8
|
+
* 1. `BIR_CONTROL_URL` in the environment — injected into generated config, and
|
|
9
|
+
* the only reliable channel for an Agent SDK session, which may not share a
|
|
10
|
+
* cwd with anything on disk.
|
|
11
|
+
* 2. The discovery file for its own cwd.
|
|
12
|
+
* 3. Give up → Tier 2.
|
|
13
|
+
*
|
|
14
|
+
* Give-up is **bounded, not immediate**: the host may spawn MCP servers before
|
|
15
|
+
* `SessionStart` fires, and the ordering between the two is not guaranteed. So a
|
|
16
|
+
* proxy retries for up to 5 s while buffering steps in memory, then falls to
|
|
17
|
+
* Tier 2 and flushes the buffer into a run it owns itself.
|
|
18
|
+
*
|
|
19
|
+
* STALENESS. A discovery file outlives its process on a crash. Every read checks
|
|
20
|
+
* that the recorded pid is still alive (`kill(pid, 0)`) and that the file is not
|
|
21
|
+
* older than {@link MAX_DISCOVERY_AGE_MS}; a stale file is treated as absent, so
|
|
22
|
+
* a dead session can never capture a live proxy's steps.
|
|
23
|
+
*
|
|
24
|
+
* SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
|
|
25
|
+
* port that accepts unauthenticated step reports is a local exfiltration channel.
|
|
26
|
+
*/
|
|
27
|
+
/** A discovery file older than this is ignored even if its pid is somehow alive. */
|
|
28
|
+
export declare const MAX_DISCOVERY_AGE_MS: number;
|
|
29
|
+
export interface DiscoveryInfo {
|
|
30
|
+
url: string;
|
|
31
|
+
/** Random 32 bytes hex; required on every control-server route. */
|
|
32
|
+
token: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
pid: number;
|
|
35
|
+
startedAt: number;
|
|
36
|
+
cwd: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function writeDiscovery(info: DiscoveryInfo): string;
|
|
39
|
+
export declare function removeDiscovery(cwd: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Read the discovery file for `cwd`, or undefined when there is none, it is
|
|
42
|
+
* malformed, or it is stale.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readDiscovery(cwd: string): DiscoveryInfo | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the control server for `cwd`, retrying until `timeoutMs` elapses.
|
|
47
|
+
* `BIR_CONTROL_URL` short-circuits the search (with `BIR_CONTROL_TOKEN` for auth).
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveControl(cwd: string, timeoutMs: number, pollMs?: number): Promise<DiscoveryInfo | undefined>;
|
|
50
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discovery — how a proxy finds the control server for its project (Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* The control server writes `~/.baseinstrunner/control/<key>.json`, where `<key>`
|
|
5
|
+
* is `sha256(normalised cwd)[0..16]`. A proxy resolves its control server in this
|
|
6
|
+
* order:
|
|
7
|
+
*
|
|
8
|
+
* 1. `BIR_CONTROL_URL` in the environment — injected into generated config, and
|
|
9
|
+
* the only reliable channel for an Agent SDK session, which may not share a
|
|
10
|
+
* cwd with anything on disk.
|
|
11
|
+
* 2. The discovery file for its own cwd.
|
|
12
|
+
* 3. Give up → Tier 2.
|
|
13
|
+
*
|
|
14
|
+
* Give-up is **bounded, not immediate**: the host may spawn MCP servers before
|
|
15
|
+
* `SessionStart` fires, and the ordering between the two is not guaranteed. So a
|
|
16
|
+
* proxy retries for up to 5 s while buffering steps in memory, then falls to
|
|
17
|
+
* Tier 2 and flushes the buffer into a run it owns itself.
|
|
18
|
+
*
|
|
19
|
+
* STALENESS. A discovery file outlives its process on a crash. Every read checks
|
|
20
|
+
* that the recorded pid is still alive (`kill(pid, 0)`) and that the file is not
|
|
21
|
+
* older than {@link MAX_DISCOVERY_AGE_MS}; a stale file is treated as absent, so
|
|
22
|
+
* a dead session can never capture a live proxy's steps.
|
|
23
|
+
*
|
|
24
|
+
* SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
|
|
25
|
+
* port that accepts unauthenticated step reports is a local exfiltration channel.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { controlDir, discoveryPath, ensureDir } from "./paths.js";
|
|
29
|
+
import { logDetail, errText } from "../util/log.js";
|
|
30
|
+
/** A discovery file older than this is ignored even if its pid is somehow alive. */
|
|
31
|
+
export const MAX_DISCOVERY_AGE_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
export function writeDiscovery(info) {
|
|
33
|
+
ensureDir(controlDir());
|
|
34
|
+
const path = discoveryPath(info.cwd);
|
|
35
|
+
writeFileSync(path, JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
export function removeDiscovery(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const path = discoveryPath(cwd);
|
|
41
|
+
if (existsSync(path))
|
|
42
|
+
unlinkSync(path);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* best effort */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** True when a pid is still running (or when we cannot tell, which we treat as alive). */
|
|
49
|
+
function pidAlive(pid) {
|
|
50
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// EPERM means the process exists but belongs to someone else — still alive.
|
|
58
|
+
return err.code === "EPERM";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Read the discovery file for `cwd`, or undefined when there is none, it is
|
|
63
|
+
* malformed, or it is stale.
|
|
64
|
+
*/
|
|
65
|
+
export function readDiscovery(cwd) {
|
|
66
|
+
const path = discoveryPath(cwd);
|
|
67
|
+
try {
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
return undefined;
|
|
70
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
71
|
+
if (typeof parsed.url !== "string" ||
|
|
72
|
+
typeof parsed.token !== "string" ||
|
|
73
|
+
typeof parsed.pid !== "number") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() - (parsed.startedAt ?? 0) > MAX_DISCOVERY_AGE_MS) {
|
|
77
|
+
logDetail("discovery.stale", { path, reason: "too old" });
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (!pidAlive(parsed.pid)) {
|
|
81
|
+
logDetail("discovery.stale", { path, reason: "pid gone", pid: parsed.pid });
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return { cwd, ...parsed };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
logDetail("discovery.unreadable", { path, error: errText(err) });
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the control server for `cwd`, retrying until `timeoutMs` elapses.
|
|
93
|
+
* `BIR_CONTROL_URL` short-circuits the search (with `BIR_CONTROL_TOKEN` for auth).
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveControl(cwd, timeoutMs, pollMs = 250) {
|
|
96
|
+
const envUrl = process.env.BIR_CONTROL_URL;
|
|
97
|
+
if (envUrl) {
|
|
98
|
+
// The env channel may still race the server's own startup, so it is verified
|
|
99
|
+
// against the discovery file when one exists (that is where the token lives).
|
|
100
|
+
const fromFile = readDiscovery(cwd);
|
|
101
|
+
return {
|
|
102
|
+
url: envUrl.replace(/\/+$/, ""),
|
|
103
|
+
token: process.env.BIR_CONTROL_TOKEN ?? fromFile?.token ?? "",
|
|
104
|
+
sessionId: fromFile?.sessionId ?? "",
|
|
105
|
+
pid: fromFile?.pid ?? 0,
|
|
106
|
+
startedAt: fromFile?.startedAt ?? Date.now(),
|
|
107
|
+
cwd,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const found = readDiscovery(cwd);
|
|
113
|
+
if (found)
|
|
114
|
+
return found;
|
|
115
|
+
if (Date.now() >= deadline)
|
|
116
|
+
return undefined;
|
|
117
|
+
await new Promise((resolve) => {
|
|
118
|
+
const t = setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
119
|
+
t.unref?.();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ordering — the single monotonic `stepIndex` sequence for a run (Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* D5's whole point: one counter shared by the hook's built-in steps and every
|
|
5
|
+
* proxy's MCP steps, so a scenario reads back in true execution order rather
|
|
6
|
+
* than as three interleaved streams with no common clock.
|
|
7
|
+
*
|
|
8
|
+
* WHERE ALLOCATION HAPPENS, AND WHY IT DIFFERS BY SOURCE:
|
|
9
|
+
*
|
|
10
|
+
* - **Built-in steps** are allocated at `PreToolUse`. The hook is their only
|
|
11
|
+
* observer, `pre`/`post` are strictly paired, and Claude Code runs built-ins
|
|
12
|
+
* one at a time — so allocating at call time preserves selection order and
|
|
13
|
+
* nothing can interleave between the pair.
|
|
14
|
+
* - **MCP steps** are allocated when the proxy *reports*, i.e. at completion.
|
|
15
|
+
* Concurrent `tools/call`s have no meaningful selection order across
|
|
16
|
+
* processes, so ordering them by completion is both deterministic and honest
|
|
17
|
+
* about what actually happened.
|
|
18
|
+
*
|
|
19
|
+
* Steps come in pairs (`tool_selected` then `tool_response`), so the unit of
|
|
20
|
+
* allocation is a pair — otherwise a concurrent allocation could land *between*
|
|
21
|
+
* a call and its own result.
|
|
22
|
+
*/
|
|
23
|
+
export interface StepPair {
|
|
24
|
+
selected: number;
|
|
25
|
+
response: number;
|
|
26
|
+
}
|
|
27
|
+
export declare class StepIndexAllocator {
|
|
28
|
+
private counter;
|
|
29
|
+
/** The next index that would be handed out. */
|
|
30
|
+
get next(): number;
|
|
31
|
+
/** Allocate one index (a final answer, a prompt step). */
|
|
32
|
+
allocate(): number;
|
|
33
|
+
/** Allocate an adjacent `tool_selected` / `tool_response` pair. */
|
|
34
|
+
allocatePair(): StepPair;
|
|
35
|
+
/** Start a new run. */
|
|
36
|
+
reset(): void;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=ordering.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ordering — the single monotonic `stepIndex` sequence for a run (Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* D5's whole point: one counter shared by the hook's built-in steps and every
|
|
5
|
+
* proxy's MCP steps, so a scenario reads back in true execution order rather
|
|
6
|
+
* than as three interleaved streams with no common clock.
|
|
7
|
+
*
|
|
8
|
+
* WHERE ALLOCATION HAPPENS, AND WHY IT DIFFERS BY SOURCE:
|
|
9
|
+
*
|
|
10
|
+
* - **Built-in steps** are allocated at `PreToolUse`. The hook is their only
|
|
11
|
+
* observer, `pre`/`post` are strictly paired, and Claude Code runs built-ins
|
|
12
|
+
* one at a time — so allocating at call time preserves selection order and
|
|
13
|
+
* nothing can interleave between the pair.
|
|
14
|
+
* - **MCP steps** are allocated when the proxy *reports*, i.e. at completion.
|
|
15
|
+
* Concurrent `tools/call`s have no meaningful selection order across
|
|
16
|
+
* processes, so ordering them by completion is both deterministic and honest
|
|
17
|
+
* about what actually happened.
|
|
18
|
+
*
|
|
19
|
+
* Steps come in pairs (`tool_selected` then `tool_response`), so the unit of
|
|
20
|
+
* allocation is a pair — otherwise a concurrent allocation could land *between*
|
|
21
|
+
* a call and its own result.
|
|
22
|
+
*/
|
|
23
|
+
export class StepIndexAllocator {
|
|
24
|
+
counter = 0;
|
|
25
|
+
/** The next index that would be handed out. */
|
|
26
|
+
get next() {
|
|
27
|
+
return this.counter;
|
|
28
|
+
}
|
|
29
|
+
/** Allocate one index (a final answer, a prompt step). */
|
|
30
|
+
allocate() {
|
|
31
|
+
return this.counter++;
|
|
32
|
+
}
|
|
33
|
+
/** Allocate an adjacent `tool_selected` / `tool_response` pair. */
|
|
34
|
+
allocatePair() {
|
|
35
|
+
const selected = this.counter++;
|
|
36
|
+
const response = this.counter++;
|
|
37
|
+
return { selected, response };
|
|
38
|
+
}
|
|
39
|
+
/** Start a new run. */
|
|
40
|
+
reset() {
|
|
41
|
+
this.counter = 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=ordering.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paths — where BaseInstRunner keeps its state on disk.
|
|
3
|
+
*
|
|
4
|
+
* One module so the discovery file, the credentials file and the install sidecar
|
|
5
|
+
* cannot drift apart, and so tests can redirect all three at once with
|
|
6
|
+
* `BIR_HOME`. Everything lives under `~/.baseinstrunner` (mode 0700), and no
|
|
7
|
+
* assumption is made about `$HOME` being set — Windows sets `USERPROFILE`
|
|
8
|
+
* instead, and `os.homedir()` already knows the difference.
|
|
9
|
+
*/
|
|
10
|
+
/** Root of BaseInstRunner's state. `BIR_HOME` overrides it (tests, sandboxes). */
|
|
11
|
+
export declare function configDir(): string;
|
|
12
|
+
/** Where control servers advertise themselves, one file per project cwd. */
|
|
13
|
+
export declare function controlDir(): string;
|
|
14
|
+
/** The sidecar holding original MCP entries so `bir uninstall` can restore them. */
|
|
15
|
+
export declare function installedPath(): string;
|
|
16
|
+
/** Create a directory with restrictive permissions. Idempotent, never throws. */
|
|
17
|
+
export declare function ensureDir(dir: string, mode?: number): void;
|
|
18
|
+
/**
|
|
19
|
+
* Separator- and case-normalised form of a path.
|
|
20
|
+
*
|
|
21
|
+
* Claude Code stores project keys with forward slashes on Windows while
|
|
22
|
+
* `process.cwd()` returns backslashes; comparing the literal strings is the
|
|
23
|
+
* number one cause of "the proxy is installed but never runs" (Phase 7.1). The
|
|
24
|
+
* same normalisation keys the discovery file, so a proxy started from
|
|
25
|
+
* `C:\proj` finds the control server registered by a hook reporting `C:/proj`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function normalizePath(p: string): string;
|
|
28
|
+
/** The discovery key for a project directory: `sha256(normalised cwd)[0..16]`. */
|
|
29
|
+
export declare function controlKey(cwd: string): string;
|
|
30
|
+
/** Absolute path of the discovery file for `cwd`. */
|
|
31
|
+
export declare function discoveryPath(cwd: string): string;
|
|
32
|
+
//# sourceMappingURL=paths.d.ts.map
|