diff --git a/src/discord/monitor/dm-command-auth.test.ts b/src/discord/monitor/dm-command-auth.test.ts index ce92b06fb7b..769d1d61666 100644 --- a/src/discord/monitor/dm-command-auth.test.ts +++ b/src/discord/monitor/dm-command-auth.test.ts @@ -8,31 +8,27 @@ describe("resolveDiscordDmCommandAccess", () => { tag: "alice#0001", }; - it("allows open DMs and keeps command auth enabled without allowlist entries", async () => { - const result = await resolveDiscordDmCommandAccess({ + async function resolveOpenDmAccess(configuredAllowFrom: string[]) { + return await resolveDiscordDmCommandAccess({ accountId: "default", dmPolicy: "open", - configuredAllowFrom: [], + configuredAllowFrom, sender, allowNameMatching: false, useAccessGroups: true, readStoreAllowFrom: async () => [], }); + } + + it("allows open DMs and keeps command auth enabled without allowlist entries", async () => { + const result = await resolveOpenDmAccess([]); expect(result.decision).toBe("allow"); expect(result.commandAuthorized).toBe(true); }); it("marks command auth true when sender is allowlisted", async () => { - const result = await resolveDiscordDmCommandAccess({ - accountId: "default", - dmPolicy: "open", - configuredAllowFrom: ["discord:123"], - sender, - allowNameMatching: false, - useAccessGroups: true, - readStoreAllowFrom: async () => [], - }); + const result = await resolveOpenDmAccess(["discord:123"]); expect(result.decision).toBe("allow"); expect(result.commandAuthorized).toBe(true); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 1c0e8f029f0..6284509073f 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -168,6 +168,18 @@ function getLastDispatchCtx(): return params?.ctx; } +async function runProcessDiscordMessage(ctx: unknown): Promise { + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); +} + +async function runInPartialStreamMode(): Promise { + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + await runProcessDiscordMessage(ctx); +} + describe("processDiscordMessage ack reactions", () => { it("skips ack reactions for group-mentions when mentions are not required", async () => { const ctx = await createBaseContext({ @@ -543,12 +555,7 @@ describe("processDiscordMessage draft streaming", () => { return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; }); - const ctx = await createBaseContext({ - discordConfig: { streamMode: "partial" }, - }); - - // oxlint-disable-next-line typescript/no-explicit-any - await processDiscordMessage(ctx as any); + await runInPartialStreamMode(); const updates = draftStream.update.mock.calls.map((call) => call[0]); for (const text of updates) { @@ -567,12 +574,7 @@ describe("processDiscordMessage draft streaming", () => { return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; }); - const ctx = await createBaseContext({ - discordConfig: { streamMode: "partial" }, - }); - - // oxlint-disable-next-line typescript/no-explicit-any - await processDiscordMessage(ctx as any); + await runInPartialStreamMode(); expect(draftStream.update).not.toHaveBeenCalled(); }); diff --git a/src/discord/monitor/native-command.model-picker.test.ts b/src/discord/monitor/native-command.model-picker.test.ts index e8277757620..2932dc9dbf5 100644 --- a/src/discord/monitor/native-command.model-picker.test.ts +++ b/src/discord/monitor/native-command.model-picker.test.ts @@ -167,6 +167,24 @@ async function runSubmitButton(params: { return submitInteraction; } +async function runModelSelect(params: { + context: ModelPickerContext; + data?: PickerSelectData; + userId?: string; + values?: string[]; +}) { + const select = createDiscordModelPickerFallbackSelect(params.context); + const selectInteraction = createInteraction({ + userId: params.userId ?? "owner", + values: params.values ?? ["gpt-4o"], + }); + await select.run( + selectInteraction as unknown as PickerSelectInteraction, + params.data ?? createModelsViewSelectData(), + ); + return selectInteraction; +} + function expectDispatchedModelSelection(params: { dispatchSpy: { mock: { calls: Array<[unknown]> } }; model: string; @@ -270,15 +288,7 @@ describe("Discord model picker interactions", () => { .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") .mockResolvedValue({} as never); - const select = createDiscordModelPickerFallbackSelect(context); - const selectInteraction = createInteraction({ - userId: "owner", - values: ["gpt-4o"], - }); - - const selectData = createModelsViewSelectData(); - - await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData); + const selectInteraction = await runModelSelect({ context }); expect(selectInteraction.update).toHaveBeenCalledTimes(1); expect(dispatchSpy).not.toHaveBeenCalled(); @@ -315,15 +325,7 @@ describe("Discord model picker interactions", () => { .spyOn(timeoutModule, "withTimeout") .mockRejectedValue(new Error("timeout")); - const select = createDiscordModelPickerFallbackSelect(context); - const selectInteraction = createInteraction({ - userId: "owner", - values: ["gpt-4o"], - }); - - const selectData = createModelsViewSelectData(); - - await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData); + await runModelSelect({ context }); const button = createDiscordModelPickerFallbackButton(context); const submitInteraction = createInteraction({ userId: "owner" }); diff --git a/src/discord/monitor/provider.lifecycle.test.ts b/src/discord/monitor/provider.lifecycle.test.ts index 22e8be6353f..961a8170dd7 100644 --- a/src/discord/monitor/provider.lifecycle.test.ts +++ b/src/discord/monitor/provider.lifecycle.test.ts @@ -143,6 +143,11 @@ describe("runDiscordGatewayLifecycle", () => { return { emitter, gateway }; } + async function emitGatewayOpenAndWait(emitter: EventEmitter, delayMs = 30000): Promise { + emitter.emit("debug", "WebSocket connection opened"); + await vi.advanceTimersByTimeAsync(delayMs); + } + it("cleans up thread bindings when exec approvals startup fails", async () => { const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } = @@ -260,12 +265,9 @@ describe("runDiscordGatewayLifecycle", () => { }); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce(async () => { - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(30000); - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(30000); - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(30000); + await emitGatewayOpenAndWait(emitter); + await emitGatewayOpenAndWait(emitter); + await emitGatewayOpenAndWait(emitter); }); const { lifecycleParams } = createLifecycleHarness({ gateway }); @@ -299,22 +301,17 @@ describe("runDiscordGatewayLifecycle", () => { }); getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter); waitForDiscordGatewayStopMock.mockImplementationOnce(async () => { - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(30000); + await emitGatewayOpenAndWait(emitter); // Successful reconnect (READY/RESUMED sets isConnected=true), then // quick drop before the HELLO timeout window finishes. gateway.isConnected = true; - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(10); + await emitGatewayOpenAndWait(emitter, 10); emitter.emit("debug", "WebSocket connection closed with code 1006"); gateway.isConnected = false; - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(30000); - - emitter.emit("debug", "WebSocket connection opened"); - await vi.advanceTimersByTimeAsync(30000); + await emitGatewayOpenAndWait(emitter); + await emitGatewayOpenAndWait(emitter); }); const { lifecycleParams } = createLifecycleHarness({ gateway }); diff --git a/src/discord/probe.ts b/src/discord/probe.ts index 8bbaa6bff67..358a3177812 100644 --- a/src/discord/probe.ts +++ b/src/discord/probe.ts @@ -38,24 +38,32 @@ async function fetchDiscordApplicationMe( timeoutMs: number, fetcher: typeof fetch, ): Promise<{ id?: string; flags?: number } | undefined> { + try { + const appResponse = await fetchDiscordApplicationMeResponse(token, timeoutMs, fetcher); + if (!appResponse || !appResponse.ok) { + return undefined; + } + return (await appResponse.json()) as { id?: string; flags?: number }; + } catch { + return undefined; + } +} + +async function fetchDiscordApplicationMeResponse( + token: string, + timeoutMs: number, + fetcher: typeof fetch, +): Promise { const normalized = normalizeDiscordToken(token); if (!normalized) { return undefined; } - try { - const res = await fetchWithTimeout( - `${DISCORD_API_BASE}/oauth2/applications/@me`, - { headers: { Authorization: `Bot ${normalized}` } }, - timeoutMs, - getResolvedFetch(fetcher), - ); - if (!res.ok) { - return undefined; - } - return (await res.json()) as { id?: string; flags?: number }; - } catch { - return undefined; - } + return await fetchWithTimeout( + `${DISCORD_API_BASE}/oauth2/applications/@me`, + { headers: { Authorization: `Bot ${normalized}` } }, + timeoutMs, + getResolvedFetch(fetcher), + ); } export function resolveDiscordPrivilegedIntentsFromFlags( @@ -198,17 +206,14 @@ export async function fetchDiscordApplicationId( timeoutMs: number, fetcher: typeof fetch = fetch, ): Promise { - const normalized = normalizeDiscordToken(token); - if (!normalized) { + if (!normalizeDiscordToken(token)) { return undefined; } try { - const res = await fetchWithTimeout( - `${DISCORD_API_BASE}/oauth2/applications/@me`, - { headers: { Authorization: `Bot ${normalized}` } }, - timeoutMs, - getResolvedFetch(fetcher), - ); + const res = await fetchDiscordApplicationMeResponse(token, timeoutMs, fetcher); + if (!res) { + return undefined; + } if (res.ok) { const json = (await res.json()) as { id?: string }; if (json?.id) { diff --git a/src/gateway/node-invoke-system-run-approval-match.test.ts b/src/gateway/node-invoke-system-run-approval-match.test.ts index 33234c2fd8d..a3713b970ab 100644 --- a/src/gateway/node-invoke-system-run-approval-match.test.ts +++ b/src/gateway/node-invoke-system-run-approval-match.test.ts @@ -19,6 +19,29 @@ function expectMismatch( expect(result.code).toBe(code); } +function expectV1BindingMatch(params: { + argv: string[]; + requestCommand: string; + commandArgv?: string[]; +}) { + const result = evaluateSystemRunApprovalMatch({ + argv: params.argv, + request: { + host: "node", + command: params.requestCommand, + commandArgv: params.commandArgv, + systemRunBinding: buildSystemRunApprovalBinding({ + argv: params.argv, + cwd: null, + agentId: null, + sessionKey: null, + }).binding, + }, + binding: defaultBinding, + }); + expect(result).toEqual({ ok: true }); +} + describe("evaluateSystemRunApprovalMatch", () => { test("rejects approvals that do not carry v1 binding", () => { const result = evaluateSystemRunApprovalMatch({ @@ -33,21 +56,10 @@ describe("evaluateSystemRunApprovalMatch", () => { }); test("enforces exact argv binding in v1 object", () => { - const result = evaluateSystemRunApprovalMatch({ + expectV1BindingMatch({ argv: ["echo", "SAFE"], - request: { - host: "node", - command: "echo SAFE", - systemRunBinding: buildSystemRunApprovalBinding({ - argv: ["echo", "SAFE"], - cwd: null, - agentId: null, - sessionKey: null, - }).binding, - }, - binding: defaultBinding, + requestCommand: "echo SAFE", }); - expect(result).toEqual({ ok: true }); }); test("rejects argv mismatch in v1 object", () => { @@ -124,21 +136,10 @@ describe("evaluateSystemRunApprovalMatch", () => { }); test("uses v1 binding even when legacy command text diverges", () => { - const result = evaluateSystemRunApprovalMatch({ + expectV1BindingMatch({ argv: ["echo", "SAFE"], - request: { - host: "node", - command: "echo STALE", - commandArgv: ["echo STALE"], - systemRunBinding: buildSystemRunApprovalBinding({ - argv: ["echo", "SAFE"], - cwd: null, - agentId: null, - sessionKey: null, - }).binding, - }, - binding: defaultBinding, + requestCommand: "echo STALE", + commandArgv: ["echo STALE"], }); - expect(result).toEqual({ ok: true }); }); }); diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 61d8be8a8a7..ecea8c47a25 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -352,6 +352,26 @@ function respondWorkspaceFileInvalid(respond: RespondFn, name: string, reason: s ); } +async function resolveWorkspaceFilePathOrRespond(params: { + respond: RespondFn; + workspaceDir: string; + name: string; +}): Promise< + | Exclude>, { kind: "invalid" }> + | undefined +> { + const resolvedPath = await resolveAgentWorkspaceFilePath({ + workspaceDir: params.workspaceDir, + name: params.name, + allowMissing: true, + }); + if (resolvedPath.kind === "invalid") { + respondWorkspaceFileInvalid(params.respond, params.name, resolvedPath.reason); + return undefined; + } + return resolvedPath; +} + function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void { respond( false, @@ -629,13 +649,12 @@ export const agentsHandlers: GatewayRequestHandlers = { } const { agentId, workspaceDir, name } = resolved; const filePath = path.join(workspaceDir, name); - const resolvedPath = await resolveAgentWorkspaceFilePath({ + const resolvedPath = await resolveWorkspaceFilePathOrRespond({ + respond, workspaceDir, name, - allowMissing: true, }); - if (resolvedPath.kind === "invalid") { - respondWorkspaceFileInvalid(respond, name, resolvedPath.reason); + if (!resolvedPath) { return; } if (resolvedPath.kind === "missing") { @@ -691,13 +710,12 @@ export const agentsHandlers: GatewayRequestHandlers = { const { agentId, workspaceDir, name } = resolved; await fs.mkdir(workspaceDir, { recursive: true }); const filePath = path.join(workspaceDir, name); - const resolvedPath = await resolveAgentWorkspaceFilePath({ + const resolvedPath = await resolveWorkspaceFilePathOrRespond({ + respond, workspaceDir, name, - allowMissing: true, }); - if (resolvedPath.kind === "invalid") { - respondWorkspaceFileInvalid(respond, name, resolvedPath.reason); + if (!resolvedPath) { return; } const content = String(params.content ?? ""); diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts index 202e1df8ae0..0df85701a05 100644 --- a/src/gateway/server-methods/secrets.test.ts +++ b/src/gateway/server-methods/secrets.test.ts @@ -1,20 +1,29 @@ import { describe, expect, it, vi } from "vitest"; import { createSecretsHandlers } from "./secrets.js"; +async function invokeSecretsReload(params: { + handlers: ReturnType; + respond: ReturnType; +}) { + await params.handlers["secrets.reload"]({ + req: { type: "req", id: "1", method: "secrets.reload" }, + params: {}, + client: null, + isWebchatConnect: () => false, + respond: params.respond as unknown as Parameters< + ReturnType["secrets.reload"] + >[0]["respond"], + context: {} as never, + }); +} + describe("secrets handlers", () => { it("responds with warning count on successful reload", async () => { const handlers = createSecretsHandlers({ reloadSecrets: vi.fn().mockResolvedValue({ warningCount: 2 }), }); const respond = vi.fn(); - await handlers["secrets.reload"]({ - req: { type: "req", id: "1", method: "secrets.reload" }, - params: {}, - client: null, - isWebchatConnect: () => false, - respond, - context: {} as never, - }); + await invokeSecretsReload({ handlers, respond }); expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 2 }); }); @@ -23,14 +32,7 @@ describe("secrets handlers", () => { reloadSecrets: vi.fn().mockRejectedValue(new Error("reload failed")), }); const respond = vi.fn(); - await handlers["secrets.reload"]({ - req: { type: "req", id: "1", method: "secrets.reload" }, - params: {}, - client: null, - isWebchatConnect: () => false, - respond, - context: {} as never, - }); + await invokeSecretsReload({ handlers, respond }); expect(respond).toHaveBeenCalledWith( false, undefined, diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 68b27e657b2..297e3577b93 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -3,7 +3,6 @@ import { WebSocket } from "ws"; import { approvePendingPairingIfNeeded, BACKEND_GATEWAY_CLIENT, - buildDeviceAuthPayload, connectReq, configureTrustedProxyControlUiAuth, CONTROL_UI_CLIENT, @@ -64,6 +63,32 @@ export function registerControlUiAndPairingSuite(): void { }, ]; + const buildSignedDeviceForIdentity = async (params: { + identityPath: string; + client: { id: string; mode: string }; + nonce: string; + scopes: string[]; + role?: "operator" | "node"; + }) => { + const { device } = await createSignedDevice({ + token: "secret", + scopes: params.scopes, + clientId: params.client.id, + clientMode: params.client.mode, + role: params.role ?? "operator", + identityPath: params.identityPath, + nonce: params.nonce, + }); + return device; + }; + + const expectStatusAndHealthOk = async (ws: WebSocket) => { + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(true); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + }; + for (const tc of trustedProxyControlUiCases) { test(tc.name, async () => { await configureTrustedProxyControlUiAuth(); @@ -104,10 +129,7 @@ export function registerControlUiAndPairingSuite(): void { return; } if (tc.expectStatusChecks) { - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); + await expectStatusAndHealthOk(ws); } ws.close(); }); @@ -122,18 +144,10 @@ export function registerControlUiAndPairingSuite(): void { const res = await connectReq(ws, { token: "secret", device: null, - client: { - id: GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: "1.0.0", - platform: "web", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, + client: { ...CONTROL_UI_CLIENT }, }); expect(res.ok).toBe(true); - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); + await expectStatusAndHealthOk(ws); ws.close(); await server.close(); restoreGatewayToken(prevToken); @@ -147,15 +161,10 @@ export function registerControlUiAndPairingSuite(): void { const res = await connectReq(ws, { password: "secret", device: null, - client: { - ...CONTROL_UI_CLIENT, - }, + client: { ...CONTROL_UI_CLIENT }, }); expect(res.ok).toBe(true); - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); + await expectStatusAndHealthOk(ws); ws.close(); }); }); @@ -408,39 +417,13 @@ export function registerControlUiAndPairingSuite(): void { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); - const { buildDeviceAuthPayload } = await import("./device-auth.js"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = - await import("../infra/device-identity.js"); + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); - const client = { - id: GATEWAY_CLIENT_NAMES.TEST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.TEST, - }; - const buildDevice = (scopes: string[], nonce: string) => { - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: client.id, - clientMode: client.mode, - role: "operator", - scopes, - signedAtMs, - token: "secret", - nonce, - }); - return { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; - }; + const identityPath = join(identityDir, "device.json"); + const identity = loadOrCreateDeviceIdentity(identityPath); + const client = { ...TEST_OPERATOR_CLIENT }; ws.close(); const wsRemoteRead = await openWs(port, { host: "gateway.example" }); @@ -449,7 +432,12 @@ export function registerControlUiAndPairingSuite(): void { token: "secret", scopes: ["operator.read"], client, - device: buildDevice(["operator.read"], initialNonce), + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.read"], + nonce: initialNonce, + }), }); expect(initial.ok).toBe(false); expect(initial.error?.message ?? "").toContain("pairing required"); @@ -469,7 +457,12 @@ export function registerControlUiAndPairingSuite(): void { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"], nonce2), + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.admin"], + nonce: nonce2, + }), }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("pairing required"); @@ -491,35 +484,15 @@ export function registerControlUiAndPairingSuite(): void { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); - const { buildDeviceAuthPayload } = await import("./device-auth.js"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-token-scope-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const identityPath = join(identityDir, "device.json"); + const identity = loadOrCreateDeviceIdentity(identityPath); const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); - const buildDevice = (scopes: string[], nonce: string) => { - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - role: "operator", - scopes, - signedAtMs, - token: "secret", - nonce, - }); - return { - id: identity.deviceId, - publicKey: devicePublicKey, - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; - }; const seeded = await requestDevicePairing({ deviceId: identity.deviceId, publicKey: devicePublicKey, @@ -540,7 +513,12 @@ export function registerControlUiAndPairingSuite(): void { token: "secret", scopes: ["operator.admin"], client: { ...CONTROL_UI_CLIENT }, - device: buildDevice(["operator.admin"], nonce2), + device: await buildSignedDeviceForIdentity({ + identityPath, + client: CONTROL_UI_CLIENT, + scopes: ["operator.admin"], + nonce: nonce2, + }), }); expect(upgraded.ok).toBe(true); const pending = await listDevicePairing(); @@ -557,40 +535,15 @@ export function registerControlUiAndPairingSuite(): void { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = - await import("../infra/device-identity.js"); + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); ws.close(); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); - const client = { - id: GATEWAY_CLIENT_NAMES.TEST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.TEST, - }; - const buildDevice = (role: "operator" | "node", scopes: string[], nonce: string) => { - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: client.id, - clientMode: client.mode, - role, - scopes, - signedAtMs, - token: "secret", - nonce, - }); - return { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; - }; + const identityPath = join(identityDir, "device.json"); + const identity = loadOrCreateDeviceIdentity(identityPath); + const client = { ...TEST_OPERATOR_CLIENT }; const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { const socket = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { host: "gateway.example" }, @@ -609,7 +562,13 @@ export function registerControlUiAndPairingSuite(): void { role, scopes, client, - device: buildDevice(role, scopes, String(nonce)), + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + role, + scopes, + nonce: String(nonce), + }), }); socket.close(); return result; @@ -656,45 +615,25 @@ export function registerControlUiAndPairingSuite(): void { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = - await import("../infra/device-identity.js"); + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { listDevicePairing } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); - const client = { - id: GATEWAY_CLIENT_NAMES.TEST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.TEST, - }; - const buildDevice = (scopes: string[], nonce: string) => { - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: client.id, - clientMode: client.mode, - role: "operator", - scopes, - signedAtMs, - token: "secret", - nonce, - }); - return { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; - }; + const identityPath = join(identityDir, "device.json"); + const identity = loadOrCreateDeviceIdentity(identityPath); + const client = { ...TEST_OPERATOR_CLIENT }; const initialNonce = await readConnectChallengeNonce(ws); const initial = await connectReq(ws, { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"], initialNonce), + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.admin"], + nonce: initialNonce, + }), }); if (!initial.ok) { await approvePendingPairingIfNeeded(); @@ -708,7 +647,12 @@ export function registerControlUiAndPairingSuite(): void { token: "secret", scopes: ["operator.read"], client, - device: buildDevice(["operator.read"], nonce2), + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.read"], + nonce: nonce2, + }), }); expect(res.ok).toBe(true); ws2.close(); @@ -724,15 +668,15 @@ export function registerControlUiAndPairingSuite(): void { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); - const { buildDeviceAuthPayload } = await import("./device-auth.js"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js"); const { writeJsonAtomic } = await import("../infra/json-files.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const identityPath = join(identityDir, "device.json"); + const identity = loadOrCreateDeviceIdentity(identityPath); const deviceId = identity.deviceId; const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); const pending = await requestDevicePairing({ @@ -757,26 +701,6 @@ export function registerControlUiAndPairingSuite(): void { delete legacy.scopes; await writeJsonAtomic(pairedPath, paired); - const buildDevice = (nonce: string) => { - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId, - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - role: "operator", - scopes: ["operator.read"], - signedAtMs, - token: "secret", - nonce, - }); - return { - id: deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; - }; const { server, ws, port, prevToken } = await startServerWithClient("secret"); let ws2: WebSocket | undefined; try { @@ -789,7 +713,12 @@ export function registerControlUiAndPairingSuite(): void { token: "secret", scopes: ["operator.read"], client: TEST_OPERATOR_CLIENT, - device: buildDevice(reconnectNonce), + device: await buildSignedDeviceForIdentity({ + identityPath, + client: TEST_OPERATOR_CLIENT, + scopes: ["operator.read"], + nonce: reconnectNonce, + }), }); expect(reconnect.ok).toBe(true); @@ -812,23 +741,21 @@ export function registerControlUiAndPairingSuite(): void { const { join } = await import("node:path"); const { readJsonFile, resolvePairingPaths } = await import("../infra/pairing-files.js"); const { writeJsonAtomic } = await import("../infra/json-files.js"); - const { buildDeviceAuthPayload } = await import("./device-auth.js"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); - const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } = - await import("../utils/message-channel.js"); const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-")); - const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const identityPath = join(identityDir, "device.json"); + const identity = loadOrCreateDeviceIdentity(identityPath); const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); const seeded = await requestDevicePairing({ deviceId: identity.deviceId, publicKey: devicePublicKey, role: "operator", scopes: ["operator.read"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, displayName: "legacy-upgrade-test", platform: "test", }); @@ -848,32 +775,7 @@ export function registerControlUiAndPairingSuite(): void { const { server, ws, port, prevToken } = await startServerWithClient("secret"); let ws2: WebSocket | undefined; try { - const client = { - id: GATEWAY_CLIENT_NAMES.TEST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.TEST, - }; - const buildDevice = (scopes: string[], nonce: string) => { - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: client.id, - clientMode: client.mode, - role: "operator", - scopes, - signedAtMs, - token: "secret", - nonce, - }); - return { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; - }; + const client = { ...TEST_OPERATOR_CLIENT }; ws.close(); @@ -884,7 +786,12 @@ export function registerControlUiAndPairingSuite(): void { token: "secret", scopes: ["operator.admin"], client, - device: buildDevice(["operator.admin"], upgradeNonce), + device: await buildSignedDeviceForIdentity({ + identityPath, + client, + scopes: ["operator.admin"], + nonce: upgradeNonce, + }), }); expect(upgraded.ok).toBe(true); wsUpgrade.close(); diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 0f779a3cacb..85227e05880 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -37,6 +37,36 @@ export function registerDefaultAuthTokenSuite(): void { await server.close(); }); + async function expectNonceValidationError(params: { + connectId: string; + mutateNonce: (nonce: string) => string; + expectedMessage: string; + expectedCode: string; + expectedReason: string; + }) { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token, + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: params.connectId, + token, + device: { ...device, nonce: params.mutateNonce(nonce) }, + }); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain(params.expectedMessage); + expect(connectRes.error?.details?.code).toBe(params.expectedCode); + expect(connectRes.error?.details?.reason).toBe(params.expectedReason); + await new Promise((resolve) => ws.once("close", () => resolve())); + } + test("closes silent handshakes after timeout", async () => { vi.useRealTimers(); const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; @@ -316,55 +346,23 @@ export function registerDefaultAuthTokenSuite(): void { }); test("returns nonce-required detail code when nonce is blank", async () => { - const ws = await openWs(port); - const token = resolveGatewayTokenOrEnv(); - const nonce = await readConnectChallengeNonce(ws); - const { device } = await createSignedDevice({ - token, - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - nonce, + await expectNonceValidationError({ + connectId: "c-blank-nonce", + mutateNonce: () => " ", + expectedMessage: "device nonce required", + expectedCode: ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED, + expectedReason: "device-nonce-missing", }); - - const connectRes = await sendRawConnectReq(ws, { - id: "c-blank-nonce", - token, - device: { ...device, nonce: " " }, - }); - expect(connectRes.ok).toBe(false); - expect(connectRes.error?.message ?? "").toContain("device nonce required"); - expect(connectRes.error?.details?.code).toBe( - ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED, - ); - expect(connectRes.error?.details?.reason).toBe("device-nonce-missing"); - await new Promise((resolve) => ws.once("close", () => resolve())); }); test("returns nonce-mismatch detail code when nonce does not match challenge", async () => { - const ws = await openWs(port); - const token = resolveGatewayTokenOrEnv(); - const nonce = await readConnectChallengeNonce(ws); - const { device } = await createSignedDevice({ - token, - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - nonce, + await expectNonceValidationError({ + connectId: "c-wrong-nonce", + mutateNonce: (nonce) => `${nonce}-stale`, + expectedMessage: "device nonce mismatch", + expectedCode: ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH, + expectedReason: "device-nonce-mismatch", }); - - const connectRes = await sendRawConnectReq(ws, { - id: "c-wrong-nonce", - token, - device: { ...device, nonce: `${nonce}-stale` }, - }); - expect(connectRes.ok).toBe(false); - expect(connectRes.error?.message ?? "").toContain("device nonce mismatch"); - expect(connectRes.error?.details?.code).toBe( - ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH, - ); - expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch"); - await new Promise((resolve) => ws.once("close", () => resolve())); }); test("invalid connect params surface in response and close reason", async () => { diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index a37c992da21..efe9ad7b111 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -8,6 +8,7 @@ import { openWs, originForPort, rpcReq, + restoreGatewayToken, startGatewayServer, testState, testTailscaleWhois, @@ -58,11 +59,7 @@ export function registerAuthModesSuite(): void { afterAll(async () => { await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } + restoreGatewayToken(prevToken); }); test("rejects invalid token", async () => { @@ -119,11 +116,7 @@ export function registerAuthModesSuite(): void { afterAll(async () => { await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } + restoreGatewayToken(prevToken); }); test("allows loopback connect without shared secret when mode is none", async () => { diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 837a17cd3bd..6b95ff62d25 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -191,6 +191,29 @@ describe("gateway server models + voicewake", () => { } }; + const expectAllowlistedModels = async (options: { + primary: string; + models: Record; + expected: ModelCatalogRpcEntry[]; + }): Promise => { + await withModelsConfig( + { + agents: { + defaults: { + model: { primary: options.primary }, + models: options.models, + }, + }, + }, + async () => { + seedPiCatalog(); + const res = await listModels(); + expect(res.ok).toBe(true); + expect(res.payload?.models).toEqual(options.expected); + }, + ); + }; + test( "voicewake.get returns defaults and voicewake.set broadcasts", { timeout: 20_000 }, @@ -294,66 +317,42 @@ describe("gateway server models + voicewake", () => { }); test("models.list filters to allowlisted configured models by default", async () => { - await withModelsConfig( - { - agents: { - defaults: { - model: { primary: "openai/gpt-test-z" }, - models: { - "openai/gpt-test-z": {}, - "anthropic/claude-test-a": {}, - }, - }, + await expectAllowlistedModels({ + primary: "openai/gpt-test-z", + models: { + "openai/gpt-test-z": {}, + "anthropic/claude-test-a": {}, + }, + expected: [ + { + id: "claude-test-a", + name: "A-Model", + provider: "anthropic", + contextWindow: 200_000, }, - }, - async () => { - seedPiCatalog(); - const res = await listModels(); - - expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual([ - { - id: "claude-test-a", - name: "A-Model", - provider: "anthropic", - contextWindow: 200_000, - }, - { - id: "gpt-test-z", - name: "gpt-test-z", - provider: "openai", - }, - ]); - }, - ); + { + id: "gpt-test-z", + name: "gpt-test-z", + provider: "openai", + }, + ], + }); }); test("models.list includes synthetic entries for allowlist models absent from catalog", async () => { - await withModelsConfig( - { - agents: { - defaults: { - model: { primary: "openai/not-in-catalog" }, - models: { - "openai/not-in-catalog": {}, - }, - }, + await expectAllowlistedModels({ + primary: "openai/not-in-catalog", + models: { + "openai/not-in-catalog": {}, + }, + expected: [ + { + id: "not-in-catalog", + name: "not-in-catalog", + provider: "openai", }, - }, - async () => { - seedPiCatalog(); - const res = await listModels(); - - expect(res.ok).toBe(true); - expect(res.payload?.models).toEqual([ - { - id: "not-in-catalog", - name: "not-in-catalog", - provider: "openai", - }, - ]); - }, - ); + ], + }); }); test("models.list rejects unknown params", async () => { diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 4deaa6705d0..f87c307c211 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -94,6 +94,39 @@ async function expectDiscordSessionTargetRequest(params: { expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount); } +async function expectSessionFilterRequestResult(params: { + sessionFilter: string[]; + sessionKey: string; + expectedAccepted: boolean; + expectedDeliveryCount: number; +}) { + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "session", + sessionFilter: params.sessionFilter, + }, + }, + } as OpenClawConfig; + + const { deliver, forwarder } = createForwarder({ + cfg, + resolveSessionTarget: () => ({ channel: "slack", to: "U1" }), + }); + + const request = { + ...baseRequest, + request: { + ...baseRequest.request, + sessionKey: params.sessionKey, + }, + }; + + await expect(forwarder.handleRequested(request)).resolves.toBe(params.expectedAccepted); + expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount); +} + describe("exec approval forwarder", () => { it("forwards to session target and resolves", async () => { vi.useFakeTimers(); @@ -167,59 +200,21 @@ describe("exec approval forwarder", () => { }); it("rejects unsafe nested-repetition regex in sessionFilter", async () => { - const cfg = { - approvals: { - exec: { - enabled: true, - mode: "session", - sessionFilter: ["(a+)+$"], - }, - }, - } as OpenClawConfig; - - const { deliver, forwarder } = createForwarder({ - cfg, - resolveSessionTarget: () => ({ channel: "slack", to: "U1" }), + await expectSessionFilterRequestResult({ + sessionFilter: ["(a+)+$"], + sessionKey: `${"a".repeat(28)}!`, + expectedAccepted: false, + expectedDeliveryCount: 0, }); - - const request = { - ...baseRequest, - request: { - ...baseRequest.request, - sessionKey: `${"a".repeat(28)}!`, - }, - }; - - await expect(forwarder.handleRequested(request)).resolves.toBe(false); - expect(deliver).not.toHaveBeenCalled(); }); it("matches long session keys with tail-bounded regex checks", async () => { - const cfg = { - approvals: { - exec: { - enabled: true, - mode: "session", - sessionFilter: ["discord:tail$"], - }, - }, - } as OpenClawConfig; - - const { deliver, forwarder } = createForwarder({ - cfg, - resolveSessionTarget: () => ({ channel: "slack", to: "U1" }), + await expectSessionFilterRequestResult({ + sessionFilter: ["discord:tail$"], + sessionKey: `${"x".repeat(5000)}discord:tail`, + expectedAccepted: true, + expectedDeliveryCount: 1, }); - - const request = { - ...baseRequest, - request: { - ...baseRequest.request, - sessionKey: `${"x".repeat(5000)}discord:tail`, - }, - }; - - await expect(forwarder.handleRequested(request)).resolves.toBe(true); - expect(deliver).toHaveBeenCalledTimes(1); }); it("returns false when all targets are skipped", async () => { diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index cadbba199d2..1c961059080 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -46,6 +46,33 @@ function tryResolveRealpath(filePath: string | undefined): string | undefined { } } +function buildCommandResolution(params: { + rawExecutable: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + effectiveArgv: string[]; + wrapperChain: string[]; + policyBlocked: boolean; + blockedWrapper?: string; +}): CommandResolution { + const resolvedPath = resolveExecutableCandidatePath(params.rawExecutable, { + cwd: params.cwd, + env: params.env, + }); + const resolvedRealPath = tryResolveRealpath(resolvedPath); + const executableName = resolvedPath ? path.basename(resolvedPath) : params.rawExecutable; + return { + rawExecutable: params.rawExecutable, + resolvedPath, + resolvedRealPath, + executableName, + effectiveArgv: params.effectiveArgv, + wrapperChain: params.wrapperChain, + policyBlocked: params.policyBlocked, + blockedWrapper: params.blockedWrapper, + }; +} + export function resolveCommandResolution( command: string, cwd?: string, @@ -55,18 +82,14 @@ export function resolveCommandResolution( if (!rawExecutable) { return null; } - const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { cwd, env }); - const resolvedRealPath = tryResolveRealpath(resolvedPath); - const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; - return { + return buildCommandResolution({ rawExecutable, - resolvedPath, - resolvedRealPath, - executableName, effectiveArgv: [rawExecutable], wrapperChain: [], policyBlocked: false, - }; + cwd, + env, + }); } export function resolveCommandResolutionFromArgv( @@ -80,19 +103,15 @@ export function resolveCommandResolutionFromArgv( if (!rawExecutable) { return null; } - const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { cwd, env }); - const resolvedRealPath = tryResolveRealpath(resolvedPath); - const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; - return { + return buildCommandResolution({ rawExecutable, - resolvedPath, - resolvedRealPath, - executableName, effectiveArgv, wrapperChain: plan.wrappers, policyBlocked: plan.policyBlocked, blockedWrapper: plan.blockedWrapper, - }; + cwd, + env, + }); } function normalizeMatchTarget(value: string): string {