@tangle-network/agent-provider-cli-bridge 2.0.9 → 2.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/sandbox/box.d.ts +18 -12
- package/dist/sandbox/box.js +62 -21
- package/dist/sandbox/image.d.ts +3 -3
- package/dist/sandbox/image.js +5 -15
- package/dist/sandbox/index.d.ts +2 -1
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/provider.js +18 -12
- package/dist/sandbox/refusal.d.ts +2 -2
- package/dist/sandbox/refusal.js +3 -3
- package/dist/sandbox/shell.d.ts +10 -0
- package/dist/sandbox/shell.js +24 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -209,8 +209,12 @@ A vendor whose command call runs to completion, as Daytona's and computesdk's do
|
|
|
209
209
|
- Turns run through the Bridge. `turn.timeoutMs` cancels the run and ends the stream with a failed `result` that names the timeout, an abort rejects with `AbortError`, and a second turn on a busy environment is refused with `conflict`.
|
|
210
210
|
- Events carry `execution.started` with the declared model and a terminal `result` with the model the harness reported serving. The Bridge's dollar figure is the harness's catalog estimate, so events omit `usage.cost`.
|
|
211
211
|
|
|
212
|
-
The box image is `renderBoxDockerfile()`: Node, CLI Bridge at the commit in `BOX_PINS`, and each harness CLI at the version
|
|
213
|
-
|
|
212
|
+
The box image is `renderBoxDockerfile()`: Node, CLI Bridge at the commit in `BOX_PINS`, and each harness CLI at the version the harness catalog pins.
|
|
213
|
+
The catalog is `HARNESSES` from `@tangle-network/cli-agent-registry`, which agent-container generates from `infra/nix/agent-clis.json`, the pins the Tangle sandbox image is built from.
|
|
214
|
+
The box decides only how it installs and routes each catalog harness: every Router-eligible catalog harness has a recipe in `BOX_HARNESSES` or a reason and owner in `BOX_EXCLUSIONS`, and the type fails when the registry adds a harness the box does not place.
|
|
215
|
+
A harness joins `BOX_HARNESSES` only after a live turn through the Router completed in a box.
|
|
216
|
+
`boxInstallScript()` is the one install step; the image build and Boat's setup script both run it.
|
|
217
|
+
The image build refuses a lockfile whose registry pins differ from the latest published registry, so the box never ships older CLIs than the Tangle image.
|
|
214
218
|
Build the image for a cloud with `pnpm build:sandbox-image --target e2b|daytona|docker` from the repository root.
|
|
215
219
|
`@tangle-network/agent-provider-testkit` runs the live profile conformance suite against the result.
|
|
216
220
|
|
package/dist/sandbox/box.d.ts
CHANGED
|
@@ -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: "
|
|
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. */
|
|
@@ -31,27 +31,33 @@ export interface BoxRouter {
|
|
|
31
31
|
/** The per-run capped Router key. */
|
|
32
32
|
key: string;
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
35
|
-
* A harness the box carries and routes through the Tangle Router.
|
|
36
|
-
*
|
|
37
|
-
* A harness joins this table only after a live turn through the Router
|
|
38
|
-
* completed in a box, so the declared set is the proven set.
|
|
39
|
-
*/
|
|
34
|
+
/** A harness the box carries and routes through the Tangle Router. */
|
|
40
35
|
export interface BoxHarness {
|
|
41
36
|
readonly harness: HarnessType;
|
|
42
37
|
/** The Bridge backend name in `BRIDGE_BACKENDS`. */
|
|
43
38
|
readonly bridgeBackend: string;
|
|
44
|
-
/** The npm package that installs the CLI. */
|
|
39
|
+
/** The npm package that installs the CLI at the catalog's version. */
|
|
45
40
|
readonly npmPackage: string;
|
|
46
|
-
/** The CLI binary; `<bin> --version` prints the pinned version first. */
|
|
47
|
-
readonly bin: string;
|
|
48
41
|
/** Environment that points this harness at the Router, set on the Bridge process only. */
|
|
49
42
|
routerEnv(router: BoxRouter): Record<string, string>;
|
|
50
43
|
}
|
|
44
|
+
/** Why a catalog harness is not in the box, and who owns closing the gap. */
|
|
45
|
+
export interface BoxExclusion {
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
readonly owner: string;
|
|
48
|
+
}
|
|
51
49
|
export declare const BOX_HARNESSES: Readonly<Partial<Record<HarnessType, BoxHarness>>>;
|
|
50
|
+
/** Every catalog harness the box does not carry, with the reason and its owner. */
|
|
51
|
+
export declare const BOX_EXCLUSIONS: Readonly<Partial<Record<HarnessType, BoxExclusion>>>;
|
|
52
52
|
export declare function boxHarness(harness: string | undefined): BoxHarness | undefined;
|
|
53
|
-
/** The CLI version the harness
|
|
54
|
-
export declare
|
|
53
|
+
/** The CLI version the harness catalog pins; the box image installs exactly this. */
|
|
54
|
+
export declare const boxHarnessVersion: (harness: HarnessType) => string;
|
|
55
|
+
/** The catalog's command that prints the installed CLI version. */
|
|
56
|
+
export declare const boxVersionCommand: (harness: HarnessType) => string;
|
|
57
|
+
/** A shell test that the version command prints the pin as one token, with or without a leading `v` (`codex-cli 0.164.0`). */
|
|
58
|
+
export declare function boxVersionProbe(harness: HarnessType): string;
|
|
59
|
+
/** The one install step every box runs, the image build and Boat's setup script alike: the pinned CLIs and Bridge, then a version probe for each. */
|
|
60
|
+
export declare function boxInstallScript(harnesses: readonly HarnessType[]): string;
|
|
55
61
|
/** The command that starts the Bridge; its settings come from the process environment. */
|
|
56
62
|
export declare const BRIDGE_START_COMMAND = "cli-bridge";
|
|
57
63
|
/**
|
package/dist/sandbox/box.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,34 +20,75 @@ 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: "
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
},
|
|
28
|
+
const noBridgeBackend = { reason: `CLI Bridge ${BOX_PINS.cliBridgeCommit.slice(0, 12)} registers no backend for it`, owner: BOX_PINS.cliBridgeRepository };
|
|
29
|
+
/**
|
|
30
|
+
* Every Router-eligible catalog harness, as a recipe or an exclusion; the type
|
|
31
|
+
* fails when the registry adds a harness the box does not place. A recipe joins
|
|
32
|
+
* only after a live turn through the Router completed in a box.
|
|
33
|
+
*/
|
|
34
|
+
const BOX_ROWS = {
|
|
35
|
+
// The Router authenticates a Bearer token. ANTHROPIC_API_KEY sends x-api-key, which it rejects.
|
|
36
|
+
"claude-code": { bridgeBackend: "claude", npmPackage: "@anthropic-ai/claude-code", routerEnv: (router) => ({ ANTHROPIC_BASE_URL: router.baseUrl, ANTHROPIC_AUTH_TOKEN: router.key }) },
|
|
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.
|
|
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 },
|
|
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)" },
|
|
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)" },
|
|
42
|
+
hermes: { reason: "hermes 0.22.4 has no PyPI or official npm release, and the registry publishes no install source (git repository, ref, installer) for it; after that, the Bridge's AcpBackend forwards no request model to `hermes acp`", owner: "tangle-network/agent-container (cli-agent-registry)" },
|
|
43
|
+
openclaw: { reason: `CLI Bridge ${BOX_PINS.cliBridgeCommit.slice(0, 12)} runs \`openclaw acp\`, a client of an OpenClaw Gateway the box never starts (ECONNREFUSED 127.0.0.1:18789) that also rejects per-session MCP; openclaw reads its endpoint only from an openclaw.json the Bridge never writes`, owner: BOX_PINS.cliBridgeRepository },
|
|
44
|
+
"factory-droids": noBridgeBackend,
|
|
45
|
+
grok: noBridgeBackend,
|
|
46
|
+
nanoclaw: noBridgeBackend,
|
|
47
|
+
acp: noBridgeBackend,
|
|
40
48
|
};
|
|
49
|
+
const rows = Object.entries(BOX_ROWS);
|
|
50
|
+
export const BOX_HARNESSES = Object.fromEntries(rows.flatMap(([harness, row]) => ("npmPackage" in row ? [[harness, { harness, ...row }]] : [])));
|
|
51
|
+
/** Every catalog harness the box does not carry, with the reason and its owner. */
|
|
52
|
+
export const BOX_EXCLUSIONS = Object.fromEntries([
|
|
53
|
+
...rows.filter(([, row]) => !("npmPackage" in row)),
|
|
54
|
+
...Object.entries(HARNESSES)
|
|
55
|
+
.filter(([harness]) => !(harness in BOX_ROWS))
|
|
56
|
+
.map(([harness, { config }]) => [harness, {
|
|
57
|
+
reason: config.baseUrl === "vendor-only" ? "the catalog declares it vendor-only: no Router endpoint" : "the catalog declares no native CLI for it",
|
|
58
|
+
owner: "tangle-network/agent-container (cli-agent-registry)",
|
|
59
|
+
}]),
|
|
60
|
+
]);
|
|
41
61
|
export function boxHarness(harness) {
|
|
42
62
|
return harness === undefined ? undefined : BOX_HARNESSES[harness];
|
|
43
63
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
function catalogCli(harness) {
|
|
65
|
+
const session = HARNESSES[harness]?.session;
|
|
66
|
+
if (session?.kind !== "native")
|
|
67
|
+
throw new Error(`the harness catalog declares no native CLI for ${harness}`);
|
|
68
|
+
return { versionCommand: session.processProbe.versionCommand, cliVersion: session.contract.cliVersion };
|
|
69
|
+
}
|
|
70
|
+
/** The CLI version the harness catalog pins; the box image installs exactly this. */
|
|
71
|
+
export const boxHarnessVersion = (harness) => catalogCli(harness).cliVersion;
|
|
72
|
+
/** The catalog's command that prints the installed CLI version. */
|
|
73
|
+
export const boxVersionCommand = (harness) => catalogCli(harness).versionCommand;
|
|
74
|
+
/** A shell test that the version command prints the pin as one token, with or without a leading `v` (`codex-cli 0.164.0`). */
|
|
75
|
+
export function boxVersionProbe(harness) {
|
|
76
|
+
const { versionCommand, cliVersion } = catalogCli(harness);
|
|
77
|
+
return `(${versionCommand}) 2>&1 | tr -s ' \\t' '\\n\\n' | sed 's/^v//' | grep -Fxq -- '${cliVersion}'`;
|
|
78
|
+
}
|
|
79
|
+
/** The one install step every box runs, the image build and Boat's setup script alike: the pinned CLIs and Bridge, then a version probe for each. */
|
|
80
|
+
export function boxInstallScript(harnesses) {
|
|
81
|
+
if (harnesses.length === 0)
|
|
82
|
+
throw new Error("no harness to install; the box needs at least one");
|
|
83
|
+
const entries = harnesses.map((name) => {
|
|
84
|
+
const entry = boxHarness(name);
|
|
85
|
+
if (entry === undefined)
|
|
86
|
+
throw new Error(`harness ${name} is not in the box: ${BOX_EXCLUSIONS[name]?.reason ?? "not in the harness catalog"}`);
|
|
87
|
+
return entry;
|
|
88
|
+
});
|
|
89
|
+
const bridge = `https://github.com/${BOX_PINS.cliBridgeRepository}/archive/${BOX_PINS.cliBridgeCommit}.tar.gz`;
|
|
90
|
+
const packages = entries.map((entry) => `${entry.npmPackage}@${boxHarnessVersion(entry.harness)}`);
|
|
91
|
+
return [`npm install -g ${[...packages, bridge].join(" ")}`, ...entries.map((entry) => boxVersionProbe(entry.harness)), "command -v cli-bridge >/dev/null"].join(" \\\n && ");
|
|
51
92
|
}
|
|
52
93
|
/** The command that starts the Bridge; its settings come from the process environment. */
|
|
53
94
|
export const BRIDGE_START_COMMAND = "cli-bridge";
|
package/dist/sandbox/image.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type { HarnessType } from "@tangle-network/agent-interface";
|
|
|
4
4
|
*
|
|
5
5
|
* Every cloud builds from this text: E2B and Daytona build their template and
|
|
6
6
|
* image from it, and a local Docker build of it is the same box. Harness
|
|
7
|
-
* versions come from the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* versions come from the harness catalog. `baseImage` overrides the Node base
|
|
8
|
+
* for a box that carries more than the pins; the install layer is unchanged,
|
|
9
|
+
* so every box keeps the same Bridge and harness versions.
|
|
10
10
|
*/
|
|
11
11
|
export declare function renderBoxDockerfile(harnesses?: readonly HarnessType[], baseImage?: string): string;
|
package/dist/sandbox/image.js
CHANGED
|
@@ -1,29 +1,19 @@
|
|
|
1
|
-
import { BOX_HARNESSES, BOX_HOME, BOX_NODE_IMAGE,
|
|
1
|
+
import { BOX_HARNESSES, BOX_HOME, BOX_NODE_IMAGE, BOX_WORKSPACE_DIR, boxInstallScript } from "./box.js";
|
|
2
2
|
/**
|
|
3
3
|
* The box image as one Dockerfile.
|
|
4
4
|
*
|
|
5
5
|
* Every cloud builds from this text: E2B and Daytona build their template and
|
|
6
6
|
* image from it, and a local Docker build of it is the same box. Harness
|
|
7
|
-
* versions come from the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* versions come from the harness catalog. `baseImage` overrides the Node base
|
|
8
|
+
* for a box that carries more than the pins; the install layer is unchanged,
|
|
9
|
+
* so every box keeps the same Bridge and harness versions.
|
|
10
10
|
*/
|
|
11
11
|
export function renderBoxDockerfile(harnesses = Object.keys(BOX_HARNESSES), baseImage = BOX_NODE_IMAGE) {
|
|
12
|
-
const installs = harnesses.map((name) => {
|
|
13
|
-
const entry = BOX_HARNESSES[name];
|
|
14
|
-
if (entry === undefined)
|
|
15
|
-
throw new Error(`harness ${name} is not in the box harness table`);
|
|
16
|
-
return { entry, version: boxHarnessVersion(name) };
|
|
17
|
-
});
|
|
18
|
-
const bridge = `https://github.com/${BOX_PINS.cliBridgeRepository}/archive/${BOX_PINS.cliBridgeCommit}.tar.gz`;
|
|
19
|
-
const packages = installs.map(({ entry, version }) => `${entry.npmPackage}@${version}`);
|
|
20
|
-
const probes = installs.map(({ entry, version }) => `test "$(${entry.bin} --version | cut -d' ' -f1)" = "${version}"`);
|
|
21
12
|
return [
|
|
22
13
|
`FROM ${baseImage}`,
|
|
23
14
|
"RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates python3 make g++ ripgrep procps \\",
|
|
24
15
|
" && rm -rf /var/lib/apt/lists/*",
|
|
25
|
-
`RUN
|
|
26
|
-
` && ${[...probes, "command -v cli-bridge >/dev/null"].join(" \\\n && ")}`,
|
|
16
|
+
`RUN ${boxInstallScript(harnesses)}`,
|
|
27
17
|
`RUN mkdir -p ${BOX_WORKSPACE_DIR} && chown -R node:node ${BOX_HOME}`,
|
|
28
18
|
"USER node",
|
|
29
19
|
`ENV HOME=${BOX_HOME}`,
|
package/dist/sandbox/index.d.ts
CHANGED
|
@@ -4,5 +4,6 @@ export type { BridgeSandbox, BridgeSandboxCreateRequest, BridgeSandboxSummary, B
|
|
|
4
4
|
export { checkpointSnapshotName } from "./checkpoint.js";
|
|
5
5
|
export { validateBoxProfile } from "./refusal.js";
|
|
6
6
|
export { envFileLaunch, markedExec, untilAborted } from "./shell.js";
|
|
7
|
-
export {
|
|
7
|
+
export type { BoxExclusion, BoxHarness } from "./box.js";
|
|
8
|
+
export { BOX_EXCLUSIONS, BOX_HARNESSES, BOX_HOME, BOX_PINS, BOX_TEMPLATE_NAME, BOX_WORKSPACE_DIR, BRIDGE_PORT, boxHarnessVersion, boxInstallScript, boxVersionCommand, boxVersionProbe, } from "./box.js";
|
|
8
9
|
export { renderBoxDockerfile } from "./image.js";
|
package/dist/sandbox/index.js
CHANGED
|
@@ -2,5 +2,5 @@ export { createBridgeSandboxProvider, BRIDGE_RUN_TAG } from "./provider.js";
|
|
|
2
2
|
export { checkpointSnapshotName } from "./checkpoint.js";
|
|
3
3
|
export { validateBoxProfile } from "./refusal.js";
|
|
4
4
|
export { envFileLaunch, markedExec, untilAborted } from "./shell.js";
|
|
5
|
-
export { BOX_HARNESSES, BOX_HOME, BOX_PINS, BOX_TEMPLATE_NAME, BOX_WORKSPACE_DIR, BRIDGE_PORT, boxHarnessVersion, } from "./box.js";
|
|
5
|
+
export { BOX_EXCLUSIONS, BOX_HARNESSES, BOX_HOME, BOX_PINS, BOX_TEMPLATE_NAME, BOX_WORKSPACE_DIR, BRIDGE_PORT, boxHarnessVersion, boxInstallScript, boxVersionCommand, boxVersionProbe, } from "./box.js";
|
|
6
6
|
export { renderBoxDockerfile } from "./image.js";
|
package/dist/sandbox/provider.js
CHANGED
|
@@ -6,9 +6,10 @@ import { AgentEnvironmentGetOptionsSchema, AgentTurnInputSchema, EnvironmentProv
|
|
|
6
6
|
import { shellQuote } from "@tangle-network/agent-core/node";
|
|
7
7
|
import { harnessModelSupportFor } from "@tangle-network/cli-agent-registry";
|
|
8
8
|
import { createCliBridgeProvider, defaultCliBridgeCapabilities } from "../index.js";
|
|
9
|
-
import { BOX_WORKSPACE_DIR, BRIDGE_PORT, BRIDGE_START_COMMAND, bridgeProcessEnv, boxHarness, boxHarnessVersion, } from "./box.js";
|
|
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
|
|
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,
|
|
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);
|
|
@@ -217,12 +218,12 @@ async function waitForBridge(baseUrl, bearer, bridgeFetch, backend, timeoutMs, s
|
|
|
217
218
|
throw new EnvironmentProviderError("provision_failed", `cli-bridge was not ready within ${timeoutMs} ms (${last})`, { secrets });
|
|
218
219
|
}
|
|
219
220
|
async function assertHarnessVersion(sandbox, harness, signal) {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
221
|
+
const probe = await sandbox.exec(boxVersionProbe(harness), { ...signalOption(signal), timeoutMs: 30_000 });
|
|
222
|
+
if (probe.exitCode === 0)
|
|
223
|
+
return;
|
|
224
|
+
const command = boxVersionCommand(harness);
|
|
225
|
+
const shown = await sandbox.exec(command, { ...signalOption(signal), timeoutMs: 30_000 });
|
|
226
|
+
throw new EnvironmentProviderError("materialization_failed", `${command} reported "${(shown.stdout.trim() || shown.stderr.trim()).slice(0, 200)}" (exit ${shown.exitCode}); the harness catalog pins ${boxHarnessVersion(harness)}`);
|
|
226
227
|
}
|
|
227
228
|
async function cloneRepository(sandbox, input, secrets) {
|
|
228
229
|
const repoUrl = input.workspace?.repoUrl;
|
|
@@ -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,
|
|
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
|
|
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
|
|
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;
|
package/dist/sandbox/refusal.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/sandbox/shell.d.ts
CHANGED
|
@@ -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;
|
package/dist/sandbox/shell.js
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/agent-provider-cli-bridge",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.11",
|
|
4
4
|
"description": "AgentEnvironmentProvider adapter for cli-bridge retained sessions and one-shot turns",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@tangle-network/agent-core": "^1.0.2",
|
|
40
40
|
"@tangle-network/agent-interface": "^5.10.19",
|
|
41
|
-
"@tangle-network/agent-profile-materialize": "1.1.4",
|
|
42
|
-
"@tangle-network/cli-agent-registry": "^0.
|
|
41
|
+
"@tangle-network/agent-profile-materialize": "^1.1.4",
|
|
42
|
+
"@tangle-network/cli-agent-registry": "^0.15.30",
|
|
43
43
|
"undici": "8.12.3"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@tangle-network/agent-provider-testkit": "0.12.
|
|
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"
|