@elinpf/dsh-ops-access 0.1.6 → 0.2.0

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/lib/index.js CHANGED
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * Ops access capability seam.
3
3
  *
4
- * Owns the YAML credential registry file (default `~/.dsh-ops/access.yaml`)
5
- * and exposes `ctx.opsAccess`: a generic `resolve(kind, name)` / `list()`
6
- * entry plus a `register(provider)` surface for provider plugins. Providers
7
- * (one per credential kind) supply the zod schema for their entry shape and
8
- * an optional `process` step (e.g. `~` expansion); secret material never
9
- * leaves the filesystem — profiles carry only paths and connection params.
4
+ * Exposes `ctx.opsAccess`: a generic `resolve(kind, name)` / `list()` entry
5
+ * plus a `register(provider)` surface for provider plugins. Providers (one
6
+ * per credential kind) supply the zod schema for their entry shape and an
7
+ * optional `process` step (e.g. `~` expansion); profiles carry only paths
8
+ * and connection params, never inline secret material.
10
9
  *
11
- * The registry file is re-read, re-parsed, and re-validated on every call —
12
- * edits take effect immediately, nothing is cached.
10
+ * The credential SOURCE is pluggable (see backend.ts):
11
+ * - 'yaml' (default) owns the local YAML registry file
12
+ * (`~/.dsh-ops/access.yaml`), re-read, re-parsed, and re-validated on
13
+ * every call — edits take effect immediately, nothing is cached.
14
+ * - 'hub' fetches entries from a remote ops-access-hub service on every
15
+ * call (hub-backend.ts): file-field CONTENT is materialized to managed
16
+ * local files at resolve time, so profiles still carry only paths.
13
17
  *
14
18
  * Also registers the `register_access` tool: the agent's self-service path
15
- * for writing the ro tier of a profile (rw tiers stay human-managed via the
16
- * admin HTTP routes below).
19
+ * for writing the ro tier of a profile. rw tiers stay human-approved the
20
+ * tool can only QUEUE an rw registration request on the hub (hub mode);
21
+ * a human approves it in the admin UI before anything is written.
17
22
  *
18
- * Registry format:
23
+ * Registry format (yaml source):
19
24
  *
20
25
  * ```yaml
21
26
  * version: 1
@@ -40,35 +45,25 @@ import { readFile, writeFile, mkdir, rm, rmdir } from 'node:fs/promises';
40
45
  import { resolve } from 'node:path';
41
46
  import os from 'node:os';
42
47
  import z from '@deepseek-ai/schemastery';
43
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
44
48
  import { z as zod } from 'zod';
45
49
  import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm';
46
50
  import { defineTool } from '@deepseek-ai/dsh-tools';
47
51
  import { formatAccessMention, parseAccessReferenceText } from './mention.js';
52
+ import { YamlBackend, buildEnvelope, isPlainObject } from './backend.js';
53
+ import { HubBackend, sweepMaterialized } from './hub-backend.js';
48
54
  // ── Plugin identity ───────────────────────────────────────────────────────────
49
55
  export const name = 'ops-access';
50
56
  export const inject = ['tools'];
51
57
  export const Config = z.object({
52
58
  registryFile: z.string().default('~/.dsh-ops/access.yaml'),
53
59
  credentialsDir: z.string().default('~/.dsh-ops/credentials'),
60
+ source: z.union(['yaml', 'hub']).default('yaml'),
61
+ hubUrl: z.string().default(''),
62
+ hubToken: z.string().default(''),
63
+ hubAdminToken: z.string().default(''),
64
+ materializeTtlMinutes: z.number().default(15),
65
+ hubCacheDir: z.string().default('~/.dsh-ops/hub-cache'),
54
66
  });
55
- /** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
56
- function probeOf(tierRaw) {
57
- if (!isPlainObject(tierRaw))
58
- return undefined;
59
- const p = tierRaw.probe;
60
- if (!isPlainObject(p))
61
- return undefined;
62
- const probe = p;
63
- if (probe.status !== 'verified' && probe.status !== 'mismatch' && probe.status !== 'unverifiable')
64
- return undefined;
65
- if (typeof probe.probedAt !== 'string')
66
- return undefined;
67
- const out = { status: probe.status, probedAt: probe.probedAt };
68
- if (typeof probe.detail === 'string')
69
- out.detail = probe.detail;
70
- return out;
71
- }
72
67
  // ── Provider registration helper ─────────────────────────────────────────────
73
68
  /**
74
69
  * Register a provider into the seam from a provider plugin's `apply()`. The
@@ -104,66 +99,23 @@ export function expandHome(p) {
104
99
  return home + p.slice(1);
105
100
  return p;
106
101
  }
107
- function isPlainObject(value) {
108
- return typeof value === 'object' && value !== null && !Array.isArray(value);
109
- }
110
- /**
111
- * Read and parse the registry file. Returns null when the file does not
112
- * exist so callers can pick their own discipline (list → empty, resolve →
113
- * error). Never includes raw file text in errors.
114
- */
115
- async function loadRegistry(file) {
116
- let text;
117
- try {
118
- text = await readFile(file, 'utf8');
119
- }
120
- catch (err) {
121
- if (err?.code === 'ENOENT')
122
- return null;
123
- throw new Error(`ops-access: failed to read registry file ${file}: ${err?.message ?? err}`);
124
- }
125
- let doc;
126
- try {
127
- doc = parseYaml(text);
128
- }
129
- catch (err) {
130
- // First line only — the yaml library appends a source snippet to its
131
- // messages, and raw registry text must not leak into errors.
132
- const summary = String(err?.message ?? err).split('\n')[0];
133
- throw new Error(`ops-access: failed to parse registry file ${file}: ${summary}`);
134
- }
135
- // An empty file parses to null — treat it as an empty registry.
136
- if (doc == null)
137
- return {};
138
- if (!isPlainObject(doc)) {
139
- throw new Error(`ops-access: registry file ${file} must contain a top-level mapping`);
140
- }
141
- const registry = {};
142
- for (const [kind, section] of Object.entries(doc)) {
143
- if (kind === 'version')
144
- continue;
145
- if (!isPlainObject(section)) {
146
- throw new Error(`ops-access: section "${kind}" in registry file ${file} must be a mapping of profile names`);
147
- }
148
- registry[kind] = section;
149
- }
150
- return registry;
151
- }
152
102
  /**
153
103
  * Validate one tier sub-object against the provider schema and build the
154
104
  * profile. `raw` carries only the provider fields; the envelope
155
105
  * (description/environment) lives on the parent entry and is passed separately.
106
+ * `source` is the backend's label phrase (e.g. `registry file <path>` /
107
+ * `access hub at <url>`), interpolated as `in ${source}` in error messages.
156
108
  */
157
- function buildProfile(provider, kind, profileName, tier, raw, file, parentEntry) {
109
+ function buildProfile(provider, kind, profileName, tier, raw, source, parentEntry) {
158
110
  if (!isPlainObject(raw)) {
159
- throw new Error(`ops-access: entry ${kind}.${profileName} in registry file ${file} must be a mapping`);
111
+ throw new Error(`ops-access: entry ${kind}.${profileName} in ${source} must be a mapping`);
160
112
  }
161
113
  const result = provider.schema.safeParse(raw);
162
114
  if (!result.success) {
163
115
  const issues = result.error.issues
164
116
  .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
165
117
  .join('; ');
166
- throw new Error(`ops-access: invalid entry ${kind}.${profileName} in registry file ${file}: ${issues}`);
118
+ throw new Error(`ops-access: invalid entry ${kind}.${profileName} in ${source}: ${issues}`);
167
119
  }
168
120
  const fields = provider.process
169
121
  ? provider.process(result.data, profileName)
@@ -178,13 +130,56 @@ function buildProfile(provider, kind, profileName, tier, raw, file, parentEntry)
178
130
  profile.environment = env.environment;
179
131
  return profile;
180
132
  }
181
- /** Serialize a registry back to its YAML file with the version header. */
182
- async function saveRegistry(file, registry) {
183
- const doc = { version: 1 };
184
- for (const [kind, section] of Object.entries(registry)) {
185
- doc[kind] = section;
133
+ /**
134
+ * The final step of building a profile: expand the provider's declared
135
+ * reference fields through the SAME backend and tier, then run the
136
+ * provider's post-merge validation. Referenced fields are merged UNDER the
137
+ * referring entry's (the referring entry wins conflicts — e.g. a per-host
138
+ * user override of a shared credential's default user). One level only: a
139
+ * referenced entry's own reference fields are not expanded. The broker is
140
+ * NOT consulted for the reference — it is an implementation detail of the
141
+ * referring resolve, which was already gated.
142
+ *
143
+ * References load through the backend (`loadTier`), so in hub mode the
144
+ * referenced credential's file-field content is fetched and materialized by
145
+ * the same machinery as a direct resolve — `materialize` propagates the
146
+ * caller's intent (resolve issues credentials → write the files; canResolve/
147
+ * list are metadata reads → paths only, nothing touches disk). Shared by
148
+ * resolve, canResolve, and list so all three see the same resolved shape.
149
+ */
150
+ async function finalizeProfile(backend, providers, provider, profile, opts) {
151
+ if (provider.references) {
152
+ const merged = {};
153
+ let any = false;
154
+ for (const [field, refKind] of Object.entries(provider.references)) {
155
+ const refName = profile.fields[field];
156
+ if (refName === undefined)
157
+ continue;
158
+ if (typeof refName !== 'string' || refName.length === 0) {
159
+ throw new Error(`ops-access: entry ${profile.kind}.${profile.name} ${profile.tier} field "${field}" must be a non-empty string naming a ${refKind} profile`);
160
+ }
161
+ const refProvider = providers.get(refKind);
162
+ if (!refProvider) {
163
+ throw new Error(`ops-access: entry ${profile.kind}.${profile.name} references ${refKind}/${refName}, but no provider is registered for kind "${refKind}"`);
164
+ }
165
+ const loaded = await backend.loadTier(refKind, refName, profile.tier, { materialize: opts.materialize });
166
+ if (loaded === null) {
167
+ const entries = await backend.listEntries().catch(() => []);
168
+ const available = entries.filter((e) => e.kind === refKind).map((e) => e.name).sort();
169
+ throw new Error(`ops-access: entry ${profile.kind}.${profile.name} references ${refKind}/${refName}, which has no ${profile.tier} tier in ${backend.label} (available: ${available.join(', ') || '(none)'})`);
170
+ }
171
+ const refProfile = buildProfile(refProvider, refKind, refName, profile.tier, loaded.fields, backend.label, loaded.envelope);
172
+ Object.assign(merged, refProfile.fields);
173
+ any = true;
174
+ }
175
+ if (any)
176
+ profile.fields = { ...merged, ...profile.fields };
177
+ }
178
+ const problem = provider.validateResolved?.(profile.fields);
179
+ if (problem) {
180
+ throw new Error(`ops-access: invalid entry ${profile.kind}.${profile.name} in ${backend.label}: ${problem}`);
186
181
  }
187
- await writeFile(file, stringifyYaml(doc), 'utf8');
182
+ return profile;
188
183
  }
189
184
  /** Read the full HTTP request body as a string. */
190
185
  function readRequestBody(req) {
@@ -201,21 +196,6 @@ function sendJsonError(res, status, err) {
201
196
  res.writeHead(status, { 'content-type': 'application/json' });
202
197
  res.end(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
203
198
  }
204
- /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
205
- function buildEnvelope(sources) {
206
- const envelope = {};
207
- for (const source of sources) {
208
- if (!isPlainObject(source))
209
- continue;
210
- if (envelope.name === undefined && typeof source.name === 'string')
211
- envelope.name = source.name;
212
- if (envelope.description === undefined && typeof source.description === 'string')
213
- envelope.description = source.description;
214
- if (envelope.environment === undefined && typeof source.environment === 'string')
215
- envelope.environment = source.environment;
216
- }
217
- return envelope;
218
- }
219
199
  /** Split "kind/name" on the FIRST slash — profile names may contain '@' etc. */
220
200
  function parseProfile(raw) {
221
201
  if (typeof raw !== 'string')
@@ -225,6 +205,17 @@ function parseProfile(raw) {
225
205
  return undefined;
226
206
  return { kind: raw.slice(0, slash), profileName: raw.slice(slash + 1) };
227
207
  }
208
+ /**
209
+ * Tolerate a redundant kind prefix on a profile name: the mention/recall
210
+ * syntax writes profiles as `kind/name` and agents routinely pass that whole
211
+ * token as the bare name (2026-09-10: ssh/ssh/b200-02 failed three times
212
+ * across the ssh tool and request_access). Registered names can never
213
+ * contain '/', so stripping `${kind}/` is unambiguous and idempotent.
214
+ */
215
+ function stripKindPrefix(kind, profileName) {
216
+ const prefix = kind + '/';
217
+ return profileName.startsWith(prefix) ? profileName.slice(prefix.length) : profileName;
218
+ }
228
219
  /**
229
220
  * Write credential CONTENT to managed files under
230
221
  * `<credentialsDir>/<kind>/<name>/<tier>/<field>` and record the resulting
@@ -341,7 +332,7 @@ async function rollbackContentFiles(credentialsDir, kind, profileName, tier, wri
341
332
  * the session holds no grant. listAll (not list) so rw-only entries render
342
333
  * too — they exist in the registry, the agent just cannot read them yet.
343
334
  */
344
- async function renderAccessReferences(handle, references) {
335
+ async function renderAccessReferences(handle, providers, references) {
345
336
  // A listAll failure (unreadable/corrupt registry file) degrades the mention
346
337
  // render to name-only lines — the registry being temporarily unreadable must
347
338
  // not break prompt assembly for the whole turn.
@@ -366,6 +357,19 @@ async function renderAccessReferences(handle, references) {
366
357
  : '';
367
358
  lines.push(`- ${key}${label}${env}${desc}${tierNote}`);
368
359
  }
360
+ // Kind-level credential boundaries, once per referenced kind: the agent
361
+ // should learn "view does not cover nodes" from this line, not from a
362
+ // Forbidden it will misread as a cluster problem (and re-hit after every
363
+ // compaction).
364
+ const limitsSeen = new Set();
365
+ for (const ref of references) {
366
+ if (limitsSeen.has(ref.kind))
367
+ continue;
368
+ limitsSeen.add(ref.kind);
369
+ const limits = providers.get(ref.kind)?.knownLimits;
370
+ if (limits)
371
+ lines.push(`- [${ref.kind} ro-tier limits] ${limits}`);
372
+ }
369
373
  return `<referenced-access>\nThe user explicitly referenced these access profiles (use them with the matching tools):\n${lines.join('\n')}\n</referenced-access>`;
370
374
  }
371
375
  // ── Plugin apply ─────────────────────────────────────────────────────────────
@@ -373,6 +377,50 @@ export function apply(ctx, config) {
373
377
  const registryFile = expandHome(config.registryFile);
374
378
  const credentialsDir = expandHome(config.credentialsDir);
375
379
  const providers = new Map();
380
+ // Credential source backend (see backend.ts / hub-backend.ts): yaml is the
381
+ // default and behaves byte-for-byte as before; hub fetches entries from a
382
+ // remote ops-access-hub on every call and materializes file-field content
383
+ // to managed local files under credentialsDir.
384
+ const source = config.source ?? 'yaml';
385
+ // In hub mode every local credential file — materialized reads AND staged
386
+ // writes — lives under hubCacheDir as a TTL-bound cache. credentialsDir
387
+ // stays yaml-mode territory: the sweeper must never touch files the yaml
388
+ // registry still references (the documented fallback).
389
+ const contentDir = source === 'hub' ? expandHome(config.hubCacheDir ?? '~/.dsh-ops/hub-cache') : credentialsDir;
390
+ let backend;
391
+ if (source === 'hub') {
392
+ const hubUrl = (config.hubUrl ?? '').replace(/\/+$/, '');
393
+ if (hubUrl === '') {
394
+ throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090)');
395
+ }
396
+ backend = new HubBackend({
397
+ baseUrl: hubUrl,
398
+ readToken: config.hubToken || process.env.ACCESS_HUB_READ_TOKEN || '',
399
+ adminToken: config.hubAdminToken || process.env.ACCESS_HUB_ADMIN_TOKEN || '',
400
+ cacheDir: contentDir,
401
+ getProvider: (kind) => providers.get(kind),
402
+ });
403
+ // Hub mode: local credential files are a TTL-bound cache of hub content,
404
+ // never permanent copies. Startup sweeps EVERYTHING in the cache dir (the
405
+ // grant ledger is in-memory and cleared by this very restart — cached rw
406
+ // material must not outlive it); the interval sweep then expires files
407
+ // past the TTL. Both tiers: ro cache expiry is equally transparent
408
+ // (resolve re-fetches and re-materializes on demand). apply is sync — the
409
+ // boot sweep runs detached; a resolve re-materializes anything it needs
410
+ // anyway, so a slow boot sweep can only leave a stale file for seconds.
411
+ const ttlMs = (config.materializeTtlMinutes ?? 15) * 60_000;
412
+ void sweepMaterialized(contentDir, 0).catch(() => { });
413
+ ctx.effect(() => {
414
+ const timer = setInterval(() => {
415
+ void sweepMaterialized(contentDir, ttlMs).catch(() => { });
416
+ }, Math.min(ttlMs, 60_000));
417
+ timer.unref?.();
418
+ return () => clearInterval(timer);
419
+ });
420
+ }
421
+ else {
422
+ backend = new YamlBackend(registryFile);
423
+ }
376
424
  // At most one broker is active; a later registration replaces an earlier one.
377
425
  // The replaced broker's disposer is folded into the replacement's, so each
378
426
  // registration's effect cleanup runs exactly once even under replacement or
@@ -409,36 +457,34 @@ export function apply(ctx, config) {
409
457
  return dispose;
410
458
  },
411
459
  async canResolve(kind, profileName, tier) {
460
+ profileName = stripKindPrefix(kind, profileName);
412
461
  const provider = providers.get(kind);
413
462
  if (!provider)
414
463
  return { ok: false };
415
- // Load + locate the entry in its own try/catch: a missing or unparseable
416
- // file is a structural "not resolvable" with no validation reason — the
417
- // admin does not need a zod message to fix a file that isn't there.
418
- let raw;
419
- let parentEntry;
464
+ // Load + locate the entry in its own try/catch: a missing source or
465
+ // entry is a structural "not resolvable" with no validation reason —
466
+ // the admin does not need a zod message to fix an entry that isn't
467
+ // there. materialize: false — a precheck must not write secret files
468
+ // (hub mode), e.g. the gate's pre-approval check on the rw tier.
469
+ let loaded;
420
470
  try {
421
- const registry = await loadRegistry(registryFile);
422
- if (registry === null)
423
- return { ok: false };
424
- const entry = registry[kind]?.[profileName];
425
- if (!isPlainObject(entry))
426
- return { ok: false };
427
- parentEntry = entry;
428
- raw = parentEntry[tier];
471
+ loaded = await backend.loadTier(kind, profileName, tier, { materialize: false });
429
472
  }
430
473
  catch {
431
474
  return { ok: false };
432
475
  }
433
- if (!isPlainObject(raw))
476
+ if (loaded === null)
434
477
  return { ok: false };
435
478
  // Run the same buildProfile validation resolve would run — a precheck
436
479
  // shallower than the real issuance approves grants that cannot be
437
480
  // fulfilled. The profile itself is discarded: existence, not fields.
438
- // A validation failure surfaces the reason (zod issue paths + messages,
439
- // never raw field values) so the admin UI can show it.
481
+ // Reference expansion runs too (finalizeProfile): a dangling credential
482
+ // reference is exactly the kind of undeliverable resolve this precheck
483
+ // exists to catch. A validation failure surfaces the reason (zod issue
484
+ // paths + messages, never raw field values) so the admin UI can show it.
440
485
  try {
441
- buildProfile(provider, kind, profileName, tier, raw, registryFile, parentEntry);
486
+ const profile = buildProfile(provider, kind, profileName, tier, loaded.fields, backend.label, loaded.envelope);
487
+ await finalizeProfile(backend, providers, provider, profile, { materialize: false });
442
488
  return { ok: true };
443
489
  }
444
490
  catch (err) {
@@ -446,6 +492,7 @@ export function apply(ctx, config) {
446
492
  }
447
493
  },
448
494
  async resolve(kind, profileName, agent) {
495
+ profileName = stripKindPrefix(kind, profileName);
449
496
  const provider = providers.get(kind);
450
497
  if (!provider) {
451
498
  const registered = [...providers.keys()].sort();
@@ -464,52 +511,53 @@ export function apply(ctx, config) {
464
511
  if (decision === 'rw')
465
512
  tier = 'rw';
466
513
  }
467
- const registry = await loadRegistry(registryFile);
468
- if (registry === null) {
469
- throw new Error(`ops-access: registry file not found: ${registryFile}`);
470
- }
471
- const section = registry[kind];
472
- const entry = section?.[profileName];
473
- if (!isPlainObject(entry)) {
474
- const available = Object.keys(section ?? {}).sort();
475
- const hint = tier === 'rw' ? ' — a grant was approved but no rw credential is registered; ask the operator to add it via the admin UI' : '';
476
- throw new Error(`ops-access: no profile "${profileName}" for kind "${kind}" in registry file ${registryFile} (available: ${available.join(', ') || '(none)'})${hint}`);
477
- }
478
- const tierData = entry[tier];
479
- if (!isPlainObject(tierData)) {
514
+ // A missing SOURCE (yaml: no registry file) throws from the backend
515
+ // verbatim (SourceUnavailableError); an unreadable source propagates
516
+ // its read/parse error the same way.
517
+ const loaded = await backend.loadTier(kind, profileName, tier);
518
+ if (loaded === null) {
519
+ // Distinguish "no such entry" from "entry without this tier" via the
520
+ // fields-free listing — the hints below guide the agent's next move.
521
+ const entries = await backend.listEntries().catch(() => []);
522
+ const inKind = entries.filter((e) => e.kind === kind);
523
+ const entry = inKind.find((e) => e.name === profileName);
524
+ if (!entry) {
525
+ const available = inKind.map((e) => e.name).sort();
526
+ const hint = tier === 'rw' ? ' — a grant was approved but no rw credential is registered; ask the operator to add it via the admin UI' : '';
527
+ throw new Error(`ops-access: no profile "${profileName}" for kind "${kind}" in ${backend.label} (available: ${available.join(', ') || '(none)'})${hint}`);
528
+ }
480
529
  // On the rw tier the grant was already approved — say so, so the agent
481
530
  // reports "no rw credential registered" to the operator instead of
482
531
  // re-requesting a grant that can never be fulfilled. On the ro tier
483
532
  // with rw present, point at the self-service derivation path.
484
533
  const hint = tier === 'rw'
485
534
  ? ' — a grant was approved but no rw credential is registered; ask the operator to add it via the admin UI'
486
- : isPlainObject(entry.rw)
535
+ : entry.tiers.rw !== undefined
487
536
  ? ' — the rw tier is registered; derive a read-only credential from it (list_access help: true has the recipe) and register it via the register_access tool'
488
537
  : '';
489
- throw new Error(`ops-access: no ${tier} tier for profile "${profileName}" (kind "${kind}") in registry file ${registryFile}${hint}`);
538
+ throw new Error(`ops-access: no ${tier} tier for profile "${profileName}" (kind "${kind}") in ${backend.label}${hint}`);
490
539
  }
491
- return buildProfile(provider, kind, profileName, tier, tierData, registryFile, entry);
540
+ const profile = buildProfile(provider, kind, profileName, tier, loaded.fields, backend.label, loaded.envelope);
541
+ return finalizeProfile(backend, providers, provider, profile, { materialize: true });
492
542
  },
493
543
  async list() {
494
- const registry = await loadRegistry(registryFile);
495
- if (registry === null)
496
- return [];
544
+ // A missing source lists empty; an unreadable/corrupt source throws
545
+ // (same discipline as the pre-backend list).
546
+ const entries = await backend.listEntries();
497
547
  const profiles = [];
498
- for (const [kind, section] of Object.entries(registry)) {
499
- // Sections whose kind has no registered provider are skipped —
500
- // an unrecognized kind must not fail the whole listing.
501
- const provider = providers.get(kind);
502
- if (!provider)
548
+ for (const entry of entries) {
549
+ // Kinds without a registered provider are skipped — an unrecognized
550
+ // kind must not fail the whole listing. list() surfaces the
551
+ // agent-readable ro tier only. materialize: false — listing is not
552
+ // issuance; file fields carry their would-be managed path.
553
+ const provider = providers.get(entry.kind);
554
+ if (!provider || entry.tiers.ro === undefined)
503
555
  continue;
504
- for (const [profileName, entry] of Object.entries(section)) {
505
- // list() surfaces the agent-readable ro tier only.
506
- if (!isPlainObject(entry))
507
- continue;
508
- const roData = entry.ro;
509
- if (!isPlainObject(roData))
510
- continue;
511
- profiles.push(buildProfile(provider, kind, profileName, 'ro', roData, registryFile, entry));
512
- }
556
+ const loaded = await backend.loadTier(entry.kind, entry.name, 'ro', { materialize: false }).catch(() => null);
557
+ if (loaded === null)
558
+ continue;
559
+ const profile = buildProfile(provider, entry.kind, entry.name, 'ro', loaded.fields, backend.label, loaded.envelope);
560
+ profiles.push(await finalizeProfile(backend, providers, provider, profile, { materialize: false }));
513
561
  }
514
562
  return profiles;
515
563
  },
@@ -517,23 +565,14 @@ export function apply(ctx, config) {
517
565
  const lines = [
518
566
  'Ops access registry — how to manage credentials',
519
567
  '',
520
- `File: ${registryFile}`,
521
- 'Re-read, re-parsed, and re-validated on EVERY call — edit it with the fs tools and the change takes effect immediately, no restart.',
522
- '',
523
- 'Format:',
524
- ' version: 1',
525
- ' <kind>:',
526
- ' <profile-id>: # stable id: letters/digits plus . _ - @; used in paths, mentions, grants',
527
- ' name: display label, freely editable # optional, UI-facing only',
528
- ' description: what this profile is for # optional, shown by list_access',
529
- ' environment: prod | staging | ... # optional; the future audit gate reads this',
530
- ' ro: # ro tier fields (agent-readable default)',
531
- ' <kind-specific fields, see below>',
532
- ' rw: # rw tier fields (grant-gated)',
533
- ' probe: {...} # auto-managed capability check (ticket 10): status/detail/probedAt, written at save time — do not edit',
534
- '',
535
- 'Registered kinds and their entry fields:',
536
568
  ];
569
+ if (source === 'hub') {
570
+ lines.push(`Source: ${backend.label}`, 'Entries are fetched from the hub on EVERY call — edits in the hub UI take effect immediately, no restart.', `File-field contents live in the hub; at resolve time they are materialized to cache files under ${contentDir} (0600, TTL-bound — swept on expiry and at startup, re-materialized on demand) and profiles carry those paths — secret material never enters logs or model context.`);
571
+ }
572
+ else {
573
+ lines.push(`File: ${registryFile}`, 'Re-read, re-parsed, and re-validated on EVERY call — edit it with the fs tools and the change takes effect immediately, no restart.', '', 'Format:', ' version: 1', ' <kind>:', ' <profile-id>: # stable id: letters/digits plus . _ - @; used in paths, mentions, grants', ' name: display label, freely editable # optional, UI-facing only', ' description: what this profile is for # optional, shown by list_access', ' environment: prod | staging | ... # optional; the future audit gate reads this', ' ro: # ro tier fields (agent-readable default)', ' <kind-specific fields, see below>', ' rw: # rw tier fields (grant-gated)', ' probe: {...} # auto-managed capability check (ticket 10): status/detail/probedAt, written at save time — do not edit');
574
+ }
575
+ lines.push('', 'Registered kinds and their entry fields:');
537
576
  const kinds = [...providers.values()].sort((a, b) => a.kind.localeCompare(b.kind));
538
577
  if (kinds.length === 0) {
539
578
  lines.push('- (none registered)');
@@ -542,9 +581,11 @@ export function apply(ctx, config) {
542
581
  lines.push(`- ${p.kind}: ${p.fieldsDoc ?? '(no field docs provided by this provider)'}`);
543
582
  if (p.derivationDoc)
544
583
  lines.push(` derive ro: ${p.derivationDoc}`);
584
+ if (p.knownLimits)
585
+ lines.push(` ro-tier limits: ${p.knownLimits}`);
545
586
  }
546
587
  lines.push('');
547
- lines.push('Agents register ro tiers with the register_access tool rw tiers stay human-managed via the admin UI.');
588
+ lines.push('Agents register ro tiers with the register_access tool; rw tiers are human-approved the tool can submit an rw registration REQUEST (tier: "rw") which takes effect only after an operator approves it in the admin UI (hub mode).');
548
589
  lines.push('Registering: pass the full file CONTENT for file fields, or a single-line path to an existing readable file (read server-side, content never passes through the model). Multi-line pastes are always treated as content.');
549
590
  lines.push('In the REGISTRY itself, file fields carry the managed file paths — secrets never go inline, so logs and model context never contain secret material.');
550
591
  return lines.join('\n');
@@ -559,121 +600,62 @@ export function apply(ctx, config) {
559
600
  // The tier sub-object carries only provider fields; the envelope
560
601
  // (name/description/environment) lives on the parent entry.
561
602
  const tierData = { ...fields };
562
- // Read → merge → validate → write back. A missing file starts from an
563
- // empty registry; an unparseable file throws (we will not overwrite a
564
- // file we cannot read).
565
- let registry = {};
566
- const loaded = await loadRegistry(registryFile);
567
- if (loaded !== null)
568
- registry = loaded;
569
- if (!registry[kind])
570
- registry[kind] = {};
571
- if (!isPlainObject(registry[kind][profileName]))
572
- registry[kind][profileName] = {};
573
- const entry = registry[kind][profileName];
574
- entry[tier] = tierData;
575
- // Envelope discipline: omitted = preserve, empty string = delete, else set.
576
- // The admin UI always sends all three so the operator can clear them.
577
- if (envelope?.name !== undefined) {
578
- if (envelope.name === '')
579
- delete entry.name;
580
- else
581
- entry.name = envelope.name;
582
- }
583
- if (envelope?.description !== undefined) {
584
- if (envelope.description === '')
585
- delete entry.description;
586
- else
587
- entry.description = envelope.description;
588
- }
589
- if (envelope?.environment !== undefined) {
590
- if (envelope.environment === '')
591
- delete entry.environment;
592
- else
593
- entry.environment = envelope.environment;
594
- }
595
603
  // Validate via buildProfile BEFORE writing — a schema failure must not
596
- // touch the file. buildProfile throws with zod issue paths + messages,
597
- // never raw field values. (The in-memory merged entry is what we
598
- // validate, matching the spec's read→merge→validate→write sequence.)
599
- const writtenProfile = buildProfile(provider, kind, profileName, tier, tierData, registryFile, entry);
604
+ // touch the source. buildProfile throws with zod issue paths + messages,
605
+ // never raw field values.
606
+ const writtenProfile = buildProfile(provider, kind, profileName, tier, tierData, backend.label);
600
607
  // Capability probe (ticket 10): verify claims against reality at save
601
608
  // time — the credential files are already on disk (the caller writes
602
609
  // them first). A probe failure degrades to 'unverifiable', never a
603
610
  // write rejection.
611
+ let probe;
604
612
  if (provider.probe) {
605
613
  const probed = await provider.probe(writtenProfile.fields, tier)
606
614
  .catch((err) => ({
607
615
  status: 'unverifiable',
608
616
  detail: err instanceof Error ? err.message.split('\n')[0] : String(err),
609
617
  }));
610
- tierData.probe = { ...probed, probedAt: new Date().toISOString() };
618
+ probe = { ...probed, probedAt: new Date().toISOString() };
611
619
  }
612
- await saveRegistry(registryFile, registry);
620
+ // The backend applies the envelope patch discipline (undefined =
621
+ // preserve, empty string = delete) and persists the tier.
622
+ await backend.putTier(kind, profileName, tier, tierData, envelope, probe);
613
623
  },
614
624
  async deleteEntry(kind, profileName, tier) {
615
- const registry = await loadRegistry(registryFile);
616
- if (registry === null)
617
- return false;
618
- const section = registry[kind];
619
- if (!section || !(profileName in section))
625
+ const outcome = await backend.deleteTier(kind, profileName, tier);
626
+ if (outcome === 'missing')
620
627
  return false;
621
- const entry = section[profileName];
622
- if (!isPlainObject(entry))
623
- return false;
624
- // Remove the tier sub-object and its managed credential files.
625
- delete entry[tier];
628
+ // Remove the tier's managed credential files; when the whole entry
629
+ // went, remove its credential directory too. (contentDir: the hub cache
630
+ // in hub mode, the yaml-mode managed dir otherwise.)
626
631
  const provider = providers.get(kind);
627
632
  if (provider?.fileFields && provider.fileFields.length > 0) {
628
- await rm(credentialsDir + '/' + kind + '/' + profileName + '/' + tier, { recursive: true, force: true });
629
- }
630
- // If neither tier remains, remove the whole entry, its credential
631
- // directory, and drop empty sections.
632
- const remaining = ['ro', 'rw'].filter((t) => entry[t] !== undefined);
633
- if (remaining.length === 0) {
634
- delete section[profileName];
635
- if (Object.keys(section).length === 0)
636
- delete registry[kind];
637
- if (provider?.fileFields && provider.fileFields.length > 0) {
638
- await rm(credentialsDir + '/' + kind + '/' + profileName, { recursive: true, force: true });
633
+ await rm(contentDir + '/' + kind + '/' + profileName + '/' + tier, { recursive: true, force: true });
634
+ if (outcome === 'entry') {
635
+ await rm(contentDir + '/' + kind + '/' + profileName, { recursive: true, force: true });
639
636
  }
640
637
  }
641
- await saveRegistry(registryFile, registry);
642
638
  return true;
643
639
  },
644
640
  async listAll() {
645
- // Load the single registry for enumeration. A parse error degrades to
646
- // an empty list — canResolve reports the failure per tier.
647
- let registry = {};
641
+ // A source failure degrades to an empty list canResolve reports the
642
+ // failure per tier.
643
+ let entries = [];
648
644
  try {
649
- const r = await loadRegistry(registryFile);
650
- if (r)
651
- registry = r;
645
+ entries = await backend.listEntries();
652
646
  }
653
647
  catch { /* canResolve reports the failure */ }
654
648
  const result = [];
655
- for (const kind of Object.keys(registry).sort()) {
656
- if (!providers.has(kind))
649
+ for (const entry of entries) {
650
+ if (!providers.has(entry.kind))
657
651
  continue;
658
- const section = registry[kind];
659
- if (!section)
660
- continue;
661
- for (const name of Object.keys(section).sort()) {
662
- const entry = section[name];
663
- if (!isPlainObject(entry))
664
- continue;
665
- const envelope = buildEnvelope([entry]);
666
- const roStatus = await handle.canResolve(kind, name, 'ro');
667
- const rwStatus = await handle.canResolve(kind, name, 'rw');
668
- const rawEntry = entry;
669
- const roProbe = probeOf(rawEntry.ro);
670
- if (roProbe !== undefined)
671
- roStatus.probe = roProbe;
672
- const rwProbe = probeOf(rawEntry.rw);
673
- if (rwProbe !== undefined)
674
- rwStatus.probe = rwProbe;
675
- result.push({ kind, name, envelope, tiers: { ro: roStatus, rw: rwStatus } });
676
- }
652
+ const roStatus = await handle.canResolve(entry.kind, entry.name, 'ro');
653
+ const rwStatus = await handle.canResolve(entry.kind, entry.name, 'rw');
654
+ if (entry.tiers.ro?.probe !== undefined)
655
+ roStatus.probe = entry.tiers.ro.probe;
656
+ if (entry.tiers.rw?.probe !== undefined)
657
+ rwStatus.probe = entry.tiers.rw.probe;
658
+ result.push({ kind: entry.kind, name: entry.name, envelope: entry.envelope, tiers: { ro: roStatus, rw: rwStatus } });
677
659
  }
678
660
  return result;
679
661
  },
@@ -691,32 +673,24 @@ export function apply(ctx, config) {
691
673
  const provider = providers.get(kind);
692
674
  if (!provider)
693
675
  return null;
694
- let registry;
676
+ let loaded;
695
677
  try {
696
- registry = await loadRegistry(registryFile);
678
+ loaded = await backend.loadTier(kind, profileName, tier, { materialize: false });
697
679
  }
698
680
  catch {
699
- // Registry file unreadable/corrupt → the entry is unknowable; getEntry
681
+ // Source unreadable/corrupt → the entry is unknowable; getEntry
700
682
  // reports null (not found) rather than failing the caller's whole flow.
701
683
  return null;
702
684
  }
703
- if (registry === null)
704
- return null;
705
- const entry = registry[kind]?.[profileName];
706
- if (!isPlainObject(entry))
685
+ if (loaded === null)
707
686
  return null;
708
- const parent = entry;
709
- const raw = parent[tier];
710
- if (!isPlainObject(raw))
711
- return null;
712
- // Return the tier's NON-file fields plus the parent's envelope. File
687
+ // Return the tier's NON-file fields plus the entry's envelope. File
713
688
  // fields (credential content) are write-only after save: content is
714
689
  // never read back — not even the managed path — only the set status
715
690
  // rides along so the UI can render "已保存,粘贴新内容以覆盖". This
716
691
  // keeps stored credentials unreachable for anyone (or anything) that
717
692
  // can merely reach the admin routes.
718
- const { name: displayName, description, environment } = parent;
719
- const fields = { ...raw };
693
+ const fields = { ...loaded.fields };
720
694
  const fileFields = {};
721
695
  for (const ff of provider.fileFields ?? []) {
722
696
  const stored = fields[ff];
@@ -724,30 +698,35 @@ export function apply(ctx, config) {
724
698
  delete fields[ff];
725
699
  }
726
700
  const result = { fields, fileFields };
727
- if (typeof displayName === 'string')
728
- result.displayName = displayName;
729
- if (typeof description === 'string')
730
- result.description = description;
731
- if (typeof environment === 'string')
732
- result.environment = environment;
701
+ if (loaded.envelope.name !== undefined)
702
+ result.displayName = loaded.envelope.name;
703
+ if (loaded.envelope.description !== undefined)
704
+ result.description = loaded.envelope.description;
705
+ if (loaded.envelope.environment !== undefined)
706
+ result.environment = loaded.envelope.environment;
733
707
  return result;
734
708
  },
735
709
  };
736
710
  ctx.provide('opsAccess', handle);
737
- // ── register_access tool (agent-facing ro-tier writer) ────────────────────
711
+ // ── register_access tool (agent-facing ro writer / rw requester) ──────────
738
712
  // The agent's self-service registration path: it derives a read-only
739
713
  // credential from the rw one (per-kind recipe in the provider's
740
714
  // derivationDoc, surfaced by help()) and writes the ro tier here.
741
715
  // Deliberately ungated — the ro tier is the agent's default operating
742
- // level and the operator can overwrite it from the admin UI at any time;
743
- // the rw tier stays human-only (no tool writes it). Tool calls sit in the
744
- // session event log, so every registration is reconstructable.
716
+ // level and the operator can overwrite it from the admin UI at any time.
717
+ // The rw tier stays approval-gated: tier:"rw" only QUEUES a registration
718
+ // request on the hub (validated like a real write); a human reviews the
719
+ // content in the admin UI and only an approval persists the tier.
720
+ // Tool calls sit in the session event log, so every registration and
721
+ // request is reconstructable.
745
722
  ctx.effect(() => ctx.tools.register(defineTool({
746
723
  name: 'register_access',
747
- description: 'Register or overwrite the read-only (ro) credential tier of an access profile — typically a credential you derived from the rw tier (a read-only ServiceAccount token, a read-only cephx keyring, a dedicated SSH key). The rw tier is human-managed via the admin UI; this tool writes ro only. File fields (kubeconfig, conf, keyring, key) take the full file CONTENT, stored to a managed path automatically; a path to an existing readable file also works and is read server-side, so the content never needs to pass through this call. Other fields are inline values. Run list_access with help: true for per-kind field docs and derivation recipes.',
724
+ description: 'Register or overwrite the read-only (ro) credential tier of an access profile — typically a credential you derived from the rw tier (a read-only ServiceAccount token, a read-only cephx keyring, a dedicated SSH key). Pass tier: "rw" to instead SUBMIT an rw registration request for human approval (hub mode only) it writes nothing until an operator approves it in the access admin UI. File fields (kubeconfig, conf, keyring, key) take the full file CONTENT, stored to a managed path automatically; a path to an existing readable file also works and is read server-side, so the content never needs to pass through this call. Other fields are inline values. Run list_access with help: true for per-kind field docs and derivation recipes.',
748
725
  parameters: {
749
726
  profile: { type: 'string', required: true, description: '"kind/id", e.g. "k8s/prod". The entry is created when it does not exist yet.' },
750
- fields: { type: 'object', additionalProperties: true, required: true, description: 'The ro tier field values for this kind. File fields (kubeconfig, conf, keyring, key) take the full file CONTENT — or a single-line path to an existing readable file, which is read server-side. Multi-line pastes are always treated as content.' },
727
+ fields: { type: 'object', additionalProperties: true, required: true, description: 'The tier field values for this kind. File fields (kubeconfig, conf, keyring, key) take the full file CONTENT — or a single-line path to an existing readable file, which is read server-side. Multi-line pastes are always treated as content.' },
728
+ tier: { type: 'string', description: '"ro" (default) writes the read-only tier directly. "rw" does NOT write anything: in hub mode it submits a registration REQUEST that takes effect only after a human approves it in the access admin UI; in yaml mode it fails (rw stays human-managed).' },
729
+ reason: { type: 'string', description: 'For tier "rw": why this rw credential is needed — shown to the approving human.' },
751
730
  description: { type: 'string', description: 'Optional envelope description (empty string clears it).' },
752
731
  environment: { type: 'string', description: 'Optional envelope environment label (empty string clears it).' },
753
732
  },
@@ -776,7 +755,7 @@ export function apply(ctx, config) {
776
755
  return { ok: false, message: `unknown kind "${kind}" (registered kinds: ${registered.join(', ') || '(none)'})` };
777
756
  }
778
757
  if (!isPlainObject(args.fields)) {
779
- return { ok: false, message: 'fields must be an object of ro tier field values' };
758
+ return { ok: false, message: 'fields must be an object of tier field values' };
780
759
  }
781
760
  // File fields take CONTENT from the agent; everything else is inline.
782
761
  const entryFields = {};
@@ -794,7 +773,7 @@ export function apply(ctx, config) {
794
773
  if (typeof args.environment === 'string')
795
774
  envelope.environment = args.environment;
796
775
  // Reject a bad id BEFORE any file IO, and roll back written files when
797
- // writeEntry fails — a rejected registration must not leave orphan
776
+ // the write fails — a rejected registration must not leave orphan
798
777
  // credential files on disk.
799
778
  try {
800
779
  assertValidProfileName(profileName);
@@ -802,10 +781,52 @@ export function apply(ctx, config) {
802
781
  catch (err) {
803
782
  return { ok: false, message: String(err?.message ?? err) };
804
783
  }
784
+ // ── rw tier: approval-gated registration request (hub mode only) ──────
785
+ // Nothing is written to the credential store here. The fields are
786
+ // validated exactly as a real write would be (staging + provider
787
+ // content hooks + zod schema), then queued on the hub; a human reviews
788
+ // the actual content in the admin UI and only an approval writes the
789
+ // tier. Staging files are removed before returning either way.
790
+ if (args.tier === 'rw') {
791
+ if (!(backend instanceof HubBackend)) {
792
+ return { ok: false, message: 'The rw tier is human-managed: ask the operator to register it in the admin UI (凭证管理 settings section). Agent-submitted rw registration requests require hub mode; this registry is local yaml.' };
793
+ }
794
+ const reason = typeof args.reason === 'string' ? args.reason : undefined;
795
+ let written = [];
796
+ try {
797
+ if (Object.keys(contentFiles).length > 0) {
798
+ written = await writeContentFiles(contentDir, kind, profileName, 'rw', descriptor.fileFields ?? [], contentFiles, entryFields, providers.get(kind));
799
+ }
800
+ const provider = providers.get(kind);
801
+ buildProfile(provider, kind, profileName, 'rw', entryFields, backend.label);
802
+ // The hub stores CONTENT; convert the staged paths back.
803
+ const requestFields = { ...entryFields };
804
+ for (const ff of descriptor.fileFields ?? []) {
805
+ const p = requestFields[ff];
806
+ if (typeof p === 'string' && p !== '')
807
+ requestFields[ff] = await readFile(expandHome(p), 'utf8');
808
+ }
809
+ const id = await backend.submitRequest({
810
+ kind,
811
+ name: profileName,
812
+ tier: 'rw',
813
+ fields: requestFields,
814
+ ...(Object.keys(envelope).length > 0 ? { envelope } : {}),
815
+ ...(reason !== undefined ? { reason } : {}),
816
+ });
817
+ return { ok: true, message: `rw registration request for ${kind}/${profileName} submitted (id ${id}). It takes effect ONLY after a human approves it in the access admin UI (凭证管理 settings section) — tell the operator it is waiting. Do not retry; poll list_access to see when the rw tier appears.` };
818
+ }
819
+ catch (err) {
820
+ return { ok: false, message: `registration request failed: ${String(err?.message ?? err)}` };
821
+ }
822
+ finally {
823
+ await rollbackContentFiles(contentDir, kind, profileName, 'rw', written);
824
+ }
825
+ }
805
826
  let written = [];
806
827
  try {
807
828
  if (Object.keys(contentFiles).length > 0) {
808
- written = await writeContentFiles(credentialsDir, kind, profileName, 'ro', descriptor.fileFields ?? [], contentFiles, entryFields, providers.get(kind));
829
+ written = await writeContentFiles(contentDir, kind, profileName, 'ro', descriptor.fileFields ?? [], contentFiles, entryFields, providers.get(kind));
809
830
  }
810
831
  // writeEntry validates against the provider schema BEFORE touching
811
832
  // the registry; its errors carry zod issue paths + messages, never
@@ -813,7 +834,7 @@ export function apply(ctx, config) {
813
834
  await handle.writeEntry(kind, profileName, 'ro', entryFields, Object.keys(envelope).length > 0 ? envelope : undefined);
814
835
  }
815
836
  catch (err) {
816
- await rollbackContentFiles(credentialsDir, kind, profileName, 'ro', written);
837
+ await rollbackContentFiles(contentDir, kind, profileName, 'ro', written);
817
838
  return { ok: false, message: `registration failed: ${String(err?.message ?? err)}` };
818
839
  }
819
840
  return { ok: true, message: `Registered the ro tier of ${kind}/${profileName}. Verify it with a read command before relying on it.` };
@@ -932,21 +953,22 @@ export function apply(ctx, config) {
932
953
  assertValidProfileName(name);
933
954
  let writtenFiles = [];
934
955
  if (isPlainObject(contentFiles)) {
935
- writtenFiles = await writeContentFiles(credentialsDir, kind, name, tier, provider?.fileFields ?? [], contentFiles, entryFields, provider);
956
+ writtenFiles = await writeContentFiles(contentDir, kind, name, tier, provider?.fileFields ?? [], contentFiles, entryFields, provider);
936
957
  }
937
958
  // Write-only-after-save preserve: file fields never come back
938
959
  // from the UI (getEntry withholds them), so an edit request
939
960
  // cannot carry them. Carry over the stored path for any declared
940
961
  // file field the request omits — otherwise the tier-replace
941
- // write would silently drop the credential.
962
+ // write would silently drop the credential. Hub mode materializes
963
+ // the carry-over (the hub backend's putTier re-uploads file
964
+ // CONTENT read from the path, so the file must actually exist;
965
+ // the cache dir is TTL-bound, so this leaves no permanent copy).
942
966
  if (provider?.fileFields?.length) {
943
- const existing = await loadRegistry(registryFile);
944
- const existingEntry = existing?.[kind]?.[name];
945
- const existingTier = isPlainObject(existingEntry) ? existingEntry[tier] : undefined;
946
- if (isPlainObject(existingTier)) {
967
+ const existing = await backend.loadTier(kind, name, tier, { materialize: source === 'hub' }).catch(() => null);
968
+ if (existing !== null) {
947
969
  for (const ff of provider.fileFields) {
948
- if (entryFields[ff] === undefined && typeof existingTier[ff] === 'string') {
949
- entryFields[ff] = existingTier[ff];
970
+ if (entryFields[ff] === undefined && typeof existing.fields[ff] === 'string') {
971
+ entryFields[ff] = existing.fields[ff];
950
972
  }
951
973
  }
952
974
  }
@@ -956,7 +978,7 @@ export function apply(ctx, config) {
956
978
  await handle.writeEntry(kind, name, tier, entryFields, Object.keys(envelope).length > 0 ? envelope : undefined);
957
979
  }
958
980
  catch (err) {
959
- await rollbackContentFiles(credentialsDir, kind, name, tier, writtenFiles);
981
+ await rollbackContentFiles(contentDir, kind, name, tier, writtenFiles);
960
982
  throw err;
961
983
  }
962
984
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -1004,6 +1026,87 @@ export function apply(ctx, config) {
1004
1026
  }
1005
1027
  },
1006
1028
  }));
1029
+ // ── Registration-request proxy routes (hub mode only) ──────────────────
1030
+ // The approval UI for agent-submitted rw registration requests lives in
1031
+ // the dsh settings section (ops-access-ui); these routes proxy the hub's
1032
+ // /requests API so the browser never needs the hub's admin token. Yaml
1033
+ // mode has no remote queue — the routes are simply not mounted there.
1034
+ if (backend instanceof HubBackend) {
1035
+ wctx.effect(() => wctx.webServer.register({
1036
+ kind: 'exact',
1037
+ path: '/ops-access/admin/requests',
1038
+ handler: async (req, res) => {
1039
+ try {
1040
+ if (req.method !== 'GET') {
1041
+ sendJsonError(res, 405, new Error('method not allowed'));
1042
+ return;
1043
+ }
1044
+ const list = await backend.listRequests('pending');
1045
+ res.writeHead(200, { 'content-type': 'application/json' });
1046
+ res.end(JSON.stringify(list));
1047
+ }
1048
+ catch (err) {
1049
+ sendJsonError(res, 500, err);
1050
+ }
1051
+ },
1052
+ }));
1053
+ wctx.effect(() => wctx.webServer.register({
1054
+ kind: 'exact',
1055
+ path: '/ops-access/admin/requests/detail',
1056
+ handler: async (req, res) => {
1057
+ try {
1058
+ if (req.method !== 'GET') {
1059
+ sendJsonError(res, 405, new Error('method not allowed'));
1060
+ return;
1061
+ }
1062
+ const id = new URL(req.url, 'http://localhost').searchParams.get('id');
1063
+ if (!id) {
1064
+ sendJsonError(res, 400, new Error('id query parameter is required'));
1065
+ return;
1066
+ }
1067
+ // Full field values cross here — pre-approval review is exactly
1068
+ // the moment a human must see the secret material being asked for.
1069
+ const request = await backend.getRequest(id);
1070
+ res.writeHead(200, { 'content-type': 'application/json' });
1071
+ res.end(JSON.stringify(request));
1072
+ }
1073
+ catch (err) {
1074
+ sendJsonError(res, 500, err);
1075
+ }
1076
+ },
1077
+ }));
1078
+ wctx.effect(() => wctx.webServer.register({
1079
+ kind: 'exact',
1080
+ path: '/ops-access/admin/requests/decide',
1081
+ handler: async (req, res) => {
1082
+ try {
1083
+ if (req.method !== 'POST') {
1084
+ sendJsonError(res, 405, new Error('method not allowed'));
1085
+ return;
1086
+ }
1087
+ const body = await readRequestBody(req);
1088
+ let parsed;
1089
+ try {
1090
+ parsed = JSON.parse(body);
1091
+ }
1092
+ catch {
1093
+ sendJsonError(res, 400, new Error('request body must be valid JSON'));
1094
+ return;
1095
+ }
1096
+ if (typeof parsed.id !== 'string' || typeof parsed.approved !== 'boolean') {
1097
+ sendJsonError(res, 400, new Error('id (string) and approved (boolean) are required'));
1098
+ return;
1099
+ }
1100
+ const decided = await backend.decideRequest(parsed.id, parsed.approved);
1101
+ res.writeHead(200, { 'content-type': 'application/json' });
1102
+ res.end(JSON.stringify(decided ? { ok: true } : { ok: false, error: 'request not found or already decided' }));
1103
+ }
1104
+ catch (err) {
1105
+ sendJsonError(res, 500, err);
1106
+ }
1107
+ },
1108
+ }));
1109
+ }
1007
1110
  });
1008
1111
  ctx.on('agent/pre-step', async (payload, next) => {
1009
1112
  const decision = await next();
@@ -1033,7 +1136,7 @@ export function apply(ctx, config) {
1033
1136
  out.push(freezeMessage({ ...message, content }));
1034
1137
  out.push(createUserMessage({
1035
1138
  source: { kind: 'plugin', plugin: name, form: 'recall' },
1036
- content: [{ type: 'text', text: await renderAccessReferences(handle, references) }],
1139
+ content: [{ type: 'text', text: await renderAccessReferences(handle, providers, references) }],
1037
1140
  }));
1038
1141
  }
1039
1142
  return changed ? { kind: 'enter', messages: out } : decision;