Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

477 lines
16 KiB
TypeScript

/**
* Credential-proxy mode for spawned coding sub-agents (#11536 E3).
*
* Contract under test:
* - resolveOrchestratorCredentialProxyConfig gates on BOTH URL + TOKEN.
* - applyCredentialProxyEnv deletes every raw VCS PAT, injects the proxy config
* + a git credential helper, and (strict mode) refuses when a raw PAT is
* present in the parent env.
* - Through the REAL AcpService.buildEnv spawn seam, the child env in proxy
* mode contains no raw PAT and does carry the helper wiring.
* - The materialized git credential helper, run by the REAL node binary against
* a REAL (mock) proxy HTTP server, returns broker-minted credentials with a
* valid HMAC signature and no PAT — and fails closed for off-allowlist hosts.
*/
import { spawn } from "node:child_process";
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import {
buildCredentialProxyCanonicalString,
credentialProxyBodyHash,
signCredentialProxyRequest,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
AcpJsonRpcMessage,
ApprovalPreset,
} from "../../src/services/types.js";
type NativeEventHandler = (
event: AcpJsonRpcMessage,
sessionId?: string,
) => void;
type NativeOptions = {
command: string;
cwd: string;
approvalPreset: ApprovalPreset;
timeoutMs?: number;
terminal?: boolean;
env?: NodeJS.ProcessEnv;
onEvent?: NativeEventHandler;
onStderr?: (chunk: string) => void;
};
type MockNativeClient = {
opts: NativeOptions;
eventHandler?: NativeEventHandler;
start: ReturnType<typeof vi.fn>;
createSession: ReturnType<typeof vi.fn>;
prompt: ReturnType<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
closeSession: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
setEventHandler: (handler: NativeEventHandler | undefined) => void;
setTimeoutMs: (timeoutMs: number | undefined) => void;
};
type NativeMockState = {
NativeAcpClient?: new (opts: NativeOptions) => MockNativeClient;
instances: MockNativeClient[];
};
function getNativeMockState(): NativeMockState {
const g = globalThis as typeof globalThis & {
__credProxyNativeMock?: NativeMockState;
};
g.__credProxyNativeMock ??= { instances: [] };
return g.__credProxyNativeMock;
}
const nativeClientMock = getNativeMockState();
vi.mock("../../src/services/acp-native-transport.js", () => {
const state = getNativeMockState();
state.NativeAcpClient = class MockNativeAcpClient
implements MockNativeClient
{
opts: NativeOptions;
eventHandler?: NativeEventHandler;
start = vi.fn(async () => undefined);
createSession = vi.fn(async () => ({
sessionId: "protocol-session",
agentSessionId: "agent-session",
}));
prompt = vi.fn(async () => ({ stopReason: "end_turn" }));
cancel = vi.fn(async () => undefined);
closeSession = vi.fn(async () => undefined);
close = vi.fn(async () => undefined);
constructor(opts: NativeOptions) {
this.opts = opts;
this.eventHandler = opts.onEvent;
getNativeMockState().instances.push(this);
}
setEventHandler(handler: NativeEventHandler | undefined) {
this.eventHandler = handler;
this.opts.onEvent = handler;
}
setTimeoutMs(timeoutMs: number | undefined) {
this.opts.timeoutMs = timeoutMs;
}
};
return { NativeAcpClient: state.NativeAcpClient };
});
// Baseline git capture uses execFile; make it a no-op so spawns don't hang.
// The REAL `spawn` is preserved for the end-to-end helper test below.
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
exec: vi.fn(),
execFile: vi.fn(
(
_file: string,
_args: string[],
_opts: unknown,
cb?: (err: Error | null, stdout: string, stderr: string) => void,
) => {
const callback = typeof _opts === "function" ? _opts : cb;
if (typeof callback === "function") {
callback(new Error("git unavailable in test"), "", "");
}
},
),
execFileSync: vi.fn(),
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "" })),
};
});
import { AcpService } from "../../src/services/acp-service.js";
import {
_resetCredentialProxyHelperCache,
applyCredentialProxyEnv,
CREDENTIAL_PROXY_GIT_HOSTS_KEY,
GIT_CREDENTIAL_PROXY_HELPER_SOURCE,
materializeGitCredentialHelper,
resolveOrchestratorCredentialProxyConfig,
} from "../../src/services/credential-proxy-env.js";
const PROXY_URL = "https://cred-proxy.test.invalid/broker";
const PROXY_TOKEN = "agent-scoped-handle-xyz";
const SIGNING_KEY = "shared-hmac-secret";
const RAW_PAT = "ghp_RAW_PAT_DO_NOT_LEAK_0123456789";
const MANAGED_ENV_KEYS = [
"ELIZA_CREDENTIAL_PROXY_URL",
"ELIZA_CREDENTIAL_PROXY_TOKEN",
"ELIZA_CREDENTIAL_PROXY_SIGNING_KEY",
"ELIZA_CREDENTIAL_PROXY_STRICT",
"ELIZA_CREDENTIAL_PROXY_ROUTES",
"ELIZA_CONFIG_PATH",
"GITHUB_TOKEN",
"GH_TOKEN",
"GH_ENTERPRISE_TOKEN",
"GITHUB_PAT",
] as const;
let savedEnv: Record<string, string | undefined>;
type MockLogger = {
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
function runtime(settings: Record<string, string | undefined> = {}) {
const values = { ELIZA_ACP_TRANSPORT: "native", ...settings };
const logger: MockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return {
runtime: {
logger,
getSetting: vi.fn((key: string) => values[key]),
services: new Map<string, unknown[]>(),
} as never,
logger,
};
}
function firstNativeClient(): MockNativeClient {
const client = nativeClientMock.instances[0];
if (!client) throw new Error("expected NativeAcpClient to be constructed");
return client;
}
function enableProxy(strict = false): void {
process.env.ELIZA_CREDENTIAL_PROXY_URL = PROXY_URL;
process.env.ELIZA_CREDENTIAL_PROXY_TOKEN = PROXY_TOKEN;
process.env.ELIZA_CREDENTIAL_PROXY_SIGNING_KEY = SIGNING_KEY;
if (strict) process.env.ELIZA_CREDENTIAL_PROXY_STRICT = "1";
}
async function spawnAndCaptureEnv(): Promise<NodeJS.ProcessEnv> {
const { runtime: rt } = runtime();
const service = new AcpService(rt);
await service.start();
await service.spawnSession({
name: "codex-cp",
agentType: "codex",
workdir: "/tmp/acp-cp-test",
});
const env = firstNativeClient().opts.env ?? {};
await service.stop();
return env;
}
beforeEach(() => {
nativeClientMock.instances.length = 0;
_resetCredentialProxyHelperCache();
savedEnv = {};
for (const key of MANAGED_ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
process.env.ELIZA_CONFIG_PATH = "/nonexistent/cred-proxy-test/eliza.json";
});
afterEach(() => {
for (const key of MANAGED_ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("resolveOrchestratorCredentialProxyConfig (mode gate)", () => {
it("is ON only when both URL + TOKEN are set", () => {
expect(resolveOrchestratorCredentialProxyConfig()).toBeUndefined();
process.env.ELIZA_CREDENTIAL_PROXY_URL = PROXY_URL;
expect(resolveOrchestratorCredentialProxyConfig()).toBeUndefined();
process.env.ELIZA_CREDENTIAL_PROXY_TOKEN = PROXY_TOKEN;
const cfg = resolveOrchestratorCredentialProxyConfig();
expect(cfg?.url).toBe(PROXY_URL);
expect(cfg?.token).toBe(PROXY_TOKEN);
});
});
describe("applyCredentialProxyEnv (pure env rewrite)", () => {
it("deletes raw PATs and injects proxy config + git helper wiring", () => {
enableProxy();
const cfg = resolveOrchestratorCredentialProxyConfig();
if (!cfg) throw new Error("expected proxy config");
const env: NodeJS.ProcessEnv = {
GITHUB_TOKEN: RAW_PAT,
GH_TOKEN: RAW_PAT,
PATH: "/usr/bin",
};
applyCredentialProxyEnv(env, cfg, "/opt/node");
const dump = JSON.stringify(env);
expect(dump).not.toContain(RAW_PAT);
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(env.GH_TOKEN).toBeUndefined();
expect(env.ELIZA_CREDENTIAL_PROXY_URL).toBe(PROXY_URL);
expect(env.ELIZA_CREDENTIAL_PROXY_TOKEN).toBe(PROXY_TOKEN);
expect(env.ELIZA_CREDENTIAL_PROXY_SIGNING_KEY).toBe(SIGNING_KEY);
expect(env[CREDENTIAL_PROXY_GIT_HOSTS_KEY]).toBe(
"github.com,api.github.com",
);
expect(env.GIT_CONFIG_COUNT).toBe("2");
expect(env.GIT_CONFIG_KEY_0).toBe("credential.helper");
expect(env.GIT_CONFIG_VALUE_0).toBe("");
expect(env.GIT_CONFIG_KEY_1).toBe("credential.helper");
expect(env.GIT_CONFIG_VALUE_1).toContain("'/opt/node'");
expect(env.GIT_CONFIG_VALUE_1).toContain("git-credential-proxy-helper.mjs");
});
it("strict mode throws when a raw PAT is present in the scanned env", () => {
enableProxy(true);
const cfg = resolveOrchestratorCredentialProxyConfig();
if (!cfg) throw new Error("expected proxy config");
expect(cfg.strict).toBe(true);
expect(() =>
applyCredentialProxyEnv({}, cfg, "/opt/node", {
strictScanEnv: { GITHUB_TOKEN: RAW_PAT },
}),
).toThrow(/GITHUB_TOKEN/);
});
it("non-strict mode does not throw; it just scrubs", () => {
enableProxy(false);
const cfg = resolveOrchestratorCredentialProxyConfig();
if (!cfg) throw new Error("expected proxy config");
const env: NodeJS.ProcessEnv = { GH_TOKEN: RAW_PAT };
expect(() => applyCredentialProxyEnv(env, cfg, "/opt/node")).not.toThrow();
expect(env.GH_TOKEN).toBeUndefined();
});
});
describe("AcpService.buildEnv (real spawn seam)", () => {
it("child env in proxy mode carries no raw PAT and has the helper wiring", async () => {
enableProxy();
// A PAT on the box must never reach the child, and (non-strict) must not
// block the spawn.
process.env.GITHUB_TOKEN = RAW_PAT;
const env = await spawnAndCaptureEnv();
expect(JSON.stringify(env)).not.toContain(RAW_PAT);
expect(env.GITHUB_TOKEN).toBeUndefined();
expect(env.ELIZA_CREDENTIAL_PROXY_URL).toBe(PROXY_URL);
expect(env.GIT_CONFIG_VALUE_1).toContain("git-credential-proxy-helper.mjs");
});
it("strict mode refuses to spawn when a raw PAT is on the box", async () => {
enableProxy(true);
process.env.GITHUB_TOKEN = RAW_PAT;
const { runtime: rt } = runtime();
const service = new AcpService(rt);
await service.start();
await expect(
service.spawnSession({
name: "codex-strict",
agentType: "codex",
workdir: "/tmp/acp-cp-strict",
}),
).rejects.toThrow(/GITHUB_TOKEN/);
await service.stop();
});
});
describe("git credential helper (signing lockstep with core)", () => {
it("materializes an executable helper and produces the core canonical signature", () => {
const helperPath = materializeGitCredentialHelper();
const onDisk = readFileSync(helperPath, "utf8");
expect(onDisk).toBe(GIT_CREDENTIAL_PROXY_HELPER_SOURCE);
// The helper hard-codes the same canonical scheme as core. Recompute the
// signature the helper would emit for a fixed request and assert it equals
// core's signCredentialProxyRequest — the lockstep guard.
const body = JSON.stringify({
host: "github.com",
protocol: "https",
path: "o/r",
});
const canonical = buildCredentialProxyCanonicalString({
method: "POST",
targetHost: "github.com",
pathAndSearch: "/git-credential",
timestamp: "1700000000",
bodyHash: credentialProxyBodyHash(new TextEncoder().encode(body)),
});
const helperSig = `v1=${createHmac("sha256", SIGNING_KEY).update(canonical).digest("hex")}`;
expect(helperSig).toBe(signCredentialProxyRequest(SIGNING_KEY, canonical));
});
});
// --- end-to-end: real node runs the helper against a real mock proxy --------
type ProxyRequest = {
headers: Record<string, string | string[] | undefined>;
body: string;
};
function startMockProxy(): Promise<{
server: Server;
origin: string;
received: ProxyRequest[];
}> {
const received: ProxyRequest[] = [];
return new Promise((resolve) => {
const server = createServer((req, res) => {
let raw = "";
req.on("data", (c) => {
raw += c;
});
req.on("end", () => {
if (req.url !== "/git-credential" || req.method !== "POST") {
res.writeHead(404).end();
return;
}
received.push({ headers: req.headers, body: raw });
// Verify the HMAC signature the helper sent.
const parsed = JSON.parse(raw) as { host: string };
const canonical = buildCredentialProxyCanonicalString({
method: "POST",
targetHost: parsed.host,
pathAndSearch: "/git-credential",
timestamp: String(req.headers["x-eliza-proxy-timestamp"]),
bodyHash: credentialProxyBodyHash(new TextEncoder().encode(raw)),
});
const expected = signCredentialProxyRequest(SIGNING_KEY, canonical);
if (req.headers["x-eliza-proxy-signature"] !== expected) {
res.writeHead(401).end("bad signature");
return;
}
res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
username: "x-access-token",
password: "broker-minted-ephemeral-token",
}),
);
});
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
resolve({ server, origin: `http://127.0.0.1:${port}`, received });
});
});
}
function runHelper(
helperPath: string,
env: NodeJS.ProcessEnv,
stdin: string,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [helperPath, "get"], {
env: { ...process.env, ...env },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => {
stdout += c;
});
child.stderr.on("data", (c) => {
stderr += c;
});
child.on("close", (code) => resolve({ code, stdout, stderr }));
child.stdin.write(stdin);
child.stdin.end();
});
}
describe("git credential helper (real end-to-end against a mock proxy)", () => {
it("returns broker-minted credentials with a valid signature and no PAT", async () => {
const { server, origin, received } = await startMockProxy();
try {
const helperPath = materializeGitCredentialHelper();
const result = await runHelper(
helperPath,
{
ELIZA_CREDENTIAL_PROXY_URL: origin,
ELIZA_CREDENTIAL_PROXY_TOKEN: PROXY_TOKEN,
ELIZA_CREDENTIAL_PROXY_SIGNING_KEY: SIGNING_KEY,
ELIZA_CREDENTIAL_PROXY_GIT_HOSTS: "github.com,api.github.com",
},
"protocol=https\nhost=github.com\npath=o/r\n\n",
);
expect(result.stderr).toBe("");
expect(result.code).toBe(0);
expect(result.stdout).toContain("username=x-access-token");
expect(result.stdout).toContain("password=broker-minted-ephemeral-token");
// The proxy received the scoped handle, never a raw PAT.
expect(received).toHaveLength(1);
expect(received[0].headers.authorization).toBe(`Bearer ${PROXY_TOKEN}`);
expect(JSON.stringify(received[0].headers)).not.toContain(RAW_PAT);
} finally {
server.close();
}
});
it("fails closed for an off-allowlist host (no proxy call, non-zero exit)", async () => {
const { server, origin, received } = await startMockProxy();
try {
const helperPath = materializeGitCredentialHelper();
const result = await runHelper(
helperPath,
{
ELIZA_CREDENTIAL_PROXY_URL: origin,
ELIZA_CREDENTIAL_PROXY_TOKEN: PROXY_TOKEN,
ELIZA_CREDENTIAL_PROXY_SIGNING_KEY: SIGNING_KEY,
ELIZA_CREDENTIAL_PROXY_GIT_HOSTS: "github.com",
},
"protocol=https\nhost=evil.example.com\npath=o/r\n\n",
);
expect(result.code).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("allowlist");
expect(received).toHaveLength(0);
} finally {
server.close();
}
});
});