@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/README.md +9 -1
- package/README.zh.md +9 -1
- package/lib/backend.d.ts +113 -0
- package/lib/hub-backend.d.ts +83 -0
- package/lib/index.d.ts +44 -12
- package/lib/index.js +397 -294
- package/lib/types.d.ts +34 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ops access capability seam.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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
|
|
16
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
416
|
-
//
|
|
417
|
-
// admin does not need a zod message to fix
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
439
|
-
//
|
|
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,
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
:
|
|
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
|
|
538
|
+
throw new Error(`ops-access: no ${tier} tier for profile "${profileName}" (kind "${kind}") in ${backend.label}${hint}`);
|
|
490
539
|
}
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
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
|
|
597
|
-
// never raw field values.
|
|
598
|
-
|
|
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
|
-
|
|
618
|
+
probe = { ...probed, probedAt: new Date().toISOString() };
|
|
611
619
|
}
|
|
612
|
-
|
|
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
|
|
616
|
-
if (
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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(
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
//
|
|
646
|
-
//
|
|
647
|
-
let
|
|
641
|
+
// A source failure degrades to an empty list — canResolve reports the
|
|
642
|
+
// failure per tier.
|
|
643
|
+
let entries = [];
|
|
648
644
|
try {
|
|
649
|
-
|
|
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
|
|
656
|
-
if (!providers.has(kind))
|
|
649
|
+
for (const entry of entries) {
|
|
650
|
+
if (!providers.has(entry.kind))
|
|
657
651
|
continue;
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
676
|
+
let loaded;
|
|
695
677
|
try {
|
|
696
|
-
|
|
678
|
+
loaded = await backend.loadTier(kind, profileName, tier, { materialize: false });
|
|
697
679
|
}
|
|
698
680
|
catch {
|
|
699
|
-
//
|
|
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 (
|
|
704
|
-
return null;
|
|
705
|
-
const entry = registry[kind]?.[profileName];
|
|
706
|
-
if (!isPlainObject(entry))
|
|
685
|
+
if (loaded === null)
|
|
707
686
|
return null;
|
|
708
|
-
|
|
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
|
|
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 (
|
|
728
|
-
result.displayName =
|
|
729
|
-
if (
|
|
730
|
-
result.description = description;
|
|
731
|
-
if (
|
|
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
|
|
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
|
-
//
|
|
744
|
-
//
|
|
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).
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
944
|
-
|
|
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
|
|
949
|
-
entryFields[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(
|
|
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;
|