Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/wbot/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to the public wbot client are documented in this file.

## Unreleased

## 0.2.9 - 2026-11-06

### Changed

- Update the CLI framework to `@celados/argc` 8.1.0. CLI and MCP resolve the API key through one
declared value source, lazily and only for commands that call the Platform: `WBOT_API_KEY`, then the
key saved by `wbot auth set`, then (only when `WBOT_PLATFORM_URL` is the test origin) the team test
Tenant key through latch. `wbot @schema` and `--help` list the sources under `values:`.
- A missing API key now fails with `error: DOMAIN_ERROR`, `code: value_unavailable` and a `tried:` list
(one entry per source, with reason and hint) instead of a `RUNTIME_ERROR` sentence. MCP tool errors
carry the same structured envelope. A damaged credentials file is reported as `invalid_credentials`
without echoing its content.

## 0.2.8 - 2026-11-06

### Changed
Expand Down
2 changes: 2 additions & 0 deletions packages/wbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ wbot auth set

`wbot auth set` reads the key without echoing it and stores it at `$XDG_CONFIG_HOME/wbot/credentials.json`, or `~/.config/wbot/credentials.json` when `XDG_CONFIG_HOME` is unset. For automation, set `WBOT_API_KEY`; it overrides the stored credential. `WBOT_PLATFORM_URL` is an optional development or self-hosted endpoint override.

The API key is resolved lazily, only by commands that call the Platform (`@schema`, `--help` and `auth set` never read it), from the first source that has one: `WBOT_API_KEY`, then the key saved by `wbot auth set`, then — only when `WBOT_PLATFORM_URL` is `https://wbot-api-test.celados.com` — the team's test Tenant key through [latch](https://github.com/celados/latch). `wbot @schema` lists these sources under `values:`. When none has a key, the command fails before any network request with `error: DOMAIN_ERROR`, `code: value_unavailable`, and one `tried:` entry per source with its reason and next step.

The default public API is `https://wbot-api-prod.celados.com`. Internal dogfood API keys created by the test deployment require `WBOT_PLATFORM_URL=https://wbot-api-test.celados.com`. This reuses `WBOT_API_KEY`, the local credentials file, and the same CLI and MCP schemas; there is no test-specific executable.

The `wbot-cloud-prod.celados.com` and `wbot-cloud-test.celados.com` origins belong to Convex browser clients; they are not valid `WBOT_PLATFORM_URL` values.
Expand Down
5 changes: 3 additions & 2 deletions packages/wbot/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@celados/wbot",
"version": "0.2.8",
"version": "0.2.9",
"private": true,
"description": "Agent-first conversation reading and authorized text and JPEG sending with read-only MCP tools",
"license": "MIT",
Expand All @@ -23,7 +23,8 @@
"build": "bun build ./wbot-cli.ts --target=bun --outfile=dist/wbot.js"
},
"devDependencies": {
"@celados/argc": "^8.0.0",
"@celados/argc": "^8.1.0",
"@celados/latch-source": "^0.1.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@valibot/to-json-schema": "1.7.1",
"valibot": "1.4.2",
Expand Down
15 changes: 14 additions & 1 deletion packages/wbot/wbot-agent-tools.bdd.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ And 本地凭据文件保持不变
**场景 2.5:缺少凭据时在网络请求前失败**
Given 环境变量和本地凭据文件都没有可用 API Key
When Agent 通过 CLI 或 MCP 发起读取
Then 动作在发出网络请求前失败
Then 动作在发出网络请求前失败,错误为 `value_unavailable`,逐个列出尝试过的来源
And 错误指引用户运行 `wbot auth set` 或配置 `WBOT_API_KEY`
And 错误不伪装成空会话列表

Expand All @@ -110,6 +110,19 @@ Then 动作在发出网络请求前失败
And 错误指引用户重新运行 `wbot auth set`
And 错误不把文件内容输出给 Agent

**场景 2.8:未设置环境变量时复用 `wbot auth set` 保存的凭据**
Given 本地凭据文件已经保存一把 API Key
And 当前进程没有提供 `WBOT_API_KEY`
When Agent 通过 CLI 或 MCP 发起读取
Then 本次调用使用本地凭据文件中的 API Key

**场景 2.9:团队测试 key 不会流向生产调用**
Given 环境变量和本地凭据文件都没有可用 API Key
When Agent 以默认生产 endpoint 发起读取
Then 团队 vault 中的测试 Tenant key 不参与解析,动作失败
When 内部成员以 `WBOT_PLATFORM_URL=https://wbot-api-test.celados.com` 发起读取
Then CLI 通过 latch 读取团队测试 Tenant key,无需渲染 `.env` 或保存本地凭据

**场景 2.7:普通用户不需要配置 Platform URL**
Given 用户使用公开发布的 wbot 工具
And 用户没有提供 endpoint 覆盖
Expand Down
83 changes: 70 additions & 13 deletions packages/wbot/wbot-agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { afterEach, describe, expect, test } from "vitest";

import type { PlatformClient } from "./platform-client";

import { DEFAULT_WBOT_PLATFORM_URL, resolveWbotConfig } from "./wbot-config";
import {
DEFAULT_WBOT_PLATFORM_URL,
TEST_WBOT_PLATFORM_URL,
resolveWbotPlatformUrl,
} from "./wbot-config";
import { createWbotMcpServer } from "./wbot-mcp";

type PluginMcpConfig = {
Expand Down Expand Up @@ -76,16 +80,52 @@ describe("功能 2:CLI 与 MCP 复用安全的本机凭据", () => {
test("场景 2.4:环境变量 API Key 覆盖本地凭据", async () => {
const configRoot = await createTemporaryDirectory();
await writeCredentials(configRoot, "file-secret");
const platform = await createRecordingPlatformServer();

const config = await resolveWbotConfig({
const result = await runCli(["conversations.list"], {
WBOT_API_KEY: "environment-secret",
WBOT_PLATFORM_URL: platform.url,
XDG_CONFIG_HOME: configRoot,
});

expect(config.apiKey).toBe("environment-secret");
expect(result.exitCode, result.stderr).toBe(0);
expect(platform.authorizations).toEqual(["Bearer environment-secret"]);
expect(await readCredentials(configRoot)).toContain("file-secret");
});

test("场景 2.8:未设置环境变量时使用 wbot auth set 保存的凭据", async () => {
const configRoot = await createTemporaryDirectory();
await writeCredentials(configRoot, "file-secret");
const platform = await createRecordingPlatformServer();

const result = await runCli(["conversations.list"], {
WBOT_PLATFORM_URL: platform.url,
XDG_CONFIG_HOME: configRoot,
});

expect(result.exitCode, result.stderr).toBe(0);
expect(platform.authorizations).toEqual(["Bearer file-secret"]);
});

test("场景 2.9:团队 vault 中的测试 key 只在目标为测试服务时参与解析", async () => {
const configRoot = await createTemporaryDirectory();
// NODE_ENV=test keeps the latch source from ever running the real vault.
const environment = { XDG_CONFIG_HOME: configRoot, NODE_ENV: "test" };

const production = await runCli(["conversations.list"], environment);
const testPlatform = await runCli(["conversations.list"], {
...environment,
WBOT_PLATFORM_URL: TEST_WBOT_PLATFORM_URL,
});

expect(production.stderr).toContain("value_unavailable");
expect(production.stderr).toContain("not_test_platform");
expect(production.stderr).not.toContain("disabled_in_test");
expect(testPlatform.stderr).toContain("value_unavailable");
expect(testPlatform.stderr).toContain("disabled_in_test");
expect(testPlatform.stderr).not.toContain("not_test_platform");
});

test("场景 2.5:缺少凭据时 CLI 在网络请求前失败", async () => {
const configRoot = await createTemporaryDirectory();
const result = await runCli(["conversations.list"], {
Expand All @@ -95,7 +135,8 @@ describe("功能 2:CLI 与 MCP 复用安全的本机凭据", () => {

expect(result.exitCode).not.toBe(0);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("RUNTIME_ERROR");
expect(result.stderr).toContain("DOMAIN_ERROR");
expect(result.stderr).toContain("value_unavailable");
expect(result.stderr).toContain("wbot auth set");
expect(result.stderr).toContain("WBOT_API_KEY");
});
Expand All @@ -115,15 +156,11 @@ describe("功能 2:CLI 与 MCP 复用安全的本机凭据", () => {
expect(result.stderr).not.toContain(damagedSecret);
});

test("场景 2.7:默认使用生产 endpoint,并允许显式覆盖", async () => {
const defaultConfig = await resolveWbotConfig({ WBOT_API_KEY: "secret" });
const overriddenConfig = await resolveWbotConfig({
WBOT_API_KEY: "secret",
WBOT_PLATFORM_URL: "http://localhost:8787",
});

expect(defaultConfig.baseUrl).toBe(DEFAULT_WBOT_PLATFORM_URL);
expect(overriddenConfig.baseUrl).toBe("http://localhost:8787");
test("场景 2.7:默认使用生产 endpoint,并允许显式覆盖", () => {
expect(resolveWbotPlatformUrl({})).toBe(DEFAULT_WBOT_PLATFORM_URL);
expect(resolveWbotPlatformUrl({ WBOT_PLATFORM_URL: "http://localhost:8787" })).toBe(
"http://localhost:8787",
);
});

test("场景 2.3:非交互 auth set 立即失败并指引环境变量", async () => {
Expand Down Expand Up @@ -375,6 +412,26 @@ const createTemporaryDirectory = async () => {
return path;
};

const createRecordingPlatformServer = () =>
new Promise<{ url: string; authorizations: Array<string | undefined> }>((resolve, reject) => {
const authorizations: Array<string | undefined> = [];
const server = createServer((request, response) => {
authorizations.push(request.headers.authorization);
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify({ items: [], nextCursor: "next", hasMore: false }));
});
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address === null || typeof address === "string") {
reject(new Error("Test server did not expose a TCP address"));
return;
}
servers.push(server);
resolve({ url: `http://127.0.0.1:${address.port}`, authorizations });
});
});

const credentialsPath = (configRoot: string) => join(configRoot, "wbot", "credentials.json");

const writeCredentials = async (configRoot: string, apiKey: string, validJson = true) => {
Expand Down
8 changes: 5 additions & 3 deletions packages/wbot/wbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import * as v from "valibot";
import type { WbotClient } from "./platform-client";

import packageJson from "./package.json" with { type: "json" };
import { createWbotPlatformClientFromEnvironment, storeWbotApiKey } from "./wbot-config";
import { storeWbotApiKey } from "./wbot-config";
import { runWbotMcpServer } from "./wbot-mcp";
import { createWbotPlatformClient, wbotValues } from "./wbot-values";

const schema = toStandardJsonSchema;
const nonEmptyString = v.pipe(v.string(), v.minLength(1));
Expand Down Expand Up @@ -124,11 +125,12 @@ const app = cli(commands, {
name: "wbot",
version: packageJson.version,
description: "Read WeChat conversations and send authorized text or JPEG images through wbot",
values: wbotValues,
});

export const runWbotCli = async () => {
const callPlatform = async <Result>(operation: (client: WbotClient) => Promise<Result>) =>
operation(await createWbotPlatformClientFromEnvironment(process.env));
operation(await createWbotPlatformClient());

await app.run({
handlers: {
Expand Down Expand Up @@ -201,7 +203,7 @@ export const runWbotCli = async () => {
get: async (args) =>
JSON.stringify(await callPlatform((client) => client.getOutboundSend(args.input))),
},
mcp: runWbotMcpServer,
mcp: () => runWbotMcpServer(createWbotPlatformClient),
},
});
};
Expand Down
Loading
Loading