@pingroom/cli 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -8
- package/bin/pingroom.js +4 -2
- package/lib/commands/ask.js +4 -42
- package/lib/commands/connect.js +97 -15
- package/lib/commands/hook.js +17 -2
- package/lib/commands/manage.js +25 -18
- package/lib/continuations.js +106 -0
- package/lib/help.js +24 -3
- package/lib/http.js +8 -3
- package/lib/parser.js +11 -0
- package/lib/question-wait.js +45 -0
- package/lib/render.js +39 -0
- package/lib/scopes.js +95 -0
- package/lib/util.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,8 +85,9 @@ and is not retried as if history could be rewritten.
|
|
|
85
85
|
An incomplete run exits `1`; it never deletes or replaces the saved credential.
|
|
86
86
|
The command does not fall back to `PINGROOM_TOKEN`, an email-only credential, or
|
|
87
87
|
a credential without `pingroom:handoffs:create` and a delivery room. A grant of
|
|
88
|
-
all rooms pins no delivery room — pick one under Connected Agents in
|
|
89
|
-
|
|
88
|
+
all rooms pins no delivery room up front — pick one under Connected Agents in
|
|
89
|
+
the app, or run `pingroom rooms create`, which adopts the new room as the
|
|
90
|
+
delivery room when the agent was granted every room.
|
|
90
91
|
|
|
91
92
|
There is deliberately no `login` command: being unconnected is a state the tool
|
|
92
93
|
resolves, not one you have to discover. Once connected, bare `pingroom` prints
|
|
@@ -96,18 +97,26 @@ that status line followed by the usual help.
|
|
|
96
97
|
|
|
97
98
|
Approving on the phone grants two separate things, and both are enforced:
|
|
98
99
|
|
|
99
|
-
- **Permissions** —
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
- **Permissions** — one approval covers every command this CLI ships, so it
|
|
101
|
+
asks for all 16 scopes its commands can need (`lib/scopes.js` is the list):
|
|
102
|
+
send pings, upload attachments, read pings, ask questions, request approvals,
|
|
103
|
+
create handoffs, drive live status; read/create/publish/join rooms and
|
|
104
|
+
set/trigger quick actions; and read/create/delete incoming webhooks. It does
|
|
105
|
+
**not** ask for `profile:write` (no command uses it) or the retired
|
|
106
|
+
`agents:ping`. Nothing widens them later — consent is an intersection, so a
|
|
107
|
+
scope the pairing did not request can never be granted afterwards; a command
|
|
108
|
+
needing one you did not approve returns `403 insufficient_scope`.
|
|
103
109
|
- **Rooms** — one room, several, or all of them. A room outside that grant
|
|
104
110
|
returns `403 room_not_granted` on every room-scoped call — writes such as
|
|
105
111
|
pings, questions and live streams, and reads such as listing a room's quick
|
|
106
112
|
actions or webhooks. Widen it under Connected Agents in the app.
|
|
107
113
|
|
|
108
114
|
Both refusals print the fix, not just the code. A credential paired by an older
|
|
109
|
-
CLI carries the scope set that version asked for —
|
|
110
|
-
command starts reporting
|
|
115
|
+
CLI carries the scope set that version asked for — CLIs before 0.8.1 asked for
|
|
116
|
+
only seven — so run `pingroom reconnect` if a command starts reporting
|
|
117
|
+
`insufficient_scope`. That re-approves with the current set, keeps the existing
|
|
118
|
+
connection working until you approve the new one, and revokes the old one only
|
|
119
|
+
afterwards, so cancelling changes nothing.
|
|
111
120
|
|
|
112
121
|
The credential lands in `~/.pingroom/credentials.json` (mode `0600`, inside a
|
|
113
122
|
`0700` directory). `PINGROOM_HOME` moves that directory; `pingroom logout`
|
package/bin/pingroom.js
CHANGED
|
@@ -43,7 +43,8 @@ import { HELP } from '../lib/help.js';
|
|
|
43
43
|
import { maybeNotifyUpdate } from '../lib/update-check.js';
|
|
44
44
|
import {
|
|
45
45
|
parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
|
|
46
|
-
parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs,
|
|
46
|
+
parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseReconnectArgs,
|
|
47
|
+
parseSkillsArgs,
|
|
47
48
|
} from '../lib/parser.js';
|
|
48
49
|
import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
|
|
49
50
|
import { ping } from '../lib/commands/ping.js';
|
|
@@ -54,7 +55,7 @@ import { live } from '../lib/commands/live.js';
|
|
|
54
55
|
import { hook } from '../lib/commands/hook.js';
|
|
55
56
|
import { mcp } from '../lib/commands/mcp.js';
|
|
56
57
|
import { skills } from '../lib/commands/skills.js';
|
|
57
|
-
import { activateStoredInbox, bare } from '../lib/commands/connect.js';
|
|
58
|
+
import { activateStoredInbox, bare, reconnect } from '../lib/commands/connect.js';
|
|
58
59
|
import { config, logout } from '../lib/commands/config.js';
|
|
59
60
|
|
|
60
61
|
const COMMANDS = {
|
|
@@ -78,6 +79,7 @@ const COMMANDS = {
|
|
|
78
79
|
approval: (rest) => approval(parseManageArgs(rest)),
|
|
79
80
|
attachment: (rest) => attachment(parseManageArgs(rest)),
|
|
80
81
|
config: (rest) => config(parseConfigArgs(rest)),
|
|
82
|
+
reconnect: (rest) => reconnect(parseReconnectArgs(rest)),
|
|
81
83
|
logout: (rest) => logout(parseLogoutArgs(rest)),
|
|
82
84
|
};
|
|
83
85
|
|
package/lib/commands/ask.js
CHANGED
|
@@ -2,41 +2,14 @@
|
|
|
2
2
|
// existing one), `cancel`, and `list`.
|
|
3
3
|
|
|
4
4
|
import { EXIT } from '../constants.js';
|
|
5
|
-
import { fail, parseDataObject, requireMaxLength, resolveWaitHold
|
|
5
|
+
import { applyIdempotencyKey, fail, parseDataObject, requireMaxLength, resolveWaitHold } from '../util.js';
|
|
6
6
|
import { commandHelp } from '../help.js';
|
|
7
7
|
import { apiDetail, httpJson } from '../http.js';
|
|
8
8
|
import { agentContext } from '../config.js';
|
|
9
|
-
import { buildOptions
|
|
9
|
+
import { buildOptions } from '../render.js';
|
|
10
|
+
import { waitForResolution } from '../question-wait.js';
|
|
10
11
|
import { writeGitHubQuestionOutputs } from '../github-output.js';
|
|
11
12
|
|
|
12
|
-
// Long-poll the wait endpoint until the question leaves `pending`, then print
|
|
13
|
-
// and return the state's exit code. The server expires it at its ttl, so this
|
|
14
|
-
// always terminates.
|
|
15
|
-
async function waitForResolution(id, args, { token, apiBase }) {
|
|
16
|
-
const hold = resolveWaitHold(args, { def: 25, cap: 30 });
|
|
17
|
-
|
|
18
|
-
for (;;) {
|
|
19
|
-
const started = Date.now();
|
|
20
|
-
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
21
|
-
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
22
|
-
if (!res.ok) {
|
|
23
|
-
const detail = apiDetail(res, json);
|
|
24
|
-
fail(`wait failed: ${detail}`);
|
|
25
|
-
}
|
|
26
|
-
if (json && json.state && json.state !== 'pending') {
|
|
27
|
-
if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
|
|
28
|
-
if (args.json) process.stdout.write(`${text}\n`);
|
|
29
|
-
else printResolution(json);
|
|
30
|
-
return exitForState(json.state);
|
|
31
|
-
}
|
|
32
|
-
// Still pending at the hold timeout — poll again, but never hot-loop: a
|
|
33
|
-
// misbehaving server that answers `pending` instantly (ignoring the hold)
|
|
34
|
-
// would otherwise be hammered at full speed.
|
|
35
|
-
const elapsed = Date.now() - started;
|
|
36
|
-
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
13
|
export async function ask(args) {
|
|
41
14
|
if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
|
|
42
15
|
|
|
@@ -76,18 +49,7 @@ export async function ask(args) {
|
|
|
76
49
|
}
|
|
77
50
|
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
78
51
|
|
|
79
|
-
const headers = { Authorization: `Bearer ${token}` };
|
|
80
|
-
// Question creation is the durable human gate for workflows. A printable,
|
|
81
|
-
// bounded key lets a caller safely replay the exact same create request
|
|
82
|
-
// after an ambiguous transport failure; the server returns 409 if the key is
|
|
83
|
-
// ever reused for a different payload.
|
|
84
|
-
if (args.idempotency_key !== undefined) {
|
|
85
|
-
const key = String(args.idempotency_key);
|
|
86
|
-
if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
|
|
87
|
-
fail('--idempotency-key must be 1–255 printable ASCII characters without spaces', EXIT.USAGE);
|
|
88
|
-
}
|
|
89
|
-
headers['Idempotency-Key'] = key;
|
|
90
|
-
}
|
|
52
|
+
const headers = applyIdempotencyKey(args, { Authorization: `Bearer ${token}` });
|
|
91
53
|
|
|
92
54
|
// Pre-flight: reject a bad --timeout before the question exists.
|
|
93
55
|
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
package/lib/commands/connect.js
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
import { HELP, commandHelp } from '../help.js';
|
|
10
10
|
import { apiDetail, httpJson, requireSafeUrl, retryAfterMs } from '../http.js';
|
|
11
11
|
import {
|
|
12
|
-
credentialsPath, readStoredCredential,
|
|
12
|
+
credentialsPath, readStoredCredential, requireStoredCredentialOrigin, resolveApiBase,
|
|
13
|
+
resolveRoom, saveCredential,
|
|
13
14
|
} from '../config.js';
|
|
15
|
+
import { CLI_SCOPES } from '../scopes.js';
|
|
14
16
|
|
|
15
17
|
// --- connecting (pairing + email fallback) ---------------------------------
|
|
16
18
|
//
|
|
@@ -19,20 +21,6 @@ import {
|
|
|
19
21
|
// room, so an agent can never end up connected with nobody's say-so about where
|
|
20
22
|
// it pings. There is no `login` subcommand: `pingroom` resolves the state.
|
|
21
23
|
|
|
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
24
|
// What the human reads on the approval screen. A product name, not a package
|
|
37
25
|
// id: the phone shows it verbatim ("PingRoom CLI wants to connect").
|
|
38
26
|
const AGENT_LABEL = 'PingRoom CLI';
|
|
@@ -724,3 +712,97 @@ export async function bare(args) {
|
|
|
724
712
|
|
|
725
713
|
return connect(args);
|
|
726
714
|
}
|
|
715
|
+
|
|
716
|
+
// --- reconnect --------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Re-pair an existing connection so it carries the scopes this CLI version
|
|
720
|
+
* needs. Ordered so that any failure is a no-op:
|
|
721
|
+
*
|
|
722
|
+
* 1. the old credential stays in place and keeps working throughout;
|
|
723
|
+
* 2. the human approves a NEW pairing — a separate registration, since the
|
|
724
|
+
* server puts no uniqueness on user_id, so both are live at once;
|
|
725
|
+
* 3. the new credential is written atomically (temp file + rename);
|
|
726
|
+
* 4. only THEN is the old one revoked.
|
|
727
|
+
*
|
|
728
|
+
* Cancelling, Ctrl-C, or any error before step 3 leaves the machine exactly as
|
|
729
|
+
* it was. Revocation is last and best-effort on purpose: a crash between the
|
|
730
|
+
* rename and the revoke leaves a stale-but-harmless registration the human can
|
|
731
|
+
* remove from Connected Agents, whereas the reverse order would leave a working
|
|
732
|
+
* credentials file holding a dead token.
|
|
733
|
+
*
|
|
734
|
+
* This is NOT `logout && pingroom`. `logout` only unlinks the local file; the
|
|
735
|
+
* server-side credential stays active forever, so that sequence leaks a live
|
|
736
|
+
* credential every time.
|
|
737
|
+
*/
|
|
738
|
+
export async function reconnect(args) {
|
|
739
|
+
if (args.help) { process.stdout.write(`${commandHelp('reconnect')}\n`); return EXIT.OK; }
|
|
740
|
+
|
|
741
|
+
// An env token is not ours to replace: it was pasted here from somewhere else
|
|
742
|
+
// (a CI secret, a password manager), the same registration is probably in use
|
|
743
|
+
// on other machines, and revoking it would break all of them from a terminal
|
|
744
|
+
// whose owner may not even know. Refuse rather than guess.
|
|
745
|
+
if (process.env.PINGROOM_TOKEN) {
|
|
746
|
+
fail(
|
|
747
|
+
'PINGROOM_TOKEN is set, and reconnect would revoke whatever credential it names.\n'
|
|
748
|
+
+ ' Unset it and run "pingroom reconnect" against the stored credential, or re-pair\n'
|
|
749
|
+
+ ' from scratch with "pingroom" and update the token where it is configured.',
|
|
750
|
+
EXIT.USAGE,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const stored = readStoredCredential();
|
|
755
|
+
if (!stored) {
|
|
756
|
+
fail(`not connected — there is no credential in ${credentialsPath()} to replace. Run "pingroom" to pair.`, EXIT.USAGE);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!isInteractive()) {
|
|
760
|
+
fail('reconnect needs an interactive terminal to show the QR code.', EXIT.USAGE);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const apiBase = resolveApiBase(args);
|
|
764
|
+
requireSafeUrl('--api', apiBase);
|
|
765
|
+
// The stored credential is about to be sent to `apiBase` in the revoke below,
|
|
766
|
+
// so it is bound by the same origin rule every other stored-bearer command
|
|
767
|
+
// obeys. Without this, `--api` (or a stale config.json api_url) redirects a
|
|
768
|
+
// live production credential to an arbitrary host.
|
|
769
|
+
requireStoredCredentialOrigin(args, apiBase);
|
|
770
|
+
|
|
771
|
+
process.stdout.write(' Reconnecting updates the permissions this CLI holds.\n');
|
|
772
|
+
process.stdout.write(' Your current connection keeps working until the new one is approved,\n');
|
|
773
|
+
process.stdout.write(' and is then revoked — any other machine or CI job using that same\n');
|
|
774
|
+
process.stdout.write(' credential will stop working.\n\n');
|
|
775
|
+
|
|
776
|
+
const prompter = createPrompter();
|
|
777
|
+
const ask = (question) => prompter.ask(question);
|
|
778
|
+
let cred;
|
|
779
|
+
try {
|
|
780
|
+
cred = await connectByPairing(apiBase, ask);
|
|
781
|
+
} finally {
|
|
782
|
+
prompter.close();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Declined, expired, or the user closed stdin: nothing was written, so the old
|
|
786
|
+
// credential is still the one on disk and still valid.
|
|
787
|
+
if (!cred) {
|
|
788
|
+
process.stdout.write(' Kept your current connection.\n');
|
|
789
|
+
return EXIT.EXPIRED;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// The new credential is already durable (connectByPairing saved it). From
|
|
793
|
+
// here on, failing to revoke is untidy, not dangerous.
|
|
794
|
+
const { res, json, error } = await httpJson('POST', `${apiBase}/api/agent/auth/revoke`, {
|
|
795
|
+
headers: { Authorization: `Bearer ${stored.token}` },
|
|
796
|
+
body: {},
|
|
797
|
+
soft: true,
|
|
798
|
+
});
|
|
799
|
+
if (error || !res || !res.ok) {
|
|
800
|
+
const detail = error ? error.message : apiDetail(res, json);
|
|
801
|
+
process.stdout.write(` Note: the previous connection could not be revoked (${detail}).\n`);
|
|
802
|
+
process.stdout.write(' Remove it from PingRoom → Settings → Connected Agents when convenient.\n');
|
|
803
|
+
return EXIT.OK;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
process.stdout.write(' Previous connection revoked.\n');
|
|
807
|
+
return EXIT.OK;
|
|
808
|
+
}
|
package/lib/commands/hook.js
CHANGED
|
@@ -5,6 +5,7 @@ import { truncate } from '../util.js';
|
|
|
5
5
|
import { commandHelp } from '../help.js';
|
|
6
6
|
import { hookFetch, isSafeUrl } from '../http.js';
|
|
7
7
|
import { resolveApiBase, resolveRoom, resolveToken, storedCredentialOriginError } from '../config.js';
|
|
8
|
+
import { forgetContinuation, recordContinuation } from '../continuations.js';
|
|
8
9
|
import { VERSION } from '../version.js';
|
|
9
10
|
|
|
10
11
|
// --- hook (Claude Code integration) ----------------------------------------
|
|
@@ -139,8 +140,11 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
|
139
140
|
process.on('SIGTERM', onSignal);
|
|
140
141
|
|
|
141
142
|
try {
|
|
143
|
+
// `data` is echoed into every room member's push AND into the room's
|
|
144
|
+
// outgoing webhook, so it carries only what a recipient should see. The
|
|
145
|
+
// working directory is a local filesystem path: it goes to
|
|
146
|
+
// ~/.pingroom/continuations.json below, never on the wire.
|
|
142
147
|
const data = { tool_name: String(toolName) };
|
|
143
|
-
if (event.cwd) data.cwd = String(event.cwd);
|
|
144
148
|
const created = await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, {
|
|
145
149
|
token,
|
|
146
150
|
body: {
|
|
@@ -161,7 +165,16 @@ async function hookPreToolUse(event, { token, room, apiBase, args }) {
|
|
|
161
165
|
return EXIT.OK;
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
// Where this session would need to resume from, kept locally. Best-effort:
|
|
169
|
+
// a failure here must not change the tool decision.
|
|
170
|
+
recordContinuation(questionId, {
|
|
171
|
+
sessionId: event.session_id,
|
|
172
|
+
cwd: event.cwd,
|
|
173
|
+
transcriptPath: event.transcript_path,
|
|
174
|
+
});
|
|
175
|
+
|
|
164
176
|
const resolved = await hookWaitForAnswer(questionId, { token, apiBase });
|
|
177
|
+
forgetContinuation(questionId);
|
|
165
178
|
if (resolved.state === 'answered') {
|
|
166
179
|
const value = resolved.answer && (resolved.answer.value || resolved.answer.text);
|
|
167
180
|
if (value === 'allow') { emitPreToolUseDecision('allow', 'Approved via PingRoom'); return EXIT.OK; }
|
|
@@ -210,9 +223,11 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
|
|
|
210
223
|
return EXIT.OK; // unknown event — stay silent rather than send noise
|
|
211
224
|
}
|
|
212
225
|
|
|
226
|
+
// Same rule as the question path: nothing here is private to this machine.
|
|
227
|
+
// `session_id` is already the ping's correlation_id, so it is on the wire by
|
|
228
|
+
// design; `cwd` is a local path and stays local.
|
|
213
229
|
const data = { event: name };
|
|
214
230
|
if (event.session_id) data.session_id = String(event.session_id);
|
|
215
|
-
if (event.cwd) data.cwd = String(event.cwd);
|
|
216
231
|
|
|
217
232
|
try {
|
|
218
233
|
await hookFetch('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/notifications`, {
|
package/lib/commands/manage.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
// response; the default prints a compact human line per record).
|
|
5
5
|
|
|
6
6
|
import { EXIT } from '../constants.js';
|
|
7
|
-
import { fail } from '../util.js';
|
|
7
|
+
import { applyIdempotencyKey, fail, requireMaxLength, resolveWaitHold } from '../util.js';
|
|
8
8
|
import { commandHelp } from '../help.js';
|
|
9
9
|
import { apiDetail, httpJson } from '../http.js';
|
|
10
10
|
import { agentContext } from '../config.js';
|
|
11
|
+
import { APPROVAL_OPTIONS, exitForApproval, printApproval } from '../render.js';
|
|
12
|
+
import { waitForResolution } from '../question-wait.js';
|
|
11
13
|
|
|
12
14
|
function sub(args, allowed, noun) {
|
|
13
15
|
const name = args._[0];
|
|
@@ -182,43 +184,48 @@ export async function actions(args) {
|
|
|
182
184
|
|
|
183
185
|
// ------------------------------------------------------------- approval
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The deploy gate. An approval is the canonical two-option Question, so this
|
|
189
|
+
* creates one rather than using the older `/approvals` endpoint: only Questions
|
|
190
|
+
* reach the phone with real Approve/Deny buttons on the lock screen, and only
|
|
191
|
+
* Questions get idempotency, the expiry sweep and the resolution webhook.
|
|
192
|
+
*/
|
|
187
193
|
export async function approval(args) {
|
|
188
194
|
if (args.help) { process.stdout.write(`${commandHelp('approval')}\n`); return EXIT.OK; }
|
|
189
195
|
|
|
190
196
|
if (!args.prompt) fail('an approval needs --prompt', EXIT.USAGE);
|
|
197
|
+
requireMaxLength(args.prompt, 500, '--prompt');
|
|
198
|
+
requireMaxLength(args.context, 40, '--context');
|
|
199
|
+
|
|
191
200
|
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
192
201
|
|
|
193
|
-
const body = { prompt: args.prompt };
|
|
202
|
+
const body = { prompt: args.prompt, options: APPROVAL_OPTIONS };
|
|
194
203
|
if (args.context) body.context = args.context;
|
|
195
204
|
if (args.ttl !== undefined) {
|
|
196
205
|
if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
|
|
197
206
|
body.ttl = Number(args.ttl);
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
const headers = applyIdempotencyKey(args, auth(token));
|
|
210
|
+
|
|
211
|
+
// Pre-flight: reject a bad --timeout before the approval is on someone's phone.
|
|
212
|
+
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
|
213
|
+
|
|
200
214
|
const { text, json } = await requireOk(
|
|
201
|
-
httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/
|
|
215
|
+
httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`, { headers, body }),
|
|
202
216
|
'approval',
|
|
203
217
|
);
|
|
204
218
|
|
|
205
219
|
if (!args.wait) {
|
|
206
|
-
process.stdout.write(`${text}\n`);
|
|
220
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
221
|
+
else process.stdout.write(`${json.id}\n`);
|
|
207
222
|
return EXIT.OK;
|
|
208
223
|
}
|
|
209
224
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{ headers: auth(token) },
|
|
215
|
-
);
|
|
216
|
-
if (!res.ok) fail(`approval wait failed: ${apiDetail(res, state)}`);
|
|
217
|
-
if (state && state.state && state.state !== 'pending') {
|
|
218
|
-
process.stdout.write(`${args.json ? waitText : state.state}\n`);
|
|
219
|
-
return APPROVAL_EXIT[state.state] ?? EXIT.OK;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
225
|
+
return waitForResolution(json.id, args, { token, apiBase }, {
|
|
226
|
+
exitFor: exitForApproval,
|
|
227
|
+
print: printApproval,
|
|
228
|
+
});
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
// ----------------------------------------------------------- attachment
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Where a blocked agent left off, kept on THIS machine only.
|
|
2
|
+
//
|
|
3
|
+
// When a hook turns a tool-permission prompt into a PingRoom question, the
|
|
4
|
+
// thing that will eventually need to resume — the Claude Code session, its
|
|
5
|
+
// working directory, its transcript — is local. The server never needs to know
|
|
6
|
+
// any of it: the question already carries `correlation_id = session_id`, which
|
|
7
|
+
// is enough to match an answer back to a row here.
|
|
8
|
+
//
|
|
9
|
+
// That is deliberate, not incidental. A question's `data` object is echoed into
|
|
10
|
+
// every room member's push payload AND into the room's outgoing webhook, so a
|
|
11
|
+
// `cwd` put there would publish a local filesystem path to everyone in the room
|
|
12
|
+
// and to whatever URL the room forwards to. Keeping the record local leaks
|
|
13
|
+
// nothing and needs no wire contract.
|
|
14
|
+
//
|
|
15
|
+
// Nothing consumes this yet. It exists so that resuming an ended session is a
|
|
16
|
+
// matter of writing the consumer, not of changing the protocol — and until that
|
|
17
|
+
// consumer exists, PingRoom only claims to block an agent that is still running.
|
|
18
|
+
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { chmodSync, closeSync, fchmodSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { pingroomHome, readJsonFile } from './config.js';
|
|
24
|
+
|
|
25
|
+
/** Enough for any plausible backlog of open questions on one machine. */
|
|
26
|
+
const MAX_ENTRIES = 100;
|
|
27
|
+
/** A question cannot outlive its TTL by this much; anything older is dead. */
|
|
28
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
export function continuationsPath() { return join(pingroomHome(), 'continuations.json'); }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Atomic write that REPORTS failure instead of exiting.
|
|
34
|
+
*
|
|
35
|
+
* config.js's writeJsonFile calls fail() — correct when a human ran `config
|
|
36
|
+
* set` and needs to know it didn't take, fatal here: this is called from the
|
|
37
|
+
* hook, which must never break the agent it is advising. A full disk loses a
|
|
38
|
+
* resume hint; it must not kill the session.
|
|
39
|
+
*/
|
|
40
|
+
function writeQuietly(path, value) {
|
|
41
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
42
|
+
let fd;
|
|
43
|
+
try {
|
|
44
|
+
const created = mkdirSync(pingroomHome(), { recursive: true, mode: 0o700 });
|
|
45
|
+
if (created !== undefined) chmodSync(pingroomHome(), 0o700);
|
|
46
|
+
fd = openSync(tmp, 'wx', 0o600);
|
|
47
|
+
fchmodSync(fd, 0o600);
|
|
48
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`);
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
fd = undefined;
|
|
51
|
+
renameSync(tmp, path);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
|
|
55
|
+
try { unlinkSync(tmp); } catch { /* never created */ }
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function load() {
|
|
61
|
+
const stored = readJsonFile(continuationsPath());
|
|
62
|
+
const entries = stored && typeof stored.entries === 'object' && !Array.isArray(stored.entries)
|
|
63
|
+
? stored.entries
|
|
64
|
+
: {};
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Drop expired rows, then the oldest ones, so the file cannot grow forever. */
|
|
69
|
+
function bound(entries, now) {
|
|
70
|
+
const live = Object.entries(entries).filter(([, e]) => {
|
|
71
|
+
const at = Date.parse(e && e.recorded_at);
|
|
72
|
+
return Number.isFinite(at) && now - at < MAX_AGE_MS;
|
|
73
|
+
});
|
|
74
|
+
live.sort((a, b) => Date.parse(a[1].recorded_at) - Date.parse(b[1].recorded_at));
|
|
75
|
+
return Object.fromEntries(live.slice(-MAX_ENTRIES));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Remember where to come back to for one question. Best-effort by design: the
|
|
80
|
+
* caller is a hook that has already decided the agent proceeds either way.
|
|
81
|
+
*/
|
|
82
|
+
export function recordContinuation(questionId, { sessionId, cwd, transcriptPath } = {}) {
|
|
83
|
+
if (!questionId || typeof questionId !== 'string') return false;
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const entries = bound(load(), now);
|
|
86
|
+
entries[questionId] = {
|
|
87
|
+
recorded_at: new Date(now).toISOString(),
|
|
88
|
+
...(sessionId ? { session_id: String(sessionId) } : {}),
|
|
89
|
+
...(cwd ? { cwd: String(cwd) } : {}),
|
|
90
|
+
...(transcriptPath ? { transcript_path: String(transcriptPath) } : {}),
|
|
91
|
+
};
|
|
92
|
+
return writeQuietly(continuationsPath(), { version: 1, entries: bound(entries, now) });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The record for one question, or null. */
|
|
96
|
+
export function readContinuation(questionId) {
|
|
97
|
+
return load()[questionId] ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Forget one question once it has resolved — the hint has done its job. */
|
|
101
|
+
export function forgetContinuation(questionId) {
|
|
102
|
+
const entries = load();
|
|
103
|
+
if (!(questionId in entries)) return false;
|
|
104
|
+
delete entries[questionId];
|
|
105
|
+
return writeQuietly(continuationsPath(), { version: 1, entries: bound(entries, Date.now()) });
|
|
106
|
+
}
|
package/lib/help.js
CHANGED
|
@@ -38,6 +38,7 @@ Commands:
|
|
|
38
38
|
approval Send an approve/deny request; with --wait, block on the decision
|
|
39
39
|
attachment Download or delete an attachment by id (attachment get|delete)
|
|
40
40
|
config Read/write local settings (config list | get <key> | set <key> <val>)
|
|
41
|
+
reconnect Re-approve this CLI so it holds every permission it needs
|
|
41
42
|
logout Forget the stored credential`;
|
|
42
43
|
|
|
43
44
|
export const HELP_PING = `ping options:
|
|
@@ -332,18 +333,34 @@ export const COMMAND_HELP_SECTIONS = {
|
|
|
332
333
|
pingroom actions trigger <1-4> --room <code>`,
|
|
333
334
|
approval: `approval (agent token + --room required):
|
|
334
335
|
pingroom approval -p <prompt> Send an approve/deny request
|
|
336
|
+
Prints the id; --wait prints the decision
|
|
335
337
|
-c, --context <text> Secondary line (<= 40 chars)
|
|
336
338
|
--ttl <seconds> Expiry; omit for the server default
|
|
337
|
-
--
|
|
338
|
-
|
|
339
|
+
--idempotency-key <k> Safe replay of the create after a network failure
|
|
340
|
+
--wait Block until the human decides
|
|
341
|
+
(exit 0 approve · 4 deny/cancelled · 3 expired)
|
|
342
|
+
--timeout <0-30> Seconds per long-poll while waiting (default 25)`,
|
|
339
343
|
attachment: `attachment (agent token required):
|
|
340
344
|
pingroom attachment get <id> [--out <path>]
|
|
341
345
|
Download; bytes go to --out or stdout
|
|
342
346
|
pingroom attachment delete <id> Delete an unclaimed upload`,
|
|
343
347
|
config: HELP_CONFIG,
|
|
348
|
+
reconnect: `reconnect:
|
|
349
|
+
pingroom reconnect Re-approve this CLI with the permissions the
|
|
350
|
+
current version needs, then revoke the old
|
|
351
|
+
connection. Your existing connection keeps
|
|
352
|
+
working until you approve the new one, and
|
|
353
|
+
cancelling changes nothing.
|
|
354
|
+
Any other machine or CI job sharing the same
|
|
355
|
+
credential will stop working once it is revoked.
|
|
356
|
+
--api <url> API base URL (default https://api.pingroom.io)`,
|
|
344
357
|
logout: `logout:
|
|
345
358
|
pingroom logout Forget the stored credential (PINGROOM_TOKEN
|
|
346
|
-
in the environment is unaffected)
|
|
359
|
+
in the environment is unaffected).
|
|
360
|
+
This is LOCAL ONLY — the connection stays
|
|
361
|
+
active on the server. To replace it, use
|
|
362
|
+
"pingroom reconnect"; to end it, revoke it in
|
|
363
|
+
PingRoom -> Settings -> Connected Agents.`,
|
|
347
364
|
};
|
|
348
365
|
|
|
349
366
|
// config and logout are local-only commands that reject --token/--api (and,
|
|
@@ -355,6 +372,10 @@ export const COMMAND_HELP_FOOTERS = {
|
|
|
355
372
|
-h, --help Show this help`,
|
|
356
373
|
logout: `Shared:
|
|
357
374
|
-h, --help Show this help`,
|
|
375
|
+
// reconnect drives the QR pairing flow; a --token would have nothing to do
|
|
376
|
+
// with the credential it is about to replace.
|
|
377
|
+
reconnect: `Shared:
|
|
378
|
+
-h, --help Show this help`,
|
|
358
379
|
// `skills` reaches GitHub, never the PingRoom API, so the shared credential
|
|
359
380
|
// and --json flags would all be lies here.
|
|
360
381
|
skills: `Shared:
|
package/lib/http.js
CHANGED
|
@@ -13,11 +13,16 @@ import { readStrictJsonResponse } from './strict-json.js';
|
|
|
13
13
|
*/
|
|
14
14
|
const API_HINTS = {
|
|
15
15
|
room_not_granted:
|
|
16
|
-
'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom" to
|
|
16
|
+
'That room is outside the grant this agent was given. Add it under Connected Agents in the PingRoom app, or run "pingroom reconnect" to re-approve and pick it.',
|
|
17
|
+
// The remedy has to be `reconnect`, not `pingroom`: on a machine that is
|
|
18
|
+
// already connected, bare `pingroom` just prints the status line and the help,
|
|
19
|
+
// so it reads as a fix and does nothing. Consent is an intersection
|
|
20
|
+
// server-side, so a scope the old pairing never requested can only be gained
|
|
21
|
+
// by approving a new one.
|
|
17
22
|
insufficient_scope:
|
|
18
|
-
'This credential was approved before the
|
|
23
|
+
'This credential was approved before the CLI needed that permission. Run "pingroom reconnect" to re-approve with the current permissions — your existing connection keeps working until you approve the new one.',
|
|
19
24
|
no_room_configured:
|
|
20
|
-
'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app.',
|
|
25
|
+
'This agent has no delivery room. Pick one under Connected Agents in the PingRoom app, or — if it was granted every room — let it create one with "pingroom rooms create".',
|
|
21
26
|
};
|
|
22
27
|
|
|
23
28
|
/**
|
package/lib/parser.js
CHANGED
|
@@ -205,6 +205,15 @@ export const parseConfigArgs = makeParser({
|
|
|
205
205
|
bareDashIsPositional: true,
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
// reconnect re-runs the pairing flow against the stored credential. It takes no
|
|
209
|
+
// --token deliberately: the credential it replaces is the one on disk, and an
|
|
210
|
+
// env token is refused outright by the command.
|
|
211
|
+
export const parseReconnectArgs = makeParser({
|
|
212
|
+
aliases: { '--api': 'api', '-h': 'help', '--help': 'help' },
|
|
213
|
+
booleans: ['help'],
|
|
214
|
+
bareDashIsPositional: true,
|
|
215
|
+
});
|
|
216
|
+
|
|
208
217
|
export const parseLogoutArgs = makeParser({
|
|
209
218
|
aliases: { '-h': 'help', '--help': 'help' },
|
|
210
219
|
booleans: ['help'],
|
|
@@ -247,6 +256,8 @@ export const parseManageArgs = makeParser({
|
|
|
247
256
|
'--require-ack': 'require_ack',
|
|
248
257
|
'--out': 'out',
|
|
249
258
|
'--wait': 'wait',
|
|
259
|
+
'--timeout': 'timeout',
|
|
260
|
+
'--idempotency-key': 'idempotency_key',
|
|
250
261
|
'--token': 'token',
|
|
251
262
|
'--room': 'room',
|
|
252
263
|
'--expected-room-sha256': 'expected_room_sha256',
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The one long-poll loop behind every command that blocks on a human: `ask`,
|
|
2
|
+
// `watch`, and `approval`. It lives here rather than in ask.js so the hot-loop
|
|
3
|
+
// floor and the terminal-state handling cannot drift apart — `approval` shipped
|
|
4
|
+
// its own copy of this loop once, and that copy read a field the server never
|
|
5
|
+
// sends, so it spun forever after the human had already decided.
|
|
6
|
+
|
|
7
|
+
import { fail, resolveWaitHold, sleep } from './util.js';
|
|
8
|
+
import { apiDetail, httpJson } from './http.js';
|
|
9
|
+
import { exitForState, printResolution } from './render.js';
|
|
10
|
+
import { writeGitHubQuestionOutputs } from './github-output.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Long-poll until the question leaves `pending`, then print and return its exit
|
|
14
|
+
* code. The server expires it at its ttl, so this always terminates.
|
|
15
|
+
*
|
|
16
|
+
* `exitFor` and `print` take the whole resolved question, not just its state:
|
|
17
|
+
* a deploy gate has to read the chosen *value* to tell approve from deny, which
|
|
18
|
+
* the state alone ("answered") cannot express.
|
|
19
|
+
*/
|
|
20
|
+
export async function waitForResolution(id, args, { token, apiBase }, opts = {}) {
|
|
21
|
+
const exitFor = opts.exitFor ?? ((q) => exitForState(q.state));
|
|
22
|
+
const print = opts.print ?? printResolution;
|
|
23
|
+
const hold = resolveWaitHold(args, { def: 25, cap: 30 });
|
|
24
|
+
|
|
25
|
+
for (;;) {
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
28
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const detail = apiDetail(res, json);
|
|
31
|
+
fail(`wait failed: ${detail}`);
|
|
32
|
+
}
|
|
33
|
+
if (json && json.state && json.state !== 'pending') {
|
|
34
|
+
if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
|
|
35
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
36
|
+
else print(json);
|
|
37
|
+
return exitFor(json);
|
|
38
|
+
}
|
|
39
|
+
// Still pending at the hold timeout — poll again, but never hot-loop: a
|
|
40
|
+
// misbehaving server that answers `pending` instantly (ignoring the hold)
|
|
41
|
+
// would otherwise be hammered at full speed.
|
|
42
|
+
const elapsed = Date.now() - started;
|
|
43
|
+
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -116,6 +116,45 @@ export function printResolution(q) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The two options behind `pingroom approval`. An approval IS a two-option
|
|
121
|
+
* Question — same wire shape, same lock-screen buttons, same first-answer-wins
|
|
122
|
+
* — so the command builds one rather than reaching for the older Approvals API,
|
|
123
|
+
* which delivers no answer actions to the phone at all.
|
|
124
|
+
*/
|
|
125
|
+
export const APPROVAL_OPTIONS = Object.freeze([
|
|
126
|
+
Object.freeze({ value: 'approve', label: 'Approve', style: 'primary' }),
|
|
127
|
+
Object.freeze({ value: 'deny', label: 'Deny', style: 'danger' }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A deploy gate reads the chosen VALUE, not just the state.
|
|
132
|
+
*
|
|
133
|
+
* `exitForState` maps every answer to 0, which is right for `ask` (a human
|
|
134
|
+
* answering "hold" is not an infra failure) and catastrophic for a gate:
|
|
135
|
+
* `if pingroom approval --wait; then deploy; fi` must refuse on a denial.
|
|
136
|
+
* So `approve` is 0 and anything else answered is 4, and only the non-answered
|
|
137
|
+
* states fall through to the shared mapping.
|
|
138
|
+
*/
|
|
139
|
+
export function exitForApproval(q) {
|
|
140
|
+
if (q.state === 'answered') {
|
|
141
|
+
const value = (q.answer && (q.answer.value ?? q.answer.text)) || '';
|
|
142
|
+
return value === 'approve' ? EXIT.OK : EXIT.CANCELLED;
|
|
143
|
+
}
|
|
144
|
+
return exitForState(q.state);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Same split as printResolution: the decision goes to stdout so `$(...)` can
|
|
148
|
+
// capture it, every other outcome reports to stderr and leaves stdout empty.
|
|
149
|
+
export function printApproval(q) {
|
|
150
|
+
if (q.state === 'answered') {
|
|
151
|
+
const value = (q.answer && (q.answer.value ?? q.answer.text)) || '';
|
|
152
|
+
process.stdout.write(`${value}\n`);
|
|
153
|
+
} else {
|
|
154
|
+
process.stderr.write(`pingroom: approval ${q.state}\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
/** One readable line per incoming ping. */
|
|
120
159
|
export function formatIncoming(item) {
|
|
121
160
|
const room = item?.room?.name || item?.room?.code || '?';
|
package/lib/scopes.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// What the official CLI asks for, and what each command actually needs.
|
|
2
|
+
//
|
|
3
|
+
// One pairing approval enables every command PingRoom ships. That is a
|
|
4
|
+
// deliberate reading of OAuth's minimum-required principle (RFC 9700 §2.3): the
|
|
5
|
+
// unit being authorized here is the complete official CLI, not the server's
|
|
6
|
+
// whole capability surface. Consent is an intersection server-side
|
|
7
|
+
// (AgentAuthController::pairingClaim), so this list is a hard CEILING — a scope
|
|
8
|
+
// missing here can never be granted, and the only remedy is a fresh pairing.
|
|
9
|
+
// That is why the set is not trimmed to "what you happen to run today".
|
|
10
|
+
//
|
|
11
|
+
// Two catalog scopes are deliberately absent:
|
|
12
|
+
// pingroom:profile:write — no CLI command sets the agent avatar or handle.
|
|
13
|
+
// pingroom:agents:ping — retired; the route answers 410.
|
|
14
|
+
//
|
|
15
|
+
// Ordered by the group the consent screen shows, so the approval reads as three
|
|
16
|
+
// coherent sections even on a client that has not shipped the grouping yet.
|
|
17
|
+
export const CLI_SCOPES = [
|
|
18
|
+
// Communication and human decisions
|
|
19
|
+
'pingroom:broadcast:send', // ping
|
|
20
|
+
'pingroom:attachments:write', // ping --attach (upload), attachment delete
|
|
21
|
+
'pingroom:notifications:read',// listen, attachment get
|
|
22
|
+
'pingroom:questions:ask', // ask / watch / cancel / list, approval, the hook
|
|
23
|
+
'pingroom:approvals:request', // the legacy approvals surface (SDK/MCP parity)
|
|
24
|
+
'pingroom:handoffs:create', // handoff / handoffs / activate
|
|
25
|
+
'pingroom:live:write', // live start/update/end/get
|
|
26
|
+
// Rooms and quick actions
|
|
27
|
+
'pingroom:rooms:read', // rooms list/get, actions list, room icons
|
|
28
|
+
'pingroom:rooms:write', // rooms create
|
|
29
|
+
'pingroom:rooms:publish', // rooms create --public
|
|
30
|
+
'pingroom:rooms:join', // rooms join
|
|
31
|
+
'pingroom:actions:trigger', // actions trigger
|
|
32
|
+
'pingroom:actions:write', // actions set
|
|
33
|
+
// Webhooks
|
|
34
|
+
'pingroom:webhooks:read', // webhooks list
|
|
35
|
+
'pingroom:webhooks:write', // webhooks create/update
|
|
36
|
+
'pingroom:webhooks:delete', // webhooks delete
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Command -> every scope any of its verbs can need, taken from the route
|
|
41
|
+
* middleware in laravel/routes/api.php.
|
|
42
|
+
*
|
|
43
|
+
* This exists to be TESTED, not consulted at runtime for authorization: the
|
|
44
|
+
* server is the authority. `test/cli.test.mjs` asserts both directions — every
|
|
45
|
+
* dispatched command appears here, and every scope named here is in
|
|
46
|
+
* CLI_SCOPES — so a new command whose scope nobody requested cannot ship. That
|
|
47
|
+
* check is the thing that would have caught `approval` 403ing on a fresh
|
|
48
|
+
* pairing for as long as it did.
|
|
49
|
+
*
|
|
50
|
+
* An empty array means the command touches no agent-authenticated route.
|
|
51
|
+
*/
|
|
52
|
+
export const COMMAND_SCOPES = {
|
|
53
|
+
ping: ['pingroom:broadcast:send', 'pingroom:attachments:write'],
|
|
54
|
+
ask: ['pingroom:questions:ask'],
|
|
55
|
+
watch: ['pingroom:questions:ask'],
|
|
56
|
+
await: ['pingroom:questions:ask'],
|
|
57
|
+
cancel: ['pingroom:questions:ask'],
|
|
58
|
+
list: ['pingroom:questions:ask'],
|
|
59
|
+
approval: ['pingroom:questions:ask'],
|
|
60
|
+
handoff: ['pingroom:handoffs:create'],
|
|
61
|
+
handoffs: ['pingroom:handoffs:create'],
|
|
62
|
+
activate: ['pingroom:handoffs:create'],
|
|
63
|
+
listen: ['pingroom:notifications:read'],
|
|
64
|
+
hook: ['pingroom:questions:ask', 'pingroom:broadcast:send'],
|
|
65
|
+
live: ['pingroom:live:write'],
|
|
66
|
+
rooms: [
|
|
67
|
+
'pingroom:rooms:read', 'pingroom:rooms:write',
|
|
68
|
+
'pingroom:rooms:publish', 'pingroom:rooms:join',
|
|
69
|
+
],
|
|
70
|
+
actions: ['pingroom:rooms:read', 'pingroom:actions:write', 'pingroom:actions:trigger'],
|
|
71
|
+
webhooks: ['pingroom:webhooks:read', 'pingroom:webhooks:write', 'pingroom:webhooks:delete'],
|
|
72
|
+
attachment: ['pingroom:notifications:read', 'pingroom:attachments:write'],
|
|
73
|
+
// Local-only: no agent-authenticated route, so nothing to request.
|
|
74
|
+
mcp: [],
|
|
75
|
+
skills: [],
|
|
76
|
+
config: [],
|
|
77
|
+
logout: [],
|
|
78
|
+
reconnect: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scopes a command needs that this credential was never granted.
|
|
83
|
+
*
|
|
84
|
+
* Returns [] whenever the answer is not knowable — no stored scope list at all
|
|
85
|
+
* (a PINGROOM_TOKEN in CI has none), or an unrecognized command. That is the
|
|
86
|
+
* whole contract: this is a courtesy that turns a 403 into a sentence naming
|
|
87
|
+
* the fix, never a gate. Refusing on unknown scopes would break every CI job
|
|
88
|
+
* holding a perfectly good token.
|
|
89
|
+
*/
|
|
90
|
+
export function missingScopesFor(command, grantedScopes) {
|
|
91
|
+
if (!Array.isArray(grantedScopes) || grantedScopes.length === 0) return [];
|
|
92
|
+
const needed = COMMAND_SCOPES[command];
|
|
93
|
+
if (!needed || needed.length === 0) return [];
|
|
94
|
+
return needed.filter((scope) => !grantedScopes.includes(scope));
|
|
95
|
+
}
|
package/lib/util.js
CHANGED
|
@@ -73,6 +73,24 @@ export function resolveWaitHold(args, { def, cap }) {
|
|
|
73
73
|
return Math.min(hold, cap);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Validate --idempotency-key and stamp it on the outgoing headers.
|
|
78
|
+
*
|
|
79
|
+
* Creating a human gate is the one CLI write a caller genuinely wants to replay
|
|
80
|
+
* after an ambiguous transport failure, so the key must survive a shell round
|
|
81
|
+
* trip intact: printable ASCII, no spaces. The server returns 409 if the same
|
|
82
|
+
* key is ever reused for a different payload.
|
|
83
|
+
*/
|
|
84
|
+
export function applyIdempotencyKey(args, headers) {
|
|
85
|
+
if (args.idempotency_key === undefined) return headers;
|
|
86
|
+
const key = String(args.idempotency_key);
|
|
87
|
+
if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
|
|
88
|
+
fail('--idempotency-key must be 1–255 printable ASCII characters without spaces', EXIT.USAGE);
|
|
89
|
+
}
|
|
90
|
+
headers['Idempotency-Key'] = key;
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
export function numberOption(raw, flag, { min, max, integer = false } = {}) {
|
|
77
95
|
if (raw === undefined) return undefined;
|
|
78
96
|
const value = Number(raw);
|