@openparachute/agent 0.1.0 → 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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +675 -21
- package/LICENSE-NANOCLAW-MIT +21 -0
- package/README.md +8 -1
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
- package/package.json +2 -1
- package/scripts/init-cli-agent.ts +2 -1
- package/scripts/init-first-agent.ts +2 -1
- package/scripts/seed-discord.ts +2 -1
- package/src/channels/api-translator.test.ts +306 -0
- package/src/channels/api-translator.ts +214 -0
- package/src/config.ts +23 -3
- package/src/container-runtime.test.ts +101 -1
- package/src/container-runtime.ts +76 -1
- package/src/db/connection.migrate.test.ts +35 -2
- package/src/db/connection.ts +40 -5
- package/src/index.ts +6 -1
- package/src/mcp/tools/channels.test.ts +126 -0
- package/src/mcp/tools/channels.ts +33 -98
- package/src/modules/mount-security/expand-path.test.ts +82 -0
- package/src/modules/mount-security/index.ts +21 -10
- package/src/modules/permissions/sender-approval.test.ts +171 -0
- package/src/secrets/index.ts +127 -21
- package/src/secrets/secrets.test.ts +301 -4
- package/src/session-manager.attachments.test.ts +171 -0
- package/src/session-manager.dup-skip.test.ts +173 -0
- package/src/session-manager.ts +22 -4
- package/src/types.ts +4 -1
- package/src/web/routes/channels-mga-detail.test.ts +49 -2
- package/src/web/routes/channels.ts +25 -203
- package/src/web/routes/secrets.test.ts +46 -1
- package/src/web/routes/secrets.ts +35 -0
- package/src/web/server.ts +34 -13
- package/src/web/services-manifest.test.ts +37 -9
- package/src/web/services-manifest.ts +14 -9
- package/web/ui/index.html +2 -2
- package/web/ui/src/App.tsx +1 -1
- package/web/ui/src/lib/api.test.ts +2 -2
- package/web/ui/src/lib/api.ts +40 -2
- package/web/ui/src/lib/auth.test.ts +214 -1
- package/web/ui/src/lib/auth.ts +79 -22
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
- package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
- package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
- package/web/ui/src/routes/GroupDetail.tsx +126 -1
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
- package/web/ui/src/routes/SecretsList.tsx +22 -1
- 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
|
|
76
|
-
// The
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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
|
-
|
|
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;
|
|
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].
|
|
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
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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>
|
|
7
|
-
<meta name="description" content="Manage Parachute Vault access for your
|
|
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>
|
package/web/ui/src/App.tsx
CHANGED
|
@@ -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/
|
|
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: '
|
|
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: '
|
|
361
|
+
senderScope: 'unrestricted',
|
|
362
362
|
ignoredMessagePolicy: 'drop',
|
|
363
363
|
priority: 0,
|
|
364
364
|
createdAt: '2026-04-20T10:00:00Z',
|
package/web/ui/src/lib/api.ts
CHANGED
|
@@ -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.
|
|
996
|
-
|
|
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 {
|
|
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
|
+
});
|
package/web/ui/src/lib/auth.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* /api/sessions/:id/close. The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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), {
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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="
|
|
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>
|