mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(pi-web-search): 搜索凭据迁移到 Pi auth.json
三个搜索 key 改为 auth.json 中的 tavily/exa/keenable api_key 凭据, pi-web-search/auth.ts 复刻上游 resolve-config-value 语义(!command、 $VAR 插值、credential env 优先、$$/$! 转义)并在每次调用时读取, 进程环境变量与 search.env 不再被直接读取。search_config.sh 重写为 scripts/search-auth.mjs 的薄封装:加锁原子写、权限 600、保留其他 凭据条目,支持 --migrate 从旧 search.env 一次性导入。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
ee1e6999d7
commit
0c9823ef5c
@@ -201,12 +201,8 @@ test("all package extensions load together without global registration conflicts
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PI_CODING_AGENT_DIR: join(home, ".pi-agent"),
|
||||
MY_PI_SEARCH_CONFIG: join(home, "missing-search.env"),
|
||||
PATH: `${mockBin}:${process.env.PATH ?? ""}`,
|
||||
PI_OFFLINE: "1",
|
||||
TAVILY_API_KEY: "",
|
||||
EXA_API_KEY: "",
|
||||
KEENABLE_API_KEY: "",
|
||||
};
|
||||
const runBundle = () => spawnSync("pi", args, {
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getSearchApiKey,
|
||||
getSearchAuthPath,
|
||||
getSearchCredentialEnv,
|
||||
parseAuthKeyTemplate,
|
||||
resolveAuthKeyValue,
|
||||
} from "../pi-web-search/auth.ts";
|
||||
|
||||
async function withAuthFile(
|
||||
data: Record<string, unknown>,
|
||||
fn: (authPath: string) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(authPath, JSON.stringify(data));
|
||||
await fn(authPath);
|
||||
}
|
||||
|
||||
test("auth path follows PI_CODING_AGENT_DIR and falls back to ~/.pi/agent", () => {
|
||||
assert.equal(
|
||||
getSearchAuthPath({ PI_CODING_AGENT_DIR: "/tmp/agent" }),
|
||||
"/tmp/agent/auth.json",
|
||||
);
|
||||
assert.equal(getSearchAuthPath({ HOME: "/tmp/home" }), "/tmp/home/.pi/agent/auth.json");
|
||||
});
|
||||
|
||||
test("literal api_key credentials resolve verbatim", async () => {
|
||||
await withAuthFile(
|
||||
{
|
||||
tavily: { type: "api_key", key: "tvly-test" },
|
||||
exa: { type: "api_key", key: " exa-test " },
|
||||
keenable: { type: "api_key", key: "" },
|
||||
anthropic: { type: "oauth", access: "a", refresh: "r", expires: 1 },
|
||||
},
|
||||
(authPath) => {
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-test");
|
||||
assert.equal(getSearchApiKey("exa", { authPath }), "exa-test");
|
||||
assert.equal(getSearchApiKey("keenable", { authPath }), undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("missing file, malformed json, non-api_key entries all resolve to undefined", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-"));
|
||||
assert.equal(getSearchApiKey("tavily", { authPath: join(dir, "absent.json") }), undefined);
|
||||
const bad = join(dir, "bad.json");
|
||||
await writeFile(bad, "not json{");
|
||||
assert.equal(getSearchApiKey("tavily", { authPath: bad }), undefined);
|
||||
await withAuthFile({ tavily: { type: "oauth", access: "a", refresh: "r", expires: 1 } }, (authPath) => {
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), undefined);
|
||||
});
|
||||
await withAuthFile({ tavily: "literal-string-entry" }, (authPath) => {
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("$VAR and ${VAR} interpolate; credential env beats ambient env", async () => {
|
||||
await withAuthFile(
|
||||
{
|
||||
tavily: { type: "api_key", key: "$TAVILY_KEY" },
|
||||
exa: { type: "api_key", key: "prefix-${EXA_KEY}-suffix" },
|
||||
keenable: { type: "api_key", key: "$SHARED", env: { SHARED: "scoped-wins" } },
|
||||
},
|
||||
(authPath) => {
|
||||
const env = { TAVILY_KEY: "from-env", EXA_KEY: "braced", SHARED: "ambient-loses" };
|
||||
assert.equal(getSearchApiKey("tavily", { authPath, env }), "from-env");
|
||||
assert.equal(getSearchApiKey("exa", { authPath, env }), "prefix-braced-suffix");
|
||||
assert.equal(getSearchApiKey("keenable", { authPath, env }), "scoped-wins");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a missing env variable resolves the whole key to undefined", async () => {
|
||||
await withAuthFile({ tavily: { type: "api_key", key: "$NOPE_MISSING" } }, (authPath) => {
|
||||
assert.equal(getSearchApiKey("tavily", { authPath, env: {} }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("$$ and $! escape to literal characters", async () => {
|
||||
await withAuthFile(
|
||||
{ tavily: { type: "api_key", key: "cost-is-$$5-and-$!literal" } },
|
||||
(authPath) => {
|
||||
assert.equal(getSearchApiKey("tavily", { authPath, env: {} }), "cost-is-$5-and-!literal");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("!command executes and caches trimmed stdout", async () => {
|
||||
const authPath = join(await mkdtemp(join(tmpdir(), "my-pi-auth-")), "auth.json");
|
||||
await writeFile(
|
||||
authPath,
|
||||
JSON.stringify({ tavily: { type: "api_key", key: "!printf secret-from-cmd" } }),
|
||||
);
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), "secret-from-cmd");
|
||||
|
||||
let calls = 0;
|
||||
const runCommand = (command: string) => {
|
||||
calls += 1;
|
||||
assert.equal(command, "get-key --flag");
|
||||
return "injected";
|
||||
};
|
||||
await withAuthFile({ exa: { type: "api_key", key: "!get-key --flag" } }, (otherPath) => {
|
||||
assert.equal(getSearchApiKey("exa", { authPath: otherPath, runCommand }), "injected");
|
||||
assert.equal(getSearchApiKey("exa", { authPath: otherPath, runCommand }), "injected");
|
||||
});
|
||||
});
|
||||
|
||||
test("a failing !command resolves to undefined", async () => {
|
||||
await withAuthFile({ tavily: { type: "api_key", key: "!exit 3" } }, (authPath) => {
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("credential env map exposes non-key config like KEENABLE_API_URL", async () => {
|
||||
await withAuthFile(
|
||||
{ keenable: { type: "api_key", key: "k", env: { KEENABLE_API_URL: "https://keenable.example" } } },
|
||||
(authPath) => {
|
||||
assert.equal(
|
||||
getSearchCredentialEnv("keenable", "KEENABLE_API_URL", { authPath }),
|
||||
"https://keenable.example",
|
||||
);
|
||||
assert.equal(getSearchCredentialEnv("keenable", "MISSING", { authPath }), undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("template parser handles env names, braces and escapes", () => {
|
||||
assert.deepEqual(parseAuthKeyTemplate("$A_${B}-$$-$!-x"), [
|
||||
{ type: "env", name: "A_" },
|
||||
{ type: "env", name: "B" },
|
||||
{ type: "literal", value: "-$-!-x" },
|
||||
]);
|
||||
});
|
||||
|
||||
const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname;
|
||||
const SEARCH_SH = new URL("../search_config.sh", import.meta.url).pathname;
|
||||
|
||||
function runMjs(args: string[], env: Record<string, string> = {}) {
|
||||
return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } });
|
||||
}
|
||||
|
||||
test("search-auth.mjs --set writes api_key entries and preserves other providers", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(
|
||||
authPath,
|
||||
JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }),
|
||||
);
|
||||
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-new", "--set", "exa=!op read x"]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" });
|
||||
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" });
|
||||
assert.deepEqual(written.exa, { type: "api_key", key: "!op read x" });
|
||||
assert.equal((await stat(authPath)).mode & 0o777, 0o600);
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-new");
|
||||
});
|
||||
|
||||
test("search-auth.mjs preserves an existing credential env map on --set", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(
|
||||
authPath,
|
||||
JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }),
|
||||
);
|
||||
const result = runMjs(["--auth", authPath, "--set", "keenable=new-key"]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.keenable, {
|
||||
type: "api_key",
|
||||
key: "new-key",
|
||||
env: { KEENABLE_API_URL: "https://k.example" },
|
||||
});
|
||||
});
|
||||
|
||||
test("search-auth.mjs creates a missing auth.json and honors PI_CODING_AGENT_DIR", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const agentDir = join(dir, "agent");
|
||||
const result = runMjs(["--set", "keenable=keen-1"], { PI_CODING_AGENT_DIR: agentDir });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" });
|
||||
});
|
||||
|
||||
test("search-auth.mjs --unset removes only the named provider", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(authPath, JSON.stringify({ tavily: { type: "api_key", key: "t" }, exa: { type: "api_key", key: "e" } }));
|
||||
const result = runMjs(["--auth", authPath, "--unset", "tavily"]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.equal(written.tavily, undefined);
|
||||
assert.deepEqual(written.exa, { type: "api_key", key: "e" });
|
||||
});
|
||||
|
||||
test("search-auth.mjs refuses to clobber a malformed auth.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(authPath, "{oops");
|
||||
const result = runMjs(["--auth", authPath, "--set", "tavily=x"]);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /不是合法 JSON|abort|clobber/i);
|
||||
assert.equal(await readFile(authPath, "utf8"), "{oops");
|
||||
});
|
||||
|
||||
test("search-auth.mjs --migrate imports search.env keys without overwriting existing entries", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(authPath, JSON.stringify({ tavily: { type: "api_key", key: "existing" } }));
|
||||
const envPath = join(dir, "search.env");
|
||||
await writeFile(envPath, "export TAVILY_API_KEY=old\nEXA_API_KEY=exa-file\nKEENABLE_API_KEY=bad$(cmd)\n");
|
||||
const result = runMjs(["--auth", authPath, "--migrate", envPath]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /已迁移 exa/);
|
||||
assert.match(result.stdout, /tavily/);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.equal(written.tavily.key, "existing");
|
||||
assert.equal(written.exa.key, "exa-file");
|
||||
assert.equal(written.keenable, undefined);
|
||||
});
|
||||
|
||||
test("search-auth.mjs --has and --list never print key material", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(authPath, JSON.stringify({ tavily: { type: "api_key", key: "supersecret" } }));
|
||||
assert.equal(runMjs(["--auth", authPath, "--has", "tavily"]).status, 0);
|
||||
assert.equal(runMjs(["--auth", authPath, "--has", "exa"]).status, 1);
|
||||
const list = runMjs(["--auth", authPath, "--list"]);
|
||||
assert.equal(list.status, 0);
|
||||
assert.match(list.stdout, /tavily: 已配置/);
|
||||
assert.doesNotMatch(list.stdout, /supersecret/);
|
||||
});
|
||||
|
||||
test("search_config.sh writes keys through search-auth.mjs noninteractively", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
const result = spawnSync("sh", [SEARCH_SH, "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" });
|
||||
assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" });
|
||||
assert.equal((await stat(authPath)).mode & 0o777, 0o600);
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getSearchConfigPath,
|
||||
loadSearchConfigEnv,
|
||||
parseSearchConfig,
|
||||
} from "../extensions/search-config.ts";
|
||||
|
||||
test("search config parses only supported safe assignments", () => {
|
||||
assert.deepEqual(
|
||||
parseSearchConfig(`
|
||||
export TAVILY_API_KEY=tvly-test_1
|
||||
EXA_API_KEY=exa-test.2
|
||||
UNKNOWN_KEY=ignored
|
||||
KEENABLE_API_KEY=$(not-allowed)
|
||||
`),
|
||||
{
|
||||
TAVILY_API_KEY: "tvly-test_1",
|
||||
EXA_API_KEY: "exa-test.2",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("search config does not override explicit environment values", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "my-pi-search-config-"));
|
||||
const configPath = join(root, "search.env");
|
||||
await import("node:fs/promises").then(({ writeFile }) =>
|
||||
writeFile(configPath, "export TAVILY_API_KEY=file-value\nexport EXA_API_KEY=exa-file\n"),
|
||||
);
|
||||
const env: Record<string, string | undefined> = { TAVILY_API_KEY: "explicit-value" };
|
||||
const effective = loadSearchConfigEnv(env, configPath);
|
||||
assert.equal(env.TAVILY_API_KEY, "explicit-value");
|
||||
assert.equal(env.EXA_API_KEY, "exa-file");
|
||||
assert.deepEqual(effective, { TAVILY_API_KEY: "explicit-value", EXA_API_KEY: "exa-file" });
|
||||
});
|
||||
|
||||
test("search config path follows XDG_CONFIG_HOME", () => {
|
||||
assert.equal(getSearchConfigPath({ XDG_CONFIG_HOME: "/tmp/config" }), "/tmp/config/my-pi/search.env");
|
||||
});
|
||||
|
||||
test("search_config.sh supports noninteractive updates and preserves omitted keys", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "my-pi-search-script-"));
|
||||
const script = new URL("../search_config.sh", import.meta.url);
|
||||
let result = spawnSync("sh", [script.pathname, "--tavily", "tvly-test", "--exa", "exa-test"], {
|
||||
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
result = spawnSync("sh", [script.pathname, "--keenable", "keen-test"], {
|
||||
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config") },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const configPath = join(home, ".config", "my-pi", "search.env");
|
||||
const content = await readFile(configPath, "utf8");
|
||||
assert.match(content, /^export TAVILY_API_KEY=tvly-test$/m);
|
||||
assert.match(content, /^export EXA_API_KEY=exa-test$/m);
|
||||
assert.match(content, /^export KEENABLE_API_KEY=keen-test$/m);
|
||||
assert.equal((await stat(configPath)).mode & 0o777, 0o600);
|
||||
});
|
||||
@@ -8,12 +8,13 @@ const packageJson = JSON.parse(await readFile(new URL("../package.json", import.
|
||||
pi: { extensions: string[]; skills: string[] };
|
||||
};
|
||||
|
||||
test("pi-web-search is the single search surface, loading after search-config", () => {
|
||||
test("pi-web-search is the single search surface and reads credentials from auth.json", () => {
|
||||
const entries = packageJson.pi.extensions;
|
||||
assert.ok(entries.includes("./pi-web-search/index.ts"), "the bundle must load pi-web-search");
|
||||
assert.ok(
|
||||
entries.indexOf("./extensions/search-config.ts") < entries.indexOf("./pi-web-search/index.ts"),
|
||||
"search credentials must load before pi-web-search",
|
||||
assert.equal(
|
||||
entries.includes("./extensions/search-config.ts"),
|
||||
false,
|
||||
"search keys come from auth.json — the env loader extension is retired",
|
||||
);
|
||||
for (const retired of [
|
||||
"./extensions/mcp.ts",
|
||||
|
||||
Reference in New Issue
Block a user