@crewhaus/hangar-ui 0.5.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 +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect — the browsers that make "inspect ALL captured data" literally
|
|
3
|
+
* true: scope-audit, the prompt-cache rotation record, logs, skills,
|
|
4
|
+
* commands, preferences, settings.json, knowledge.json, identity.json,
|
|
5
|
+
* meta.json and environments.json — plus a generic raw browser for anything
|
|
6
|
+
* unrecognized.
|
|
7
|
+
*
|
|
8
|
+
* Three subtrees are deliberately unreachable and the screen says so rather
|
|
9
|
+
* than showing an empty folder: `secrets/`, the raw audit files (rendered
|
|
10
|
+
* only as verified records on the Security tab), and `.env` (presence
|
|
11
|
+
* booleans only, on the Credentials tab). The server refuses them — this
|
|
12
|
+
* screen only explains them, which is why the exclusions arrive as DATA.
|
|
13
|
+
*
|
|
14
|
+
* The other half of that honesty is `unmodelled`: a `.crewhaus/` entry this
|
|
15
|
+
* manager version has no rich view for is NAMED, with the raw browser one
|
|
16
|
+
* click away. "We have not modelled this yet" is a true sentence; an empty
|
|
17
|
+
* folder is not.
|
|
18
|
+
*
|
|
19
|
+
* `identity.json` displays the Ed25519 FINGERPRINT — the one that stamps
|
|
20
|
+
* every trace envelope. The key material never reaches this browser at all:
|
|
21
|
+
* the server projects it to presence booleans. Everything here is read-only
|
|
22
|
+
* except `settings.json`, which is human-owned configuration and therefore
|
|
23
|
+
* carries the same redacted diff plus typed confirmation the spec's
|
|
24
|
+
* human-owned paths do.
|
|
25
|
+
*
|
|
26
|
+
* And one rule sits above that editor: every document on this screen arrives
|
|
27
|
+
* MASKED. A masked read is a view, never a source for a write — replaying it
|
|
28
|
+
* would persist `[redacted]` over the operator's real env values and `***`
|
|
29
|
+
* over a hook command's token. So a settings document carrying a mask marker
|
|
30
|
+
* makes the editor read-only and says which file to edit instead. Refusing
|
|
31
|
+
* to write is the only honest answer available to a screen that was never
|
|
32
|
+
* shown what it would be overwriting.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { api } from "../api.js";
|
|
36
|
+
import {
|
|
37
|
+
asOf,
|
|
38
|
+
clear,
|
|
39
|
+
collapsible,
|
|
40
|
+
copyBtn,
|
|
41
|
+
dot,
|
|
42
|
+
el,
|
|
43
|
+
emptyState,
|
|
44
|
+
jsonPre,
|
|
45
|
+
numberedCode,
|
|
46
|
+
skeleton,
|
|
47
|
+
toast,
|
|
48
|
+
} from "../dom.js";
|
|
49
|
+
import { fmtCount, fmtRelativeTime } from "../util.js";
|
|
50
|
+
|
|
51
|
+
/** The store opened when the screen first paints — the one that answers
|
|
52
|
+
* "what is this agent allowed to do", which is what people come here for. */
|
|
53
|
+
const DEFAULT_STORE = "settings";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What the server's redaction leaves behind: `[redacted]` for a value under
|
|
57
|
+
* a credential-named key (`env`, `headers`, `*Token`, …) and `***` for a
|
|
58
|
+
* token-shaped span inside free text (a hook command, say). Either one in a
|
|
59
|
+
* document means the console was NOT shown what is on disk.
|
|
60
|
+
*/
|
|
61
|
+
const MASK_MARKS = ["[redacted]", "***"];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a served document may be written back, and why not when it may
|
|
65
|
+
* not. Pure — the decision is unit-tested rather than clicked at.
|
|
66
|
+
*
|
|
67
|
+
* The check is textual on purpose: it runs over the serialized document, so
|
|
68
|
+
* it catches a marker at any depth, in a key or a value, in a document this
|
|
69
|
+
* screen has no schema for. A false positive (a settings.json that really
|
|
70
|
+
* does contain `***`) costs one refusal that names the file to edit; a false
|
|
71
|
+
* negative costs the operator their real credentials, silently, behind a
|
|
72
|
+
* success toast.
|
|
73
|
+
*/
|
|
74
|
+
export function settingsWriteGuard(document, path) {
|
|
75
|
+
let serialized;
|
|
76
|
+
try {
|
|
77
|
+
serialized = JSON.stringify(document ?? {});
|
|
78
|
+
} catch {
|
|
79
|
+
// A document that will not serialize cannot be written back either.
|
|
80
|
+
return {
|
|
81
|
+
blocked: true,
|
|
82
|
+
marks: [],
|
|
83
|
+
reason: "this document could not be serialized, so the console cannot write it back",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const marks = MASK_MARKS.filter((mark) => serialized.includes(mark));
|
|
87
|
+
if (marks.length === 0) return { blocked: false, marks: [], reason: null };
|
|
88
|
+
const file = typeof path === "string" && path !== "" ? path : ".crewhaus/settings.json";
|
|
89
|
+
return {
|
|
90
|
+
blocked: true,
|
|
91
|
+
marks,
|
|
92
|
+
reason: `this document was served masked (${marks.join(" and ")}) — the manager redacts credential-named keys and token-shaped spans before they leave the server, so saving it from here would write the mask over the real values. Edit ${file} on the harness host instead; \`crewhaus permissions suggest\` writes the permission half.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function renderInspect(root, ctx) {
|
|
97
|
+
clear(root).appendChild(skeleton(6));
|
|
98
|
+
const res = await api.inspectIndex({ id: ctx.id });
|
|
99
|
+
clear(root);
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
root.appendChild(errorCard("The inspect index could not be read", res));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const view = res.body ?? {};
|
|
105
|
+
const state = {
|
|
106
|
+
store: DEFAULT_STORE,
|
|
107
|
+
entry: null,
|
|
108
|
+
rawPath: typeof view.root === "string" ? view.root : ".crewhaus",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const indexHost = el("div");
|
|
112
|
+
const storeHost = el("div", { class: "tab-section" });
|
|
113
|
+
const rawHost = el("div", { class: "tab-section" });
|
|
114
|
+
root.appendChild(indexHost);
|
|
115
|
+
root.appendChild(storeHost);
|
|
116
|
+
root.appendChild(rawHost);
|
|
117
|
+
|
|
118
|
+
const openStore = async (store, entry = null) => {
|
|
119
|
+
state.store = store;
|
|
120
|
+
state.entry = entry;
|
|
121
|
+
await paintStore(storeHost, ctx, state, openStore);
|
|
122
|
+
};
|
|
123
|
+
const openRaw = async (path) => {
|
|
124
|
+
state.rawPath = path;
|
|
125
|
+
await paintRaw(rawHost, ctx, state, openRaw);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
indexHost.appendChild(indexCard(view, openStore, openRaw));
|
|
129
|
+
await openStore(state.store);
|
|
130
|
+
await openRaw(state.rawPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A failed read, said out loud. No screen in this console fails silently. */
|
|
134
|
+
function errorCard(title, res) {
|
|
135
|
+
const message =
|
|
136
|
+
typeof res?.body?.error === "string"
|
|
137
|
+
? res.body.error
|
|
138
|
+
: typeof res?.body?.message === "string"
|
|
139
|
+
? res.body.message
|
|
140
|
+
: `HTTP ${res ? res.status : "?"}`;
|
|
141
|
+
return el("div", { class: "card error-card" }, [
|
|
142
|
+
el("h3", { class: "card-title" }, [dot("bad", title)]),
|
|
143
|
+
el("p", { class: "muted", text: message }),
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// The store index
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function indexCard(view, openStore, openRaw) {
|
|
152
|
+
const stores = Array.isArray(view.stores) ? view.stores : [];
|
|
153
|
+
const excluded = Array.isArray(view.excluded) ? view.excluded : [];
|
|
154
|
+
const unmodelled = Array.isArray(view.unmodelled) ? view.unmodelled : [];
|
|
155
|
+
const present = stores.filter((s) => s.present === true).length;
|
|
156
|
+
const nowMs = Date.now();
|
|
157
|
+
|
|
158
|
+
const tbody = el("tbody");
|
|
159
|
+
for (const store of stores) {
|
|
160
|
+
const name = el("button", {
|
|
161
|
+
class: "btn btn-ghost",
|
|
162
|
+
type: "button",
|
|
163
|
+
text: String(store.store ?? ""),
|
|
164
|
+
});
|
|
165
|
+
name.addEventListener("click", () => {
|
|
166
|
+
void openStore(String(store.store ?? ""));
|
|
167
|
+
});
|
|
168
|
+
tbody.appendChild(
|
|
169
|
+
el("tr", null, [
|
|
170
|
+
el("td", null, [name]),
|
|
171
|
+
el("td", null, [
|
|
172
|
+
store.present === true
|
|
173
|
+
? dot("ok", store.kind === "dir" ? "directory" : "file")
|
|
174
|
+
: dot("off", "not written yet"),
|
|
175
|
+
]),
|
|
176
|
+
el("td", { class: "mono", text: String(store.path ?? "") }),
|
|
177
|
+
el("td", {
|
|
178
|
+
class: "num",
|
|
179
|
+
text:
|
|
180
|
+
typeof store.entries === "number"
|
|
181
|
+
? fmtCount(store.entries)
|
|
182
|
+
: typeof store.bytes === "number"
|
|
183
|
+
? `${fmtCount(store.bytes)} B`
|
|
184
|
+
: "—",
|
|
185
|
+
}),
|
|
186
|
+
el("td", {
|
|
187
|
+
text:
|
|
188
|
+
typeof store.modifiedAt === "string" ? fmtRelativeTime(store.modifiedAt, nowMs) : "—",
|
|
189
|
+
}),
|
|
190
|
+
el("td", { class: "muted", text: String(store.what ?? "") }),
|
|
191
|
+
]),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const card = el("section", { class: "card ov-wide" }, [
|
|
196
|
+
el("h3", { class: "card-title" }, [
|
|
197
|
+
el("span", { text: "Inspect" }),
|
|
198
|
+
dot(present > 0 ? "ok" : "off", `${present} of ${stores.length} stores written`),
|
|
199
|
+
el("span", { class: "muted card-sub", text: String(view.root ?? ".crewhaus") }),
|
|
200
|
+
]),
|
|
201
|
+
stores.length === 0
|
|
202
|
+
? emptyState(
|
|
203
|
+
typeof view.note === "string" ? view.note : "No inspectable stores yet",
|
|
204
|
+
typeof view.verb === "string" ? view.verb : null,
|
|
205
|
+
)
|
|
206
|
+
: el("div", { class: "table-scroll" }, [
|
|
207
|
+
el("table", { class: "fleet" }, [
|
|
208
|
+
el(
|
|
209
|
+
"thead",
|
|
210
|
+
null,
|
|
211
|
+
el(
|
|
212
|
+
"tr",
|
|
213
|
+
null,
|
|
214
|
+
["Store", "State", "Path", "Size", "Changed", "What it holds"].map((h) =>
|
|
215
|
+
el("th", { text: h }),
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
tbody,
|
|
220
|
+
]),
|
|
221
|
+
]),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
if (unmodelled.length > 0) {
|
|
225
|
+
const chips = el("div", { class: "chip-group" });
|
|
226
|
+
for (const name of unmodelled) {
|
|
227
|
+
const chip = el("button", { class: "btn btn-ghost", type: "button", text: String(name) });
|
|
228
|
+
chip.addEventListener("click", () => {
|
|
229
|
+
void openRaw(`${String(view.root ?? ".crewhaus")}/${String(name)}`);
|
|
230
|
+
});
|
|
231
|
+
chips.appendChild(chip);
|
|
232
|
+
}
|
|
233
|
+
card.appendChild(
|
|
234
|
+
el("div", { class: "rollup" }, [
|
|
235
|
+
dot("unknown", "recognized, but no rich view here yet"),
|
|
236
|
+
el("span", {
|
|
237
|
+
class: "muted",
|
|
238
|
+
text:
|
|
239
|
+
typeof view.unmodelledNote === "string"
|
|
240
|
+
? view.unmodelledNote
|
|
241
|
+
: "open these with the raw browser below",
|
|
242
|
+
}),
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
card.appendChild(chips);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (excluded.length > 0) {
|
|
249
|
+
const list = el("div", { class: "check-list" });
|
|
250
|
+
for (const item of excluded) {
|
|
251
|
+
list.appendChild(
|
|
252
|
+
el("div", { class: "check" }, [
|
|
253
|
+
dot("off", String(item.path ?? "")),
|
|
254
|
+
el("div", { class: "muted reason", text: String(item.reason ?? "") }),
|
|
255
|
+
el("div", { class: "muted reason", text: `available on: ${String(item.where ?? "")}` }),
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
card.appendChild(
|
|
260
|
+
collapsible(
|
|
261
|
+
[
|
|
262
|
+
el("span", { class: "muted", text: "excluded from every inspect route — on purpose" }),
|
|
263
|
+
el("span", { class: "chip", text: String(excluded.length) }),
|
|
264
|
+
],
|
|
265
|
+
[list],
|
|
266
|
+
true,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return card;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// One store
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
async function paintStore(host, ctx, state, openStore) {
|
|
278
|
+
clear(host).appendChild(skeleton(4));
|
|
279
|
+
const res =
|
|
280
|
+
state.entry === null
|
|
281
|
+
? await api.inspectStore({ id: ctx.id, store: state.store })
|
|
282
|
+
: await api.inspectEntry({ id: ctx.id, store: state.store, name: state.entry });
|
|
283
|
+
clear(host);
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
host.appendChild(errorCard(`Could not read "${state.store}"`, res));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const view = res.body ?? {};
|
|
289
|
+
const title = state.entry === null ? state.store : `${state.store} / ${state.entry}`;
|
|
290
|
+
|
|
291
|
+
const head = el("h3", { class: "card-title" }, [
|
|
292
|
+
el("span", { text: String(title) }),
|
|
293
|
+
view.present === true ? dot("ok", "present") : dot("off", "nothing written yet"),
|
|
294
|
+
el("span", { class: "muted card-sub", text: String(view.path ?? "") }),
|
|
295
|
+
]);
|
|
296
|
+
const card = el("section", { class: "card ov-wide" }, [head]);
|
|
297
|
+
|
|
298
|
+
if (state.entry !== null) {
|
|
299
|
+
const back = el("button", { class: "btn btn-ghost", type: "button", text: "← all entries" });
|
|
300
|
+
back.addEventListener("click", () => {
|
|
301
|
+
void openStore(state.store, null);
|
|
302
|
+
});
|
|
303
|
+
card.appendChild(back);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (view.present !== true) {
|
|
307
|
+
card.appendChild(
|
|
308
|
+
emptyState(
|
|
309
|
+
typeof view.note === "string" ? view.note : "Nothing written here yet",
|
|
310
|
+
typeof view.verb === "string" ? view.verb : null,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
host.appendChild(card);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const entries = Array.isArray(view.entries) ? view.entries : [];
|
|
318
|
+
const files = Array.isArray(view.files) ? view.files : [];
|
|
319
|
+
const rows = entries.length > 0 ? entries : files;
|
|
320
|
+
if (rows.length > 0) {
|
|
321
|
+
card.appendChild(entryTable(rows, state, openStore));
|
|
322
|
+
if (view.truncated === true) {
|
|
323
|
+
card.appendChild(
|
|
324
|
+
el("p", {
|
|
325
|
+
class: "muted",
|
|
326
|
+
text: "the listing was capped — open the rest with the raw browser",
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (state.store === "identity" && view.document && typeof view.document === "object") {
|
|
333
|
+
card.appendChild(identityCard(view.document));
|
|
334
|
+
} else if (state.store === "settings" && state.entry === null) {
|
|
335
|
+
card.appendChild(settingsPanel(ctx, view, () => openStore("settings", null)));
|
|
336
|
+
} else if (view.document && typeof view.document === "object") {
|
|
337
|
+
card.appendChild(jsonPre(view.document));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (typeof view.text === "string" && view.text !== "") {
|
|
341
|
+
card.appendChild(numberedCode(view.text, String(title)));
|
|
342
|
+
}
|
|
343
|
+
if (typeof view.parseError === "string" && view.parseError !== "") {
|
|
344
|
+
card.appendChild(
|
|
345
|
+
el("p", { class: "muted", text: `this file did not parse: ${view.parseError}` }),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (view.truncated === true && typeof view.text === "string") {
|
|
349
|
+
card.appendChild(el("p", { class: "muted", text: "shown up to the read cap" }));
|
|
350
|
+
}
|
|
351
|
+
host.appendChild(card);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function entryTable(rows, state, openStore) {
|
|
355
|
+
const nowMs = Date.now();
|
|
356
|
+
const tbody = el("tbody");
|
|
357
|
+
for (const row of rows) {
|
|
358
|
+
const name = String(row.name ?? "");
|
|
359
|
+
const cell =
|
|
360
|
+
state.entry === null && row.kind !== "other"
|
|
361
|
+
? (() => {
|
|
362
|
+
const btn = el("button", { class: "btn btn-ghost", type: "button", text: name });
|
|
363
|
+
btn.addEventListener("click", () => {
|
|
364
|
+
void openStore(state.store, name);
|
|
365
|
+
});
|
|
366
|
+
return btn;
|
|
367
|
+
})()
|
|
368
|
+
: el("span", { class: "mono", text: name });
|
|
369
|
+
tbody.appendChild(
|
|
370
|
+
el("tr", null, [
|
|
371
|
+
el("td", null, [cell]),
|
|
372
|
+
el("td", null, [
|
|
373
|
+
row.kind === "other"
|
|
374
|
+
? dot("warn", "not readable from here")
|
|
375
|
+
: dot("ok", row.kind === "dir" ? "directory" : "file"),
|
|
376
|
+
]),
|
|
377
|
+
el("td", {
|
|
378
|
+
class: "num",
|
|
379
|
+
text: typeof row.bytes === "number" ? `${fmtCount(row.bytes)} B` : "—",
|
|
380
|
+
}),
|
|
381
|
+
el("td", {
|
|
382
|
+
text: typeof row.modifiedAt === "string" ? fmtRelativeTime(row.modifiedAt, nowMs) : "—",
|
|
383
|
+
}),
|
|
384
|
+
el("td", { class: "muted", text: typeof row.note === "string" ? row.note : "" }),
|
|
385
|
+
]),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return el("div", { class: "table-scroll" }, [
|
|
389
|
+
el("table", { class: "fleet" }, [
|
|
390
|
+
el(
|
|
391
|
+
"thead",
|
|
392
|
+
null,
|
|
393
|
+
el(
|
|
394
|
+
"tr",
|
|
395
|
+
null,
|
|
396
|
+
["Name", "Kind", "Size", "Changed", "Note"].map((h) => el("th", { text: h })),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
tbody,
|
|
400
|
+
]),
|
|
401
|
+
]);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The fingerprint that stamps every trace envelope — and PRESENCE, never a
|
|
405
|
+
* value, for the key material sitting beside it in the same file. */
|
|
406
|
+
function identityCard(doc) {
|
|
407
|
+
const kv = (k, v) =>
|
|
408
|
+
el("div", { class: "kv" }, [
|
|
409
|
+
el("span", { class: "kv-k", text: k }),
|
|
410
|
+
typeof v === "string"
|
|
411
|
+
? el("span", { class: "kv-v mono", text: v })
|
|
412
|
+
: el("span", { class: "kv-v" }, [v]),
|
|
413
|
+
]);
|
|
414
|
+
return el("div", { class: "proc-facts" }, [
|
|
415
|
+
kv("fingerprint", String(doc.fingerprint ?? "—")),
|
|
416
|
+
kv("short", String(doc.fingerprintShort ?? "—")),
|
|
417
|
+
kv("algorithm", String(doc.algorithm ?? "—")),
|
|
418
|
+
kv("created", String(doc.createdAt ?? "—")),
|
|
419
|
+
kv("public key", doc.publicKeyPresent === true ? dot("ok", "present") : dot("off", "absent")),
|
|
420
|
+
kv(
|
|
421
|
+
"private key",
|
|
422
|
+
doc.privateKeyPresent === true
|
|
423
|
+
? dot("ok", "present on disk — never served")
|
|
424
|
+
: dot("off", "absent"),
|
|
425
|
+
),
|
|
426
|
+
el("p", {
|
|
427
|
+
class: "muted reason",
|
|
428
|
+
text: "this fingerprint is stamped onto every trace envelope and audit record; the key material stays on disk and is never sent to this browser",
|
|
429
|
+
}),
|
|
430
|
+
]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// settings.json — the one write on this surface
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The settings editor.
|
|
439
|
+
*
|
|
440
|
+
* Human-owned configuration, so it takes the same route the spec's
|
|
441
|
+
* human-owned paths do: Preview first (a server-computed, credential-redacted
|
|
442
|
+
* diff), then a typed confirmation the SERVER verifies. Nothing here
|
|
443
|
+
* auto-tunes, and nothing merges — the editor holds the whole next document,
|
|
444
|
+
* so what an operator confirmed is exactly what lands.
|
|
445
|
+
*
|
|
446
|
+
* Which is exactly why a MASKED document cannot be edited here: "what an
|
|
447
|
+
* operator confirmed is what lands" would then mean landing `[redacted]` on
|
|
448
|
+
* top of a real value. {@link settingsWriteGuard} makes that document
|
|
449
|
+
* read-only and names the file to edit instead — and the guard runs again on
|
|
450
|
+
* the way out, so text pasted in from the raw view cannot slip past it.
|
|
451
|
+
*/
|
|
452
|
+
function settingsPanel(ctx, view, reload) {
|
|
453
|
+
const summary = view.summary && typeof view.summary === "object" ? view.summary : {};
|
|
454
|
+
const rules =
|
|
455
|
+
summary.permissionRules && typeof summary.permissionRules === "object"
|
|
456
|
+
? summary.permissionRules
|
|
457
|
+
: { allow: 0, deny: 0, ask: 0 };
|
|
458
|
+
const hooks = Array.isArray(summary.hooks) ? summary.hooks : [];
|
|
459
|
+
const source = JSON.stringify(view.document ?? {}, null, 2);
|
|
460
|
+
|
|
461
|
+
const facts = el("div", { class: "proc-facts" }, [
|
|
462
|
+
el("div", { class: "kv" }, [
|
|
463
|
+
el("span", { class: "kv-k", text: "permission rules" }),
|
|
464
|
+
el("span", { class: "kv-v" }, [
|
|
465
|
+
el("span", { class: "chip", text: `allow ${fmtCount(rules.allow ?? 0)}` }),
|
|
466
|
+
el("span", { class: "chip", text: `deny ${fmtCount(rules.deny ?? 0)}` }),
|
|
467
|
+
el("span", { class: "chip", text: `ask ${fmtCount(rules.ask ?? 0)}` }),
|
|
468
|
+
]),
|
|
469
|
+
]),
|
|
470
|
+
el("div", { class: "kv" }, [
|
|
471
|
+
el("span", { class: "kv-k", text: "hooks" }),
|
|
472
|
+
el("span", { class: "kv-v" }, [
|
|
473
|
+
hooks.length === 0 ? dot("off", "none") : dot("ok", `${fmtCount(hooks.length)} wired`),
|
|
474
|
+
]),
|
|
475
|
+
]),
|
|
476
|
+
]);
|
|
477
|
+
for (const hook of hooks) {
|
|
478
|
+
facts.appendChild(
|
|
479
|
+
el("div", { class: "kv" }, [
|
|
480
|
+
el("span", { class: "kv-k", text: String(hook.event ?? "hook") }),
|
|
481
|
+
el("span", { class: "kv-v mono", text: String(hook.command ?? "") }),
|
|
482
|
+
]),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const guard = settingsWriteGuard(view.document, String(view.path ?? ""));
|
|
487
|
+
const editor = el("textarea", { class: "notes", rows: "14", spellcheck: "false" });
|
|
488
|
+
editor.value = source;
|
|
489
|
+
if (guard.blocked) {
|
|
490
|
+
editor.readOnly = true;
|
|
491
|
+
editor.setAttribute("aria-readonly", "true");
|
|
492
|
+
}
|
|
493
|
+
const diffHost = el("div");
|
|
494
|
+
const confirm = el("input", {
|
|
495
|
+
class: "token-input",
|
|
496
|
+
type: "text",
|
|
497
|
+
placeholder: "type the spec name to confirm",
|
|
498
|
+
"aria-label": "typed confirmation",
|
|
499
|
+
});
|
|
500
|
+
confirm.disabled = guard.blocked;
|
|
501
|
+
|
|
502
|
+
const parsed = () => {
|
|
503
|
+
try {
|
|
504
|
+
return { ok: true, value: JSON.parse(editor.value) };
|
|
505
|
+
} catch (err) {
|
|
506
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const previewBtn = el("button", { class: "btn", type: "button", text: "Preview changes" });
|
|
511
|
+
previewBtn.disabled = guard.blocked;
|
|
512
|
+
previewBtn.addEventListener("click", async () => {
|
|
513
|
+
const next = parsed();
|
|
514
|
+
if (!next.ok) {
|
|
515
|
+
toast(`That is not valid JSON: ${next.message}`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const res = await api.settingsWrite({ id: ctx.id }, { settings: next.value, dryRun: true });
|
|
519
|
+
clear(diffHost);
|
|
520
|
+
if (!res.ok) {
|
|
521
|
+
diffHost.appendChild(errorCard("Preview failed", res));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const body = res.body ?? {};
|
|
525
|
+
const issues = Array.isArray(body.issues) ? body.issues : [];
|
|
526
|
+
const diff = Array.isArray(body.diff) ? body.diff : [];
|
|
527
|
+
diffHost.appendChild(
|
|
528
|
+
el("div", { class: "rollup" }, [
|
|
529
|
+
issues.length === 0
|
|
530
|
+
? dot("ok", diff.length === 0 ? "no changes" : `${fmtCount(diff.length)} changed lines`)
|
|
531
|
+
: dot("bad", `${fmtCount(issues.length)} problems — this would break the agent's rules`),
|
|
532
|
+
typeof body.note === "string" ? el("span", { class: "muted", text: body.note }) : null,
|
|
533
|
+
]),
|
|
534
|
+
);
|
|
535
|
+
for (const issue of issues) {
|
|
536
|
+
diffHost.appendChild(
|
|
537
|
+
el("div", { class: "muted reason", text: `${issue.path || "(root)"}: ${issue.message}` }),
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
if (diff.length > 0)
|
|
541
|
+
diffHost.appendChild(el("pre", { class: "prose-pre", text: diff.join("\n") }));
|
|
542
|
+
if (typeof body.confirmName === "string") {
|
|
543
|
+
confirm.placeholder = `type "${body.confirmName}" to confirm`;
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const applyBtn = el("button", { class: "btn btn-danger", type: "button", text: "Apply" });
|
|
548
|
+
applyBtn.disabled = guard.blocked;
|
|
549
|
+
applyBtn.addEventListener("click", async () => {
|
|
550
|
+
const next = parsed();
|
|
551
|
+
if (!next.ok) {
|
|
552
|
+
toast(`That is not valid JSON: ${next.message}`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// The document in the box, not the one that was served: a mask marker
|
|
556
|
+
// pasted in from the raw view would destroy just as much.
|
|
557
|
+
const outgoing = settingsWriteGuard(next.value, String(view.path ?? ""));
|
|
558
|
+
if (outgoing.blocked) {
|
|
559
|
+
toast(outgoing.reason);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const res = await api.settingsWrite(
|
|
563
|
+
{ id: ctx.id },
|
|
564
|
+
{ settings: next.value, confirmName: confirm.value },
|
|
565
|
+
);
|
|
566
|
+
if (!res.ok) {
|
|
567
|
+
toast(
|
|
568
|
+
res.status === 409
|
|
569
|
+
? "This write needs the harness's spec name typed in, exactly."
|
|
570
|
+
: `Apply failed: ${res.body?.error ?? `HTTP ${res.status}`}`,
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
toast("settings.json written", "info");
|
|
575
|
+
void reload();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return el("div", null, [
|
|
579
|
+
facts,
|
|
580
|
+
collapsible(
|
|
581
|
+
[
|
|
582
|
+
el("span", { class: "muted", text: "edit settings.json" }),
|
|
583
|
+
el("span", {
|
|
584
|
+
class: "chip chip-warn",
|
|
585
|
+
text: guard.blocked ? "read-only here" : "human-owned",
|
|
586
|
+
}),
|
|
587
|
+
],
|
|
588
|
+
[
|
|
589
|
+
guard.blocked
|
|
590
|
+
? el("div", { class: "gated" }, [
|
|
591
|
+
dot("warn", "read-only here"),
|
|
592
|
+
el("span", { class: "muted gated-why", text: guard.reason }),
|
|
593
|
+
])
|
|
594
|
+
: el("p", {
|
|
595
|
+
class: "muted",
|
|
596
|
+
text: "These rules decide what the agent may do. Preview shows a credential-redacted diff; applying needs the harness's spec name typed in, and the server checks it.",
|
|
597
|
+
}),
|
|
598
|
+
editor,
|
|
599
|
+
el("div", { class: "editor-actions" }, [previewBtn, confirm, applyBtn]),
|
|
600
|
+
diffHost,
|
|
601
|
+
],
|
|
602
|
+
),
|
|
603
|
+
collapsible(
|
|
604
|
+
[el("span", { class: "muted", text: "raw document" })],
|
|
605
|
+
[jsonPre(view.document ?? {})],
|
|
606
|
+
),
|
|
607
|
+
]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// The generic raw browser
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
async function paintRaw(host, ctx, state, openRaw) {
|
|
615
|
+
clear(host).appendChild(skeleton(3));
|
|
616
|
+
const res = await api.inspectRaw(
|
|
617
|
+
{ id: ctx.id },
|
|
618
|
+
undefined,
|
|
619
|
+
`?path=${encodeURIComponent(state.rawPath)}`,
|
|
620
|
+
);
|
|
621
|
+
clear(host);
|
|
622
|
+
const card = el("section", { class: "card ov-wide" }, [
|
|
623
|
+
el("h3", { class: "card-title" }, [
|
|
624
|
+
el("span", { text: "Raw browser" }),
|
|
625
|
+
dot("unknown", "read-only"),
|
|
626
|
+
el("span", { class: "muted card-sub", text: state.rawPath }),
|
|
627
|
+
]),
|
|
628
|
+
]);
|
|
629
|
+
|
|
630
|
+
const input = el("input", {
|
|
631
|
+
class: "search",
|
|
632
|
+
type: "text",
|
|
633
|
+
"aria-label": "harness-relative path",
|
|
634
|
+
});
|
|
635
|
+
input.value = state.rawPath;
|
|
636
|
+
const go = el("button", { class: "btn", type: "button", text: "Open" });
|
|
637
|
+
go.addEventListener("click", () => {
|
|
638
|
+
void openRaw(input.value.trim());
|
|
639
|
+
});
|
|
640
|
+
input.addEventListener("keydown", (event) => {
|
|
641
|
+
if (event.key === "Enter") void openRaw(input.value.trim());
|
|
642
|
+
});
|
|
643
|
+
card.appendChild(el("div", { class: "toolbar" }, [input, go, crumbs(state.rawPath, openRaw)]));
|
|
644
|
+
|
|
645
|
+
if (!res.ok) {
|
|
646
|
+
// A 403 here is the allowlist doing its job, not a fault — say which.
|
|
647
|
+
card.appendChild(
|
|
648
|
+
res.status === 403
|
|
649
|
+
? el("div", { class: "rollup" }, [
|
|
650
|
+
dot("off", "excluded on purpose"),
|
|
651
|
+
el("span", {
|
|
652
|
+
class: "muted",
|
|
653
|
+
text:
|
|
654
|
+
res.body && typeof res.body.error === "string"
|
|
655
|
+
? res.body.error
|
|
656
|
+
: "this path is never served raw",
|
|
657
|
+
}),
|
|
658
|
+
])
|
|
659
|
+
: errorCard("That path could not be read", res),
|
|
660
|
+
);
|
|
661
|
+
host.appendChild(card);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const view = res.body ?? {};
|
|
666
|
+
card.appendChild(
|
|
667
|
+
el("div", { class: "rollup" }, [
|
|
668
|
+
view.modelled ? dot("ok", `modelled as "${view.modelled}"`) : dot("unknown", "unmodelled"),
|
|
669
|
+
el("span", { class: "muted", text: String(view.modelledNote ?? "") }),
|
|
670
|
+
]),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
if (view.present !== true) {
|
|
674
|
+
card.appendChild(
|
|
675
|
+
emptyState(typeof view.note === "string" ? view.note : "Nothing at that path"),
|
|
676
|
+
);
|
|
677
|
+
host.appendChild(card);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (view.kind === "dir") {
|
|
682
|
+
const entries = Array.isArray(view.entries) ? view.entries : [];
|
|
683
|
+
const hidden = Array.isArray(view.excludedHere) ? view.excludedHere : [];
|
|
684
|
+
if (entries.length === 0) {
|
|
685
|
+
card.appendChild(emptyState("This directory holds nothing readable from here"));
|
|
686
|
+
} else {
|
|
687
|
+
const list = el("div", { class: "chip-group" });
|
|
688
|
+
for (const entry of entries) {
|
|
689
|
+
const name = String(entry.name ?? "");
|
|
690
|
+
const btn = el("button", {
|
|
691
|
+
class: "btn btn-ghost",
|
|
692
|
+
type: "button",
|
|
693
|
+
text: entry.kind === "dir" ? `${name}/` : name,
|
|
694
|
+
title: entry.kind === "other" ? String(entry.note ?? "not readable") : name,
|
|
695
|
+
});
|
|
696
|
+
if (entry.kind === "other") btn.disabled = true;
|
|
697
|
+
else {
|
|
698
|
+
btn.addEventListener("click", () => {
|
|
699
|
+
void openRaw(`${state.rawPath}/${name}`);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
list.appendChild(btn);
|
|
703
|
+
}
|
|
704
|
+
card.appendChild(list);
|
|
705
|
+
}
|
|
706
|
+
if (hidden.length > 0) {
|
|
707
|
+
card.appendChild(
|
|
708
|
+
el("div", { class: "rollup" }, [
|
|
709
|
+
dot("off", `${fmtCount(hidden.length)} entries excluded here`),
|
|
710
|
+
el("span", { class: "muted", text: hidden.join(", ") }),
|
|
711
|
+
]),
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
host.appendChild(card);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
card.appendChild(
|
|
719
|
+
el("div", { class: "h-dir" }, [
|
|
720
|
+
el("span", { class: "mono muted", text: String(view.path ?? "") }),
|
|
721
|
+
copyBtn(String(view.path ?? ""), "copy path"),
|
|
722
|
+
typeof view.modifiedAt === "string" ? asOf(view.modifiedAt) : null,
|
|
723
|
+
]),
|
|
724
|
+
);
|
|
725
|
+
if (view.document && typeof view.document === "object") card.appendChild(jsonPre(view.document));
|
|
726
|
+
if (typeof view.text === "string" && view.text !== "") {
|
|
727
|
+
card.appendChild(numberedCode(view.text, String(view.path ?? "file")));
|
|
728
|
+
}
|
|
729
|
+
if (view.truncated === true) {
|
|
730
|
+
card.appendChild(el("p", { class: "muted", text: "shown up to the read cap" }));
|
|
731
|
+
}
|
|
732
|
+
host.appendChild(card);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Clickable breadcrumbs for the current raw path. */
|
|
736
|
+
function crumbs(path, openRaw) {
|
|
737
|
+
const parts = String(path)
|
|
738
|
+
.split("/")
|
|
739
|
+
.filter((p) => p !== "");
|
|
740
|
+
const wrap = el("span", { class: "crumb-line" });
|
|
741
|
+
parts.forEach((part, index) => {
|
|
742
|
+
if (index > 0) wrap.appendChild(el("span", { class: "muted", text: " / " }));
|
|
743
|
+
const upto = parts.slice(0, index + 1).join("/");
|
|
744
|
+
const btn = el("button", { class: "btn btn-ghost", type: "button", text: part });
|
|
745
|
+
btn.addEventListener("click", () => {
|
|
746
|
+
void openRaw(upto);
|
|
747
|
+
});
|
|
748
|
+
wrap.appendChild(btn);
|
|
749
|
+
});
|
|
750
|
+
return wrap;
|
|
751
|
+
}
|