@pdpp/cli 0.1.0-beta.2 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,21 +4,64 @@ Command-line tools for PDPP providers.
4
4
 
5
5
  ## Status
6
6
 
7
- This package is the public npm home for the `pdpp` command. The beta CLI supports
8
- `pdpp connect <provider-url>` for delegated access: the agent runs the command,
9
- the owner approves scoped access in the browser, and the CLI stores scoped client
10
- credentials in the project-local `.pdpp/` cache without asking for an owner
11
- bearer token.
7
+ This package is the public npm home for the `pdpp` command. The beta CLI
8
+ supports three command namespaces:
9
+
10
+ - **`pdpp connect <provider-url>`** delegated access: discovers provider
11
+ metadata, self-registers a public client when the AS advertises dynamic
12
+ registration, asks the owner to approve scoped access in the browser, and
13
+ stores scoped client credentials in the project-local `.pdpp/` cache without
14
+ asking for an owner bearer token.
15
+
16
+ - **`pdpp collector <advertise|enroll|run>`** — operator surface for the
17
+ local collector runner. Pairs a host the operator controls (Claude Code or
18
+ Codex CLI data) with a remote PDPP reference deployment via device-scoped
19
+ enrollment, then runs connectors that the provider/control-plane container
20
+ cannot run on its own. The runner ships separately as
21
+ `@pdpp/local-collector` and owns the `pdpp-local-collector` binary; `pdpp
22
+ collector ...` is a slim `@pdpp/cli` shim that resolves that package lazily.
23
+ Public onboarding should use `npx -y @pdpp/local-collector ...` or
24
+ `npm i -g @pdpp/local-collector` unless the operator intentionally wants the
25
+ `@pdpp/cli` shim.
26
+
27
+ - **`pdpp ref ...`** — reference operator diagnostics over `_ref` routes on a
28
+ running reference deployment. Current subcommands: `pdpp ref run timeline
29
+ <run-id>`, `pdpp ref grant timeline <grant-id>`, `pdpp ref trace show
30
+ <trace-id>`. Requires `PDPP_OWNER_SESSION_COOKIE` when owner auth is enabled.
31
+ These are reference-only operator tools, not core PDPP protocol.
12
32
 
13
33
  ## Install
14
34
 
15
35
  ```bash
36
+ # @pdpp/cli package, npx-launched pdpp binary
16
37
  npx -y @pdpp/cli@beta --help
17
38
  ```
18
39
 
19
40
  Use the `beta` dist-tag until PDPP intentionally enables stable `latest`
20
41
  publication.
21
42
 
43
+ When working from this monorepo without installing or linking the binary, use
44
+ the workspace executable:
45
+
46
+ ```bash
47
+ # @pdpp/cli package, workspace-launched pdpp binary
48
+ pnpm exec pdpp ref run timeline <run-id>
49
+ ```
50
+
51
+ The public command surface is still the `pdpp` binary; `pnpm exec` is only the
52
+ local workspace launcher.
53
+
54
+ The local collector runtime is a separate public package:
55
+
56
+ ```bash
57
+ # @pdpp/local-collector package, npx-launched pdpp-local-collector binary
58
+ npx -y @pdpp/local-collector advertise
59
+
60
+ # @pdpp/local-collector package, installs the pdpp-local-collector binary
61
+ npm i -g @pdpp/local-collector
62
+ pdpp-local-collector advertise
63
+ ```
64
+
22
65
  ## Ownership And Publishing
23
66
 
24
67
  The intended npm scope is `@pdpp`, owned by the durable PDPP/Vana project
@@ -32,6 +75,7 @@ After the package exists on npm, configure the trusted publisher with npm CLI
32
75
  11.5.1+:
33
76
 
34
77
  ```bash
78
+ # npm trust command for the @pdpp/cli package publisher config
35
79
  npm trust github @pdpp/cli --repo vana-com/pdpp --file semantic-release.yml
36
80
  npm trust list @pdpp/cli
37
81
  ```
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@pdpp/cli",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "Command-line tools for PDPP providers.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "pdpp": "./bin/pdpp.js"
7
+ "pdpp": "bin/pdpp.js"
8
8
  },
9
9
  "exports": {
10
10
  ".": "./src/index.js",
@@ -5,13 +5,16 @@ export function getPdppCacheLayout(cacheRoot = '.pdpp') {
5
5
  return {
6
6
  root: cacheRoot,
7
7
  clientsDir: join(cacheRoot, 'clients'),
8
- grantsDir: join(cacheRoot, 'grants'),
9
- secretsDir: join(cacheRoot, 'secrets'),
10
- accessFile: join(cacheRoot, 'agent-access.json'),
11
- secretFile: (name) => join(cacheRoot, 'secrets', `${name}.secret`),
8
+ gitignoreFile: join(cacheRoot, '.gitignore'),
9
+ credentialFile: (providerUrl) => join(cacheRoot, 'clients', `${providerCacheKey(providerUrl)}.json`),
12
10
  };
13
11
  }
14
12
 
13
+ function providerCacheKey(providerUrl) {
14
+ const host = providerUrl.includes('://') ? new URL(providerUrl).host : providerUrl;
15
+ return host.replace(/[^a-zA-Z0-9.-]/g, '_');
16
+ }
17
+
15
18
  export function writePdppSecretFile(path, value) {
16
19
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
17
20
  writeFileSync(path, value, { mode: 0o600 });
@@ -0,0 +1,94 @@
1
+ import { PDPP_CLI_BIN_NAME } from '../package-info.js';
2
+ import { CollectorUsageError } from './errors.js';
3
+ import { spawnCollectorRunner } from './runner.js';
4
+
5
+ const COLLECTOR_HELP = `Local collector runner (reference operator surface).
6
+
7
+ Pair a host you control with a PDPP reference deployment, then run
8
+ filesystem-class or local-device connectors that the provider container
9
+ cannot run on its own. Runner-owned flags are documented by
10
+ "pdpp-local-collector --help" (see "Distribution" below).
11
+
12
+ Distribution:
13
+ The collector runtime ships in @pdpp/local-collector, a separate npm
14
+ package owned by the PDPP monorepo. @pdpp/cli stays slim and resolves
15
+ the runner lazily:
16
+ # @pdpp/local-collector package, installs pdpp-local-collector
17
+ npm i -g @pdpp/local-collector
18
+ # @pdpp/local-collector package, npx-launched pdpp-local-collector
19
+ npx -y @pdpp/local-collector advertise
20
+ See openspec/changes/publish-pdpp-local-collector/design.md.
21
+
22
+ Usage:
23
+ ${PDPP_CLI_BIN_NAME} collector advertise
24
+ ${PDPP_CLI_BIN_NAME} collector enroll --base-url <url> --code <one-time-code>
25
+ [--device-label <label>]
26
+ ${PDPP_CLI_BIN_NAME} collector run --base-url <url> --connector <id>
27
+ --device-id <id> --device-token <token>
28
+ --connection-id <id>
29
+ [--streams a,b,c]
30
+ [--backfill-streams attachments]
31
+ [--run-id <id>]
32
+
33
+ Suggested operator flow:
34
+ 1. Start the reference deployment somewhere reachable (e.g. Docker on a
35
+ server) so it has a base URL such as http://server.local:7662.
36
+ 2. Confirm runtime capabilities with:
37
+ ${PDPP_CLI_BIN_NAME} collector advertise
38
+ (@pdpp/cli pdpp shim, resolving @pdpp/local-collector)
39
+ The collector advertises network, filesystem, local_device
40
+ and reports collector_protocol_version.
41
+ 3. Mint an enrollment code from the dashboard or "pdpp ref" tooling,
42
+ then on the host with Claude/Codex data run:
43
+ ${PDPP_CLI_BIN_NAME} collector enroll --base-url <url> --code <code>
44
+ (@pdpp/cli pdpp shim, resolving @pdpp/local-collector)
45
+ The JSON response returns device_id, device_token, and
46
+ source_instance_id (the connection id for this local binding) —
47
+ persist all three to a secrets store. You will pass them back as
48
+ flags/env vars in step 4.
49
+ 4. Run a connector with:
50
+ PDPP_LOCAL_DEVICE_ID=<device_id> \\
51
+ PDPP_LOCAL_DEVICE_TOKEN=<device_token> \\
52
+ PDPP_CONNECTION_ID=<connection_id> \\
53
+ ${PDPP_CLI_BIN_NAME} collector run --base-url <url> \\
54
+ --connector claude_code
55
+ (@pdpp/cli pdpp shim, resolving @pdpp/local-collector)
56
+ Connectors that need bindings the collector does not advertise fail
57
+ before spawn with "runtime_capability_mismatch".
58
+
59
+ Notes:
60
+ Collector credentials are device-scoped; they cannot read records,
61
+ approve grants, or mint owner tokens. Do not log device tokens. See
62
+ openspec/changes/publish-pdpp-local-collector/design.md.
63
+ Required flags can also be supplied via PDPP_REFERENCE_BASE_URL,
64
+ PDPP_LOCAL_DEVICE_ID, PDPP_LOCAL_DEVICE_TOKEN, PDPP_CONNECTION_ID,
65
+ PDPP_RUN_ID. PDPP_SOURCE_INSTANCE_ID remains a compatibility alias.
66
+ `;
67
+
68
+ const SUBCOMMANDS = new Set(['advertise', 'enroll', 'run']);
69
+
70
+ export async function runCollector(argv, io) {
71
+ const [sub, ...rest] = argv;
72
+
73
+ if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
74
+ io.stdout.write(COLLECTOR_HELP);
75
+ return 0;
76
+ }
77
+
78
+ if (!SUBCOMMANDS.has(sub)) {
79
+ io.stderr.write(`Unknown collector subcommand: ${sub}\n\n${COLLECTOR_HELP}`);
80
+ return 64;
81
+ }
82
+
83
+ try {
84
+ return await spawnCollectorRunner(sub, rest);
85
+ } catch (error) {
86
+ if (error instanceof CollectorUsageError) {
87
+ io.stderr.write(`${error.message}\n`);
88
+ return error.exitCode;
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ export { COLLECTOR_HELP };
@@ -0,0 +1,7 @@
1
+ export class CollectorUsageError extends Error {
2
+ constructor(message, { exitCode = 64 } = {}) {
3
+ super(message);
4
+ this.name = 'CollectorUsageError';
5
+ this.exitCode = exitCode;
6
+ }
7
+ }
@@ -0,0 +1,193 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { CollectorUsageError } from './errors.js';
8
+
9
+ /**
10
+ * Resolve the published `@pdpp/local-collector` package, if installed.
11
+ *
12
+ * The shim prefers an installed `@pdpp/local-collector` so an operator who
13
+ * `npm i -g @pdpp/cli && npm i -g @pdpp/local-collector` can run
14
+ * `pdpp collector ...` without a monorepo checkout. Resolution is lazy —
15
+ * the CLI does NOT declare a runtime dependency on `@pdpp/local-collector`
16
+ * (per `publish-pdpp-local-collector` task 4.4); a missing package is
17
+ * surfaced as an actionable install hint rather than a hard import error.
18
+ *
19
+ * Spec: openspec/changes/publish-pdpp-local-collector/design.md §1.
20
+ */
21
+ export function resolveLocalCollectorPackage(startDir = dirname(fileURLToPath(import.meta.url))) {
22
+ // Primary resolution: Node module resolution from the caller. Works for an
23
+ // npm install where @pdpp/local-collector is alongside @pdpp/cli in the
24
+ // same node_modules tree.
25
+ try {
26
+ const require = createRequire(join(startDir, '_'));
27
+ const manifestPath = require.resolve('@pdpp/local-collector/package.json');
28
+ return { manifestPath, packageDir: dirname(manifestPath) };
29
+ } catch {
30
+ // Continue to workspace fallback.
31
+ }
32
+ // Fallback: walk up the directory tree looking for a sibling
33
+ // packages/local-collector workspace. Preserves the monorepo dev flow
34
+ // where pnpm does not hoist workspace packages into @pdpp/cli's local
35
+ // node_modules (per the slim-CLI invariant in task 4.4).
36
+ let cursor = resolve(startDir);
37
+ const seen = new Set();
38
+ while (!seen.has(cursor)) {
39
+ seen.add(cursor);
40
+ const candidate = join(cursor, 'packages', 'local-collector', 'package.json');
41
+ if (existsSync(candidate)) {
42
+ return { manifestPath: candidate, packageDir: dirname(candidate) };
43
+ }
44
+ const parent = dirname(cursor);
45
+ if (parent === cursor) {
46
+ break;
47
+ }
48
+ cursor = parent;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Locate the in-monorepo collector-runner TypeScript entrypoint.
55
+ *
56
+ * The shim's resolution order is:
57
+ *
58
+ * 1. monorepo workspace walk — preserves the current dev flow when
59
+ * `pdpp` is invoked from inside a checkout, which uses the
60
+ * filesystem-only `bin/collector-runner.ts` directly;
61
+ * 2. resolved `@pdpp/local-collector` package (via
62
+ * `resolveLocalCollectorPackage`);
63
+ * 3. fail-fast with a one-line install hint.
64
+ *
65
+ * This function only handles step 1; the higher-level `spawnCollectorRunner`
66
+ * weaves the order together so behavior is deterministic across
67
+ * monorepo + npm install postures.
68
+ */
69
+ export function resolveCollectorRunnerScript(startDir = dirname(fileURLToPath(import.meta.url))) {
70
+ let cursor = resolve(startDir);
71
+ const seen = new Set();
72
+ while (!seen.has(cursor)) {
73
+ seen.add(cursor);
74
+ const candidate = join(cursor, 'packages', 'polyfill-connectors', 'bin', 'collector-runner.ts');
75
+ if (existsSync(candidate)) {
76
+ return candidate;
77
+ }
78
+ const parent = dirname(cursor);
79
+ if (parent === cursor) {
80
+ break;
81
+ }
82
+ cursor = parent;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export function resolveTsxBinary(startDir = dirname(fileURLToPath(import.meta.url))) {
88
+ let cursor = resolve(startDir);
89
+ const seen = new Set();
90
+ while (!seen.has(cursor)) {
91
+ seen.add(cursor);
92
+ const candidate = join(cursor, 'node_modules', '.bin', 'tsx');
93
+ if (existsSync(candidate)) {
94
+ return candidate;
95
+ }
96
+ const parent = dirname(cursor);
97
+ if (parent === cursor) {
98
+ break;
99
+ }
100
+ cursor = parent;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * One-line install hint surfaced when neither the monorepo nor an
107
+ * installed `@pdpp/local-collector` can be found.
108
+ */
109
+ const RUNNER_MISSING_MESSAGE =
110
+ 'pdpp collector requires @pdpp/local-collector. Install once with ' +
111
+ '"npm i -g @pdpp/local-collector" or run "npx -y @pdpp/local-collector ...". ' +
112
+ 'See openspec/changes/publish-pdpp-local-collector/design.md.';
113
+
114
+ const TSX_MISSING_MESSAGE =
115
+ 'Could not locate tsx alongside the collector runner. Install ' +
116
+ '@pdpp/local-collector with "npm i -g @pdpp/local-collector" or run ' +
117
+ '"pnpm install" at the monorepo root.';
118
+
119
+ /**
120
+ * Spawn the collector-runner subprocess. Inherits stdio so operators see
121
+ * device tokens, run results, and diagnostics directly. Returns the exit
122
+ * code, never throws on non-zero exits.
123
+ *
124
+ * Resolution order, locked in by `publish-pdpp-local-collector` design §1:
125
+ * 1. monorepo `bin/collector-runner.ts` if walking up the FS finds one;
126
+ * 2. published `@pdpp/local-collector` bin if installed;
127
+ * 3. fail-fast `RUNNER_MISSING_MESSAGE`.
128
+ */
129
+ export async function spawnCollectorRunner(
130
+ subcommand,
131
+ argv,
132
+ {
133
+ env = process.env,
134
+ runnerScript = resolveCollectorRunnerScript(),
135
+ localCollector = resolveLocalCollectorPackage(),
136
+ tsxBinary = resolveTsxBinary(),
137
+ spawnFn = spawn,
138
+ stdio = 'inherit',
139
+ } = {},
140
+ ) {
141
+ if (runnerScript) {
142
+ if (!tsxBinary) {
143
+ throw new CollectorUsageError(TSX_MISSING_MESSAGE);
144
+ }
145
+ return await runSubprocess(spawnFn, tsxBinary, [runnerScript, subcommand, ...argv], { env, stdio });
146
+ }
147
+
148
+ if (localCollector) {
149
+ const binPath = resolveLocalCollectorBin(localCollector.packageDir);
150
+ if (!existsSync(binPath)) {
151
+ throw new CollectorUsageError(
152
+ `@pdpp/local-collector is installed at ${localCollector.packageDir} but is missing its bin entrypoint. ` +
153
+ 'Reinstall the package or report this on https://github.com/vana-com/pdpp/issues.',
154
+ );
155
+ }
156
+ if (binPath.endsWith('.ts')) {
157
+ if (!tsxBinary) {
158
+ throw new CollectorUsageError(TSX_MISSING_MESSAGE);
159
+ }
160
+ return await runSubprocess(spawnFn, tsxBinary, [binPath, subcommand, ...argv], { env, stdio });
161
+ }
162
+ return await runSubprocess(spawnFn, process.execPath, [binPath, subcommand, ...argv], { env, stdio });
163
+ }
164
+
165
+ throw new CollectorUsageError(RUNNER_MISSING_MESSAGE);
166
+ }
167
+
168
+ function resolveLocalCollectorBin(packageDir) {
169
+ try {
170
+ const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
171
+ const bin = manifest?.bin?.['pdpp-local-collector'];
172
+ if (typeof bin === 'string' && bin.trim()) {
173
+ return join(packageDir, bin);
174
+ }
175
+ } catch {}
176
+ const publishedBin = join(packageDir, 'dist', 'local-collector', 'bin', 'pdpp-local-collector.js');
177
+ if (existsSync(publishedBin)) return publishedBin;
178
+ return join(packageDir, 'bin', 'pdpp-local-collector.ts');
179
+ }
180
+
181
+ function runSubprocess(spawnFn, binary, args, { env, stdio }) {
182
+ return new Promise((resolvePromise, rejectPromise) => {
183
+ const child = spawnFn(binary, args, { env, stdio });
184
+ child.on('error', rejectPromise);
185
+ child.on('exit', (code, signal) => {
186
+ if (signal) {
187
+ rejectPromise(new Error(`collector-runner terminated by signal ${signal}`));
188
+ return;
189
+ }
190
+ resolvePromise(code ?? 0);
191
+ });
192
+ });
193
+ }
@@ -53,11 +53,24 @@ export async function connectProvider(providerUrl, options = {}) {
53
53
  );
54
54
  }
55
55
 
56
- const start = await postJson(fetchFn, connectEndpoint, {
56
+ const publicClient = await getOrRegisterPublicClient({
57
+ fetchFn,
58
+ authorizationMetadata,
59
+ cacheRoot,
60
+ providerUrl: normalizedProviderUrl,
61
+ clientName: 'PDPP CLI',
62
+ });
63
+
64
+ const startRequest = {
57
65
  resource: normalizedProviderUrl,
58
66
  scope,
59
67
  client_name: 'PDPP CLI',
60
- });
68
+ };
69
+ if (publicClient?.client_id) {
70
+ startRequest.client_id = publicClient.client_id;
71
+ }
72
+
73
+ const start = await postJson(fetchFn, connectEndpoint, startRequest);
61
74
 
62
75
  const approvalUrl = start.approval_url ?? start.verification_uri_complete ?? start.verification_uri;
63
76
  const pollUrl = start.poll_url ?? start.token_url ?? start.device_poll_endpoint ?? start.completion_endpoint;
@@ -87,6 +100,7 @@ export async function connectProvider(providerUrl, options = {}) {
87
100
  provider_url: normalizedProviderUrl,
88
101
  authorization_server: authorizationServerUrl,
89
102
  scope,
103
+ client: publicClient,
90
104
  credential,
91
105
  created_at: new Date((options.now?.() ?? Date.now())).toISOString(),
92
106
  });
@@ -100,9 +114,50 @@ export async function connectProvider(providerUrl, options = {}) {
100
114
  authorizationServerUrl,
101
115
  cacheFile,
102
116
  scope,
117
+ clientId: publicClient?.client_id ?? null,
103
118
  };
104
119
  }
105
120
 
121
+ export async function readStoredCredential(providerUrl, options = {}) {
122
+ const normalizedProviderUrl = normalizeProviderUrl(providerUrl);
123
+ if (!normalizedProviderUrl) {
124
+ throw new ConnectError('invalid_provider_url', `Invalid provider URL: ${providerUrl}`, 64);
125
+ }
126
+
127
+ const cacheRoot = options.cacheRoot ?? '.pdpp';
128
+ const cacheFile = getCredentialCacheFile(cacheRoot, normalizedProviderUrl);
129
+ let payload;
130
+ try {
131
+ payload = JSON.parse(await readFile(cacheFile, 'utf8'));
132
+ } catch (error) {
133
+ if (error?.code === 'ENOENT') {
134
+ throw new ConnectError(
135
+ 'not_connected',
136
+ `No PDPP credential found for ${normalizedProviderUrl}. Run pdpp connect ${normalizedProviderUrl} first.`
137
+ );
138
+ }
139
+ throw error;
140
+ }
141
+
142
+ const credential = payload?.credential;
143
+ if (!credential?.access_token) {
144
+ throw new ConnectError('credential_invalid', `Credential cache entry is missing an access token: ${cacheFile}`);
145
+ }
146
+
147
+ if (credential.expires_at) {
148
+ const expiresAtMs = Date.parse(credential.expires_at);
149
+ const now = options.now?.() ?? Date.now();
150
+ if (Number.isFinite(expiresAtMs) && expiresAtMs <= now) {
151
+ throw new ConnectError(
152
+ 'credential_expired',
153
+ `Credential for ${normalizedProviderUrl} expired. Run pdpp connect ${normalizedProviderUrl} again.`
154
+ );
155
+ }
156
+ }
157
+
158
+ return { cacheFile, payload, credential, providerUrl: normalizedProviderUrl };
159
+ }
160
+
106
161
  export function normalizeProviderUrl(value) {
107
162
  try {
108
163
  const parsed = new URL(value.includes('://') ? value : `https://${value}`);
@@ -167,6 +222,56 @@ function findAgentConnectEndpoint(resourceMetadata, authorizationMetadata) {
167
222
  }
168
223
  }
169
224
 
225
+ function findRegistrationEndpoint(authorizationMetadata) {
226
+ const endpoint = authorizationMetadata.registration_endpoint;
227
+ if (!endpoint) {
228
+ return null;
229
+ }
230
+ const modes = authorizationMetadata.pdpp_registration_modes_supported;
231
+ if (Array.isArray(modes) && !modes.includes('dynamic')) {
232
+ return null;
233
+ }
234
+ try {
235
+ return new URL(endpoint, authorizationMetadata.issuer).toString();
236
+ } catch {
237
+ throw new ConnectError('metadata_failure', 'Registration endpoint in provider metadata is not a valid URL.');
238
+ }
239
+ }
240
+
241
+ async function getOrRegisterPublicClient({ fetchFn, authorizationMetadata, cacheRoot, providerUrl, clientName }) {
242
+ const cached = await readCachedClientRegistration(cacheRoot, providerUrl);
243
+ if (cached) {
244
+ return cached;
245
+ }
246
+ const registrationEndpoint = findRegistrationEndpoint(authorizationMetadata);
247
+ if (!registrationEndpoint) {
248
+ return null;
249
+ }
250
+ const registered = await postJson(fetchFn, registrationEndpoint, {
251
+ client_name: clientName,
252
+ token_endpoint_auth_method: 'none',
253
+ });
254
+ if (!registered?.client_id) {
255
+ throw new ConnectError('connect_contract_invalid', 'Dynamic client registration response did not include client_id.');
256
+ }
257
+ return {
258
+ client_id: registered.client_id,
259
+ client_name: registered.client_name ?? clientName,
260
+ token_endpoint_auth_method: registered.token_endpoint_auth_method ?? 'none',
261
+ };
262
+ }
263
+
264
+ async function readCachedClientRegistration(cacheRoot, providerUrl) {
265
+ try {
266
+ const payload = JSON.parse(await readFile(getCredentialCacheFile(cacheRoot, providerUrl), 'utf8'));
267
+ const client = payload?.client;
268
+ return client?.client_id ? client : null;
269
+ } catch (error) {
270
+ if (error?.code === 'ENOENT') return null;
271
+ throw error;
272
+ }
273
+ }
274
+
170
275
  async function pollForCredential(fetchFn, pollUrl, options) {
171
276
  const startedAt = options.now?.() ?? Date.now();
172
277
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -194,6 +299,7 @@ async function pollForCredential(fetchFn, pollUrl, options) {
194
299
  access_token: credential.access_token,
195
300
  token_type: credential.token_type ?? 'Bearer',
196
301
  expires_at: credential.expires_at,
302
+ grant_id: credential.grant_id ?? result.grant_id,
197
303
  scope: credential.scope,
198
304
  };
199
305
  }
@@ -238,14 +344,18 @@ async function verifySchema(fetchFn, providerUrl, accessToken) {
238
344
  }
239
345
 
240
346
  async function storeCredential(cacheRoot, providerUrl, payload) {
241
- const host = new URL(providerUrl).host.replace(/[^a-zA-Z0-9.-]/g, '_');
242
- const cacheFile = join(cacheRoot, 'clients', `${host}.json`);
347
+ const cacheFile = getCredentialCacheFile(cacheRoot, providerUrl);
243
348
  await mkdir(dirname(cacheFile), { recursive: true, mode: 0o700 });
244
349
  await ensurePdppGitignore(cacheRoot);
245
350
  await writeFile(cacheFile, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
246
351
  return cacheFile;
247
352
  }
248
353
 
354
+ function getCredentialCacheFile(cacheRoot, providerUrl) {
355
+ const host = new URL(providerUrl).host.replace(/[^a-zA-Z0-9.-]/g, '_');
356
+ return join(cacheRoot, 'clients', `${host}.json`);
357
+ }
358
+
249
359
  async function ensurePdppGitignore(cacheRoot) {
250
360
  const gitignorePath = join(cacheRoot, '.gitignore');
251
361
  let current = '';