diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f22a3df..85afd4070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Feature: extensions can request sensitive environment variables + +Copilot CLI extensions can now ask for named sensitive environment variables when they join a session. `joinSession()` accepts an `env` option listing the variable names the extension needs. The CLI shows a permission prompt naming the extension and the exact variables requested. On approval, only those variables reach that extension and their values are written into the extension process's `process.env` before `joinSession()` resolves. On denial, `joinSession()` rejects, the extension does not load, and its tools never reach the model. + +An approval is remembered against the exact set of names the user saw, so an extension that later asks for one more variable prompts again. Names that are unset, or that the CLI does not filter from extensions, are not prompted for. This is the client half of the feature; it requires a Copilot CLI that supports extension environment access, and older CLIs ignore the request and grant nothing. + +```ts +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ env: ["GITHUB_TOKEN"] }); +const token = process.env.GITHUB_TOKEN; +``` + ### Feature: host-injected managed settings permissions Session create and resume accept a new optional `managedSettings` option that injects an enterprise permissions policy at session startup, alongside the existing `enableManagedSettings` self-fetch flag. The current contract is permissions-only: `disableBypassPermissionsMode` (the literal `"disable"`), plus `deny`, `ask`, and `allow` rule lists. The layer composes restrictively with any server- or device-level managed settings (deny/ask are unioned, every present allow list must admit a tool, and `disableBypassPermissionsMode` is deny-wins). diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md index d33a73312..c577e1966 100644 --- a/nodejs/docs/extensions.md +++ b/nodejs/docs/extensions.md @@ -53,6 +53,25 @@ const session = await joinSession({ The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information. +## Requesting Sensitive Environment Variables + +The CLI strips sensitive environment variables (for example `GITHUB_TOKEN`) from every extension process before it starts. An extension that needs one asks for it by name: + +```js +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ env: ["GITHUB_TOKEN"] }); + +// Granted values are in process.env once joinSession resolves. +const token = process.env.GITHUB_TOKEN; +``` + +The CLI prompts the user with the extension's name and the exact list of variables requested. If the user approves, only those variables reach this extension and their values are written into `process.env` before `joinSession()` resolves. If the user denies, `joinSession()` rejects, the extension does not load, and its tools never reach the model. + +An approval is remembered against the exact set of names the user saw, so an extension that later asks for an additional variable prompts again. Names that are unset, or that the CLI does not filter from extensions, are not prompted for. + +An approved extension can pass a granted value to anything it starts, so ask only for what the extension genuinely needs. + ## Further Reading - `examples.md` — Practical code examples for tools, hooks, events, and complete extensions diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 30095186e..196d52642 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -54,6 +54,7 @@ import type { CustomAgentConfig, ExitPlanModeRequest, ExitPlanModeResult, + ExtensionJoinOptions, ForegroundSessionInfo, GetAuthStatusResponse, BearerTokenProvider, @@ -1711,15 +1712,17 @@ export class CopilotClient { async resumeSessionForExtension( sessionId: string, config: ResumeSessionConfig, - factories?: FactoryHandle[] + factories?: FactoryHandle[], + extensionOptions?: ExtensionJoinOptions ): Promise { - return this.resumeSessionInternal(sessionId, config, factories); + return this.resumeSessionInternal(sessionId, config, factories, extensionOptions); } private async resumeSessionInternal( sessionId: string, config: ResumeSessionConfig, - factories?: FactoryHandle[] + factories?: FactoryHandle[], + extensionOptions?: ExtensionJoinOptions ): Promise { if (!this.connection) { await this.start(); @@ -1884,8 +1887,31 @@ export class CopilotClient { expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, managedSettings: config.managedSettings, + ...(extensionOptions?.requestedEnvironmentVariables + ? { + requestedEnvironmentVariables: + extensionOptions.requestedEnvironmentVariables, + } + : {}), }); + // The host answers an approved environment request with the resolved + // values, and this method consumes the response, so the grant has to be + // applied here — no caller ever sees it. Only the names the user + // approved may reach the extension, so a host that answers with + // anything extra cannot widen the grant. + if (extensionOptions?.requestedEnvironmentVariables) { + const requested = new Set(extensionOptions.requestedEnvironmentVariables); + const { grantedEnvironmentVariables } = response as { + grantedEnvironmentVariables?: Record; + }; + for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) { + if (requested.has(name)) { + process.env[name] = value; + } + } + } + const { workspacePath, capabilities, openCanvases } = response as { sessionId: string; workspacePath?: string; diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index c3ae0fd87..19854d074 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -27,6 +27,33 @@ export type JoinSessionConfig = Omit< "onPermissionRequest" | "extensionSdkPath" > & { onPermissionRequest?: PermissionHandler; + /** + * Names of sensitive environment variables this extension needs, such as + * `"GITHUB_TOKEN"`. + * + * The Copilot CLI strips sensitive variables from every extension process + * before it starts, so an extension that needs one must ask for it by name. + * The CLI prompts the user with the extension's name and the exact list of + * variables requested. On approval the granted values are written into this + * process's `process.env` before {@link joinSession} resolves, so they are + * readable afterwards. On denial the join rejects and the extension does not + * load, so its tools never reach the model. + * + * An approval is remembered against the exact set of names the user saw, so + * asking for an additional variable later prompts again. Names that are unset + * or that the CLI does not filter from extensions are not prompted for. An + * empty list means the same as omitting the option: nothing is requested. + * + * Requires a Copilot CLI that supports extension environment access; older + * CLIs ignore the request and grant nothing. + * + * @example + * ```typescript + * const session = await joinSession({ env: ["GITHUB_TOKEN"] }); + * const token = process.env.GITHUB_TOKEN; + * ``` + */ + env?: string[]; /** * Factory handles to register when the extension joins the session. * @@ -94,6 +121,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise { expect(payload.openCanvasInstances).toBeUndefined(); }); + it("forwards an extension environment request and applies the grant to process.env", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + onTestFinished(() => { + delete process.env.SDK_TEST_GRANTED_TOKEN; + }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") { + return { + sessionId: params.sessionId, + grantedEnvironmentVariables: { SDK_TEST_GRANTED_TOKEN: "granted-value" }, + }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSessionForExtension( + "session-env", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestedEnvironmentVariables: ["SDK_TEST_GRANTED_TOKEN"] } + ); + + const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; + expect(payload.requestedEnvironmentVariables).toEqual(["SDK_TEST_GRANTED_TOKEN"]); + expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value"); + }); + + it("ignores granted variables the extension never requested", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + onTestFinished(() => { + delete process.env.SDK_TEST_GRANTED_TOKEN; + }); + + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.resume") { + return { + sessionId: params.sessionId, + grantedEnvironmentVariables: { + SDK_TEST_GRANTED_TOKEN: "granted-value", + SDK_TEST_SMUGGLED: "not-approved", + }, + }; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + + await client.resumeSessionForExtension( + "session-env-extra", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestedEnvironmentVariables: ["SDK_TEST_GRANTED_TOKEN"] } + ); + + expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value"); + // The user approved one name, so a host answering with a second one + // cannot widen the grant. + expect(process.env.SDK_TEST_SMUGGLED).toBeUndefined(); + }); + + it("omits the environment request when a resume does not ask for one", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") { + return { + sessionId: params.sessionId, + grantedEnvironmentVariables: { SDK_TEST_UNREQUESTED: "leaked" }, + }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession("session-no-env", { onPermissionRequest: approveAll }); + + const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; + expect(payload).not.toHaveProperty("requestedEnvironmentVariables"); + // A grant is only honored for a request this client actually made. + expect(process.env.SDK_TEST_UNREQUESTED).toBeUndefined(); + }); + it("forwards reasoningSummary in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts new file mode 100644 index 000000000..f034db905 --- /dev/null +++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, it } from "vitest"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node.js"; +import { approveAll } from "../../src/index.js"; +import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; +import { retry } from "./harness/sdkTestHelper.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "fixtures", "env-access-extension.mjs"); +const DIST_DIR = resolve(__dirname, "..", "..", "dist"); + +interface ExtensionRun { + resumeParams: Record; + /** Sample taken before the join, so an inherited value is distinguishable from a granted one. */ + prejoin: string; + postjoin: string; + result: string; +} + +/** + * Run the fixture extension as a real child process against a stub host that + * speaks the extension side of the wire. + * + * The extension connection is plain JSON-RPC over the extension process's own + * stdio, so a stub host observes exactly what the CLI observes. That is the only + * way to cover this feature end to end today: the released CLI predates the host + * half (github/copilot-agent-runtime#15144), so it ignores the request and grants + * nothing. Once the `@github/copilot` dependency carries the host half, the + * real-CLI case below can assert the grant instead. + */ +async function runExtensionAgainstStubHost(options: { + requested: string[]; + /** Pass an explicit empty list rather than omitting the option. */ + requestEmptyList?: boolean; + /** Names the extension samples but never asks for. */ + observe?: string[]; + /** Values the host resolves for an approved request, or undefined to deny it. */ + granted?: Record; +}): Promise { + if (!existsSync(join(DIST_DIR, "extension.js"))) { + throw new Error(`Built SDK not found at ${DIST_DIR}. Run \`npm run build\` first.`); + } + + const dir = mkdtempSync(join(tmpdir(), "copilot-env-access-")); + const prejoinFile = join(dir, "prejoin"); + const postjoinFile = join(dir, "postjoin"); + const resultFile = join(dir, "result"); + + const child = spawn(process.execPath, [FIXTURE], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + SESSION_ID: "stub-host-session", + EXTENSION_SDK_MODULE: pathToFileURL(join(DIST_DIR, "extension.js")).href, + EXTENSION_ENV_REQUEST: options.requested.join(","), + EXTENSION_ENV_REQUEST_EMPTY: options.requestEmptyList ? "1" : "", + EXTENSION_OBSERVE_ENV_NAMES: (options.observe ?? []).join(","), + EXTENSION_PREJOIN_FILE: prejoinFile, + EXTENSION_POSTJOIN_FILE: postjoinFile, + EXTENSION_RESULT_FILE: resultFile, + }, + }); + + const stderr: string[] = []; + child.stderr!.on("data", (chunk) => stderr.push(String(chunk))); + + let capturedResumeParams: Record = {}; + const connection = createMessageConnection( + new StreamMessageReader(child.stdout!), + new StreamMessageWriter(child.stdin!) + ); + connection.onRequest("connect", () => ({ protocolVersion: getSdkProtocolVersion() })); + connection.onRequest("session.resume", (params: Record) => { + capturedResumeParams = params; + if (!options.granted) { + throw new Error( + 'Extension "env-access" was denied access to sensitive environment variables' + ); + } + return { sessionId: params.sessionId, grantedEnvironmentVariables: options.granted }; + }); + // Everything else the SDK issues while joining is irrelevant here, and an + // unanswered request would hang the join. + connection.onRequest(() => ({})); + connection.onNotification(() => {}); + connection.listen(); + + try { + await retry( + "wait for the fixture extension to report its join result", + async () => { + expect( + existsSync(resultFile), + `extension never reported; stderr: ${stderr.join("")}` + ).toBe(true); + }, + 300, + 100 + ); + + return { + resumeParams: capturedResumeParams, + prejoin: readFileSync(prejoinFile, "utf-8"), + postjoin: readFileSync(postjoinFile, "utf-8"), + result: readFileSync(resultFile, "utf-8"), + }; + } finally { + connection.dispose(); + child.kill(); + // Windows keeps the directory locked until the child is gone. + await new Promise((resolveExit) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveExit(); + return; + } + child.once("exit", () => resolveExit()); + }); + await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); + } +} + +it("puts an extension's environment request on the wire and applies the grant", async () => { + const run = await runExtensionAgainstStubHost({ + requested: ["E2E_SDK_TOKEN", "E2E_SDK_OTHER"], + granted: { E2E_SDK_TOKEN: "granted-token", E2E_SDK_OTHER: "granted-other" }, + }); + + expect(run.resumeParams.requestedEnvironmentVariables).toEqual([ + "E2E_SDK_TOKEN", + "E2E_SDK_OTHER", + ]); + // The values crossed the process boundary rather than being inherited. + expect(run.prejoin).toBe("E2E_SDK_TOKEN=\nE2E_SDK_OTHER="); + expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_OTHER=granted-other"); + expect(run.result).toBe("joined"); +}); + +it("grants nothing to an extension whose request the host denies", async () => { + const run = await runExtensionAgainstStubHost({ requested: ["E2E_SDK_TOKEN"] }); + + expect(run.resumeParams.requestedEnvironmentVariables).toEqual(["E2E_SDK_TOKEN"]); + expect(run.result).toContain("denied access to sensitive environment variables"); + expect(run.postjoin).toBe("E2E_SDK_TOKEN="); +}); + +it("leaves the wire payload alone when an extension asks for nothing", async () => { + const omitted = await runExtensionAgainstStubHost({ requested: [], granted: {} }); + const emptyList = await runExtensionAgainstStubHost({ + requested: [], + requestEmptyList: true, + granted: {}, + }); + + expect(omitted.resumeParams).not.toHaveProperty("requestedEnvironmentVariables"); + // An empty list is the other public way to ask for nothing. + expect(emptyList.resumeParams).not.toHaveProperty("requestedEnvironmentVariables"); + expect(omitted.result).toBe("joined"); + expect(emptyList.result).toBe("joined"); +}); + +it("ignores a granted variable the extension never requested", async () => { + const run = await runExtensionAgainstStubHost({ + requested: ["E2E_SDK_TOKEN"], + observe: ["E2E_SDK_SMUGGLED"], + granted: { E2E_SDK_TOKEN: "granted-token", E2E_SDK_SMUGGLED: "not-approved" }, + }); + + // The user approved one name, so a host answering with a second one cannot + // widen the grant. + expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_SMUGGLED="); +}); + +const cliObservations = isInProcessTransport + ? "" + : mkdtempSync(join(tmpdir(), "copilot-env-access-cli-")); +const cliResultFile = join(cliObservations, "result"); +const cliContext = isInProcessTransport + ? undefined + : await createSdkTestContext({ + copilotClientOptions: { + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", + EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", + EXTENSION_RESULT_FILE: cliResultFile, + EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), + EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), + }, + }, + }); + +// The released CLI ignores `requestedEnvironmentVariables`, so this covers the +// half a real CLI can prove today: asking for variables does not break the join. +// It becomes the grant test once `@github/copilot` carries the host half. +it.skipIf(isInProcessTransport)( + "joins a real CLI that does not support environment requests", + async () => { + if (!cliContext) { + throw new Error("Extension E2E requires an out-of-process transport"); + } + const { workDir, copilotClient } = cliContext; + const extensionDir = join(workDir, ".github", "extensions", "env-access"); + await rm(join(workDir, ".github"), { recursive: true, force: true }); + await rm(cliResultFile, { force: true }); + await mkdir(extensionDir, { recursive: true }); + await copyFile(FIXTURE, join(extensionDir, "extension.mjs")); + execFileSync("git", ["init", "--quiet"], { cwd: workDir }); + + await using _session = await copilotClient.createSession({ + requestExtensions: true, + extensionSdkPath: DIST_DIR, + onPermissionRequest: approveAll, + }); + + await retry( + "wait for the env-access extension to join the session", + async () => { + expect(existsSync(cliResultFile)).toBe(true); + }, + 300, + 100 + ); + + expect(readFileSync(cliResultFile, "utf-8")).toBe("joined"); + } +); diff --git a/nodejs/test/e2e/fixtures/env-access-extension.mjs b/nodejs/test/e2e/fixtures/env-access-extension.mjs new file mode 100644 index 000000000..929bdb0b6 --- /dev/null +++ b/nodejs/test/e2e/fixtures/env-access-extension.mjs @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// Extension that asks for named sensitive environment variables at join time. +// +// Every input is an environment variable so one fixture serves both the stub-host +// case (spawned directly, importing the built SDK through EXTENSION_SDK_MODULE) +// and the real-CLI case (forked by the CLI, which injects the SDK module). +// +// - EXTENSION_SDK_MODULE: import specifier for the SDK. Defaults to the module +// name the CLI resolves for a forked extension. +// - EXTENSION_ENV_REQUEST: comma-separated names to pass to joinSession({ env }). +// - EXTENSION_PREJOIN_FILE: `NAME=` per requested name, sampled BEFORE the +// join. A test compares it with the post-join sample to tell a value the +// process already inherited from one the host granted. +// - EXTENSION_POSTJOIN_FILE: the same sample, written once the join settles. +// - EXTENSION_RESULT_FILE: `joined` or `rejected:`. A denied extension +// has no session to report through, so it reports here. +// - EXTENSION_ENV_REQUEST_EMPTY: set to `1` to pass an explicit empty list when +// EXTENSION_ENV_REQUEST names nothing, instead of omitting the option. +// - EXTENSION_OBSERVE_ENV_NAMES: comma-separated names that are sampled but +// deliberately NOT requested, so a test can prove a name outside the approved +// set never reached this process. + +import { writeFileSync } from "node:fs"; + +const sdkModule = process.env.EXTENSION_SDK_MODULE ?? "@github/copilot-sdk/extension"; +const { joinSession } = await import(sdkModule); + +const requested = (process.env.EXTENSION_ENV_REQUEST ?? "") + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); +const observed = (process.env.EXTENSION_OBSERVE_ENV_NAMES ?? "") + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); +const sampledNames = [...requested, ...observed]; +const sample = () => sampledNames.map((name) => `${name}=${process.env[name] ?? ""}`).join("\n"); + +const record = (file, contents) => { + if (file) { + writeFileSync(file, contents); + } +}; + +record(process.env.EXTENSION_PREJOIN_FILE, sample()); + +const config = { + tools: [ + { + name: "env_access_greeter", + description: "Greets someone. Always call this tool when asked to greet.", + parameters: { type: "object", properties: { name: { type: "string" } } }, + handler: async (args) => `Hello from env-access, ${args.name || "World"}!`, + }, + ], +}; +// An extension that wants nothing normally omits the option entirely. +// EXTENSION_ENV_REQUEST_EMPTY covers the other public way to ask for nothing: +// passing an empty list, which must reach the wire the same way. +if (requested.length > 0) { + config.env = requested; +} else if (process.env.EXTENSION_ENV_REQUEST_EMPTY === "1") { + config.env = []; +} + +try { + await joinSession(config); + record(process.env.EXTENSION_POSTJOIN_FILE, sample()); + record(process.env.EXTENSION_RESULT_FILE, "joined"); +} catch (error) { + // Sampled after the rejection too, so a test can prove a denied extension + // never saw the value rather than only that the join failed. + record(process.env.EXTENSION_POSTJOIN_FILE, sample()); + record( + process.env.EXTENSION_RESULT_FILE, + `rejected:${error instanceof Error ? error.message : String(error)}` + ); +} diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index e94ad2204..82f34cdfe 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -47,6 +47,36 @@ describe("joinSession", () => { expect(config.suppressResumeEvent).toBe(false); }); + it("forwards the requested environment variables and keeps them off the resume config", async () => { + process.env.SESSION_ID = "session-123"; + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as any); + + await joinSession({ env: ["GITHUB_TOKEN", "MY_SECRET"], tools: [] }); + + const [, config, , extensionOptions] = resumeForExtension.mock.calls[0]!; + expect(extensionOptions).toEqual({ + requestedEnvironmentVariables: ["GITHUB_TOKEN", "MY_SECRET"], + }); + expect(config).not.toHaveProperty("env"); + }); + + it("requests no environment variables when env is omitted or empty", async () => { + process.env.SESSION_ID = "session-123"; + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as any); + + await joinSession({ tools: [] }); + await joinSession({ env: [], tools: [] }); + + expect(resumeForExtension.mock.calls[0]![3]).toBeUndefined(); + // An empty list means the same as omitting the option, so it must not put + // an environment request on the wire either. + expect(resumeForExtension.mock.calls[1]![3]).toBeUndefined(); + }); + it("exports the canvas helper from the extension surface", () => { const canvas = createCanvas({ id: "counter", diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 3d85b972d..13133397f 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -627,7 +627,8 @@ describe("factories", () => { expect(resumeSessionForExtension).toHaveBeenCalledWith( "session-extension", expect.objectContaining({ suppressResumeEvent: true }), - [factory] + [factory], + undefined ); });