@testchimp/cli 0.1.83 → 0.1.85

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.
package/README.md CHANGED
@@ -36,7 +36,7 @@ The config **file path** depends on the host (e.g. Cursor often uses `<repo>/.cu
36
36
  ## CLI
37
37
 
38
38
  ```bash
39
- export TESTCHIMP_API_KEY=... # required (often read from project MCP env; never commit keys)
39
+ export TESTCHIMP_API_KEY=... # required unless TESTCHIMP_OAUTH_TOKEN is set (often read from project MCP env; never commit keys)
40
40
  testchimp --help
41
41
  testchimp get-requirement-coverage --branch-name main --help
42
42
  testchimp create-user-story --platform-file-path plans/stories/foo.md --title "Checkout"
@@ -59,6 +59,83 @@ testchimp get-meeting-transcript --meeting-id <meeting-id> --summary-only
59
59
  - **Meetings:** `list-meetings` / `list-meeting-filter-options` cover team-wide Meeting Bots meetings only (same filters as the Meetings page). `--from` / `--to` take `YYYY-MM-DD` (inclusive local days), ISO datetimes, or epoch millis; `--label`, `--participant`, `--domain` are repeatable or comma-separated.
60
60
  - **Flags:** default for each subcommand; **`--json-input '<json>'`** or **`--json-input @file.json`** merges over flags (JSON wins on conflicts). Use JSON for nested bodies (e.g. TrueCoverage scopes).
61
61
 
62
+ ## Authentication and environment
63
+
64
+ | Variable | Purpose |
65
+ |---|---|
66
+ | `TESTCHIMP_API_KEY` | Project API key (sent as `TestChimp-Api-Key`). Required unless `TESTCHIMP_OAUTH_TOKEN` is set. |
67
+ | `TESTCHIMP_OAUTH_TOKEN` | OAuth 2.1 access token issued by featureservice (sent as `Authorization: Bearer`). When both are set, both are sent and the backend prefers the bearer. |
68
+ | `TESTCHIMP_BOT_ID` | QA bot id (sent as `bot-id` header for attribution). Must be 1–64 printable ASCII characters without spaces; otherwise it is ignored with a warning. |
69
+ | `TESTCHIMP_BACKEND_URL` | Featureservice base URL (default `https://featureservice.testchimp.io`). |
70
+ | `TESTCHIMP_INGRESS_URL` | Ingress base URL used for bot event acks (default `https://ingress.testchimp.io`). |
71
+ | `TESTCHIMP_HOME` | TestChimp Studio / CLI home holding `projects.json` (default `~/.testchimp`). |
72
+
73
+ ## Workspace folder mapping
74
+
75
+ Map a local repository folder to a TestChimp project for the current user. The mapping lives in `~/.testchimp/projects.json` (or `$TESTCHIMP_HOME/projects.json`), the same file TestChimp Studio uses, so a folder mapped from the CLI shows up in Studio and in the headless AgentWatch daemon.
76
+
77
+ ```bash
78
+ testchimp workspace map --project-id <id> --folder ~/code/shop [--project-name "Shop"] [--reassign] [--skip-repo-check]
79
+ testchimp workspace get --project-id <id> # prints the mapping JSON; exit 1 when unmapped
80
+ ```
81
+
82
+ `workspace map` follows the same rules as Studio:
83
+
84
+ - The folder must exist and be a git work tree. Paths are stored as canonical real paths.
85
+ - When `TESTCHIMP_API_KEY` / `TESTCHIMP_OAUTH_TOKEN` is set, the CLI loads the project's connected repository (`get-git-folder-mapping`). If one is connected, the folder must be the repository root and one of its git remotes must match (`owner/repo`, case-insensitive). If no credential is set, no repo is connected, or the lookup fails, the check is skipped with a note on stderr. The credential's own project is used for this lookup, so run it with the same project's key or bot token. `--skip-repo-check` skips the lookup entirely.
86
+ - A folder belongs to one project. Mapping it to a second project fails unless you pass `--reassign`, which moves it (and drops the old project's entry if that leaves it with no folders).
87
+ - The file is written atomically (temp file, then rename) with mode `0600`. Other projects are left alone, and unknown fields written by newer Studio versions are kept. A file that is not valid JSON or does not match schema v2 is moved to `projects.invalid.<millis>.json` and replaced with an empty registry. Schema v1 files are migrated to v2.
88
+
89
+ The byte-level format is pinned by `fixtures/projects-registry/`, with an identical copy in the Studio repo. Both test suites replay the same upsert sequences and compare the output bytes.
90
+
91
+ ## QA bots
92
+
93
+ ```bash
94
+ testchimp get-my-tasks --user-id <user-id> # OAuth tokens imply the user
95
+ testchimp list-tests-awaiting-verification --limit 20
96
+ testchimp get-qa-posture
97
+ testchimp bot get-profile
98
+ testchimp bot register-profile --role QA_ENGINEER --responsibilities "Checkout + payments" \
99
+ --capability E2E_AUTHORING --capability TEST_BATCH_FIX \
100
+ --subscriptions-json '[{"eventType":"git-push","filters":[{"field":"author","op":"eq","value":"me"}]},{"eventType":"e2e-batch-completed"}]'
101
+ testchimp bot ack <eventId> [<eventId>...] --ack-url <delivery ackUrl>
102
+ testchimp bot compat --skill-version 1.0.53
103
+ ```
104
+
105
+ `bot ack` prints one `eventId<TAB>status` line per id and exits non-zero when any status is `BOT_ACK_UNKNOWN_EVENT`, `BOT_ACK_NOT_A_TARGET`, or `BOT_ACK_MISSING_BOT_ID`. An explicit `--ack-url` must be https (http only on localhost) and point at a TestChimp host or the `TESTCHIMP_INGRESS_URL` host. `bot compat` exits 0 and prints the deployment minimums plus `cliUpgradeRequired` / `skillUpgradeRequired`.
106
+
107
+ ### AgentWatch without TestChimp Studio
108
+
109
+ ```bash
110
+ testchimp bot connect --project-id <id> # browser approval (OAuth, "agentwatch" scope)
111
+ npx -y @testchimp/agentwatch query --project-id <id>
112
+ testchimp bot disconnect --project-id <id> # forget the stored keys
113
+ ```
114
+
115
+ Headless AgentWatch acts as the user, so it needs their user id, personal access key and the project API key. `bot connect` runs an OAuth 2.1 PKCE login with a loopback redirect, asks for the opt-in `agentwatch` scope (the consent page warns that keys will be stored locally), fetches the keys from `/bots/get_agentwatch_credentials`, and writes them to `~/.testchimp/agentwatch/credentials.json` (mode `0600`, keyed by project) with the backend and ingress URLs. It revokes the OAuth refresh token straight away and never prints the keys. Approving also opts the project in to AgentWatch.
116
+
117
+ ## Remote MCP (Streamable HTTP)
118
+
119
+ ```bash
120
+ testchimp mcp --http [--port 8080] [--host 0.0.0.0]
121
+ ```
122
+
123
+ Stateless Streamable HTTP at `POST /mcp`. Every request must carry `Authorization: Bearer <OAuth access token>`; the caller's token is forwarded to TestChimp for each tool call, and the server's own `TESTCHIMP_API_KEY` / `TESTCHIMP_OAUTH_TOKEN` / `TESTCHIMP_BOT_ID` are never used. Requests without a bearer get `401` with `WWW-Authenticate: Bearer resource_metadata="…"`. Other endpoints: `GET /.well-known/oauth-protected-resource` (RFC 9728 metadata), `GET /healthz`. Request bodies are capped at 1 MB.
124
+
125
+ | Variable | Purpose |
126
+ |---|---|
127
+ | `PORT` | Listen port when `--port` is not given (default `8080`; Cloud Run sets it). |
128
+ | `TESTCHIMP_MCP_PUBLIC_URL` | Public base URL of this server (e.g. `https://mcp.testchimp.io`); used for the protected-resource `resource` and `WWW-Authenticate`. Defaults to the request's forwarded proto + host. |
129
+ | `TESTCHIMP_OAUTH_ISSUER` | Authorization server advertised in the metadata (default: `TESTCHIMP_BACKEND_URL`). |
130
+ | `TESTCHIMP_BACKEND_URL` / `TESTCHIMP_INGRESS_URL` | Upstream TestChimp services. |
131
+
132
+ The repository `Dockerfile` builds an image that runs `testchimp mcp --http` as a non-root user (Cloud Run ready):
133
+
134
+ ```bash
135
+ docker build -t testchimp-mcp .
136
+ docker run -p 8080:8080 -e TESTCHIMP_MCP_PUBLIC_URL=https://mcp.example.com testchimp-mcp
137
+ ```
138
+
62
139
  ## Migration from `testchimp-mcp-client`
63
140
 
64
141
  The npm package **`testchimp-mcp-client`** is superseded by **`@testchimp/cli`**. Update MCP `args` to `["-y", "@testchimp/cli@latest", "mcp"]` and rename tool references to **kebab-case**. See [MIGRATION.md](./MIGRATION.md).
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `testchimp bot connect`: OAuth 2.1 (PKCE, loopback redirect) against the TestChimp authorization
3
+ * server with the opt-in `agentwatch` scope, then one call to `/bots/get_agentwatch_credentials` to
4
+ * fetch the user id, PAT and project API key, stored for headless AgentWatch. Nothing secret is
5
+ * printed; the refresh token is revoked straight away because the stored credentials replace it.
6
+ */
7
+ import type { RegistryEnv } from "../workspace/projectsRegistry.js";
8
+ export declare const AGENTWATCH_OAUTH_SCOPE = "testchimp agentwatch";
9
+ export type ConnectOptions = {
10
+ backendUrl: string;
11
+ ingressUrl?: string;
12
+ /** When set, the project picked on the consent page must match. */
13
+ expectedProjectId?: string;
14
+ /** Loopback port for the redirect; 0 picks a free one. */
15
+ port?: number;
16
+ timeoutMs?: number;
17
+ /** Opens the consent URL; default launches the system browser. */
18
+ openUrl?: (url: string) => void | Promise<void>;
19
+ /** Progress lines (stderr in the CLI). */
20
+ log?: (line: string) => void;
21
+ env?: RegistryEnv;
22
+ };
23
+ export type ConnectResult = {
24
+ projectId: string;
25
+ userId: string;
26
+ email?: string;
27
+ botId?: string;
28
+ credentialsPath: string;
29
+ };
30
+ /**
31
+ * Ingress for a TestChimp SaaS backend (`featureservice[-env].testchimp.io` → `ingress[-env].testchimp.io`),
32
+ * so stored credentials never mix deployments. Undefined for other hosts.
33
+ */
34
+ export declare function ingressUrlForBackend(backendUrl: string): string | undefined;
35
+ /** Best-effort system browser launch; the URL is always printed too. */
36
+ export declare function openInBrowser(url: string): void;
37
+ export declare function connectAgentWatch(opts: ConnectOptions): Promise<ConnectResult>;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `testchimp bot connect`: OAuth 2.1 (PKCE, loopback redirect) against the TestChimp authorization
3
+ * server with the opt-in `agentwatch` scope, then one call to `/bots/get_agentwatch_credentials` to
4
+ * fetch the user id, PAT and project API key, stored for headless AgentWatch. Nothing secret is
5
+ * printed; the refresh token is revoked straight away because the stored credentials replace it.
6
+ */
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { spawn } from "node:child_process";
9
+ import { createServer } from "node:http";
10
+ import { saveProjectCredentials } from "./credentialsFile.js";
11
+ export const AGENTWATCH_OAUTH_SCOPE = "testchimp agentwatch";
12
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000;
13
+ /**
14
+ * Ingress for a TestChimp SaaS backend (`featureservice[-env].testchimp.io` → `ingress[-env].testchimp.io`),
15
+ * so stored credentials never mix deployments. Undefined for other hosts.
16
+ */
17
+ export function ingressUrlForBackend(backendUrl) {
18
+ const url = new URL(backendUrl);
19
+ const m = /^featureservice(-[a-z0-9-]+)?\.testchimp\.io$/i.exec(url.hostname);
20
+ return m ? `https://ingress${m[1] ?? ""}.testchimp.io` : undefined;
21
+ }
22
+ function base64url(buf) {
23
+ return buf.toString("base64url");
24
+ }
25
+ function stringField(obj, key) {
26
+ const v = obj[key];
27
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
28
+ }
29
+ async function readJson(res, what) {
30
+ const text = await res.text();
31
+ let body = {};
32
+ try {
33
+ body = text ? JSON.parse(text) : {};
34
+ }
35
+ catch {
36
+ /* non-JSON error body */
37
+ }
38
+ if (!res.ok) {
39
+ const detail = stringField(body, "error_description") ??
40
+ stringField(body.responseHeader ?? {}, "errorMessage") ??
41
+ text.slice(0, 200);
42
+ throw new Error(`${what} failed (${res.status})${detail ? `: ${detail}` : ""}`);
43
+ }
44
+ return body;
45
+ }
46
+ async function discover(backendUrl) {
47
+ const res = await fetch(`${backendUrl}/.well-known/oauth-authorization-server`);
48
+ if (res.status === 404) {
49
+ throw new Error(`OAuth is not enabled on ${backendUrl}; bot connect needs a deployment with the TestChimp OAuth server`);
50
+ }
51
+ // RFC 8414 metadata field names.
52
+ const m = await readJson(res, "OAuth discovery");
53
+ const authorizationEndpoint = stringField(m, "authorization_endpoint");
54
+ const tokenEndpoint = stringField(m, "token_endpoint");
55
+ const registrationEndpoint = stringField(m, "registration_endpoint");
56
+ if (!authorizationEndpoint || !tokenEndpoint || !registrationEndpoint) {
57
+ throw new Error("OAuth discovery returned incomplete metadata");
58
+ }
59
+ return {
60
+ issuer: stringField(m, "issuer"),
61
+ authorizationEndpoint,
62
+ tokenEndpoint,
63
+ registrationEndpoint,
64
+ revocationEndpoint: stringField(m, "revocation_endpoint"),
65
+ };
66
+ }
67
+ async function registerClient(meta, redirectUri) {
68
+ // RFC 7591 client metadata field names.
69
+ const res = await fetch(meta.registrationEndpoint, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({
73
+ client_name: "TestChimp CLI (AgentWatch)",
74
+ redirect_uris: [redirectUri],
75
+ token_endpoint_auth_method: "none",
76
+ grant_types: ["authorization_code", "refresh_token"],
77
+ response_types: ["code"],
78
+ }),
79
+ });
80
+ const clientId = stringField(await readJson(res, "OAuth client registration"), "client_id");
81
+ if (!clientId)
82
+ throw new Error("OAuth client registration returned no client_id");
83
+ return clientId;
84
+ }
85
+ function listenForCallback(port, state, issuer) {
86
+ return new Promise((resolveListen, rejectListen) => {
87
+ let settle = null;
88
+ const callback = new Promise((resolve, reject) => {
89
+ settle = { resolve, reject };
90
+ });
91
+ // The browser can answer before the caller starts awaiting (e.g. while openUrl is still running).
92
+ callback.catch(() => undefined);
93
+ const server = createServer((req, res) => {
94
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
95
+ if (url.pathname !== "/callback") {
96
+ res.writeHead(404).end();
97
+ return;
98
+ }
99
+ const page = (title, body) => {
100
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
101
+ res.end(`<!doctype html><title>${title}</title><body style="font-family:sans-serif;padding:2rem"><h2>${title}</h2><p>${body}</p></body>`);
102
+ };
103
+ const error = url.searchParams.get("error");
104
+ if (url.searchParams.get("state") !== state) {
105
+ page("TestChimp connection failed", "The response did not match this login attempt. Run testchimp bot connect again.");
106
+ settle?.reject(new Error("OAuth state mismatch"));
107
+ return;
108
+ }
109
+ const iss = url.searchParams.get("iss");
110
+ if (issuer && iss && iss !== issuer) {
111
+ page("TestChimp connection failed", "Unexpected authorization server.");
112
+ settle?.reject(new Error(`OAuth issuer mismatch: ${iss}`));
113
+ return;
114
+ }
115
+ if (error) {
116
+ const denied = error === "access_denied";
117
+ page(denied ? "Connection denied" : "TestChimp connection failed", denied ? "You can close this tab." : "You can close this tab and check the terminal.");
118
+ settle?.reject(new Error(denied ? "The user denied access" : `OAuth error: ${error} ${url.searchParams.get("error_description") ?? ""}`.trim()));
119
+ return;
120
+ }
121
+ const code = url.searchParams.get("code");
122
+ if (!code) {
123
+ page("TestChimp connection failed", "No authorization code was returned.");
124
+ settle?.reject(new Error("OAuth callback carried no code"));
125
+ return;
126
+ }
127
+ page("Connected to TestChimp", "AgentWatch credentials are being saved. You can close this tab.");
128
+ settle?.resolve({ code });
129
+ });
130
+ server.once("error", rejectListen);
131
+ server.listen(port, "127.0.0.1", () => {
132
+ server.off("error", rejectListen);
133
+ const actual = server.address().port;
134
+ resolveListen({ server, redirectUri: `http://127.0.0.1:${actual}/callback`, callback });
135
+ });
136
+ });
137
+ }
138
+ /** Best-effort system browser launch; the URL is always printed too. */
139
+ export function openInBrowser(url) {
140
+ const [cmd, args] = process.platform === "darwin"
141
+ ? ["open", [url]]
142
+ : process.platform === "win32"
143
+ ? ["cmd", ["/c", "start", "", url]]
144
+ : ["xdg-open", [url]];
145
+ try {
146
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
147
+ child.on("error", () => undefined);
148
+ child.unref();
149
+ }
150
+ catch {
151
+ /* printed URL is the fallback */
152
+ }
153
+ }
154
+ function withTimeout(p, ms) {
155
+ let timer;
156
+ const timeout = new Promise((_, reject) => {
157
+ timer = setTimeout(() => reject(new Error(`Timed out after ${Math.round(ms / 1000)}s waiting for browser approval`)), ms);
158
+ });
159
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
160
+ }
161
+ export async function connectAgentWatch(opts) {
162
+ const log = opts.log ?? (() => undefined);
163
+ const meta = await discover(opts.backendUrl);
164
+ const state = base64url(randomBytes(16));
165
+ const verifier = base64url(randomBytes(32));
166
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
167
+ const { server, redirectUri, callback } = await listenForCallback(opts.port ?? 0, state, meta.issuer);
168
+ try {
169
+ const clientId = await registerClient(meta, redirectUri);
170
+ const authorize = new URL(meta.authorizationEndpoint);
171
+ authorize.search = new URLSearchParams({
172
+ response_type: "code",
173
+ client_id: clientId,
174
+ redirect_uri: redirectUri,
175
+ code_challenge: challenge,
176
+ code_challenge_method: "S256",
177
+ state,
178
+ scope: AGENTWATCH_OAUTH_SCOPE,
179
+ }).toString();
180
+ log(`Open this URL to approve AgentWatch access (pick the project, tick "Use this connection as my QA bot"):`);
181
+ log(authorize.toString());
182
+ await (opts.openUrl ?? openInBrowser)(authorize.toString());
183
+ const { code } = await withTimeout(callback, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
184
+ // RFC 6749 token request / response field names.
185
+ const tokenRes = await fetch(meta.tokenEndpoint, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
188
+ body: new URLSearchParams({
189
+ grant_type: "authorization_code",
190
+ code,
191
+ client_id: clientId,
192
+ redirect_uri: redirectUri,
193
+ code_verifier: verifier,
194
+ }).toString(),
195
+ });
196
+ const tokens = await readJson(tokenRes, "OAuth token exchange");
197
+ const accessToken = stringField(tokens, "access_token");
198
+ const refreshToken = stringField(tokens, "refresh_token");
199
+ if (!accessToken)
200
+ throw new Error("OAuth token exchange returned no access_token");
201
+ try {
202
+ const granted = (stringField(tokens, "scope") ?? "").split(/\s+/);
203
+ if (!granted.includes("agentwatch")) {
204
+ throw new Error("This deployment did not grant the agentwatch scope; upgrade TestChimp or use TestChimp Studio sign-in");
205
+ }
206
+ const credsRes = await fetch(`${opts.backendUrl}/bots/get_agentwatch_credentials`, {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
209
+ body: "{}",
210
+ });
211
+ const creds = await readJson(credsRes, "Fetching AgentWatch credentials");
212
+ const projectId = stringField(creds, "projectId");
213
+ const userId = stringField(creds, "userId");
214
+ const userAuthKey = stringField(creds, "userAuthKey");
215
+ const projectApiKey = stringField(creds, "projectApiKey");
216
+ if (!projectId || !userId || !userAuthKey || !projectApiKey) {
217
+ throw new Error("AgentWatch credentials response was incomplete");
218
+ }
219
+ if (opts.expectedProjectId && opts.expectedProjectId !== projectId) {
220
+ throw new Error(`Approved project ${projectId} does not match --project-id ${opts.expectedProjectId}; run bot connect again and pick the right project`);
221
+ }
222
+ const email = stringField(creds, "email");
223
+ const botId = stringField(creds, "botId");
224
+ const credentialsPath = saveProjectCredentials(projectId, {
225
+ userId,
226
+ ...(email ? { email } : {}),
227
+ userAuthKey,
228
+ projectApiKey,
229
+ ...(botId ? { botId } : {}),
230
+ backendUrl: opts.backendUrl,
231
+ ...(opts.ingressUrl ? { ingressUrl: opts.ingressUrl } : {}),
232
+ savedAtMillis: Date.now(),
233
+ }, opts.env);
234
+ return { projectId, userId, ...(email ? { email } : {}), ...(botId ? { botId } : {}), credentialsPath };
235
+ }
236
+ finally {
237
+ if (refreshToken && meta.revocationEndpoint) {
238
+ await fetch(meta.revocationEndpoint, {
239
+ method: "POST",
240
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
241
+ body: new URLSearchParams({ token: refreshToken }).toString(),
242
+ }).catch(() => undefined);
243
+ }
244
+ }
245
+ }
246
+ finally {
247
+ server.closeAllConnections?.();
248
+ server.close();
249
+ }
250
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,166 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { afterEach, beforeEach, describe, it } from "node:test";
8
+ import { connectAgentWatch, ingressUrlForBackend } from "./connect.js";
9
+ import { agentwatchCredentialsPath, readAgentwatchCredentials, removeProjectCredentials, saveProjectCredentials, } from "./credentialsFile.js";
10
+ function body(req) {
11
+ return new Promise((resolve) => {
12
+ let data = "";
13
+ req.on("data", (c) => (data += c));
14
+ req.on("end", () => resolve(data));
15
+ });
16
+ }
17
+ async function startFakeAuthServer(opts = {}) {
18
+ const fake = { url: "", server: createServer(), authorizeScopes: [], revoked: [] };
19
+ let challenge = "";
20
+ let redirectUri = "";
21
+ fake.server.on("request", async (req, res) => {
22
+ const url = new URL(req.url ?? "/", fake.url);
23
+ const json = (status, value) => {
24
+ res.writeHead(status, { "Content-Type": "application/json" });
25
+ res.end(JSON.stringify(value));
26
+ };
27
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
28
+ return json(200, {
29
+ issuer: fake.url,
30
+ authorization_endpoint: `${fake.url}/oauth/authorize`,
31
+ token_endpoint: `${fake.url}/oauth/token`,
32
+ registration_endpoint: `${fake.url}/oauth/register`,
33
+ revocation_endpoint: `${fake.url}/oauth/revoke`,
34
+ });
35
+ }
36
+ if (url.pathname === "/oauth/register") {
37
+ redirectUri = JSON.parse(await body(req)).redirect_uris[0];
38
+ return json(201, { client_id: "client-1" });
39
+ }
40
+ if (url.pathname === "/oauth/authorize") {
41
+ fake.authorizeScopes.push(url.searchParams.get("scope") ?? "");
42
+ challenge = url.searchParams.get("code_challenge") ?? "";
43
+ const back = new URL(url.searchParams.get("redirect_uri") ?? redirectUri);
44
+ back.searchParams.set("state", url.searchParams.get("state") ?? "");
45
+ back.searchParams.set("iss", fake.url);
46
+ if (opts.deny)
47
+ back.searchParams.set("error", "access_denied");
48
+ else
49
+ back.searchParams.set("code", "code-1");
50
+ res.writeHead(302, { Location: back.toString() });
51
+ return res.end();
52
+ }
53
+ if (url.pathname === "/oauth/token") {
54
+ const form = new URLSearchParams(await body(req));
55
+ const expected = createHash("sha256").update(form.get("code_verifier") ?? "").digest("base64url");
56
+ if (form.get("code") !== "code-1" || expected !== challenge || form.get("redirect_uri") !== redirectUri) {
57
+ return json(400, { error: "invalid_grant", error_description: "bad exchange" });
58
+ }
59
+ return json(200, {
60
+ access_token: "access-1",
61
+ refresh_token: "refresh-1",
62
+ token_type: "Bearer",
63
+ scope: opts.grantedScope ?? "testchimp agentwatch",
64
+ });
65
+ }
66
+ if (url.pathname === "/bots/get_agentwatch_credentials") {
67
+ if (req.headers.authorization !== "Bearer access-1")
68
+ return json(401, {});
69
+ return json(200, {
70
+ projectId: opts.projectId ?? "proj-1",
71
+ userId: "user-1",
72
+ email: "dev@example.com",
73
+ userAuthKey: "pat-0123456789",
74
+ projectApiKey: "key-0123456789",
75
+ botId: "bot-1",
76
+ });
77
+ }
78
+ if (url.pathname === "/oauth/revoke") {
79
+ fake.revoked.push(new URLSearchParams(await body(req)).get("token") ?? "");
80
+ return json(200, {});
81
+ }
82
+ json(404, {});
83
+ });
84
+ await new Promise((r) => fake.server.listen(0, "127.0.0.1", () => r()));
85
+ fake.url = `http://127.0.0.1:${fake.server.address().port}`;
86
+ return fake;
87
+ }
88
+ /** Plays the browser: follows the consent redirect back to the CLI's loopback callback. */
89
+ async function browser(url) {
90
+ await fetch(url);
91
+ }
92
+ describe("bot connect (AgentWatch credentials)", () => {
93
+ let home;
94
+ let fake = null;
95
+ beforeEach(() => {
96
+ home = mkdtempSync(join(tmpdir(), "tc-aw-"));
97
+ });
98
+ afterEach(() => {
99
+ fake?.server.close();
100
+ fake = null;
101
+ rmSync(home, { recursive: true, force: true });
102
+ });
103
+ it("requests the agentwatch scope, stores user id / PAT / API key at 0600 and revokes the refresh token", async () => {
104
+ fake = await startFakeAuthServer();
105
+ const result = await connectAgentWatch({
106
+ backendUrl: fake.url,
107
+ ingressUrl: "https://ingress.testchimp.io",
108
+ openUrl: browser,
109
+ env: { home },
110
+ });
111
+ assert.deepEqual(result, {
112
+ projectId: "proj-1",
113
+ userId: "user-1",
114
+ email: "dev@example.com",
115
+ botId: "bot-1",
116
+ credentialsPath: agentwatchCredentialsPath({ home }),
117
+ });
118
+ assert.deepEqual(fake.authorizeScopes, ["testchimp agentwatch"]);
119
+ assert.deepEqual(fake.revoked, ["refresh-1"]);
120
+ const stored = readAgentwatchCredentials({ home })?.projects["proj-1"];
121
+ assert.equal(stored?.userId, "user-1");
122
+ assert.equal(stored?.userAuthKey, "pat-0123456789");
123
+ assert.equal(stored?.projectApiKey, "key-0123456789");
124
+ assert.equal(stored?.botId, "bot-1");
125
+ assert.equal(stored?.backendUrl, fake.url);
126
+ if (process.platform !== "win32") {
127
+ assert.equal(statSync(agentwatchCredentialsPath({ home })).mode & 0o777, 0o600);
128
+ }
129
+ });
130
+ it("refuses a project other than --project-id and stores nothing", async () => {
131
+ fake = await startFakeAuthServer({ projectId: "proj-2" });
132
+ await assert.rejects(connectAgentWatch({ backendUrl: fake.url, expectedProjectId: "proj-1", openUrl: browser, env: { home } }), /does not match --project-id proj-1/);
133
+ assert.equal(readAgentwatchCredentials({ home }), null);
134
+ assert.deepEqual(fake.revoked, ["refresh-1"]);
135
+ });
136
+ it("fails clearly when the deployment does not grant the agentwatch scope", async () => {
137
+ fake = await startFakeAuthServer({ grantedScope: "testchimp" });
138
+ await assert.rejects(connectAgentWatch({ backendUrl: fake.url, openUrl: browser, env: { home } }), /did not grant the agentwatch scope/);
139
+ assert.equal(readAgentwatchCredentials({ home }), null);
140
+ });
141
+ it("reports a denied consent", async () => {
142
+ fake = await startFakeAuthServer({ deny: true });
143
+ await assert.rejects(connectAgentWatch({ backendUrl: fake.url, openUrl: browser, env: { home } }), /denied access/);
144
+ });
145
+ it("derives the ingress of the same SaaS deployment", () => {
146
+ assert.equal(ingressUrlForBackend("https://featureservice.testchimp.io"), "https://ingress.testchimp.io");
147
+ assert.equal(ingressUrlForBackend("https://featureservice-staging.testchimp.io/"), "https://ingress-staging.testchimp.io");
148
+ assert.equal(ingressUrlForBackend("http://localhost:8080"), undefined);
149
+ });
150
+ it("keeps other projects when saving and removes the file with the last project", () => {
151
+ const entry = (userId) => ({
152
+ userId,
153
+ userAuthKey: "pat-0123456789",
154
+ projectApiKey: "key-0123456789",
155
+ backendUrl: "https://featureservice.testchimp.io",
156
+ savedAtMillis: 1,
157
+ });
158
+ saveProjectCredentials("a", entry("u1"), { home });
159
+ saveProjectCredentials("b", entry("u2"), { home });
160
+ assert.deepEqual(Object.keys(readAgentwatchCredentials({ home })?.projects ?? {}).sort(), ["a", "b"]);
161
+ assert.equal(removeProjectCredentials("a", { home }), true);
162
+ assert.equal(removeProjectCredentials("a", { home }), false);
163
+ assert.equal(removeProjectCredentials("b", { home }), true);
164
+ assert.equal(readAgentwatchCredentials({ home }), null);
165
+ });
166
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Local AgentWatch credentials in `~/.testchimp/agentwatch/credentials.json` (TESTCHIMP_HOME honoured).
3
+ *
4
+ * Written by `testchimp bot connect`, read by headless AgentWatch (desktop
5
+ * `src/main/auth/agentwatchCredentials.ts`) so it runs without TestChimp Studio sign-in. Keyed by
6
+ * project because the API key is per project. Holds the user's PAT: directory 0700, file 0600.
7
+ */
8
+ import { z } from "zod";
9
+ import { type RegistryEnv } from "../workspace/projectsRegistry.js";
10
+ export declare const AgentwatchProjectCredentialsSchema: z.ZodObject<{
11
+ userId: z.ZodString;
12
+ email: z.ZodOptional<z.ZodString>;
13
+ userAuthKey: z.ZodString;
14
+ projectApiKey: z.ZodString;
15
+ botId: z.ZodOptional<z.ZodString>;
16
+ backendUrl: z.ZodString;
17
+ ingressUrl: z.ZodOptional<z.ZodString>;
18
+ savedAtMillis: z.ZodNumber;
19
+ }, z.core.$loose>;
20
+ export type AgentwatchProjectCredentials = z.infer<typeof AgentwatchProjectCredentialsSchema>;
21
+ export declare const AgentwatchCredentialsFileSchema: z.ZodObject<{
22
+ schemaVersion: z.ZodLiteral<1>;
23
+ projects: z.ZodRecord<z.ZodString, z.ZodObject<{
24
+ userId: z.ZodString;
25
+ email: z.ZodOptional<z.ZodString>;
26
+ userAuthKey: z.ZodString;
27
+ projectApiKey: z.ZodString;
28
+ botId: z.ZodOptional<z.ZodString>;
29
+ backendUrl: z.ZodString;
30
+ ingressUrl: z.ZodOptional<z.ZodString>;
31
+ savedAtMillis: z.ZodNumber;
32
+ }, z.core.$loose>>;
33
+ }, z.core.$loose>;
34
+ export type AgentwatchCredentialsFile = z.infer<typeof AgentwatchCredentialsFileSchema>;
35
+ export declare function agentwatchCredentialsPath(env?: RegistryEnv): string;
36
+ /** Parsed file, or null when missing / unreadable / another schema. */
37
+ export declare function readAgentwatchCredentials(env?: RegistryEnv): AgentwatchCredentialsFile | null;
38
+ export declare function saveProjectCredentials(projectId: string, creds: AgentwatchProjectCredentials, env?: RegistryEnv): string;
39
+ /** True when an entry was removed. Deletes the file once no project is left. */
40
+ export declare function removeProjectCredentials(projectId: string, env?: RegistryEnv): boolean;