Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/scripts/openshell-bind-conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ const load = (path) => import(pathToFileURL(join(root, path)).href);

const { makeOpenshellBackend, OPENSHELL_OWNER, OPENSHELL_OWNER_LABEL, sandboxNameOf } = await load("worker/src/backend-openshell.mjs");
const { READ_BACK_BY_A_LIVE_PROBE, runBackendConformance } = await load("worker/src/backend-conformance.mjs");
const { makeLogSink } = await load("worker/src/run-history.mjs");
const { buildRecord, makeLogSink } = await load("worker/src/run-history.mjs");
const { networkPolicyFor } = await load("worker/src/openshell-policy.mjs");
const { makeLiveTranscriptOpener } = await load("worker/src/live-transcript.mjs");

// Every owned sandbox the gateway still lists, polled until the listing is empty or 30 s have passed: a delete is
Expand Down Expand Up @@ -341,6 +342,17 @@ if (!readings.readOnlyJobInputs?.ok) failed = true;
const ok = result.code === 0 && got.btoken === "placeholder" && (got["benviron-readable"] === "no" || got.benviron === "1") && got.bplain === "file" && got.bown === "200" && swapped && Array.isArray(left) && left.length === 0;
console.log(`${ok ? "ok " : "FAIL "} a secret bundle in bind mode: ${JSON.stringify({ exit: result.code, ...got, swapped, providersLeft: left })}`);
if (!ok) failed = true;
// Issue #712: the run's record-facing facts in bind mode (collectRun's early return carries them too): the mode, the
// policy's hosts with the bundle's own among them, the job's provider and the bundle host's. Never a value.
const record = buildRecord({ job: { id: "conformance", attemptsMade: 0, data: { ...JOB, backend: "openshell-bind" } }, result: { outcome: "completed", exitCode: result.code, openshell: result.openshell }, startedAt: null, endedAt: null });
const facts = record.openshell ?? null;
// Exact, from this run's sources (POLICY_SOURCES' allowlist, the endpoint this case reads) through the same generator,
// and the keys: the job's provider binds ANTHROPIC_API_KEY (no token is passed here), the bundle host's BUNDLE_TOKEN.
const policy = networkPolicyFor({ allowlist: POLICY_SOURCES.readAllowlist(), endpoints: [{ id: "lab-b", host: "host.docker.internal", port: 18080, slots: 1 }], kind: JOB.kind, repo: JOB.repo });
const wantHosts = [...new Set(Object.values(policy.network_policies ?? {}).flatMap((rule) => rule.endpoints.map((e) => e.host)))].sort();
const factsOk = facts !== null && facts.mode === "bind" && JSON.stringify(facts.ruleHosts) === JSON.stringify(wantHosts) && facts.ruleHostCount === wantHosts.length && facts.providers === 2 && facts.placeholders === 2 && !Object.values(values).some((v) => JSON.stringify(record).includes(v));
console.log(`${factsOk ? "ok " : "FAIL "} the bind run's record carries its openshell facts: ${JSON.stringify(facts)} (hosts wanted ${JSON.stringify(wantHosts)}, 2 providers, 2 placeholders)`);
if (!factsOk) failed = true;
}

// Review round 1 (#714): run_as_user other than the image's, exercised in CI. The job dir is handed to uid 2000 (sudo),
Expand Down
16 changes: 15 additions & 1 deletion .github/scripts/openshell-conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const load = (path) => import(pathToFileURL(join(root, path)).href);

const { makeOpenshellBackend, OPENSHELL_OWNER, OPENSHELL_OWNER_LABEL, sandboxNameOf } = await load("worker/src/backend-openshell.mjs");
const { READ_BACK_BY_A_LIVE_PROBE, UNVERIFIED_BY_THIS_HARNESS, runBackendConformance } = await load("worker/src/backend-conformance.mjs");
const { makeLogSink } = await load("worker/src/run-history.mjs");
const { buildRecord, makeLogSink } = await load("worker/src/run-history.mjs");
const { networkPolicyFor } = await load("worker/src/openshell-policy.mjs");
const { makeLiveTranscriptOpener } = await load("worker/src/live-transcript.mjs");
const { makeInboxRelay } = await load("worker/src/inbox-relay.mjs");

Expand Down Expand Up @@ -288,6 +289,19 @@ const providersListed = async () => {
const ok = !logged && said.bcmdline === "0" && gate?.ok === true && gate.placeholders === 1 && gate.plain === 1 && refusedWide?.venueRefused?.reason === "openshell-bundle-endpoint-not-allowed" && result.code === 0 && said.btoken === "placeholder" && (said["benviron-readable"] === "no" || said.benviron === "1") && said["benviron-values"] === "0" && said.bplain === "file" && said.bown === "200" && swapped && elsewhere && keyRefused && !leaked;
console.log(`${ok ? "ok " : "FAIL "} a secret bundle: ${JSON.stringify({ gate, wide: refusedWide?.venueRefused?.reason ?? null, exit: result.code, btoken: said.btoken, benvironReadable: said["benviron-readable"], benviron: said.benviron, benvironValues: said["benviron-values"], bplain: said.bplain, bcmdline: said.bcmdline, gatewayLogRead: gatewayLog.length > 0, logged, bown: said.bown, bother: said.bother, bkey: said.bkey, swapped, elsewhere, keyRefused, leaked })}`);
if (!ok) failed = true;
// Issue #712: the run's record-facing facts, from the real stage through runContainer's result to the record the
// processor would write: copy mode, the policy's hosts (the allowlist's and both model endpoints'), the job's provider
// and the bundle host's, and the keys they bound. Never a value.
const record = buildRecord({ job: { id: "conformance", attemptsMade: 0, data: { ...JOB, backend: "openshell" } }, result: { outcome: "completed", exitCode: result.code, openshell: result.openshell }, startedAt: null, endedAt: null });
const facts = record.openshell ?? null;
// Exact, from the sources this run's policy was generated from (the same generator, so a host added or dropped by
// the stage is a mismatch), and the keys: the job's provider binds ANTHROPIC_API_KEY, GH_TOKEN and GITHUB_TOKEN (the
// token passed), the bundle host's BUNDLE_TOKEN; BUNDLE_PLAIN is a value by file and is not one.
const policy = networkPolicyFor({ allowlist: POLICY_SOURCES.readAllowlist(), endpoints: POLICY_SOURCES.readEndpoints(), kind: JOB.kind, repo: JOB.repo });
const wantHosts = [...new Set(Object.values(policy.network_policies ?? {}).flatMap((rule) => rule.endpoints.map((e) => e.host)))].sort();
const factsOk = facts !== null && facts.mode === "copy" && JSON.stringify(facts.ruleHosts) === JSON.stringify(wantHosts) && facts.ruleHostCount === wantHosts.length && facts.providers === 2 && facts.placeholders === 4 && !Object.values(BUNDLE_VALUES).some((v) => JSON.stringify(record).includes(v));
console.log(`${factsOk ? "ok " : "FAIL "} the run's record carries its openshell facts: ${JSON.stringify(facts)} (hosts wanted ${JSON.stringify(wantHosts)}, 2 providers, 4 placeholders)`);
if (!factsOk) failed = true;
const left = await providersListed();
const tidy = left !== null && !left.some((p) => p.startsWith(sandboxNameOf(name)));
console.log(`${tidy ? "ok " : "FAIL "} the bundle run's teardown deleted its providers: ${JSON.stringify(left)}`);
Expand Down
47 changes: 44 additions & 3 deletions admin/src/console/panels.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import { fmtCost } from "../panel.mjs";
import { renderWhatIf } from "../render.mjs";
import { recordedProgress } from "@edgehero/pi-dispatch/run-history";
import { UNATTRIBUTED_BACKEND } from "@edgehero/pi-dispatch/backends";
import { BUNDLE_SKIPPED_TEXT, GATEWAY_TEXT, OPENSHELL_UNPUBLISHED_TEXT, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, VENUE_MAY_NOT_STREAM, venuesView, liveAbsentCause, progressAbsentText, recordSkips, sandboxShellRefusal, bundleCountsText, bundleDeclaredText, bundleEndpointsText, bundleReceivedText, checkpointsText, forgeLinkOf, isProgressSnapshot, overlayBundleProfiles, progressStaleFor, recordBundle, recordRecovery, recoveredText, secretProfileNames, triggerBundleOf } from "../read-model.mjs";
import { BUNDLE_SKIPPED_TEXT, GATEWAY_TEXT, OPENSHELL_UNPUBLISHED_TEXT, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, VENUE_MAY_NOT_STREAM, venuesView, liveAbsentCause, progressAbsentText, recordSkips, sandboxShellRefusal, bundleCountsText, bundleDeclaredText, bundleEndpointsText, bundleReceivedText, checkpointsText, forgeLinkOf, isProgressSnapshot, overlayBundleProfiles, progressStaleFor, recordBundle, recordRecovery, recoveredText, secretProfileNames, triggerBundleOf, PROFILE_ROOTS_TEXT, PROFILE_SOURCE_TEXT, profileHostsText, profileLastText, openshellCountsText, openshellHostsText, recordOpenshell, recordSecretsProfile } from "../read-model.mjs";
import { RUN_FILTER_FIELDS } from "../run-filter.mjs";
import { sessionKeyOfRecord } from "@edgehero/pi-dispatch/session-key";
import { parkedVerdictOf } from "@edgehero/pi-dispatch/chat-mode";
Expand Down Expand Up @@ -801,6 +801,22 @@ function skipRows(r) {
];
}

/**
* The run summary's OpenShell rows (issue #712): "Sandbox policy" (the mode, the credential providers and placeholders
* the sandbox was given) and "Rule hosts" (the hosts its policy admitted, with the count of any the record does not
* list), the worker's rebuild (`recordOpenshell`: hosts held to the host-name charset), escaped by the renderer; and
* "Secrets profile", the profile NAME run.secrets resolved through. No row for a record without the keys, so every
* other run renders byte-identical. MCP connection state is not recorded on any venue, so no row claims it.
*/
function openshellRows(r) {
const o = recordOpenshell(r);
return [
["Sandbox policy", o !== null ? openshellCountsText(o) : null],
["Rule hosts", o !== null ? openshellHostsText(o) : null],
["Secrets profile", recordSecretsProfile(r)],
];
}

/**
* The run summary's durable-run rows (issue #645): "Recovery of" (the base run, linked), "Recovery" (where it came from
* and the runner's counts, or why an armed job ran without recovery) and "Checkpoints". Every value is `recordRecovery`'s
Expand Down Expand Up @@ -853,6 +869,7 @@ export const runSummary = withRun("Run", (r, view, source) => {
["Host", cell(r.host)],
["Venue", cell(r.backend)],
...skipRows(r),
...openshellRows(r),
["Project", cell(r.project)],
...bundleRows(r),
["Provider and model", [r.provider, r.model].filter(Boolean).join(" / ") || "-"],
Expand Down Expand Up @@ -1514,11 +1531,35 @@ export const configSecretProfiles = fromSnapshot("Secret profiles", (s, view) =>
const fromEnv = Array.isArray(env?.names) ? env.names : [];
const envNote = env?.malformed ? html`<p class="warn">PI_SECRET_PROFILES does not parse, so the worker refuses to boot until it is fixed.</p>` : null;
// "None" only where the console can see both sources; otherwise it names the one it read.
if (names.length === 0 && fromEnv.length === 0) return html`<p class="note">No secret profile is declared${env?.seen === true ? "" : " in the settings overlay"}.</p>${envNote}${profilesEditor(view)}`;
if (names.length === 0 && fromEnv.length === 0) return html`<p class="note">No secret profile is declared${env?.seen === true ? "" : " in the settings overlay"}.</p>${envNote}${profileUse(s.secretProfiles)}${profilesEditor(view)}`;
const overlayItem = (n) => (fromEnv.includes(n) ? html`<li class="mono warn">${n} (AMBIGUOUS: also in PI_SECRET_PROFILES, so the worker refuses every secrets job)</li>` : html`<li class="mono">${n}</li>`);
return html`<p class="note">Names only: a profile's resolver and its references stay in the settings file${fromEnv.length > 0 ? " or the .env" : ""}.</p><ul class="plain">${fromEnv.map((n) => html`<li class="mono">${n} (PI_SECRET_PROFILES)</li>`)}${names.map(overlayItem)}</ul>${envNote}${profilesEditor(view)}`;
return html`<p class="note">Names only: a profile's resolver and its references stay in the settings file${fromEnv.length > 0 ? " or the .env" : ""}.</p><ul class="plain">${fromEnv.map((n) => html`<li class="mono">${n} (PI_SECRET_PROFILES)</li>`)}${names.map(overlayItem)}</ul>${envNote}${profileUse(s.secretProfiles)}${profilesEditor(view)}`;
});

/**
* Each profile's use (issue #712, the snapshot's `secretProfiles`, `secretProfilesView`): its source, where its resolver
* stands against PI_SECRET_RESOLVER_ROOTS on this host, the triggers and projects that use it, the last resolve a run in
* view recorded, and the live hosts that advertise it. Words and names only: the view carries no resolver path, so the
* page cannot (REQ-OPERATOR-CONSOLE-SCREENS (g)). Nothing when no profile is declared or named.
*/
function profileUse(v) {
const rows = Array.isArray(v?.profiles) ? v.profiles : [];
if (rows.length === 0) return null;
return table(
"Secret profiles in use",
["Profile", "Source", "Resolver roots", "Triggers", "Projects", "Last resolve", "Served by"],
rows.map((row) => {
const trig = row.triggers.length > 0 ? `${row.triggers.map((i) => `#${i}`).join(", ")}${row.triggersMore > 0 ? ` and ${row.triggersMore} more` : ""}` : "none";
const proj = [...row.projects, ...(row.projectsMore > 0 ? [`${row.projectsMore} more`] : []), ...(row.anyRepo ? ["any (a webhook trigger)"] : [])].join(", ") || "-";
const last = row.last ? html`${profileLastText(row.last).replace(/^last resolve: /, "")}${row.last.jobId ? html` (${runLink(row.last.jobId)})` : null}` : "no run in view";
const warnSource = row.source === "ambiguous" || row.source === "undeclared";
const warnRoots = row.roots === "outside" || row.roots === "empty";
return html`<tr><td class="mono">${row.name}</td><td${warnSource ? html` class="warn"` : null}>${PROFILE_SOURCE_TEXT[row.source] ?? "-"}</td><td${warnRoots ? html` class="warn"` : null}>${row.roots !== null ? (PROFILE_ROOTS_TEXT[row.roots] ?? "-") : "not bounded (an env resolver)"}</td><td>${trig}</td><td class="mono">${proj}</td><td${row.last?.outcome === "refused" ? html` class="warn"` : null}>${last}</td><td class="mono">${profileHostsText(row, v).replace(/^served by: /, "")}</td></tr>`;
}),
"",
);
}

/** The bundle resolvers editor (issue #704): a name, its resolver's absolute path and the format it prints. */
function bundleProfilesEditor(view) {
if (!canAct(view)) return null;
Expand Down
38 changes: 37 additions & 1 deletion admin/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { windowState } from "@edgehero/pi-dispatch/budget";
import { recordedProgress } from "@edgehero/pi-dispatch/run-history";
import { ranBefore } from "@edgehero/pi-dispatch/cancel-state";
import { windowEndAt } from "@edgehero/pi-dispatch/pause-windows";
import { BUNDLE_SKIPPED_TEXT, CUSTODY_TEXT, DECLARATION_TEXT, GATEWAY_TEXT, OPENSHELL_UNPUBLISHED_TEXT, venuesView, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, liveAbsentCause, recordSkips, venueCustody, VENUE_MAY_NOT_STREAM, bundleCountsText, bundleReceivedText, recordBundle, renderSecretBundles, triggerBundleOf, cancelHeldJob, checkpointsText, createFleetReader, enqueueSteer, listRuns, planSteer, steerAuditTarget, progressStaleFor, recordRecovery, recoveryCountsText, FORGE_MCP_SERVER_NAME, TRANSCRIPT_PAGE_MAX } from "./read-model.mjs";
import { BUNDLE_SKIPPED_TEXT, CUSTODY_TEXT, DECLARATION_TEXT, GATEWAY_TEXT, OPENSHELL_UNPUBLISHED_TEXT, venuesView, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, liveAbsentCause, recordSkips, venueCustody, VENUE_MAY_NOT_STREAM, bundleCountsText, bundleReceivedText, recordBundle, renderSecretBundles, triggerBundleOf, cancelHeldJob, checkpointsText, createFleetReader, enqueueSteer, listRuns, planSteer, steerAuditTarget, progressStaleFor, recordRecovery, recoveryCountsText, FORGE_MCP_SERVER_NAME, TRANSCRIPT_PAGE_MAX, openshellCountsText, openshellHostsText, recordOpenshell, recordSecretsProfile, secretProfileFacts } from "./read-model.mjs";
import { dollarWindowRows, renderDollarWindows } from "./dollar-windows.mjs";
import { formatMicros, optionalUsdMicros } from "@edgehero/pi-dispatch/money";
import { projectKeyOf } from "./costs.mjs";
Expand Down Expand Up @@ -1920,6 +1920,7 @@ function buildListLines(snapshot: any, selected: number, inner: number, styler:
...dollarSection(snapshot.dollars, inner, styler),
{ key: "triggers", head: ["triggers", `${trg.count} standing · a add · ↵ open`], body: trg.lines, priority: 3, viewKey: "tab" },
...bundlesSection(snapshot.secretBundles, inner, styler),
...profilesSection(snapshot.secretProfiles, inner, styler),
{ key: "pauses", head: ["pause windows", `${pw.count} · w manage`], body: pw.lines, priority: 1, viewKey: "w" },
// Priority 0: the section an operator acts on least often from the panel folds FIRST under the
// collapse budget. The key hint lives in this divider, not the footer -- the footer's width
Expand Down Expand Up @@ -2050,6 +2051,27 @@ function bundlesSection(view: any, inner: number, styler: any): any[] {
return [{ key: "bundles", priority: 3, viewKey: "tab", head: ["secret bundles", `${rows.length} · names only · doctor --secrets resolves`], body }];
}

/**
* The secret profiles (issue #712): per profile its name, then its facts (`secretProfileFacts`: source, where its
* resolver stands against PI_SECRET_RESOLVER_ROOTS on this host, the triggers and projects that use it, the last
* resolve a run in view recorded, the live hosts that advertise it), WRAPPED under it at most three lines, the rest
* counted. Names and words only, never a resolver path (the bundles section's rule; `/dispatch secrets list` prints the
* paths). CONDITIONAL: no profile declared or named, no section, so such a deployment renders the panel it always did.
*/
function profilesSection(view: any, inner: number, styler: any): any[] {
const rows: any[] = Array.isArray(view?.profiles) ? view.profiles : [];
if (rows.length === 0) return [];
const body: string[] = [];
for (const p of rows) {
const bad = p.source === "ambiguous" || p.source === "undeclared" || p.roots === "outside" || p.roots === "empty" || p.last?.outcome === "refused";
body.push(fitLine(styler.fg(bad ? "error" : "warning", cellOf(p.name)), inner, styler));
const parts = wrapColumns(cellOf(secretProfileFacts(p, view).join(" · ")), Math.max(1, inner - 2), styler);
parts.slice(0, 3).forEach((part: string) => body.push(fitLine(styler.fg("dim", ` ${part}`), inner, styler)));
if (parts.length > 3) body.push(fitLine(styler.fg("dim", ` ${styler.glyphs?.ellipsis ?? "…"} ${parts.length - 3} more line(s): the console's configuration page shows them`), inner, styler));
}
return [{ key: "profiles", priority: 3, viewKey: "tab", head: ["secret profiles", `${rows.length} · names only · doctor --secrets resolves`], body }];
}

/** How many held rows the section shows before it stops and counts the rest. */
const HELD_ON_DASHBOARD = 3;

Expand Down Expand Up @@ -4308,6 +4330,20 @@ function renderRunDetail(record: any, inner: number, styler: any, allRuns: any[]
// and nothing is inferred for it -- the panel shows a record, it decides nothing from one. Unlike the host
// line, every record this version writes carries the field, so the line is the normal case.
if (typeof r.backend === "string" && r.backend !== "") out.push(kv("backend", show(r.backend)));
// THE SANDBOX POLICY an openshell run was given (issue #712): its mode, the credential providers and placeholders, then
// the hosts its policy admitted, WRAPPED under the label (a cut host list would read as the whole policy), at most four
// lines, the rest counted. `recordOpenshell` is the worker's rebuild (the host-name charset, counts bounded), and each
// part still goes through `show`. Then the secrets profile NAME run.secrets resolved through. A record without either
// key (every other venue, every run without secrets) renders byte-identical. MCP connection state is recorded nowhere.
const policy = recordOpenshell(r);
if (policy !== null) {
wrapColumns(show(openshellCountsText(policy)), Math.max(1, inner - 13), styler).forEach((part: string, i: number) => out.push(kv(i === 0 ? "policy" : "", part, "dim")));
const parts = wrapColumns(show(openshellHostsText(policy)), Math.max(1, inner - 13), styler);
parts.slice(0, 4).forEach((part: string, i: number) => out.push(kv(i === 0 ? "rule hosts" : "", part, "dim")));
if (parts.length > 4) out.push(kv("", `${styler.glyphs?.ellipsis ?? "…"} ${parts.length - 4} more line(s) in the run record`, "dim"));
}
const profileName = recordSecretsProfile(r);
if (profileName !== null) out.push(kv("secrets via", show(profileName), "warning"));
// WHICH PROJECT (issue #499 part C): the id the worker resolved at pickup and wrote into the record. Absent for a run
// outside every project and for one recorded before projects existed, which are never re-attributed.
if (recordProject(r) !== null) out.push(kv("project", recordProject(r)));
Expand Down
Loading
Loading