@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/README.i18n.yaml +4 -0
- package/README.md +42 -0
- package/README.zh.md +42 -0
- package/cordis.patch.yml +1 -0
- package/lib/index.d.ts +75 -0
- package/lib/index.js +1008 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +28 -0
- package/lib/mention.d.ts +59 -0
- package/lib/mention.js +93 -0
- package/lib/types.d.ts +268 -0
- package/lib/types.js +10 -0
- package/package.json +73 -0
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
|
+
}
|