Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,7 @@ apps/cli/.packaged-plugin-build-*

# Generated by the bb:bundle-stats Vite plugin for the boot-payload budget check.
apps/app/bundle-stats.json

# Raw provider bridge recordings (record mode output) can hold secrets; only
# the redacted copies under packages/provider-bridge-protocol/recordings ship.
provider-recordings/raw/
29 changes: 29 additions & 0 deletions apps/host-daemon/src/runtime-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async function writeInjectedSkillSource(
}

afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
tempDirs
.splice(0)
Expand Down Expand Up @@ -1183,6 +1184,34 @@ describe("RuntimeManager", () => {
);
});

it("forwards the bridge record-mode directory to provider processes but not the shell env", async () => {
vi.stubEnv("BB_PROVIDER_BRIDGE_RECORD_DIR", "/tmp/provider-recordings/raw");
const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
const createRuntime = vi.fn(() => createFakeRuntime());
const manager = new RuntimeManager({
provisionWorkspace,
createRuntime,
shellEnv: {
PATH: "/tmp/bb-bin:/usr/bin",
},
});

await manager.ensureEnvironment({
environmentId: "env-1",
workspacePath: "/tmp/env-1",
});

expect(createRuntime).toHaveBeenCalledWith(
expect.objectContaining({
env: {
PATH: "/tmp/bb-bin:/usr/bin",
BB_PROVIDER_BRIDGE_RECORD_DIR: "/tmp/provider-recordings/raw",
},
shellEnv: { PATH: "/tmp/bb-bin:/usr/bin" },
}),
);
});

it("passes the resolved shell PATH to managed worktree setup", async () => {
const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
const manager = new RuntimeManager({
Expand Down
8 changes: 8 additions & 0 deletions apps/host-daemon/src/runtime-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ function providerProcessEnvFromShellEnv(
if (shellEnv.BB_CLAUDE_CODE_EXECUTABLE) {
env.BB_CLAUDE_CODE_EXECUTABLE = shellEnv.BB_CLAUDE_CODE_EXECUTABLE;
}
// Bridge record mode (docs/provider-bridge-protocol.md) rides the same
// forward, from the daemon's own env rather than the shell env: the shell
// env doubles as the agent's shell environment, and the variable must reach
// the bridge process only, never the provider child or its shells.
const recordDir = process.env.BB_PROVIDER_BRIDGE_RECORD_DIR;
if (recordDir) {
env.BB_PROVIDER_BRIDGE_RECORD_DIR = recordDir;
}
return Object.keys(env).length > 0 ? env : null;
}

Expand Down
17 changes: 17 additions & 0 deletions docs/api_to_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ content-block vocabulary; decide whether legacy aggregate fields still need to
be accepted; and define any image MIME validation, decoding, or payload-size
policy at the server boundary before making the helper stable.

## Bridge record mode (`experimental_recordProviderChildIo` and `experimental_isProviderBridgeRecording`)

**What it does.** `experimental_recordProviderChildIo` tees a provider
child's stdio into the bridge record mode (`BB_PROVIDER_BRIDGE_RECORD_DIR`),
scoped to the bb thread the child serves. It is a no-op when record mode is
off, so a bridge calls it unconditionally after `spawn()`.
`experimental_isProviderBridgeRecording` reports whether record mode is on,
for a bridge whose provider pipe is owned by an SDK and must take the spawn
over to tee it. See [provider-bridge-protocol.md](provider-bridge-protocol.md),
"Record mode".

**Audit before stabilizing.** Decide whether the bridge kit should own the
spawn itself (one helper that spawns and records) instead of a post-spawn
hook; confirm the `{ threadId | null }` scope is the right key once bridges
multiplex several threads over one child; and settle the recording entry
shape (`{ ts, run, seq, dir, line }`) as a documented fixture format.

## Provider bridge maintenance (`PluginProviderCapabilities.experimental_providerHealth`, `PluginProviderCapabilities.experimental_providerUsage`, `PluginProviderCapabilities.experimental_providerInstallation`, `ProviderInfo.experimental_providerHealth`, `ProviderInfo.experimental_providerUsage`, `ProviderInfo.experimental_providerInstallation`, `BRIDGE_REQUEST_METHODS.experimentalProviderHealth`, `BRIDGE_REQUEST_METHODS.experimentalProviderUsage`, `BRIDGE_REQUEST_METHODS.experimentalProviderInstallationStatus`, `BRIDGE_REQUEST_METHODS.experimentalProviderInstallationRun`, `experimental_providerMaintenanceParamsSchema`, `experimental_providerHealthSchema`, `experimental_providerHealthResultSchema`, `experimental_providerUsageSchema`, `experimental_providerUsageWindowSchema`, `experimental_providerUsageResultSchema`, and the `experimental_providerInstallation*` schemas/types)

**What it does.** Adds optional, sessionless `provider/health`,
Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -983,3 +983,12 @@ intended mode so ambient shell state does not silently retarget bb.

Use `pnpm reset` or `pnpm reset:dev` to clear a data directory. These only
remove bb-managed state, not provider credentials.

`BB_PROVIDER_BRIDGE_RECORD_DIR=<dir>` in the host daemon's environment turns
on bridge record mode: every provider bridge writes the lines that cross its
runtime and provider wires as NDJSON under `<dir>/<providerId>/<threadId>/`.
It is a development and diagnostics knob, off by default, and never reaches a
provider child. See [provider-bridge-protocol.md](provider-bridge-protocol.md),
"Record mode", and [debugging-and-qa.md](debugging-and-qa.md). Raw recordings
can contain secrets; redact them with `scripts/provider-recordings/redact.mjs`
before you share them.
26 changes: 26 additions & 0 deletions docs/debugging-and-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ eval "$(scripts/bb-dev-app env)"
pnpm bb:dev thread spawn --project proj_personal --provider codex --permission-mode accept-edits --title "Smoke test" --prompt "Reply only with ok." --json
```

## Record Provider Bridge Traffic

Export `BB_PROVIDER_BRIDGE_RECORD_DIR` before you start the dev app and every
provider bridge records its runtime and provider wires as NDJSON:

```bash
BB_PROVIDER_BRIDGE_RECORD_DIR=$HOME/.bb/provider-recordings/raw scripts/bb-dev-app current
eval "$(scripts/bb-dev-app env)"
pnpm bb:dev thread spawn --project proj_personal --provider codex --prompt "Run git status." --json
ls ~/.bb/provider-recordings/raw/codex/
```

The layout is `<dir>/<providerId>/<threadId>/<direction>.ndjson`, plus a
`_process` scope for lines that belong to no thread. See
[provider-bridge-protocol.md](provider-bridge-protocol.md), "Record mode",
for the entry format. Raw recordings can contain secrets and absolute paths.
Run `node scripts/provider-recordings/redact.mjs <raw-dir> <out-dir>` before
you share one, and never commit a raw recording.

To compare two checkouts' bridges on the committed recordings, run
`pnpm parity --old <checkout> --new . [--provider <id>] [--cell <name>]`.
Each leg replays every cell through its own bridge, assembler, and timeline
projection; the run prints a PASS/FAIL line per cell with event and row
counts and exits non-zero on any diff outside
`packages/provider-bridge-protocol/recordings/parity-allowlist.json`.

## Performance Fixture Database

Use `pnpm seed:perf` to fill a dev database with a large, realistic fixture:
Expand Down
70 changes: 70 additions & 0 deletions docs/provider-bridge-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,73 @@ never let a descendant holding an inherited pipe inject into a fresh
session. The bridge's own environment is constructed by the runtime from an
allowlist; bridges construct their children's environments the same way and
must not leak their own inherited env downward (#1366, #1545).

## Record mode

Set `BB_PROVIDER_BRIDGE_RECORD_DIR` to a directory and every bridge process
tees the lines that cross its two boundaries into NDJSON files. The bootstrap
(`bridge-worker-entry.ts`) records the runtime wire for every bridge, first-
or third-party. A bridge that spawns its provider child records the provider
wire by calling `experimental_recordProviderChildIo(child, { threadId })`
right after `spawn()`; the call is a no-op when record mode is off. A bridge
whose provider pipe belongs to an SDK checks
`experimental_isProviderBridgeRecording()` and takes the spawn over (the
Claude bridge does this through the Agent SDK's `spawnClaudeCodeProcess`
seam). Pi runs in-process and records its SDK event boundary instead.

Layout: `<dir>/<threadId>/<direction>.ndjson`, with `_process` for lines that
belong to no thread (`initialize`, `model/list`, provider health, and the
children those spawn). The four directions are `runtime→bridge`,
`bridge→runtime`, `provider→bridge`, and `bridge→provider`. One entry per
line: `{ "ts", "run", "seq", "dir", "line" }`. `seq` is one counter across
every lane of the process and `run` identifies the process, so the files of a
thread merge back into their exact order even across a bridge restart.
Responses, which carry only an id, land in the scope of the request they
answer. Nothing buffers: each line is appended as it crosses.

The daemon forwards the variable to the bridges it spawns and the runtime
appends the provider id, so a daemon started with it writes
`<dir>/<providerId>/<threadId>/…`. `withoutBridgeRuntimeEnv` and the
`BB_*` allowlist both strip the variable from provider children, so a
recorded provider never records itself.

Recordings are the input of the parity harness
(`packages/provider-bridge-protocol/src/testing/parity.ts`): the provider
lanes replay into a fake child (`replay-provider-child.mjs`, for which the
recording is the script), the runtime lanes replay into a bridge, and two
checkouts are diffed on the assembled events and projected rows with
`pnpm parity --old <checkout> --new .` (`@bb/provider-parity`). Each leg
assembles and projects with its own checkout's code. Differences a migration
PR intends go in `recordings/parity-allowlist.json` with the PR and reason;
an entry that masks nothing is reported stale and fails the run.

Redacted recordings live under `packages/provider-bridge-protocol/recordings`,
one `<provider>/<cell>` directory per live-QA matrix cell with a
`manifest.json` (provider, cell, CLI version, date, what the session did);
`scripts/provider-recordings/redact.mjs` and `package-cells.mjs` produce
them. `recordings/row-counts.json` pins each cell's event, row,
`provider/unhandled`, and grammar-drop counts; `parity.self.test.ts` checks
the pins and replays every cell through the current bridge on each commit,
and `UPDATE_PARITY_ROW_COUNTS=1` rewrites the pins deliberately. Raw
recordings stay out of git.

A recording is never rewritten. When a bridge change alters what the bridge
emits for a recording, `pnpm --filter @bb/provider-parity rerecord
[--plan-with <recording-time checkout>]` writes the bridge's current output
to `bridge→runtime.current.ndjson` beside the recorded lane; the self-suite
pins and compares against that file when it exists, while `pnpm parity`
still paces a pre-migration leg from the recorded lane (and the current leg
from the current one). `pnpm parity --dump-dir <dir>` writes both legs'
normalized event and row lists per cell, for allowlist entries that must
name a list index. Re-recorded lanes pass through `redact.mjs` before they
are written. The committed current lanes are the v3 bridges' output for the
v2 recordings: the stack's assembler reads only v3, so every replayable cell
carries one, and they assemble to the same pinned counts as the recordings.

The conformance kit runs the same recordings as its recorded-traffic
scenario set: `replayRecordedCells` replays a bridge's cells and
`checkRecordedCellReplay` reports `recorded/<cell>/{replays,
events-schema-valid, grammar, turn-lifecycle, not-empty}` per cell. Each
first-party bridge has a `bridge.recorded-conformance.test.ts` beside its
scripted suite, so conformance reflects the real dialect as well as the
protocol.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"start": "cross-env NODE_ENV=production dotenv -c production -- node --conditions=source --import tsx scripts/start-bb.mjs",
"start:host-daemon": "cross-env NODE_ENV=production dotenv -c production -- pnpm run --silent bb-app:prepare && cross-env NODE_ENV=production dotenv -c production -- node packages/bb-app/dist/bb-app.js host-daemon",
"seed:perf": "node scripts/ensure-native-modules.mjs && cross-env NODE_ENV=development node --conditions=source --import tsx packages/scripts/src/commands/seed-perf-db.ts",
"parity": "pnpm --filter @bb/provider-parity run parity",
"reset": "cross-env NODE_ENV=production pnpm run --silent scripts:prepare && cross-env NODE_ENV=production node packages/scripts/dist/commands/reset-bb-data.js",
"reset:dev": "cross-env NODE_ENV=development pnpm run --silent scripts:prepare && cross-env NODE_ENV=development node packages/scripts/dist/commands/reset-bb-data.js",
"reset:all": "cross-env NODE_ENV=production pnpm run --silent scripts:prepare && cross-env NODE_ENV=production node packages/scripts/dist/commands/reset-bb-data.js --all",
Expand Down
1 change: 1 addition & 0 deletions packages/agent-runtime/src/pi/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ async function startPiThreadSession(
}

const sessionOptions = buildSessionOptions({ params, providerThreadId });
sessionOptions.recordThreadId = threadId;
applyDynamicTools(sessionOptions, params.dynamicTools, threadId);

const sessionSerial = nextSessionSerial();
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-runtime/src/pi/bridge/sdk-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent } from "@earendil-works/pi-ai";
import { getBridgeRecorder } from "@bb/provider-bridge-protocol/bridge-kit";
import { createConfiguredPiServices } from "./configured-services.js";

export interface PiSdkSessionOptions {
Expand All @@ -26,6 +27,13 @@ export interface PiSdkSessionOptions {
sessionFilePath?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
/**
* The bb thread this session serves. Pi runs in-process (no provider
* pipe), so record mode captures the SDK boundary instead: every
* `AgentSessionEvent` as `provider→bridge`, every prompt/abort/compact
* dispatch as `bridge→provider`.
*/
recordThreadId?: string;
}

type ShellEnvOverrides = Record<string, string>;
Expand Down Expand Up @@ -229,6 +237,28 @@ export class PiSdkSession {
return this.isProcessing || this.session?.isStreaming === true;
}

/** Record-mode tee of the in-process SDK boundary; a no-op when off. */
private recordSdkBoundary(
direction: "provider→bridge" | "bridge→provider",
payload: unknown,
): void {
const recorder = getBridgeRecorder();
if (recorder === null) {
return;
}
let line: string;
try {
line = JSON.stringify(payload) ?? "null";
} catch {
line = JSON.stringify({ unserializable: String(payload) });
}
recorder.record({
direction,
line,
threadId: this.options.recordThreadId ?? null,
});
}

getIsCompacting(): boolean {
return this.isCompacting;
}
Expand Down Expand Up @@ -318,6 +348,7 @@ export class PiSdkSession {

// Subscribe to session events
this.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
this.recordSdkBoundary("provider→bridge", event);
this.trackProcessingState(event);
this.observeInputConsumption(event);
this.observeTerminalSteerSettlement(event);
Expand Down Expand Up @@ -414,6 +445,7 @@ export class PiSdkSession {
const completionCount = this.manualCompactionCompletionCount;
this.isProcessing = true;
this.isCompacting = true;
this.recordSdkBoundary("bridge→provider", { method: "compact" });
try {
await this.session.compact();
} catch (error) {
Expand Down Expand Up @@ -463,6 +495,7 @@ export class PiSdkSession {

let timeout: ReturnType<typeof setTimeout> | undefined;
let providerCheckpointId: string | undefined;
this.recordSdkBoundary("bridge→provider", { method: "abort" });
const abortCompleted = session.abort().catch(() => undefined);
const timeoutReached = new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
Expand Down Expand Up @@ -737,6 +770,14 @@ export class PiSdkSession {
}
this.ensureCustomToolsActive();
const pending = args.pending;
this.recordSdkBoundary("bridge→provider", {
method: "prompt",
params: {
text: args.text,
streamingBehavior: args.streamingBehavior,
imageCount: args.images?.length ?? 0,
},
});
await this.session.prompt(args.text, {
...(this.session.isStreaming
? { streamingBehavior: args.streamingBehavior }
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-runtime/src/runtime-provider-process.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { HostDaemonAcpLaunchSpec } from "@bb/host-daemon-contract";
import {
sanitizeInheritedChildProcessEnv,
Expand All @@ -13,6 +14,7 @@ import { createProviderForId } from "./provider-registry.js";
import { filterSkillRootsForProvider } from "./runtime-skill-roots.js";
import {
ignoredJsonRpcResultSchema,
PROVIDER_BRIDGE_RECORD_DIR_ENV,
readBoundedLines,
type PendingJsonRpcRequest,
sendJsonRpcRequest,
Expand Down Expand Up @@ -497,6 +499,13 @@ export class RuntimeProviderProcessManager {
...this.args.env,
...processConfig.env,
};
// Record mode: the daemon forwards one root directory; each bridge
// process records under its provider's subdirectory so the layout is
// `<root>/<providerId>/<threadId>/<direction>.ndjson`.
const recordRoot = env[PROVIDER_BRIDGE_RECORD_DIR_ENV];
if (recordRoot !== undefined && recordRoot !== "") {
env[PROVIDER_BRIDGE_RECORD_DIR_ENV] = join(recordRoot, args.providerId);
}

// Lead a process group so shutdown can also reap grandchildren the
// provider CLI starts (background dev servers, MCP servers, ...).
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-sdk/src/provider-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export {
decodeBridgeJsonRpcResponse,
decodeToolCallResponsePayload,
errorEnvelopeSchema,
experimental_isProviderBridgeRecording,
experimental_recordProviderChildIo,
extractResultText,
getRawSdkMessage,
getRecordProperty,
Expand Down
5 changes: 5 additions & 0 deletions packages/provider-bridge-protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
"types": "./src/testing/index.ts",
"default": "./src/testing/index.ts"
},
"./testing/parity": {
"source": "./src/testing/parity-entry.ts",
"types": "./src/testing/parity-entry.ts",
"default": "./src/testing/parity-entry.ts"
},
"./bridge-worker-entry": {
"source": "./src/bridge-worker-entry.ts",
"types": "./src/bridge-worker-entry.ts",
Expand Down
Loading
Loading