diff --git a/package-lock.json b/package-lock.json index c0b3f5da283..cce155a9d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1046.0", + "@grpc/grpc-js": "1.14.4", + "@grpc/proto-loader": "0.8.1", "@oclif/core": "^4.10.5", "execa": "^9.6.1", "js-yaml": "^4.1.1", @@ -3549,6 +3551,37 @@ "node": ">=18" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@j178/prek": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@j178/prek/-/prek-0.3.6.tgz", @@ -3619,6 +3652,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -3704,6 +3747,63 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -4182,7 +4282,6 @@ "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -4580,7 +4679,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -4998,7 +5096,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5332,7 +5429,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6081,7 +6177,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -6119,6 +6214,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -6448,6 +6549,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -6487,7 +6611,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6815,7 +6938,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -7102,7 +7224,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -7127,7 +7248,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -7146,7 +7266,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index a0b9c0068b7..36fc247102b 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1046.0", + "@grpc/grpc-js": "1.14.4", + "@grpc/proto-loader": "0.8.1", "@oclif/core": "^4.10.5", "execa": "^9.6.1", "js-yaml": "^4.1.1", @@ -103,6 +105,7 @@ "nemoclaw/openclaw.plugin.json", "nemoclaw/package.json", "nemoclaw-blueprint/", + "third_party/openshell/", "scripts/", "docs/resources/local-credential-form.html", "Dockerfile", diff --git a/scripts/checks/openshell-grpc-proto-pin.mts b/scripts/checks/openshell-grpc-proto-pin.mts new file mode 100644 index 00000000000..0ea8aa9295d --- /dev/null +++ b/scripts/checks/openshell-grpc-proto-pin.mts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parse as parseYaml } from "yaml"; + +interface OpenShellProtoPin { + version: string; + files: Readonly>; +} + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const PACKAGED_PROTO_ROOT = "third_party/openshell/"; + +export const OPENSHELL_GRPC_PROTO_PIN: OpenShellProtoPin = { + version: "0.0.85", + files: { + "datamodel.proto": "64d7ec700f2da4a9173e61e8af7431cf4537d0aa30f95a6a0d22b8798c8e17ee", + "openshell.proto": "ddf72e4962430e86cb16a100a36f96ca11be40f326ff31141ee10a3d073e728e", + "options.proto": "620c71e42f8fab5eb337ad297945c3638965993e4d8a8422830fcf5ab1faad6f", + "sandbox.proto": "e25b7cb053cbac79c4f9c22c7c67da8290729e88015f4c1b0a3d3d15c893d356", + }, +}; + +function readFile(rootDir: string, relativePath: string, failures: string[]): Buffer | null { + try { + return fs.readFileSync(path.join(rootDir, relativePath)); + } catch (error) { + failures.push(`${relativePath}: failed to read (${(error as Error).message})`); + return null; + } +} + +function blueprintOpenShellVersion(source: Buffer, failures: string[]): string | null { + let parsed: unknown; + try { + parsed = parseYaml(source.toString("utf8")); + } catch (error) { + failures.push( + `nemoclaw-blueprint/blueprint.yaml: failed to parse (${(error as Error).message})`, + ); + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + failures.push("nemoclaw-blueprint/blueprint.yaml: expected a mapping"); + return null; + } + const version = (parsed as Record).max_openshell_version; + if (typeof version !== "string" || !version) { + failures.push("nemoclaw-blueprint/blueprint.yaml: max_openshell_version must be a string"); + return null; + } + return version; +} + +function verifyProtocolSourcesArePackaged(source: Buffer, failures: string[]): void { + let parsed: unknown; + try { + parsed = JSON.parse(source.toString("utf8")); + } catch (error) { + failures.push(`package.json: failed to parse (${(error as Error).message})`); + return; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + failures.push("package.json: expected an object"); + return; + } + const files = (parsed as Record).files; + if (!Array.isArray(files) || !files.includes(PACKAGED_PROTO_ROOT)) { + failures.push( + `package.json: files must include ${PACKAGED_PROTO_ROOT} for runtime gRPC loading`, + ); + } +} + +export function verifyOpenShellGrpcProtoPin( + rootDir = REPO_ROOT, + pin: OpenShellProtoPin = OPENSHELL_GRPC_PROTO_PIN, +): string[] { + const failures: string[] = []; + const blueprint = readFile(rootDir, "nemoclaw-blueprint/blueprint.yaml", failures); + if (blueprint) { + const supportedVersion = blueprintOpenShellVersion(blueprint, failures); + if (supportedVersion && supportedVersion !== pin.version) { + failures.push( + `OpenShell gRPC proto version: expected blueprint maximum ${supportedVersion}, found ${pin.version}`, + ); + } + } + + const packageManifest = readFile(rootDir, "package.json", failures); + if (packageManifest) verifyProtocolSourcesArePackaged(packageManifest, failures); + + const protoRoot = `third_party/openshell/v${pin.version}/proto`; + for (const [fileName, expectedDigest] of Object.entries(pin.files)) { + const relativePath = `${protoRoot}/${fileName}`; + const source = readFile(rootDir, relativePath, failures); + if (!source) continue; + const actualDigest = createHash("sha256").update(source).digest("hex"); + if (actualDigest !== expectedDigest) { + failures.push(`${relativePath}: expected SHA-256 ${expectedDigest}, found ${actualDigest}`); + } + } + + return failures; +} + +function main(): void { + const failures = verifyOpenShellGrpcProtoPin(); + if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); + } + console.log(`OpenShell gRPC protocol sources match v${OPENSHELL_GRPC_PROTO_PIN.version}.`); +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) main(); diff --git a/scripts/checks/run.mts b/scripts/checks/run.mts index 2e8cddec95d..f9b4b2c0262 100644 --- a/scripts/checks/run.mts +++ b/scripts/checks/run.mts @@ -53,6 +53,11 @@ export const CHECKS: readonly CheckCommand[] = [ command: TSX, args: ["scripts/checks/legacy-sandbox-transports.mts"], }, + { + name: "openshell-grpc-proto-pin", + command: TSX, + args: ["scripts/checks/openshell-grpc-proto-pin.mts"], + }, { name: "dependency-pins", command: TSX, diff --git a/src/lib/actions/sandbox/sessions/passthrough.test.ts b/src/lib/actions/sandbox/sessions/passthrough.test.ts index 127c957c1ea..b59787c6724 100644 --- a/src/lib/actions/sandbox/sessions/passthrough.test.ts +++ b/src/lib/actions/sandbox/sessions/passthrough.test.ts @@ -3,13 +3,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const captureMock = vi.hoisted(() => vi.fn()); +const readOnlyExecMock = vi.hoisted(() => vi.fn()); const execMock = vi.hoisted(() => vi.fn(async () => {})); const ensureLiveMock = vi.hoisted(() => vi.fn(async () => ({}))); -const getSandboxMock = vi.hoisted(() => vi.fn(() => null as { agent?: string } | null)); - -vi.mock("../../../adapters/openshell/runtime", () => ({ - captureOpenshell: captureMock, +const getSandboxMock = vi.hoisted(() => + vi.fn( + () => + null as { + agent?: string; + gatewayName?: string; + gatewayPort?: number; + } | null, + ), +); + +vi.mock("../../../adapters/openshell/sandbox-control-routing", () => ({ + execSandboxReadOnlyWithGrpcFallback: readOnlyExecMock, })); vi.mock("../exec", async () => { const actual = await vi.importActual("../exec"); @@ -217,7 +226,7 @@ describe("runSessionsPassthrough", () => { let consoleErrorSpy: ReturnType; beforeEach(() => { - captureMock.mockReset(); + readOnlyExecMock.mockReset(); execMock.mockClear(); ensureLiveMock.mockClear(); getSandboxMock.mockReset(); @@ -234,13 +243,14 @@ describe("runSessionsPassthrough", () => { }); it("captures and filters `sessions list --json` instead of streaming warm-up entries", async () => { - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: 0, - output: JSON.stringify({ + stdout: JSON.stringify({ count: 1, totalCount: 1, sessions: [{ key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }], }), + stderr: "", }); await runSessionsPassthrough("alpha", { @@ -250,22 +260,11 @@ describe("runSessionsPassthrough", () => { expect(ensureLiveMock).toHaveBeenCalledWith("alpha", { allowNonReadyPhase: true }); expect(execMock).not.toHaveBeenCalled(); - expect(captureMock).toHaveBeenCalledWith( - [ - "sandbox", - "exec", - "--name", - "alpha", - "--", - "openclaw", - "sessions", - "list", - "--agent", - "main", - "--json", - ], - { ignoreError: true, includeStreams: true, maxBuffer: 64 * 1024 * 1024 }, - ); + expect(readOnlyExecMock).toHaveBeenCalledWith("nemoclaw", { + sandboxName: "alpha", + command: ["openclaw", "sessions", "list", "--agent", "main", "--json"], + maxOutputBytes: 64 * 1024 * 1024, + }); expect(JSON.parse(String(stdoutSpy.mock.calls[0]?.[0]))).toEqual({ count: 0, totalCount: 0, @@ -274,40 +273,38 @@ describe("runSessionsPassthrough", () => { }); it("captures and filters text `sessions list` output", async () => { - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: 0, stdout: [ "Sessions listed: 1", `direct agent:main:expli... 1m ago model id:${WARMUP_SESSION_ID_PREFIX}1`, ].join("\n"), stderr: "warning: noisy but non-fatal\n", - output: [ - "Sessions listed: 1", - `direct agent:main:expli... 1m ago model id:${WARMUP_SESSION_ID_PREFIX}1`, - ].join("\n"), }); await runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--agent", "main"] }); expect(execMock).not.toHaveBeenCalled(); - expect(captureMock).toHaveBeenCalled(); + expect(readOnlyExecMock).toHaveBeenCalled(); expect(String(stdoutSpy.mock.calls[0]?.[0])).toBe("Sessions listed: 0\n"); expect(String(stderrSpy.mock.calls[0]?.[0])).toBe("warning: noisy but non-fatal\n"); }); it("also filters the parent `sessions` list shorthand", async () => { - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: 0, - output: `Sessions listed: 1\nid:${WARMUP_SESSION_ID_PREFIX}1`, + stdout: `Sessions listed: 1\nid:${WARMUP_SESSION_ID_PREFIX}1`, + stderr: "", }); await runSessionsPassthrough("alpha", { extraArgs: [] }); expect(execMock).not.toHaveBeenCalled(); - expect(captureMock).toHaveBeenCalledWith( - ["sandbox", "exec", "--name", "alpha", "--", "openclaw", "sessions"], - { ignoreError: true, includeStreams: true, maxBuffer: 64 * 1024 * 1024 }, - ); + expect(readOnlyExecMock).toHaveBeenCalledWith("nemoclaw", { + sandboxName: "alpha", + command: ["openclaw", "sessions"], + maxOutputBytes: 64 * 1024 * 1024, + }); expect(String(stdoutSpy.mock.calls[0]?.[0])).toBe("Sessions listed: 0\n"); }); @@ -317,11 +314,12 @@ describe("runSessionsPassthrough", () => { ) => { throw new Error(`process.exit:${code}`); }) as never); - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: 0, - output: JSON.stringify({ + stdout: JSON.stringify({ records: [{ sid: `${WARMUP_SESSION_ID_PREFIX}1` }], }), + stderr: "", }); try { @@ -338,9 +336,10 @@ describe("runSessionsPassthrough", () => { it("passes through unrecognised JSON when it cannot leak a warm-up session", async () => { const raw = JSON.stringify({ records: [{ key: "agent:main:main", sessionId: "sid-real" }] }); - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: 0, - output: raw, + stdout: raw, + stderr: "", }); await runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--json"] }); @@ -355,9 +354,8 @@ describe("runSessionsPassthrough", () => { ) => { throw new Error(`process.exit:${code}`); }) as never); - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: null, - output: "", stdout: "", stderr: "", error: Object.assign(new Error("spawnSync openshell ENOBUFS"), { code: "ENOBUFS" }), @@ -371,10 +369,10 @@ describe("runSessionsPassthrough", () => { exitSpy.mockRestore(); } - expect(captureMock).toHaveBeenCalledWith(expect.any(Array), { - ignoreError: true, - includeStreams: true, - maxBuffer: 64 * 1024 * 1024, + expect(readOnlyExecMock).toHaveBeenCalledWith("nemoclaw", { + sandboxName: "alpha", + command: ["openclaw", "sessions", "list", "--all-agents", "--json"], + maxOutputBytes: 64 * 1024 * 1024, }); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining("output exceeded NemoClaw's 64 MiB filtering buffer"), @@ -388,21 +386,30 @@ describe("runSessionsPassthrough", () => { await runSessionsPassthrough("hermes", { extraArgs: [] }); - expect(captureMock).not.toHaveBeenCalled(); + expect(readOnlyExecMock).not.toHaveBeenCalled(); expect(execMock).toHaveBeenCalledWith("hermes", ["hermes", "sessions", "list"]); }); it("uses openclaw binary for openclaw-agent sandboxes (#6247)", async () => { - getSandboxMock.mockReturnValue({ agent: "openclaw" }); - captureMock.mockReturnValueOnce({ status: 0, output: "Sessions listed: 0\n" }); + getSandboxMock.mockReturnValue({ + agent: "openclaw", + gatewayName: "nemoclaw-9090", + gatewayPort: 9090, + }); + readOnlyExecMock.mockResolvedValueOnce({ + status: 0, + stdout: "Sessions listed: 0\n", + stderr: "", + }); await runSessionsPassthrough("alpha", { extraArgs: [] }); expect(execMock).not.toHaveBeenCalled(); - expect(captureMock).toHaveBeenCalledWith( - ["sandbox", "exec", "--name", "alpha", "--", "openclaw", "sessions"], - { ignoreError: true, includeStreams: true, maxBuffer: 64 * 1024 * 1024 }, - ); + expect(readOnlyExecMock).toHaveBeenCalledWith("nemoclaw-9090", { + sandboxName: "alpha", + command: ["openclaw", "sessions"], + maxOutputBytes: 64 * 1024 * 1024, + }); }); it("routes hermes `sessions list` with forwarded flags via execSandbox (#6247)", async () => { @@ -413,34 +420,44 @@ describe("runSessionsPassthrough", () => { extraArgs: ["--limit", "5"], }); - expect(captureMock).not.toHaveBeenCalled(); + expect(readOnlyExecMock).not.toHaveBeenCalled(); expect(execMock).toHaveBeenCalledWith("hermes", ["hermes", "sessions", "list", "--limit", "5"]); }); it("defaults to the openclaw binary + filter path when the registry has no entry (#6247)", async () => { getSandboxMock.mockReturnValue(null); - captureMock.mockReturnValueOnce({ status: 0, output: "Sessions listed: 0\n" }); + readOnlyExecMock.mockResolvedValueOnce({ + status: 0, + stdout: "Sessions listed: 0\n", + stderr: "", + }); await runSessionsPassthrough("alpha", { extraArgs: [] }); expect(execMock).not.toHaveBeenCalled(); - expect(captureMock).toHaveBeenCalledWith( - ["sandbox", "exec", "--name", "alpha", "--", "openclaw", "sessions"], - { ignoreError: true, includeStreams: true, maxBuffer: 64 * 1024 * 1024 }, - ); + expect(readOnlyExecMock).toHaveBeenCalledWith("nemoclaw", { + sandboxName: "alpha", + command: ["openclaw", "sessions"], + maxOutputBytes: 64 * 1024 * 1024, + }); }); it("defaults to the openclaw binary for an unknown agent value (#6247)", async () => { getSandboxMock.mockReturnValue({ agent: "custom-future-agent" }); - captureMock.mockReturnValueOnce({ status: 0, output: "Sessions listed: 0\n" }); + readOnlyExecMock.mockResolvedValueOnce({ + status: 0, + stdout: "Sessions listed: 0\n", + stderr: "", + }); await runSessionsPassthrough("alpha", { extraArgs: [] }); expect(execMock).not.toHaveBeenCalled(); - expect(captureMock).toHaveBeenCalledWith( - ["sandbox", "exec", "--name", "alpha", "--", "openclaw", "sessions"], - { ignoreError: true, includeStreams: true, maxBuffer: 64 * 1024 * 1024 }, - ); + expect(readOnlyExecMock).toHaveBeenCalledWith("nemoclaw", { + sandboxName: "alpha", + command: ["openclaw", "sessions"], + maxOutputBytes: 64 * 1024 * 1024, + }); }); it("prints captured output when OpenClaw exits non-zero", async () => { @@ -449,9 +466,8 @@ describe("runSessionsPassthrough", () => { ) => { throw new Error(`process.exit:${code}`); }) as never); - captureMock.mockReturnValueOnce({ + readOnlyExecMock.mockResolvedValueOnce({ status: 2, - output: "", stdout: "", stderr: "unknown flag: --bad\n", }); diff --git a/src/lib/actions/sandbox/sessions/passthrough.ts b/src/lib/actions/sandbox/sessions/passthrough.ts index 36f4ee5935f..a48545dee02 100644 --- a/src/lib/actions/sandbox/sessions/passthrough.ts +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { openShellSandboxControl } from "../../../adapters/openshell/sandbox-control"; +import { execSandboxReadOnlyWithGrpcFallback } from "../../../adapters/openshell/sandbox-control-routing"; import { CLI_NAME } from "../../../cli/branding"; +import { resolveSandboxGatewayName } from "../../../onboard/gateway-binding"; import * as registry from "../../../state/registry"; import { computeExitCode, execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; @@ -36,11 +37,11 @@ export function printSessionsPassthroughHelp(verb?: SessionsPassthroughVerb): vo console.log( ` Pass-through to the sandbox agent's \`sessions${usageSuffix} ...\` command inside the sandbox`, ); - console.log(" via `openshell sandbox exec` — `openclaw sessions ...` for OpenClaw sandboxes,"); + console.log(" via OpenShell's supported sandbox execution API — `openclaw sessions ...` for"); console.log( - ` \`hermes sessions${hermesUsageSuffix} ...\` for Hermes sandboxes; the in-sandbox binary is picked`, + ` OpenClaw sandboxes, \`hermes sessions${hermesUsageSuffix} ...\` for Hermes sandboxes; the`, ); - console.log(" from the sandbox's agent."); + console.log(" in-sandbox binary is picked from the sandbox's agent."); console.log( " On OpenClaw sandboxes, internal NemoClaw onboard warm-up sessions are hidden from default", ); @@ -223,14 +224,15 @@ export async function runSessionsPassthrough( // `~/.nemoclaw/sandboxes.json` registry (`REGISTRY_FILE`). Sandbox processes // cannot access the host filesystem to change this agent selection; unknown // or missing values deliberately default to `openclaw` below. - const sandboxAgent = registry.getSandbox(sandboxName)?.agent; + const sandbox = registry.getSandbox(sandboxName); + const sandboxAgent = sandbox?.agent; const inSandboxBinary = sandboxAgent === "hermes" ? "hermes" : "openclaw"; const command = [inSandboxBinary, "sessions"]; if (verb) command.push(verb); else if (inSandboxBinary === "hermes") command.push("list"); for (const arg of extraArgs) command.push(arg); if (isFilterableListPassthrough(verb) && inSandboxBinary === "openclaw") { - const result = await openShellSandboxControl.exec({ + const result = await execSandboxReadOnlyWithGrpcFallback(resolveSandboxGatewayName(sandbox), { sandboxName, command, maxOutputBytes: SESSIONS_LIST_CAPTURE_MAX_BUFFER_BYTES, diff --git a/src/lib/adapters/openshell/grpc-gateway-config.test.ts b/src/lib/adapters/openshell/grpc-gateway-config.test.ts new file mode 100644 index 00000000000..6c91d82531b --- /dev/null +++ b/src/lib/adapters/openshell/grpc-gateway-config.test.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + createGrpcOpenShellSandboxControlForGateway, + resolveOpenShellGrpcGateway, +} from "./grpc-gateway-config"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { force: true, recursive: true }); +}); + +function fixture(): { root: string; xdg: string; system: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-grpc-gateway-")); + roots.push(root); + return { root, xdg: path.join(root, "xdg"), system: path.join(root, "system") }; +} + +function writeMetadata( + configRoot: string, + gatewayName: string, + metadata: Record, +): string { + const target = path.join(configRoot, "gateways", gatewayName, "metadata.json"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, JSON.stringify({ name: gatewayName, ...metadata })); + return target; +} + +function writeMtls(xdg: string, gatewayName: string): void { + const target = path.join(xdg, "openshell", "gateways", gatewayName, "mtls"); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, "ca.crt"), "ca-pem"); + fs.writeFileSync(path.join(target, "tls.crt"), "cert-pem"); + fs.writeFileSync(path.join(target, "tls.key"), "key-pem"); +} + +describe("OpenShell gRPC gateway configuration", () => { + it("resolves user metadata and per-user mTLS materials", () => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "nemoclaw", { + gateway_endpoint: "https://127.0.0.1:8080", + auth_mode: "mtls", + }); + writeMtls(dirs.xdg, "nemoclaw"); + + const resolved = resolveOpenShellGrpcGateway("nemoclaw", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + }); + + expect(resolved).toEqual({ + gatewayName: "nemoclaw", + metadataSource: "user", + authMode: "mtls", + clientConfig: { + endpoint: "https://127.0.0.1:8080", + caCertificate: Buffer.from("ca-pem"), + clientCertificate: Buffer.from("cert-pem"), + clientKey: Buffer.from("key-pem"), + }, + }); + }); + + it("falls back to system metadata while keeping credentials in the user gateway directory", () => { + const dirs = fixture(); + writeMetadata(dirs.system, "nemoclaw", { + gateway_endpoint: "https://localhost:8080", + auth_mode: "mtls", + }); + writeMtls(dirs.xdg, "nemoclaw"); + + const resolved = resolveOpenShellGrpcGateway("nemoclaw", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + }); + + expect(resolved.metadataSource).toBe("system"); + expect(resolved.clientConfig.clientKey?.toString()).toBe("key-pem"); + }); + + it("lets a user metadata entry shadow system metadata even when malformed", () => { + const dirs = fixture(); + const userPath = writeMetadata(path.join(dirs.xdg, "openshell"), "nemoclaw", { + gateway_endpoint: "http://127.0.0.1:8080", + }); + fs.writeFileSync(userPath, "not-json"); + writeMetadata(dirs.system, "nemoclaw", { + gateway_endpoint: "http://127.0.0.1:9090", + auth_mode: "plaintext", + }); + + expect(() => + resolveOpenShellGrpcGateway("nemoclaw", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + }), + ).toThrow(`Failed to parse OpenShell gateway metadata from ${userPath}`); + }); + + it("infers plaintext for legacy HTTP metadata and constructs a direct client", () => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "nemoclaw", { + gateway_endpoint: "http://127.0.0.1:1", + }); + const options = { xdgConfigHome: dirs.xdg, systemConfigRoot: dirs.system }; + + expect(resolveOpenShellGrpcGateway("nemoclaw", options)).toMatchObject({ + authMode: "plaintext", + clientConfig: { endpoint: "http://127.0.0.1:1" }, + }); + const control = createGrpcOpenShellSandboxControlForGateway("nemoclaw", options); + control.close(); + }); + + it("loads an unexpired OIDC token and optional private CA", () => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "cloud", { + gateway_endpoint: "https://gateway.example:443", + auth_mode: "oidc", + }); + const gatewayDir = path.join(dirs.xdg, "openshell", "gateways", "cloud"); + fs.writeFileSync( + path.join(gatewayDir, "oidc_token.json"), + JSON.stringify({ access_token: "oidc-token", expires_at: 2000 }), + ); + fs.mkdirSync(path.join(gatewayDir, "mtls")); + fs.writeFileSync(path.join(gatewayDir, "mtls", "ca.crt"), "private-ca"); + + const resolved = resolveOpenShellGrpcGateway("cloud", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + nowSeconds: 1000, + }); + + expect(resolved.authMode).toBe("oidc"); + expect(resolved.clientConfig).toEqual({ + endpoint: "https://gateway.example:443", + bearerToken: "oidc-token", + caCertificate: Buffer.from("private-ca"), + }); + }); + + it("rejects OIDC tokens that need refresh", () => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "cloud", { + gateway_endpoint: "https://gateway.example:443", + auth_mode: "oidc", + }); + fs.writeFileSync( + path.join(dirs.xdg, "openshell", "gateways", "cloud", "oidc_token.json"), + JSON.stringify({ access_token: "expired", expires_at: 1020 }), + ); + + expect(() => + resolveOpenShellGrpcGateway("cloud", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + nowSeconds: 1000, + }), + ).toThrow("expired or near expiry"); + }); + + it("fails closed for edge-tunneled and unknown auth modes", () => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "edge", { + gateway_endpoint: "https://edge.example:443", + auth_mode: "cloudflare_jwt", + }); + writeMetadata(path.join(dirs.xdg, "openshell"), "unknown", { + gateway_endpoint: "https://unknown.example:443", + auth_mode: "future-auth", + }); + const options = { xdgConfigHome: dirs.xdg, systemConfigRoot: dirs.system }; + + expect(() => resolveOpenShellGrpcGateway("edge", options)).toThrow( + "require the OpenShell edge tunnel", + ); + expect(() => resolveOpenShellGrpcGateway("unknown", options)).toThrow( + "Unsupported OpenShell gateway auth mode 'future-auth'", + ); + }); + + it.each([ + ["plaintext", "https://gateway.example:443", "plaintext gateway metadata"], + ["mtls", "http://127.0.0.1:8080", "mTLS gateway metadata"], + ["oidc", "http://127.0.0.1:8080", "OIDC gateway metadata"], + ])("rejects %s metadata with an incompatible endpoint", (authMode, endpoint, message) => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "mismatch", { + gateway_endpoint: endpoint, + auth_mode: authMode, + }); + + expect(() => + resolveOpenShellGrpcGateway("mismatch", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + }), + ).toThrow(message); + }); + + it("rejects relative configuration roots and invalid clocks", () => { + expect(() => + resolveOpenShellGrpcGateway("nemoclaw", { xdgConfigHome: "relative/config" }), + ).toThrow("must be an absolute path"); + + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "nemoclaw", { + gateway_endpoint: "http://127.0.0.1:8080", + auth_mode: "plaintext", + }); + expect(() => + resolveOpenShellGrpcGateway("nemoclaw", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + nowSeconds: Number.NaN, + }), + ).toThrow("nowSeconds must be a non-negative safe integer"); + }); + + it("rejects unsafe names and metadata name mismatches", () => { + const dirs = fixture(); + const metadataPath = writeMetadata(path.join(dirs.xdg, "openshell"), "nemoclaw", { + gateway_endpoint: "http://127.0.0.1:8080", + auth_mode: "plaintext", + }); + fs.writeFileSync( + metadataPath, + JSON.stringify({ + name: "other", + gateway_endpoint: "http://127.0.0.1:8080", + auth_mode: "plaintext", + }), + ); + const options = { xdgConfigHome: dirs.xdg, systemConfigRoot: dirs.system }; + + expect(() => resolveOpenShellGrpcGateway("../escape", options)).toThrow("one path component"); + expect(() => resolveOpenShellGrpcGateway("nemoclaw", options)).toThrow("name mismatch"); + }); + + it("reports the exact missing mTLS material", () => { + const dirs = fixture(); + writeMetadata(path.join(dirs.xdg, "openshell"), "nemoclaw", { + gateway_endpoint: "https://127.0.0.1:8080", + auth_mode: "mtls", + }); + + expect(() => + resolveOpenShellGrpcGateway("nemoclaw", { + xdgConfigHome: dirs.xdg, + systemConfigRoot: dirs.system, + }), + ).toThrow(path.join("nemoclaw", "mtls", "ca.crt")); + }); +}); diff --git a/src/lib/adapters/openshell/grpc-gateway-config.ts b/src/lib/adapters/openshell/grpc-gateway-config.ts new file mode 100644 index 00000000000..ace3611bf52 --- /dev/null +++ b/src/lib/adapters/openshell/grpc-gateway-config.ts @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + createGrpcOpenShellSandboxControl, + type GrpcOpenShellSandboxControl, + type OpenShellGrpcClientConfig, + validateOpenShellGrpcClientConfig, +} from "./grpc-sandbox-control"; + +interface GatewayMetadata { + name: string; + gateway_endpoint: string; + auth_mode?: string | null; +} + +interface OidcTokenBundle { + access_token: string; + expires_at?: number | null; +} + +export interface OpenShellGrpcGatewayResolverOptions { + env?: NodeJS.ProcessEnv; + homeDir?: string; + nowSeconds?: number; + systemConfigRoot?: string; + xdgConfigHome?: string; +} + +export interface ResolvedOpenShellGrpcGateway { + gatewayName: string; + metadataSource: "user" | "system"; + authMode: "plaintext" | "mtls" | "oidc"; + clientConfig: OpenShellGrpcClientConfig; +} + +function validateGatewayName(name: string): void { + if ( + !name || + name === "." || + name === ".." || + name.includes("/") || + name.includes("\\") || + name.includes("\0") + ) { + throw new Error(`Invalid OpenShell gateway name '${name}': expected one path component`); + } +} + +function configRoots(options: OpenShellGrpcGatewayResolverOptions): { + user: string; + system: string; +} { + const env = options.env ?? process.env; + const xdgConfigHome = options.xdgConfigHome ?? env.XDG_CONFIG_HOME; + const user = xdgConfigHome ?? path.join(options.homeDir ?? os.homedir(), ".config"); + if (!path.isAbsolute(user)) { + throw new Error("OpenShell XDG config home must be an absolute path"); + } + const systemOverride = options.systemConfigRoot ?? env.OPENSHELL_SYSTEM_GATEWAY_DIR; + const system = + systemOverride && path.isAbsolute(systemOverride) ? systemOverride : "/etc/openshell"; + return { user: path.join(user, "openshell"), system }; +} + +function readJson(pathname: string, label: string): unknown { + let source: string; + try { + source = fs.readFileSync(pathname, "utf8"); + } catch (error) { + throw new Error(`Failed to read ${label} from ${pathname}: ${(error as Error).message}`); + } + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`Failed to parse ${label} from ${pathname}: ${(error as Error).message}`); + } +} + +function pathExists(pathname: string): boolean { + try { + fs.lstatSync(pathname); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw new Error(`Failed to inspect OpenShell path ${pathname}: ${(error as Error).message}`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function metadataAt(pathname: string, gatewayName: string): GatewayMetadata { + const value = readJson(pathname, "OpenShell gateway metadata"); + if (!isRecord(value)) throw new Error(`Invalid OpenShell gateway metadata in ${pathname}`); + if (value.name !== gatewayName) { + throw new Error( + `OpenShell gateway metadata name mismatch in ${pathname}: expected '${gatewayName}'`, + ); + } + if (typeof value.gateway_endpoint !== "string" || !value.gateway_endpoint) { + throw new Error(`OpenShell gateway metadata in ${pathname} has no gateway_endpoint`); + } + if ( + value.auth_mode !== undefined && + value.auth_mode !== null && + typeof value.auth_mode !== "string" + ) { + throw new Error(`OpenShell gateway metadata in ${pathname} has an invalid auth_mode`); + } + return value as unknown as GatewayMetadata; +} + +function resolveMetadata( + gatewayName: string, + roots: { user: string; system: string }, +): { metadata: GatewayMetadata; source: "user" | "system" } { + const userPath = path.join(roots.user, "gateways", gatewayName, "metadata.json"); + if (pathExists(userPath)) { + return { metadata: metadataAt(userPath, gatewayName), source: "user" }; + } + const systemPath = path.join(roots.system, "gateways", gatewayName, "metadata.json"); + if (pathExists(systemPath)) { + return { metadata: metadataAt(systemPath, gatewayName), source: "system" }; + } + throw new Error( + `No OpenShell metadata found for gateway '${gatewayName}' (looked in ${userPath} and ${systemPath})`, + ); +} + +function readRequired(pathname: string, label: string): Buffer { + let value: Buffer; + try { + value = fs.readFileSync(pathname); + } catch (error) { + throw new Error(`Failed to read ${label} from ${pathname}: ${(error as Error).message}`); + } + if (value.length === 0) throw new Error(`${label} is empty at ${pathname}`); + return value; +} + +function mtlsConfig(endpoint: string, mtlsDir: string): OpenShellGrpcClientConfig { + return { + endpoint, + caCertificate: readRequired(path.join(mtlsDir, "ca.crt"), "OpenShell TLS CA"), + clientCertificate: readRequired( + path.join(mtlsDir, "tls.crt"), + "OpenShell TLS client certificate", + ), + clientKey: readRequired(path.join(mtlsDir, "tls.key"), "OpenShell TLS client key"), + }; +} + +function oidcConfig( + endpoint: string, + gatewayDir: string, + nowSeconds: number, +): OpenShellGrpcClientConfig { + const tokenPath = path.join(gatewayDir, "oidc_token.json"); + const value = readJson(tokenPath, "OpenShell OIDC token"); + if (!isRecord(value) || typeof value.access_token !== "string" || !value.access_token) { + throw new Error(`OpenShell OIDC token in ${tokenPath} has no access_token`); + } + if ( + value.expires_at !== undefined && + value.expires_at !== null && + (!Number.isSafeInteger(value.expires_at) || Number(value.expires_at) < 0) + ) { + throw new Error(`OpenShell OIDC token in ${tokenPath} has an invalid expires_at`); + } + const token = value as unknown as OidcTokenBundle; + if ( + token.expires_at !== undefined && + token.expires_at !== null && + nowSeconds + 30 >= token.expires_at + ) { + throw new Error( + `OpenShell OIDC token for this gateway is expired or near expiry; refresh it with the OpenShell CLI`, + ); + } + + const config: OpenShellGrpcClientConfig = { endpoint, bearerToken: token.access_token }; + const mtlsDir = path.join(gatewayDir, "mtls"); + const caPath = path.join(mtlsDir, "ca.crt"); + const certPath = path.join(mtlsDir, "tls.crt"); + const keyPath = path.join(mtlsDir, "tls.key"); + const hasCa = pathExists(caPath); + if (hasCa) config.caCertificate = readRequired(caPath, "OpenShell TLS CA"); + if (hasCa && pathExists(certPath) && pathExists(keyPath)) { + config.clientCertificate = readRequired(certPath, "OpenShell TLS client certificate"); + config.clientKey = readRequired(keyPath, "OpenShell TLS client key"); + } + return config; +} + +function effectiveAuthMode(metadata: GatewayMetadata): string { + if (metadata.auth_mode) return metadata.auth_mode; + return metadata.gateway_endpoint.startsWith("http://") ? "plaintext" : "mtls"; +} + +export function resolveOpenShellGrpcGateway( + gatewayName: string, + options: OpenShellGrpcGatewayResolverOptions = {}, +): ResolvedOpenShellGrpcGateway { + validateGatewayName(gatewayName); + const roots = configRoots(options); + const { metadata, source } = resolveMetadata(gatewayName, roots); + const authMode = effectiveAuthMode(metadata); + const gatewayDir = path.join(roots.user, "gateways", gatewayName); + const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000); + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw new Error("OpenShell gateway resolver nowSeconds must be a non-negative safe integer"); + } + + let clientConfig: OpenShellGrpcClientConfig; + if (authMode === "plaintext") { + if (!metadata.gateway_endpoint.startsWith("http://")) { + throw new Error("OpenShell plaintext gateway metadata must use an http:// endpoint"); + } + clientConfig = { endpoint: metadata.gateway_endpoint }; + } else if (authMode === "mtls") { + if (!metadata.gateway_endpoint.startsWith("https://")) { + throw new Error("OpenShell mTLS gateway metadata must use an https:// endpoint"); + } + clientConfig = mtlsConfig(metadata.gateway_endpoint, path.join(gatewayDir, "mtls")); + } else if (authMode === "oidc") { + if (!metadata.gateway_endpoint.startsWith("https://")) { + throw new Error("OpenShell OIDC gateway metadata must use an https:// endpoint"); + } + clientConfig = oidcConfig(metadata.gateway_endpoint, gatewayDir, nowSeconds); + } else if (authMode === "cloudflare_jwt") { + throw new Error( + "OpenShell Cloudflare JWT gateways require the OpenShell edge tunnel and are not supported by the direct gRPC client", + ); + } else { + throw new Error(`Unsupported OpenShell gateway auth mode '${authMode}'`); + } + validateOpenShellGrpcClientConfig(clientConfig); + + return { + gatewayName, + metadataSource: source, + authMode: authMode as ResolvedOpenShellGrpcGateway["authMode"], + clientConfig, + }; +} + +export function createGrpcOpenShellSandboxControlForGateway( + gatewayName: string, + options: OpenShellGrpcGatewayResolverOptions = {}, +): GrpcOpenShellSandboxControl { + const resolved = resolveOpenShellGrpcGateway(gatewayName, options); + return createGrpcOpenShellSandboxControl(resolved.clientConfig); +} diff --git a/src/lib/adapters/openshell/grpc-sandbox-control.test.ts b/src/lib/adapters/openshell/grpc-sandbox-control.test.ts new file mode 100644 index 00000000000..a40b3440fd3 --- /dev/null +++ b/src/lib/adapters/openshell/grpc-sandbox-control.test.ts @@ -0,0 +1,450 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventEmitter } from "node:events"; +import path from "node:path"; +import { + type CallOptions, + loadPackageDefinition, + Metadata, + Server, + ServerCredentials, + type ServerUnaryCall, + type ServerWritableStream, + type ServiceClientConstructor, + type ServiceError, + type sendUnaryData, +} from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import { describe, expect, it, vi } from "vitest"; + +import { + createGrpcOpenShellSandboxControl, + createOpenShellGrpcApi, + type OpenShellGrpcApi, + OpenShellGrpcOutputLimitError, + OpenShellGrpcPreDispatchError, +} from "./grpc-sandbox-control"; +import { OpenShellExecRequestValidationError } from "./sandbox-control"; + +class FakeStream extends EventEmitter { + cancelled = false; + + cancel(): void { + this.cancelled = true; + } +} + +interface FakeApiOptions { + sandboxId?: string; + getError?: ServiceError; + emit?: (stream: FakeStream) => void; +} + +function fakeApi(fixture: FakeApiOptions = {}): { + api: OpenShellGrpcApi; + stream: FakeStream; + getMetadata: Metadata[]; + execMetadata: Metadata[]; + getOptions: CallOptions[]; + execOptions: CallOptions[]; + close: ReturnType; +} { + const stream = new FakeStream(); + const getMetadata: Metadata[] = []; + const execMetadata: Metadata[] = []; + const getOptions: CallOptions[] = []; + const execOptions: CallOptions[] = []; + const close = vi.fn(); + const api: OpenShellGrpcApi = { + close, + getSandbox(request, metadata, options, callback) { + getMetadata.push(metadata); + getOptions.push(options); + queueMicrotask(() => { + fixture.getError + ? callback(fixture.getError) + : callback(null, { + sandbox: { + metadata: fixture.sandboxId === "" ? {} : { id: fixture.sandboxId ?? "sb-id" }, + }, + }); + }); + return request; + }, + execSandbox(request, metadata, options) { + execMetadata.push(metadata); + execOptions.push(options); + queueMicrotask(() => fixture.emit?.(stream)); + return Object.assign(stream, { request }); + }, + }; + return { api, stream, getMetadata, execMetadata, getOptions, execOptions, close }; +} + +function serviceError(message: string): ServiceError { + return Object.assign(new Error(message), { + code: 14, + details: message, + metadata: new Metadata(), + }); +} + +function bind(server: Server): Promise { + return new Promise((resolve, reject) => { + server.bindAsync("127.0.0.1:0", ServerCredentials.createInsecure(), (error, port) => { + error ? reject(error) : resolve(port); + }); + }); +} + +function shutdown(server: Server): Promise { + return new Promise((resolve, reject) => { + server.tryShutdown((error) => { + error ? reject(error) : resolve(); + }); + }); +} + +describe("gRPC OpenShell sandbox control", () => { + it("rejects invalid commands before sandbox lookup or exec", async () => { + const fake = fakeApi(); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + + const result = await control.exec({ sandboxName: "alpha", command: ["bad\0command"] }); + + expect(result).toMatchObject({ status: null, stdout: "", stderr: "" }); + expect(result.error).toBeInstanceOf(OpenShellExecRequestValidationError); + expect(fake.getMetadata).toEqual([]); + expect(fake.execMetadata).toEqual([]); + }); + + it("dispatches an exact 32768-byte UTF-8 argument unchanged", async () => { + const fake = fakeApi({ + emit(stream) { + stream.emit("data", { exit: { exitCode: 0 } }); + stream.emit("end"); + }, + }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + const boundaryArgument = "é".repeat(16 * 1024); + + await expect( + control.exec({ sandboxName: "alpha", command: ["printf", boundaryArgument] }), + ).resolves.toMatchObject({ status: 0 }); + + expect( + (fake.stream as FakeStream & { request: { command: string[] } }).request.command, + ).toEqual(["printf", boundaryArgument]); + }); + + it("resolves a sandbox id and normalizes streamed output", async () => { + const euro = Buffer.from("€"); + const fake = fakeApi({ + emit(stream) { + stream.emit("data", { stdout: { data: Buffer.from("hello ") } }); + stream.emit("data", { stdout: { data: euro.subarray(0, 1) } }); + stream.emit("data", { stdout: { data: euro.subarray(1) } }); + stream.emit("data", { stderr: { data: Buffer.from("warning\n") } }); + stream.emit("data", { exit: { exitCode: 7 } }); + stream.emit("end"); + }, + }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "https://gateway.example:443", bearerToken: "secret-token" }, + fake.api, + ); + + await expect( + control.exec({ + sandboxName: "alpha", + command: ["sh", "-lc", "echo hello"], + }), + ).resolves.toEqual({ + status: 7, + stdout: "hello €", + stderr: "warning\n", + }); + expect(fake.getMetadata[0].get("authorization")).toEqual(["Bearer secret-token"]); + expect(fake.execMetadata[0]).toBe(fake.getMetadata[0]); + expect((fake.stream as FakeStream & { request: unknown }).request).toEqual({ + sandboxId: "sb-id", + command: ["sh", "-lc", "echo hello"], + }); + control.close(); + expect(fake.close).toHaveBeenCalledOnce(); + }); + + it("executes against the pinned OpenShell service definition", async () => { + const protoFile = path.resolve( + __dirname, + "../../../../third_party/openshell/v0.0.85/proto/openshell.proto", + ); + const loaded = loadPackageDefinition( + protoLoader.loadSync(protoFile, { + defaults: false, + includeDirs: [path.dirname(protoFile)], + keepCase: false, + oneofs: true, + }), + ) as unknown as { + openshell: { v1: { OpenShell: ServiceClientConstructor } }; + }; + const server = new Server(); + const requests: unknown[] = []; + server.addService(loaded.openshell.v1.OpenShell.service, { + getSandbox( + call: ServerUnaryCall<{ name: string }, { sandbox: { metadata: { id: string } } }>, + callback: sendUnaryData<{ sandbox: { metadata: { id: string } } }>, + ) { + requests.push(call.request); + callback(null, { sandbox: { metadata: { id: "wire-id" } } }); + }, + execSandbox( + call: ServerWritableStream< + { sandboxId: string; command: string[] }, + { + stdout?: { data: Buffer }; + stderr?: { data: Buffer }; + exit?: { exitCode: number }; + } + >, + ) { + requests.push(call.request); + call.write({ stdout: { data: Buffer.from("wire stdout") } }); + call.write({ stderr: { data: Buffer.from("wire stderr") } }); + call.write({ exit: { exitCode: 0 } }); + call.end(); + }, + }); + const port = await bind(server); + const proxyEnvironment = { + grpc_proxy: process.env.grpc_proxy, + no_grpc_proxy: process.env.no_grpc_proxy, + no_proxy: process.env.no_proxy, + }; + process.env.grpc_proxy = "http://127.0.0.1:1"; + process.env.no_grpc_proxy = ""; + process.env.no_proxy = ""; + let control: ReturnType; + try { + control = createGrpcOpenShellSandboxControl({ + endpoint: `http://127.0.0.1:${port}`, + }); + } finally { + for (const [name, value] of Object.entries(proxyEnvironment)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + try { + await expect( + control.exec({ sandboxName: "alpha", command: ["printf", "wire one\nwire two\r"] }), + ).resolves.toEqual({ + status: 0, + stdout: "wire stdout", + stderr: "wire stderr", + }); + expect(requests).toEqual([ + { name: "alpha" }, + { sandboxId: "wire-id", command: ["printf", "wire one\nwire two\r"] }, + ]); + } finally { + control.close(); + await shutdown(server); + } + }); + + it("returns lookup failures without starting exec", async () => { + const error = serviceError("gateway unavailable"); + const fake = fakeApi({ getError: error }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + + await expect(control.exec({ sandboxName: "alpha", command: ["true"] })).resolves.toEqual({ + status: null, + stdout: "", + stderr: "", + error: expect.objectContaining({ + cause: error, + name: OpenShellGrpcPreDispatchError.name, + }), + }); + expect(fake.execMetadata).toEqual([]); + }); + + it("uses one deadline for lookup and exec and forwards the command timeout", async () => { + const fake = fakeApi({ + emit(stream) { + stream.emit("data", { exit: { exitCode: 0 } }); + stream.emit("end"); + }, + }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + const before = Date.now(); + + await control.exec({ sandboxName: "alpha", command: ["true"], timeoutMs: 1500 }); + + expect(fake.getOptions[0].deadline).toBeInstanceOf(Date); + expect(fake.execOptions[0].deadline).toBe(fake.getOptions[0].deadline); + expect((fake.getOptions[0].deadline as Date).getTime()).toBeGreaterThanOrEqual(before + 1500); + expect((fake.stream as FakeStream & { request: unknown }).request).toEqual({ + sandboxId: "sb-id", + command: ["true"], + timeoutSeconds: 2, + }); + }); + + it("rejects sandbox responses without a stable id", async () => { + const fake = fakeApi({ sandboxId: "" }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + + const result = await control.exec({ + sandboxName: "alpha", + command: ["true"], + }); + + expect(result).toEqual({ + status: null, + stdout: "", + stderr: "", + error: expect.objectContaining({ + message: "OpenShell returned sandbox 'alpha' without an id", + }), + }); + }); + + it.each([ + [{ maxOutputBytes: -1 }, "maxOutputBytes"], + [{ timeoutMs: 1.5 }, "timeoutMs"], + ])("rejects invalid execution limits before gateway lookup", async (limits, field) => { + const fake = fakeApi(); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + + const result = await control.exec({ sandboxName: "alpha", command: ["true"], ...limits }); + + expect(result.error?.message).toContain(field); + expect(fake.getMetadata).toEqual([]); + }); + + it("caps combined output, cancels the stream, and returns ENOBUFS-compatible context", async () => { + const fake = fakeApi({ + emit(stream) { + stream.emit("data", { stdout: { data: Buffer.from("abcdef") } }); + }, + }); + const control = createGrpcOpenShellSandboxControl({ endpoint: "http://[::1]:8080" }, fake.api); + + const result = await control.exec({ + sandboxName: "alpha", + command: ["cat", "large-file"], + maxOutputBytes: 4, + }); + + expect(result).toEqual({ + status: null, + stdout: "abcd", + stderr: "", + error: expect.any(OpenShellGrpcOutputLimitError), + }); + expect((result.error as NodeJS.ErrnoException).code).toBe("ENOBUFS"); + expect(fake.stream.cancelled).toBe(true); + }); + + it("preserves partial output when the stream fails", async () => { + const error = serviceError("relay reset"); + const fake = fakeApi({ + emit(stream) { + stream.emit("data", { stdout: { data: Buffer.from("partial") } }); + stream.emit("error", error); + }, + }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + + await expect(control.exec({ sandboxName: "alpha", command: ["true"] })).resolves.toEqual({ + status: null, + stdout: "partial", + stderr: "", + error, + }); + }); + + it("fails closed when the stream ends without an exit event", async () => { + const fake = fakeApi({ emit: (stream) => stream.emit("end") }); + const control = createGrpcOpenShellSandboxControl( + { endpoint: "http://127.0.0.1:8080" }, + fake.api, + ); + + const result = await control.exec({ + sandboxName: "alpha", + command: ["true"], + }); + + expect(result.error?.message).toBe("OpenShell gRPC exec stream ended without an exit status"); + }); + + it.each([ + ["ftp://localhost:8080", "must use http:// or https://"], + ["http://localhost:8080", "restricted to loopback"], + ["http://gateway.example:8080", "restricted to loopback"], + ["http://128.0.0.1:8080", "restricted to loopback"], + ["http://[::2]:8080", "restricted to loopback"], + ["http://127.999.999.999:8080", "Invalid OpenShell gRPC endpoint"], + ["http://localhost:8080/path", "must not contain"], + ])("rejects unsafe endpoint %s", (endpoint, message) => { + expect(() => createOpenShellGrpcApi({ endpoint })).toThrow(message); + }); + + it.each([ + "http://127.0.0.1:8080", + "http://[::1]:8080", + ])("accepts literal loopback endpoint %s", (endpoint) => { + const api = createOpenShellGrpcApi({ endpoint }); + api.close(); + }); + + it("rejects bearer tokens or TLS material on plaintext endpoints", () => { + expect(() => + createOpenShellGrpcApi({ + endpoint: "http://127.0.0.1:8080", + bearerToken: "token", + }), + ).toThrow("requires TLS"); + expect(() => + createOpenShellGrpcApi({ + endpoint: "http://127.0.0.1:8080", + caCertificate: Buffer.from("ca"), + }), + ).toThrow("require an https:// endpoint"); + }); + + it("rejects incomplete mTLS credentials", () => { + expect(() => + createOpenShellGrpcApi({ + endpoint: "https://gateway.example:443", + clientCertificate: Buffer.from("cert"), + }), + ).toThrow("clientCertificate and clientKey must be provided together"); + }); +}); diff --git a/src/lib/adapters/openshell/grpc-sandbox-control.ts b/src/lib/adapters/openshell/grpc-sandbox-control.ts new file mode 100644 index 00000000000..bc9d99f47ce --- /dev/null +++ b/src/lib/adapters/openshell/grpc-sandbox-control.ts @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { EventEmitter } from "node:events"; +import { isIP } from "node:net"; +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; + +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; + +import { + openShellExecRequestValidationFailure, + validateOpenShellExecCommand, + type OpenShellSandboxControl, + type SandboxExecRequest, + type SandboxExecResult, +} from "./sandbox-control"; + +const DEFAULT_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024; +const PROTO_VERSION = "0.0.85"; + +interface GetSandboxResponse { + sandbox?: { metadata?: { id?: string } }; +} + +interface ExecSandboxEvent { + stdout?: { data?: Buffer | Uint8Array | string }; + stderr?: { data?: Buffer | Uint8Array | string }; + exit?: { exitCode?: number }; +} + +interface ExecEventStream extends EventEmitter { + cancel(): void; +} + +export interface OpenShellGrpcApi { + getSandbox( + request: { name: string }, + metadata: grpc.Metadata, + options: grpc.CallOptions, + callback: (error: grpc.ServiceError | null, response?: GetSandboxResponse) => void, + ): unknown; + execSandbox( + request: { sandboxId: string; command: readonly string[]; timeoutSeconds?: number }, + metadata: grpc.Metadata, + options: grpc.CallOptions, + ): ExecEventStream; + close(): void; +} + +export interface OpenShellGrpcClientConfig { + /** Full gateway URL, including http:// or https://. */ + endpoint: string; + /** Optional private CA for a TLS gateway. System roots are used when omitted. */ + caCertificate?: Buffer; + /** mTLS client certificate. Must be provided together with clientKey. */ + clientCertificate?: Buffer; + /** mTLS client private key. Must be provided together with clientCertificate. */ + clientKey?: Buffer; + /** Optional gateway authorization token. Allowed only with TLS. */ + bearerToken?: string; +} + +export interface GrpcOpenShellSandboxControl extends OpenShellSandboxControl { + close(): void; +} + +export class OpenShellGrpcOutputLimitError extends Error { + readonly code = "ENOBUFS"; + + constructor(readonly maxOutputBytes: number) { + super(`OpenShell gRPC exec output exceeded ${maxOutputBytes} bytes`); + this.name = "OpenShellGrpcOutputLimitError"; + } +} + +export class OpenShellGrpcPreDispatchError extends Error { + constructor(readonly cause: Error) { + super(cause.message, { cause }); + this.name = "OpenShellGrpcPreDispatchError"; + } +} + +function isLoopback(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const version = isIP(normalized); + return ( + (version === 4 && normalized.startsWith("127.")) || (version === 6 && normalized === "::1") + ); +} + +function parseEndpoint(endpoint: string): { target: string; secure: boolean } { + let url: URL; + try { + url = new URL(endpoint); + } catch (error) { + throw new Error(`Invalid OpenShell gRPC endpoint: ${(error as Error).message}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("OpenShell gRPC endpoint must use http:// or https://"); + } + if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error( + "OpenShell gRPC endpoint must not contain credentials, a path, query, or fragment", + ); + } + if (url.protocol === "http:" && !isLoopback(url.hostname)) { + throw new Error("Plaintext OpenShell gRPC is restricted to loopback endpoints"); + } + const secure = url.protocol === "https:"; + return { + target: `${url.hostname}:${url.port || (secure ? "443" : "80")}`, + secure, + }; +} + +function validateBearerToken(token: string | undefined, secure: boolean): void { + if (token === undefined) return; + if (!secure) throw new Error("OpenShell gRPC bearer authentication requires TLS"); + if (!token || token.trim() !== token || /[\r\n]/.test(token)) { + throw new Error( + "OpenShell gRPC bearer token must be non-empty and contain no surrounding whitespace", + ); + } +} + +function validateTlsMaterial(config: OpenShellGrpcClientConfig, secure: boolean): void { + const hasClientCertificate = config.clientCertificate !== undefined; + const hasClientKey = config.clientKey !== undefined; + if (hasClientCertificate !== hasClientKey) { + throw new Error("OpenShell gRPC clientCertificate and clientKey must be provided together"); + } + if (!secure && (config.caCertificate !== undefined || hasClientCertificate || hasClientKey)) { + throw new Error("OpenShell gRPC TLS credentials require an https:// endpoint"); + } +} + +export function validateOpenShellGrpcClientConfig(config: OpenShellGrpcClientConfig): void { + const { secure } = parseEndpoint(config.endpoint); + validateBearerToken(config.bearerToken, secure); + validateTlsMaterial(config, secure); +} + +function createChannelCredentials( + config: OpenShellGrpcClientConfig, + secure: boolean, +): grpc.ChannelCredentials { + validateTlsMaterial(config, secure); + if (!secure) { + return grpc.credentials.createInsecure(); + } + return grpc.credentials.createSsl( + config.caCertificate ?? null, + config.clientKey ?? null, + config.clientCertificate ?? null, + ); +} + +function protocolPath(): string { + return path.resolve( + __dirname, + `../../../../third_party/openshell/v${PROTO_VERSION}/proto/openshell.proto`, + ); +} + +export function createOpenShellGrpcApi(config: OpenShellGrpcClientConfig): OpenShellGrpcApi { + validateOpenShellGrpcClientConfig(config); + const { target, secure } = parseEndpoint(config.endpoint); + const credentials = createChannelCredentials(config, secure); + const protoFile = protocolPath(); + const packageDefinition = protoLoader.loadSync(protoFile, { + defaults: false, + enums: String, + includeDirs: [path.dirname(protoFile)], + keepCase: false, + longs: String, + oneofs: true, + }); + const loaded = grpc.loadPackageDefinition(packageDefinition) as unknown as { + openshell: { v1: { OpenShell: grpc.ServiceClientConstructor } }; + }; + const channelOptions: grpc.ChannelOptions = secure ? {} : { "grpc.enable_http_proxy": 0 }; + return new loaded.openshell.v1.OpenShell( + target, + credentials, + channelOptions, + ) as unknown as OpenShellGrpcApi; +} + +function callMetadata(bearerToken: string | undefined): grpc.Metadata { + const metadata = new grpc.Metadata(); + if (bearerToken) metadata.set("authorization", `Bearer ${bearerToken}`); + return metadata; +} + +function sandboxId( + client: OpenShellGrpcApi, + sandboxName: string, + metadata: grpc.Metadata, + options: grpc.CallOptions, +): Promise { + return new Promise((resolve, reject) => { + client.getSandbox({ name: sandboxName }, metadata, options, (error, response) => { + if (error) { + reject(error); + return; + } + const id = response?.sandbox?.metadata?.id; + if (!id) { + reject(new Error(`OpenShell returned sandbox '${sandboxName}' without an id`)); + return; + } + resolve(id); + }); + }); +} + +function asBuffer(data: Buffer | Uint8Array | string | undefined): Buffer { + if (data === undefined) return Buffer.alloc(0); + return Buffer.isBuffer(data) ? data : Buffer.from(data); +} + +function execute( + client: OpenShellGrpcApi, + id: string, + request: SandboxExecRequest, + metadata: grpc.Metadata, + options: grpc.CallOptions, +): Promise { + const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_EXEC_MAX_OUTPUT_BYTES; + return new Promise((resolve) => { + const timeoutSeconds = + request.timeoutMs && request.timeoutMs > 0 ? Math.ceil(request.timeoutMs / 1000) : undefined; + const stream = client.execSandbox( + { sandboxId: id, command: request.command, timeoutSeconds }, + metadata, + options, + ); + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + let stdout = ""; + let stderr = ""; + let retainedBytes = 0; + let status: number | null = null; + let settled = false; + + const finish = (error?: Error) => { + if (settled) return; + settled = true; + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); + const result: SandboxExecResult = { status, stdout, stderr }; + if (error) result.error = error; + resolve(result); + }; + + const append = (destination: "stdout" | "stderr", data: Buffer) => { + const remaining = maxOutputBytes - retainedBytes; + const retained = data.subarray(0, Math.max(0, remaining)); + retainedBytes += retained.length; + if (destination === "stdout") stdout += stdoutDecoder.write(retained); + else stderr += stderrDecoder.write(retained); + if (retained.length < data.length) { + finish(new OpenShellGrpcOutputLimitError(maxOutputBytes)); + stream.cancel(); + } + }; + + stream.on("data", (event: ExecSandboxEvent) => { + if (settled) return; + if (event.stdout) append("stdout", asBuffer(event.stdout.data)); + else if (event.stderr) append("stderr", asBuffer(event.stderr.data)); + else if (event.exit && Number.isInteger(event.exit.exitCode)) { + status = event.exit.exitCode as number; + } + }); + stream.on("error", (error: Error) => finish(error)); + stream.on("end", () => { + if (status === null) { + finish(new Error("OpenShell gRPC exec stream ended without an exit status")); + } else { + finish(); + } + }); + }); +} + +export function createGrpcOpenShellSandboxControl( + config: OpenShellGrpcClientConfig, + injectedClient?: OpenShellGrpcApi, +): GrpcOpenShellSandboxControl { + validateOpenShellGrpcClientConfig(config); + const client = injectedClient ?? createOpenShellGrpcApi(config); + return { + close: () => client.close(), + async exec(request): Promise { + const validationError = validateOpenShellExecCommand(request.command); + if (validationError) return openShellExecRequestValidationFailure(validationError); + + const maxOutputBytes = request.maxOutputBytes ?? DEFAULT_EXEC_MAX_OUTPUT_BYTES; + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) { + return { + status: null, + stdout: "", + stderr: "", + error: new Error("maxOutputBytes must be a non-negative safe integer"), + }; + } + if ( + request.timeoutMs !== undefined && + (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 0) + ) { + return { + status: null, + stdout: "", + stderr: "", + error: new Error("timeoutMs must be a non-negative safe integer"), + }; + } + const metadata = callMetadata(config.bearerToken); + const deadline = + request.timeoutMs && request.timeoutMs > 0 + ? new Date(Date.now() + request.timeoutMs) + : undefined; + const options: grpc.CallOptions = deadline ? { deadline } : {}; + let id: string; + try { + id = await sandboxId(client, request.sandboxName, metadata, options); + } catch (error) { + const cause = error instanceof Error ? error : new Error(String(error)); + return { + status: null, + stdout: "", + stderr: "", + error: new OpenShellGrpcPreDispatchError(cause), + }; + } + return execute(client, id, request, metadata, options); + }, + }; +} diff --git a/src/lib/adapters/openshell/sandbox-control-routing.test.ts b/src/lib/adapters/openshell/sandbox-control-routing.test.ts new file mode 100644 index 00000000000..63d0a45f43b --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-control-routing.test.ts @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + type GrpcOpenShellSandboxControl, + OpenShellGrpcPreDispatchError, +} from "./grpc-sandbox-control"; +import { + type OpenShellSandboxControl, + OpenShellExecRequestValidationError, + openShellExecRequestValidationFailure, + type SandboxExecResult, +} from "./sandbox-control"; +import { execSandboxReadOnlyWithGrpcFallback } from "./sandbox-control-routing"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "./timeouts"; + +function dependencies(grpcResult: SandboxExecResult | Error, cliResult?: SandboxExecResult) { + const close = vi.fn(); + const grpcExec = vi.fn(() => + grpcResult instanceof Error ? Promise.reject(grpcResult) : Promise.resolve(grpcResult), + ); + const grpc: GrpcOpenShellSandboxControl = { close, exec: grpcExec }; + const cliExec = vi.fn(async () => cliResult ?? { status: 0, stdout: "cli", stderr: "" }); + const cli: OpenShellSandboxControl = { exec: cliExec }; + const createCli = vi.fn(() => cli); + const createGrpc = vi.fn(() => grpc); + const debug = vi.fn(); + return { + close, + grpcExec, + cliExec, + createCli, + createGrpc, + debug, + deps: { createCli, createGrpc, debug }, + }; +} + +const request = { + sandboxName: "alpha", + command: ["openclaw", "sessions", "list"] as const, + maxOutputBytes: 4096, +}; + +describe("read-only OpenShell sandbox control routing", () => { + it.each([ + ["too many session arguments", ["openclaw", "sessions", "list", ...Array(1022).fill("x")]], + [ + "an oversized UTF-8 session argument", + ["openclaw", "sessions", "list", "é".repeat(16 * 1024 + 1)], + ], + ["a NUL session argument", ["openclaw", "sessions", "list", "bad\0arg"]], + ])("rejects %s before creating either transport", async (_label, command) => { + const test = dependencies({ status: 0, stdout: "unused", stderr: "" }); + + const result = await execSandboxReadOnlyWithGrpcFallback( + "nemoclaw", + { sandboxName: "alpha", command }, + test.deps, + ); + + expect(result.error).toBeInstanceOf(OpenShellExecRequestValidationError); + expect(test.createGrpc).not.toHaveBeenCalled(); + expect(test.grpcExec).not.toHaveBeenCalled(); + expect(test.cliExec).not.toHaveBeenCalled(); + expect(test.createCli).not.toHaveBeenCalled(); + }); + + it("routes session arguments containing line endings without rewriting them", async () => { + const test = dependencies({ status: 0, stdout: "grpc", stderr: "" }); + const multilineRequest = { ...request, command: [...request.command, "line one\nline two\r"] }; + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", multilineRequest, test.deps), + ).resolves.toMatchObject({ status: 0 }); + + expect(test.grpcExec).toHaveBeenCalledWith({ + ...multilineRequest, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + expect(test.cliExec).not.toHaveBeenCalled(); + }); + + it("accepts the exact session count and UTF-8 byte boundaries", async () => { + const test = dependencies({ status: 0, stdout: "grpc", stderr: "" }); + const command = [ + "openclaw", + "sessions", + "list", + "é".repeat(16 * 1024), + ...Array(1020).fill("x"), + ]; + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", { sandboxName: "alpha", command }, test.deps), + ).resolves.toMatchObject({ status: 0 }); + + expect(command).toHaveLength(1024); + expect(test.grpcExec).toHaveBeenCalledOnce(); + expect(test.cliExec).not.toHaveBeenCalled(); + }); + + it("does not route a thrown typed validation error through the CLI", async () => { + const error = new OpenShellExecRequestValidationError({ kind: "empty-command" }); + const test = dependencies(error); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps), + ).resolves.toEqual(openShellExecRequestValidationFailure(error)); + + expect(test.cliExec).not.toHaveBeenCalled(); + }); + + it("does not replay a rejected gRPC execution through the CLI", async () => { + const error = new Error("stream rejected after dispatch"); + const test = dependencies(error); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps), + ).resolves.toEqual({ status: null, stdout: "", stderr: "", error }); + + expect(test.grpcExec).toHaveBeenCalledOnce(); + expect(test.cliExec).not.toHaveBeenCalled(); + expect(test.close).toHaveBeenCalledOnce(); + }); + + it("retries a rejected explicit pre-dispatch lookup failure through the CLI", async () => { + const cause = new Error("UNAVAILABLE"); + const test = dependencies(new OpenShellGrpcPreDispatchError(cause)); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps), + ).resolves.toEqual({ status: 0, stdout: "cli", stderr: "" }); + + expect(test.cliExec).toHaveBeenCalledOnce(); + expect(test.createCli).toHaveBeenCalledWith("nemoclaw"); + expect(test.debug).toHaveBeenCalledWith(expect.stringContaining("before dispatch"), cause); + expect(test.close).toHaveBeenCalledOnce(); + }); + + it("fails closed when the scoped CLI refuses an endpoint override after lookup", async () => { + const cause = new Error("UNAVAILABLE"); + const refusal = new Error("Unset OPENSHELL_GATEWAY_ENDPOINT and retry"); + const test = dependencies(new OpenShellGrpcPreDispatchError(cause)); + test.createCli.mockImplementation(() => { + throw refusal; + }); + + await expect(execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps)).rejects.toBe( + refusal, + ); + + expect(test.cliExec).not.toHaveBeenCalled(); + expect(test.close).toHaveBeenCalledOnce(); + }); + + it("uses direct gRPC with a bounded deadline", async () => { + const test = dependencies({ status: 0, stdout: "grpc", stderr: "" }); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw-9090", request, test.deps), + ).resolves.toEqual({ status: 0, stdout: "grpc", stderr: "" }); + + expect(test.createGrpc).toHaveBeenCalledWith("nemoclaw-9090"); + expect(test.grpcExec).toHaveBeenCalledWith({ + ...request, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + expect(test.cliExec).not.toHaveBeenCalled(); + expect(test.close).toHaveBeenCalledOnce(); + }); + + it("does not replay a completed non-zero command through the CLI", async () => { + const result = { status: 2, stdout: "", stderr: "unknown flag" }; + const test = dependencies(result); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps), + ).resolves.toEqual(result); + + expect(test.cliExec).not.toHaveBeenCalled(); + }); + + it("retries a pre-dispatch gRPC lookup failure with the same bounded deadline", async () => { + const grpcError = new Error("UNAVAILABLE"); + const cliResult = { status: 0, stdout: "cli", stderr: "" }; + const test = dependencies( + { + status: null, + stdout: "", + stderr: "", + error: new OpenShellGrpcPreDispatchError(grpcError), + }, + cliResult, + ); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps), + ).resolves.toEqual(cliResult); + + expect(test.cliExec).toHaveBeenCalledWith({ + ...request, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + expect(test.createCli).toHaveBeenCalledWith("nemoclaw"); + expect(test.debug).toHaveBeenCalledWith(expect.stringContaining("before dispatch"), grpcError); + expect(test.close).toHaveBeenCalledOnce(); + }); + + it("does not replay a post-dispatch gRPC stream failure", async () => { + const grpcError = new Error("stream reset"); + const result = { status: null, stdout: "partial", stderr: "", error: grpcError }; + const test = dependencies(result); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", request, test.deps), + ).resolves.toEqual(result); + + expect(test.cliExec).not.toHaveBeenCalled(); + }); + + it("uses the CLI when gateway configuration cannot create a gRPC client", async () => { + const test = dependencies({ status: 0, stdout: "unused", stderr: "" }); + const error = new Error("edge tunnel required"); + test.createGrpc.mockImplementation(() => { + throw error; + }); + + await expect(execSandboxReadOnlyWithGrpcFallback("edge", request, test.deps)).resolves.toEqual({ + status: 0, + stdout: "cli", + stderr: "", + }); + + expect(test.grpcExec).not.toHaveBeenCalled(); + expect(test.createCli).toHaveBeenCalledWith("edge"); + expect(test.cliExec).toHaveBeenCalledWith({ + ...request, + timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS, + }); + expect(test.debug).toHaveBeenCalledWith(expect.stringContaining("configuration failed"), error); + }); + + it("fails closed when the scoped CLI refuses an endpoint override after configuration", async () => { + const configurationError = new Error("edge tunnel required"); + const refusal = new Error("Unset OPENSHELL_GATEWAY_ENDPOINT and retry"); + const test = dependencies({ status: 0, stdout: "unused", stderr: "" }); + test.createGrpc.mockImplementation(() => { + throw configurationError; + }); + test.createCli.mockImplementation(() => { + throw refusal; + }); + + await expect(execSandboxReadOnlyWithGrpcFallback("edge", request, test.deps)).rejects.toBe( + refusal, + ); + + expect(test.grpcExec).not.toHaveBeenCalled(); + expect(test.cliExec).not.toHaveBeenCalled(); + }); + + it("does not let close failures replace a successful result", async () => { + const test = dependencies({ status: 0, stdout: "grpc", stderr: "" }); + const closeError = new Error("close failed"); + test.close.mockImplementation(() => { + throw closeError; + }); + + await expect( + execSandboxReadOnlyWithGrpcFallback("nemoclaw", { ...request, timeoutMs: 0 }, test.deps), + ).resolves.toEqual({ status: 0, stdout: "grpc", stderr: "" }); + + expect(test.grpcExec).toHaveBeenCalledWith({ ...request, timeoutMs: 0 }); + expect(test.debug).toHaveBeenCalledWith( + "OpenShell direct gRPC client close failed", + closeError, + ); + }); +}); diff --git a/src/lib/adapters/openshell/sandbox-control-routing.ts b/src/lib/adapters/openshell/sandbox-control-routing.ts new file mode 100644 index 00000000000..3340970f1e5 --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-control-routing.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { log } from "../../cli/logger"; +import { createGrpcOpenShellSandboxControlForGateway } from "./grpc-gateway-config"; +import { + type GrpcOpenShellSandboxControl, + OpenShellGrpcPreDispatchError, +} from "./grpc-sandbox-control"; +import { + createGatewayScopedCliOpenShellSandboxControl, + OpenShellExecRequestValidationError, + openShellExecRequestValidationFailure, + type OpenShellSandboxControl, + type SandboxExecRequest, + type SandboxExecResult, + validateOpenShellExecCommand, +} from "./sandbox-control"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "./timeouts"; + +export interface ReadOnlyRoutingDependencies { + createCli: (gatewayName: string) => OpenShellSandboxControl; + createGrpc: (gatewayName: string) => GrpcOpenShellSandboxControl; + debug: (message: string, context: unknown) => void; +} + +const defaultDependencies: ReadOnlyRoutingDependencies = { + createCli: createGatewayScopedCliOpenShellSandboxControl, + createGrpc: createGrpcOpenShellSandboxControlForGateway, + debug: (message, context) => log.debug(message, context), +}; + +/** + * Prefer direct gRPC for the OpenClaw session-list read and retry through the + * OpenShell CLI only when configuration or sandbox lookup fails before Exec is + * dispatched. OpenShell v0.0.85 can persist `auth_mode: "cloudflare_jwt"`; + * only its CLI can establish that OpenShell-owned edge tunnel and manage the + * associated credential lifecycle, so NemoClaw cannot replace that path here. + * + * This is a migration compatibility contract for the single current caller, + * `runSessionsPassthrough`, not a general routing policy. Remove the CLI + * fallback when OpenShell's public client or bindings support the edge-tunnel + * auth and credential-refresh lifecycle. Do not reuse it for another command + * without reviewing that command's replay semantics; mutations must select one + * transport before dispatch and must never be replayed automatically. + */ +export async function execSandboxReadOnlyWithGrpcFallback( + gatewayName: string, + request: SandboxExecRequest, + dependencies: ReadOnlyRoutingDependencies = defaultDependencies, +): Promise { + const validationError = validateOpenShellExecCommand(request.command); + if (validationError) return openShellExecRequestValidationFailure(validationError); + + let grpc: GrpcOpenShellSandboxControl; + try { + grpc = dependencies.createGrpc(gatewayName); + } catch (error) { + if (error instanceof OpenShellExecRequestValidationError) { + return openShellExecRequestValidationFailure(error); + } + dependencies.debug( + "OpenShell direct gRPC configuration failed; retrying through the CLI", + error, + ); + return dependencies.createCli(gatewayName).exec({ + ...request, + timeoutMs: request.timeoutMs ?? OPENSHELL_OPERATION_TIMEOUT_MS, + }); + } + + let preDispatchError: OpenShellGrpcPreDispatchError | undefined; + try { + const result = await grpc.exec({ + ...request, + timeoutMs: request.timeoutMs ?? OPENSHELL_OPERATION_TIMEOUT_MS, + }); + if (!(result.error instanceof OpenShellGrpcPreDispatchError)) return result; + preDispatchError = result.error; + } catch (error) { + if (error instanceof OpenShellGrpcPreDispatchError) { + preDispatchError = error; + } else { + const cause = error instanceof Error ? error : new Error(String(error)); + return { status: null, stdout: "", stderr: "", error: cause }; + } + } finally { + try { + grpc.close(); + } catch (error) { + dependencies.debug("OpenShell direct gRPC client close failed", error); + } + } + + dependencies.debug( + "OpenShell direct gRPC lookup failed before dispatch; retrying through the CLI", + preDispatchError.cause, + ); + return dependencies.createCli(gatewayName).exec({ + ...request, + timeoutMs: request.timeoutMs ?? OPENSHELL_OPERATION_TIMEOUT_MS, + }); +} diff --git a/src/lib/adapters/openshell/sandbox-control.test.ts b/src/lib/adapters/openshell/sandbox-control.test.ts index 358bd385bd3..91d11400806 100644 --- a/src/lib/adapters/openshell/sandbox-control.test.ts +++ b/src/lib/adapters/openshell/sandbox-control.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CaptureOpenshellResult } from "./client"; import { createCliOpenShellSandboxControl, + createGatewayScopedCliOpenShellSandboxControl, OpenShellExecRequestValidationError, validateOpenShellExecCommand, } from "./sandbox-control"; @@ -80,20 +81,18 @@ describe("OpenShell exec command validation", () => { }); }); - it.each([ - ["nul", "\0"], - ["lf", "\n"], - ["cr", "\r"], - ] as const)("rejects %s characters", (character, value) => { - expectValidationIssue(["sh", `bad${value}argument`], { + it("rejects NUL characters", () => { + expectValidationIssue(["sh", "bad\0argument"], { kind: "argument-control-character", index: 1, - character, + character: "nul", }); }); - it("allows tabs, shell metacharacters, and empty non-command arguments", () => { - expect(validateOpenShellExecCommand(["sh", "", "\t; | & $() <> * ?"])).toBeNull(); + it("allows line endings, tabs, shell metacharacters, and empty non-command arguments", () => { + expect( + validateOpenShellExecCommand(["sh", "", "line one\nline two\r", "\t; | & $() <> * ?"]), + ).toBeNull(); }); }); @@ -148,11 +147,41 @@ describe("CLI OpenShell sandbox control", () => { }); }); + it("pins fallback execution to the requested gateway", async () => { + const capture = vi.fn( + (): CaptureOpenshellResult => ({ + status: 0, + output: "ok", + stdout: "ok\n", + stderr: "", + }), + ); + const control = createGatewayScopedCliOpenShellSandboxControl("nemoclaw-19080", capture); + + await control.exec({ sandboxName: "alpha", command: ["true"] }); + + expect(capture).toHaveBeenCalledWith( + ["--gateway", "nemoclaw-19080", "sandbox", "exec", "--name", "alpha", "--", "true"], + expect.objectContaining({ ignoreError: true, includeStreams: true }), + ); + }); + + it("rejects an ambient endpoint that could override the fallback gateway", () => { + const capture = vi.fn<() => CaptureOpenshellResult>(); + + expect(() => + createGatewayScopedCliOpenShellSandboxControl("nemoclaw-19080", capture, { + OPENSHELL_GATEWAY_ENDPOINT: "https://other.example.test", + }), + ).toThrow(/Unset OPENSHELL_GATEWAY_ENDPOINT/); + expect(capture).not.toHaveBeenCalled(); + }); + it("returns a standard failure without capture for invalid commands", async () => { const capture = vi.fn<() => CaptureOpenshellResult>(); const control = createCliOpenShellSandboxControl(capture); - const result = await control.exec({ sandboxName: "alpha", command: ["bad\ncommand"] }); + const result = await control.exec({ sandboxName: "alpha", command: ["bad\0command"] }); expect(capture).not.toHaveBeenCalled(); expect(result).toMatchObject({ status: null, stdout: "", stderr: "" }); @@ -160,7 +189,7 @@ describe("CLI OpenShell sandbox control", () => { expect((result.error as OpenShellExecRequestValidationError).issue).toEqual({ kind: "argument-control-character", index: 0, - character: "lf", + character: "nul", }); }); diff --git a/src/lib/adapters/openshell/sandbox-control.ts b/src/lib/adapters/openshell/sandbox-control.ts index de3b8053eb3..c1adc19c918 100644 --- a/src/lib/adapters/openshell/sandbox-control.ts +++ b/src/lib/adapters/openshell/sandbox-control.ts @@ -3,11 +3,17 @@ import type { CaptureOpenshellResult } from "./client"; import { captureOpenshell } from "./runtime"; +import { + assertNoOpenShellGatewayEndpointOverride, + type OpenShellGatewayEndpointEnvironment, +} from "../../openshell-gateway-endpoint-guard"; export interface SandboxExecRequest { sandboxName: string; command: readonly string[]; + /** Maximum combined stdout and stderr bytes retained by the transport. */ maxOutputBytes?: number; + /** End-to-end lookup and execution deadline. Zero means no deadline. */ timeoutMs?: number; } @@ -36,12 +42,12 @@ export type OpenShellExecRequestValidationIssue = | { kind: "argument-control-character"; index: number; - character: "nul" | "lf" | "cr"; + character: "nul"; }; export const OPENSHELL_EXEC_INVALID_ARGUMENT = "OPENSHELL_EXEC_INVALID_ARGUMENT"; -/** A command OpenShell v0.0.72 will reject before attempting sandbox execution. */ +/** A command OpenShell v0.0.85 will reject before attempting sandbox execution. */ export class OpenShellExecRequestValidationError extends Error { readonly code = OPENSHELL_EXEC_INVALID_ARGUMENT; @@ -53,9 +59,9 @@ export class OpenShellExecRequestValidationError extends Error { type CaptureOpenShell = typeof captureOpenshell; -const OPENSHELL_V0072_MAX_EXEC_COMMAND_ARGS = 1024; -const OPENSHELL_V0072_MAX_EXEC_ARGUMENT_BYTES = 32 * 1024; -const OPENSHELL_V0072_MAX_ASSEMBLED_COMMAND_BYTES = 256 * 1024; +const OPENSHELL_V0085_MAX_EXEC_COMMAND_ARGS = 1024; +const OPENSHELL_V0085_MAX_EXEC_ARGUMENT_BYTES = 32 * 1024; +const OPENSHELL_V0085_MAX_ASSEMBLED_COMMAND_BYTES = 256 * 1024; function openShellExecRequestValidationMessage(issue: OpenShellExecRequestValidationIssue): string { switch (issue.kind) { @@ -68,9 +74,7 @@ function openShellExecRequestValidationMessage(issue: OpenShellExecRequestValida case "argument-too-large": return `command argument ${String(issue.index)} exceeds ${String(issue.maxBytes)} byte limit`; case "argument-control-character": - return issue.character === "nul" - ? `command argument ${String(issue.index)} contains null bytes` - : `command argument ${String(issue.index)} contains newline or carriage return characters`; + return `command argument ${String(issue.index)} contains null bytes`; } } @@ -103,29 +107,29 @@ function openShellEscapedArgumentByteLength(argument: string): number { return bytes.length + 2 + singleQuotes * 4; } -/** Match OpenShell v0.0.72's pre-dispatch command validation. */ +/** Match OpenShell v0.0.85's pre-dispatch command validation. */ export function validateOpenShellExecCommand( command: readonly string[], ): OpenShellExecRequestValidationError | null { if (command.length === 0 || command[0]?.trim().length === 0) { return new OpenShellExecRequestValidationError({ kind: "empty-command" }); } - if (command.length > OPENSHELL_V0072_MAX_EXEC_COMMAND_ARGS) { + if (command.length > OPENSHELL_V0085_MAX_EXEC_COMMAND_ARGS) { return new OpenShellExecRequestValidationError({ kind: "too-many-arguments", actual: command.length, - max: OPENSHELL_V0072_MAX_EXEC_COMMAND_ARGS, + max: OPENSHELL_V0085_MAX_EXEC_COMMAND_ARGS, }); } for (const [index, argument] of command.entries()) { const actualBytes = Buffer.byteLength(argument, "utf8"); - if (actualBytes > OPENSHELL_V0072_MAX_EXEC_ARGUMENT_BYTES) { + if (actualBytes > OPENSHELL_V0085_MAX_EXEC_ARGUMENT_BYTES) { return new OpenShellExecRequestValidationError({ kind: "argument-too-large", index, actualBytes, - maxBytes: OPENSHELL_V0072_MAX_EXEC_ARGUMENT_BYTES, + maxBytes: OPENSHELL_V0085_MAX_EXEC_ARGUMENT_BYTES, }); } if (argument.includes("\0")) { @@ -135,25 +139,17 @@ export function validateOpenShellExecCommand( character: "nul", }); } - const newlineIndex = argument.search(/[\n\r]/); - if (newlineIndex !== -1) { - return new OpenShellExecRequestValidationError({ - kind: "argument-control-character", - index, - character: argument[newlineIndex] === "\n" ? "lf" : "cr", - }); - } } const assembledBytes = command.reduce((total, argument) => total + openShellEscapedArgumentByteLength(argument), 0) + command.length - 1; - if (assembledBytes > OPENSHELL_V0072_MAX_ASSEMBLED_COMMAND_BYTES) { + if (assembledBytes > OPENSHELL_V0085_MAX_ASSEMBLED_COMMAND_BYTES) { return new OpenShellExecRequestValidationError({ kind: "assembled-command-too-large", actualBytes: assembledBytes, - maxBytes: OPENSHELL_V0072_MAX_ASSEMBLED_COMMAND_BYTES, + maxBytes: OPENSHELL_V0085_MAX_ASSEMBLED_COMMAND_BYTES, }); } @@ -177,16 +173,26 @@ function normalizeExecResult(result: CaptureOpenshellResult): SandboxExecResult return normalized; } -export function createCliOpenShellSandboxControl( - capture: CaptureOpenShell = captureOpenshell, +function createCliSandboxControl( + capture: CaptureOpenShell, + gatewayName?: string, ): OpenShellSandboxControl { return { async exec(request): Promise { const validationError = validateOpenShellExecCommand(request.command); if (validationError) return openShellExecRequestValidationFailure(validationError); + const gatewayArgs = gatewayName ? ["--gateway", gatewayName] : []; const result = capture( - ["sandbox", "exec", "--name", request.sandboxName, "--", ...request.command], + [ + ...gatewayArgs, + "sandbox", + "exec", + "--name", + request.sandboxName, + "--", + ...request.command, + ], { ignoreError: true, includeStreams: true, @@ -199,4 +205,20 @@ export function createCliOpenShellSandboxControl( }; } +export function createCliOpenShellSandboxControl( + capture: CaptureOpenShell = captureOpenshell, +): OpenShellSandboxControl { + return createCliSandboxControl(capture); +} + +/** Bind every CLI fallback invocation to the gateway selected by the caller. */ +export function createGatewayScopedCliOpenShellSandboxControl( + gatewayName: string, + capture: CaptureOpenShell = captureOpenshell, + env: OpenShellGatewayEndpointEnvironment = process.env, +): OpenShellSandboxControl { + assertNoOpenShellGatewayEndpointOverride(env); + return createCliSandboxControl(capture, gatewayName); +} + export const openShellSandboxControl = createCliOpenShellSandboxControl(); diff --git a/test/openshell-grpc-proto-pin.test.ts b/test/openshell-grpc-proto-pin.test.ts new file mode 100644 index 00000000000..2d0beb871bd --- /dev/null +++ b/test/openshell-grpc-proto-pin.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { verifyOpenShellGrpcProtoPin } from "../scripts/checks/openshell-grpc-proto-pin.mts"; + +function withFixture( + blueprintVersion: string, + protoSource: string, + assertion: (root: string, digest: string) => void, + packageFiles: string[] = ["third_party/openshell/"], +): void { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-proto-pin-")); + const protoDir = path.join(root, "third_party/openshell/v1.2.3/proto"); + fs.mkdirSync(path.join(root, "nemoclaw-blueprint"), { recursive: true }); + fs.mkdirSync(protoDir, { recursive: true }); + fs.writeFileSync( + path.join(root, "nemoclaw-blueprint/blueprint.yaml"), + `max_openshell_version: "${blueprintVersion}"\n`, + ); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ files: packageFiles })); + fs.writeFileSync(path.join(protoDir, "openshell.proto"), protoSource); + try { + assertion(root, createHash("sha256").update(protoSource).digest("hex")); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +} + +describe("OpenShell gRPC protocol pin", () => { + it("accepts protocol sources bound to the supported OpenShell version", () => { + withFixture("1.2.3", 'syntax = "proto3";\n', (root, digest) => { + expect( + verifyOpenShellGrpcProtoPin(root, { + version: "1.2.3", + files: { "openshell.proto": digest }, + }), + ).toEqual([]); + }); + }); + + it("rejects version drift and modified protocol sources", () => { + withFixture("1.2.4", "modified\n", (root) => { + expect( + verifyOpenShellGrpcProtoPin(root, { + version: "1.2.3", + files: { "openshell.proto": "a".repeat(64) }, + }), + ).toEqual([ + "OpenShell gRPC proto version: expected blueprint maximum 1.2.4, found 1.2.3", + expect.stringMatching( + /^third_party\/openshell\/v1\.2\.3\/proto\/openshell\.proto: expected SHA-256 a{64}, found [a-f0-9]{64}$/, + ), + ]); + }); + }); + + it("rejects a declared protocol source that is missing", () => { + withFixture("1.2.3", 'syntax = "proto3";\n', (root, digest) => { + expect( + verifyOpenShellGrpcProtoPin(root, { + version: "1.2.3", + files: { + "openshell.proto": digest, + "sandbox.proto": "a".repeat(64), + }, + }), + ).toEqual([ + expect.stringMatching( + /^third_party\/openshell\/v1\.2\.3\/proto\/sandbox\.proto: failed to read \(/, + ), + ]); + }); + }); + + it("rejects protocol sources omitted from the package", () => { + withFixture( + "1.2.3", + 'syntax = "proto3";\n', + (root, digest) => { + expect( + verifyOpenShellGrpcProtoPin(root, { + version: "1.2.3", + files: { "openshell.proto": digest }, + }), + ).toEqual([ + "package.json: files must include third_party/openshell/ for runtime gRPC loading", + ]); + }, + ["dist/"], + ); + }); +}); diff --git a/third_party/openshell/v0.0.85/README.md b/third_party/openshell/v0.0.85/README.md new file mode 100644 index 00000000000..ef108179de4 --- /dev/null +++ b/third_party/openshell/v0.0.85/README.md @@ -0,0 +1,19 @@ + + +# OpenShell gRPC protocol pin + +This directory contains exact copies of the public protocol sources from +NVIDIA OpenShell tag `v0.0.85` at commit +`3dee5570a46076a57a3b056f35f35ebc0861ac85`. + +NemoClaw keeps these sources private to its OpenShell adapter. They provide the +wire contract for the direct gRPC migration and can be replaced by the official +TypeScript SDK without changing callers of that adapter. + +The repository dependency check binds this directory to +`nemoclaw-blueprint/blueprint.yaml` and verifies the SHA-256 digest of every +protocol file. Update the version, sources, and digests together when NemoClaw +changes its supported OpenShell version. diff --git a/third_party/openshell/v0.0.85/proto/datamodel.proto b/third_party/openshell/v0.0.85/proto/datamodel.proto new file mode 100644 index 00000000000..ada4716b117 --- /dev/null +++ b/third_party/openshell/v0.0.85/proto/datamodel.proto @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.datamodel.v1; + +import "options.proto"; + +// Kubernetes-style metadata shared by all top-level OpenShell domain objects. +// +// This structure provides consistent metadata (identity, labels, annotations, +// timestamps, resource versioning) across Sandbox, Provider, SshSession, and +// other resources. +message ObjectMeta { + // Stable object ID generated by the gateway. + string id = 1; + + // Human-readable object name (unique per object type). + string name = 2; + + // Milliseconds since Unix epoch when the object was created. + int64 created_at_ms = 3; + + // Key-value labels for filtering and organization. + // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. + map labels = 4; + + // Optimistic concurrency control version. + // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. + uint64 resource_version = 5; + + // Opaque key-value metadata that is not used for selectors. + // Annotation keys use the same qualified-key shape as labels, but values may be longer. + map annotations = 6; +} + +// Provider model stored by OpenShell. +message Provider { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + ObjectMeta metadata = 1; + // Canonical provider type slug (for example: "claude", "gitlab"). + string type = 2; + // Secret values used for authentication. + map credentials = 3 [(openshell.options.v1.secret) = true]; + // Non-secret provider configuration. + map config = 4; + // Expiration timestamps for credential values, keyed by credential/env var + // name. A zero or missing value means the credential does not expire. + map credential_expires_at_ms = 5; +} diff --git a/third_party/openshell/v0.0.85/proto/openshell.proto b/third_party/openshell/v0.0.85/proto/openshell.proto new file mode 100644 index 00000000000..b437613b045 --- /dev/null +++ b/third_party/openshell/v0.0.85/proto/openshell.proto @@ -0,0 +1,1955 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.v1; + +import "datamodel.proto"; +import "google/protobuf/struct.proto"; +import "options.proto"; +import "sandbox.proto"; + +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +service OpenShell { + // Check the health of the service. + rpc Health(HealthRequest) returns (HealthResponse); + + // Fetch elevated live gateway runtime metadata. + rpc GetGatewayInfo(GetGatewayInfoRequest) returns (GetGatewayInfoResponse); + + // Create a new sandbox. + rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); + + // Fetch a sandbox by name. + rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse); + + // List sandboxes. + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + + // List provider records attached to a sandbox. + rpc ListSandboxProviders(ListSandboxProvidersRequest) + returns (ListSandboxProvidersResponse); + + // Attach a provider record to an existing sandbox. + rpc AttachSandboxProvider(AttachSandboxProviderRequest) + returns (AttachSandboxProviderResponse); + + // Detach a provider record from an existing sandbox. + rpc DetachSandboxProvider(DetachSandboxProviderRequest) + returns (DetachSandboxProviderResponse); + + // Delete a sandbox by name. + rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse); + + // Create a short-lived SSH session for a sandbox. + rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + + // Create or update a sandbox HTTP service endpoint for local routing. + rpc ExposeService(ExposeServiceRequest) returns (ServiceEndpointResponse); + + // Fetch one sandbox HTTP service endpoint. + rpc GetService(GetServiceRequest) returns (ServiceEndpointResponse); + + // List sandbox HTTP service endpoints. + rpc ListServices(ListServicesRequest) returns (ListServicesResponse); + + // Delete one sandbox HTTP service endpoint. + rpc DeleteService(DeleteServiceRequest) returns (DeleteServiceResponse); + + // Revoke a previously issued SSH session. + rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); + + // Execute a command in a ready sandbox and stream output. + rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent); + + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + rpc ForwardTcp(stream TcpForwardFrame) returns (stream TcpForwardFrame); + + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + rpc ExecSandboxInteractive(stream ExecSandboxInput) returns (stream ExecSandboxEvent); + + // Create a provider. + rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse); + + // Fetch a provider by name. + rpc GetProvider(GetProviderRequest) returns (ProviderResponse); + + // List providers. + rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + + // List available provider type profiles. + rpc ListProviderProfiles(ListProviderProfilesRequest) + returns (ListProviderProfilesResponse); + + // Fetch one provider type profile by id. + rpc GetProviderProfile(GetProviderProfileRequest) + returns (ProviderProfileResponse); + + // Import custom provider type profiles. + rpc ImportProviderProfiles(ImportProviderProfilesRequest) + returns (ImportProviderProfilesResponse); + + // Update an existing custom provider type profile. + rpc UpdateProviderProfiles(UpdateProviderProfilesRequest) + returns (UpdateProviderProfilesResponse); + + // Validate provider type profiles without registering them. + rpc LintProviderProfiles(LintProviderProfilesRequest) + returns (LintProviderProfilesResponse); + + // Update an existing provider by name. + rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); + + // Fetch refresh status for one provider or provider credential. + rpc GetProviderRefreshStatus(GetProviderRefreshStatusRequest) + returns (GetProviderRefreshStatusResponse); + + // Configure gateway-owned refresh material for one provider credential. + rpc ConfigureProviderRefresh(ConfigureProviderRefreshRequest) + returns (ConfigureProviderRefreshResponse); + + // Record a gateway-owned refresh request for one provider credential. + rpc RotateProviderCredential(RotateProviderCredentialRequest) + returns (RotateProviderCredentialResponse); + + // Delete gateway-owned refresh configuration for one provider credential. + rpc DeleteProviderRefresh(DeleteProviderRefreshRequest) + returns (DeleteProviderRefreshResponse); + + // Delete a provider by name. + rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); + + // Delete a custom provider type profile by id. + rpc DeleteProviderProfile(DeleteProviderProfileRequest) + returns (DeleteProviderProfileResponse); + + // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + rpc GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) + returns (openshell.sandbox.v1.GetSandboxConfigResponse); + + // Get gateway-global settings. + rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) + returns (openshell.sandbox.v1.GetGatewayConfigResponse); + + // Update settings or policy at sandbox or global scope. + rpc UpdateConfig(UpdateConfigRequest) + returns (UpdateConfigResponse); + + // Get the load status of a specific policy version. + rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) + returns (GetSandboxPolicyStatusResponse); + + // List policy history for a sandbox. + rpc ListSandboxPolicies(ListSandboxPoliciesRequest) + returns (ListSandboxPoliciesResponse); + + // Report policy load result (called by sandbox after reload attempt). + rpc ReportPolicyStatus(ReportPolicyStatusRequest) + returns (ReportPolicyStatusResponse); + + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) + returns (GetSandboxProviderEnvironmentResponse); + + // Fetch recent sandbox logs (one-shot). + rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); + + // Push sandbox supervisor logs to the server (client-streaming). + rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse); + + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage); + + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + rpc RelayStream(stream RelayFrame) returns (stream RelayFrame); + + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent); + + // --------------------------------------------------------------------------- + // Draft policy recommendation RPCs + // --------------------------------------------------------------------------- + + // Submit denial analysis results from sandbox (summaries + proposed chunks). + rpc SubmitPolicyAnalysis(SubmitPolicyAnalysisRequest) + returns (SubmitPolicyAnalysisResponse); + + // Get draft policy recommendations for a sandbox. + rpc GetDraftPolicy(GetDraftPolicyRequest) returns (GetDraftPolicyResponse); + + // Approve a single draft policy chunk (merges into active policy). + rpc ApproveDraftChunk(ApproveDraftChunkRequest) + returns (ApproveDraftChunkResponse); + + // Reject a single draft policy chunk. + rpc RejectDraftChunk(RejectDraftChunkRequest) + returns (RejectDraftChunkResponse); + + // Approve all pending draft chunks (skips security-flagged unless forced). + rpc ApproveAllDraftChunks(ApproveAllDraftChunksRequest) + returns (ApproveAllDraftChunksResponse); + + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + rpc EditDraftChunk(EditDraftChunkRequest) returns (EditDraftChunkResponse); + + // Reverse an approval (remove merged rule from active policy). + rpc UndoDraftChunk(UndoDraftChunkRequest) returns (UndoDraftChunkResponse); + + // Clear all pending draft chunks for a sandbox. + rpc ClearDraftChunks(ClearDraftChunksRequest) + returns (ClearDraftChunksResponse); + + // Get decision history for a sandbox's draft policy. + rpc GetDraftHistory(GetDraftHistoryRequest) returns (GetDraftHistoryResponse); + + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + rpc IssueSandboxToken(IssueSandboxTokenRequest) returns (IssueSandboxTokenResponse); + + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + rpc RefreshSandboxToken(RefreshSandboxTokenRequest) + returns (RefreshSandboxTokenResponse); +} + +// IssueSandboxToken request. Empty body; identity is established by the +// authentication credentials carried in the request headers (a projected +// Kubernetes ServiceAccount JWT in the K8s driver path). +message IssueSandboxTokenRequest {} + +// IssueSandboxToken response. The supervisor caches the returned token in +// memory and presents it as `Authorization: Bearer` on every subsequent +// gateway RPC. +message IssueSandboxTokenResponse { + // Gateway-minted JWT bound to the calling sandbox's UUID. + string token = 1 [(openshell.options.v1.secret) = true]; + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 expires_at_ms = 2; +} + +// RefreshSandboxToken request. Empty body; the calling principal must +// already be a sandbox principal (i.e. the request carries a still-valid +// gateway-minted JWT in its Authorization header). +message RefreshSandboxTokenRequest {} + +// RefreshSandboxToken response. The new token replaces the supervisor's +// in-memory bearer credential. +message RefreshSandboxTokenResponse { + // Fresh gateway-minted JWT bound to the same sandbox UUID. + string token = 1 [(openshell.options.v1.secret) = true]; + // Absolute expiry of the new token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 expires_at_ms = 2; +} + +// Health check request. +message HealthRequest {} + +// Health check response. +message HealthResponse { + // Service status. + ServiceStatus status = 1; + + // Service version. + string version = 2; +} + +// Gateway info request. +message GetGatewayInfoRequest {} + +// Gateway info response. +message GetGatewayInfoResponse { + // Service status. + ServiceStatus status = 1; + + // OpenShell gateway binary version. + string gateway_version = 2; + + // Compute driver runtimes initialized by this gateway. Current gateways + // return exactly one entry. + repeated ComputeDriverInfo compute_drivers = 3; +} + +// Info for one initialized compute driver runtime. +message ComputeDriverInfo { + // Gateway-selected driver name used for routing and driver_config keys. + string name = 1; + + // Capabilities reported by the driver during gateway runtime initialization. + ComputeDriverCapabilities capabilities = 2; +} + +// Public compute driver capability snapshot. +message ComputeDriverCapabilities { + // Driver-reported human-readable name from the startup capability snapshot. + string driver_name = 1; + + // Driver-reported implementation version from the startup capability snapshot. + string driver_version = 2; +} + +// Public sandbox resource exposed by the OpenShell API. +// +// This is the canonical gateway-owned view of a sandbox. It merges user intent +// (`spec`) with gateway-managed metadata and status derived from internal +// compute-driver observations. +// +// Note: The `namespace` field has been removed from the public API. It remains +// in the internal `DriverSandbox` message as a compute-driver implementation detail. +message Sandbox { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Desired sandbox configuration submitted through the API. + SandboxSpec spec = 2; + // Latest user-facing observed status derived by the gateway. + SandboxStatus status = 3; + + reserved 4, 5; + reserved "phase", "current_policy_version"; +} + +// Desired sandbox configuration provided through the public API. +message SandboxSpec { + // Log level exposed to processes running inside the sandbox. + string log_level = 1; + // Environment variables injected into the sandbox runtime. + map environment = 5; + // Container or VM template used to provision the sandbox. + SandboxTemplate template = 6; + // Required sandbox policy configuration. + openshell.sandbox.v1.SandboxPolicy policy = 7; + // Provider names to attach to this sandbox. + repeated string providers = 8; + // Portable resource requirements used by the gateway for driver selection + // and by drivers for provisioning. + ResourceRequirements resource_requirements = 9; + reserved 10; + reserved "gpu_device"; + // Field 11 was `proposal_approval_mode`. The approval mode is now a + // runtime setting (gateway or sandbox scope) read via UpdateConfig / + // GetSandboxConfig, so it can be flipped on a running sandbox and + // managed fleet-wide. + reserved 11; + reserved "proposal_approval_mode"; +} + +message ResourceRequirements { + // GPU requirements for the sandbox. Presence indicates a GPU request. + GpuResourceRequirements gpu = 1; +} + +// Public GPU resource requirements. +message GpuResourceRequirements { + // Optional number of GPUs requested. When omitted, the request is for one + // GPU using the selected driver's default assignment behavior. + optional uint32 count = 1; +} + +// Public sandbox template mapped onto compute-driver template inputs. +message SandboxTemplate { + // Fully-qualified OCI image reference used to boot the sandbox. + string image = 1; + // Optional runtime class name requested from the compute platform. + string runtime_class_name = 2; + // Optional agent socket path exposed to the workload. + string agent_socket = 3; + // Labels applied to compute-platform resources for this sandbox. + map labels = 4; + // Annotations applied to compute-platform resources for this sandbox. + map annotations = 5; + // Additional environment variables injected by the template. + map environment = 6; + // Platform-specific compute resource requirements and limits. + google.protobuf.Struct resources = 7; + reserved 9; + reserved "volume_claim_templates"; + // Enable Kubernetes user namespace isolation (hostUsers: false). + // When true, container UID 0 maps to a non-root host UID and capabilities + // become namespaced. Requires Kubernetes 1.33+ with user namespace support + // available (beta through 1.35, GA in 1.36+) and a supporting runtime. + // When unset, the cluster-wide default is used. + optional bool user_namespaces = 10; + // Driver-keyed opaque config envelope supplied by the caller. + // The gateway selects the block matching the active compute driver and + // forwards only that inner Struct to DriverSandboxTemplate.driver_config. + // The selected driver owns nested schema validation. + google.protobuf.Struct driver_config = 11; +} + +// User-facing sandbox status derived by the gateway from compute-driver observations. +// +// Public status does not embed driver-only flags such as `deleting`. +message SandboxStatus { + // Compute-platform sandbox object name. + string sandbox_name = 1; + // Name of the agent pod or equivalent runtime instance. + string agent_pod = 2; + // File descriptor or endpoint for reaching the agent service, when available. + string agent_fd = 3; + // File descriptor or endpoint for reaching the sandbox service, when available. + string sandbox_fd = 4; + // Latest user-facing readiness and lifecycle conditions. + repeated SandboxCondition conditions = 5; + // Gateway-derived lifecycle summary. + SandboxPhase phase = 6; + // Currently active policy version (updated when sandbox reports loaded). + uint32 current_policy_version = 7; +} + +// User-facing sandbox condition derived from driver-native conditions. +message SandboxCondition { + // Condition class, typically mirroring the underlying platform condition type. + string type = 1; + // Condition status value such as `True`, `False`, or `Unknown`. + string status = 2; + // Short machine-readable reason associated with the condition. + string reason = 3; + // Human-readable condition message. + string message = 4; + // Timestamp reported by the underlying platform for the last transition. + string last_transition_time = 5; +} + +// High-level sandbox lifecycle phase derived by the gateway. +// +// Clients should rely on this normalized lifecycle summary for readiness and +// deletion decisions instead of interpreting raw conditions. +enum SandboxPhase { + SANDBOX_PHASE_UNSPECIFIED = 0; + SANDBOX_PHASE_PROVISIONING = 1; + SANDBOX_PHASE_READY = 2; + SANDBOX_PHASE_ERROR = 3; + SANDBOX_PHASE_DELETING = 4; + SANDBOX_PHASE_UNKNOWN = 5; +} + +// Public platform event exposed on the sandbox watch stream. +message PlatformEvent { + // Event timestamp in milliseconds since epoch. + int64 timestamp_ms = 1; + // Event source (e.g. "kubernetes", "docker", "process"). + string source = 2; + // Event type/severity (e.g. "Normal", "Warning"). + string type = 3; + // Short reason code (e.g. "Started", "Pulled", "Failed"). + string reason = 4; + // Human-readable event message. + string message = 5; + // Optional metadata as key-value pairs. + map metadata = 6; +} + +// Create sandbox request. +message CreateSandboxRequest { + SandboxSpec spec = 1; + // Optional user-supplied sandbox name. When empty the server generates one. + string name = 2; + // Optional labels for the sandbox (key-value metadata). + map labels = 3; + // Optional annotations for the sandbox (non-selector metadata). + map annotations = 4; +} + +// Get sandbox request. +message GetSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// List sandboxes request. +message ListSandboxesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + string label_selector = 3; +} + +// List providers attached to a sandbox request. +message ListSandboxProvidersRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; +} + +// Attach provider to sandbox request. +message AttachSandboxProviderRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; + // Provider name to attach. + string provider_name = 2; + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + uint64 expected_resource_version = 3; +} + +// Detach provider from sandbox request. +message DetachSandboxProviderRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; + // Provider name to detach. + string provider_name = 2; + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + uint64 expected_resource_version = 3; +} + +// Delete sandbox request. +message DeleteSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// Sandbox response. +message SandboxResponse { + Sandbox sandbox = 1; +} + +// List sandboxes response. +message ListSandboxesResponse { + repeated Sandbox sandboxes = 1; +} + +// List providers attached to a sandbox response. +message ListSandboxProvidersResponse { + repeated openshell.datamodel.v1.Provider providers = 1; +} + +// Attach provider to sandbox response. +message AttachSandboxProviderResponse { + Sandbox sandbox = 1; + // True when the provider was newly attached. False means it was already attached. + bool attached = 2; +} + +// Detach provider from sandbox response. +message DetachSandboxProviderResponse { + Sandbox sandbox = 1; + // True when the provider was removed. False means it was not attached. + bool detached = 2; +} + +// Delete sandbox response. +message DeleteSandboxResponse { + bool deleted = 1; +} + +// Create SSH session request. +message CreateSshSessionRequest { + // Sandbox id. + string sandbox_id = 1; +} + +// Create SSH session response. +// +// Fields are interpolated into an SSH `ProxyCommand` string that OpenSSH +// executes through `/bin/sh -c` on the caller's workstation. Servers MUST +// uphold the charset contract below; clients MUST reject responses that +// violate it. The client's own escaping provides defense-in-depth, but +// narrow charsets close injection vectors at the trust boundary. +message CreateSshSessionResponse { + // Sandbox id. [A-Za-z0-9._-]{1,128}. + string sandbox_id = 1; + + // Session token for the gateway tunnel. URL-safe ASCII + // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or + // whitespace. + string token = 2 [(openshell.options.v1.secret) = true]; + + // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 + // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus + // `.-:[]` only, up to 253 bytes. + string gateway_host = 3; + + // Gateway port for SSH proxy connection. Must be in range 1..=65535. + uint32 gateway_port = 4; + + // Gateway scheme. Must be exactly "http" or "https". + string gateway_scheme = 5; + + // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. + string host_key_fingerprint = 7; + + // Expiry timestamp in milliseconds since epoch. 0 means no expiry. + int64 expires_at_ms = 8; +} + +// Request to expose an HTTP service running inside a sandbox. +message ExposeServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. + string service = 2; + // Loopback TCP port inside the sandbox. + uint32 target_port = 3; + // Whether to print/use the browser-facing service URL. + bool domain = 4; +} + +// Request to fetch an exposed sandbox service endpoint. +message GetServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 2; +} + +// Request to list exposed sandbox service endpoints. +message ListServicesRequest { + // Optional sandbox name. Empty lists endpoints for all sandboxes. + string sandbox = 1; + // Page size. Zero uses the server default. + uint32 limit = 2; + // Page offset. + uint32 offset = 3; +} + +// Response containing exposed sandbox service endpoints. +message ListServicesResponse { + repeated ServiceEndpointResponse services = 1; +} + +// Request to delete an exposed sandbox service endpoint. +message DeleteServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 2; +} + +// Response for deleting an exposed sandbox service endpoint. +message DeleteServiceResponse { + // True when an endpoint existed and was deleted. + bool deleted = 1; +} + +// Persisted sandbox service endpoint. +message ServiceEndpoint { + // Kubernetes-style metadata. + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Sandbox object ID. + string sandbox_id = 2; + // Sandbox name. + string sandbox_name = 3; + // Service name within the sandbox. + string service_name = 4; + // Loopback TCP port inside the sandbox. + uint32 target_port = 5; + // Whether browser-facing service routing is enabled for this endpoint. + bool domain = 6; +} + +// Response containing a service endpoint and, when available, its local URL. +message ServiceEndpointResponse { + ServiceEndpoint endpoint = 1; + string url = 2; +} + +// Revoke SSH session request. +message RevokeSshSessionRequest { + // Session token to revoke. + string token = 1 [(openshell.options.v1.secret) = true]; +} + +// Revoke SSH session response. +message RevokeSshSessionResponse { + // True when a session was revoked. + bool revoked = 1; +} + +// Execute command request. +message ExecSandboxRequest { + // Sandbox id. + string sandbox_id = 1; + + // Command and arguments. + repeated string command = 2; + + // Optional working directory. + string workdir = 3; + + // Optional environment overrides. + map environment = 4; + + // Optional timeout in seconds. 0 means no timeout. + uint32 timeout_seconds = 5; + + // Optional stdin payload passed to the command. + bytes stdin = 6; + + // Request a pseudo-terminal for the remote command. + bool tty = 7; + + // Initial terminal columns (used when tty=true, 0 = use default). + uint32 cols = 8; + + // Initial terminal rows (used when tty=true, 0 = use default). + uint32 rows = 9; +} + +// One stdout chunk from a sandbox exec. +message ExecSandboxStdout { + bytes data = 1; +} + +// One stderr chunk from a sandbox exec. +message ExecSandboxStderr { + bytes data = 1; +} + +// Final exit status for a sandbox exec. +message ExecSandboxExit { + int32 exit_code = 1; +} + +// One event in a sandbox exec stream. +message ExecSandboxEvent { + oneof payload { + ExecSandboxStdout stdout = 1; + ExecSandboxStderr stderr = 2; + ExecSandboxExit exit = 3; + } +} + +// Initial frame for one TCP forward stream. +message TcpForwardInit { + // Sandbox id. + string sandbox_id = 1; + // Optional service identifier for audit/correlation. + string service_id = 4; + // Target the gateway should request from the supervisor. + oneof target { + SshRelayTarget ssh = 5; + TcpRelayTarget tcp = 6; + } + // Optional target-specific authorization token. SSH targets use this as the + // short-lived SSH session token issued by CreateSshSession. + string authorization_token = 7 [(openshell.options.v1.secret) = true]; +} + +// A single frame on the CLI-to-gateway TCP forward stream. +message TcpForwardFrame { + oneof payload { + TcpForwardInit init = 1; + bytes data = 2; + } +} + +// Client-to-server message for interactive exec. +message ExecSandboxInput { + oneof payload { + // First message: exec request metadata. + ExecSandboxRequest start = 1; + // Subsequent messages: raw stdin bytes. + bytes stdin = 2; + // Terminal window size change. + ExecSandboxWindowResize resize = 3; + } +} + +// Terminal window resize event for interactive exec. +message ExecSandboxWindowResize { + uint32 cols = 1; + uint32 rows = 2; +} + + +// SSH session record stored in persistence. +message SshSession { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + + // Sandbox id. + string sandbox_id = 2; + + // Session token. + string token = 3 [(openshell.options.v1.secret) = true]; + + // Expiry timestamp in milliseconds since epoch. 0 means no expiry + // (backward-compatible default for sessions created before this field existed). + int64 expires_at_ms = 4; + + // Revoked flag. + bool revoked = 5; +} + +// Watch sandbox request. +message WatchSandboxRequest { + // Sandbox id. + string id = 1; + + // Stream sandbox status snapshots. + bool follow_status = 2; + + // Stream openshell-server process logs correlated to this sandbox. + bool follow_logs = 3; + + // Stream platform events correlated to this sandbox. + bool follow_events = 4; + + // Replay the last N log lines (best-effort) before following. + uint32 log_tail_lines = 5; + + // Replay the last N platform events (best-effort) before following. + uint32 event_tail = 6; + + // Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). + bool stop_on_terminal = 7; + + // Only include log lines with timestamp >= this value (milliseconds since epoch). + // 0 means no time filter. Applies to both tail replay and live streaming. + int64 log_since_ms = 8; + + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string log_sources = 9; + + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string log_min_level = 10; +} + +// One event in a sandbox watch stream. +message SandboxStreamEvent { + oneof payload { + // Latest sandbox snapshot. + Sandbox sandbox = 1; + // One server log line/event. + SandboxLogLine log = 2; + // One platform event. + PlatformEvent event = 3; + // Warning from the server (e.g. missed messages due to lag). + SandboxStreamWarning warning = 4; + // Draft policy update notification. + DraftPolicyUpdate draft_policy_update = 5; + } +} + +// Log line correlated to a sandbox. +message SandboxLogLine { + string sandbox_id = 1; + int64 timestamp_ms = 2; + string level = 3; + string target = 4; + string message = 5; + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + string source = 6; + // Structured key-value fields from the tracing event (e.g. dst_host, action). + map fields = 7; +} + +message SandboxStreamWarning { + string message = 1; +} + +// Create provider request. +message CreateProviderRequest { + openshell.datamodel.v1.Provider provider = 1; +} + +// Get provider request. +message GetProviderRequest { + string name = 1; +} + +// List providers request. +message ListProvidersRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Update provider request. +message UpdateProviderRequest { + openshell.datamodel.v1.Provider provider = 1; + // Optional per-credential expiry timestamps to merge into the provider. + // A zero value removes the expiry for that credential. + map credential_expires_at_ms = 2; +} + +// Delete provider request. +message DeleteProviderRequest { + string name = 1; +} + +// Provider response. +message ProviderResponse { + openshell.datamodel.v1.Provider provider = 1; +} + +// List providers response. +message ListProvidersResponse { + repeated openshell.datamodel.v1.Provider providers = 1; +} + +// List provider type profiles request. +message ListProviderProfilesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Fetch provider type profile request. +message GetProviderProfileRequest { + string id = 1; +} + +// Provider profile payload with optional source metadata for diagnostics. +message ProviderProfileImportItem { + ProviderProfile profile = 1; + string source = 2; +} + +// Provider profile validation diagnostic. +message ProviderProfileDiagnostic { + string source = 1; + string profile_id = 2; + string field = 3; + string message = 4; + string severity = 5; +} + +// Endpoint selector for token grant audience overrides. +message ProviderCredentialTokenGrantAudienceOverride { + // Optional: endpoint host selector. If omitted, inherits the profile endpoint host. + string host = 1; + + // Optional: endpoint port selector. If omitted, matches the expanded profile endpoint port. + uint32 port = 2; + + // Optional: endpoint path selector. If omitted, inherits the profile endpoint path. + string path = 3; + + // Resource audience to request for matching endpoints. + string audience = 4; + + // Optional: OAuth2 scopes to request. If omitted, inherits the token grant scopes. + repeated string scopes = 5; +} + +// Provider credential token grant configuration. +// When present, the credential is obtained dynamically via OAuth2 grant when needed. +message ProviderCredentialTokenGrant { + // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) + string token_endpoint = 1; + + // Optional: default resource audience to request from the token service + string audience = 2; + + // Optional: audience to request when fetching the JWT-SVID from SPIRE. + // If omitted, the sandbox derives this from token_endpoint. + string jwt_svid_audience = 6; + + // Optional: OAuth2 scopes to request + repeated string scopes = 3; + + // Optional: override token cache TTL (seconds) + // If 0 or omitted, use expires_in from token response + int64 cache_ttl_seconds = 4; + + // Optional: endpoint-specific resource audience overrides. + repeated ProviderCredentialTokenGrantAudienceOverride audience_overrides = 5; + + // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses + // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. + string client_assertion_type = 7; +} + +// Provider credential declaration. +message ProviderProfileCredential { + string name = 1; + string description = 2; + repeated string env_vars = 3; + bool required = 4; + string auth_style = 5; + string header_name = 6; + string query_param = 7; + ProviderCredentialRefresh refresh = 8; + string path_template = 9; + ProviderCredentialTokenGrant token_grant = 10; +} + +enum ProviderCredentialRefreshStrategy { + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED = 0; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC = 1; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL = 2; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN = 3; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; +} + +message ProviderCredentialRefreshMaterial { + string name = 1; + string description = 2; + bool required = 3; + bool secret = 4; +} + +message ProviderCredentialRefresh { + ProviderCredentialRefreshStrategy strategy = 1; + string token_url = 2; + repeated string scopes = 3; + int64 refresh_before_seconds = 4; + int64 max_lifetime_seconds = 5; + repeated ProviderCredentialRefreshMaterial material = 6; +} + +message ProviderCredentialRefreshStatus { + string provider_name = 1; + string provider_id = 2; + string credential_key = 3; + ProviderCredentialRefreshStrategy strategy = 4; + string status = 5; + int64 expires_at_ms = 6; + int64 next_refresh_at_ms = 7; + int64 last_refresh_at_ms = 8; + string last_error = 9; +} + +// Provider profile local discovery declaration. +message ProviderProfileDiscovery { + // Credential names from ProviderProfile.credentials eligible for local discovery. + repeated string credentials = 1; +} + +message StoredProviderCredentialRefreshState { + openshell.datamodel.v1.ObjectMeta metadata = 1; + string provider_id = 2; + string provider_name = 3; + string credential_key = 4; + ProviderCredentialRefreshStrategy strategy = 5; + map material = 6 [(openshell.options.v1.secret) = true]; + repeated string secret_material_keys = 7; + int64 expires_at_ms = 8; + int64 next_refresh_at_ms = 9; + int64 last_refresh_at_ms = 10; + string status = 11; + string last_error = 12; + string token_url = 13; + repeated string scopes = 14; + int64 refresh_before_seconds = 15; + int64 max_lifetime_seconds = 16; +} + +message GetProviderRefreshStatusRequest { + string provider = 1; + string credential_key = 2; +} + +message GetProviderRefreshStatusResponse { + repeated ProviderCredentialRefreshStatus credentials = 1; +} + +message ConfigureProviderRefreshRequest { + string provider = 1; + string credential_key = 2; + ProviderCredentialRefreshStrategy strategy = 3; + map material = 4 [(openshell.options.v1.secret) = true]; + repeated string secret_material_keys = 5; + optional int64 expires_at_ms = 6; +} + +message ConfigureProviderRefreshResponse { + ProviderCredentialRefreshStatus status = 1; +} + +message RotateProviderCredentialRequest { + string provider = 1; + string credential_key = 2; +} + +message RotateProviderCredentialResponse { + ProviderCredentialRefreshStatus status = 1; +} + +message DeleteProviderRefreshRequest { + string provider = 1; + string credential_key = 2; +} + +message DeleteProviderRefreshResponse { + bool deleted = 1; +} + +// Stable provider profile categories used by clients for grouping and filtering. +enum ProviderProfileCategory { + PROVIDER_PROFILE_CATEGORY_UNSPECIFIED = 0; + PROVIDER_PROFILE_CATEGORY_OTHER = 1; + PROVIDER_PROFILE_CATEGORY_INFERENCE = 2; + PROVIDER_PROFILE_CATEGORY_AGENT = 3; + PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL = 4; + PROVIDER_PROFILE_CATEGORY_MESSAGING = 5; + PROVIDER_PROFILE_CATEGORY_DATA = 6; + PROVIDER_PROFILE_CATEGORY_KNOWLEDGE = 7; +} + +// Provider type profile metadata exposed to clients. +message ProviderProfile { + string id = 1; + string display_name = 2; + string description = 3; + ProviderProfileCategory category = 4; + repeated ProviderProfileCredential credentials = 5; + repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; + repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + bool inference_capable = 8; + ProviderProfileDiscovery discovery = 9; + // Storage resource version for custom profiles. Built-in profiles and new + // profile files use 0. Gateway responses set this for stored custom profiles. + // Update calls use this for optimistic concurrency. + uint64 resource_version = 10; + // Optional non-secret annotations attached by profile sources or importers. + map annotations = 11; +} + +// Stored custom provider profile object. +message StoredProviderProfile { + openshell.datamodel.v1.ObjectMeta metadata = 1; + ProviderProfile profile = 2; +} + +// Provider profile response. +message ProviderProfileResponse { + ProviderProfile profile = 1; +} + +// List provider profiles response. +message ListProviderProfilesResponse { + repeated ProviderProfile profiles = 1; +} + +// Import custom provider profiles request. +message ImportProviderProfilesRequest { + repeated ProviderProfileImportItem profiles = 1; +} + +// Import custom provider profiles response. +message ImportProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + repeated ProviderProfile profiles = 2; + bool imported = 3; +} + +// Update one custom provider profile request. +message UpdateProviderProfilesRequest { + ProviderProfileImportItem profile = 1; + // Expected storage resource version for optimistic concurrency control. + // If 0, the server uses the resource_version embedded in profile.profile. + // Updates without a non-zero version are rejected to prevent stale files from + // silently overwriting newer profile definitions. + uint64 expected_resource_version = 2; + // Existing custom provider profile ID to update. The payload ID must match. + string id = 3; +} + +// Update one custom provider profile response. +message UpdateProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + ProviderProfile profile = 2; + bool updated = 3; +} + +// Lint provider profiles request. +message LintProviderProfilesRequest { + repeated ProviderProfileImportItem profiles = 1; +} + +// Lint provider profiles response. +message LintProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + bool valid = 2; +} + +// Delete provider response. +message DeleteProviderResponse { + bool deleted = 1; +} + +// Delete custom provider profile request. +message DeleteProviderProfileRequest { + string id = 1; +} + +// Delete custom provider profile response. +message DeleteProviderProfileResponse { + bool deleted = 1; +} + +// Get sandbox provider environment request. +message GetSandboxProviderEnvironmentRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Get sandbox provider environment response. +message GetSandboxProviderEnvironmentResponse { + // Provider credential environment variables. + map environment = 1 [(openshell.options.v1.secret) = true]; + // Fingerprint for the provider credential inputs that produced environment. + uint64 provider_env_revision = 2; + // Expiration timestamps for returned environment variables. + map credential_expires_at_ms = 3; + // Dynamic credentials that require token grants or other runtime injection. + // Maps endpoint-bound provider metadata to credential metadata. + // Supervisor uses this to inject Authorization headers for token grant credentials. + map dynamic_credentials = 4; +} + +// --------------------------------------------------------------------------- +// Policy update messages +// --------------------------------------------------------------------------- + +// Update sandbox policy request. +message UpdateConfigRequest { + // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. + // Not required when `global=true`. + string name = 1; + // The new policy to apply. + // + // Sandbox scope (`global=false`): + // - only network_policies and inference fields may differ from create-time + // policy; static fields must match version 1. + // + // Global scope (`global=true`): + // - applies to all sandboxes in full (no merge). + openshell.sandbox.v1.SandboxPolicy policy = 2; + // Optional single setting key to mutate. + string setting_key = 3; + // Setting value for upsert operations. + openshell.sandbox.v1.SettingValue setting_value = 4; + // Delete the setting key from scope. + // Sandbox-scoped deletes are rejected; only global delete is supported. + bool delete_setting = 5; + // Apply mutation at gateway-global scope. + bool global = 6; + // Batched incremental policy merge operations. Sandbox-scoped only. + repeated PolicyMergeOperation merge_operations = 7; + // Expected resource version for optimistic concurrency control (sandbox-scoped only). + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + // Ignored for global-scoped updates. + uint64 expected_resource_version = 8; + // Caller-provided annotations associated with a sandbox-scoped update. Values + // must not contain secrets; the gateway treats them as opaque metadata and does + // not interpret or verify their semantics. For policy updates, the gateway + // stores the annotations immutably with the revision and merges them into + // sandbox metadata as a convenience projection. For setting-only updates, it + // only merges them into sandbox metadata. + map annotations = 9; +} + +message PolicyMergeOperation { + oneof operation { + AddNetworkRule add_rule = 1; + RemoveNetworkEndpoint remove_endpoint = 2; + RemoveNetworkRule remove_rule = 3; + AddDenyRules add_deny_rules = 4; + AddAllowRules add_allow_rules = 5; + RemoveNetworkBinary remove_binary = 6; + } +} + +message AddNetworkRule { + string rule_name = 1; + openshell.sandbox.v1.NetworkPolicyRule rule = 2; +} + +message RemoveNetworkEndpoint { + string rule_name = 1; + string host = 2; + uint32 port = 3; +} + +message RemoveNetworkRule { + string rule_name = 1; +} + +message AddDenyRules { + string host = 1; + uint32 port = 2; + repeated openshell.sandbox.v1.L7DenyRule deny_rules = 3; +} + +message AddAllowRules { + string host = 1; + uint32 port = 2; + repeated openshell.sandbox.v1.L7Rule rules = 3; +} + +message RemoveNetworkBinary { + string rule_name = 1; + string binary_path = 2; +} + +// Update sandbox policy response. +message UpdateConfigResponse { + // Assigned policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Settings revision for the scope that was modified. + uint64 settings_revision = 3; + // True when a setting delete operation removed an existing key. + bool deleted = 4; + // Sandbox metadata annotations after the update. Empty for global updates. + map annotations = 5; +} + +// Get sandbox policy status request. +message GetSandboxPolicyStatusRequest { + // Sandbox name (canonical lookup key). Ignored when global is true. + string name = 1; + // The specific policy version to query. 0 means latest. + uint32 version = 2; + // Query global policy revisions instead of a sandbox-scoped one. + bool global = 3; +} + +// Get sandbox policy status response. +message GetSandboxPolicyStatusResponse { + // The queried policy revision. + SandboxPolicyRevision revision = 1; + // The currently active (loaded) policy version for this sandbox. + uint32 active_version = 2; +} + +// List sandbox policies request. +message ListSandboxPoliciesRequest { + // Sandbox name (canonical lookup key). Ignored when global is true. + string name = 1; + uint32 limit = 2; + uint32 offset = 3; + // List global policy revisions instead of sandbox-scoped ones. + bool global = 4; +} + +// List sandbox policies response. +message ListSandboxPoliciesResponse { + repeated SandboxPolicyRevision revisions = 1; +} + +// Report policy load status (called by sandbox runtime after reload attempt). +message ReportPolicyStatusRequest { + // Sandbox id. + string sandbox_id = 1; + // The policy version that was attempted. + uint32 version = 2; + // Load result status. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; +} + +// Report policy status response. +message ReportPolicyStatusResponse {} + +// A versioned policy revision with metadata. +message SandboxPolicyRevision { + // Policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Load status of this revision. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; + // Milliseconds since epoch when this revision was created. + int64 created_at_ms = 5; + // Milliseconds since epoch when this revision was loaded by the sandbox. + int64 loaded_at_ms = 6; + // The full policy (only populated when explicitly requested). + openshell.sandbox.v1.SandboxPolicy policy = 7; + // Immutable provenance supplied with this policy revision. + map provenance = 8; +} + +// Policy load status. +enum PolicyStatus { + POLICY_STATUS_UNSPECIFIED = 0; + // Server received the update; sandbox has not yet loaded it. + POLICY_STATUS_PENDING = 1; + // Sandbox successfully applied this policy version. + POLICY_STATUS_LOADED = 2; + // Sandbox attempted to apply but failed; LKG policy remains active. + POLICY_STATUS_FAILED = 3; + // A newer version was persisted before the sandbox loaded this one. + POLICY_STATUS_SUPERSEDED = 4; +} + +// --------------------------------------------------------------------------- +// Sandbox logs messages +// --------------------------------------------------------------------------- + +// Get sandbox logs request (one-shot fetch). +message GetSandboxLogsRequest { + // Sandbox id. + string sandbox_id = 1; + // Maximum number of log lines to return. 0 means use default (2000). + uint32 lines = 2; + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + int64 since_ms = 3; + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string sources = 4; + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string min_level = 5; +} + +// Batch of log lines pushed from sandbox to server. +message PushSandboxLogsRequest { + // The sandbox ID. + string sandbox_id = 1; + // Log lines to ingest. + repeated SandboxLogLine logs = 2; +} + +// Push sandbox logs response. +message PushSandboxLogsResponse {} + +// Get sandbox logs response. +message GetSandboxLogsResponse { + // Log lines in chronological order. + repeated SandboxLogLine logs = 1; + // Total number of lines in the server's buffer for this sandbox. + uint32 buffer_total = 2; +} + +// --------------------------------------------------------------------------- +// Supervisor session messages +// --------------------------------------------------------------------------- + +// Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream. +message SupervisorMessage { + oneof payload { + SupervisorHello hello = 1; + SupervisorHeartbeat heartbeat = 2; + RelayOpenResult relay_open_result = 3; + RelayClose relay_close = 4; + } +} + +// Envelope for gateway-to-supervisor messages on the ConnectSupervisor stream. +message GatewayMessage { + oneof payload { + SessionAccepted session_accepted = 1; + SessionRejected session_rejected = 2; + GatewayHeartbeat heartbeat = 3; + RelayOpen relay_open = 4; + RelayClose relay_close = 5; + } +} + +// Supervisor identifies itself and the sandbox it manages. +message SupervisorHello { + // Sandbox ID this supervisor manages. + string sandbox_id = 1; + // Supervisor instance ID (e.g. boot id or process epoch). + string instance_id = 2; +} + +// Gateway accepts the supervisor session. +message SessionAccepted { + // Gateway-assigned session ID for this connection. + string session_id = 1; + // Recommended heartbeat interval in seconds. + uint32 heartbeat_interval_secs = 2; +} + +// Gateway rejects the supervisor session. +message SessionRejected { + // Human-readable rejection reason. + string reason = 1; +} + +// Supervisor heartbeat. +message SupervisorHeartbeat {} + +// Gateway heartbeat. +message GatewayHeartbeat {} + +// Gateway requests the supervisor to open a relay channel. +// +// On receiving this, the supervisor should initiate a RelayStream RPC to +// the gateway, sending a RelayInit in the first RelayFrame to associate +// the new HTTP/2 stream with the pending relay slot. The supervisor +// bridges that stream to the requested local target. +message RelayOpen { + // Gateway-allocated channel identifier (UUID). + string channel_id = 1; + // Target the supervisor should dial inside the sandbox. + // If absent, supervisors treat the relay as SSH for compatibility. + oneof target { + SshRelayTarget ssh = 2; + TcpRelayTarget tcp = 3; + } + // Optional service identifier for audit/correlation. + string service_id = 5; +} + +// Built-in SSH relay target. +message SshRelayTarget {} + +// TCP target dialed by the supervisor from inside the sandbox. +message TcpRelayTarget { + // Phase 1 accepts loopback only: 127.0.0.1, ::1, or localhost. + string host = 1; + // Target port. Must fit in u16 and be non-zero. + uint32 port = 2; +} + +// Initial RelayStream frame sent by the supervisor to claim a pending relay. +message RelayInit { + // Gateway-allocated channel identifier (UUID). + string channel_id = 1; +} + +// A single frame on the RelayStream RPC. +// +// The supervisor MUST send `init` as the first frame. All subsequent frames +// in either direction carry raw bytes in `data`. +message RelayFrame { + oneof payload { + RelayInit init = 1; + bytes data = 2; + } +} + +// Supervisor reports the result of a relay open request. +message RelayOpenResult { + // Channel identifier from the RelayOpen request. + string channel_id = 1; + // True if the relay was successfully established. + bool success = 2; + // Error message if success is false. + string error = 3; +} + +// Either side requests closure of a relay channel. +message RelayClose { + // Channel identifier to close. + string channel_id = 1; + // Optional reason for closure. + string reason = 2; +} + +// --------------------------------------------------------------------------- +// Service status +// --------------------------------------------------------------------------- + +// Service status enum. +enum ServiceStatus { + SERVICE_STATUS_UNSPECIFIED = 0; + SERVICE_STATUS_HEALTHY = 1; + SERVICE_STATUS_DEGRADED = 2; + SERVICE_STATUS_UNHEALTHY = 3; +} + +// --------------------------------------------------------------------------- +// Draft policy recommendation messages +// --------------------------------------------------------------------------- + +// Observed HTTP method+path pattern from L7 inspection. +message L7RequestSample { + // HTTP method: GET, POST, PUT, DELETE, etc. + string method = 1; + // HTTP path: /v1/models, /repos/myorg/issues + string path = 2; + // L7 decision: "audit" or "deny" (allowed requests not collected). + string decision = 3; + // Number of times this (method, path) was observed. + uint32 count = 4; +} + +// Structured denial summary from sandbox aggregator. +message DenialSummary { + // Sandbox ID that produced this summary. + string sandbox_id = 1; + // Denied destination host. + string host = 2; + // Denied destination port. + uint32 port = 3; + // Binary that attempted the connection. + string binary = 4; + // Process ancestor chain. + repeated string ancestors = 5; + // Denial reason from OPA evaluation. + string deny_reason = 6; + // First denial timestamp (ms since epoch). + int64 first_seen_ms = 7; + // Most recent denial timestamp (ms since epoch). + int64 last_seen_ms = 8; + // Number of denials in the current window. + uint32 count = 9; + // Events dropped during aggregator cooldown. + uint32 suppressed_count = 10; + // Cumulative lifetime count (never resets). + uint32 total_count = 11; + // Distinct cmdline strings observed (sanitized of credentials). + repeated string sample_cmdlines = 12; + // SHA-256 of the binary for audit trail. + string binary_sha256 = 13; + // True if emitted by stale-flush rather than threshold. + bool persistent = 14; + // Denial category: "l4_deny", "l7_deny", "l7_audit", "ssrf". + string denial_stage = 15; + // Observed HTTP request patterns (from L7 inspection). + repeated L7RequestSample l7_request_samples = 16; + // True if L7 inspection was active during observation window. + bool l7_inspection_active = 17; +} + +// Count of denied actions grouped only by sanitized telemetry category. +message DenialGroupCount { + // Sanitized denial category, e.g. "connect_policy", "l7_policy", "ssrf". + string deny_group = 1; + // Number of denied actions in this category. + uint32 denied_count = 2; +} + +// Anonymous sandbox network activity counters. This intentionally excludes +// hosts, paths, binaries, raw deny reasons, sandbox IDs, and user content. +message NetworkActivitySummary { + // Total observed network activities in the current window. + uint32 network_activity_count = 1; + // Total denied actions in the current window. + uint32 denied_action_count = 2; + // Denied action counts grouped by sanitized category. + repeated DenialGroupCount denials_by_group = 3; +} + +// A proposed policy rule with rationale and approval status. +message PolicyChunk { + // Unique chunk identifier. + string id = 1; + // Approval status: "pending", "approved", "rejected". + string status = 2; + // Proposed network_policies map key. + string rule_name = 3; + // The proposed network policy rule. + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 4; + // Human-readable explanation of why this rule is proposed. + string rationale = 5; + // Security concerns flagged by analysis (empty if none). + string security_notes = 6; + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + float confidence = 7; + // IDs of denial summaries that led to this chunk. + repeated string denial_summary_ids = 8; + // Creation timestamp (ms since epoch). + int64 created_at_ms = 9; + // When the user approved/rejected (ms since epoch). 0 if undecided. + int64 decided_at_ms = 10; + // Recommendation stage: "initial" or "refined" (progressive L7 visibility). + string stage = 11; + // For stage="refined": the initial chunk this replaces. + string supersedes_chunk_id = 12; + // How many times this endpoint has been seen across denial flush cycles. + int32 hit_count = 13; + // First time this endpoint was proposed (ms since epoch). + int64 first_seen_ms = 14; + // Most recent time this endpoint was re-proposed (ms since epoch). + int64 last_seen_ms = 15; + // Binary path that triggered the denial (denormalized for display convenience). + string binary = 16; + // Validation verdict from gateway-side static checks (prover output). + // Free-form summary string for human consumption in the inbox card. + // Empty until the prover has run for this chunk. + string validation_result = 17; + // Operator-supplied free-form text accompanying a rejection. Populated + // when the reviewer rejects via `RejectDraftChunkRequest.reason`; surfaced + // back to the in-sandbox agent so it can revise the proposal. + // Empty for non-rejected chunks. + string rejection_reason = 18; +} + +// Notification that the draft policy was updated. +message DraftPolicyUpdate { + // Current draft version. + uint64 draft_version = 1; + // Number of new chunks added in this update. + uint32 new_chunks = 2; + // Total pending chunks awaiting approval. + uint32 total_pending = 3; + // Brief description of what changed. + string summary = 4; +} + +// Submit analysis results from sandbox to gateway. +message SubmitPolicyAnalysisRequest { + // Aggregated denial summaries. + repeated DenialSummary summaries = 1; + // Proposed policy chunks (validated by sandbox OPA engine). + repeated PolicyChunk proposed_chunks = 2; + // Analysis mode. `mechanistic` is the observation-driven path from the + // denial aggregator — chunks targeting the same host|port|binary fold + // into one row with hit_count incremented. `agent_authored` is an + // intentional proposal from an in-sandbox agent — each submission lands + // as its own chunk so the redraft-after-rejection loop has a stable id + // to watch. Other values are treated as agent-style (no dedup) so a new + // mode does not silently collapse proposals. + string analysis_mode = 3; + // Sandbox name. + string name = 4; + // Anonymous network activity counters. + repeated NetworkActivitySummary network_activity_summaries = 5; +} + +message SubmitPolicyAnalysisResponse { + // Number of chunks accepted by the gateway. + uint32 accepted_chunks = 1; + // Number of chunks rejected by gateway validation. + uint32 rejected_chunks = 2; + // Reasons for each rejected chunk. + repeated string rejection_reasons = 3; + // Server-assigned chunk IDs for the accepted chunks, in submission order. + // Agents use these to watch proposal state via policy.local's + // GET /v1/proposals/{id} and /wait endpoints. + repeated string accepted_chunk_ids = 4; +} + +// Get draft policy for a sandbox. +message GetDraftPolicyRequest { + // Sandbox name. + string name = 1; + // Optional status filter: "pending", "approved", "rejected", or "" for all. + string status_filter = 2; +} + +message GetDraftPolicyResponse { + // Draft policy chunks. + repeated PolicyChunk chunks = 1; + // LLM-generated summary of all analysis (empty in mechanistic mode). + string rolling_summary = 2; + // Current draft version. + uint64 draft_version = 3; + // When the last analysis completed (ms since epoch). + int64 last_analyzed_at_ms = 4; +} + +// Approve a single draft chunk. +message ApproveDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to approve. + string chunk_id = 2; +} + +message ApproveDraftChunkResponse { + // New policy version after merge. + uint32 policy_version = 1; + // SHA-256 hash of the new policy. + string policy_hash = 2; +} + +// Reject a single draft chunk. +message RejectDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to reject. + string chunk_id = 2; + // Optional reason for rejection (fed to LLM context in future analysis). + string reason = 3; +} + +message RejectDraftChunkResponse {} + +// Approve all pending chunks. +message ApproveAllDraftChunksRequest { + // Sandbox name. + string name = 1; + // Include chunks with security_notes (default false: skips them). + bool include_security_flagged = 2; +} + +message ApproveAllDraftChunksResponse { + // New policy version after merge. + uint32 policy_version = 1; + // SHA-256 hash of the new policy. + string policy_hash = 2; + // Number of chunks approved. + uint32 chunks_approved = 3; + // Number of chunks skipped (security-flagged). + uint32 chunks_skipped = 4; +} + +// Edit a pending chunk in-place. +message EditDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to edit. + string chunk_id = 2; + // The modified rule (replaces existing proposed_rule). + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; +} + +message EditDraftChunkResponse {} + +// Reverse an approval (remove merged rule from active policy). +message UndoDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to undo. + string chunk_id = 2; +} + +message UndoDraftChunkResponse { + // New policy version after removal. + uint32 policy_version = 1; + // SHA-256 hash of the updated policy. + string policy_hash = 2; +} + +// Clear all pending draft chunks for a sandbox. +message ClearDraftChunksRequest { + // Sandbox name. + string name = 1; +} + +message ClearDraftChunksResponse { + // Number of chunks cleared. + uint32 chunks_cleared = 1; +} + +// Get decision history for a sandbox's draft policy. +message GetDraftHistoryRequest { + // Sandbox name. + string name = 1; +} + +message DraftHistoryEntry { + // Event timestamp (ms since epoch). + int64 timestamp_ms = 1; + // Event type: "denial_detected", "analysis_cycle", "approved", + // "rejected", "edited", "undone", "cleared". + string event_type = 2; + // Human-readable description. + string description = 3; + // Associated chunk ID (if applicable). + string chunk_id = 4; +} + +message GetDraftHistoryResponse { + // Chronological decision history. + repeated DraftHistoryEntry entries = 1; +} + +// Stored payload for a policy revision row in the generic objects table. +message PolicyRevisionPayload { + // Serialized policy contents. + openshell.sandbox.v1.SandboxPolicy policy = 1; + // Deterministic hash of the policy payload. + string hash = 2; + // Load error reported by the sandbox, if any. + string load_error = 3; + // When the policy version was reported as loaded (ms since epoch). 0 if unset. + int64 loaded_at_ms = 4; + // Immutable provenance supplied when this revision was created. + map provenance = 5; +} + +// Stored payload for a draft policy chunk row in the generic objects table. +message DraftChunkPayload { + // Proposed network_policies map key. + string rule_name = 1; + // Proposed network policy rule. + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 2; + // Human-readable explanation of why this rule is proposed. + string rationale = 3; + // Security concerns flagged by analysis (empty if none). + string security_notes = 4; + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + float confidence = 5; + // When the user approved/rejected (ms since epoch). 0 if undecided. + int64 decided_at_ms = 6; + // Denormalized endpoint host for dedup and display. + string host = 7; + // Denormalized endpoint port for dedup and display. + int32 port = 8; + // Binary path that triggered the denial. + string binary = 9; + // Current draft version for the owning sandbox. + int64 draft_version = 10; + // Gateway prover verdict for this chunk; empty until prover runs. + // Mirrors PolicyChunk.validation_result. + string validation_result = 11; + // Operator-supplied free-form rejection text; empty for non-rejected + // chunks. Mirrors PolicyChunk.rejection_reason. + string rejection_reason = 12; +} + +// Internal stored policy revision row materialized from the generic objects table. +message StoredPolicyRevision { + string id = 1; + string sandbox_id = 2; + int64 version = 3; + bytes policy_payload = 4; + string policy_hash = 5; + string status = 6; + optional string load_error = 7; + int64 created_at_ms = 8; + optional int64 loaded_at_ms = 9; + map provenance = 10; +} + +// Internal stored draft chunk row materialized from the generic objects table. +message StoredDraftChunk { + string id = 1; + string sandbox_id = 2; + int64 draft_version = 3; + string status = 4; + string rule_name = 5; + bytes proposed_rule = 6; + string rationale = 7; + string security_notes = 8; + double confidence = 9; + int64 created_at_ms = 10; + optional int64 decided_at_ms = 11; + string host = 12; + int32 port = 13; + string binary = 14; + int32 hit_count = 15; + int64 first_seen_ms = 16; + int64 last_seen_ms = 17; + // Gateway prover verdict; empty until the prover runs. See PolicyChunk. + string validation_result = 18; + // Operator-supplied free-form rejection text. See PolicyChunk. + string rejection_reason = 19; +} diff --git a/third_party/openshell/v0.0.85/proto/options.proto b/third_party/openshell/v0.0.85/proto/options.proto new file mode 100644 index 00000000000..ca0764cc3ff --- /dev/null +++ b/third_party/openshell/v0.0.85/proto/options.proto @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.options.v1; + +import "google/protobuf/descriptor.proto"; + +// Marks a protobuf field whose value must not cross generic observation or +// extension boundaries such as gateway interceptors. +extend google.protobuf.FieldOptions { + bool secret = 50001; +} diff --git a/third_party/openshell/v0.0.85/proto/sandbox.proto b/third_party/openshell/v0.0.85/proto/sandbox.proto new file mode 100644 index 00000000000..8a5a5933346 --- /dev/null +++ b/third_party/openshell/v0.0.85/proto/sandbox.proto @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.sandbox.v1; + +// Sandbox-supervisor configuration and policy messages. +// +// Conventions: +// - This file owns messages exchanged between the gateway and the sandbox +// supervisor/runtime. +// - Public sandbox resource types live in `openshell.proto`. +// - Internal compute-driver sandbox observation types live in `compute_driver.proto`. + +// Sandbox security policy configuration. +message SandboxPolicy { + // Policy version. + uint32 version = 1; + // Filesystem access policy. + FilesystemPolicy filesystem = 2; + // Landlock configuration. + LandlockPolicy landlock = 3; + // Process execution policy. + ProcessPolicy process = 4; + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + map network_policies = 5; +} + +// Filesystem access policy. +message FilesystemPolicy { + // Automatically include the workdir as read-write. + bool include_workdir = 1; + // Read-only directory allow list. + repeated string read_only = 2; + // Read-write directory allow list. + repeated string read_write = 3; +} + +// Landlock policy configuration. +message LandlockPolicy { + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + string compatibility = 1; +} + +// Process execution policy. +message ProcessPolicy { + // User name to run the sandboxed process as. + string run_as_user = 1; + // Group name to run the sandboxed process as. + string run_as_group = 2; +} + +// A named network access policy rule. +message NetworkPolicyRule { + // Human-readable name for this policy rule. + string name = 1; + // Allowed endpoint (host:port) pairs. + repeated NetworkEndpoint endpoints = 2; + // Allowed binary identities. + repeated NetworkBinary binaries = 3; +} + +// A network endpoint (host + port) with optional L7 inspection config. +message NetworkEndpoint { + // Hostname or host glob pattern. Exact match is case-insensitive. + // Glob patterns use "." as delimiter: "*.example.com" matches a single + // subdomain label, "**.example.com" matches across labels. + string host = 1; + // Single port (backwards compat). Use `ports` for multiple ports. + // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. + uint32 port = 2; + // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + string protocol = 3; + // TLS handling: "terminate" or "passthrough" (default). + string tls = 4; + // Enforcement mode: "enforce" or "audit" (default). + string enforcement = 5; + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + string access = 6; + // Explicit L7 rules (mutually exclusive with access). + repeated L7Rule rules = 7; + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + repeated string allowed_ips = 8; + // Multiple ports. When non-empty, this endpoint covers all listed ports. + // If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`. + // If both are set, `ports` takes precedence. + repeated uint32 ports = 9; + // Explicit L7 deny rules. When present, requests matching any deny rule + // are blocked even if they match an allow rule or access preset. + // Deny rules take precedence over allow rules. + repeated L7DenyRule deny_rules = 10; + // When true, percent-encoded '/' (%2F) is preserved in path segments + // rather than rejected by the L7 path canonicalizer. Required for + // upstreams like GitLab that embed %2F in namespaced resource paths. + // Defaults to false (strict). + bool allow_encoded_slash = 11; + // GraphQL persisted-query behavior for hash-only/saved-query requests: + // "deny" (default) or "allow_registered". + string persisted_queries = 12; + // Trusted GraphQL persisted-query registry keyed by hash or service-specific ID. + // Only used when persisted_queries is "allow_registered". + map graphql_persisted_queries = 13; + // Maximum GraphQL request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + uint32 graphql_max_body_bytes = 14; + // Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs. + // Example: use path "/graphql" for protocol "graphql" and "/repos/**" for + // protocol "rest" when both surfaces live under api.example.com:443. + // Empty means all paths. + string path = 15; + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside client-to-server WebSocket text messages after an allowed HTTP 101 + // upgrade. Defaults to false. + bool websocket_credential_rewrite = 16; + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside supported textual HTTP request bodies before forwarding upstream. + // Defaults to false. + bool request_body_credential_rewrite = 17; + // Internal provenance marker for policy-advisor generated endpoints. + // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless + // they are converted through an explicit user-authored policy path. + bool advisor_proposed = 18; + // Proxy-side credential signing mode: "sigv4" for AWS SigV4 re-signing. + // When set, the proxy strips the client's Authorization header and computes + // a fresh SigV4 signature using real credentials from the provider. + string credential_signing = 19; + // AWS signing service name override. Required when credential_signing is + // "sigv4" — e.g. "bedrock" for bedrock-runtime endpoints. + string signing_service = 20; + // AWS region override for SigV4 signing. When set, takes precedence over + // hostname-based region extraction. Required for non-standard endpoints. + string signing_region = 21; + // Maximum JSON-RPC-over-HTTP request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + uint32 json_rpc_max_body_bytes = 22; + // MCP-only policy and inspection options. Only used when protocol is "mcp". + McpOptions mcp = 23; +} + +// MCP options are grouped so MCP-specific policy can grow without adding more +// top-level NetworkEndpoint fields. Current enforcement targets the active +// 2025-11-25 Streamable HTTP/tools behavior, while preserving space for +// version-profile policy if OpenShell adopts 2026-07-28 draft behavior later. +// +// Planned policy extensions should use OpenShell-owned static definitions for +// MCP method/version profiles rather than treating dependency enums as the +// policy contract. Candidate profile checks include request metadata/header +// validation, response/SSE introspection, trusted annotation handling, +// resultType/cache metadata validation, x-mcp-header tool-definition checks, +// and subscriptions/listen handling. +// +// Sources: +// - https://modelcontextprotocol.io/specification/2025-11-25/server/tools +// - https://modelcontextprotocol.io/specification/draft/changelog +// - https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http +// - https://modelcontextprotocol.io/specification/draft/server/tools +message McpOptions { + // Hardening boundary for tools/call params.name. When unset or true, the + // supervisor enforces the MCP recommended tool-name syntax + // ^[A-Za-z0-9_.-]{1,128}$ before policy evaluation. Set false only for + // compatibility with servers that intentionally use non-recommended names. + // + // Source: + // - https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names + optional bool strict_tool_names = 1; + // Method-layer default for MCP endpoints. When true, OpenShell allows parsed + // MCP-family methods at the method layer unless a tool-name policy narrows + // tools/call. When unset or false, explicit method rules are required. + optional bool allow_all_known_mcp_methods = 2; +} + +// Trusted GraphQL operation classification. +message GraphqlOperation { + // Operation type: "query", "mutation", or "subscription". + string operation_type = 1; + // Operation name, if known. + string operation_name = 2; + // Root field names selected by the operation. + repeated string fields = 3; +} + +// An L7 deny rule that blocks specific requests. +// Mirrors L7Allow — same fields, same matching semantics, inverted effect. +// Deny rules are evaluated after allow rules and take precedence. +message L7DenyRule { + // Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or + // "*" for any when supported by the protocol. + string method = 1; + // URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; + // Query parameter matcher map (REST). + // Same semantics as L7Allow.query. + map query = 4; + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + string operation_type = 5; + // GraphQL operation name glob. "*" matches any operation name. + string operation_name = 6; + // GraphQL root field globs. Deny rules match when any selected root field + // matches any configured glob. + repeated string fields = 7; + reserved 8; + // MCP params matcher map. Currently only params.name is supported for + // tools/call filtering. Generic protocol "json-rpc" rejects params matchers. + map params = 9; +} + +// An L7 policy rule (allow-only). +message L7Rule { + L7Allow allow = 1; +} + +// Allowed action definition for L7 rules. +message L7Allow { + // Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or + // "*" for any when supported by the protocol. + string method = 1; + // URL path glob pattern (REST): "/repos/**", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; + // Query parameter matcher map (REST). + // Key is the decoded query parameter name (case-sensitive). + // Value supports either a single glob (`glob`) or a list (`any`). + map query = 4; + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + string operation_type = 5; + // GraphQL operation name glob. "*" matches any operation name. + string operation_name = 6; + // GraphQL root field globs. Allow rules match only when every selected root + // field matches one of the configured globs. Omit to match all fields. + repeated string fields = 7; + reserved 8; + // MCP params matcher map. Currently only params.name is supported for + // tools/call filtering. Generic protocol "json-rpc" rejects params matchers. + map params = 9; +} + +// Query value matcher for one query parameter key. +message L7QueryMatcher { + // Single glob pattern. + string glob = 1; + // Any-of glob patterns. + repeated string any = 2; +} + +// A binary identity for network policy matching. +message NetworkBinary { + string path = 1; + // Deprecated: the harness concept has been removed. This field is ignored. + bool harness = 2 [deprecated = true]; +} + +// Request to get sandbox settings by sandbox ID. +message GetSandboxConfigRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Request to get gateway-global settings. +message GetGatewayConfigRequest {} + +// Response containing gateway-global settings. +message GetGatewayConfigResponse { + // Gateway-global settings map excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty SettingValue. + map settings = 1; + // Monotonically increasing revision for gateway-global settings. + uint64 settings_revision = 2; +} + +// Scope that currently controls a setting. +enum SettingScope { + SETTING_SCOPE_UNSPECIFIED = 0; + SETTING_SCOPE_SANDBOX = 1; + SETTING_SCOPE_GLOBAL = 2; +} + +// Type-aware setting value for sandbox/gateway settings. +message SettingValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + bytes bytes_value = 4; + } +} + +// Effective setting value and the scope it was resolved from. +message EffectiveSetting { + SettingValue value = 1; + SettingScope scope = 2; +} + +// Source used for the policy payload in GetSandboxConfigResponse. +enum PolicySource { + POLICY_SOURCE_UNSPECIFIED = 0; + POLICY_SOURCE_SANDBOX = 1; + POLICY_SOURCE_GLOBAL = 2; +} + +// Response containing effective sandbox settings and policy. +message GetSandboxConfigResponse { + // The sandbox policy configuration. + SandboxPolicy policy = 1; + // Current policy version (monotonically increasing per sandbox). + uint32 version = 2; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 3; + // Effective settings resolved for this sandbox, excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty EffectiveSetting.value. + map settings = 4; + // Fingerprint for effective config (policy + settings). Changes when any effective input changes. + uint64 config_revision = 5; + // Source of the policy payload for this response. + PolicySource policy_source = 6; + // When policy_source is GLOBAL, the version of the global policy revision. + // Zero when no global policy is active or when policy_source is SANDBOX. + uint32 global_policy_version = 7; + // Fingerprint for provider credential inputs attached to this sandbox. + // Changes when attached provider names or attached provider records change. + uint64 provider_env_revision = 8; +}