@tangle-network/agent-provider-cli-bridge 2.0.10 → 2.0.12

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/dist/index.d.ts CHANGED
@@ -12,9 +12,5 @@ export interface CliBridgeProvider extends AgentEnvironmentProvider {
12
12
  lookupRun(input: CliBridgeRunLookupInput): Promise<AgentExactRunControlRef | null>;
13
13
  }
14
14
  export declare function createCliBridgeProvider(options: CliBridgeProviderOptions): CliBridgeProvider;
15
- /**
16
- * Describe the bridge backend, not this adapter.
17
- *
18
- * The adapter forwards an AgentProfile but does not own harness prompt controls.
19
- */
15
+ /** Bridge capabilities. The adapter forwards an AgentProfile and does not own harness prompt controls. */
20
16
  export declare function defaultCliBridgeCapabilities(harness?: HarnessType): AgentEnvironmentCapabilities;
package/dist/index.js CHANGED
@@ -61,9 +61,6 @@ export function createCliBridgeProvider(options) {
61
61
  if (input.workspace?.snapshot !== undefined) {
62
62
  throw new Error("CLI Bridge provider does not support portable workspace snapshots");
63
63
  }
64
- if (input.runtimeAttachments !== undefined) {
65
- throw new Error("CLI Bridge provider does not support runtime MCP attachments");
66
- }
67
64
  if (typeof input.profile === "string") {
68
65
  throw new Error(`createCliBridgeProvider requires an inline AgentProfile; named profile "${input.profile}" is unsupported`);
69
66
  }
@@ -207,11 +204,7 @@ function assertRequestedEnvironmentId(value) {
207
204
  throw new Error("cli-bridge requested environment id must be 1-256 URL-safe characters");
208
205
  }
209
206
  }
210
- /**
211
- * Describe the bridge backend, not this adapter.
212
- *
213
- * The adapter forwards an AgentProfile but does not own harness prompt controls.
214
- */
207
+ /** Bridge capabilities. The adapter forwards an AgentProfile and does not own harness prompt controls. */
215
208
  export function defaultCliBridgeCapabilities(harness) {
216
209
  return {
217
210
  profile: {
@@ -284,9 +277,8 @@ export function defaultCliBridgeCapabilities(harness) {
284
277
  placement: true,
285
278
  usage: true,
286
279
  confidential: false,
287
- // cli-bridge measures what a turn cost and where it forwarded that turn.
288
- // It provisions no compute and holds no account, so the surfaces that
289
- // describe provisioned resources and billing are never claimed.
280
+ create: { runtimeAttachments: { mcp: true } },
281
+ // cli-bridge measures turn cost and forwarding. It provisions no compute and holds no account.
290
282
  observation: {
291
283
  identity: true,
292
284
  lifecycle: true,
@@ -20,7 +20,7 @@ export declare const BOX_NODE_IMAGE = "node:24.22.1-trixie-slim";
20
20
  */
21
21
  export declare const BOX_PINS: {
22
22
  readonly cliBridgeRepository: "drewstone/cli-bridge";
23
- readonly cliBridgeCommit: "263f700dc5ca8b7b6e53b986d376102e0d82b42f";
23
+ readonly cliBridgeCommit: "de0ca4d7a426d77f8aa8bc373bf98e8fb92e9da2";
24
24
  readonly agentProfileMaterialize: "1.1.4";
25
25
  };
26
26
  /** The E2B template and Daytona snapshot name `pnpm build:sandbox-image` publishes for these pins. */
@@ -1,4 +1,4 @@
1
- import { HARNESSES } from "@tangle-network/cli-agent-registry";
1
+ import { HARNESSES, MANAGED_MODEL_LADDER } from "@tangle-network/cli-agent-registry";
2
2
  /**
3
3
  * The box: one image every cloud runs, holding Node, CLI Bridge and the pinned
4
4
  * harness CLIs. The Bridge listens on {@link BRIDGE_PORT}, materializes the
@@ -20,12 +20,11 @@ export const BOX_NODE_IMAGE = "node:24.22.1-trixie-slim";
20
20
  */
21
21
  export const BOX_PINS = {
22
22
  cliBridgeRepository: "drewstone/cli-bridge",
23
- cliBridgeCommit: "263f700dc5ca8b7b6e53b986d376102e0d82b42f",
23
+ cliBridgeCommit: "de0ca4d7a426d77f8aa8bc373bf98e8fb92e9da2",
24
24
  agentProfileMaterialize: "1.1.4",
25
25
  };
26
26
  /** The E2B template and Daytona snapshot name `pnpm build:sandbox-image` publishes for these pins. */
27
27
  export const BOX_TEMPLATE_NAME = `tangle-cli-bridge-box-${BOX_PINS.cliBridgeCommit.slice(0, 12)}`;
28
- const unproven = { reason: "no live Router turn in a box yet", owner: "tangle-network/agent-sdk" };
29
28
  const noBridgeBackend = { reason: `CLI Bridge ${BOX_PINS.cliBridgeCommit.slice(0, 12)} registers no backend for it`, owner: BOX_PINS.cliBridgeRepository };
30
29
  /**
31
30
  * Every Router-eligible catalog harness, as a recipe or an exclusion; the type
@@ -35,8 +34,8 @@ const noBridgeBackend = { reason: `CLI Bridge ${BOX_PINS.cliBridgeCommit.slice(0
35
34
  const BOX_ROWS = {
36
35
  // The Router authenticates a Bearer token. ANTHROPIC_API_KEY sends x-api-key, which it rejects.
37
36
  "claude-code": { bridgeBackend: "claude", npmPackage: "@anthropic-ai/claude-code", routerEnv: (router) => ({ ANTHROPIC_BASE_URL: router.baseUrl, ANTHROPIC_AUTH_TOKEN: router.key }) },
38
- codex: unproven,
39
- opencode: unproven,
37
+ codex: { bridgeBackend: "codex", npmPackage: "@openai/codex", routerEnv: (router) => ({ CODEX_TANGLE_ROUTER_URL: `${router.baseUrl}/v1`, TANGLE_API_KEY: router.key }) }, // the Bridge turns this env into a codex model_providers entry (wire_api "responses", env_key TANGLE_API_KEY) and passes the Router slug whole; the live suite pins the dial against a key-rejecting listener in the box
38
+ opencode: { bridgeBackend: "opencode", npmPackage: "opencode-ai", routerEnv: (router) => ({ TANGLE_API_KEY: router.key, OPENCODE_CONFIG_CONTENT: JSON.stringify({ provider: MANAGED_MODEL_LADDER.reduce((providers, { id }) => { const [provider, ...model] = id.split("/"); (providers[provider] ??= { npm: "@ai-sdk/openai-compatible", options: { baseURL: `${router.baseUrl}/v1`, apiKey: "{env:TANGLE_API_KEY}" }, models: {} }).models[model.join("/")] = { id }; return providers; }, {}) }) }) }, // opencode resolves only configured models; the Bridge merges this config under each profile's, routing every Router-servable `<provider>/<model>` through the Router's chat wire.
40
39
  pi: { reason: `CLI Bridge ${BOX_PINS.cliBridgeCommit.slice(0, 12)} runs pi only in a bubblewrap fs-jail, which a Docker box cannot create (no user namespace; unconfined, --proc /proc is denied), and takes its endpoint only from a models.json file`, owner: BOX_PINS.cliBridgeRepository },
41
40
  "kimi-code": { reason: "the catalog pins Python kimi-cli 1.49.0, which refuses the Bridge's Kimi Code argv (`--output-format stream-json` without `--print`, exit 2); it joins once the catalog ports to npm @moonshot-ai/kimi-code (ops-board#10682)", owner: "tangle-network/agent-container (cli-agent-registry)" },
42
41
  prime: { reason: "not on npm, and the catalog publishes only its version: no tarball url, hash or pythonPackages to install prime-agent from", owner: "tangle-network/agent-container (cli-agent-registry)" },
@@ -9,6 +9,7 @@ import { createCliBridgeProvider, defaultCliBridgeCapabilities } from "../index.
9
9
  import { BOX_WORKSPACE_DIR, BRIDGE_PORT, BRIDGE_START_COMMAND, bridgeProcessEnv, boxHarness, boxHarnessVersion, boxVersionCommand, boxVersionProbe, } from "./box.js";
10
10
  import { assertProfileValid, declarableHarnesses, plannerLowers, refuseUnsupportedCreateInput, resolveBoxHarness, validateBoxProfile } from "./refusal.js";
11
11
  import { assertForkableCheckpoint, createVendorSnapshot, forkCreateInput, } from "./checkpoint.js";
12
+ import { pidListScript, turnSweepScript } from "./shell.js";
12
13
  export const BRIDGE_RUN_TAG = "tangle-bridge-run";
13
14
  const DEFAULT_LIFETIME_MS = 2 * 60 * 60 * 1000;
14
15
  const DEFAULT_READY_TIMEOUT_MS = 120_000;
@@ -36,7 +37,7 @@ export function createBridgeSandboxProvider(options) {
36
37
  const routerBaseUrl = (options.router.baseUrl ?? TANGLE_ROUTER_DEFAULT_ROOT_URL).replace(/\/+$/, "");
37
38
  const lifetimeMs = options.lifetimeMs ?? DEFAULT_LIFETIME_MS;
38
39
  const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
39
- const providerCapabilities = (harness) => environmentCapabilities(defaultCliBridgeCapabilities(harness), vendor, harness === undefined ? false : plannerLowers(harness, "hooks"));
40
+ const providerCapabilities = (harness) => environmentCapabilities(defaultCliBridgeCapabilities(harness), vendor, harness);
40
41
  async function create(input, restore) {
41
42
  input.signal?.throwIfAborted();
42
43
  refuseUnsupportedCreateInput(input, vendor.support);
@@ -103,7 +104,7 @@ export function createBridgeSandboxProvider(options) {
103
104
  });
104
105
  input.signal?.throwIfAborted();
105
106
  const forkFromCheckpoint = vendor.snapshots === undefined ? undefined : (checkpoint, request, signal) => create({ ...forkCreateInput(input, request), ...(signal === undefined ? {} : { signal }) }, { snapshot: checkpoint.id });
106
- return wrapEnvironment({ inner, sandbox, vendor, harness, profile, workspaceDir, dispatcher, capabilities: environmentCapabilities(inner.capabilities ?? providerCapabilities(harness), vendor, plannerLowers(harness, "hooks")), input, ...(forkFromCheckpoint === undefined ? {} : { forkFromCheckpoint }) });
107
+ return wrapEnvironment({ inner, sandbox, vendor, harness, profile, workspaceDir, dispatcher, capabilities: environmentCapabilities(inner.capabilities ?? providerCapabilities(harness), vendor, harness), input, ...(forkFromCheckpoint === undefined ? {} : { forkFromCheckpoint }) });
107
108
  }
108
109
  catch (error) {
109
110
  await dispatcher?.close().catch(() => undefined);
@@ -271,12 +272,12 @@ function assertGetOptionsSupported(options) {
271
272
  }
272
273
  }
273
274
  }
274
- /** The document for one environment: what the Bridge claims, plus the vendor sandbox's workspace and placement. */
275
- function environmentCapabilities(bridge, vendor, hooks) {
275
+ /** The document for one environment: what the Bridge claims, plus the vendor sandbox's workspace and placement. The profile's hooks, permissions and subagents axes claim only what the pinned planner lowers for the harness. */
276
+ function environmentCapabilities(bridge, vendor, harness) {
276
277
  const { retainedControl: _retained, nativeContinuation: _native, contextTransfer: _transfer, interactions: _interactions, observation: _observation, ...base } = bridge;
277
278
  return {
278
279
  ...base,
279
- profile: { ...base.profile, validation: true, hooks: hooks === true },
280
+ profile: { ...base.profile, validation: true, ...(harness === undefined ? {} : { hooks: plannerLowers(harness, "hooks"), permissions: plannerLowers(harness, "permissions"), subagents: plannerLowers(harness, "subagents") }) },
280
281
  workspace: { read: true, write: true, exec: true, git: false, upload: false, download: false, cwdBases: { repository: true, host: false } },
281
282
  branching: { checkpoint: vendor.snapshots !== undefined, fork: vendor.snapshots !== undefined },
282
283
  placement: true,
@@ -341,6 +342,9 @@ function wrapEnvironment(args) {
341
342
  if (busy)
342
343
  throw new EnvironmentProviderError("conflict", "a turn is already running on this environment", { provider: vendor.name });
343
344
  busy = true;
345
+ const baseline = await sandbox.exec(pidListScript(), { timeoutMs: 15_000 })
346
+ .then((result) => (result.exitCode === 0 ? result.stdout.split(/\s+/).filter(Boolean) : undefined))
347
+ .catch(() => undefined);
344
348
  try {
345
349
  yield* projectTurn(inner, checked, checked.model ?? args.profile.model?.default);
346
350
  }
@@ -352,6 +356,8 @@ function wrapEnvironment(args) {
352
356
  throw error;
353
357
  }
354
358
  finally {
359
+ if (baseline !== undefined)
360
+ await sandbox.exec(turnSweepScript(baseline), { timeoutMs: 30_000 }).catch(() => undefined);
355
361
  busy = false;
356
362
  }
357
363
  };
@@ -19,5 +19,5 @@ export declare function assertProfileValid(result: AgentProfileValidationResult)
19
19
  export declare function refuseUnsupportedCreateInput(input: CreateAgentEnvironmentInput, support: BridgeVendorSupport): void;
20
20
  /** The harnesses this module can declare. */
21
21
  export declare function declarableHarnesses(): HarnessType[];
22
- /** Whether the shared planner lowers this profile dimension for the harness. */
23
- export declare function plannerLowers(harness: HarnessType, dimension: "hooks"): boolean;
22
+ /** Whether the box lowers this profile dimension for the harness: the planner accepts and emits it, and the runtime honors it. The planner silently drops codex hooks (no refusal, no artifact), and codex 0.164 exposes no in-turn delegation tool through the box's exec path, so hooks and subagents lower there. */
23
+ export declare function plannerLowers(harness: HarnessType, dimension: "hooks" | "permissions" | "subagents"): boolean;
@@ -152,12 +152,12 @@ export function refuseUnsupportedCreateInput(input, support) {
152
152
  export function declarableHarnesses() {
153
153
  return Object.keys(BOX_HARNESSES);
154
154
  }
155
- /** Whether the shared planner lowers this profile dimension for the harness. */
155
+ /** Whether the box lowers this profile dimension for the harness: the planner accepts and emits it, and the runtime honors it. The planner silently drops codex hooks (no refusal, no artifact), and codex 0.164 exposes no in-turn delegation tool through the box's exec path, so hooks and subagents lower there. */
156
156
  export function plannerLowers(harness, dimension) {
157
- const probe = { hooks: { PostToolUse: [{ command: "true" }] } };
157
+ const probe = dimension === "hooks" ? { hooks: { PostToolUse: [{ command: "true" }] } } : dimension === "permissions" ? { permissions: { Write: "deny" } } : { subagents: { "probe-child": { description: "probe", prompt: "probe" } } };
158
158
  try {
159
159
  const plan = materializeProfile(probe, harness);
160
- return !plan.unsupported.some((entry) => entry.dimension === dimension);
160
+ return !plan.unsupported.some((entry) => entry.dimension === dimension) && (dimension !== "hooks" || JSON.stringify(plan.files).includes("PostToolUse")) && (dimension !== "subagents" || harness !== "codex");
161
161
  }
162
162
  catch {
163
163
  return false;
@@ -14,3 +14,13 @@ export declare function envFileLaunch(command: string, { cwd, env }: {
14
14
  };
15
15
  /** For an SDK that takes no AbortSignal: an abort settles the call at once, and `late` receives a result that arrives afterwards. */
16
16
  export declare function untilAborted<T>(pending: Promise<T>, signal: AbortSignal | undefined, late?: (value: T) => Promise<unknown>): Promise<T>;
17
+ /** The pids alive in the sandbox, one per line: the baseline a turn's processes are measured against. */
18
+ export declare function pidListScript(): string;
19
+ /**
20
+ * A harness may put tool children outside its process group (opencode's Bash tool spawns them detached), where
21
+ * neither the harness's exit nor the adapter's group kill reaches them. After every turn the box restores the
22
+ * process set it had before the turn: one pass over each live pid's /proc status file (cat skips the
23
+ * already-dead) TERMs, then KILLs, every pid outside the baseline that is not the sweep shell's own tree — the baseline already covers
24
+ * everything older than the turn, including pid 1, so orphans reparented to pid 1 are still caught.
25
+ */
26
+ export declare function turnSweepScript(baseline: readonly string[]): string;
@@ -48,3 +48,27 @@ export async function untilAborted(pending, signal, late) {
48
48
  void pending.then(late).catch(() => undefined);
49
49
  }
50
50
  }
51
+ /** The pids alive in the sandbox, one per line: the baseline a turn's processes are measured against. */
52
+ export function pidListScript() {
53
+ return "for d in /proc/[0-9]*; do echo ${d#/proc/}; done";
54
+ }
55
+ /**
56
+ * A harness may put tool children outside its process group (opencode's Bash tool spawns them detached), where
57
+ * neither the harness's exit nor the adapter's group kill reaches them. After every turn the box restores the
58
+ * process set it had before the turn: one pass over each live pid's /proc status file (cat skips the
59
+ * already-dead) TERMs, then KILLs, every pid outside the baseline that is not the sweep shell's own tree — the baseline already covers
60
+ * everything older than the turn, including pid 1, so orphans reparented to pid 1 are still caught.
61
+ */
62
+ export function turnSweepScript(baseline) {
63
+ return [
64
+ `victims=$(cat /proc/[0-9]*/status 2>/dev/null | awk -v base=${shellQuote(` ${baseline.join(" ")} `)} -v self=$$ '`,
65
+ `$1 == "Pid:" { p = $2 } $1 == "PPid:" { ppid[p] = $2 }`,
66
+ `END {`,
67
+ ` split(base, b, / +/); for (i in b) if (b[i] != "") known[b[i]] = 1`,
68
+ ` mine[self] = 1; changed = 1`,
69
+ ` while (changed) { changed = 0; for (p in ppid) if (!(p in mine) && (ppid[p] in mine)) { mine[p] = 1; changed = 1 } }`,
70
+ ` for (p in ppid) if (!(p in known) && !(p in mine)) printf "%s ", p`,
71
+ `}')`,
72
+ `[ -z "$victims" ] && exit 0; kill -TERM $victims 2>/dev/null; sleep 1; kill -KILL $victims 2>/dev/null; true`,
73
+ ].join("\n");
74
+ }
package/dist/wire.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { AgentExactRunControlRefSchema, CONTRACT_MAX_ARRAY_LENGTH, RequestedInteractionsSchema, WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface";
2
+ import { AgentExactRunControlRefSchema, AgentRuntimeAttachmentsSchema, CONTRACT_MAX_ARRAY_LENGTH, RequestedInteractionsSchema, WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface";
3
+ function runtimeAttachmentsField(input) {
4
+ if (input.runtimeAttachments === undefined)
5
+ return {};
6
+ return { runtime_attachments: AgentRuntimeAttachmentsSchema.parse(input.runtimeAttachments) };
7
+ }
3
8
  export function toChatCompletionsBody(options, environmentInput, turn, coordinates) {
4
9
  const profile = inlineProfile(environmentInput.profile);
5
10
  const execution = executionFromInput(options, environmentInput);
@@ -25,6 +30,7 @@ export function toChatCompletionsBody(options, environmentInput, turn, coordinat
25
30
  ...(workspaceCwd === undefined ? {} : { cwd: workspaceCwd }),
26
31
  ...(execution ? { execution } : {}),
27
32
  ...(interactions === undefined ? {} : { interactions }),
33
+ ...runtimeAttachmentsField(environmentInput),
28
34
  ...(turn.contextTransfer === undefined
29
35
  ? {}
30
36
  : { context_transfer: turn.contextTransfer }),
@@ -72,6 +78,7 @@ export function toRetainedSessionBody(options, environmentInput, profile, sessio
72
78
  ...(environmentInput.providerOptions !== undefined
73
79
  ? { provider_options: environmentInput.providerOptions }
74
80
  : {}),
81
+ ...runtimeAttachmentsField(environmentInput),
75
82
  };
76
83
  }
77
84
  /** Encode the exact retained turn contract without moving posture into metadata. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-cli-bridge",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "description": "AgentEnvironmentProvider adapter for cli-bridge retained sessions and one-shot turns",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "undici": "8.12.3"
44
44
  },
45
45
  "devDependencies": {
46
- "@tangle-network/agent-provider-testkit": "0.12.5",
46
+ "@tangle-network/agent-provider-testkit": "0.12.7",
47
47
  "@types/node": "26.6.5",
48
48
  "typescript": "6.0.3",
49
49
  "vitest": "5.0.5"