@openparachute/agent 0.1.1 → 0.1.2

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
  3. package/package.json +1 -1
  4. package/scripts/init-cli-agent.ts +2 -1
  5. package/scripts/init-first-agent.ts +2 -1
  6. package/scripts/seed-discord.ts +2 -1
  7. package/src/channels/api-translator.test.ts +306 -0
  8. package/src/channels/api-translator.ts +214 -0
  9. package/src/config.ts +23 -3
  10. package/src/container-runtime.test.ts +101 -1
  11. package/src/container-runtime.ts +76 -1
  12. package/src/db/connection.migrate.test.ts +35 -2
  13. package/src/db/connection.ts +40 -5
  14. package/src/index.ts +6 -1
  15. package/src/mcp/tools/channels.test.ts +126 -0
  16. package/src/mcp/tools/channels.ts +33 -98
  17. package/src/modules/mount-security/expand-path.test.ts +82 -0
  18. package/src/modules/mount-security/index.ts +21 -10
  19. package/src/modules/permissions/sender-approval.test.ts +171 -0
  20. package/src/secrets/index.ts +127 -21
  21. package/src/secrets/secrets.test.ts +301 -4
  22. package/src/session-manager.attachments.test.ts +171 -0
  23. package/src/session-manager.dup-skip.test.ts +173 -0
  24. package/src/session-manager.ts +22 -4
  25. package/src/types.ts +4 -1
  26. package/src/web/routes/channels-mga-detail.test.ts +49 -2
  27. package/src/web/routes/channels.ts +25 -203
  28. package/src/web/routes/secrets.test.ts +46 -1
  29. package/src/web/routes/secrets.ts +35 -0
  30. package/src/web/server.ts +34 -13
  31. package/src/web/services-manifest.test.ts +37 -9
  32. package/src/web/services-manifest.ts +14 -9
  33. package/web/ui/index.html +2 -2
  34. package/web/ui/src/App.tsx +1 -1
  35. package/web/ui/src/lib/api.test.ts +2 -2
  36. package/web/ui/src/lib/api.ts +40 -2
  37. package/web/ui/src/lib/auth.test.ts +214 -1
  38. package/web/ui/src/lib/auth.ts +79 -22
  39. package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
  40. package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
  41. package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
  42. package/web/ui/src/routes/GroupDetail.tsx +126 -1
  43. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
  44. package/web/ui/src/routes/SecretsList.tsx +22 -1
  45. package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
@@ -36,6 +36,7 @@ describe('upsertService', () => {
36
36
  version: '0.0.6-rc.1',
37
37
  displayName: 'Parachute Agent',
38
38
  tagline: 'Manage your Parachute agent groups + vault attachments.',
39
+ installDir: '/Users/test/parachute-agent',
39
40
  },
40
41
  path,
41
42
  );
@@ -50,11 +51,35 @@ describe('upsertService', () => {
50
51
  version: '0.0.6-rc.1',
51
52
  displayName: 'Parachute Agent',
52
53
  tagline: 'Manage your Parachute agent groups + vault attachments.',
54
+ installDir: '/Users/test/parachute-agent',
53
55
  },
54
56
  ],
55
57
  });
56
58
  });
57
59
 
60
+ it('self-registers installDir so hub can resolve `parachute restart agent`', () => {
61
+ // Regression for paraclaw#115: pre-fix the agent registered without
62
+ // installDir, so hub's third-party lifecycle resolution path (parachute-
63
+ // hub#84) couldn't find a startCmd target and `parachute restart agent`
64
+ // dead-ended. Self-registering the field here is the proper fix —
65
+ // hub#177's graceful-degradation is the safety net.
66
+ upsertService(
67
+ {
68
+ name: 'agent',
69
+ port: 1944,
70
+ paths: ['/agent'],
71
+ health: '/api/health',
72
+ version: '0.1.2-rc.2',
73
+ installDir: '/Users/test/parachute-agent',
74
+ },
75
+ path,
76
+ );
77
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as {
78
+ services: { name: string; installDir?: string }[];
79
+ };
80
+ expect(raw.services[0].installDir).toBe('/Users/test/parachute-agent');
81
+ });
82
+
58
83
  it('replaces an existing entry with the same name in-place', () => {
59
84
  upsertService({ name: 'agent', port: 1944, paths: ['/agent'], health: '/api/health', version: 'a' }, path);
60
85
  upsertService({ name: 'agent', port: 1944, paths: ['/agent'], health: '/api/health', version: 'b' }, path);
@@ -72,12 +97,15 @@ describe('upsertService', () => {
72
97
  expect(raw.services.map((s) => s.name).sort()).toEqual(['agent', 'vault']);
73
98
  });
74
99
 
75
- it('preserves hub-stamped fields on the row (e.g. installDir from parachute-hub#84)', () => {
76
- // The hub stamps `installDir` onto the row at install time. Paraclaw's
77
- // self-registration row shape doesn't know about that field, but the
78
- // upsert must merge rather than replace so the hub-stamped value
79
- // survives the second write otherwise `parachute start agent` after
80
- // an auto-start round-trip can't resolve installDir "unknown service".
100
+ it('preserves fields not in the new entry on the row (merge, not replace)', () => {
101
+ // The agent now self-registers `installDir` (paraclaw#115), but the
102
+ // merge-not-replace behavior is still load-bearing for any field hub
103
+ // stamps that the agent's entry doesn't carry. `publicExposure` is the
104
+ // realistic case: it's a real first-party-schema field on hub's side
105
+ // (set when the operator runs `parachute expose`) but absent from
106
+ // paraclaw's `ServiceEntry` — so a self-registration round trip must
107
+ // not drop it. Spread order is `{ ...existing, ...entry }`, so the
108
+ // agent still wins on the fields it owns.
81
109
  writeFileSync(
82
110
  path,
83
111
  JSON.stringify({
@@ -88,7 +116,7 @@ describe('upsertService', () => {
88
116
  paths: ['/agent'],
89
117
  health: '/api/health',
90
118
  version: '0.0.7-rc.1',
91
- installDir: '/Users/test/.parachute/agent',
119
+ publicExposure: 'loopback',
92
120
  },
93
121
  ],
94
122
  }),
@@ -104,11 +132,11 @@ describe('upsertService', () => {
104
132
  path,
105
133
  );
106
134
  const raw = JSON.parse(readFileSync(path, 'utf8')) as {
107
- services: { version: string; installDir?: string }[];
135
+ services: { version: string; publicExposure?: string }[];
108
136
  };
109
137
  expect(raw.services).toHaveLength(1);
110
138
  expect(raw.services[0].version).toBe('0.0.8-rc.1');
111
- expect(raw.services[0].installDir).toBe('/Users/test/.parachute/agent');
139
+ expect(raw.services[0].publicExposure).toBe('loopback');
112
140
  });
113
141
 
114
142
  it('throws on a malformed existing manifest (so we never silently overwrite)', () => {
@@ -4,14 +4,14 @@
4
4
  * Mirrors `parachute-scribe/src/services-manifest.ts` deliberately — the
5
5
  * shape is the contract between every Parachute service and the hub
6
6
  * (`parachute-hub/src/services-manifest.ts` is the canonical reader).
7
- * Once paraclaw earns a slot in the hub's vendored fallback, the
8
- * manifest read flips to `.parachute/module.json`-backed and this file
9
- * becomes the live state-side companion. Until then, this is what makes
10
- * `parachute status` / `parachute expose` see paraclaw at all.
11
- *
12
7
  * Failure mode: any write error is logged + swallowed. Self-registration
13
8
  * is best-effort — the server still serves locally even if the manifest
14
9
  * write fails (permissions, disk full, race with another writer).
10
+ *
11
+ * `installDir` is the third-party-module hook (parachute-hub#84): hub
12
+ * looks the field up to resolve `parachute restart agent` back to the
13
+ * checkout it should drive. Self-registering it here means the agent
14
+ * doesn't need a vendored fallback in hub — paraclaw#115.
15
15
  */
16
16
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
@@ -26,6 +26,7 @@ export interface ServiceEntry {
26
26
  version: string;
27
27
  displayName?: string;
28
28
  tagline?: string;
29
+ installDir?: string;
29
30
  }
30
31
 
31
32
  interface ServicesManifest {
@@ -49,10 +50,14 @@ export function upsertService(entry: ServiceEntry, path: string = resolveManifes
49
50
  mkdirSync(dirname(path), { recursive: true });
50
51
  const manifest = readManifest(path);
51
52
  const idx = manifest.services.findIndex((s) => s.name === entry.name);
52
- // Merge rather than replace so fields the hub stamps onto the row
53
- // (`installDir` from parachute-hub#84, etc.) survive a self-registration
54
- // pass. Paraclaw still wins for the fields it owns port, paths,
55
- // version, health because they spread last.
53
+ // Merge rather than replace, intentionally diverging from
54
+ // `parachute-hub/src/services-manifest.ts` which full-replaces the row.
55
+ // The asymmetry tracks who's authoritative: hub owns the first-party
56
+ // shape (read → schema-validate → write), so it can replace safely;
57
+ // we're a third-party self-registrant preserving any fields hub stamps
58
+ // that we don't own (the hub#84 `installDir` slot, future hub-stamped
59
+ // metadata). The agent still wins for the fields it owns — port, paths,
60
+ // version, health, installDir — because `entry` spreads last.
56
61
  if (idx >= 0) manifest.services[idx] = { ...manifest.services[idx], ...entry };
57
62
  else manifest.services.push(entry);
58
63
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
package/web/ui/index.html CHANGED
@@ -3,8 +3,8 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Paraclaw</title>
7
- <meta name="description" content="Manage Parachute Vault access for your Paraclaw agents." />
6
+ <title>Parachute Agent</title>
7
+ <meta name="description" content="Manage Parachute Vault access for your Parachute Agent groups." />
8
8
  </head>
9
9
  <body>
10
10
  <div id="root"></div>
@@ -49,7 +49,7 @@ export function App() {
49
49
  <Link to="/settings/approvals">Settings</Link>
50
50
  <Link to="/setup">Setup</Link>
51
51
  <a
52
- href="https://github.com/ParachuteComputer/paraclaw/blob/main/docs/parachute-integration.md"
52
+ href="https://github.com/ParachuteComputer/parachute-agent/blob/main/docs/parachute-integration.md"
53
53
  target="_blank"
54
54
  rel="noreferrer"
55
55
  >
@@ -289,7 +289,7 @@ describe('getMessagingGroupDetail — happy path', () => {
289
289
  agentGroupName: 'Main',
290
290
  engageMode: 'mention',
291
291
  engagePattern: null,
292
- senderScope: 'all',
292
+ senderScope: 'unrestricted',
293
293
  ignoredMessagePolicy: 'drop',
294
294
  priority: 0,
295
295
  createdAt: '2026-04-20T10:00:00Z',
@@ -358,7 +358,7 @@ describe('channel-wire helpers — pinned to /channels/mga/:id', () => {
358
358
  agentGroupName: 'Main',
359
359
  engageMode: 'mention',
360
360
  engagePattern: null,
361
- senderScope: 'all',
361
+ senderScope: 'unrestricted',
362
362
  ignoredMessagePolicy: 'drop',
363
363
  priority: 0,
364
364
  createdAt: '2026-04-20T10:00:00Z',
@@ -57,6 +57,12 @@ export interface AgentGroupView {
57
57
  name: string;
58
58
  folder: string;
59
59
  agent_provider: string | null;
60
+ /**
61
+ * Per-group secret injection policy. `all` = every in-scope secret lands in
62
+ * the container; `selective` = only those with an explicit assignment row.
63
+ * Surfaced for the GroupDetail "Secrets" panel context (paraclaw#104).
64
+ */
65
+ secret_mode: 'all' | 'selective';
60
66
  created_at: string;
61
67
  vault: VaultAttachment | null;
62
68
  status: GroupStatus | null;
@@ -777,6 +783,36 @@ export async function listStaleSessionsForSecret(secretId: string): Promise<Stal
777
783
  return r.staleSessions;
778
784
  }
779
785
 
786
+ /**
787
+ * What the agent group will receive at the next session spawn — the wire
788
+ * mirror of `resolveInjectableSecrets()` on the host. Powers the GroupDetail
789
+ * "Secrets" panel (paraclaw#104). Metadata only; values never traverse this
790
+ * surface.
791
+ *
792
+ * `scope` explains *why* the row is included:
793
+ * - `scoped` — owned by this group
794
+ * - `assigned` — global, with an explicit assignment row → this group
795
+ * - `global` — global, included only because the group is `mode='all'`
796
+ */
797
+ export type SecretInclusionScope = 'global' | 'scoped' | 'assigned';
798
+
799
+ export interface InjectableSecretView {
800
+ id: string;
801
+ name: string;
802
+ kind: SecretKind;
803
+ agentGroupId: string | null;
804
+ scope: SecretInclusionScope;
805
+ createdAt: string;
806
+ updatedAt: string;
807
+ }
808
+
809
+ export async function listGroupInjectableSecrets(folder: string): Promise<InjectableSecretView[]> {
810
+ const r = await request<{ secrets: InjectableSecretView[] }>(
811
+ `/groups/${encodeURIComponent(folder)}/secrets`,
812
+ );
813
+ return r.secrets;
814
+ }
815
+
780
816
  // --- Approvals ---
781
817
 
782
818
  export type ApprovalKind = 'install_packages' | 'add_mcp_server' | 'access-new-credential' | string;
@@ -992,8 +1028,10 @@ export type ChannelKind = 'discord' | 'telegram' | 'cli';
992
1028
 
993
1029
  // Wire vocabulary. See dbToApi* in src/web/routes/channels.ts for DB equivalents in src/types.ts.
994
1030
  export type EngageMode = 'mention' | 'pattern' | 'all';
995
- // Wire vocabulary. See dbToApi* in src/web/routes/channels.ts for DB equivalents in src/types.ts.
996
- export type SenderScope = 'allowlist' | 'all';
1031
+ // Wire vocabulary. The wire `'unrestricted'` corresponds to the DB
1032
+ // `'all'` (paraclaw#94 kept literal-disjoint so a grep-refactor can't
1033
+ // conflate the two unions through the translator).
1034
+ export type SenderScope = 'allowlist' | 'unrestricted';
997
1035
  // Wire vocabulary. See dbToApi* in src/web/routes/channels.ts for DB equivalents in src/types.ts.
998
1036
  export type IgnoredMessagePolicy = 'drop' | 'silent';
999
1037
 
@@ -10,7 +10,12 @@
10
10
  */
11
11
  import { beforeEach, describe, expect, it, vi } from 'vitest';
12
12
 
13
- import { migrateLegacyAuthKeys } from './auth.ts';
13
+ import {
14
+ buildAuthorizeUrl,
15
+ ensureClient,
16
+ migrateLegacyAuthKeys,
17
+ REQUESTED_SCOPES,
18
+ } from './auth.ts';
14
19
 
15
20
  // jsdom's Storage in this vitest config doesn't reliably expose the full
16
21
  // Storage prototype methods (the `--localstorage-file` warning at runtime
@@ -137,3 +142,211 @@ describe('migrateLegacyAuthKeys', () => {
137
142
  expect(localStorage.getItem('parachute-agent.discovery')).toBe('{"hubOrigin":"http://hub"}');
138
143
  });
139
144
  });
145
+
146
+ /**
147
+ * Bootstrap-scope narrowing — paraclaw#136. The agent SPA used to request
148
+ * `vault:read vault:write` at bootstrap, but every vault flow already runs
149
+ * the paraclaw#56 re-consent pattern (narrow `vault:<name>:admin` via
150
+ * extraScopes), so the broad bootstrap scopes were dead weight on the
151
+ * consent screen. These tests pin the post-narrowing surface so a future
152
+ * edit can't silently re-add vault scopes to the bootstrap grant.
153
+ */
154
+ describe('REQUESTED_SCOPES + buildAuthorizeUrl', () => {
155
+ const baseOpts = {
156
+ hubOrigin: 'http://hub.test',
157
+ clientId: 'client-abc',
158
+ redirectUri: 'http://app.test/agent/oauth/callback',
159
+ challenge: 'challenge-xyz',
160
+ state: 'state-123',
161
+ };
162
+
163
+ it('REQUESTED_SCOPES is exactly "agent:admin agent:write" — no vault:* at bootstrap', () => {
164
+ expect(REQUESTED_SCOPES).toBe('agent:admin agent:write');
165
+ expect(REQUESTED_SCOPES).not.toMatch(/vault:/);
166
+ });
167
+
168
+ it('builds an authorize URL with only agent:* scopes when extraScopes is empty', () => {
169
+ const u = buildAuthorizeUrl(baseOpts);
170
+ expect(u.searchParams.get('scope')).toBe('agent:admin agent:write');
171
+ // Belt-and-suspenders: the URL string itself must not carry any
172
+ // vault scope, even URL-encoded.
173
+ expect(u.toString()).not.toMatch(/vault(%3A|:)/);
174
+ });
175
+
176
+ it('appends a narrow vault:<name>:admin scope when passed in extraScopes', () => {
177
+ const u = buildAuthorizeUrl({ ...baseOpts, extraScopes: ['vault:default:admin'] });
178
+ const scope = u.searchParams.get('scope') ?? '';
179
+ expect(scope.split(' ')).toEqual(['agent:admin', 'agent:write', 'vault:default:admin']);
180
+ });
181
+
182
+ it('de-dupes extraScopes that are already in REQUESTED_SCOPES', () => {
183
+ const u = buildAuthorizeUrl({
184
+ ...baseOpts,
185
+ extraScopes: ['agent:admin', 'vault:foo:admin'],
186
+ });
187
+ const scope = u.searchParams.get('scope') ?? '';
188
+ // agent:admin should appear exactly once even though it was passed
189
+ // again — the consent screen would otherwise show the same scope twice.
190
+ expect(scope.split(' ').filter((s) => s === 'agent:admin')).toHaveLength(1);
191
+ expect(scope.split(' ')).toContain('vault:foo:admin');
192
+ });
193
+
194
+ it('writes the standard PKCE-S256 query params alongside the scope', () => {
195
+ const u = buildAuthorizeUrl(baseOpts);
196
+ expect(u.origin + u.pathname).toBe('http://hub.test/oauth/authorize');
197
+ expect(u.searchParams.get('client_id')).toBe('client-abc');
198
+ expect(u.searchParams.get('redirect_uri')).toBe('http://app.test/agent/oauth/callback');
199
+ expect(u.searchParams.get('response_type')).toBe('code');
200
+ expect(u.searchParams.get('code_challenge')).toBe('challenge-xyz');
201
+ expect(u.searchParams.get('code_challenge_method')).toBe('S256');
202
+ expect(u.searchParams.get('state')).toBe('state-123');
203
+ });
204
+ });
205
+
206
+ /**
207
+ * Regression-pin the OAuth client_name in the `/oauth/register` body —
208
+ * paraclaw#137. The hub's consent screen renders this string verbatim, so
209
+ * it's operator-visible UX, not a free-form internal identifier. The
210
+ * 0.1.0 brand sweep renamed "Paraclaw web UI" → "Parachute Agent web UI"
211
+ * (commit 2a83e77 / PR #112); this test prevents a future rename or copy
212
+ * regression from silently shipping a stale brand on the consent screen.
213
+ *
214
+ * No production behavior change — the existing string literal at line ~166
215
+ * is the only thing this test asserts on.
216
+ */
217
+ describe('ensureClient — /oauth/register body', () => {
218
+ it('sends client_name "Parachute Agent web UI" on first registration', async () => {
219
+ const fetchSpy = vi.fn().mockResolvedValue({
220
+ ok: true,
221
+ json: async () => ({ client_id: 'returned-client-id' }),
222
+ });
223
+ vi.stubGlobal('fetch', fetchSpy);
224
+
225
+ const id = await ensureClient('http://hub.test');
226
+
227
+ expect(id).toBe('returned-client-id');
228
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
229
+ const [url, init] = fetchSpy.mock.calls[0];
230
+ expect(url).toBe('http://hub.test/oauth/register');
231
+ expect(init.method).toBe('POST');
232
+ const body = JSON.parse(init.body as string);
233
+ expect(body.client_name).toBe('Parachute Agent web UI');
234
+ // Belt: also pin scope + auth method so a future copy edit can't ship
235
+ // a partially-renamed body.
236
+ expect(body.scope).toBe(REQUESTED_SCOPES);
237
+ expect(body.token_endpoint_auth_method).toBe('none');
238
+ });
239
+
240
+ it('reuses the cached client_id when redirect_uri matches the current bootstrap', async () => {
241
+ // Pre-seed a record whose redirect_uri matches what getRedirectUri()
242
+ // computes in this test environment (same logic as the prod helper:
243
+ // origin + BASE_URL + 'oauth/callback'). Cache hit ⇒ no fetch.
244
+ const expectedRedirect = `${window.location.origin}${import.meta.env.BASE_URL}oauth/callback`;
245
+ localStorage.setItem(
246
+ 'parachute-agent.client.http://hub.test',
247
+ JSON.stringify({ client_id: 'cached-client-id', redirect_uri: expectedRedirect }),
248
+ );
249
+ const fetchSpy = vi.fn();
250
+ vi.stubGlobal('fetch', fetchSpy);
251
+
252
+ const id = await ensureClient('http://hub.test');
253
+
254
+ expect(id).toBe('cached-client-id');
255
+ expect(fetchSpy).not.toHaveBeenCalled();
256
+ });
257
+ });
258
+
259
+ /**
260
+ * Re-register the OAuth client when the SPA's redirect_uri changes —
261
+ * paraclaw#138. The hub binds each DCR client_id to the redirect_uri it
262
+ * was registered with; if the operator changes the SPA's mount path
263
+ * (e.g. `/claw/` → `/agent/` after the 0.1.0 rename, or any custom
264
+ * `PARACHUTE_AGENT_WEB_MOUNT` change), the cached client_id stops
265
+ * matching and `/oauth/authorize` errors out before the consent screen.
266
+ *
267
+ * Fix: cache the redirect_uri alongside the client_id and treat any
268
+ * mismatch (or a legacy record with no redirect_uri at all) as a cache
269
+ * miss so the SPA registers a fresh client_id under the new path.
270
+ */
271
+ describe('ensureClient — redirect_uri-aware cache', () => {
272
+ function mockFetchOk(clientId: string) {
273
+ const fetchSpy = vi.fn().mockResolvedValue({
274
+ ok: true,
275
+ json: async () => ({ client_id: clientId }),
276
+ });
277
+ vi.stubGlobal('fetch', fetchSpy);
278
+ return fetchSpy;
279
+ }
280
+
281
+ it('re-registers when the cached redirect_uri does not match the current one', async () => {
282
+ // Stale cache from before a mount-path change: redirect_uri points
283
+ // at the old path. Current bootstrap computes a different URI, so
284
+ // the cached client_id is unusable on the hub.
285
+ localStorage.setItem(
286
+ 'parachute-agent.client.http://hub.test',
287
+ JSON.stringify({
288
+ client_id: 'stale-client-id',
289
+ redirect_uri: 'http://app.test/claw/oauth/callback',
290
+ }),
291
+ );
292
+ const fetchSpy = mockFetchOk('fresh-client-id');
293
+
294
+ const id = await ensureClient('http://hub.test');
295
+
296
+ expect(id).toBe('fresh-client-id');
297
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
298
+ // The cache must now hold the freshly-registered client_id paired
299
+ // with the *current* redirect_uri, not the stale one — otherwise
300
+ // the next bootstrap would re-register on every page load.
301
+ const updated = JSON.parse(
302
+ localStorage.getItem('parachute-agent.client.http://hub.test') ?? 'null',
303
+ );
304
+ expect(updated.client_id).toBe('fresh-client-id');
305
+ const expectedRedirect = `${window.location.origin}${import.meta.env.BASE_URL}oauth/callback`;
306
+ expect(updated.redirect_uri).toBe(expectedRedirect);
307
+ });
308
+
309
+ it('re-registers a legacy ClientRecord that lacks a redirect_uri field (self-heals)', async () => {
310
+ // Records written before paraclaw#138 had only `{ client_id }`. On
311
+ // the first bootstrap after upgrade we treat the missing-field case
312
+ // as a cache miss and re-register so subsequent loads have the full
313
+ // record. This means existing operators see exactly one extra
314
+ // registration round-trip on first 0.1.x reload.
315
+ localStorage.setItem(
316
+ 'parachute-agent.client.http://hub.test',
317
+ JSON.stringify({ client_id: 'legacy-client-id' }),
318
+ );
319
+ const fetchSpy = mockFetchOk('healed-client-id');
320
+
321
+ const id = await ensureClient('http://hub.test');
322
+
323
+ expect(id).toBe('healed-client-id');
324
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
325
+ const healed = JSON.parse(
326
+ localStorage.getItem('parachute-agent.client.http://hub.test') ?? 'null',
327
+ );
328
+ expect(healed.client_id).toBe('healed-client-id');
329
+ expect(typeof healed.redirect_uri).toBe('string');
330
+ expect(healed.redirect_uri.length).toBeGreaterThan(0);
331
+ });
332
+
333
+ it('persists redirect_uri alongside client_id on first registration', async () => {
334
+ // No prior cache. After first registration, the persisted record
335
+ // must include both fields — otherwise subsequent bootstraps would
336
+ // legacy-self-heal on every load and burn a hub /oauth/register
337
+ // call per page reload.
338
+ const fetchSpy = mockFetchOk('first-client-id');
339
+
340
+ await ensureClient('http://hub.test');
341
+
342
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
343
+ const persisted = JSON.parse(
344
+ localStorage.getItem('parachute-agent.client.http://hub.test') ?? 'null',
345
+ );
346
+ expect(persisted).toMatchObject({
347
+ client_id: 'first-client-id',
348
+ });
349
+ const expectedRedirect = `${window.location.origin}${import.meta.env.BASE_URL}oauth/callback`;
350
+ expect(persisted.redirect_uri).toBe(expectedRedirect);
351
+ });
352
+ });
@@ -11,13 +11,18 @@
11
11
  * 5. POST <hub>/oauth/token — authorization_code, then refresh_token
12
12
  * on 401 from /api/* before re-login.
13
13
  *
14
- * Scopes: `agent:admin agent:write vault:read vault:write`. `agent:admin` is
15
- * required for /api/secrets writes + the setup wizard install-channel
16
- * step; `agent:write` is the bar for /api/approvals decisions and
17
- * /api/sessions/:id/close. The vault scopes anticipate the vault tokens-API
18
- * REST endpoint (paraclaw#4 companion vault issue); today the server still
19
- * shells out, but minting the user JWT with vault:* now means no re-consent
20
- * later.
14
+ * Bootstrap scopes: `agent:admin agent:write`. `agent:admin` is required
15
+ * for /api/secrets writes + the setup wizard install-channel step;
16
+ * `agent:write` is the bar for /api/approvals decisions and
17
+ * /api/sessions/:id/close. The agent SPA is self-contained vault is one
18
+ * per-agent-group action, not the SPA's identity, so vault scopes are
19
+ * NOT requested at bootstrap. Per-vault flows extend with `extraScopes`
20
+ * on `beginLogin([\`vault:\${name}:admin\`])` (see line 199-200) — this is
21
+ * the paraclaw#56 re-consent pattern, request-on-demand. (paraclaw#136)
22
+ *
23
+ * Vault listing for pickers reads `/.well-known/parachute.json` (public,
24
+ * no scope needed); per-vault token-API access enforces `vault:<name>:admin`
25
+ * on the vault side regardless of what the bootstrap JWT carries.
21
26
  *
22
27
  * Existing users with cached tokens that lack a newly-required scope will
23
28
  * hit 403 with a body like `requires the X scope`. The api.ts wrapper
@@ -31,6 +36,17 @@ interface DiscoveryResponse {
31
36
 
32
37
  interface ClientRecord {
33
38
  client_id: string;
39
+ /**
40
+ * The redirect_uri the client_id was registered with. Hub-side, the
41
+ * DCR row is bound to the original redirect_uri — sending an authorize
42
+ * request with a different redirect_uri (e.g. after a mount-path
43
+ * change like `/claw/` → `/agent/`) fails the hub's redirect_uri
44
+ * check and the operator sees a hub error instead of the consent
45
+ * screen. Stash this alongside the client_id so bootstrap can detect
46
+ * the mismatch and re-register a fresh client_id with the new path.
47
+ * (paraclaw#138)
48
+ */
49
+ redirect_uri: string;
34
50
  }
35
51
 
36
52
  interface TokenSet {
@@ -46,7 +62,7 @@ interface FlowState {
46
62
  hub_origin: string;
47
63
  }
48
64
 
49
- const REQUESTED_SCOPES = "agent:admin agent:write vault:read vault:write";
65
+ export const REQUESTED_SCOPES = "agent:admin agent:write";
50
66
  const DISCOVERY_KEY = "parachute-agent.discovery";
51
67
  const FLOW_KEY = "parachute-agent.flow";
52
68
 
@@ -148,10 +164,20 @@ async function getDiscovery(): Promise<DiscoveryResponse> {
148
164
  return fresh;
149
165
  }
150
166
 
151
- async function ensureClient(hubOrigin: string): Promise<string> {
152
- const cached = readJson<ClientRecord>(localStorage, clientKey(hubOrigin));
153
- if (cached?.client_id) return cached.client_id;
167
+ export async function ensureClient(hubOrigin: string): Promise<string> {
154
168
  const redirectUri = getRedirectUri();
169
+ const cached = readJson<ClientRecord>(localStorage, clientKey(hubOrigin));
170
+ // Cache hit only if BOTH client_id is present AND the cached
171
+ // redirect_uri matches the current bootstrap's redirect_uri. A mismatch
172
+ // means the SPA's mount path changed (e.g. `/claw/` → `/agent/`) and
173
+ // the hub-side DCR row is bound to the old redirect_uri — re-using the
174
+ // stale client_id would fail the hub's redirect_uri check on
175
+ // /oauth/authorize. Treat as cache-miss → re-register a fresh client.
176
+ // Records written before this guard landed lack `redirect_uri`; treat
177
+ // those as miss so the migration self-heals. (paraclaw#138)
178
+ if (cached?.client_id && cached.redirect_uri === redirectUri) {
179
+ return cached.client_id;
180
+ }
155
181
  const res = await fetch(`${hubOrigin}/oauth/register`, {
156
182
  method: "POST",
157
183
  headers: { "content-type": "application/json", accept: "application/json" },
@@ -166,7 +192,10 @@ async function ensureClient(hubOrigin: string): Promise<string> {
166
192
  throw new Error(`hub /oauth/register failed: ${res.status} ${await res.text()}`);
167
193
  }
168
194
  const body = (await res.json()) as { client_id: string };
169
- writeJson(localStorage, clientKey(hubOrigin), { client_id: body.client_id });
195
+ writeJson(localStorage, clientKey(hubOrigin), {
196
+ client_id: body.client_id,
197
+ redirect_uri: redirectUri,
198
+ });
170
199
  return body.client_id;
171
200
  }
172
201
 
@@ -217,23 +246,51 @@ export async function beginLogin(extraScopes: string[] = []): Promise<never> {
217
246
  hub_origin: hubOrigin,
218
247
  };
219
248
  writeJson(sessionStorage, FLOW_KEY, flow);
249
+ const u = buildAuthorizeUrl({
250
+ hubOrigin,
251
+ clientId,
252
+ redirectUri,
253
+ challenge,
254
+ state,
255
+ extraScopes,
256
+ });
257
+ window.location.replace(u.toString());
258
+ // Block until navigation actually happens — callers expect this never
259
+ // returns control.
260
+ return new Promise<never>(() => {});
261
+ }
262
+
263
+ /**
264
+ * Build the `/oauth/authorize` URL. Pure — no side effects, no storage
265
+ * reads, no network. Exported so tests can pin the scope string and the
266
+ * extraScopes append behavior without mocking `window.location.replace`.
267
+ *
268
+ * `extraScopes` are appended after `REQUESTED_SCOPES` and de-duped, so a
269
+ * caller passing a scope already in REQUESTED_SCOPES doesn't double it on
270
+ * the consent screen.
271
+ */
272
+ export function buildAuthorizeUrl(opts: {
273
+ hubOrigin: string;
274
+ clientId: string;
275
+ redirectUri: string;
276
+ challenge: string;
277
+ state: string;
278
+ extraScopes?: string[];
279
+ }): URL {
220
280
  const scopes = new Set(REQUESTED_SCOPES.split(/\s+/).filter(Boolean));
221
- for (const s of extraScopes) {
281
+ for (const s of opts.extraScopes ?? []) {
222
282
  const trimmed = s.trim();
223
283
  if (trimmed) scopes.add(trimmed);
224
284
  }
225
- const u = new URL(`${hubOrigin}/oauth/authorize`);
226
- u.searchParams.set("client_id", clientId);
227
- u.searchParams.set("redirect_uri", redirectUri);
285
+ const u = new URL(`${opts.hubOrigin}/oauth/authorize`);
286
+ u.searchParams.set("client_id", opts.clientId);
287
+ u.searchParams.set("redirect_uri", opts.redirectUri);
228
288
  u.searchParams.set("response_type", "code");
229
289
  u.searchParams.set("scope", Array.from(scopes).join(" "));
230
- u.searchParams.set("code_challenge", challenge);
290
+ u.searchParams.set("code_challenge", opts.challenge);
231
291
  u.searchParams.set("code_challenge_method", "S256");
232
- u.searchParams.set("state", state);
233
- window.location.replace(u.toString());
234
- // Block until navigation actually happens — callers expect this never
235
- // returns control.
236
- return new Promise<never>(() => {});
292
+ u.searchParams.set("state", opts.state);
293
+ return u;
237
294
  }
238
295
 
239
296
  /**
@@ -55,7 +55,7 @@ const baseWire: api.ChannelWireView = {
55
55
  agentGroupName: 'Main agent',
56
56
  engageMode: 'mention',
57
57
  engagePattern: null,
58
- senderScope: 'all',
58
+ senderScope: 'unrestricted',
59
59
  ignoredMessagePolicy: 'drop',
60
60
  priority: 0,
61
61
  createdAt: '2026-04-20T10:00:00Z',
@@ -136,7 +136,7 @@ describe('ChannelWireDetail — save', () => {
136
136
  expect(api.updateChannelWire).toHaveBeenCalledWith('mga_1', {
137
137
  engageMode: 'all',
138
138
  engagePattern: null,
139
- senderScope: 'all',
139
+ senderScope: 'unrestricted',
140
140
  ignoredMessagePolicy: 'drop',
141
141
  priority: 0,
142
142
  });
@@ -360,7 +360,7 @@ function RoutingRulesEditor({
360
360
  onChange={(e) => setSenderScope(e.target.value as SenderScope)}
361
361
  disabled={saving}
362
362
  >
363
- <option value="all">all — anyone in the thread</option>
363
+ <option value="unrestricted">unrestricted — anyone in the thread</option>
364
364
  <option value="allowlist">allowlist — only members of the agent group</option>
365
365
  </select>
366
366
  </div>