From c2d8e1a62cb1e565df8d5c5e6945a2462c3ff8b9 Mon Sep 17 00:00:00 2001 From: koreankop Date: Wed, 19 Aug 2026 15:36:55 +0900 Subject: [PATCH] feat(connectors): inject every connected account slot, not just the winning one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A principal can connect the same provider more than once — the default, personal, and company slots each hold their own token — but a turn only ever saw one of them: the first hit of personal, default, company went into VAULT_TOKEN_ and the rest were invisible. Someone with a personal and a company Google account connected could therefore reach only one of the two mailboxes, with no way to name the other. Keep the unsuffixed variable exactly as it was — same precedence, same value — and add VAULT_TOKEN___ for each connected slot when more than one exists, so a skill can address a specific account. A single-slot connector's environment is unchanged, which keeps every existing skill working as written. --- src/core/orchestrator.ts | 14 ++++++++++---- test/connector-invariants.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index d6071d572..06d7faa98 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -41,6 +41,7 @@ import { configuredConnectorProviders, connectorStatusIsStale, refreshConnectorStatus, + CONNECTOR_STATUS_ACCOUNT_TYPES, } from "../credentials/connector-status.ts"; import { renderComputerBlock, renderResidentLoginsBlock, renderConnectedAppsBlock } from "./environment-facts.ts"; import { PROVIDERS } from "../connectors/oauth.ts"; @@ -1069,11 +1070,16 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } if (!strictReadOnly && deps.connectorTokens && conversation.kind === "dm") { for (const host of CONNECTOR_HOSTS) { - const token = - (await deps.connectorTokens.connectorAccessToken(host, actor.id, "personal")) ?? - (await deps.connectorTokens.connectorAccessToken(host, actor.id)) ?? - (await deps.connectorTokens.connectorAccessToken(host, actor.id, "company")); + const slots = new Map(); + for (const accountType of CONNECTOR_STATUS_ACCOUNT_TYPES) { + const slotToken = await deps.connectorTokens.connectorAccessToken(host, actor.id, accountType); + if (slotToken) slots.set(accountType ?? "default", slotToken); + } + const token = slots.get("personal") ?? slots.get("default") ?? slots.get("company"); if (token) connectorEnv[envKey(host)] = token; + if (slots.size > 1) { + for (const [slot, slotToken] of slots) connectorEnv[`${envKey(host)}__${slot.toUpperCase()}`] = slotToken; + } } } perf.credsMs += Date.now() - credsStart; diff --git a/test/connector-invariants.test.ts b/test/connector-invariants.test.ts index f0e3f4c24..31fe7787f 100644 --- a/test/connector-invariants.test.ts +++ b/test/connector-invariants.test.ts @@ -225,3 +225,30 @@ test("a read-only wake never reaches the sandbox (execute stripped), so no exec "a read-only wake spins no sandbox exec", ); }); + +test("every connected account slot reaches the exec env, not just the winning one", async () => { + const built: BuiltApp = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "slots-")) })); + const host = "gmail.googleapis.com"; + await built.connectorTokens.setConnectorToken(host, "U1", { accessToken: "u1-default" }); + await built.connectorTokens.setConnectorToken(host, "U1", { accessToken: "u1-company" }, "company"); + + fakeSprites.reset(); + const dm = await built.app.turn(turn("dm", "!run true")); + assert.equal(dm.status, "ok"); + assert.ok(execScriptsMention(`export ${envKey(host)}=`), "the primary slot keeps the unsuffixed name"); + assert.ok(execScriptsMention("u1-default"), "the default slot stays the primary token"); + assert.ok(execScriptsMention(`${envKey(host)}__COMPANY=`), "the second slot gets its own variable"); + assert.ok(execScriptsMention("u1-company"), "the second slot's token value is present"); +}); + +test("a single connected slot injects only the unsuffixed variable", async () => { + const built: BuiltApp = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "one-slot-")) })); + const host = "gmail.googleapis.com"; + await built.connectorTokens.setConnectorToken(host, "U1", { accessToken: "u1-only" }); + + fakeSprites.reset(); + const dm = await built.app.turn(turn("dm", "!run true")); + assert.equal(dm.status, "ok"); + assert.ok(execScriptsMention(`export ${envKey(host)}=`)); + assert.ok(!execScriptsMention(`${envKey(host)}__`), "no per-slot variables when there is nothing to disambiguate"); +});