@botiverse/testbed-cli 0.1.0
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/bin/testbed.mjs +15 -0
- package/lib/agent-login.mjs +190 -0
- package/lib/agent-refresh.mjs +168 -0
- package/lib/auth.mjs +55 -0
- package/lib/client.mjs +31 -0
- package/lib/main.mjs +231 -0
- package/package.json +30 -0
package/bin/testbed.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// testbed — CLI for https://testbed.botiverse.build (cloud phones + acceptance runs).
|
|
3
|
+
// See lib/main.mjs for the command list.
|
|
4
|
+
//
|
|
5
|
+
// Auth:
|
|
6
|
+
// inside a managed Raft agent `testbed login` (no browser; PKCE grant via
|
|
7
|
+
// `raft integration invoke`, session stored per agent)
|
|
8
|
+
// humans / CI TESTBED_TOKEN env (session or deploy token)
|
|
9
|
+
// TESTBED_URL overrides the instance. This default is pinned by
|
|
10
|
+
// scripts/verify-cli-default-origin.mjs and worker/test/raftauth.test.ts — keep the line.
|
|
11
|
+
import { main } from "../lib/main.mjs";
|
|
12
|
+
|
|
13
|
+
const URL_BASE = process.env.TESTBED_URL || "https://testbed.botiverse.build";
|
|
14
|
+
|
|
15
|
+
await main(process.argv.slice(2), { urlBase: URL_BASE, urlExplicit: Boolean(process.env.TESTBED_URL) });
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent CLI login (RFC 057 contract; testbed instance, ported from the Hands CLI).
|
|
3
|
+
* The non-interactive `testbed login` used inside a managed Raft agent.
|
|
4
|
+
*
|
|
5
|
+
* 1. Generate a PKCE code_verifier locally; send only its S256 challenge to Raft.
|
|
6
|
+
* 2. Run the EXACT wrapper `$SLOCK_CLI_TRANSPORT_DIR/raft integration invoke
|
|
7
|
+
* --service testbed --action agent-login --json` (never PATH `raft`); require
|
|
8
|
+
* exit 0; STRICTLY validate the result. Errors carry stable reasons only — never
|
|
9
|
+
* raw stdout/stderr/body (which could contain the grant).
|
|
10
|
+
* 3. Exchange { grant, code_verifier } at the PUBLIC testbed endpoint for a
|
|
11
|
+
* raft-cli-agent-session.v1; strictly validate it.
|
|
12
|
+
* 4. Persist through @botiverse/agent-session-store (atomic, 0600 under 0700 dirs),
|
|
13
|
+
* recording the api base so later commands need no config.
|
|
14
|
+
*
|
|
15
|
+
* The verifier never reaches Raft, logs, or the store. Only the testbed token is stored.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
19
|
+
import { writeAgentSession } from "@botiverse/agent-session-store";
|
|
20
|
+
|
|
21
|
+
/** The exact installed Raft client key of this service (`raft integration list`). */
|
|
22
|
+
export const TESTBED_SERVICE = "testbed";
|
|
23
|
+
|
|
24
|
+
function base64url(buf) {
|
|
25
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** PKCE S256: 64-byte verifier → 86 unreserved base64url chars; challenge = SHA-256. */
|
|
29
|
+
export function generatePkce() {
|
|
30
|
+
const verifier = base64url(randomBytes(64));
|
|
31
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
32
|
+
return { verifier, challenge };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hasExactKeys(o, keys) {
|
|
36
|
+
const k = Object.keys(o);
|
|
37
|
+
return k.length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(o, key));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** RFC3339 date-time → epoch ms, or null. */
|
|
41
|
+
export function parseRfc3339(s) {
|
|
42
|
+
if (typeof s !== "string") return null;
|
|
43
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/.test(s)) return null;
|
|
44
|
+
const t = Date.parse(s);
|
|
45
|
+
return Number.isFinite(t) ? t : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Server issues expiry = server_now + 300s; allow clock-skew headroom above that.
|
|
49
|
+
const AGENT_GRANT_TTL_CEILING_MS = 300_000 + 120_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strictly validate a `raft integration invoke --action agent-login --json` result:
|
|
53
|
+
* { ok:true, data:{ service, action, status, result:{schema,service,grant,expires_at} } }
|
|
54
|
+
* NO part of stdout is ever echoed in an error.
|
|
55
|
+
*/
|
|
56
|
+
export function parseAgentLoginInvoke(stdout, service, now) {
|
|
57
|
+
let outer;
|
|
58
|
+
try { outer = JSON.parse(stdout); } catch { throw new Error("agent-login: `raft integration invoke` did not return JSON"); }
|
|
59
|
+
if (!outer || typeof outer !== "object") throw new Error("agent-login: invoke output is not an object");
|
|
60
|
+
if (outer.ok !== true) throw new Error("agent-login: invoke did not succeed");
|
|
61
|
+
if (!hasExactKeys(outer, ["ok", "data"])) throw new Error("agent-login: invoke envelope has unexpected fields");
|
|
62
|
+
const data = outer.data;
|
|
63
|
+
if (!data || typeof data !== "object") throw new Error("agent-login: invoke result is missing data");
|
|
64
|
+
if (!hasExactKeys(data, ["service", "action", "status", "result"])) throw new Error("agent-login: invoke data has unexpected fields");
|
|
65
|
+
if (data.service !== service) throw new Error("agent-login: invoke service does not match the requested service");
|
|
66
|
+
if (data.action !== "agent-login") throw new Error("agent-login: invoke action is not agent-login");
|
|
67
|
+
if (data.status !== 200) throw new Error("agent-login: agent-login action did not return HTTP 200");
|
|
68
|
+
const result = data.result;
|
|
69
|
+
if (!result || typeof result !== "object") throw new Error("agent-login: invoke result is missing the grant body");
|
|
70
|
+
if (!hasExactKeys(result, ["schema", "service", "grant", "expires_at"])) throw new Error("agent-login: grant result has unexpected fields");
|
|
71
|
+
if (result.schema !== "raft-cli-agent-login-grant.v1") throw new Error("agent-login: unexpected grant result schema");
|
|
72
|
+
if (result.service !== service) throw new Error("agent-login: grant result service mismatch");
|
|
73
|
+
if (typeof result.grant !== "string" || result.grant.length === 0) throw new Error("agent-login: grant is missing");
|
|
74
|
+
const exp = parseRfc3339(result.expires_at);
|
|
75
|
+
if (exp === null) throw new Error("agent-login: grant expires_at is not an RFC3339 timestamp");
|
|
76
|
+
if (exp <= now) throw new Error("agent-login: grant is already expired");
|
|
77
|
+
if (exp > now + AGENT_GRANT_TTL_CEILING_MS) throw new Error("agent-login: grant expiry exceeds the ceiling");
|
|
78
|
+
return { grant: result.grant, expires_at: result.expires_at };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Strictly validate the exchange/refresh success body (closed keys + RFC3339). */
|
|
82
|
+
export function parseAgentSession(body, now) {
|
|
83
|
+
if (!body || typeof body !== "object") throw new Error("agent-login: exchange response is not an object");
|
|
84
|
+
if (!hasExactKeys(body, ["schema", "token_type", "access_token", "access_expires_at", "refresh_token", "refresh_expires_at"])) {
|
|
85
|
+
throw new Error("agent-login: session has unexpected fields");
|
|
86
|
+
}
|
|
87
|
+
if (body.schema !== "raft-cli-agent-session.v1") throw new Error("agent-login: unexpected session schema");
|
|
88
|
+
if (body.token_type !== "Bearer") throw new Error("agent-login: unexpected token_type");
|
|
89
|
+
for (const k of ["access_token", "refresh_token"]) {
|
|
90
|
+
if (typeof body[k] !== "string" || body[k].length === 0) throw new Error(`agent-login: session missing ${k}`);
|
|
91
|
+
}
|
|
92
|
+
const accessExp = parseRfc3339(body.access_expires_at);
|
|
93
|
+
if (accessExp === null) throw new Error("agent-login: session access_expires_at is not RFC3339");
|
|
94
|
+
if (accessExp <= now) throw new Error("agent-login: session access token is already expired");
|
|
95
|
+
if (body.refresh_expires_at !== null) {
|
|
96
|
+
const refreshExp = parseRfc3339(body.refresh_expires_at);
|
|
97
|
+
if (refreshExp === null) throw new Error("agent-login: session refresh_expires_at must be RFC3339 or null");
|
|
98
|
+
if (refreshExp <= now) throw new Error("agent-login: session refresh token is already expired");
|
|
99
|
+
}
|
|
100
|
+
return body;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
104
|
+
|
|
105
|
+
/** Read a response body with a hard byte cap; abort the controller when exceeded. */
|
|
106
|
+
export async function readBoundedText(res, controller) {
|
|
107
|
+
const declared = Number(res.headers?.get?.("content-length"));
|
|
108
|
+
if (Number.isFinite(declared) && declared > MAX_TOKEN_RESPONSE_BYTES) {
|
|
109
|
+
controller?.abort();
|
|
110
|
+
throw new Error("agent-login: token response exceeds the size limit");
|
|
111
|
+
}
|
|
112
|
+
const reader = res.body?.getReader?.();
|
|
113
|
+
if (!reader) {
|
|
114
|
+
const t = await res.text();
|
|
115
|
+
if (t.length > MAX_TOKEN_RESPONSE_BYTES) throw new Error("agent-login: token response exceeds the size limit");
|
|
116
|
+
return t;
|
|
117
|
+
}
|
|
118
|
+
const chunks = [];
|
|
119
|
+
let total = 0;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done) break;
|
|
123
|
+
if (value) {
|
|
124
|
+
total += value.byteLength;
|
|
125
|
+
if (total > MAX_TOKEN_RESPONSE_BYTES) {
|
|
126
|
+
controller?.abort();
|
|
127
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
128
|
+
throw new Error("agent-login: token response exceeds the size limit");
|
|
129
|
+
}
|
|
130
|
+
chunks.push(value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const out = new Uint8Array(total);
|
|
134
|
+
let off = 0;
|
|
135
|
+
for (const c of chunks) { out.set(c, off); off += c.byteLength; }
|
|
136
|
+
return new TextDecoder().decode(out);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function defaultInvoke(raftBin, args) {
|
|
140
|
+
const res = spawnSync(raftBin, args, { encoding: "utf8" });
|
|
141
|
+
return { status: res.error ? null : res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Full agent-login flow: invoke the pinned wrapper → strict-validate grant → exchange
|
|
146
|
+
* → strict-validate session → atomic store. Returns the stored session on success.
|
|
147
|
+
* `apiBase` is required: the exchange must target the instance the grant came from.
|
|
148
|
+
*/
|
|
149
|
+
export async function runAgentLogin(a, apiBase, opts = {}) {
|
|
150
|
+
const service = opts.service ?? TESTBED_SERVICE;
|
|
151
|
+
const now = opts.now ?? Date.now();
|
|
152
|
+
const { verifier, challenge } = generatePkce();
|
|
153
|
+
const args = [
|
|
154
|
+
"integration", "invoke",
|
|
155
|
+
"--service", service,
|
|
156
|
+
"--action", "agent-login",
|
|
157
|
+
"--json",
|
|
158
|
+
"--data-json", JSON.stringify({
|
|
159
|
+
schema: "raft-cli-agent-login-request.v1",
|
|
160
|
+
code_challenge: challenge,
|
|
161
|
+
code_challenge_method: "S256",
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
const runner = opts.invoke ?? ((a2) => defaultInvoke(a.raftBin, a2));
|
|
165
|
+
const res = runner(args);
|
|
166
|
+
if (res.status !== 0) {
|
|
167
|
+
throw new Error(`agent-login: raft invoke exited with a non-zero status (${res.status ?? "spawn error"})`);
|
|
168
|
+
}
|
|
169
|
+
const { grant } = parseAgentLoginInvoke(res.stdout, service, now);
|
|
170
|
+
|
|
171
|
+
// Independent token request: no stored bearer, no auto-refresh — `testbed login` is
|
|
172
|
+
// the recovery path when the stored refresh is dead.
|
|
173
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
174
|
+
const exchangeRes = await fetchImpl(new URL("/api/auth/agent/exchange", apiBase).toString(), {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
177
|
+
body: JSON.stringify({ schema: "raft-cli-agent-login-exchange.v1", grant, code_verifier: verifier }),
|
|
178
|
+
// A 307/308 would forward the grant + verifier to the redirect target.
|
|
179
|
+
redirect: "manual",
|
|
180
|
+
});
|
|
181
|
+
if (!exchangeRes.ok) {
|
|
182
|
+
throw new Error(`agent-login: grant exchange failed (HTTP ${exchangeRes.status})`);
|
|
183
|
+
}
|
|
184
|
+
const exchangeText = await readBoundedText(exchangeRes);
|
|
185
|
+
let exchangeBody;
|
|
186
|
+
try { exchangeBody = JSON.parse(exchangeText); } catch { throw new Error("agent-login: exchange response was not JSON"); }
|
|
187
|
+
const session = parseAgentSession(exchangeBody, now);
|
|
188
|
+
writeAgentSession(a, service, session, apiBase);
|
|
189
|
+
return session;
|
|
190
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent token auto-refresh + cross-process single-flight (RFC 057; ported from the
|
|
3
|
+
* Hands CLI). The access token is short-lived; the rotating refresh token in the store
|
|
4
|
+
* renews it. Refresh happens PROACTIVELY inside a skew window of expiry and REACTIVELY
|
|
5
|
+
* once after a 401 (client.mjs). Concurrent `testbed` processes sharing one
|
|
6
|
+
* $SLOCK_HOME serialize through an O_EXCL lock: a loser waits for the winner's
|
|
7
|
+
* strictly-newer session and never rotates in parallel (a double rotation trips the
|
|
8
|
+
* server's reuse detection and revokes the whole chain).
|
|
9
|
+
*
|
|
10
|
+
* A lock is broken ONLY when its owner pid is provably dead; dead-lock recovery is
|
|
11
|
+
* serialized by an exclusive reaper fence. Any uncertainty fails closed (loser).
|
|
12
|
+
*/
|
|
13
|
+
import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { agentAuthPath, writeAgentSession } from "@botiverse/agent-session-store";
|
|
17
|
+
import { parseAgentSession, readBoundedText, TESTBED_SERVICE } from "./agent-login.mjs";
|
|
18
|
+
|
|
19
|
+
export const REFRESH_SKEW_MS = 60_000;
|
|
20
|
+
const REFRESH_DEADLINE_MS = 20_000;
|
|
21
|
+
const LOCK_WAIT_MS = 25_000;
|
|
22
|
+
const LOCK_POLL_MS = 100;
|
|
23
|
+
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
const newOwnerId = () => `${process.pid}:${randomBytes(12).toString("hex")}`;
|
|
26
|
+
|
|
27
|
+
/** The whole stored record (session + service + api_base), or null. */
|
|
28
|
+
export function readStore(a, service = TESTBED_SERVICE) {
|
|
29
|
+
let path;
|
|
30
|
+
try { path = agentAuthPath(a, service); } catch { return null; }
|
|
31
|
+
if (!existsSync(path)) return null;
|
|
32
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function accessExpiresWithinSkew(store, now) {
|
|
36
|
+
const exp = Date.parse(store.access_expires_at);
|
|
37
|
+
return !Number.isFinite(exp) || exp - now <= REFRESH_SKEW_MS;
|
|
38
|
+
}
|
|
39
|
+
function accessExpired(store, now) {
|
|
40
|
+
const exp = Date.parse(store.access_expires_at);
|
|
41
|
+
return !Number.isFinite(exp) || exp <= now;
|
|
42
|
+
}
|
|
43
|
+
function lockPath(a, service) {
|
|
44
|
+
return join(dirname(agentAuthPath(a, service)), ".auth.refresh.lock");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function rotate(a, service, store, now, fetchImpl, deadlineMs) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetchImpl(new URL("/api/auth/agent/refresh", store.api_base).toString(), {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
54
|
+
body: JSON.stringify({ schema: "raft-cli-agent-refresh.v1", refresh_token: store.refresh_token }),
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
redirect: "manual",
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) throw new Error(`agent-login: token refresh failed (HTTP ${res.status})`);
|
|
59
|
+
const text = await readBoundedText(res, controller);
|
|
60
|
+
let body;
|
|
61
|
+
try { body = JSON.parse(text); } catch { throw new Error("agent-login: refresh response was not JSON"); }
|
|
62
|
+
const session = parseAgentSession(body, now);
|
|
63
|
+
writeAgentSession(a, service, session, store.api_base, () => new Date(now).toISOString());
|
|
64
|
+
return session;
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A valid access token, refreshing (single-flight) if within the skew window; null if
|
|
71
|
+
* there is no stored session (caller must `testbed login`). */
|
|
72
|
+
export async function getFreshAgentAccessToken(a, opts = {}) {
|
|
73
|
+
const service = opts.service ?? TESTBED_SERVICE;
|
|
74
|
+
const now = opts.now ?? Date.now();
|
|
75
|
+
const store = readStore(a, service);
|
|
76
|
+
if (!store) return null;
|
|
77
|
+
if (!accessExpiresWithinSkew(store, now)) return store.access_token;
|
|
78
|
+
return singleFlightRefresh(a, service, store, now, opts.fetchImpl ?? fetch, opts.sleepImpl ?? sleep, false, opts.deadlineMs ?? REFRESH_DEADLINE_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Force one refresh (after a 401), single-flight. */
|
|
82
|
+
export async function forceRefreshAgentToken(a, opts = {}) {
|
|
83
|
+
const service = opts.service ?? TESTBED_SERVICE;
|
|
84
|
+
const now = opts.now ?? Date.now();
|
|
85
|
+
const store = readStore(a, service);
|
|
86
|
+
if (!store) return null;
|
|
87
|
+
return singleFlightRefresh(a, service, store, now, opts.fetchImpl ?? fetch, opts.sleepImpl ?? sleep, true, opts.deadlineMs ?? REFRESH_DEADLINE_MS);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function singleFlightRefresh(a, service, baseline, now, fetchImpl, sleepImpl, force, deadlineMs) {
|
|
91
|
+
const lock = lockPath(a, service);
|
|
92
|
+
const ownerId = newOwnerId();
|
|
93
|
+
let acquired;
|
|
94
|
+
try {
|
|
95
|
+
const fd = openSync(lock, "wx");
|
|
96
|
+
try { writeSync(fd, ownerId); } finally { try { closeSync(fd); } catch { /* ignore */ } }
|
|
97
|
+
acquired = true;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e?.code !== "EEXIST") throw e;
|
|
100
|
+
acquired = acquireIfDeadOwner(lock, ownerId);
|
|
101
|
+
if (!acquired) return waitForNewerSession(a, service, baseline, now, force, sleepImpl);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const fresh = readStore(a, service);
|
|
105
|
+
if (!fresh) return null;
|
|
106
|
+
if (!force && !accessExpiresWithinSkew(fresh, now)) return fresh.access_token;
|
|
107
|
+
const session = await rotate(a, service, fresh, now, fetchImpl, deadlineMs);
|
|
108
|
+
return session.access_token;
|
|
109
|
+
} finally {
|
|
110
|
+
releaseOwnLock(lock, ownerId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function releaseOwnLock(lock, ownerId) {
|
|
115
|
+
try { if (readFileSync(lock, "utf8") === ownerId) unlinkSync(lock); } catch { /* gone or foreign */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function acquireIfDeadOwner(lock, ownerId) {
|
|
119
|
+
const ffd = acquireReaperFence(`${lock}.reap`);
|
|
120
|
+
if (ffd === null) return false;
|
|
121
|
+
try {
|
|
122
|
+
let owner = "";
|
|
123
|
+
try { owner = readFileSync(lock, "utf8"); } catch (e) { if (e?.code !== "ENOENT") return false; }
|
|
124
|
+
if (owner) {
|
|
125
|
+
const pid = ownerPid(owner);
|
|
126
|
+
if (pid === null || ownerAlive(pid)) return false;
|
|
127
|
+
try { unlinkSync(lock); } catch { /* vanished */ }
|
|
128
|
+
}
|
|
129
|
+
let fd;
|
|
130
|
+
try { fd = openSync(lock, "wx"); } catch (e) { if (e?.code === "EEXIST") return false; throw e; }
|
|
131
|
+
try { writeSync(fd, ownerId); } finally { try { closeSync(fd); } catch { /* ignore */ } }
|
|
132
|
+
return true;
|
|
133
|
+
} finally {
|
|
134
|
+
try { closeSync(ffd); } catch { /* ignore */ }
|
|
135
|
+
try { unlinkSync(`${lock}.reap`); } catch { /* ignore */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function acquireReaperFence(fence) {
|
|
140
|
+
try {
|
|
141
|
+
const fd = openSync(fence, "wx");
|
|
142
|
+
try { writeSync(fd, newOwnerId()); } catch { /* marker only */ }
|
|
143
|
+
return fd;
|
|
144
|
+
} catch {
|
|
145
|
+
return null; // never recover a leftover fence
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function ownerPid(owner) {
|
|
149
|
+
const m = /^(\d+):/.exec(owner);
|
|
150
|
+
if (!m) return null;
|
|
151
|
+
const pid = Number(m[1]);
|
|
152
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
153
|
+
}
|
|
154
|
+
function ownerAlive(pid) {
|
|
155
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e?.code !== "ESRCH"; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function waitForNewerSession(a, service, baseline, now, force, sleepImpl) {
|
|
159
|
+
const maxPolls = Math.ceil(LOCK_WAIT_MS / LOCK_POLL_MS);
|
|
160
|
+
for (let i = 0; i < maxPolls; i += 1) {
|
|
161
|
+
await sleepImpl(LOCK_POLL_MS);
|
|
162
|
+
const cur = readStore(a, service);
|
|
163
|
+
if (cur && cur.refresh_token !== baseline.refresh_token) return cur.access_token;
|
|
164
|
+
}
|
|
165
|
+
const cur = readStore(a, service);
|
|
166
|
+
if (!force && cur && !accessExpired(cur, now)) return cur.access_token;
|
|
167
|
+
throw new Error("agent-login: timed out waiting for a concurrent token refresh");
|
|
168
|
+
}
|
package/lib/auth.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which credential this process uses. Two swimlanes, never mixed:
|
|
3
|
+
* agent — all managed-agent markers present (SLOCK_CLI_TRANSPORT_DIR + SLOCK_HOME +
|
|
4
|
+
* SLOCK_AGENT_ID + executable raft wrapper): ONLY the per-agent store token,
|
|
5
|
+
* auto-refreshed. TESTBED_TOKEN is ignored here on purpose — an agent must
|
|
6
|
+
* authenticate as itself.
|
|
7
|
+
* human — no markers: TESTBED_TOKEN env (a session or deploy token), unchanged.
|
|
8
|
+
* fail_closed — some markers but not all: refuse. Never fall back to the human lane
|
|
9
|
+
* from a half-configured agent environment.
|
|
10
|
+
*/
|
|
11
|
+
import { unlinkSync, existsSync } from "node:fs";
|
|
12
|
+
import { admitAgent, agentAuthPath } from "@botiverse/agent-session-store";
|
|
13
|
+
import { TESTBED_SERVICE } from "./agent-login.mjs";
|
|
14
|
+
import { getFreshAgentAccessToken, forceRefreshAgentToken, readStore } from "./agent-refresh.mjs";
|
|
15
|
+
|
|
16
|
+
export function resolveAuth({ urlBase, urlExplicit, env = process.env, service = TESTBED_SERVICE }) {
|
|
17
|
+
const admission = admitAgent(env);
|
|
18
|
+
if (admission.kind === "fail_closed") {
|
|
19
|
+
throw new Error(`refusing to run: partial managed-agent environment (${admission.reason}); not falling back to TESTBED_TOKEN`);
|
|
20
|
+
}
|
|
21
|
+
if (admission.kind === "agent") {
|
|
22
|
+
const a = admission.env;
|
|
23
|
+
const store = readStore(a, service);
|
|
24
|
+
// The instance the session was minted on wins unless TESTBED_URL was set explicitly.
|
|
25
|
+
const apiBase = urlExplicit ? urlBase : (store?.api_base ?? urlBase);
|
|
26
|
+
return {
|
|
27
|
+
mode: "agent",
|
|
28
|
+
apiBase,
|
|
29
|
+
agentEnv: a,
|
|
30
|
+
service,
|
|
31
|
+
async token() {
|
|
32
|
+
const t = await getFreshAgentAccessToken(a, { service });
|
|
33
|
+
if (!t) throw new Error("not logged in: run `testbed login` (managed agent)");
|
|
34
|
+
return t;
|
|
35
|
+
},
|
|
36
|
+
async retryToken() { return forceRefreshAgentToken(a, { service }); },
|
|
37
|
+
logout() {
|
|
38
|
+
const p = agentAuthPath(a, service);
|
|
39
|
+
if (existsSync(p)) unlinkSync(p);
|
|
40
|
+
return p;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const token = env.TESTBED_TOKEN || "";
|
|
45
|
+
return {
|
|
46
|
+
mode: "human",
|
|
47
|
+
apiBase: urlBase,
|
|
48
|
+
async token() {
|
|
49
|
+
if (!token) throw new Error("TESTBED_TOKEN is not set (session or deploy token); inside a managed agent run `testbed login`");
|
|
50
|
+
return token;
|
|
51
|
+
},
|
|
52
|
+
async retryToken() { return null; },
|
|
53
|
+
logout() { return null; },
|
|
54
|
+
};
|
|
55
|
+
}
|
package/lib/client.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Minimal JSON client. Agent mode retries ONCE after a 401 with a forced refresh. */
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
constructor(status, body, text) {
|
|
4
|
+
super(`${status}: ${body?.error ?? (text ?? "").slice(0, 200)}`);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.body = body;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function makeClient(auth, fetchImpl = fetch) {
|
|
11
|
+
async function once(method, path, body, token) {
|
|
12
|
+
const res = await fetchImpl(`${auth.apiBase}/api${path}`, {
|
|
13
|
+
method,
|
|
14
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
|
|
15
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const text = await res.text();
|
|
18
|
+
let json = null;
|
|
19
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON error page */ }
|
|
20
|
+
return { res, json, text };
|
|
21
|
+
}
|
|
22
|
+
return async function api(method, path, body) {
|
|
23
|
+
let { res, json, text } = await once(method, path, body, await auth.token());
|
|
24
|
+
if (res.status === 401 && auth.mode === "agent") {
|
|
25
|
+
const t = await auth.retryToken();
|
|
26
|
+
if (t) ({ res, json, text } = await once(method, path, body, t));
|
|
27
|
+
}
|
|
28
|
+
if (!res.ok) throw new ApiError(res.status, json, text);
|
|
29
|
+
return json;
|
|
30
|
+
};
|
|
31
|
+
}
|
package/lib/main.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// testbed command set. Output is human tables by default; `--json` prints the
|
|
2
|
+
// raw API response (what an agent wants). Secrets: `acquire` returns a one-time ADB
|
|
3
|
+
// private key; it is printed only in --json output or written to --key-out (0600).
|
|
4
|
+
import { openSync, writeSync, closeSync } from "node:fs";
|
|
5
|
+
import { resolveAuth } from "./auth.mjs";
|
|
6
|
+
import { makeClient, ApiError } from "./client.mjs";
|
|
7
|
+
import { runAgentLogin } from "./agent-login.mjs";
|
|
8
|
+
|
|
9
|
+
export const USAGE = `testbed — cloud phones + the acceptance stamp in the Raft release loop
|
|
10
|
+
|
|
11
|
+
testbed login agent: PKCE login via raft (no browser); human: how to set TESTBED_TOKEN
|
|
12
|
+
testbed logout agent: forget the stored session
|
|
13
|
+
testbed whoami who the API thinks you are
|
|
14
|
+
|
|
15
|
+
testbed pool warm-pool devices and free/leased state
|
|
16
|
+
testbed acquire [--ttl MIN] [--instance ID] [--request-id ID] [--key-out FILE]
|
|
17
|
+
lease a phone; the ADB private key goes to --key-out (0600) or --json
|
|
18
|
+
testbed devices your leases
|
|
19
|
+
testbed renew <lease-id> [--ttl MIN]
|
|
20
|
+
testbed release <lease-id>
|
|
21
|
+
testbed repair-adb <lease-id>
|
|
22
|
+
testbed diagnose-adb <lease-id> [--pubkey-sha256 HEX]
|
|
23
|
+
|
|
24
|
+
testbed cases acceptance case library
|
|
25
|
+
testbed run <case-id...> [--app --ref --apk --apk-run --release --by]
|
|
26
|
+
testbed runs
|
|
27
|
+
testbed show <run-id>
|
|
28
|
+
|
|
29
|
+
flags: --json (raw API output) env: TESTBED_TOKEN (human/CI), TESTBED_URL (instance)
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function takeFlag(args, name, dflt) {
|
|
33
|
+
const i = args.indexOf(`--${name}`);
|
|
34
|
+
if (i === -1) return dflt;
|
|
35
|
+
const v = args[i + 1];
|
|
36
|
+
args.splice(i, 2);
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
function takeSwitch(args, name) {
|
|
40
|
+
const i = args.indexOf(`--${name}`);
|
|
41
|
+
if (i === -1) return false;
|
|
42
|
+
args.splice(i, 1);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function table(rows, cols, out) {
|
|
47
|
+
const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? "").length)));
|
|
48
|
+
out(cols.map((c, i) => c.padEnd(w[i])).join(" "));
|
|
49
|
+
for (const r of rows) out(cols.map((c, i) => String(r[c] ?? "").padEnd(w[i])).join(" "));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeKeyFile(path, key) {
|
|
53
|
+
const fd = openSync(path, "wx", 0o600);
|
|
54
|
+
try { writeSync(fd, key.endsWith("\n") ? key : key + "\n"); } finally { closeSync(fd); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string[]} argv
|
|
59
|
+
* @param {{urlBase:string, urlExplicit?:boolean, env?:NodeJS.ProcessEnv, stdout?:(s:string)=>void,
|
|
60
|
+
* stderr?:(s:string)=>void, fetchImpl?:typeof fetch, loginImpl?:typeof runAgentLogin}} opts
|
|
61
|
+
* @returns {Promise<number>} exit code
|
|
62
|
+
*/
|
|
63
|
+
export async function main(argv, opts) {
|
|
64
|
+
const args = [...argv];
|
|
65
|
+
const out = opts.stdout ?? ((s) => console.log(s));
|
|
66
|
+
const err = opts.stderr ?? ((s) => console.error(s));
|
|
67
|
+
const env = opts.env ?? process.env;
|
|
68
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
69
|
+
const json = takeSwitch(args, "json");
|
|
70
|
+
const cmd = args.shift();
|
|
71
|
+
const emit = (data, human) => { if (json) out(JSON.stringify(data, null, 2)); else human(); };
|
|
72
|
+
|
|
73
|
+
let auth;
|
|
74
|
+
try {
|
|
75
|
+
auth = resolveAuth({ urlBase: opts.urlBase, urlExplicit: opts.urlExplicit, env });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
err(`testbed: ${e.message}`);
|
|
78
|
+
return exit(1);
|
|
79
|
+
}
|
|
80
|
+
const api = makeClient(auth, opts.fetchImpl);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
switch (cmd) {
|
|
84
|
+
case "login": {
|
|
85
|
+
if (auth.mode !== "agent") {
|
|
86
|
+
out("Not a managed Raft agent. Humans and CI use TESTBED_TOKEN:");
|
|
87
|
+
out(` browser: ${auth.apiBase}/login (then reuse the stamp_session cookie value as TESTBED_TOKEN)`);
|
|
88
|
+
out(" CI: a deploy token issued by the testbed owner");
|
|
89
|
+
return exit(0);
|
|
90
|
+
}
|
|
91
|
+
const login = opts.loginImpl ?? runAgentLogin;
|
|
92
|
+
const session = await login(auth.agentEnv, auth.apiBase, { service: auth.service, fetchImpl: opts.fetchImpl });
|
|
93
|
+
emit({ ok: true, service: auth.service, api_base: auth.apiBase, access_expires_at: session.access_expires_at },
|
|
94
|
+
() => out(`Agent login ready (${auth.service} @ ${auth.apiBase}); access token valid until ${session.access_expires_at}`));
|
|
95
|
+
return exit(0);
|
|
96
|
+
}
|
|
97
|
+
case "logout": {
|
|
98
|
+
const p = auth.logout();
|
|
99
|
+
out(p ? `forgot agent session ${p}` : "nothing stored (human/CI lane uses TESTBED_TOKEN)");
|
|
100
|
+
return exit(0);
|
|
101
|
+
}
|
|
102
|
+
case "whoami": {
|
|
103
|
+
const me = await api("GET", "/me");
|
|
104
|
+
emit(me, () => out(`${me.actor.kind} ${me.actor.name}${me.actor.subject ? ` (${me.actor.subject}@${me.actor.server_id})` : ""} @ ${auth.apiBase}`));
|
|
105
|
+
return exit(0);
|
|
106
|
+
}
|
|
107
|
+
case "pool": {
|
|
108
|
+
const d = await api("GET", "/device-broker/pool");
|
|
109
|
+
emit(d, () => table(d.pool.map((p) => ({
|
|
110
|
+
instance: p.instance_id, status: p.status, leased_by: p.leased_by ?? "-",
|
|
111
|
+
expires: p.expires_at ? new Date(p.expires_at).toISOString().slice(0, 16) : "-", adb: p.adb_endpoint ?? "-",
|
|
112
|
+
})), ["instance", "status", "leased_by", "expires", "adb"], out));
|
|
113
|
+
return exit(0);
|
|
114
|
+
}
|
|
115
|
+
case "acquire": {
|
|
116
|
+
const ttl = takeFlag(args, "ttl");
|
|
117
|
+
const instance = takeFlag(args, "instance");
|
|
118
|
+
const requestId = takeFlag(args, "request-id");
|
|
119
|
+
const keyOut = takeFlag(args, "key-out");
|
|
120
|
+
const body = {};
|
|
121
|
+
if (ttl !== undefined) body.ttl_minutes = Number(ttl);
|
|
122
|
+
if (instance) body.instance_id = instance;
|
|
123
|
+
if (requestId) body.request_id = requestId;
|
|
124
|
+
const d = await api("POST", "/device-broker/leases", body);
|
|
125
|
+
if (d.status === "in_flight") {
|
|
126
|
+
emit(d, () => out(`in_flight: request ${d.request_id} on ${d.instance_id ?? "?"} — retry acquire with --request-id ${d.request_id}`));
|
|
127
|
+
return exit(2);
|
|
128
|
+
}
|
|
129
|
+
let keyPath = null;
|
|
130
|
+
if (keyOut && d.adb_private_key) {
|
|
131
|
+
writeKeyFile(keyOut, d.adb_private_key);
|
|
132
|
+
keyPath = keyOut;
|
|
133
|
+
}
|
|
134
|
+
const shown = keyPath ? { ...d, adb_private_key: undefined, adb_private_key_file: keyPath } : d;
|
|
135
|
+
emit(shown, () => {
|
|
136
|
+
out(`lease ${d.lease.id} on ${d.lease.instance_id} until ${d.lease.expires_at ? new Date(d.lease.expires_at).toISOString() : "?"}`);
|
|
137
|
+
out(`adb: ${d.adb_endpoint ?? "(pending) " + (d.adb_note ?? "")}`);
|
|
138
|
+
out(`adb_public_key_sha256: ${d.adb_public_key_sha256 ?? "-"}`);
|
|
139
|
+
out(keyPath ? `adb private key written to ${keyPath} (0600)` : "adb private key: not shown — pass --key-out FILE or --json");
|
|
140
|
+
});
|
|
141
|
+
return exit(0);
|
|
142
|
+
}
|
|
143
|
+
case "devices": {
|
|
144
|
+
const d = await api("GET", "/device-broker/leases");
|
|
145
|
+
emit(d, () => table(d.leases.map((l) => ({
|
|
146
|
+
lease: l.id, instance: l.instance_id, status: l.status,
|
|
147
|
+
expires: l.expires_at ? new Date(l.expires_at).toISOString().slice(0, 16) : "-", adb: l.adb_endpoint ?? "-",
|
|
148
|
+
})), ["lease", "instance", "status", "expires", "adb"], out));
|
|
149
|
+
return exit(0);
|
|
150
|
+
}
|
|
151
|
+
case "renew": {
|
|
152
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed renew <lease-id> [--ttl MIN]");
|
|
153
|
+
const ttl = takeFlag(args, "ttl");
|
|
154
|
+
const d = await api("POST", `/device-broker/leases/${encodeURIComponent(id)}/renew`, ttl !== undefined ? { ttl_minutes: Number(ttl) } : {});
|
|
155
|
+
emit(d, () => out(`lease ${d.lease.id} now until ${new Date(d.lease.expires_at).toISOString()}`));
|
|
156
|
+
return exit(0);
|
|
157
|
+
}
|
|
158
|
+
case "release": {
|
|
159
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed release <lease-id>");
|
|
160
|
+
const d = await api("DELETE", `/device-broker/leases/${encodeURIComponent(id)}`);
|
|
161
|
+
emit(d, () => out(d.status === "in_flight" ? "release in flight; reclaim continues asynchronously" : `released ${id}`));
|
|
162
|
+
return exit(0);
|
|
163
|
+
}
|
|
164
|
+
case "repair-adb": {
|
|
165
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed repair-adb <lease-id>");
|
|
166
|
+
const d = await api("POST", `/device-broker/leases/${encodeURIComponent(id)}/repair-adb`, {});
|
|
167
|
+
emit(d, () => out(`repair-adb: ${d.status} on ${d.instance_id}`));
|
|
168
|
+
return exit(0);
|
|
169
|
+
}
|
|
170
|
+
case "diagnose-adb": {
|
|
171
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed diagnose-adb <lease-id> [--pubkey-sha256 HEX]");
|
|
172
|
+
const sha = takeFlag(args, "pubkey-sha256");
|
|
173
|
+
const d = await api("POST", `/device-broker/leases/${encodeURIComponent(id)}/diagnose-adb`, sha ? { expected_public_key_sha256: sha } : {});
|
|
174
|
+
emit(d, () => out(JSON.stringify(d, null, 2)));
|
|
175
|
+
return exit(0);
|
|
176
|
+
}
|
|
177
|
+
case "cases": {
|
|
178
|
+
const d = await api("GET", "/cases");
|
|
179
|
+
emit(d, () => table(d.cases.map((c) => ({ id: c.id, tier: c.tier, rev: c.revision, title: c.title })), ["id", "tier", "rev", "title"], out));
|
|
180
|
+
return exit(0);
|
|
181
|
+
}
|
|
182
|
+
case "run": {
|
|
183
|
+
const app = takeFlag(args, "app", "raft-android");
|
|
184
|
+
const apk = takeFlag(args, "apk");
|
|
185
|
+
const ref = takeFlag(args, "ref");
|
|
186
|
+
const apkRun = takeFlag(args, "apk-run");
|
|
187
|
+
const release = takeFlag(args, "release");
|
|
188
|
+
const by = takeFlag(args, "by", env.USER || "testbed-cli");
|
|
189
|
+
const caseIds = args.filter((a) => !a.startsWith("--"));
|
|
190
|
+
if (!caseIds.length) throw new Error("usage: testbed run <case-id...> [--app slug] [--ref branch] [--apk url] [--apk-run ci-run-id] [--release id]");
|
|
191
|
+
const d = await api("POST", "/runs", { app_slug: app, case_ids: caseIds, apk_ref: apk, ref, apk_run_id: apkRun, hands_release_id: release, requested_by: by });
|
|
192
|
+
emit(d, () => { out(`run ${d.run_id} — ${d.cases} case(s), ${d.dispatched} dispatched`); out(`${auth.apiBase}/runs/${d.run_id}`); });
|
|
193
|
+
return exit(0);
|
|
194
|
+
}
|
|
195
|
+
case "runs": {
|
|
196
|
+
const d = await api("GET", "/runs");
|
|
197
|
+
emit(d, () => table(d.runs.map((r) => ({
|
|
198
|
+
run: r.id.slice(0, 8), app: r.app_slug, status: r.status, cases: r.case_count,
|
|
199
|
+
release: r.hands_release_id ?? "-", created: new Date(r.created_at).toISOString().slice(0, 16),
|
|
200
|
+
})), ["run", "app", "status", "cases", "release", "created"], out));
|
|
201
|
+
return exit(0);
|
|
202
|
+
}
|
|
203
|
+
case "show": {
|
|
204
|
+
const id = args[0]; if (!id) throw new Error("usage: testbed show <run-id>");
|
|
205
|
+
const d = await api("GET", `/runs/${encodeURIComponent(id)}`);
|
|
206
|
+
emit(d, () => {
|
|
207
|
+
out(`run ${d.run.id} — ${d.run.status} (${d.run.app_slug}, release: ${d.run.hands_release_id ?? "-"})`);
|
|
208
|
+
out(`${auth.apiBase}/runs/${d.run.id}\n`);
|
|
209
|
+
table(d.cases.map((rc) => {
|
|
210
|
+
const v = rc.verdict_json ? JSON.parse(rc.verdict_json) : null;
|
|
211
|
+
const ev = d.evidence.filter((e) => e.run_case_id === rc.id).length;
|
|
212
|
+
return { case: rc.case_id, status: rc.status, verdict: v?.verdict ?? "-", turns: v?.turns ?? "-", evidence: ev };
|
|
213
|
+
}), ["case", "status", "verdict", "turns", "evidence"], out);
|
|
214
|
+
for (const rv of d.reviews) out(`\nfinal: ${rv.verdict} by ${rv.reviewer}${rv.comment ? ` — ${rv.comment}` : ""}`);
|
|
215
|
+
});
|
|
216
|
+
return exit(0);
|
|
217
|
+
}
|
|
218
|
+
default:
|
|
219
|
+
out(USAGE);
|
|
220
|
+
return exit(cmd ? 1 : 0);
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (e instanceof ApiError) {
|
|
224
|
+
err(`testbed: ${e.message}`);
|
|
225
|
+
if (e.status === 401) err(auth.mode === "agent" ? "testbed: session rejected — run `testbed login`" : "testbed: check TESTBED_TOKEN");
|
|
226
|
+
return exit(1);
|
|
227
|
+
}
|
|
228
|
+
err(`testbed: ${e.message}`);
|
|
229
|
+
return exit(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@botiverse/testbed-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for testbed \u2014 cloud phones and the acceptance stamp in the Raft release loop",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"testbed": "bin/testbed.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@botiverse/agent-session-store": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/botiverse/testbed.git"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
}
|
|
30
|
+
}
|