@pingroom/cli 0.7.2 → 0.7.3
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 +41 -11
- package/bin/pingroom.js +22 -3016
- package/lib/commands/ask.js +146 -0
- package/lib/commands/config.js +114 -0
- package/lib/commands/connect.js +726 -0
- package/lib/commands/handoff.js +149 -0
- package/lib/commands/hook.js +301 -0
- package/lib/commands/listen.js +83 -0
- package/lib/commands/live.js +165 -0
- package/lib/commands/mcp.js +47 -0
- package/lib/commands/ping.js +123 -0
- package/lib/config.js +206 -0
- package/lib/constants.js +7 -0
- package/lib/github-output.js +76 -0
- package/lib/help.js +305 -0
- package/lib/http.js +214 -0
- package/lib/parser.js +214 -0
- package/lib/render.js +174 -0
- package/lib/util.js +105 -0
- package/lib/version.js +10 -0
- package/package.json +3 -2
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
// Connecting: QR pairing, the email fallback, Agent Inbox activation, and the
|
|
2
|
+
// bare `pingroom` status/prompt. Everything a human touches once and then never
|
|
3
|
+
// thinks about again.
|
|
4
|
+
|
|
5
|
+
import { EXIT } from '../constants.js';
|
|
6
|
+
import {
|
|
7
|
+
fail, isInteractive, isJsonObject, isNonEmptyString, isNullableString, sleep, stripControlChars,
|
|
8
|
+
} from '../util.js';
|
|
9
|
+
import { HELP, commandHelp } from '../help.js';
|
|
10
|
+
import { apiDetail, httpJson, requireSafeUrl, retryAfterMs } from '../http.js';
|
|
11
|
+
import {
|
|
12
|
+
credentialsPath, readStoredCredential, resolveApiBase, resolveRoom, saveCredential,
|
|
13
|
+
} from '../config.js';
|
|
14
|
+
|
|
15
|
+
// --- connecting (pairing + email fallback) ---------------------------------
|
|
16
|
+
//
|
|
17
|
+
// Wire contract: AGENT_PAIRING_SPEC.md. The shape is deliberately one gesture —
|
|
18
|
+
// scanning the QR is where the human picks BOTH the account and the delivery
|
|
19
|
+
// room, so an agent can never end up connected with nobody's say-so about where
|
|
20
|
+
// it pings. There is no `login` subcommand: `pingroom` resolves the state.
|
|
21
|
+
|
|
22
|
+
// The scopes this CLI can actually use, one per command surface. Requested at
|
|
23
|
+
// registration so the approval screen shows exactly what it is granting; the
|
|
24
|
+
// server intersects, so asking for less is always safe and asking for more than
|
|
25
|
+
// the human approves is impossible.
|
|
26
|
+
const CLI_SCOPES = [
|
|
27
|
+
'pingroom:rooms:read', // resolve/display the connected room
|
|
28
|
+
'pingroom:broadcast:send', // ping
|
|
29
|
+
'pingroom:attachments:write', // ping --attach (the upload leg)
|
|
30
|
+
'pingroom:notifications:read',// listen
|
|
31
|
+
'pingroom:questions:ask', // ask / watch / cancel / list, and the hook
|
|
32
|
+
'pingroom:handoffs:create', // handoff / handoffs
|
|
33
|
+
'pingroom:live:write', // live start/update/end/get
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// What the human reads on the approval screen. A product name, not a package
|
|
37
|
+
// id: the phone shows it verbatim ("PingRoom CLI wants to connect").
|
|
38
|
+
const AGENT_LABEL = 'PingRoom CLI';
|
|
39
|
+
// A connect command should prove the phone round-trip, but it must not hold a
|
|
40
|
+
// terminal for the onboarding Question's full 24-hour server TTL. The Question
|
|
41
|
+
// remains answerable after this local deadline and the credential is already
|
|
42
|
+
// durable before the wait begins.
|
|
43
|
+
const ACTIVATION_MAX_WAIT_MS = 2 * 60 * 1000;
|
|
44
|
+
// The wait route is limited to 30 requests/minute. Keep immediate pending or
|
|
45
|
+
// answered-without-completion observations safely below that ceiling while a
|
|
46
|
+
// mixed-version or commit-propagation race is still being reconciled.
|
|
47
|
+
const ACTIVATION_MIN_POLL_INTERVAL_MS = 2100;
|
|
48
|
+
|
|
49
|
+
function activationMaxWaitMs() {
|
|
50
|
+
// Keep production fixed at two minutes. The guarded override lets the real
|
|
51
|
+
// subprocess tests exercise deadline behavior without holding the suite for
|
|
52
|
+
// two minutes; it is ignored outside NODE_ENV=test.
|
|
53
|
+
if (process.env.NODE_ENV === 'test') {
|
|
54
|
+
const testValue = Number(process.env.PINGROOM_INTERNAL_ACTIVATION_TIMEOUT_MS);
|
|
55
|
+
if (Number.isInteger(testValue) && testValue > 0 && testValue <= ACTIVATION_MAX_WAIT_MS) {
|
|
56
|
+
return testValue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return ACTIVATION_MAX_WAIT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Widest QR we render (compact half-block form of a ~110-char pair URL is 39
|
|
63
|
+
// columns). Anything narrower would wrap and become unscannable, so we print
|
|
64
|
+
// the URL alone instead of a broken QR.
|
|
65
|
+
const QR_MIN_COLUMNS = 41;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Draw the pair URL as a scannable QR. Returns false when it could not — a too
|
|
69
|
+
* narrow terminal, or the optional dependency being absent (someone vendored
|
|
70
|
+
* just bin/) — and the caller falls back to the printed URL, which always works.
|
|
71
|
+
*/
|
|
72
|
+
async function renderQr(url) {
|
|
73
|
+
// A real terminal reports its width on the stream; COLUMNS covers the rest.
|
|
74
|
+
// Unknown width is treated as wide enough — the URL is printed either way.
|
|
75
|
+
const columns = Number(process.stdout.columns || process.env.COLUMNS || 0);
|
|
76
|
+
if (columns > 0 && columns < QR_MIN_COLUMNS) return false;
|
|
77
|
+
|
|
78
|
+
let qr;
|
|
79
|
+
try {
|
|
80
|
+
const mod = await import('qrcode-terminal');
|
|
81
|
+
qr = mod.default || mod;
|
|
82
|
+
} catch { return false; }
|
|
83
|
+
if (!qr || typeof qr.generate !== 'function') return false;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
let art = '';
|
|
87
|
+
// Call it as a method: qrcode-terminal reads its error-correction level off
|
|
88
|
+
// `this`, so a detached `generate` reference silently builds a version-1
|
|
89
|
+
// code and throws on anything longer than a few characters.
|
|
90
|
+
// `small` is the half-block form: two module rows per text row, so the code
|
|
91
|
+
// stays square-ish and fits an 80-column terminal.
|
|
92
|
+
qr.generate(url, { small: true }, (rendered) => { art = rendered; });
|
|
93
|
+
if (!art) return false;
|
|
94
|
+
process.stdout.write(`\n${art}\n`);
|
|
95
|
+
return true;
|
|
96
|
+
} catch { return false; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A line-at-a-time reader over stdin.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately not node:readline: its Interface keeps consuming while we are
|
|
103
|
+
* awaiting an HTTP round trip between two questions and drops the lines nobody
|
|
104
|
+
* is listening for, which silently loses piped answers. This queues every line
|
|
105
|
+
* instead, so the answers can arrive in one blob or one keystroke at a time.
|
|
106
|
+
*
|
|
107
|
+
* ask() resolves `null` — never a string — once the input is closed, so it can
|
|
108
|
+
* never be confused with a real empty line. That distinction is load-bearing:
|
|
109
|
+
* callers treat an empty line as "take the default", and a caller that reads EOF
|
|
110
|
+
* as an empty line will take that default again on the next question, and the
|
|
111
|
+
* next, forever, because nothing will ever arrive to change its mind. Callers
|
|
112
|
+
* that genuinely want the empty-line behaviour opt in with `?? ''`.
|
|
113
|
+
*/
|
|
114
|
+
function createPrompter() {
|
|
115
|
+
const queued = [];
|
|
116
|
+
const waiting = [];
|
|
117
|
+
let buffer = '';
|
|
118
|
+
let closed = false;
|
|
119
|
+
|
|
120
|
+
const deliver = (line) => {
|
|
121
|
+
const waiter = waiting.shift();
|
|
122
|
+
if (waiter) waiter(line);
|
|
123
|
+
else queued.push(line);
|
|
124
|
+
};
|
|
125
|
+
const onData = (chunk) => {
|
|
126
|
+
buffer += chunk;
|
|
127
|
+
let idx;
|
|
128
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
129
|
+
deliver(buffer.slice(0, idx).replace(/\r$/, ''));
|
|
130
|
+
buffer = buffer.slice(idx + 1);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const onEnd = () => {
|
|
134
|
+
if (closed) return;
|
|
135
|
+
closed = true;
|
|
136
|
+
if (buffer) { deliver(buffer); buffer = ''; }
|
|
137
|
+
while (waiting.length) waiting.shift()(null);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
process.stdin.setEncoding('utf8');
|
|
141
|
+
process.stdin.on('data', onData);
|
|
142
|
+
process.stdin.once('end', onEnd);
|
|
143
|
+
process.stdin.resume();
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
ask(question) {
|
|
147
|
+
process.stdout.write(question);
|
|
148
|
+
if (queued.length > 0) return Promise.resolve(queued.shift());
|
|
149
|
+
if (closed) return Promise.resolve(null);
|
|
150
|
+
return new Promise((resolve) => { waiting.push(resolve); });
|
|
151
|
+
},
|
|
152
|
+
close() {
|
|
153
|
+
process.stdin.off('data', onData);
|
|
154
|
+
process.stdin.off('end', onEnd);
|
|
155
|
+
process.stdin.pause();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** POST /api/agent/auth — anonymous registration, yields the pre-claim credential. */
|
|
161
|
+
async function registerAnonymous(apiBase) {
|
|
162
|
+
const { res, json } = await httpJson('POST', `${apiBase}/api/agent/auth`, {
|
|
163
|
+
body: { type: 'anonymous', agent_label: AGENT_LABEL, scopes: CLI_SCOPES },
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok || !json || typeof json.credential !== 'string') {
|
|
166
|
+
const detail = apiDetail(res, json);
|
|
167
|
+
fail(`could not start a connection: ${detail}`);
|
|
168
|
+
}
|
|
169
|
+
return json.credential;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* "✓ Connected as @agt_ab12 → #Project X" — the room half is omitted if unknown,
|
|
174
|
+
* and widened to "→ all rooms" / "→ #Project X +2 more" when the human granted
|
|
175
|
+
* this agent more than the one delivery room.
|
|
176
|
+
*/
|
|
177
|
+
function connectedLine(cred) {
|
|
178
|
+
const who = cred.handle ? `@${cred.handle}` : 'this machine';
|
|
179
|
+
const room = cred.room && (cred.room.name || cred.room.invite_code);
|
|
180
|
+
const access = cred.room_access ?? cred.roomAccess;
|
|
181
|
+
|
|
182
|
+
if (access === 'all') return `✓ Connected as ${who} → all rooms`;
|
|
183
|
+
|
|
184
|
+
if (!room) return `✓ Connected as ${who}`;
|
|
185
|
+
|
|
186
|
+
const extra = Math.max(0, (Array.isArray(cred.rooms) ? cred.rooms.length : 0) - 1);
|
|
187
|
+
return `✓ Connected as ${who} → #${room}${extra > 0 ? ` +${extra} more` : ''}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function activationFailureDetail(result) {
|
|
191
|
+
if (result.error) return result.error.message;
|
|
192
|
+
const status = result.res ? `HTTP ${result.res.status}` : 'request failed';
|
|
193
|
+
return (result.json && (result.json.message || result.json.error || result.json.code)) || status;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateActivationEnsure(json) {
|
|
197
|
+
const room = json?.room;
|
|
198
|
+
const question = json?.question;
|
|
199
|
+
const validState = question?.state === 'pending'
|
|
200
|
+
|| question?.state === 'answered'
|
|
201
|
+
|| question?.state === 'expired'
|
|
202
|
+
|| question?.state === 'cancelled';
|
|
203
|
+
if (
|
|
204
|
+
!isJsonObject(json)
|
|
205
|
+
|| json.onboarded !== true
|
|
206
|
+
|| typeof json.replayed !== 'boolean'
|
|
207
|
+
|| !isJsonObject(room)
|
|
208
|
+
|| !isNonEmptyString(room.id)
|
|
209
|
+
|| typeof room.name !== 'string'
|
|
210
|
+
|| !isNonEmptyString(room.invite_code)
|
|
211
|
+
|| typeof room.is_agent_inbox !== 'boolean'
|
|
212
|
+
|| !isJsonObject(question)
|
|
213
|
+
|| !isNonEmptyString(question.id)
|
|
214
|
+
|| question.kind !== 'question'
|
|
215
|
+
|| !isNonEmptyString(question.prompt)
|
|
216
|
+
|| !Array.isArray(question.options)
|
|
217
|
+
|| question.options.some((option) => (
|
|
218
|
+
!isJsonObject(option)
|
|
219
|
+
|| !isNonEmptyString(option.value)
|
|
220
|
+
|| !isNonEmptyString(option.label)
|
|
221
|
+
))
|
|
222
|
+
|| !validState
|
|
223
|
+
|| !isNullableString(question.expires_at)
|
|
224
|
+
|| !isNullableString(question.created_at)
|
|
225
|
+
) {
|
|
226
|
+
return { error: 'PingRoom returned an incomplete Agent Inbox ensure response' };
|
|
227
|
+
}
|
|
228
|
+
return { question };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateActivationWait(json, questionId) {
|
|
232
|
+
const state = json?.state;
|
|
233
|
+
const validState = state === 'pending' || state === 'answered' || state === 'expired' || state === 'cancelled';
|
|
234
|
+
if (
|
|
235
|
+
!isJsonObject(json)
|
|
236
|
+
|| !isNonEmptyString(json.id)
|
|
237
|
+
|| json.id !== questionId
|
|
238
|
+
|| json.kind !== 'question'
|
|
239
|
+
|| !validState
|
|
240
|
+
|| (json.activation_completed !== undefined && typeof json.activation_completed !== 'boolean')
|
|
241
|
+
|| (state !== 'answered' && json.activation_completed === true)
|
|
242
|
+
) {
|
|
243
|
+
return { error: 'PingRoom returned a mismatched Agent Inbox wait response' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (state === 'answered') {
|
|
247
|
+
const answer = json.answer;
|
|
248
|
+
const responder = answer?.responder;
|
|
249
|
+
if (
|
|
250
|
+
!isJsonObject(answer)
|
|
251
|
+
|| !isNullableString(answer.value)
|
|
252
|
+
|| !isNullableString(answer.label)
|
|
253
|
+
|| !isNullableString(answer.text)
|
|
254
|
+
|| (!isNonEmptyString(answer.value) && !isNonEmptyString(answer.text))
|
|
255
|
+
|| !isNullableString(answer.answered_at)
|
|
256
|
+
|| (responder !== null && !isJsonObject(responder))
|
|
257
|
+
|| (isJsonObject(responder)
|
|
258
|
+
&& (!isNullableString(responder.id) || !isNullableString(responder.display_name)))
|
|
259
|
+
) {
|
|
260
|
+
return { error: 'PingRoom returned an answered activation without a valid answer' };
|
|
261
|
+
}
|
|
262
|
+
} else if (json.answer !== undefined && json.answer !== null) {
|
|
263
|
+
return { error: 'PingRoom returned an answer for an unresolved activation' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { value: json };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function activationRetryDelay(result, transientRun, deadline) {
|
|
270
|
+
const fromHeader = result.res?.status === 429 ? retryAfterMs(result.res) : null;
|
|
271
|
+
const fallback = Math.min(1000 * 2 ** Math.max(0, transientRun - 1), 10_000);
|
|
272
|
+
return Math.max(0, Math.min(fromHeader ?? fallback, deadline - Date.now()));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function activationIncomplete(detail, instruction = 'Run "pingroom activate" to retry with this saved connection.') {
|
|
276
|
+
const safeDetail = detail ? `: ${stripControlChars(detail)}` : '';
|
|
277
|
+
process.stdout.write(` Agent Inbox activation is not complete${safeDetail}\n`);
|
|
278
|
+
process.stdout.write(' Your connection is saved and usable.\n');
|
|
279
|
+
process.stdout.write(` ${instruction}\n`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Prove the freshly paired credential can complete a human round-trip. This is
|
|
284
|
+
* intentionally best-effort: saveCredential() has already committed the active
|
|
285
|
+
* bearer atomically, so no activation outage can roll back or corrupt it.
|
|
286
|
+
*/
|
|
287
|
+
async function activateInboxAfterPairing(cred) {
|
|
288
|
+
const headers = { Authorization: `Bearer ${cred.token}` };
|
|
289
|
+
const overallDeadline = Date.now() + activationMaxWaitMs();
|
|
290
|
+
process.stdout.write(' Sending a test question to PingRoom…\n');
|
|
291
|
+
|
|
292
|
+
let ensured;
|
|
293
|
+
let ensureTransientRun = 0;
|
|
294
|
+
while (Date.now() < overallDeadline) {
|
|
295
|
+
ensured = await httpJson('POST', `${cred.apiBase}/api/agent/inbox/ensure`, {
|
|
296
|
+
body: {},
|
|
297
|
+
headers,
|
|
298
|
+
soft: true,
|
|
299
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(15_000, overallDeadline - Date.now()))),
|
|
300
|
+
});
|
|
301
|
+
const transient = ensured.error || ensured.res?.status === 429 || ensured.res?.status >= 500;
|
|
302
|
+
if (!transient) break;
|
|
303
|
+
ensureTransientRun += 1;
|
|
304
|
+
await sleep(activationRetryDelay(ensured, ensureTransientRun, overallDeadline));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!ensured.res?.ok) {
|
|
308
|
+
const detail = Date.now() >= overallDeadline
|
|
309
|
+
? 'the two-minute activation deadline elapsed while PingRoom was unavailable'
|
|
310
|
+
: activationFailureDetail(ensured);
|
|
311
|
+
activationIncomplete(detail);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const ensureEnvelope = validateActivationEnsure(ensured.json);
|
|
316
|
+
if (ensureEnvelope.error) {
|
|
317
|
+
activationIncomplete(ensureEnvelope.error);
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
const { question } = ensureEnvelope;
|
|
321
|
+
|
|
322
|
+
process.stdout.write(' Answer “PingRoom connected. Can you answer this?” on your phone.\n');
|
|
323
|
+
// The server stamp, not the terminal state by itself, is the activation
|
|
324
|
+
// authority. A terminal answer without the stamp cannot become a valid
|
|
325
|
+
// receipt-before-answer sequence later, so fail clearly instead of polling a
|
|
326
|
+
// state the server intentionally will not rewrite.
|
|
327
|
+
const deadline = overallDeadline;
|
|
328
|
+
let transientRun = 0;
|
|
329
|
+
|
|
330
|
+
while (Date.now() < deadline) {
|
|
331
|
+
const pollStartedAt = Date.now();
|
|
332
|
+
const remainingSeconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
333
|
+
const hold = Math.min(20, remainingSeconds);
|
|
334
|
+
const waited = await httpJson(
|
|
335
|
+
'GET',
|
|
336
|
+
`${cred.apiBase}/api/agent/handoffs/${encodeURIComponent(question.id)}/wait?timeout=${hold}`,
|
|
337
|
+
{
|
|
338
|
+
headers,
|
|
339
|
+
soft: true,
|
|
340
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(
|
|
341
|
+
hold * 1000 + 10_000,
|
|
342
|
+
deadline - Date.now(),
|
|
343
|
+
))),
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const transient = waited.error || waited.res?.status === 429 || waited.res?.status >= 500;
|
|
348
|
+
if (transient) {
|
|
349
|
+
transientRun += 1;
|
|
350
|
+
const retryDelay = activationRetryDelay(waited, transientRun, deadline);
|
|
351
|
+
const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
|
|
352
|
+
await sleep(Math.max(0, Math.min(Math.max(retryDelay, cadenceDelay), deadline - Date.now())));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
transientRun = 0;
|
|
356
|
+
|
|
357
|
+
if (!waited.res?.ok) {
|
|
358
|
+
activationIncomplete(activationFailureDetail(waited));
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const waitEnvelope = validateActivationWait(waited.json, question.id);
|
|
363
|
+
if (waitEnvelope.error) {
|
|
364
|
+
activationIncomplete(waitEnvelope.error);
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
const resolved = waitEnvelope.value;
|
|
368
|
+
const state = resolved.state;
|
|
369
|
+
if (state === 'answered') {
|
|
370
|
+
if (resolved.activation_completed !== true) {
|
|
371
|
+
activationIncomplete(
|
|
372
|
+
'the test question was answered without verified phone receipt before the answer',
|
|
373
|
+
'Update the PingRoom app if needed, then run "pingroom activate" to send a fresh test with this saved connection.',
|
|
374
|
+
);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
const answer = resolved.answer.text || resolved.answer.label || resolved.answer.value;
|
|
378
|
+
process.stdout.write(`✓ Test question answered (${stripControlChars(answer)}). Agent Inbox is ready.\n`);
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
if (state === 'expired' || state === 'cancelled') {
|
|
382
|
+
activationIncomplete(
|
|
383
|
+
`the test question ${state}`,
|
|
384
|
+
'Run "pingroom activate" to send a fresh test with this saved connection.',
|
|
385
|
+
);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
// `pending` at the bounded hold timeout — continue at a throttle-safe
|
|
389
|
+
// cadence until the local/server deadline.
|
|
390
|
+
const cadenceDelay = ACTIVATION_MIN_POLL_INTERVAL_MS - (Date.now() - pollStartedAt);
|
|
391
|
+
await sleep(Math.max(0, Math.min(cadenceDelay, deadline - Date.now())));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
activationIncomplete(
|
|
395
|
+
'still waiting for the test answer at the activation deadline',
|
|
396
|
+
);
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Retry activation only for the durable credential created by QR pairing. */
|
|
401
|
+
export async function activateStoredInbox(args) {
|
|
402
|
+
if (args.help) { process.stdout.write(`${commandHelp('activate')}\n`); return EXIT.OK; }
|
|
403
|
+
if (args._.length > 0) fail('usage: pingroom activate', EXIT.USAGE);
|
|
404
|
+
if (args.token !== undefined) {
|
|
405
|
+
fail('pingroom activate uses the saved QR-paired credential; remove --token', EXIT.USAGE);
|
|
406
|
+
}
|
|
407
|
+
const unsupported = Object.keys(args).filter((key) => !['_', 'help', 'api', 'token'].includes(key));
|
|
408
|
+
if (unsupported.length > 0) {
|
|
409
|
+
fail('usage: pingroom activate [--api <url>]', EXIT.USAGE);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const credential = readStoredCredential();
|
|
413
|
+
if (!credential) {
|
|
414
|
+
fail('no saved QR-paired credential; run "pingroom" in an interactive terminal first', EXIT.USAGE);
|
|
415
|
+
}
|
|
416
|
+
if (!credential.room || !isNonEmptyString(credential.room.invite_code)) {
|
|
417
|
+
// Granting every room is a valid answer that pins no destination, so the
|
|
418
|
+
// fix there is picking one — not pairing again, which would only offer the
|
|
419
|
+
// same choice back.
|
|
420
|
+
fail(
|
|
421
|
+
credential.room_access === 'all'
|
|
422
|
+
? 'this agent was granted all rooms but no delivery room; pick one in the PingRoom app under Connected Agents, then run "pingroom activate" again'
|
|
423
|
+
: 'the saved credential has no QR-selected delivery room; reconnect with QR pairing before running "pingroom activate"',
|
|
424
|
+
EXIT.USAGE,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (!Array.isArray(credential.scopes) || !credential.scopes.includes('pingroom:handoffs:create')) {
|
|
428
|
+
fail('the saved credential lacks pingroom:handoffs:create; reconnect with QR pairing before running "pingroom activate"', EXIT.USAGE);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const apiBase = resolveApiBase(args);
|
|
432
|
+
requireSafeUrl('--api', apiBase);
|
|
433
|
+
if (!isNonEmptyString(credential.api_url)) {
|
|
434
|
+
fail('the saved QR-paired credential has no trusted API origin; pair again before running "pingroom activate"', EXIT.USAGE);
|
|
435
|
+
}
|
|
436
|
+
let credentialOrigin;
|
|
437
|
+
let targetOrigin;
|
|
438
|
+
try {
|
|
439
|
+
credentialOrigin = new URL(credential.api_url).origin;
|
|
440
|
+
targetOrigin = new URL(apiBase).origin;
|
|
441
|
+
} catch {
|
|
442
|
+
fail('the saved QR-paired credential has an invalid API origin; pair again', EXIT.USAGE);
|
|
443
|
+
}
|
|
444
|
+
if (credentialOrigin !== targetOrigin) {
|
|
445
|
+
fail(`stored credential is bound to ${credentialOrigin}; refusing to send it to ${targetOrigin}`, EXIT.USAGE);
|
|
446
|
+
}
|
|
447
|
+
process.stdout.write(`${connectedLine(credential)}\n`);
|
|
448
|
+
|
|
449
|
+
const completed = await activateInboxAfterPairing({
|
|
450
|
+
...credential,
|
|
451
|
+
apiBase,
|
|
452
|
+
});
|
|
453
|
+
return completed ? EXIT.OK : EXIT.ERROR;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The QR path. Mints a pre-claim credential, asks the server for a pairing
|
|
458
|
+
* token, renders it, then polls until the human approves. Returns a credential
|
|
459
|
+
* object, or null when the pairing lapsed and the user declined a fresh one.
|
|
460
|
+
*/
|
|
461
|
+
async function connectByPairing(apiBase, ask) {
|
|
462
|
+
for (;;) {
|
|
463
|
+
const preClaim = await registerAnonymous(apiBase);
|
|
464
|
+
const headers = { Authorization: `Bearer ${preClaim}` };
|
|
465
|
+
|
|
466
|
+
const start = await httpJson('POST', `${apiBase}/api/agent/auth/pair/start`, {
|
|
467
|
+
body: { scopes: CLI_SCOPES },
|
|
468
|
+
headers,
|
|
469
|
+
});
|
|
470
|
+
if (!start.res.ok || !start.json || typeof start.json.pair_url !== 'string') {
|
|
471
|
+
const detail = (start.json && (start.json.message || start.json.error || start.json.code))
|
|
472
|
+
|| `HTTP ${start.res.status}`;
|
|
473
|
+
fail(`could not start pairing: ${detail}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The URL is server-controlled and goes straight to the terminal, so strip
|
|
477
|
+
// C0/C1 controls: an --api / config api_url pointing at a hostile host could
|
|
478
|
+
// otherwise emit ANSI escapes that repaint or hide the line the user is
|
|
479
|
+
// about to trust with their account.
|
|
480
|
+
const pairUrl = stripControlChars(start.json.pair_url);
|
|
481
|
+
// 900s is the server's pre-claim lifetime; never poll past it, and clamp the
|
|
482
|
+
// server's suggested interval so a bad value can't busy-loop or stall.
|
|
483
|
+
// The 1000ms floor is not cosmetic: AGENT_PAIRING_SPEC.md throttles
|
|
484
|
+
// pair/status at `60,1`, so a faster floor spends the pairing window
|
|
485
|
+
// collecting 429s instead of the approval.
|
|
486
|
+
const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
|
|
487
|
+
const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
|
|
488
|
+
const deadline = Date.now() + lifetimeMs;
|
|
489
|
+
|
|
490
|
+
const drew = await renderQr(pairUrl);
|
|
491
|
+
process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
|
|
492
|
+
process.stdout.write(' Waiting for approval… ');
|
|
493
|
+
|
|
494
|
+
// A transient failure must not end a wait the human is mid-way through.
|
|
495
|
+
// Network errors, 5xx and 429 are the load balancer / rate limiter talking,
|
|
496
|
+
// not the pairing being over; hard-failing on the first one throws away the
|
|
497
|
+
// whole 15 minutes over a single blip. 401/403/404 still exit immediately —
|
|
498
|
+
// those say the pre-claim is gone, and retrying can only spin.
|
|
499
|
+
// The `Date.now() < deadline` bound is what keeps a *persistent* outage from
|
|
500
|
+
// retrying forever: it ends at the same moment a clean poll would have.
|
|
501
|
+
let transientRun = 0;
|
|
502
|
+
let lastTransient = null;
|
|
503
|
+
let warnedTransient = false;
|
|
504
|
+
|
|
505
|
+
while (Date.now() < deadline) {
|
|
506
|
+
const { res, json, error } = await httpJson(
|
|
507
|
+
'GET', `${apiBase}/api/agent/auth/pair/status`, { headers, soft: true },
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
if (error || res.status >= 500 || res.status === 429) {
|
|
511
|
+
transientRun += 1;
|
|
512
|
+
lastTransient = error
|
|
513
|
+
? error.message
|
|
514
|
+
: `HTTP ${res.status}`;
|
|
515
|
+
// Say something rather than sitting mute: a user watching a QR with no
|
|
516
|
+
// output cannot tell a slow approval from a broken endpoint.
|
|
517
|
+
if (transientRun === 3 && !warnedTransient) {
|
|
518
|
+
warnedTransient = true;
|
|
519
|
+
process.stdout.write(`\n (still trying — ${lastTransient}) `);
|
|
520
|
+
}
|
|
521
|
+
// Ride out a short blip at the normal cadence, then back off
|
|
522
|
+
// geometrically so a real outage is not also a thundering herd. Never
|
|
523
|
+
// sleep past the deadline this loop is bounded by.
|
|
524
|
+
const backoff = Math.min(intervalMs * 2 ** Math.max(0, transientRun - 3), 30_000);
|
|
525
|
+
await sleep(Math.max(0, Math.min(backoff, deadline - Date.now())));
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
transientRun = 0;
|
|
530
|
+
|
|
531
|
+
if (!res.ok) {
|
|
532
|
+
process.stdout.write('\n');
|
|
533
|
+
const detail = apiDetail(res, json);
|
|
534
|
+
fail(`pairing failed: ${detail}`);
|
|
535
|
+
}
|
|
536
|
+
const status = json && json.status;
|
|
537
|
+
if (status === 'active') {
|
|
538
|
+
// A server that says "active" with no credential has not paired us.
|
|
539
|
+
// Without this, `token: undefined` is written to credentials.json and
|
|
540
|
+
// every later command reads a credential file that exists but cannot
|
|
541
|
+
// authenticate — a far more confusing failure than stopping here.
|
|
542
|
+
if (typeof json.credential !== 'string' || json.credential === '') {
|
|
543
|
+
process.stdout.write('\n');
|
|
544
|
+
fail('pairing succeeded but the server returned no credential');
|
|
545
|
+
}
|
|
546
|
+
const cred = {
|
|
547
|
+
token: json.credential,
|
|
548
|
+
handle: json.handle,
|
|
549
|
+
room: json.room,
|
|
550
|
+
rooms: Array.isArray(json.rooms) ? json.rooms : [],
|
|
551
|
+
roomAccess: typeof json.room_access === 'string' ? json.room_access : null,
|
|
552
|
+
account: json.account,
|
|
553
|
+
scopes: json.scopes,
|
|
554
|
+
apiBase,
|
|
555
|
+
};
|
|
556
|
+
saveCredential(cred);
|
|
557
|
+
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
558
|
+
// Connecting deliberately sends nothing to the human's phone. The
|
|
559
|
+
// approval they just tapped IS the round-trip; a test Question on top of
|
|
560
|
+
// it was one more thing to answer before the tool could be used, and it
|
|
561
|
+
// made a healthy connection look broken whenever the answer was slow.
|
|
562
|
+
// `pingroom activate` still sends one for anyone who wants the proof.
|
|
563
|
+
return cred;
|
|
564
|
+
}
|
|
565
|
+
if (status === 'expired') break;
|
|
566
|
+
// `pending` (or anything unrecognized) — keep waiting.
|
|
567
|
+
await sleep(intervalMs);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (transientRun > 0) {
|
|
571
|
+
process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
|
|
572
|
+
} else {
|
|
573
|
+
process.stdout.write(`\n That code expired.\n`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// `null` means the input is closed, and that is the whole point of this
|
|
577
|
+
// guard. Reading EOF as "" would fall through the y/yes test below (empty
|
|
578
|
+
// means "take the default: yes"), restart the for(;;), mint another
|
|
579
|
+
// anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
|
|
580
|
+
// a single pairing attempt into thousands of registrations against the API.
|
|
581
|
+
const again = await ask(' Show a fresh QR code? [Y/n]: ');
|
|
582
|
+
if (again === null) { process.stdout.write('\n'); return null; }
|
|
583
|
+
const answer = again.trim().toLowerCase();
|
|
584
|
+
if (answer && answer !== 'y' && answer !== 'yes') return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The email fallback, over the unchanged claim/* endpoints: the server mails a
|
|
590
|
+
* link, the web page shows a 6-digit code, the user reads it back here.
|
|
591
|
+
*/
|
|
592
|
+
async function connectByEmail(apiBase, ask) {
|
|
593
|
+
const preClaim = await registerAnonymous(apiBase);
|
|
594
|
+
const headers = { Authorization: `Bearer ${preClaim}` };
|
|
595
|
+
|
|
596
|
+
// `?? ''` preserves the old EOF behaviour deliberately: ask() now returns null
|
|
597
|
+
// at EOF, and without the coalesce this would throw a TypeError on `.trim()`
|
|
598
|
+
// instead of reaching the "this is required" error the user should see.
|
|
599
|
+
const email = (await ask(' Your PingRoom email: ') ?? '').trim();
|
|
600
|
+
if (!email) fail('an email address is required', EXIT.USAGE);
|
|
601
|
+
|
|
602
|
+
const start = await httpJson('POST', `${apiBase}/api/agent/auth/claim/start`, {
|
|
603
|
+
body: { email },
|
|
604
|
+
headers,
|
|
605
|
+
});
|
|
606
|
+
if (!start.res.ok) {
|
|
607
|
+
const detail = (start.json && (start.json.message || start.json.error || start.json.code))
|
|
608
|
+
|| `HTTP ${start.res.status}`;
|
|
609
|
+
fail(`could not send the email: ${detail}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
process.stdout.write(' Sent. Open the link in that email — the page shows a 6-digit code.\n');
|
|
613
|
+
|
|
614
|
+
// A mistyped code is the common case, so allow a few tries before giving up.
|
|
615
|
+
// The server locks the registration out after its own attempt cap anyway.
|
|
616
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
617
|
+
// Same reason as the email prompt: EOF stays an empty answer, which the
|
|
618
|
+
// server rejects, rather than a TypeError on null.
|
|
619
|
+
const otp = (await ask(' Code: ') ?? '').trim();
|
|
620
|
+
const done = await httpJson('POST', `${apiBase}/api/agent/auth/claim/complete`, {
|
|
621
|
+
body: { email, otp },
|
|
622
|
+
headers,
|
|
623
|
+
});
|
|
624
|
+
if (done.res.ok && done.json && typeof done.json.credential === 'string') {
|
|
625
|
+
const cred = {
|
|
626
|
+
token: done.json.credential,
|
|
627
|
+
handle: done.json.handle,
|
|
628
|
+
// claim/complete carries no room — the email flow does not choose one.
|
|
629
|
+
room: done.json.room,
|
|
630
|
+
account: done.json.account,
|
|
631
|
+
scopes: done.json.scopes,
|
|
632
|
+
apiBase,
|
|
633
|
+
};
|
|
634
|
+
saveCredential(cred);
|
|
635
|
+
process.stdout.write(`${connectedLine(cred)}\n`);
|
|
636
|
+
if (!cred.room) {
|
|
637
|
+
process.stdout.write(' For room commands: pingroom config set default_room <invite code>\n');
|
|
638
|
+
process.stdout.write(' For private Inbox/Handoff delivery, reconnect with QR pairing.\n');
|
|
639
|
+
}
|
|
640
|
+
return cred;
|
|
641
|
+
}
|
|
642
|
+
const detail = (done.json && (done.json.message || done.json.error || done.json.code))
|
|
643
|
+
|| `HTTP ${done.res.status}`;
|
|
644
|
+
if (attempt === 3) fail(`could not connect: ${detail}`);
|
|
645
|
+
process.stderr.write(`pingroom: ${detail}\n`);
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Resolve the unconnected state interactively. Refuses outright when there is no
|
|
652
|
+
* TTY — a hung prompt in CI is worse than a clean failure, and the fix there is
|
|
653
|
+
* PINGROOM_TOKEN, not a QR nobody can scan.
|
|
654
|
+
*/
|
|
655
|
+
export async function connect(args) {
|
|
656
|
+
if (!isInteractive()) {
|
|
657
|
+
fail(
|
|
658
|
+
'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
|
|
659
|
+
EXIT.USAGE,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const apiBase = resolveApiBase(args);
|
|
664
|
+
requireSafeUrl('--api', apiBase);
|
|
665
|
+
|
|
666
|
+
const prompter = createPrompter();
|
|
667
|
+
const ask = (question) => prompter.ask(question);
|
|
668
|
+
try {
|
|
669
|
+
process.stdout.write(' Not connected. How do you want to connect?\n');
|
|
670
|
+
process.stdout.write(' 1) Scan a QR code with the PingRoom app\n');
|
|
671
|
+
process.stdout.write(' 2) Email me a code\n');
|
|
672
|
+
// EOF here means "no answer", which is what the default already covers, so
|
|
673
|
+
// coalesce rather than crash on null — the pairing branch below is the one
|
|
674
|
+
// that must distinguish EOF, and it does.
|
|
675
|
+
const choice = (await ask(' Choose [1]: ') ?? '').trim();
|
|
676
|
+
if (choice && choice !== '1' && choice !== '2') {
|
|
677
|
+
process.stderr.write('pingroom: choose 1 or 2\n');
|
|
678
|
+
return EXIT.USAGE;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const cred = choice === '2'
|
|
682
|
+
? await connectByEmail(apiBase, ask)
|
|
683
|
+
: await connectByPairing(apiBase, ask);
|
|
684
|
+
|
|
685
|
+
return cred ? EXIT.OK : EXIT.EXPIRED;
|
|
686
|
+
} finally {
|
|
687
|
+
prompter.close();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- status / bare invocation ----------------------------------------------
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* `pingroom` with no arguments. Connected -> one status line then the usual
|
|
695
|
+
* help. Not connected -> pair (interactive) or, in a pipe/CI, say so on stderr
|
|
696
|
+
* and still print the help rather than prompting into the void.
|
|
697
|
+
*/
|
|
698
|
+
export async function bare(args) {
|
|
699
|
+
const envToken = process.env.PINGROOM_TOKEN;
|
|
700
|
+
const stored = readStoredCredential();
|
|
701
|
+
|
|
702
|
+
if (envToken) {
|
|
703
|
+
process.stdout.write('Using the agent token from PINGROOM_TOKEN.\n');
|
|
704
|
+
if (stored) process.stdout.write(`(the stored credential in ${credentialsPath()} is ignored while it is set)\n`);
|
|
705
|
+
const room = resolveRoom(args);
|
|
706
|
+
if (room) process.stdout.write(`Default room: ${room}\n`);
|
|
707
|
+
process.stdout.write(`\n${HELP}\n`);
|
|
708
|
+
return EXIT.OK;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (stored) {
|
|
712
|
+
process.stdout.write(`${connectedLine(stored)}\n`);
|
|
713
|
+
const room = resolveRoom(args);
|
|
714
|
+
if (room) process.stdout.write(`Default room: ${room}\n`);
|
|
715
|
+
process.stdout.write(`\n${HELP}\n`);
|
|
716
|
+
return EXIT.OK;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!isInteractive()) {
|
|
720
|
+
process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
|
|
721
|
+
process.stdout.write(`${HELP}\n`);
|
|
722
|
+
return EXIT.OK;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return connect(args);
|
|
726
|
+
}
|