@botiverse/hands-cli 0.5.15 → 0.5.16

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.
@@ -0,0 +1,33 @@
1
+ export declare const HANDS_SERVICE = "hands-4cc7a2";
2
+ export interface AgentEnv {
3
+ transportDir: string;
4
+ slockHome: string;
5
+ agentId: string;
6
+ raftBin: string;
7
+ }
8
+ export type Admission = {
9
+ kind: "human";
10
+ } | {
11
+ kind: "agent";
12
+ env: AgentEnv;
13
+ } | {
14
+ kind: "fail_closed";
15
+ reason: string;
16
+ };
17
+ /**
18
+ * Decide human vs agent vs fail-closed. Once ANY agent marker is present we never
19
+ * return `human` — a partial/invalid environment fails closed rather than silently
20
+ * using ambient human credentials.
21
+ */
22
+ export declare function admitAgent(env?: NodeJS.ProcessEnv): Admission;
23
+ /** Canonical per-agent store path, with slug validation + root containment. */
24
+ export declare function agentAuthPath(a: AgentEnv, service?: string): string;
25
+ /**
26
+ * Read the stored Hands access token for this agent, or null if absent/unreadable.
27
+ * Dependency-free (fs + path only) so `config.ts` can call it without an import cycle
28
+ * through the api client. Auto-refresh-on-expiry lands in CP3 checkpoint-2.
29
+ */
30
+ export declare function readAgentAccessToken(a: AgentEnv, service?: string): string | null;
31
+ /** Read the api base recorded in the agent store (so the resolver never reads the
32
+ * human config in agent mode), or null. */
33
+ export declare function readAgentApiBase(a: AgentEnv, service?: string): string | null;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Agent runtime admission + credential-store path (RFC 057 agent login, CP3).
3
+ *
4
+ * Admission is TRI-STATE (Volta): no markers → normal human/CI; any agent marker but
5
+ * incomplete/invalid/no-executable-wrapper → FAIL CLOSED (never fall back to human
6
+ * credentials); complete + valid → agent mode, pinned to the exact `raft` wrapper
7
+ * inside $SLOCK_CLI_TRANSPORT_DIR (never the PATH `raft`).
8
+ */
9
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
10
+ import { join, resolve, sep } from "node:path";
11
+ // Compiled-fixed exact installed Raft client key. NOT environment-overridable: it is
12
+ // both the invoke target AND part of the on-disk store path, so an override would be a
13
+ // cross-service / path-injection vector. Tests inject a service via function params.
14
+ export const HANDS_SERVICE = "hands-4cc7a2";
15
+ // RFC 057 service slug.
16
+ const SERVICE_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
17
+ // Daemon-issued agent id: a conservative safe token (no separators/traversal).
18
+ const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
19
+ function isExecutableFile(p) {
20
+ try {
21
+ if (!statSync(p).isFile())
22
+ return false;
23
+ accessSync(p, constants.X_OK);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Decide human vs agent vs fail-closed. Once ANY agent marker is present we never
32
+ * return `human` — a partial/invalid environment fails closed rather than silently
33
+ * using ambient human credentials.
34
+ */
35
+ export function admitAgent(env = process.env) {
36
+ const transportDir = env.SLOCK_CLI_TRANSPORT_DIR;
37
+ const slockHome = env.SLOCK_HOME;
38
+ const agentId = env.SLOCK_AGENT_ID;
39
+ if (!transportDir && !slockHome && !agentId)
40
+ return { kind: "human" };
41
+ if (!transportDir || !slockHome || !agentId) {
42
+ return { kind: "fail_closed", reason: "incomplete agent markers" };
43
+ }
44
+ if (!AGENT_ID_RE.test(agentId)) {
45
+ return { kind: "fail_closed", reason: "invalid SLOCK_AGENT_ID" };
46
+ }
47
+ const raftBin = join(transportDir, "raft");
48
+ if (!isExecutableFile(raftBin)) {
49
+ return { kind: "fail_closed", reason: "raft wrapper missing or not executable in transport dir" };
50
+ }
51
+ return { kind: "agent", env: { transportDir, slockHome, agentId, raftBin } };
52
+ }
53
+ /** Canonical per-agent store path, with slug validation + root containment. */
54
+ export function agentAuthPath(a, service = HANDS_SERVICE) {
55
+ if (!SERVICE_SLUG_RE.test(service))
56
+ throw new Error("invalid service slug");
57
+ if (!AGENT_ID_RE.test(a.agentId))
58
+ throw new Error("invalid agent id");
59
+ const root = resolve(a.slockHome, "agents", a.agentId, "integrations");
60
+ const path = resolve(root, service, "auth.json");
61
+ // Belt-and-suspenders containment (agentId/service are already regex-validated).
62
+ if (path !== join(root, service, "auth.json") || !path.startsWith(root + sep)) {
63
+ throw new Error("resolved store path escapes the integrations root");
64
+ }
65
+ return path;
66
+ }
67
+ /**
68
+ * Read the stored Hands access token for this agent, or null if absent/unreadable.
69
+ * Dependency-free (fs + path only) so `config.ts` can call it without an import cycle
70
+ * through the api client. Auto-refresh-on-expiry lands in CP3 checkpoint-2.
71
+ */
72
+ export function readAgentAccessToken(a, service = HANDS_SERVICE) {
73
+ let path;
74
+ try {
75
+ path = agentAuthPath(a, service);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ if (!existsSync(path))
81
+ return null;
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
84
+ return typeof parsed?.access_token === "string" && parsed.access_token.length > 0
85
+ ? parsed.access_token
86
+ : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /** Read the api base recorded in the agent store (so the resolver never reads the
93
+ * human config in agent mode), or null. */
94
+ export function readAgentApiBase(a, service = HANDS_SERVICE) {
95
+ let path;
96
+ try {
97
+ path = agentAuthPath(a, service);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (!existsSync(path))
103
+ return null;
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
106
+ return typeof parsed?.api_base === "string" && parsed.api_base.length > 0 ? parsed.api_base : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ //# sourceMappingURL=agent_env.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent_env.js","sourceRoot":"","sources":["../../src/lib/agent_env.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ import { type AgentEnv } from "./agent_env.js";
2
+ export declare const REFRESH_SKEW_MS = 60000;
3
+ export interface RefreshOptions {
4
+ service?: string;
5
+ now?: number;
6
+ fetchImpl?: typeof fetch;
7
+ /** Injectable sleep for tests (defaults to real setTimeout). */
8
+ sleepImpl?: (ms: number) => Promise<void>;
9
+ /** Injectable refresh deadline for tests (defaults to REFRESH_DEADLINE_MS). */
10
+ deadlineMs?: number;
11
+ }
12
+ /**
13
+ * Return a valid access token, refreshing (single-flight) if it is within the skew
14
+ * window. Returns null if there is no stored session (caller must `hands login`).
15
+ */
16
+ export declare function getFreshAgentAccessToken(a: AgentEnv, opts?: RefreshOptions): Promise<string | null>;
17
+ /**
18
+ * Force one refresh (used on a 401 even if the token looked unexpired), single-flight.
19
+ * Returns null if there is no stored session.
20
+ */
21
+ export declare function forceRefreshAgentToken(a: AgentEnv, opts?: RefreshOptions): Promise<string | null>;
22
+ /** Delete the lock only if it still carries OUR owner id (never a foreign holder's). */
23
+ export declare function releaseOwnLock(lock: string, ownerId: string): void;
24
+ /**
25
+ * Recover a lock whose owner process is provably dead and acquire it, serialized by an
26
+ * exclusive reaper fence. Judge-dead → unlink → re-acquire all happen WHILE the fence is
27
+ * held, so at most one recoverer reaps-and-rebuilds — two concurrent recoverers can never
28
+ * both re-acquire (→ never double-rotate). A LIVE owner (incl. suspended/stalled — pid
29
+ * still exists) is never touched. Any uncertainty fails closed. Returns true iff WE now
30
+ * hold the main lock carrying `ownerId`; false → the caller is a loser.
31
+ */
32
+ export declare function acquireIfDeadOwner(lock: string, ownerId: string): boolean;
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Agent token auto-refresh + cross-process single-flight (RFC 057, CP3 checkpoint-2).
3
+ *
4
+ * The agent access token is short-lived; a long-lived rotating refresh token in the
5
+ * store renews it. This module:
6
+ * - refreshes PROACTIVELY when the access token is within a skew window of expiry,
7
+ * and REACTIVELY once after a 401 (driven by api.ts);
8
+ * - serializes refresh across concurrent `hands` processes sharing one $SLOCK_HOME
9
+ * via an O_EXCL lock. A concurrent loser re-reads the store and returns ONLY the
10
+ * strictly-newer session the winner persisted (a changed refresh token vs the one
11
+ * it started with); it NEVER rotates in parallel and never re-hands the token it
12
+ * came in with (a double-rotate trips the server's refresh-reuse detection and
13
+ * chain-revokes the family; re-handing a just-401'd token would 401 again).
14
+ *
15
+ * Lock safety:
16
+ * - the refresh op (fetch + bounded body read + parse + persist) is hard-aborted at a
17
+ * deadline, so a live holder cannot hold the lock forever;
18
+ * - a lock is broken ONLY when its owner process is provably DEAD (`process.kill(pid,0)`
19
+ * → ESRCH) — never on elapsed time, so a suspended / stalled but live holder is never
20
+ * stolen;
21
+ * - dead-lock recovery is serialized by an exclusive reaper fence and the main lock is
22
+ * re-acquired WHILE the fence is held, so two concurrent recoverers can never both
23
+ * reap-and-rebuild (→ never double-rotate);
24
+ * - any uncertainty (unparseable owner, non-ENOENT read error, contended/live fence)
25
+ * fails closed: the caller becomes a loser rather than risk an unsafe break.
26
+ *
27
+ * It does its own fetch (not the api client) to avoid an import cycle, and never echoes
28
+ * response bodies in errors.
29
+ */
30
+ import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, } from "node:fs";
31
+ import { dirname, join } from "node:path";
32
+ import { randomBytes } from "node:crypto";
33
+ import { agentAuthPath, HANDS_SERVICE, } from "./agent_env.js";
34
+ import { parseAgentSession, writeAgentSession, readBoundedText, } from "./agent_auth.js";
35
+ // Refresh when the access token expires within this window (or is already expired).
36
+ export const REFRESH_SKEW_MS = 60_000;
37
+ // The whole refresh op (fetch + bounded read + parse + persist) is aborted at this
38
+ // deadline. Breaking a lock is NOT time-based, though — only a provably-dead owner is.
39
+ const REFRESH_DEADLINE_MS = 20_000;
40
+ const LOCK_WAIT_MS = 25_000; // how long a loser waits for the winner's strictly-newer session
41
+ const LOCK_POLL_MS = 100;
42
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
43
+ function newOwnerId() {
44
+ return `${process.pid}:${randomBytes(12).toString("hex")}`;
45
+ }
46
+ function readStore(a, service) {
47
+ let path;
48
+ try {
49
+ path = agentAuthPath(a, service);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (!existsSync(path))
55
+ return null;
56
+ try {
57
+ return JSON.parse(readFileSync(path, "utf8"));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ function accessExpiresWithinSkew(store, now) {
64
+ const exp = Date.parse(store.access_expires_at);
65
+ return !Number.isFinite(exp) || exp - now <= REFRESH_SKEW_MS;
66
+ }
67
+ function accessExpired(store, now) {
68
+ const exp = Date.parse(store.access_expires_at);
69
+ return !Number.isFinite(exp) || exp <= now;
70
+ }
71
+ function lockPath(a, service) {
72
+ return join(dirname(agentAuthPath(a, service)), ".auth.refresh.lock");
73
+ }
74
+ /**
75
+ * POST the refresh token, validate the session, atomically persist it. Own fetch,
76
+ * hard-aborted at `deadlineMs` across the WHOLE operation (fetch + bounded body read +
77
+ * parse + persist), never following a redirect (a 307/308 would forward the refresh
78
+ * token), and never echoing the body.
79
+ */
80
+ async function rotate(a, service, store, now, fetchImpl, deadlineMs) {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), deadlineMs);
83
+ try {
84
+ const res = await fetchImpl(new URL("/api/auth/agent/refresh", store.api_base).toString(), {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json", accept: "application/json" },
87
+ body: JSON.stringify({ schema: "raft-cli-agent-refresh.v1", refresh_token: store.refresh_token }),
88
+ signal: controller.signal,
89
+ redirect: "manual",
90
+ });
91
+ if (!res.ok) {
92
+ // `manual` leaves a 3xx as a non-ok status. Stable reason only; never echo the body.
93
+ throw new Error(`agent-login: token refresh failed (HTTP ${res.status})`);
94
+ }
95
+ const text = await readBoundedText(res, controller);
96
+ let body;
97
+ try {
98
+ body = JSON.parse(text);
99
+ }
100
+ catch {
101
+ throw new Error("agent-login: refresh response was not JSON");
102
+ }
103
+ const session = parseAgentSession(body, now);
104
+ writeAgentSession(a, service, session, store.api_base, () => new Date(now).toISOString());
105
+ return session;
106
+ }
107
+ finally {
108
+ // Deadline stays armed across fetch + bounded read + parse + persist.
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+ /**
113
+ * Return a valid access token, refreshing (single-flight) if it is within the skew
114
+ * window. Returns null if there is no stored session (caller must `hands login`).
115
+ */
116
+ export async function getFreshAgentAccessToken(a, opts = {}) {
117
+ const service = opts.service ?? HANDS_SERVICE;
118
+ const now = opts.now ?? Date.now();
119
+ const fetchImpl = opts.fetchImpl ?? fetch;
120
+ const sleepImpl = opts.sleepImpl ?? sleep;
121
+ const deadlineMs = opts.deadlineMs ?? REFRESH_DEADLINE_MS;
122
+ const store = readStore(a, service);
123
+ if (!store)
124
+ return null;
125
+ if (!accessExpiresWithinSkew(store, now))
126
+ return store.access_token; // still fresh
127
+ return singleFlightRefresh(a, service, store, now, fetchImpl, sleepImpl, /*force*/ false, deadlineMs);
128
+ }
129
+ /**
130
+ * Force one refresh (used on a 401 even if the token looked unexpired), single-flight.
131
+ * Returns null if there is no stored session.
132
+ */
133
+ export async function forceRefreshAgentToken(a, opts = {}) {
134
+ const service = opts.service ?? HANDS_SERVICE;
135
+ const now = opts.now ?? Date.now();
136
+ const fetchImpl = opts.fetchImpl ?? fetch;
137
+ const sleepImpl = opts.sleepImpl ?? sleep;
138
+ const deadlineMs = opts.deadlineMs ?? REFRESH_DEADLINE_MS;
139
+ const store = readStore(a, service);
140
+ if (!store)
141
+ return null;
142
+ return singleFlightRefresh(a, service, store, now, fetchImpl, sleepImpl, /*force*/ true, deadlineMs);
143
+ }
144
+ async function singleFlightRefresh(a, service, baseline, now, fetchImpl, sleepImpl, force, deadlineMs) {
145
+ const lock = lockPath(a, service);
146
+ const ownerId = newOwnerId();
147
+ let acquired;
148
+ try {
149
+ const fd = openSync(lock, "wx"); // fast path: no lock present
150
+ try {
151
+ writeSync(fd, ownerId);
152
+ }
153
+ finally {
154
+ try {
155
+ closeSync(fd);
156
+ }
157
+ catch { /* ignore */ }
158
+ }
159
+ acquired = true;
160
+ }
161
+ catch (e) {
162
+ if (e?.code !== "EEXIST")
163
+ throw e;
164
+ // A lock exists: recover it only if its owner is provably dead, under an exclusive
165
+ // fence, re-acquiring the main lock while the fence is held. Anything else → loser.
166
+ acquired = acquireIfDeadOwner(lock, ownerId);
167
+ if (!acquired)
168
+ return waitForNewerSession(a, service, baseline, now, force, sleepImpl);
169
+ }
170
+ // Winner: we hold `lock` carrying ownerId.
171
+ try {
172
+ const fresh = readStore(a, service);
173
+ if (!fresh)
174
+ return null;
175
+ // A prior holder may have refreshed while we blocked; only rotate if still needed.
176
+ if (!force && !accessExpiresWithinSkew(fresh, now))
177
+ return fresh.access_token;
178
+ const session = await rotate(a, service, fresh, now, fetchImpl, deadlineMs);
179
+ return session.access_token;
180
+ }
181
+ finally {
182
+ releaseOwnLock(lock, ownerId);
183
+ }
184
+ }
185
+ /** Delete the lock only if it still carries OUR owner id (never a foreign holder's). */
186
+ export function releaseOwnLock(lock, ownerId) {
187
+ try {
188
+ if (readFileSync(lock, "utf8") === ownerId)
189
+ unlinkSync(lock);
190
+ }
191
+ catch {
192
+ // already gone, unreadable, or replaced by another owner — leave it be.
193
+ }
194
+ }
195
+ /**
196
+ * Recover a lock whose owner process is provably dead and acquire it, serialized by an
197
+ * exclusive reaper fence. Judge-dead → unlink → re-acquire all happen WHILE the fence is
198
+ * held, so at most one recoverer reaps-and-rebuilds — two concurrent recoverers can never
199
+ * both re-acquire (→ never double-rotate). A LIVE owner (incl. suspended/stalled — pid
200
+ * still exists) is never touched. Any uncertainty fails closed. Returns true iff WE now
201
+ * hold the main lock carrying `ownerId`; false → the caller is a loser.
202
+ */
203
+ export function acquireIfDeadOwner(lock, ownerId) {
204
+ const ffd = acquireReaperFence(`${lock}.reap`);
205
+ if (ffd === null)
206
+ return false; // live/contended/uncertain fence → loser
207
+ try {
208
+ let owner = "";
209
+ try {
210
+ owner = readFileSync(lock, "utf8");
211
+ }
212
+ catch (e) {
213
+ // Only "already gone" is safe to proceed on; any other error is uncertain → loser.
214
+ if (e?.code !== "ENOENT")
215
+ return false;
216
+ }
217
+ if (owner) {
218
+ const pid = ownerPid(owner);
219
+ if (pid === null || ownerAlive(pid))
220
+ return false; // live / uncertain owner → loser
221
+ try {
222
+ unlinkSync(lock);
223
+ }
224
+ catch { /* vanished / already handled */ }
225
+ }
226
+ // Re-acquire the main lock while STILL holding the fence, so no other recoverer can
227
+ // rebuild it underneath us.
228
+ let fd;
229
+ try {
230
+ fd = openSync(lock, "wx");
231
+ }
232
+ catch (e) {
233
+ if (e?.code === "EEXIST")
234
+ return false; // lost the race → loser
235
+ throw e;
236
+ }
237
+ try {
238
+ writeSync(fd, ownerId);
239
+ }
240
+ finally {
241
+ try {
242
+ closeSync(fd);
243
+ }
244
+ catch { /* ignore */ }
245
+ }
246
+ return true;
247
+ }
248
+ finally {
249
+ try {
250
+ closeSync(ffd);
251
+ }
252
+ catch { /* ignore */ }
253
+ try {
254
+ unlinkSync(`${lock}.reap`);
255
+ }
256
+ catch { /* ignore */ }
257
+ }
258
+ }
259
+ /**
260
+ * Acquire the exclusive reaper fence via a single O_EXCL create. On EEXIST — a reaper is
261
+ * active, OR one crashed and left the fence — we ALWAYS fail closed (return null → loser).
262
+ * We deliberately do NOT auto-recover a leftover fence: reading its pid and unlinking it
263
+ * would recurse the very reap race the fence exists to prevent (two recoverers both unlink
264
+ * + recreate, then each deletes the other's live fence). A crashed reaper — the fence is
265
+ * held only across synchronous fs calls, never I/O — leaves a diagnosable fence for manual
266
+ * cleanup, the agreed "prefer fail-closed on reaper residue" over a second grabbable lock.
267
+ * Kernel O_EXCL is the sole mutual exclusion; nothing here reads-pid-then-unlinks.
268
+ */
269
+ function acquireReaperFence(fence) {
270
+ try {
271
+ const fd = openSync(fence, "wx");
272
+ try {
273
+ writeSync(fd, newOwnerId());
274
+ }
275
+ catch { /* diagnostic marker only; ignore */ }
276
+ return fd;
277
+ }
278
+ catch {
279
+ return null; // EEXIST or any error → fail closed (loser); never recover a leftover fence
280
+ }
281
+ }
282
+ function ownerPid(owner) {
283
+ const m = /^(\d+):/.exec(owner);
284
+ if (!m)
285
+ return null;
286
+ const pid = Number(m[1]);
287
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
288
+ }
289
+ function ownerAlive(pid) {
290
+ try {
291
+ process.kill(pid, 0); // signal 0: liveness probe, delivers nothing
292
+ return true;
293
+ }
294
+ catch (e) {
295
+ if (e?.code === "ESRCH")
296
+ return false; // no such process → dead
297
+ return true; // EPERM (exists) or anything unexpected → fail closed = treat as alive
298
+ }
299
+ }
300
+ /**
301
+ * Loser path: poll the store for the winner's strictly-newer session, identified by a
302
+ * refresh token that differs from the one we started with (`baseline`). Never rotates
303
+ * here. On timeout it FAILS rather than hand back the token we came in with; a proactive
304
+ * caller may still use a genuinely-unexpired current token, but a forced (post-401)
305
+ * caller always fails — re-handing a 401'd token would just 401 again.
306
+ */
307
+ async function waitForNewerSession(a, service, baseline, now, force, sleepImpl) {
308
+ // Bound by poll count (not wall-clock) so it is deterministic under an injected sleep in
309
+ // tests, while keeping the same real-time budget in production.
310
+ const maxPolls = Math.ceil(LOCK_WAIT_MS / LOCK_POLL_MS);
311
+ for (let i = 0; i < maxPolls; i += 1) {
312
+ await sleepImpl(LOCK_POLL_MS);
313
+ const cur = readStore(a, service);
314
+ if (cur && cur.refresh_token !== baseline.refresh_token) {
315
+ return cur.access_token; // the winner rotated: a strictly-newer session is persisted
316
+ }
317
+ }
318
+ const cur = readStore(a, service);
319
+ if (!force && cur && !accessExpired(cur, now))
320
+ return cur.access_token;
321
+ throw new Error("agent-login: timed out waiting for a concurrent token refresh");
322
+ }
323
+ //# sourceMappingURL=agent_refresh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent_refresh.js","sourceRoot":"","sources":["../../src/lib/agent_refresh.ts"],"names":[],"mappings":"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"}
package/dist/lib/api.d.ts CHANGED
@@ -7,6 +7,14 @@
7
7
  * Endpoints called by the CLI use `requireAppRole("viewer")` or
8
8
  * `requireOrgRole("member")` after the user has logged in via `hands login`.
9
9
  */
10
+ /**
11
+ * Run a bearer-parameterized request with agent-aware auth, shared by every CLI HTTP
12
+ * path (`apiRequest`, `apiUploadFile`, and `hands api`): resolve the bearer (proactive
13
+ * refresh in agent mode), and on a 401 in agent mode force ONE refresh and retry once.
14
+ * `doFetch` MUST build a fresh request each call — a request body stream cannot be reused
15
+ * across attempts, so callers that send a body rebuild it inside the thunk.
16
+ */
17
+ export declare function agentAwareFetch(doFetch: (bearer: string | undefined) => Promise<Response>): Promise<Response>;
10
18
  export declare class QuiverApiError extends Error {
11
19
  readonly status: number;
12
20
  readonly body: unknown;
package/dist/lib/api.js CHANGED
@@ -8,10 +8,47 @@
8
8
  * `requireOrgRole("member")` after the user has logged in via `hands login`.
9
9
  */
10
10
  import { resolveApiBase, resolveAuthToken } from "./config.js";
11
+ import { admitAgent } from "./agent_env.js";
12
+ import { getFreshAgentAccessToken, forceRefreshAgentToken } from "./agent_refresh.js";
11
13
  import { readEnv } from "./env.js";
12
14
  import { Blob } from "node:buffer";
13
15
  import { readFile } from "node:fs/promises";
14
16
  import { basename } from "node:path";
17
+ /**
18
+ * Resolve the bearer for a request. In a managed agent this proactively refreshes the
19
+ * stored Hands token when it is near expiry (single-flight); a broken agent env yields
20
+ * no token (fail closed). Human/CI use the ordinary resolver.
21
+ */
22
+ async function resolveBearer() {
23
+ const admission = admitAgent();
24
+ if (admission.kind === "agent") {
25
+ return (await getFreshAgentAccessToken(admission.env)) ?? undefined;
26
+ }
27
+ if (admission.kind === "fail_closed")
28
+ return undefined;
29
+ return resolveAuthToken();
30
+ }
31
+ /**
32
+ * Run a bearer-parameterized request with agent-aware auth, shared by every CLI HTTP
33
+ * path (`apiRequest`, `apiUploadFile`, and `hands api`): resolve the bearer (proactive
34
+ * refresh in agent mode), and on a 401 in agent mode force ONE refresh and retry once.
35
+ * `doFetch` MUST build a fresh request each call — a request body stream cannot be reused
36
+ * across attempts, so callers that send a body rebuild it inside the thunk.
37
+ */
38
+ export async function agentAwareFetch(doFetch) {
39
+ let res = await doFetch(await resolveBearer());
40
+ // The proactive refresh in resolveBearer covers near-expiry; this covers a token
41
+ // rejected despite looking unexpired (server-side revocation, clock skew).
42
+ if (res.status === 401) {
43
+ const admission = admitAgent();
44
+ if (admission.kind === "agent") {
45
+ const refreshed = await forceRefreshAgentToken(admission.env);
46
+ if (refreshed)
47
+ res = await doFetch(refreshed);
48
+ }
49
+ }
50
+ return res;
51
+ }
15
52
  export class QuiverApiError extends Error {
16
53
  status;
17
54
  body;
@@ -38,23 +75,26 @@ export async function apiRequest(path, opts = {}) {
38
75
  url.searchParams.set(k, String(v));
39
76
  }
40
77
  }
41
- const headers = {
78
+ const baseHeaders = {
42
79
  accept: "application/json",
43
80
  };
44
- const bearer = resolveAuthToken();
45
- if (bearer)
46
- headers.authorization = `Bearer ${bearer}`;
47
81
  let body;
48
82
  if (opts.body !== undefined) {
49
- headers["content-type"] = "application/json";
83
+ baseHeaders["content-type"] = "application/json";
50
84
  body = JSON.stringify(opts.body);
51
85
  }
52
- const res = await fetch(url.toString(), {
53
- method: opts.method ?? "GET",
54
- headers,
55
- ...(body !== undefined ? { body } : {}),
56
- ...(opts.signal ? { signal: opts.signal } : {}),
57
- });
86
+ const doFetch = (bearer) => {
87
+ const headers = { ...baseHeaders };
88
+ if (bearer)
89
+ headers.authorization = `Bearer ${bearer}`;
90
+ return fetch(url.toString(), {
91
+ method: opts.method ?? "GET",
92
+ headers,
93
+ ...(body !== undefined ? { body } : {}),
94
+ ...(opts.signal ? { signal: opts.signal } : {}),
95
+ });
96
+ };
97
+ const res = await agentAwareFetch(doFetch);
58
98
  if (readEnv("VERBOSE") === "1") {
59
99
  console.error(`> ${opts.method ?? "GET"} ${url}`);
60
100
  console.error(`< ${res.status}`);
@@ -81,19 +121,18 @@ export async function apiRequest(path, opts = {}) {
81
121
  }
82
122
  export async function apiUploadFile(path, filePath, fieldName = "apk") {
83
123
  const url = new URL(path.startsWith("/") ? path : `/${path}`, getApiBase());
84
- const form = new FormData();
85
124
  const bytes = await readFile(filePath);
86
- form.append(fieldName, new Blob([bytes]), basename(filePath));
87
- const headers = {
88
- accept: "application/json",
89
- };
90
- const bearer = resolveAuthToken();
91
- if (bearer)
92
- headers.authorization = `Bearer ${bearer}`;
93
- const res = await fetch(url.toString(), {
94
- method: "POST",
95
- headers,
96
- body: form,
125
+ const name = basename(filePath);
126
+ // Upload is a primary product path, so it uses the SAME agent-aware request + one-401
127
+ // as every other call. The multipart body is rebuilt per attempt: a body stream can't
128
+ // be reused, and agent mode may retry once after a 401.
129
+ const res = await agentAwareFetch((bearer) => {
130
+ const form = new FormData();
131
+ form.append(fieldName, new Blob([bytes]), name);
132
+ const headers = { accept: "application/json" };
133
+ if (bearer)
134
+ headers.authorization = `Bearer ${bearer}`;
135
+ return fetch(url.toString(), { method: "POST", headers, body: form });
97
136
  });
98
137
  if (readEnv("VERBOSE") === "1") {
99
138
  console.error(`> POST ${url}`);