@elinpf/dsh-ops-access 0.1.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 ADDED
@@ -0,0 +1,1008 @@
1
+ /**
2
+ * Ops access capability seam.
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.
10
+ *
11
+ * The registry file is re-read, re-parsed, and re-validated on every call —
12
+ * edits take effect immediately, nothing is cached.
13
+ *
14
+ * 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).
17
+ *
18
+ * Registry format:
19
+ *
20
+ * ```yaml
21
+ * version: 1
22
+ * k8s:
23
+ * prod:
24
+ * description: 生产集群
25
+ * environment: prod
26
+ * ro:
27
+ * kubeconfig: ~/.dsh-ops/credentials/k8s/prod/ro/kubeconfig
28
+ * rw:
29
+ * kubeconfig: ~/.dsh-ops/credentials/k8s/prod/rw/kubeconfig
30
+ * ```
31
+ *
32
+ * Every top-level section besides `version` is a kind; keys inside a section
33
+ * are profile names. `description` and `environment` are envelope fields
34
+ * on the entry; `ro` and `rw` are tier sub-objects holding the
35
+ * provider-specific fields for that tier.
36
+ *
37
+ * @module @elinpf/dsh-ops-access
38
+ */
39
+ import { readFile, writeFile, mkdir, rm, rmdir } from 'node:fs/promises';
40
+ import os from 'node:os';
41
+ import z from '@deepseek-ai/schemastery';
42
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
43
+ import { z as zod } from 'zod';
44
+ import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm';
45
+ import { defineTool } from '@deepseek-ai/dsh-tools';
46
+ import { formatAccessMention, parseAccessReferenceText } from './mention.js';
47
+ // ── Plugin identity ───────────────────────────────────────────────────────────
48
+ export const name = 'ops-access';
49
+ export const inject = ['tools'];
50
+ export const Config = z.object({
51
+ registryFile: z.string().default('~/.dsh-ops/access.yaml'),
52
+ credentialsDir: z.string().default('~/.dsh-ops/credentials'),
53
+ });
54
+ /** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
55
+ function probeOf(tierRaw) {
56
+ if (!isPlainObject(tierRaw))
57
+ return undefined;
58
+ const p = tierRaw.probe;
59
+ if (!isPlainObject(p))
60
+ return undefined;
61
+ const probe = p;
62
+ if (probe.status !== 'verified' && probe.status !== 'mismatch' && probe.status !== 'unverifiable')
63
+ return undefined;
64
+ if (typeof probe.probedAt !== 'string')
65
+ return undefined;
66
+ const out = { status: probe.status, probedAt: probe.probedAt };
67
+ if (typeof probe.detail === 'string')
68
+ out.detail = probe.detail;
69
+ return out;
70
+ }
71
+ // ── Provider registration helper ─────────────────────────────────────────────
72
+ /**
73
+ * Register a provider into the seam from a provider plugin's `apply()`. The
74
+ * preset mounts sibling rows concurrently, so a static inject on 'opsAccess'
75
+ * can deadlock the loader against the definition row — this defers through
76
+ * `ctx.inject` and ties the registration to the plugin's effect lifecycle.
77
+ * Provider packages should call this and nothing else.
78
+ */
79
+ export function registerAccessProvider(ctx, provider) {
80
+ ctx.inject(['opsAccess'], (pctx) => {
81
+ pctx.effect(() => pctx.opsAccess.register(provider));
82
+ });
83
+ }
84
+ /**
85
+ * Register an access broker (the gate) from the gate plugin's `apply()`. Same
86
+ * deferred-mount discipline as {@link registerAccessProvider}: the preset
87
+ * mounts sibling rows concurrently, so a static inject on 'opsAccess' can
88
+ * deadlock the loader against the definition row — this defers through
89
+ * `ctx.inject` and ties the registration to the plugin's effect lifecycle.
90
+ */
91
+ export function registerAccessBroker(ctx, broker) {
92
+ ctx.inject(['opsAccess'], (pctx) => {
93
+ pctx.effect(() => pctx.opsAccess.registerBroker(broker));
94
+ });
95
+ }
96
+ // ── Helpers ──────────────────────────────────────────────────────────────────
97
+ /** Expand a leading `~` (or `~/`) to the user's home directory. */
98
+ export function expandHome(p) {
99
+ const home = process.env.HOME ?? os.homedir();
100
+ if (p === '~')
101
+ return home;
102
+ if (p.startsWith('~/'))
103
+ return home + p.slice(1);
104
+ return p;
105
+ }
106
+ function isPlainObject(value) {
107
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
108
+ }
109
+ /**
110
+ * Read and parse the registry file. Returns null when the file does not
111
+ * exist so callers can pick their own discipline (list → empty, resolve →
112
+ * error). Never includes raw file text in errors.
113
+ */
114
+ async function loadRegistry(file) {
115
+ let text;
116
+ try {
117
+ text = await readFile(file, 'utf8');
118
+ }
119
+ catch (err) {
120
+ if (err?.code === 'ENOENT')
121
+ return null;
122
+ throw new Error(`ops-access: failed to read registry file ${file}: ${err?.message ?? err}`);
123
+ }
124
+ let doc;
125
+ try {
126
+ doc = parseYaml(text);
127
+ }
128
+ catch (err) {
129
+ // First line only — the yaml library appends a source snippet to its
130
+ // messages, and raw registry text must not leak into errors.
131
+ const summary = String(err?.message ?? err).split('\n')[0];
132
+ throw new Error(`ops-access: failed to parse registry file ${file}: ${summary}`);
133
+ }
134
+ // An empty file parses to null — treat it as an empty registry.
135
+ if (doc == null)
136
+ return {};
137
+ if (!isPlainObject(doc)) {
138
+ throw new Error(`ops-access: registry file ${file} must contain a top-level mapping`);
139
+ }
140
+ const registry = {};
141
+ for (const [kind, section] of Object.entries(doc)) {
142
+ if (kind === 'version')
143
+ continue;
144
+ if (!isPlainObject(section)) {
145
+ throw new Error(`ops-access: section "${kind}" in registry file ${file} must be a mapping of profile names`);
146
+ }
147
+ registry[kind] = section;
148
+ }
149
+ return registry;
150
+ }
151
+ /**
152
+ * Validate one tier sub-object against the provider schema and build the
153
+ * profile. `raw` carries only the provider fields; the envelope
154
+ * (description/environment) lives on the parent entry and is passed separately.
155
+ */
156
+ function buildProfile(provider, kind, profileName, tier, raw, file, parentEntry) {
157
+ if (!isPlainObject(raw)) {
158
+ throw new Error(`ops-access: entry ${kind}.${profileName} in registry file ${file} must be a mapping`);
159
+ }
160
+ const result = provider.schema.safeParse(raw);
161
+ if (!result.success) {
162
+ const issues = result.error.issues
163
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
164
+ .join('; ');
165
+ throw new Error(`ops-access: invalid entry ${kind}.${profileName} in registry file ${file}: ${issues}`);
166
+ }
167
+ const fields = provider.process
168
+ ? provider.process(result.data, profileName)
169
+ : result.data;
170
+ const profile = { kind, name: profileName, tier, fields };
171
+ const env = parentEntry ?? {};
172
+ if (typeof env.name === 'string')
173
+ profile.displayName = env.name;
174
+ if (typeof env.description === 'string')
175
+ profile.description = env.description;
176
+ if (typeof env.environment === 'string')
177
+ profile.environment = env.environment;
178
+ return profile;
179
+ }
180
+ /** Serialize a registry back to its YAML file with the version header. */
181
+ async function saveRegistry(file, registry) {
182
+ const doc = { version: 1 };
183
+ for (const [kind, section] of Object.entries(registry)) {
184
+ doc[kind] = section;
185
+ }
186
+ await writeFile(file, stringifyYaml(doc), 'utf8');
187
+ }
188
+ /** Read the full HTTP request body as a string. */
189
+ function readRequestBody(req) {
190
+ return new Promise((resolve, reject) => {
191
+ let data = '';
192
+ req.on('data', (chunk) => { if (chunk !== undefined)
193
+ data += chunk; });
194
+ req.on('end', () => resolve(data));
195
+ req.on('error', reject);
196
+ });
197
+ }
198
+ /** Send a JSON error response — message from buildProfile carries zod paths, never field values. */
199
+ function sendJsonError(res, status, err) {
200
+ res.writeHead(status, { 'content-type': 'application/json' });
201
+ res.end(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
202
+ }
203
+ /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
204
+ function buildEnvelope(sources) {
205
+ const envelope = {};
206
+ for (const source of sources) {
207
+ if (!isPlainObject(source))
208
+ continue;
209
+ if (envelope.name === undefined && typeof source.name === 'string')
210
+ envelope.name = source.name;
211
+ if (envelope.description === undefined && typeof source.description === 'string')
212
+ envelope.description = source.description;
213
+ if (envelope.environment === undefined && typeof source.environment === 'string')
214
+ envelope.environment = source.environment;
215
+ }
216
+ return envelope;
217
+ }
218
+ /** Split "kind/name" on the FIRST slash — profile names may contain '@' etc. */
219
+ function parseProfile(raw) {
220
+ if (typeof raw !== 'string')
221
+ return undefined;
222
+ const slash = raw.indexOf('/');
223
+ if (slash <= 0 || slash === raw.length - 1)
224
+ return undefined;
225
+ return { kind: raw.slice(0, slash), profileName: raw.slice(slash + 1) };
226
+ }
227
+ /**
228
+ * Write credential CONTENT to managed files under
229
+ * `<credentialsDir>/<kind>/<name>/<tier>/<field>` and record the resulting
230
+ * paths in entryFields. Shared by the admin POST route (the human writer)
231
+ * and the register_access tool (the agent writer). Only fields the provider
232
+ * declared in fileFields may be content-written, and field names are
233
+ * charset-guarded against path escape. Files are written 0600 — they carry
234
+ * secret material.
235
+ */
236
+ /**
237
+ * The profile name is the entry's stable id: it lands in credential file
238
+ * paths (credentials/<kind>/<name>/<tier>/<field>) and in mention syntax
239
+ * (@[kind/name]), so reject anything path- or syntax-hostile. Writer paths
240
+ * call this BEFORE any file IO — a bad name must not leave orphan files.
241
+ */
242
+ function assertValidProfileName(profileName) {
243
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._@-]*$/.test(profileName)) {
244
+ throw new Error(`ops-access: invalid profile name "${profileName}" — must start with a letter or digit and contain only letters, digits, '.', '_', '-', '@'`);
245
+ }
246
+ }
247
+ async function writeContentFiles(credentialsDir, kind, profileName, tier, fileFields, contentFiles, entryFields, provider) {
248
+ const allowed = new Set(fileFields);
249
+ const written = [];
250
+ for (const [fieldName, content] of Object.entries(contentFiles)) {
251
+ // Empty content means "untouched" (the edit form leaves saved file
252
+ // fields blank) — never clobber a stored credential with it.
253
+ if (typeof content !== 'string' || content.trim() === '')
254
+ continue;
255
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(fieldName)) {
256
+ throw new Error(`ops-access: invalid file field name "${fieldName}"`);
257
+ }
258
+ if (!allowed.has(fieldName)) {
259
+ throw new Error(`ops-access: "${fieldName}" is not a declared file field for kind "${kind}" (declared: ${fileFields.join(', ') || '(none)'})`);
260
+ }
261
+ // Provider-declared write-time normalization runs FIRST — validator
262
+ // and disk both see the normalized bytes.
263
+ const normalized = provider?.normalizeTrailingNewline ? content.replace(/[\r\n]+$/, '') + '\n' : content;
264
+ // Save-time content validation (provider hook, possibly async — ssh
265
+ // runs ssh-keygen): reject corrupt pastes BEFORE anything lands on disk.
266
+ const problem = await provider?.validateContent?.(fieldName, normalized);
267
+ if (problem) {
268
+ throw new Error(`ops-access: invalid content for ${kind}/${profileName} ${tier} ${fieldName}: ${problem}`);
269
+ }
270
+ const dir = credentialsDir + '/' + kind + '/' + profileName + '/' + tier;
271
+ await mkdir(dir, { recursive: true });
272
+ const filePath = dir + '/' + fieldName;
273
+ await writeFile(filePath, normalized, { encoding: 'utf8', mode: 0o600 });
274
+ written.push(filePath);
275
+ entryFields[fieldName] = filePath;
276
+ }
277
+ return written;
278
+ }
279
+ /**
280
+ * Roll back files writeContentFiles just wrote when the accompanying
281
+ * writeEntry fails — a rejected registration must not leave orphan
282
+ * credential files on disk. Also removes the directories this write created
283
+ * (rmdir refuses non-empty ones, so pre-existing content is never touched).
284
+ */
285
+ async function rollbackContentFiles(credentialsDir, kind, profileName, tier, written) {
286
+ for (const filePath of written)
287
+ await rm(filePath, { force: true });
288
+ for (const dir of [
289
+ credentialsDir + '/' + kind + '/' + profileName + '/' + tier,
290
+ credentialsDir + '/' + kind + '/' + profileName,
291
+ credentialsDir + '/' + kind,
292
+ ]) {
293
+ try {
294
+ await rmdir(dir);
295
+ }
296
+ catch { /* non-empty or already gone — leave it */ }
297
+ }
298
+ }
299
+ // ── Mention injection (agent/pre-step) ──────────────────────────────────────
300
+ /**
301
+ * Render the envelope context for referenced profiles. Envelope fields only —
302
+ * fields (paths, connection params) never cross into model context, keeping
303
+ * the structural secrecy discipline. Unknown profiles degrade to a note, not
304
+ * an error: a stale mention must not block the step.
305
+ *
306
+ * Reads through `listAll()`, not `resolve()`: mention rendering is metadata
307
+ * display, not credential issuance — it must never consult the broker, or an
308
+ * approval-required profile (ssh) would render as "not found" simply because
309
+ * the session holds no grant. listAll (not list) so rw-only entries render
310
+ * too — they exist in the registry, the agent just cannot read them yet.
311
+ */
312
+ async function renderAccessReferences(handle, references) {
313
+ // A listAll failure (unreadable/corrupt registry file) degrades the mention
314
+ // render to name-only lines — the registry being temporarily unreadable must
315
+ // not break prompt assembly for the whole turn.
316
+ const entries = await handle.listAll().catch(() => []);
317
+ const seen = new Set();
318
+ const lines = [];
319
+ for (const ref of references) {
320
+ const key = `${ref.kind}/${ref.name}`;
321
+ if (seen.has(key))
322
+ continue;
323
+ seen.add(key);
324
+ const entry = entries.find((e) => e.kind === ref.kind && e.name === ref.name);
325
+ if (!entry) {
326
+ lines.push(`- ${key} — (not found in the access registry; run list_access to see available profiles)`);
327
+ continue;
328
+ }
329
+ const env = entry.envelope.environment ? ` [${entry.envelope.environment}]` : '';
330
+ const label = entry.envelope.name ? ` (${entry.envelope.name})` : '';
331
+ const desc = entry.envelope.description ? ` — ${entry.envelope.description}` : '';
332
+ const tierNote = !entry.tiers.ro.ok && entry.tiers.rw.ok
333
+ ? ' (no ro tier registered yet — derivable from rw via the register_access tool)'
334
+ : '';
335
+ lines.push(`- ${key}${label}${env}${desc}${tierNote}`);
336
+ }
337
+ return `<referenced-access>\nThe user explicitly referenced these access profiles (use them with the matching tools):\n${lines.join('\n')}\n</referenced-access>`;
338
+ }
339
+ // ── Plugin apply ─────────────────────────────────────────────────────────────
340
+ export function apply(ctx, config) {
341
+ const registryFile = expandHome(config.registryFile);
342
+ const credentialsDir = expandHome(config.credentialsDir);
343
+ const providers = new Map();
344
+ // At most one broker is active; a later registration replaces an earlier one.
345
+ // The replaced broker's disposer is folded into the replacement's, so each
346
+ // registration's effect cleanup runs exactly once even under replacement or
347
+ // HMR unload — honoring the cordis effect-lifecycle discipline.
348
+ let broker;
349
+ let clearBroker = () => { };
350
+ const handle = {
351
+ register(provider) {
352
+ if (providers.has(provider.kind)) {
353
+ throw new Error(`ops-access: provider for kind "${provider.kind}" is already registered`);
354
+ }
355
+ providers.set(provider.kind, provider);
356
+ return () => { providers.delete(provider.kind); };
357
+ },
358
+ registerBroker(next) {
359
+ // Replace the active broker: fold the previous disposer into this one so
360
+ // the prior registration's cleanup still runs (once) under replacement
361
+ // or HMR unload, and the guard prevents a stale disposer clobbering a
362
+ // later broker.
363
+ const prev = clearBroker;
364
+ broker = next;
365
+ let active = true;
366
+ const dispose = () => {
367
+ if (!active)
368
+ return;
369
+ active = false;
370
+ if (broker === next) {
371
+ broker = undefined;
372
+ clearBroker = () => { };
373
+ }
374
+ prev();
375
+ };
376
+ clearBroker = dispose;
377
+ return dispose;
378
+ },
379
+ async canResolve(kind, profileName, tier) {
380
+ const provider = providers.get(kind);
381
+ if (!provider)
382
+ return { ok: false };
383
+ // Load + locate the entry in its own try/catch: a missing or unparseable
384
+ // file is a structural "not resolvable" with no validation reason — the
385
+ // admin does not need a zod message to fix a file that isn't there.
386
+ let raw;
387
+ let parentEntry;
388
+ try {
389
+ const registry = await loadRegistry(registryFile);
390
+ if (registry === null)
391
+ return { ok: false };
392
+ const entry = registry[kind]?.[profileName];
393
+ if (!isPlainObject(entry))
394
+ return { ok: false };
395
+ parentEntry = entry;
396
+ raw = parentEntry[tier];
397
+ }
398
+ catch {
399
+ return { ok: false };
400
+ }
401
+ if (!isPlainObject(raw))
402
+ return { ok: false };
403
+ // Run the same buildProfile validation resolve would run — a precheck
404
+ // shallower than the real issuance approves grants that cannot be
405
+ // fulfilled. The profile itself is discarded: existence, not fields.
406
+ // A validation failure surfaces the reason (zod issue paths + messages,
407
+ // never raw field values) so the admin UI can show it.
408
+ try {
409
+ buildProfile(provider, kind, profileName, tier, raw, registryFile, parentEntry);
410
+ return { ok: true };
411
+ }
412
+ catch (err) {
413
+ return { ok: false, error: String(err?.message ?? err) };
414
+ }
415
+ },
416
+ async resolve(kind, profileName, agent) {
417
+ const provider = providers.get(kind);
418
+ if (!provider) {
419
+ const registered = [...providers.keys()].sort();
420
+ throw new Error(`ops-access: unknown kind "${kind}" (no provider registered; registered kinds: ${registered.join(', ') || '(none)'})`);
421
+ }
422
+ // Once a broker is registered it is consulted on EVERY resolve —
423
+ // including calls without an agent. The no-agent ruling (fail closed to
424
+ // ro, or deny outright) is policy, and policy lives in the broker, not
425
+ // here. Without a broker, rw is never issued at all.
426
+ let tier = 'ro';
427
+ if (broker) {
428
+ const decision = broker(kind, profileName, agent);
429
+ if (typeof decision === 'object') {
430
+ throw new Error(`ops-access: access denied for ${kind}/${profileName}: ${decision.deny}`);
431
+ }
432
+ if (decision === 'rw')
433
+ tier = 'rw';
434
+ }
435
+ const registry = await loadRegistry(registryFile);
436
+ if (registry === null) {
437
+ throw new Error(`ops-access: registry file not found: ${registryFile}`);
438
+ }
439
+ const section = registry[kind];
440
+ const entry = section?.[profileName];
441
+ if (!isPlainObject(entry)) {
442
+ const available = Object.keys(section ?? {}).sort();
443
+ const hint = tier === 'rw' ? ' — a grant was approved but no rw credential is registered; ask the operator to add it via the admin UI' : '';
444
+ throw new Error(`ops-access: no profile "${profileName}" for kind "${kind}" in registry file ${registryFile} (available: ${available.join(', ') || '(none)'})${hint}`);
445
+ }
446
+ const tierData = entry[tier];
447
+ if (!isPlainObject(tierData)) {
448
+ // On the rw tier the grant was already approved — say so, so the agent
449
+ // reports "no rw credential registered" to the operator instead of
450
+ // re-requesting a grant that can never be fulfilled. On the ro tier
451
+ // with rw present, point at the self-service derivation path.
452
+ const hint = tier === 'rw'
453
+ ? ' — a grant was approved but no rw credential is registered; ask the operator to add it via the admin UI'
454
+ : isPlainObject(entry.rw)
455
+ ? ' — 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'
456
+ : '';
457
+ throw new Error(`ops-access: no ${tier} tier for profile "${profileName}" (kind "${kind}") in registry file ${registryFile}${hint}`);
458
+ }
459
+ return buildProfile(provider, kind, profileName, tier, tierData, registryFile, entry);
460
+ },
461
+ async list() {
462
+ const registry = await loadRegistry(registryFile);
463
+ if (registry === null)
464
+ return [];
465
+ const profiles = [];
466
+ for (const [kind, section] of Object.entries(registry)) {
467
+ // Sections whose kind has no registered provider are skipped —
468
+ // an unrecognized kind must not fail the whole listing.
469
+ const provider = providers.get(kind);
470
+ if (!provider)
471
+ continue;
472
+ for (const [profileName, entry] of Object.entries(section)) {
473
+ // list() surfaces the agent-readable ro tier only.
474
+ if (!isPlainObject(entry))
475
+ continue;
476
+ const roData = entry.ro;
477
+ if (!isPlainObject(roData))
478
+ continue;
479
+ profiles.push(buildProfile(provider, kind, profileName, 'ro', roData, registryFile, entry));
480
+ }
481
+ }
482
+ return profiles;
483
+ },
484
+ help() {
485
+ const lines = [
486
+ 'Ops access registry — how to manage credentials',
487
+ '',
488
+ `File: ${registryFile}`,
489
+ 'Re-read, re-parsed, and re-validated on EVERY call — edit it with the fs tools and the change takes effect immediately, no restart.',
490
+ '',
491
+ 'Format:',
492
+ ' version: 1',
493
+ ' <kind>:',
494
+ ' <profile-id>: # stable id: letters/digits plus . _ - @; used in paths, mentions, grants',
495
+ ' name: display label, freely editable # optional, UI-facing only',
496
+ ' description: what this profile is for # optional, shown by list_access',
497
+ ' environment: prod | staging | ... # optional; the future audit gate reads this',
498
+ ' ro: # ro tier fields (agent-readable default)',
499
+ ' <kind-specific fields, see below>',
500
+ ' rw: # rw tier fields (grant-gated)',
501
+ ' probe: {...} # auto-managed capability check (ticket 10): status/detail/probedAt, written at save time — do not edit',
502
+ '',
503
+ 'Registered kinds and their entry fields:',
504
+ ];
505
+ const kinds = [...providers.values()].sort((a, b) => a.kind.localeCompare(b.kind));
506
+ if (kinds.length === 0) {
507
+ lines.push('- (none registered)');
508
+ }
509
+ for (const p of kinds) {
510
+ lines.push(`- ${p.kind}: ${p.fieldsDoc ?? '(no field docs provided by this provider)'}`);
511
+ if (p.derivationDoc)
512
+ lines.push(` derive ro: ${p.derivationDoc}`);
513
+ }
514
+ lines.push('');
515
+ lines.push('Agents register ro tiers with the register_access tool — rw tiers stay human-managed via the admin UI.');
516
+ lines.push('Secrets never go inline — fields carry file paths and connection params only, so logs and model context never contain secret material.');
517
+ return lines.join('\n');
518
+ },
519
+ async writeEntry(kind, profileName, tier, fields, envelope) {
520
+ const provider = providers.get(kind);
521
+ if (!provider) {
522
+ const registered = [...providers.keys()].sort();
523
+ throw new Error(`ops-access: unknown kind "${kind}" (no provider registered; registered kinds: ${registered.join(', ') || '(none)'})`);
524
+ }
525
+ assertValidProfileName(profileName);
526
+ // The tier sub-object carries only provider fields; the envelope
527
+ // (name/description/environment) lives on the parent entry.
528
+ const tierData = { ...fields };
529
+ // Read → merge → validate → write back. A missing file starts from an
530
+ // empty registry; an unparseable file throws (we will not overwrite a
531
+ // file we cannot read).
532
+ let registry = {};
533
+ const loaded = await loadRegistry(registryFile);
534
+ if (loaded !== null)
535
+ registry = loaded;
536
+ if (!registry[kind])
537
+ registry[kind] = {};
538
+ if (!isPlainObject(registry[kind][profileName]))
539
+ registry[kind][profileName] = {};
540
+ const entry = registry[kind][profileName];
541
+ entry[tier] = tierData;
542
+ // Envelope discipline: omitted = preserve, empty string = delete, else set.
543
+ // The admin UI always sends all three so the operator can clear them.
544
+ if (envelope?.name !== undefined) {
545
+ if (envelope.name === '')
546
+ delete entry.name;
547
+ else
548
+ entry.name = envelope.name;
549
+ }
550
+ if (envelope?.description !== undefined) {
551
+ if (envelope.description === '')
552
+ delete entry.description;
553
+ else
554
+ entry.description = envelope.description;
555
+ }
556
+ if (envelope?.environment !== undefined) {
557
+ if (envelope.environment === '')
558
+ delete entry.environment;
559
+ else
560
+ entry.environment = envelope.environment;
561
+ }
562
+ // Validate via buildProfile BEFORE writing — a schema failure must not
563
+ // touch the file. buildProfile throws with zod issue paths + messages,
564
+ // never raw field values. (The in-memory merged entry is what we
565
+ // validate, matching the spec's read→merge→validate→write sequence.)
566
+ const writtenProfile = buildProfile(provider, kind, profileName, tier, tierData, registryFile, entry);
567
+ // Capability probe (ticket 10): verify claims against reality at save
568
+ // time — the credential files are already on disk (the caller writes
569
+ // them first). A probe failure degrades to 'unverifiable', never a
570
+ // write rejection.
571
+ if (provider.probe) {
572
+ const probed = await provider.probe(writtenProfile.fields, tier)
573
+ .catch((err) => ({
574
+ status: 'unverifiable',
575
+ detail: err instanceof Error ? err.message.split('\n')[0] : String(err),
576
+ }));
577
+ tierData.probe = { ...probed, probedAt: new Date().toISOString() };
578
+ }
579
+ await saveRegistry(registryFile, registry);
580
+ },
581
+ async deleteEntry(kind, profileName, tier) {
582
+ const registry = await loadRegistry(registryFile);
583
+ if (registry === null)
584
+ return false;
585
+ const section = registry[kind];
586
+ if (!section || !(profileName in section))
587
+ return false;
588
+ const entry = section[profileName];
589
+ if (!isPlainObject(entry))
590
+ return false;
591
+ // Remove the tier sub-object and its managed credential files.
592
+ delete entry[tier];
593
+ const provider = providers.get(kind);
594
+ if (provider?.fileFields && provider.fileFields.length > 0) {
595
+ await rm(credentialsDir + '/' + kind + '/' + profileName + '/' + tier, { recursive: true, force: true });
596
+ }
597
+ // If neither tier remains, remove the whole entry, its credential
598
+ // directory, and drop empty sections.
599
+ const remaining = ['ro', 'rw'].filter((t) => entry[t] !== undefined);
600
+ if (remaining.length === 0) {
601
+ delete section[profileName];
602
+ if (Object.keys(section).length === 0)
603
+ delete registry[kind];
604
+ if (provider?.fileFields && provider.fileFields.length > 0) {
605
+ await rm(credentialsDir + '/' + kind + '/' + profileName, { recursive: true, force: true });
606
+ }
607
+ }
608
+ await saveRegistry(registryFile, registry);
609
+ return true;
610
+ },
611
+ async listAll() {
612
+ // Load the single registry for enumeration. A parse error degrades to
613
+ // an empty list — canResolve reports the failure per tier.
614
+ let registry = {};
615
+ try {
616
+ const r = await loadRegistry(registryFile);
617
+ if (r)
618
+ registry = r;
619
+ }
620
+ catch { /* canResolve reports the failure */ }
621
+ const result = [];
622
+ for (const kind of Object.keys(registry).sort()) {
623
+ if (!providers.has(kind))
624
+ continue;
625
+ const section = registry[kind];
626
+ if (!section)
627
+ continue;
628
+ for (const name of Object.keys(section).sort()) {
629
+ const entry = section[name];
630
+ if (!isPlainObject(entry))
631
+ continue;
632
+ const envelope = buildEnvelope([entry]);
633
+ const roStatus = await handle.canResolve(kind, name, 'ro');
634
+ const rwStatus = await handle.canResolve(kind, name, 'rw');
635
+ const rawEntry = entry;
636
+ const roProbe = probeOf(rawEntry.ro);
637
+ if (roProbe !== undefined)
638
+ roStatus.probe = roProbe;
639
+ const rwProbe = probeOf(rawEntry.rw);
640
+ if (rwProbe !== undefined)
641
+ rwStatus.probe = rwProbe;
642
+ result.push({ kind, name, envelope, tiers: { ro: roStatus, rw: rwStatus } });
643
+ }
644
+ }
645
+ return result;
646
+ },
647
+ listKinds() {
648
+ return [...providers.values()]
649
+ .sort((a, b) => a.kind.localeCompare(b.kind))
650
+ .map((p) => {
651
+ const descriptor = { kind: p.kind, jsonSchema: zod.toJSONSchema(p.schema), ...(p.fileFields ? { fileFields: p.fileFields } : {}) };
652
+ if (p.fieldsDoc !== undefined)
653
+ descriptor.fieldsDoc = p.fieldsDoc;
654
+ return descriptor;
655
+ });
656
+ },
657
+ async getEntry(kind, profileName, tier) {
658
+ const provider = providers.get(kind);
659
+ if (!provider)
660
+ return null;
661
+ let registry;
662
+ try {
663
+ registry = await loadRegistry(registryFile);
664
+ }
665
+ catch {
666
+ // Registry file unreadable/corrupt → the entry is unknowable; getEntry
667
+ // reports null (not found) rather than failing the caller's whole flow.
668
+ return null;
669
+ }
670
+ if (registry === null)
671
+ return null;
672
+ const entry = registry[kind]?.[profileName];
673
+ if (!isPlainObject(entry))
674
+ return null;
675
+ const parent = entry;
676
+ const raw = parent[tier];
677
+ if (!isPlainObject(raw))
678
+ return null;
679
+ // Return the tier's NON-file fields plus the parent's envelope. File
680
+ // fields (credential content) are write-only after save: content is
681
+ // never read back — not even the managed path — only the set status
682
+ // rides along so the UI can render "已保存,粘贴新内容以覆盖". This
683
+ // keeps stored credentials unreachable for anyone (or anything) that
684
+ // can merely reach the admin routes.
685
+ const { name: displayName, description, environment } = parent;
686
+ const fields = { ...raw };
687
+ const fileFields = {};
688
+ for (const ff of provider.fileFields ?? []) {
689
+ const stored = fields[ff];
690
+ fileFields[ff] = typeof stored === 'string' && stored.length > 0;
691
+ delete fields[ff];
692
+ }
693
+ const result = { fields, fileFields };
694
+ if (typeof displayName === 'string')
695
+ result.displayName = displayName;
696
+ if (typeof description === 'string')
697
+ result.description = description;
698
+ if (typeof environment === 'string')
699
+ result.environment = environment;
700
+ return result;
701
+ },
702
+ };
703
+ ctx.provide('opsAccess', handle);
704
+ // ── register_access tool (agent-facing ro-tier writer) ────────────────────
705
+ // The agent's self-service registration path: it derives a read-only
706
+ // credential from the rw one (per-kind recipe in the provider's
707
+ // derivationDoc, surfaced by help()) and writes the ro tier here.
708
+ // Deliberately ungated — the ro tier is the agent's default operating
709
+ // level and the operator can overwrite it from the admin UI at any time;
710
+ // the rw tier stays human-only (no tool writes it). Tool calls sit in the
711
+ // session event log, so every registration is reconstructable.
712
+ ctx.effect(() => ctx.tools.register(defineTool({
713
+ name: 'register_access',
714
+ 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 full file CONTENT, stored to a managed path automatically; other fields are inline values. Run list_access with help: true for per-kind field docs and derivation recipes.',
715
+ parameters: {
716
+ profile: { type: 'string', required: true, description: '"kind/id", e.g. "k8s/prod". The entry is created when it does not exist yet.' },
717
+ fields: { type: 'object', additionalProperties: true, required: true, description: 'The ro tier field values for this kind. File fields take full content, not paths.' },
718
+ description: { type: 'string', description: 'Optional envelope description (empty string clears it).' },
719
+ environment: { type: 'string', description: 'Optional envelope environment label (empty string clears it).' },
720
+ },
721
+ output: {
722
+ schema: {
723
+ type: 'object',
724
+ additionalProperties: false,
725
+ properties: {
726
+ ok: { type: 'boolean', required: true },
727
+ message: { type: 'string', required: true },
728
+ },
729
+ },
730
+ // Pure function of (args, value): same inputs, same text, no state touched.
731
+ render: (_args, value) => [{ type: 'text', text: value.message }],
732
+ },
733
+ async execute(args) {
734
+ const parsed = parseProfile(args.profile);
735
+ if (!parsed) {
736
+ return { ok: false, message: 'profile must be "kind/id", e.g. "k8s/prod"' };
737
+ }
738
+ const { kind, profileName } = parsed;
739
+ const kinds = handle.listKinds();
740
+ const descriptor = kinds.find((k) => k.kind === kind);
741
+ if (!descriptor) {
742
+ const registered = kinds.map((k) => k.kind).sort();
743
+ return { ok: false, message: `unknown kind "${kind}" (registered kinds: ${registered.join(', ') || '(none)'})` };
744
+ }
745
+ if (!isPlainObject(args.fields)) {
746
+ return { ok: false, message: 'fields must be an object of ro tier field values' };
747
+ }
748
+ // File fields take CONTENT from the agent; everything else is inline.
749
+ const entryFields = {};
750
+ const contentFiles = {};
751
+ const fileFieldSet = new Set(descriptor.fileFields ?? []);
752
+ for (const [fieldName, value] of Object.entries(args.fields)) {
753
+ if (fileFieldSet.has(fieldName) && typeof value === 'string')
754
+ contentFiles[fieldName] = value;
755
+ else
756
+ entryFields[fieldName] = value;
757
+ }
758
+ const envelope = {};
759
+ if (typeof args.description === 'string')
760
+ envelope.description = args.description;
761
+ if (typeof args.environment === 'string')
762
+ envelope.environment = args.environment;
763
+ // Reject a bad id BEFORE any file IO, and roll back written files when
764
+ // writeEntry fails — a rejected registration must not leave orphan
765
+ // credential files on disk.
766
+ try {
767
+ assertValidProfileName(profileName);
768
+ }
769
+ catch (err) {
770
+ return { ok: false, message: String(err?.message ?? err) };
771
+ }
772
+ let written = [];
773
+ try {
774
+ if (Object.keys(contentFiles).length > 0) {
775
+ written = await writeContentFiles(credentialsDir, kind, profileName, 'ro', descriptor.fileFields ?? [], contentFiles, entryFields, providers.get(kind));
776
+ }
777
+ // writeEntry validates against the provider schema BEFORE touching
778
+ // the registry; its errors carry zod issue paths + messages, never
779
+ // raw field values.
780
+ await handle.writeEntry(kind, profileName, 'ro', entryFields, Object.keys(envelope).length > 0 ? envelope : undefined);
781
+ }
782
+ catch (err) {
783
+ await rollbackContentFiles(credentialsDir, kind, profileName, 'ro', written);
784
+ return { ok: false, message: `registration failed: ${String(err?.message ?? err)}` };
785
+ }
786
+ return { ok: true, message: `Registered the ro tier of ${kind}/${profileName}. Verify it with a read command before relying on it.` };
787
+ },
788
+ })));
789
+ // ── Mention candidate route (GET /ops-access/list) ────────────────────────
790
+ // The browser's @ menu reads this. Preset-plane registration of a host
791
+ // webServer route: reaching the preset-realm opsAccess FROM the host plane
792
+ // would need stateful dsh internals (serviceForAgent), which dual-instance
793
+ // under an external package's node_modules — so the route lives here, next
794
+ // to the data. Envelope fields + ready-made mentions only; fields never
795
+ // cross. Mounted once per process with the standing preset mount.
796
+ ctx.inject(['webServer'], (wctx) => {
797
+ wctx.effect(() => wctx.webServer.register({
798
+ kind: 'exact',
799
+ path: '/ops-access/list',
800
+ handler: async (req, res) => {
801
+ const url = new URL(req.url, 'http://localhost');
802
+ const query = url.searchParams.get('query') ?? '';
803
+ const needle = query.toLocaleLowerCase();
804
+ // listAll, not list: the picker must also show entries that carry
805
+ // only an rw tier (operator-registered, ro not yet derived) — hiding
806
+ // them would make the rw→ro derivation flow unreachable from the UI.
807
+ // Tier readiness flags ride along so the picker can badge them;
808
+ // fields never cross (listAll is envelope + status only).
809
+ const entries = await handle.listAll();
810
+ // Probe verdicts ride along for ok tiers (ticket 10 review fix: the
811
+ // @ menu is one of the three display surfaces the ticket names).
812
+ const probeOf2 = (tiers) => {
813
+ const out = {};
814
+ if (tiers.ro.ok && tiers.ro.probe !== undefined)
815
+ out.ro = tiers.ro.probe.status;
816
+ if (tiers.rw.ok && tiers.rw.probe !== undefined)
817
+ out.rw = tiers.rw.probe.status;
818
+ return Object.keys(out).length > 0 ? out : undefined;
819
+ };
820
+ const candidates = entries
821
+ .filter((e) => needle === ''
822
+ || `${e.kind}/${e.name}`.toLocaleLowerCase().includes(needle)
823
+ || e.envelope.name?.toLocaleLowerCase().includes(needle) === true
824
+ || e.envelope.description?.toLocaleLowerCase().includes(needle) === true)
825
+ .map((e) => ({
826
+ kind: e.kind,
827
+ name: e.name,
828
+ ...e.envelope.name === undefined ? {} : { displayName: e.envelope.name },
829
+ ...e.envelope.description === undefined ? {} : { description: e.envelope.description },
830
+ ...e.envelope.environment === undefined ? {} : { environment: e.envelope.environment },
831
+ ro: e.tiers.ro.ok,
832
+ rw: e.tiers.rw.ok,
833
+ ...(() => { const p = probeOf2(e.tiers); return p === undefined ? {} : { probe: p }; })(),
834
+ mention: formatAccessMention({ kind: e.kind, name: e.name }),
835
+ }));
836
+ res.writeHead(200, { 'content-type': 'application/json' });
837
+ res.end(JSON.stringify(candidates));
838
+ },
839
+ }));
840
+ // ── Admin routes (GET /admin/list, GET /admin/kinds, POST+DELETE /admin/entry) ─
841
+ // The webServer matches by path only (no HTTP method), so the entry route
842
+ // dispatches on req.method. All responses and errors exclude field values
843
+ // — buildProfile errors carry zod issue paths + messages, never raw values.
844
+ wctx.effect(() => wctx.webServer.register({
845
+ kind: 'exact',
846
+ path: '/ops-access/admin/list',
847
+ handler: async (_req, res) => {
848
+ try {
849
+ const entries = await handle.listAll();
850
+ res.writeHead(200, { 'content-type': 'application/json' });
851
+ res.end(JSON.stringify(entries));
852
+ }
853
+ catch (err) {
854
+ sendJsonError(res, 500, err);
855
+ }
856
+ },
857
+ }));
858
+ wctx.effect(() => wctx.webServer.register({
859
+ kind: 'exact',
860
+ path: '/ops-access/admin/kinds',
861
+ handler: async (_req, res) => {
862
+ try {
863
+ const kinds = handle.listKinds();
864
+ res.writeHead(200, { 'content-type': 'application/json' });
865
+ res.end(JSON.stringify(kinds));
866
+ }
867
+ catch (err) {
868
+ sendJsonError(res, 500, err);
869
+ }
870
+ },
871
+ }));
872
+ wctx.effect(() => wctx.webServer.register({
873
+ kind: 'exact',
874
+ path: '/ops-access/admin/entry',
875
+ handler: async (req, res) => {
876
+ try {
877
+ if (req.method === 'POST') {
878
+ const body = await readRequestBody(req);
879
+ let parsed;
880
+ try {
881
+ parsed = JSON.parse(body);
882
+ }
883
+ catch {
884
+ sendJsonError(res, 400, new Error('request body must be valid JSON'));
885
+ return;
886
+ }
887
+ const { kind, name, tier, fields, displayName, description, environment, contentFiles } = parsed;
888
+ if (typeof kind !== 'string' || typeof name !== 'string' || (tier !== 'ro' && tier !== 'rw')) {
889
+ sendJsonError(res, 400, new Error('kind (string), name (string), and tier ("ro"|"rw") are required'));
890
+ return;
891
+ }
892
+ const entryFields = isPlainObject(fields) ? fields : {};
893
+ // Content files: the UI sends credential file CONTENT (e.g. the full
894
+ // kubeconfig YAML) instead of a path. Write each to a managed file
895
+ // and store the path in entryFields. The id is validated BEFORE any
896
+ // file IO, and written files are rolled back when writeEntry fails —
897
+ // a rejected write must not leave orphan credential files on disk.
898
+ const provider = providers.get(kind);
899
+ assertValidProfileName(name);
900
+ let writtenFiles = [];
901
+ if (isPlainObject(contentFiles)) {
902
+ writtenFiles = await writeContentFiles(credentialsDir, kind, name, tier, provider?.fileFields ?? [], contentFiles, entryFields, provider);
903
+ }
904
+ // Write-only-after-save preserve: file fields never come back
905
+ // from the UI (getEntry withholds them), so an edit request
906
+ // cannot carry them. Carry over the stored path for any declared
907
+ // file field the request omits — otherwise the tier-replace
908
+ // write would silently drop the credential.
909
+ if (provider?.fileFields?.length) {
910
+ const existing = await loadRegistry(registryFile);
911
+ const existingEntry = existing?.[kind]?.[name];
912
+ const existingTier = isPlainObject(existingEntry) ? existingEntry[tier] : undefined;
913
+ if (isPlainObject(existingTier)) {
914
+ for (const ff of provider.fileFields) {
915
+ if (entryFields[ff] === undefined && typeof existingTier[ff] === 'string') {
916
+ entryFields[ff] = existingTier[ff];
917
+ }
918
+ }
919
+ }
920
+ }
921
+ const envelope = buildEnvelope([{ name: displayName, description, environment }]);
922
+ try {
923
+ await handle.writeEntry(kind, name, tier, entryFields, Object.keys(envelope).length > 0 ? envelope : undefined);
924
+ }
925
+ catch (err) {
926
+ await rollbackContentFiles(credentialsDir, kind, name, tier, writtenFiles);
927
+ throw err;
928
+ }
929
+ res.writeHead(200, { 'content-type': 'application/json' });
930
+ res.end(JSON.stringify({ ok: true }));
931
+ }
932
+ else if (req.method === 'GET') {
933
+ const url = new URL(req.url, 'http://localhost');
934
+ const kind = url.searchParams.get('kind');
935
+ const name = url.searchParams.get('name');
936
+ const tier = url.searchParams.get('tier');
937
+ if (!kind || !name || (tier !== 'ro' && tier !== 'rw')) {
938
+ sendJsonError(res, 400, new Error('kind, name, and tier ("ro"|"rw") query parameters are required'));
939
+ return;
940
+ }
941
+ const entry = await handle.getEntry(kind, name, tier);
942
+ if (entry === null) {
943
+ res.writeHead(200, { 'content-type': 'application/json' });
944
+ res.end(JSON.stringify(null));
945
+ }
946
+ else {
947
+ res.writeHead(200, { 'content-type': 'application/json' });
948
+ res.end(JSON.stringify(entry));
949
+ }
950
+ }
951
+ else if (req.method === 'DELETE') {
952
+ const url = new URL(req.url, 'http://localhost');
953
+ const kind = url.searchParams.get('kind');
954
+ const name = url.searchParams.get('name');
955
+ const tier = url.searchParams.get('tier');
956
+ if (!kind || !name || (tier !== 'ro' && tier !== 'rw')) {
957
+ sendJsonError(res, 400, new Error('kind, name, and tier ("ro"|"rw") query parameters are required'));
958
+ return;
959
+ }
960
+ const deleted = await handle.deleteEntry(kind, name, tier);
961
+ res.writeHead(200, { 'content-type': 'application/json' });
962
+ res.end(JSON.stringify(deleted ? { ok: true } : { ok: false, error: 'entry not found' }));
963
+ }
964
+ else {
965
+ sendJsonError(res, 405, new Error('method not allowed'));
966
+ }
967
+ }
968
+ catch (err) {
969
+ // buildProfile errors carry zod issue paths + messages, never field values.
970
+ sendJsonError(res, 400, err);
971
+ }
972
+ },
973
+ }));
974
+ });
975
+ ctx.on('agent/pre-step', async (payload, next) => {
976
+ const decision = await next();
977
+ if (decision.kind === 'reject')
978
+ return decision;
979
+ const messages = decision.messages;
980
+ const out = [];
981
+ let changed = false;
982
+ for (const message of messages) {
983
+ if (message.source?.kind !== 'user') {
984
+ out.push(message);
985
+ continue;
986
+ }
987
+ const references = [];
988
+ const content = message.content.map((block) => {
989
+ if (block.type !== 'text')
990
+ return block;
991
+ const parsed = parseAccessReferenceText(block.text);
992
+ references.push(...parsed.references);
993
+ return parsed.references.length === 0 ? block : { ...block, text: parsed.text };
994
+ });
995
+ if (references.length === 0) {
996
+ out.push(message);
997
+ continue;
998
+ }
999
+ changed = true;
1000
+ out.push(freezeMessage({ ...message, content }));
1001
+ out.push(createUserMessage({
1002
+ source: { kind: 'plugin', plugin: name, form: 'recall' },
1003
+ content: [{ type: 'text', text: await renderAccessReferences(handle, references) }],
1004
+ }));
1005
+ }
1006
+ return changed ? { kind: 'enter', messages: out } : decision;
1007
+ }, { prepend: true });
1008
+ }