u1s1-cli 1.14.6 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/agent-setup.d.ts +7 -0
  2. package/dist/agent-setup.js +14 -0
  3. package/dist/agents/agent-config.d.ts +83 -0
  4. package/dist/agents/agent-config.js +154 -0
  5. package/dist/agents/claude.d.ts +113 -0
  6. package/dist/agents/claude.js +411 -0
  7. package/dist/agents/codex.d.ts +111 -0
  8. package/dist/agents/codex.js +392 -0
  9. package/dist/agents/launch.d.ts +109 -0
  10. package/dist/agents/launch.js +444 -0
  11. package/dist/agents/pi.d.ts +9 -0
  12. package/dist/agents/pi.js +562 -0
  13. package/dist/agents/prefs.d.ts +35 -0
  14. package/dist/agents/prefs.js +96 -0
  15. package/dist/agents/runtime.d.ts +354 -0
  16. package/dist/agents/runtime.js +884 -0
  17. package/dist/agents/status.d.ts +20 -0
  18. package/dist/agents/status.js +241 -0
  19. package/dist/config-command.d.ts +18 -0
  20. package/dist/config-command.js +335 -0
  21. package/dist/config.d.ts +17 -0
  22. package/dist/config.js +35 -1
  23. package/dist/device-auth.d.ts +37 -1
  24. package/dist/device-auth.js +43 -8
  25. package/dist/embed.d.ts +13 -0
  26. package/dist/embed.js +15 -0
  27. package/dist/index.js +22 -556
  28. package/dist/launcher.d.ts +48 -0
  29. package/dist/launcher.js +235 -0
  30. package/dist/mcp-server.d.ts +105 -0
  31. package/dist/mcp-server.js +449 -0
  32. package/dist/pi-ui-text.d.ts +57 -0
  33. package/dist/pi-ui-text.js +148 -0
  34. package/dist/usage.js +2 -2
  35. package/package.json +1 -1
  36. package/scripts/p0-claude-code/README.md +39 -0
  37. package/scripts/p0-claude-code/launch-claude.ts +94 -0
  38. package/scripts/p0-claude-code/replay.ts +74 -0
  39. package/scripts/p0-claude-code/seed-local.mjs +145 -0
  40. package/scripts/p0-claude-code/tap.mjs +105 -0
  41. package/scripts/patch-pi.js +76 -55
  42. package/scripts/pi-patch-report.mjs +224 -0
  43. package/scripts/pi-upgrade.mjs +829 -0
  44. package/scripts/render-regression.mjs +67 -5
package/dist/usage.js CHANGED
@@ -72,7 +72,7 @@ function groupPackages(packages) {
72
72
  }
73
73
  function packageScopeNote(pkg) {
74
74
  if (pkg.kind === "login_checkin") {
75
- return tr("仅限 u1s1 客户端使用 · 全模型可用", "u1s1 clients only · all models");
75
+ return tr("全模型可用", "All models");
76
76
  }
77
77
  if (pkg.scope === "free") {
78
78
  // 只有按日发放的免费包(首月/年度包的每日额度)才 0 点恢复;一次性免费包用完就没了。
@@ -80,7 +80,7 @@ function packageScopeNote(pkg) {
80
80
  ? tr("免费包适用模型 · 每日额度北京时间 0 点恢复", "Free-tier models · daily quota resets at 00:00 Beijing time (UTC+8)")
81
81
  : tr("免费包适用模型", "Free-tier models");
82
82
  }
83
- return tr("仅限 u1s1 客户端使用 · 免费包适用模型", "u1s1 clients only · free-tier models");
83
+ return tr("免费包适用模型", "Free-tier models");
84
84
  }
85
85
  function packageLabel(pkg) {
86
86
  const count = pkg.count > 1 ? ` ×${pkg.count}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.14.6",
3
+ "version": "1.15.1",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,39 @@
1
+ # P0 验证:签名代理带起官方 Claude Code + /v1/messages 提示缓存
2
+
3
+ 规划见 `docs/multi-client-launcher-plan-2026-11-13.md` 第 3.4、4.3、4.4、7 节。只用于本地验证,不上线。
4
+
5
+ | 文件 | 作用 |
6
+ | --- | --- |
7
+ | `seed-local.mjs` | 给本机 `wrangler dev --env intl` 的网关建渠道、Claude Haiku 模型行、测试用户(购买余额)和设备凭证,写出隔离 HOME 下的 `.u1s1/config.json` |
8
+ | `launch-claude.ts` | 起 CLI 的 `ensureSigningProxy`,以子进程启动 `claude`(独立 `CLAUDE_CONFIG_DIR`、`ANTHROPIC_BASE_URL` 不带 `/v1`、`ANTHROPIC_AUTH_TOKEN`=本地 token、`--settings`),父进程等子进程退出 |
9
+ | `tap.mjs` | 本地录包反代:`P0_TAP_DIR` 时插在 claude 与代理之间;单独跑时配 `UPSTREAM_OVERRIDES` 插在网关与上游之间 |
10
+ | `replay.ts` | 把录到的一次 Claude Code 请求重放 N 次(直连上游 / 经本地网关),打印 usage |
11
+
12
+ 测试:`packages/cli/test/signing-proxy-claude-code.test.ts`(代理对 Claude Code 请求的处理)、`workers/gateway/test/anthropic-cache-roundtrip.test.ts`(两次转换丢了什么,特征测试)。
13
+
14
+ ## 跑法
15
+
16
+ ```bash
17
+ # 网关:见 scratchpad 的 p0-local-stack.md(wrangler dev 需要转出入口 + -c + --local-upstream)
18
+ set -a; . ~/code/selfhost/secrets/u1s1-prod/wokey.env; set +a
19
+ node packages/cli/scripts/p0-claude-code/seed-local.mjs --home $SCRATCH/home
20
+ cd $SCRATCH/work && HOME=$SCRATCH/home P0_CLAUDE_CONFIG_DIR=$SCRATCH/claude-cfg P0_TAP_DIR=$SCRATCH/tap \
21
+ <repo>/packages/cli/node_modules/.bin/tsx <repo>/packages/cli/scripts/p0-claude-code/launch-claude.ts \
22
+ -p "Use the Bash tool to run: cat note.txt — then reply with just the number in it." \
23
+ --output-format stream-json --verbose --max-turns 4
24
+ ```
25
+
26
+ ## 2026-11-13 实测结果(claude 2.1.322,claude-haiku-4-5)
27
+
28
+ - Bash 工具一轮往返、ToolSearch → TaskCreate 三轮往返都正常结束(exit 0)。
29
+ - usage 行:`client_type=terminal`、`auth_kind=device`、`client_security_verdict=suspected_relay`,信号
30
+ `client_signature_claude_code, external_client_ua, missing_brand_prompt, third_party_harness_prompt, valid_device_attestation`。
31
+ 只能花购买余额;admin 发的赠送余额会被 403 拒。
32
+ - 同一份 21.6k token 的 Claude Code 请求重放两次:
33
+
34
+ | 路径 | 第 1 次 | 第 2 次 | 计费 |
35
+ | --- | --- | --- | --- |
36
+ | 直连上游(原生 Messages,带 Claude Code 自己的 3 个 cache_control) | 写缓存 21597 | 读缓存 21597 | — |
37
+ | 网关 → `openai-chat` 渠道(网关自己补 3 个断点,`chat-compat.ts`) | 写缓存 21492 | 读缓存 21492 | 写/读都按售价计,正确 |
38
+ | 网关 → `anthropic-messages` 渠道,单条消息 | 无缓存 21673 | 无缓存 21673 | 全价 |
39
+ | 网关 → `anthropic-messages` 渠道,多条消息 | 上游自动写 21615 | 读 21615 | 写入 0 计费,读取只记 $0.00003(实付约 $0.0022) |
@@ -0,0 +1,94 @@
1
+ // P0 verification: start the CLI's local signing proxy and launch the official
2
+ // Claude Code as a child process through it, without touching the user's own
3
+ // ~/.claude or ~/.u1s1.
4
+ //
5
+ // HOME=<isolated home with .u1s1/config.json> \
6
+ // P0_CLAUDE_CONFIG_DIR=<isolated dir> [P0_TAP_DIR=<dir>] [P0_MODEL=claude-haiku-4-5] \
7
+ // npx tsx packages/cli/scripts/p0-claude-code/launch-claude.ts -p "..." --output-format stream-json --verbose
8
+ //
9
+ // P0_TAP_DIR inserts a recording hop between claude and the signing proxy
10
+ // (tap.mjs), so the exact Claude Code request bodies/headers and the SSE the
11
+ // proxy relays back are captured.
12
+ import { spawn } from "node:child_process";
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { userInfo } from "node:os";
15
+ import { join, resolve } from "node:path";
16
+ import { fetchModels } from "../../src/api.js";
17
+ import { loadConfig } from "../../src/config.js";
18
+ import { ensureSigningProxy } from "../../src/device-auth.js";
19
+ import { startTap } from "./tap.mjs";
20
+
21
+ const realHome = userInfo().homedir;
22
+ if (!process.env.HOME || resolve(process.env.HOME) === resolve(realHome)) {
23
+ throw new Error("HOME must point at an isolated directory, not the real home");
24
+ }
25
+ const configDir = process.env.P0_CLAUDE_CONFIG_DIR;
26
+ if (!configDir || resolve(configDir) === resolve(realHome, ".claude")) {
27
+ throw new Error("P0_CLAUDE_CONFIG_DIR must be set to an isolated directory");
28
+ }
29
+ mkdirSync(configDir, { recursive: true });
30
+
31
+ const cfg = loadConfig();
32
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/v1$/.test(cfg.baseUrl)) {
33
+ throw new Error(`config baseUrl must be a local gateway, got ${cfg.baseUrl}`);
34
+ }
35
+
36
+ // Same order as web.ts prepareWebEnv: one /v1/models fetch seeds the attestation.
37
+ const models = await fetchModels(cfg);
38
+ const signing = await ensureSigningProxy(cfg, "terminal", {
39
+ token: models.clientAttestation,
40
+ expiresInSeconds: models.clientAttestationExpiresInSeconds,
41
+ refresh: async () => {
42
+ const refreshed = await fetchModels(cfg);
43
+ return { token: refreshed.clientAttestation, expiresInSeconds: refreshed.clientAttestationExpiresInSeconds };
44
+ },
45
+ });
46
+ let origin = new URL(signing.baseUrl).origin; // no /v1: Claude Code appends /v1/messages itself
47
+ let tap: { port: number; close: () => void } | undefined;
48
+ if (process.env.P0_TAP_DIR) {
49
+ tap = await startTap({ target: origin, out: process.env.P0_TAP_DIR, label: "claude->proxy" });
50
+ origin = `http://127.0.0.1:${tap!.port}`;
51
+ }
52
+
53
+ // Mirrors workers/gateway/src/claude-code-config.ts claudeCodeSettings (minus base URL / token,
54
+ // which go through env so the real credential never lands in a settings file).
55
+ const model = process.env.P0_MODEL ?? "claude-haiku-4-5";
56
+ const settingsPath = join(configDir, "..", "u1s1-claude-settings.json");
57
+ writeFileSync(settingsPath, JSON.stringify({
58
+ env: {
59
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
60
+ ANTHROPIC_DEFAULT_SONNET_MODEL: model,
61
+ ANTHROPIC_DEFAULT_OPUS_MODEL: model,
62
+ },
63
+ model,
64
+ modelPicker: {
65
+ replaceBuiltInOptions: true,
66
+ options: models.models.map((m) => ({ model: m.id, label: m.name ?? m.id, description: "u1s1 local" })),
67
+ },
68
+ permissions: { deny: ["WebSearch"] },
69
+ }, null, 2));
70
+
71
+ // scrubForeignProviderEnv() deletes ANTHROPIC_AUTH_TOKEN from process.env in the real
72
+ // launcher, so the child env is built explicitly here.
73
+ const childEnv: NodeJS.ProcessEnv = {
74
+ ...process.env,
75
+ CLAUDE_CONFIG_DIR: configDir,
76
+ ANTHROPIC_BASE_URL: origin,
77
+ ANTHROPIC_AUTH_TOKEN: signing.localKey,
78
+ DISABLE_AUTOUPDATER: "1",
79
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
80
+ };
81
+ delete childEnv.ANTHROPIC_API_KEY;
82
+
83
+ const claudeBin = process.env.P0_CLAUDE_BIN ?? "claude";
84
+ const child = spawn(claudeBin, ["--settings", settingsPath, ...process.argv.slice(2)], {
85
+ env: childEnv,
86
+ stdio: "inherit",
87
+ });
88
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
89
+ process.on(signal, () => child.kill(signal));
90
+ }
91
+ // The proxy server is unref'd: keep the parent alive until the child exits.
92
+ const code: number = await new Promise((done) => child.on("exit", (c, s) => done(c ?? (s ? 128 : 1))));
93
+ tap?.close();
94
+ process.exit(code);
@@ -0,0 +1,74 @@
1
+ // P0 ② live check: replay one captured Claude Code request N times, either
2
+ // straight to the upstream (native Messages baseline) or through the LOCAL
3
+ // gateway with the device credential, and print the usage each response reports.
4
+ //
5
+ // direct : WOKEY_API_KEY=... npx tsx replay.ts --capture <tap json> --mode direct --times 2
6
+ // gateway: HOME=<isolated> npx tsx replay.ts --capture <tap json> --mode gateway --model claude-haiku-4-5 --times 2
7
+ //
8
+ // Keep requests few and the model cheap: each replay of a Claude Code turn is ~22k input tokens.
9
+ import { readFileSync } from "node:fs";
10
+ import { loadConfig } from "../../src/config.js";
11
+ import { authorizedFetch } from "../../src/device-auth.js";
12
+
13
+ function arg(name: string, fallback?: string): string | undefined {
14
+ const i = process.argv.indexOf(`--${name}`);
15
+ return i > 0 ? process.argv[i + 1] : fallback;
16
+ }
17
+
18
+ const capture = JSON.parse(readFileSync(arg("capture")!, "utf8"));
19
+ const mode = arg("mode", "gateway");
20
+ const times = Number(arg("times", "2"));
21
+ const body = { ...capture.request.body };
22
+ if (arg("model")) body.model = arg("model");
23
+ if (arg("strip-signatures") === "1") {
24
+ // Signatures minted by the gateway are "" (anthropic-inbound.ts); a native upstream rejects them.
25
+ for (const m of body.messages) {
26
+ if (Array.isArray(m.content)) m.content = m.content.filter((b: { type?: string }) => b.type !== "thinking");
27
+ }
28
+ }
29
+
30
+ const passHeaders: Record<string, string> = { "content-type": "application/json" };
31
+ for (const name of ["anthropic-version", "anthropic-beta", "user-agent", "x-app", "x-claude-code-session-id"]) {
32
+ const value = capture.request.headers[name];
33
+ if (typeof value === "string") passHeaders[name] = value;
34
+ }
35
+
36
+ function usageOf(text: string): { start?: unknown; delta?: unknown } {
37
+ const out: { start?: unknown; delta?: unknown } = {};
38
+ for (const line of text.split("\n")) {
39
+ if (!line.startsWith("data: ")) continue;
40
+ try {
41
+ const event = JSON.parse(line.slice(6));
42
+ if (event.type === "message_start") out.start = event.message?.usage;
43
+ if (event.type === "message_delta") out.delta = event.usage;
44
+ if (event.type === "error") out.delta = event.error;
45
+ } catch {
46
+ // ignore non-JSON frames
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ for (let i = 1; i <= times; i++) {
53
+ let response: Response;
54
+ if (mode === "direct") {
55
+ const key = process.env.WOKEY_API_KEY;
56
+ if (!key) throw new Error("WOKEY_API_KEY not set");
57
+ response = await fetch("https://api.wokey.ai/v1/messages", {
58
+ method: "POST",
59
+ headers: { ...passHeaders, "x-api-key": key },
60
+ body: JSON.stringify(body),
61
+ });
62
+ } else {
63
+ const cfg = loadConfig();
64
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/v1$/.test(cfg.baseUrl)) throw new Error(`not a local gateway: ${cfg.baseUrl}`);
65
+ response = await authorizedFetch(cfg, `${cfg.baseUrl}/messages?beta=true`, {
66
+ method: "POST",
67
+ headers: passHeaders,
68
+ body: JSON.stringify(body),
69
+ });
70
+ }
71
+ const text = await response.text();
72
+ const usage = usageOf(text);
73
+ console.log(JSON.stringify({ mode, round: i, model: body.model, status: response.status, ...usage, ...(response.ok ? {} : { error: text.slice(0, 300) }) }));
74
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // P0 verification only: seed a LOCAL intl gateway (wrangler dev on 127.0.0.1) with
3
+ // two upstream channels, Claude Haiku model rows, a test user with balance and a
4
+ // device credential, then write a u1s1 CLI config.json into an isolated HOME.
5
+ //
6
+ // Never point this at a deployed gateway: it writes straight into the local D1
7
+ // sqlite file and refuses non-loopback gateway URLs.
8
+ //
9
+ // Usage (from repo root, gateway already running via wrangler dev --env intl):
10
+ // WOKEY_API_KEY=... node packages/cli/scripts/p0-claude-code/seed-local.mjs --home <dir>
11
+ // Options:
12
+ // --gateway http://127.0.0.1:8787 local gateway origin
13
+ // --home <dir> isolated HOME; config lands in <dir>/.u1s1/config.json
14
+ // --d1 <sqlite> local D1 file (auto-detected under workers/gateway/.wrangler)
15
+ import { execFileSync } from "node:child_process";
16
+ import { createHash, randomBytes, webcrypto } from "node:crypto";
17
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
22
+ const gatewayDir = join(repoRoot, "workers/gateway");
23
+
24
+ function arg(name, fallback) {
25
+ const i = process.argv.indexOf(`--${name}`);
26
+ return i > 0 ? process.argv[i + 1] : fallback;
27
+ }
28
+
29
+ const gateway = arg("gateway", "http://127.0.0.1:8787");
30
+ const home = arg("home");
31
+ if (!home) throw new Error("--home <dir> is required (isolated HOME for the u1s1 CLI config)");
32
+ if (!/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(gateway)) {
33
+ throw new Error(`refusing non-loopback gateway ${gateway}`);
34
+ }
35
+ const wokeyKey = process.env.WOKEY_API_KEY;
36
+ if (!wokeyKey) throw new Error("WOKEY_API_KEY must be set in the environment");
37
+
38
+ function devVar(name) {
39
+ const text = readFileSync(join(gatewayDir, ".dev.vars"), "utf8");
40
+ const line = text.split("\n").find((l) => l.startsWith(`${name}=`));
41
+ if (!line) throw new Error(`${name} missing from workers/gateway/.dev.vars`);
42
+ return line.slice(name.length + 1).trim();
43
+ }
44
+
45
+ function d1Path() {
46
+ const explicit = arg("d1");
47
+ if (explicit) return explicit;
48
+ const dir = join(gatewayDir, ".wrangler/state/v3/d1/miniflare-D1DatabaseObject");
49
+ const files = readdirSync(dir).filter((f) => f.endsWith(".sqlite") && f !== "metadata.sqlite");
50
+ if (files.length !== 1) throw new Error(`expected exactly one local D1 file in ${dir}, got ${files.join(", ")}`);
51
+ return join(dir, files[0]);
52
+ }
53
+
54
+ const adminKey = devVar("ADMIN_KEY");
55
+ const db = d1Path();
56
+
57
+ function sql(statement) {
58
+ return execFileSync("sqlite3", [db, statement], { encoding: "utf8" }).trim();
59
+ }
60
+
61
+ const q = (v) => (v === null ? "NULL" : typeof v === "number" ? String(v) : `'${String(v).replaceAll("'", "''")}'`);
62
+
63
+ async function admin(method, path, body) {
64
+ const init = { method, headers: { authorization: `Bearer ${adminKey}`, "content-type": "application/json" } };
65
+ if (body !== undefined) init.body = JSON.stringify(body);
66
+ const res = await fetch(`${gateway}/admin${path}`, init);
67
+ const text = await res.text();
68
+ if (!res.ok) throw new Error(`admin ${method} ${path} -> ${res.status} ${text.slice(0, 300)}`);
69
+ return JSON.parse(text);
70
+ }
71
+
72
+ // 1. Channels. wokey-msg mirrors "Claude model + anthropic-messages upstream"; wokey is the
73
+ // openai-chat shape the production intl site uses today, kept for comparison.
74
+ await admin("POST", "/channels", {
75
+ name: "wokey-msg",
76
+ group_name: "wokey-msg",
77
+ chat_url: "https://api.wokey.ai/v1/messages",
78
+ protocol: "anthropic-messages",
79
+ api_key: wokeyKey,
80
+ headers: { "anthropic-version": "2023-06-01" },
81
+ note: "P0 local test",
82
+ });
83
+ await admin("POST", "/channels", {
84
+ name: "wokey",
85
+ group_name: "wokey",
86
+ chat_url: "https://api.wokey.ai/v1/chat/completions",
87
+ protocol: "openai-chat",
88
+ api_key: wokeyKey,
89
+ note: "P0 local test",
90
+ });
91
+
92
+ // 2. Models (cost = Wokey Haiku 4.5 list: $1 / $5 / $0.10 cache read per Mtok) + retail rows.
93
+ const MODELS = [
94
+ { id: "claude-haiku-4-5", provider: "wokey-msg", display: "Claude Haiku 4.5 (anthropic-messages)" },
95
+ { id: "claude-haiku-4-5-oc", provider: "wokey", display: "Claude Haiku 4.5 (openai-chat)" },
96
+ ];
97
+ for (const m of MODELS) {
98
+ sql(`DELETE FROM models WHERE id = ${q(m.id)}; DELETE FROM model_prices WHERE model_id = ${q(m.id)};`);
99
+ sql(`INSERT INTO models (id, provider, upstream_model, display_name, vendor, enabled, reasoning, vision,
100
+ price_currency, input_price, output_price, cache_read_price, context_window, max_tokens, sort, priority)
101
+ VALUES (${q(m.id)}, ${q(m.provider)}, 'claude-haiku-4-5', ${q(m.display)}, 'anthropic', 1, 1, 1,
102
+ 'USD', 1, 5, 0.1, 200000, 64000, 10, 0)`);
103
+ sql(`INSERT INTO model_prices (model_id, price_currency, input_price, output_price, cache_read_price,
104
+ price_source, checked_at, cost_markup)
105
+ VALUES (${q(m.id)}, 'USD', 1.22, 6.1, 0.122, 'p0-local-test', datetime('now'), 0.22)`);
106
+ }
107
+
108
+ // 3. Test user + paid balance + device credential.
109
+ const email = `p0-${Date.now()}@example.invalid`;
110
+ const user = await admin("POST", "/users", { email, name: "p0 local" });
111
+ await admin("POST", `/users/${user.user_id}/grant`, { amount_usd: 5, note: "P0 local test" });
112
+ // Admin grants are gift balance, usable only by official u1s1 clients (credits.ts balanceGrantStatements).
113
+ // Claude Code is classified suspected_relay -> paid balance only, so simulate a top-up instead.
114
+ sql(`UPDATE users SET gift_balance_usd = 0 WHERE id = ${user.user_id}`);
115
+ const keyId = Number(sql(`SELECT id FROM keys WHERE user_id = ${user.user_id} ORDER BY id DESC LIMIT 1`));
116
+
117
+ const pair = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
118
+ const privateJwk = await webcrypto.subtle.exportKey("jwk", pair.privateKey);
119
+ const publicJwk = await webcrypto.subtle.exportKey("jwk", pair.publicKey);
120
+ const deviceToken = `u1s1d-g${randomBytes(32).toString("hex")}`;
121
+ const tokenHash = createHash("sha256").update(deviceToken).digest("hex");
122
+ const storedJwk = JSON.stringify({ kty: "EC", crv: "P-256", x: publicJwk.x, y: publicJwk.y, ext: true });
123
+ sql(`INSERT INTO devices (user_id, key_id, token_hash, public_jwk, name, client_version)
124
+ VALUES (${user.user_id}, ${keyId}, ${q(tokenHash)}, ${q(storedJwk)}, 'p0-local', '1.14.6')`);
125
+ const deviceId = Number(sql(`SELECT id FROM devices WHERE token_hash = ${q(tokenHash)}`));
126
+
127
+ const configDir = join(home, ".u1s1");
128
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
129
+ writeFileSync(join(configDir, "config.json"), JSON.stringify({
130
+ deviceToken,
131
+ deviceId,
132
+ devicePrivateJwk: privateJwk,
133
+ devicePublicJwk: publicJwk,
134
+ baseUrl: `${gateway}/v1`,
135
+ model: "claude-haiku-4-5",
136
+ email,
137
+ }, null, 2), { mode: 0o600 });
138
+
139
+ console.log(JSON.stringify({
140
+ ok: true,
141
+ user_id: user.user_id,
142
+ device_id: deviceId,
143
+ config: join(configDir, "config.json"),
144
+ models: MODELS.map((m) => `${m.id} -> ${m.provider}`),
145
+ }, null, 2));
@@ -0,0 +1,105 @@
1
+ // P0 verification helper: a loopback reverse proxy that records every exchange
2
+ // (request headers + body, response status/headers + full body) as JSON files,
3
+ // then forwards to the target origin. Credentials are redacted in the records
4
+ // but forwarded untouched.
5
+ //
6
+ // Standalone: node tap.mjs --port 8797 --target https://api.wokey.ai --out <dir>
7
+ // Library: import { startTap } from "./tap.mjs"
8
+ import { createServer } from "node:http";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const REDACT = new Set(["authorization", "x-api-key", "dpop", "x-u1s1-attestation", "cookie"]);
14
+ const HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive"]);
15
+
16
+ function redactedHeaders(headers) {
17
+ const out = {};
18
+ for (const [name, value] of Object.entries(headers)) {
19
+ out[name] = REDACT.has(name.toLowerCase()) ? `<redacted:${String(value).length}>` : value;
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function parseMaybeJson(text) {
25
+ try {
26
+ return JSON.parse(text);
27
+ } catch {
28
+ return text;
29
+ }
30
+ }
31
+
32
+ /** @returns {Promise<{ port: number, close: () => void }>} */
33
+ export async function startTap({ port = 0, target, out, label = "tap" }) {
34
+ mkdirSync(out, { recursive: true });
35
+ let seq = 0;
36
+ const server = createServer(async (req, res) => {
37
+ const id = String(++seq).padStart(3, "0");
38
+ const chunks = [];
39
+ for await (const chunk of req) chunks.push(chunk);
40
+ const body = Buffer.concat(chunks);
41
+ const headers = new Headers();
42
+ for (const [name, value] of Object.entries(req.headers)) {
43
+ if (HOP.has(name) || value === undefined) continue;
44
+ if (Array.isArray(value)) for (const v of value) headers.append(name, v);
45
+ else headers.set(name, value);
46
+ }
47
+ const record = {
48
+ label,
49
+ at: new Date().toISOString(),
50
+ request: {
51
+ method: req.method,
52
+ url: req.url,
53
+ headers: redactedHeaders(req.headers),
54
+ body: body.length ? parseMaybeJson(body.toString("utf8")) : null,
55
+ },
56
+ };
57
+ try {
58
+ const upstream = await fetch(new URL(req.url, target), {
59
+ method: req.method,
60
+ headers,
61
+ body: body.length ? body : undefined,
62
+ });
63
+ const responseHeaders = {};
64
+ upstream.headers.forEach((value, name) => {
65
+ if (!["content-length", "transfer-encoding", "connection", "content-encoding"].includes(name)) {
66
+ responseHeaders[name] = value;
67
+ }
68
+ });
69
+ res.writeHead(upstream.status, responseHeaders);
70
+ const decoder = new TextDecoder();
71
+ let text = "";
72
+ if (upstream.body) {
73
+ for await (const chunk of upstream.body) {
74
+ text += decoder.decode(chunk, { stream: true });
75
+ res.write(chunk);
76
+ }
77
+ }
78
+ res.end();
79
+ record.response = { status: upstream.status, headers: responseHeaders, body: text };
80
+ } catch (error) {
81
+ record.error = String(error);
82
+ if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
83
+ res.end(JSON.stringify({ error: { message: `tap forward failed: ${error}` } }));
84
+ }
85
+ mkdirSync(out, { recursive: true });
86
+ writeFileSync(join(out, `${id}-${(req.url ?? "").replace(/[^a-z0-9]+/gi, "_").slice(0, 40)}.json`),
87
+ JSON.stringify(record, null, 2));
88
+ });
89
+ await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
90
+ return { port: server.address().port, close: () => server.close() };
91
+ }
92
+
93
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
94
+ const arg = (name) => {
95
+ const i = process.argv.indexOf(`--${name}`);
96
+ return i > 0 ? process.argv[i + 1] : undefined;
97
+ };
98
+ const tap = await startTap({
99
+ port: Number(arg("port") ?? 0),
100
+ target: arg("target"),
101
+ out: arg("out"),
102
+ label: arg("label") ?? "tap",
103
+ });
104
+ console.log(`tap listening on http://127.0.0.1:${tap.port} -> ${arg("target")}`);
105
+ }