Files
oh-my-pi/packages/ai/test/auth-storage-manual-code-gate.test.ts
T
jiwangyihao ec00294462 fix(ai): 仅为 paste-code provider 合成默认手动粘贴码提示
机器人指出之前的 CLI 侧 gating 是无效的:runLocalLogin 对非 paste-code provider
省略 onManualCodeInput,但 AuthStorage.login 仍以 ctrl.onManualCodeInput ??
manualCodeInput 注入默认值,因此 loopback OAuth provider 的 OAuthCallbackFlow
仍会让 readline 粘贴提示与 HTTP 回调竞争;回调先到时该提示悬挂,终端进入
脏/阻塞状态。

在唯一汇聚点 AuthStorage.login 做权威 gating:

- 仅当 provider 属于 PASTE_CODE_LOGIN_PROVIDERS 时才合成默认 manualCodeInput;
  loopback provider 不再获得手动码竞争。
- 调用方显式传入的 onManualCodeInput 对任意 provider 仍被透传(逃生舱)。
- 该修复覆盖所有调用方,不止 auth-broker CLI。
- CLI 侧的 usesManualInput gating 保留为纵深防御,并更新注释指明 storage 层
  才是权威闸门,纠正机器人指出的“只在此处省略”误导性表述。

新增针对 storage 契约的回归测试(auth-storage-manual-code-gate.test.ts):
loopback provider 不被注入默认提示;显式提示对 loopback 仍透传;paste-code
provider(gitlab-duo-agent)在调用方省略时被合成默认提示并经 onPrompt 路由。
2026-06-26 16:13:25 +08:00

107 lines
4.3 KiB
TypeScript

import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage";
import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth";
import * as gitlabDuoWorkflowOAuth from "@oh-my-pi/pi-ai/registry/oauth/gitlab-duo-workflow";
import type { OAuthLoginCallbacks, OAuthProviderInterface } from "@oh-my-pi/pi-ai/registry/oauth/types";
const TEST_SOURCE = "manual-code-gate-test";
// A custom (extension) OAuth provider is, by construction, NOT in
// PASTE_CODE_LOGIN_PROVIDERS (that set is built from the static built-in
// registry's `pasteCodeFlow` flags). It therefore exercises the loopback path:
// AuthStorage.login must NOT synthesize a default manual-code prompt for it.
function registerCapturingLoopbackProvider(id: string): { received: () => OAuthLoginCallbacks | undefined } {
let captured: OAuthLoginCallbacks | undefined;
const provider: OAuthProviderInterface = {
id,
name: `Capturing ${id}`,
sourceId: TEST_SOURCE,
async login(callbacks: OAuthLoginCallbacks) {
captured = callbacks;
// Return an empty string so AuthStorage treats it as "no key entered"
// and skips credential persistence — we only assert the forwarded callbacks.
return "";
},
};
registerOAuthProvider(provider);
return { received: () => captured };
}
describe("AuthStorage.login default manual-code prompt gating", () => {
let store: SqliteAuthCredentialStore;
let storage: AuthStorage;
beforeEach(async () => {
store = new SqliteAuthCredentialStore(new Database(":memory:"));
storage = new AuthStorage(store);
await storage.reload();
});
afterEach(() => {
unregisterOAuthProviders(TEST_SOURCE);
vi.restoreAllMocks();
store.close();
});
it("does NOT synthesize a default manual-code prompt for a loopback provider", async () => {
const capture = registerCapturingLoopbackProvider("loopback-capture-provider");
await storage.login("loopback-capture-provider", {
onAuth: () => {},
onPrompt: async () => "should-not-be-called",
});
const forwarded = capture.received();
expect(forwarded).toBeDefined();
// The loopback OAuthCallbackFlow keys its readline-vs-callback race solely on
// a truthy `onManualCodeInput`; leaving it undefined is what prevents the
// dangling-prompt regression for normal loopback logins.
expect(forwarded?.onManualCodeInput).toBeUndefined();
});
it("honors an explicit caller-supplied manual-code prompt for a loopback provider (escape hatch)", async () => {
const capture = registerCapturingLoopbackProvider("loopback-explicit-provider");
const explicit = async () => "explicit-code";
await storage.login("loopback-explicit-provider", {
onAuth: () => {},
onPrompt: async () => "unused",
onManualCodeInput: explicit,
});
const forwarded = capture.received();
expect(forwarded?.onManualCodeInput).toBe(explicit);
});
it("synthesizes a default manual-code prompt for a paste-code provider when the caller omits one", async () => {
// gitlab-duo-agent is a built-in pasteCodeFlow provider (fixed vscode://
// redirect): the default manual-code prompt is required so the user can paste
// the callback URL. Spy on the lazily-imported login to capture the callbacks
// AuthStorage forwards, and have it short-circuit before any network call.
let forwarded: OAuthLoginCallbacks | undefined;
const promptText = "PASTE-CODE-DEFAULT-PROMPT-PROBE";
vi.spyOn(gitlabDuoWorkflowOAuth, "loginGitLabDuoWorkflow").mockImplementation(
async (callbacks: OAuthLoginCallbacks) => {
forwarded = callbacks;
return { access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000 };
},
);
await storage.login("gitlab-duo-agent", {
onAuth: () => {},
onPrompt: async prompt => {
// The synthesized default routes its prompt through onPrompt; return a
// sentinel so we can prove the default (not the caller) produced it.
return `${promptText}:${prompt.message}`;
},
});
expect(forwarded).toBeDefined();
expect(forwarded?.onManualCodeInput).toBeDefined();
// Invoking the synthesized default must route through the caller's onPrompt.
const result = await forwarded?.onManualCodeInput?.();
expect(result).toContain(promptText);
});
});