mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): show saved Run on settings when editing an agent (#4539)
## What When editing an agent, show where it runs. The edit dialog previously showed nothing about the backend; the "Where to run" section only existed in the create flow. This adds a read-only **Run on** section to `AgentInstanceEditDialog`: - **Local agents:** "This computer". - **Provider agents (e.g. Kubernetes):** the provider id plus its saved config rows — context, namespace, image, resources, etc. — with labels humanized from the stored keys and rows in provider-schema order (locators first, request/limit pairs adjacent, alphabetical spillover for unknown providers). - Copy states these are the settings **saved at creation** and that the run location can't be changed afterwards (a new agent is required). ## Design decisions (from thread review with @Wren + @Sami) - **No provider probe on edit.** `info` is executable work, and its schema reflects the plugin *today* (including a freshly generated random namespace default) — not what this agent was deployed with. The stored record is the only honest source. - **Saved settings, not effective settings.** Optional fields a record omits (e.g. `service_account`) are defaulted by the provider at deploy time; we render only what was persisted and never synthesize today's defaults. - **Safe rendering of opaque provider config.** Values render as safe scalars only; arrays/objects degrade to a summary row (React throws on object children — a hand-edited record must not crash the dialog). Falsy-but-present values (`0`, `false`) render honestly. Secret-shaped keys are redacted using the same word-split heuristic as the create-time `validate_provider_config` gate — one definition of "looks like a secret". The gate already blocks such keys on every app write path; display-side redaction is screenshot hygiene and covers hand-edited records. - **`backendAgentId` intentionally excluded:** deploy-time runtime state written on start, not saved creation intent. - **Read-only, no form state.** The backend is immutable post-create (`UpdateManagedAgentRequest` has no backend field), so the section renders straight from `agent.backend` with no reset effect. - `ADVANCED_FIELDS_MOTION_TRANSITION` was duplicated in both agent dialogs; hoisted to `agentConfigOptions` (also keeps the edit dialog inside the file-size ratchet). ## Testing - Unit contract for `summarizeRunOn` (9 tests): scalar honesty incl. `0`/`false`, structured-value fallback, secret redaction fail-safe, preferred ordering with spillover, key humanization. - Playwright spec (4 tests, registered in the smoke project): kubernetes agent with the exact eight-key record a real create flow persisted, local agent, blox agent (`workstation_name`), and redacted secret-shaped keys from a hypothetical future provider. - `pnpm typecheck`, `pnpm check`, full `pnpm test` (3937 pass) green at this head. - Live screenshots posted in the originating Buzz thread. --------- Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -126,6 +126,7 @@ export default defineConfig({
|
||||
"**/agent-provider-dropdowns.spec.ts",
|
||||
"**/agent-lifecycle-feedback.spec.ts",
|
||||
"**/agent-access-warning.spec.ts",
|
||||
"**/edit-agent-run-on.spec.ts",
|
||||
"**/inbox-live-update.spec.ts",
|
||||
"**/mesh-compute.spec.ts",
|
||||
"**/observer-archive-policy.spec.ts",
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
personaBehaviorDraftValid,
|
||||
} from "./personaBehaviorDraft";
|
||||
import {
|
||||
ADVANCED_FIELDS_MOTION_TRANSITION,
|
||||
AUTO_MODEL_DROPDOWN_VALUE,
|
||||
AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
||||
@@ -118,11 +119,6 @@ export type AgentDefinitionSubmitOptions = {
|
||||
publishCatalogUpdates: boolean;
|
||||
};
|
||||
|
||||
const ADVANCED_FIELDS_MOTION_TRANSITION = {
|
||||
duration: 0.18,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
} as const;
|
||||
|
||||
export function AgentDefinitionDialog({
|
||||
open,
|
||||
title,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Input } from "@/shared/ui/input";
|
||||
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
|
||||
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
|
||||
import {
|
||||
ADVANCED_FIELDS_MOTION_TRANSITION,
|
||||
AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
|
||||
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
||||
@@ -65,6 +66,7 @@ import { AgentCreationPreview } from "./AgentCreationPreview";
|
||||
import type { EnvVarsValue } from "./EnvVarsEditor";
|
||||
import { useRequiredCredentialState } from "./useRequiredCredentialState";
|
||||
import { CreateAgentRespondToField } from "./RespondToField";
|
||||
import { RunOnSummarySection } from "./RunOnSummarySection";
|
||||
import { PersonaDropdownField } from "./PersonaDropdownField";
|
||||
import {
|
||||
MODEL_DISCOVERY_LOADING_VALUE,
|
||||
@@ -93,11 +95,6 @@ import {
|
||||
usePendingHarnessSelection,
|
||||
} from "./addCustomHarness";
|
||||
|
||||
const ADVANCED_FIELDS_MOTION_TRANSITION = {
|
||||
duration: 0.18,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
} as const;
|
||||
|
||||
export function AgentInstanceEditDialog({
|
||||
agent,
|
||||
initialFocus,
|
||||
@@ -949,6 +946,8 @@ export function AgentInstanceEditDialog({
|
||||
variant="persona"
|
||||
/>
|
||||
|
||||
<RunOnSummarySection backend={agent.backend} />
|
||||
|
||||
{/* Provider (runtime) */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
|
||||
73
desktop/src/features/agents/ui/RunOnSummarySection.tsx
Normal file
73
desktop/src/features/agents/ui/RunOnSummarySection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ManagedAgentBackend } from "@/shared/api/types";
|
||||
|
||||
import { summarizeRunOn } from "./runOnSummary";
|
||||
|
||||
/**
|
||||
* Read-only "Run on" summary for the edit-agent dialog.
|
||||
*
|
||||
* Read-only on purpose: `UpdateManagedAgentRequest` has no backend field —
|
||||
* where an agent runs is fixed at creation (a provider-backed agent's
|
||||
* deployment holds its private key; there is no migrate operation). This
|
||||
* section shows the *saved* provider config from the record, without probing
|
||||
* the provider binary: an edit dialog must not do executable work as a side
|
||||
* effect, and a live probe would show today's schema defaults instead of
|
||||
* what this agent actually deployed with.
|
||||
*
|
||||
* Named "Run on" (matching the create flow) rather than "Provider" because
|
||||
* this dialog already uses "Provider" for the ACP harness selector.
|
||||
*/
|
||||
export function RunOnSummarySection({
|
||||
backend,
|
||||
}: {
|
||||
backend: ManagedAgentBackend;
|
||||
}) {
|
||||
const summary = summarizeRunOn(backend);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-testid="edit-agent-run-on">
|
||||
<span className="text-sm font-medium text-foreground">Run on</span>
|
||||
{summary.location === "local" ? (
|
||||
<p
|
||||
className="text-sm text-muted-foreground"
|
||||
data-testid="edit-agent-run-on-location"
|
||||
>
|
||||
This computer
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 rounded-2xl border border-border bg-muted/30 px-4 py-3">
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
data-testid="edit-agent-run-on-location"
|
||||
>
|
||||
{summary.providerId}
|
||||
</p>
|
||||
{summary.rows.length > 0 ? (
|
||||
<dl className="space-y-1">
|
||||
{summary.rows.map((row) => (
|
||||
<div
|
||||
className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5"
|
||||
data-testid={`edit-agent-run-on-${row.key}`}
|
||||
key={row.key}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">{row.label}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-foreground">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No saved settings — the provider applies its defaults.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These are the settings saved when the agent was created. Where an agent
|
||||
runs can't be changed afterwards — create a new agent to run
|
||||
somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ export const PERSONA_FIELD_CONTROL_CLASS =
|
||||
export const PERSONA_LABEL_OPTIONAL_CLASS =
|
||||
"ml-1 text-xs font-normal text-muted-foreground/50";
|
||||
|
||||
/** Shared advanced-fields expand/collapse easing for the agent dialogs. */
|
||||
export const ADVANCED_FIELDS_MOTION_TRANSITION = {
|
||||
duration: 0.18,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
} as const;
|
||||
|
||||
export const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__";
|
||||
export const CUSTOM_MODEL_DROPDOWN_VALUE = "__custom_model__";
|
||||
export const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__";
|
||||
|
||||
171
desktop/src/features/agents/ui/runOnSummary.test.mjs
Normal file
171
desktop/src/features/agents/ui/runOnSummary.test.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { humanizeConfigKey, summarizeRunOn } from "./runOnSummary.ts";
|
||||
|
||||
test("local backend summarizes to the local location with no rows", () => {
|
||||
assert.deepEqual(summarizeRunOn({ type: "local" }), { location: "local" });
|
||||
});
|
||||
|
||||
test("provider backend carries the provider id", () => {
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "kubernetes",
|
||||
config: {},
|
||||
});
|
||||
assert.equal(summary.location, "provider");
|
||||
assert.equal(summary.providerId, "kubernetes");
|
||||
assert.deepEqual(summary.rows, []);
|
||||
});
|
||||
|
||||
test("a saved kubernetes config renders labeled scalar rows", () => {
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "kubernetes",
|
||||
config: {
|
||||
namespace: "buzz-agents-x7k2mp",
|
||||
image: "ghcr.io/block/buzz-sprig@sha256:17facfc7",
|
||||
cpu_request: "1",
|
||||
inactivity_seconds: 7200,
|
||||
},
|
||||
});
|
||||
assert.equal(summary.location, "provider");
|
||||
const byKey = Object.fromEntries(summary.rows.map((r) => [r.key, r]));
|
||||
assert.equal(byKey.namespace.label, "Namespace");
|
||||
assert.equal(byKey.namespace.value, "buzz-agents-x7k2mp");
|
||||
assert.equal(byKey.cpu_request.label, "CPU request");
|
||||
assert.equal(byKey.cpu_request.value, "1");
|
||||
assert.equal(byKey.inactivity_seconds.label, "Inactivity seconds");
|
||||
assert.equal(byKey.inactivity_seconds.value, "7200");
|
||||
assert.equal(byKey.image.value, "ghcr.io/block/buzz-sprig@sha256:17facfc7");
|
||||
assert.ok(summary.rows.every((r) => r.redacted === false));
|
||||
});
|
||||
|
||||
test("rows follow the provider-schema preferred order, spillover alphabetical", () => {
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "kubernetes",
|
||||
config: {
|
||||
// Deliberately shuffled persisted order.
|
||||
memory_limit: "1Gi",
|
||||
zeta_extra: "z",
|
||||
namespace: "n",
|
||||
cpu_limit: "1",
|
||||
alpha_extra: "a",
|
||||
image: "i",
|
||||
inactivity_seconds: 7200,
|
||||
cpu_request: "1",
|
||||
context: "c",
|
||||
memory_request: "1Gi",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
summary.rows.map((r) => r.key),
|
||||
[
|
||||
"context",
|
||||
"namespace",
|
||||
"image",
|
||||
"cpu_request",
|
||||
"memory_request",
|
||||
"cpu_limit",
|
||||
"memory_limit",
|
||||
"inactivity_seconds",
|
||||
"alpha_extra",
|
||||
"zeta_extra",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("null, empty-string, boolean, and number values all display honestly", () => {
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "p",
|
||||
config: { a_null: null, b_empty: "", c_flag: true, d_num: 0, e_off: false },
|
||||
});
|
||||
const values = Object.fromEntries(summary.rows.map((r) => [r.key, r.value]));
|
||||
assert.equal(values.a_null, "Not set");
|
||||
assert.equal(values.b_empty, "Not set");
|
||||
assert.equal(values.c_flag, "true");
|
||||
// Falsy-but-present values must never read as missing: coerceConfigValues
|
||||
// emits real numbers/booleans, so `value || fallback` would lie about 0/false.
|
||||
assert.equal(values.d_num, "0");
|
||||
assert.equal(values.e_off, "false");
|
||||
});
|
||||
|
||||
test("every row value is a string — objects must never reach React children", () => {
|
||||
// React throws on an object child, taking down the whole edit dialog.
|
||||
// A hand-edited managed-agents.json with nested config is exactly the
|
||||
// record the create-time scalar gate never saw.
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "p",
|
||||
config: {
|
||||
resources: { limits: { cpu: "2" } },
|
||||
flag: true,
|
||||
count: 3,
|
||||
name: "x",
|
||||
missing: null,
|
||||
},
|
||||
});
|
||||
for (const row of summary.rows) {
|
||||
assert.equal(typeof row.value, "string", `${row.key} is not a string`);
|
||||
}
|
||||
});
|
||||
|
||||
test("arrays and objects are summarized, never serialized", () => {
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "p",
|
||||
config: {
|
||||
tolerations: [{ key: "gpu" }, { key: "spot" }],
|
||||
node_selector: { disktype: "ssd" },
|
||||
},
|
||||
});
|
||||
const values = Object.fromEntries(summary.rows.map((r) => [r.key, r.value]));
|
||||
assert.equal(values.tolerations, "List (2 items)");
|
||||
assert.equal(values.node_selector, "Structured value");
|
||||
// The nested content must not leak through.
|
||||
assert.ok(!JSON.stringify(values).includes("ssd"));
|
||||
});
|
||||
|
||||
test("secret-shaped keys are redacted, fail-safe for unknown providers", () => {
|
||||
const summary = summarizeRunOn({
|
||||
type: "provider",
|
||||
id: "future-provider",
|
||||
config: {
|
||||
api_token: "abc123",
|
||||
registry_password: "hunter2",
|
||||
clientSecret: "s3cr3t",
|
||||
authHeader: "Bearer xyz",
|
||||
privateKey: "nsec1...",
|
||||
namespace: "safe-to-show",
|
||||
},
|
||||
});
|
||||
const byKey = Object.fromEntries(summary.rows.map((r) => [r.key, r]));
|
||||
for (const key of [
|
||||
"api_token",
|
||||
"registry_password",
|
||||
"clientSecret",
|
||||
"authHeader",
|
||||
"privateKey",
|
||||
]) {
|
||||
assert.equal(byKey[key].redacted, true, `${key} must be redacted`);
|
||||
assert.equal(byKey[key].value, "••••••••");
|
||||
}
|
||||
assert.equal(byKey.namespace.redacted, false);
|
||||
assert.equal(byKey.namespace.value, "safe-to-show");
|
||||
// Raw secret bytes must be absent from the whole summary.
|
||||
const dump = JSON.stringify(summary);
|
||||
for (const secret of ["abc123", "hunter2", "s3cr3t", "Bearer xyz", "nsec1"]) {
|
||||
assert.ok(!dump.includes(secret), `${secret} leaked`);
|
||||
}
|
||||
});
|
||||
|
||||
test("humanizeConfigKey handles snake_case, camelCase, and acronyms", () => {
|
||||
assert.equal(humanizeConfigKey("cpu_request"), "CPU request");
|
||||
assert.equal(humanizeConfigKey("memory_limit"), "Memory limit");
|
||||
assert.equal(humanizeConfigKey("serviceAccount"), "Service account");
|
||||
assert.equal(humanizeConfigKey("image"), "Image");
|
||||
assert.equal(humanizeConfigKey("api_url"), "API URL");
|
||||
assert.equal(humanizeConfigKey(""), "");
|
||||
});
|
||||
156
desktop/src/features/agents/ui/runOnSummary.ts
Normal file
156
desktop/src/features/agents/ui/runOnSummary.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { ManagedAgentBackend } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* A single saved provider-config row, ready to render.
|
||||
*
|
||||
* `value` is always a display string: scalars are stringified, `null` becomes
|
||||
* an explicit "Not set", and anything non-scalar (array/object) is summarized
|
||||
* rather than dumped — a generic "render every value" helper must not become
|
||||
* a disclosure surface for config shapes we have never seen.
|
||||
*/
|
||||
export type RunOnConfigRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
/** True when the key looks secret-shaped and the value was replaced. */
|
||||
redacted: boolean;
|
||||
};
|
||||
|
||||
export type RunOnSummary =
|
||||
| { location: "local" }
|
||||
| { location: "provider"; providerId: string; rows: RunOnConfigRow[] };
|
||||
|
||||
/**
|
||||
* Words that mark a key's value as unrenderable, checked against the same
|
||||
* word-split the create-time gate uses (`validate_provider_config`,
|
||||
* src-tauri managed_agents/backend.rs) so there is one definition of
|
||||
* "looks like a secret", not two that drift. That gate already rejects
|
||||
* secret-shaped keys on the only non-test write path to `agent.backend` —
|
||||
* this display-side redaction is screenshot hygiene, not the missing
|
||||
* credential fix: a value that is fine in `managed-agents.json` on the
|
||||
* owner's own disk is not fine in a dialog that gets screenshotted into a
|
||||
* channel or PR body, and hand-edited records never met the gate at all.
|
||||
* The first five words mirror the Rust list; the rest are display-only
|
||||
* extras (redacting more than create rejects fails safe).
|
||||
*/
|
||||
const SECRET_WORDS = new Set([
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"key",
|
||||
"credential",
|
||||
"passphrase",
|
||||
"auth",
|
||||
"nsec",
|
||||
]);
|
||||
|
||||
const REDACTED_PLACEHOLDER = "••••••••";
|
||||
|
||||
/** Acronyms that read wrong in sentence case ("Cpu request"). */
|
||||
const LABEL_ACRONYMS = new Set(["cpu", "id", "url", "api"]);
|
||||
|
||||
/**
|
||||
* Split a config key into lowercase words on `_`, `-`, `.`, and camelCase
|
||||
* boundaries, keeping acronym runs together ("APIKey" → ["api", "key"]).
|
||||
* Mirrors `split_config_key` in managed_agents/backend.rs — one split feeds
|
||||
* both labels and redaction, and word matching (not substring) is what
|
||||
* keeps "keyboard" from being treated as a key.
|
||||
*/
|
||||
function splitConfigKey(key: string): string[] {
|
||||
return key
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.split(/[_\s.-]+/)
|
||||
.filter((word) => word.length > 0)
|
||||
.map((word) => word.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanize a stored config key: `cpu_request` → "CPU request". Labels come
|
||||
* from the saved keys, not a live provider probe — probing during edit is
|
||||
* executable work whose schema (titles, generated defaults) reflects the
|
||||
* plugin *today*, not what this agent was deployed with. See PR #4411 for
|
||||
* why dialogs must not re-probe as a side effect.
|
||||
*/
|
||||
export function humanizeConfigKey(key: string): string {
|
||||
const words = splitConfigKey(key);
|
||||
if (words.length === 0) return key;
|
||||
return words
|
||||
.map((word, index) => {
|
||||
if (LABEL_ACRONYMS.has(word)) return word.toUpperCase();
|
||||
if (index === 0) return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
return word;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "Not set";
|
||||
if (typeof value === "string") return value.length > 0 ? value : "Not set";
|
||||
if (typeof value === "number" || typeof value === "boolean")
|
||||
return String(value);
|
||||
// Arrays/objects: summarize, never serialize. A nested structure could
|
||||
// carry values its own keys would have redacted.
|
||||
return Array.isArray(value)
|
||||
? `List (${value.length} items)`
|
||||
: "Structured value";
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred display order, traced from the Kubernetes provider schema
|
||||
* (crates/buzz-backend-kubernetes/src/config.rs): locate the deployment
|
||||
* first (context → namespace), then what runs (image), then the
|
||||
* request/limit pairs kept adjacent, then lifecycle/identity. Keys not
|
||||
* listed here (other providers, future fields) spill over alphabetically
|
||||
* after the known ones, so ordering stays deterministic for every record.
|
||||
*/
|
||||
const PREFERRED_KEY_ORDER = [
|
||||
"context",
|
||||
"namespace",
|
||||
"image",
|
||||
"cpu_request",
|
||||
"memory_request",
|
||||
"cpu_limit",
|
||||
"memory_limit",
|
||||
"inactivity_seconds",
|
||||
"service_account",
|
||||
];
|
||||
|
||||
function compareKeys(a: string, b: string): number {
|
||||
const ia = PREFERRED_KEY_ORDER.indexOf(a);
|
||||
const ib = PREFERRED_KEY_ORDER.indexOf(b);
|
||||
if (ia !== -1 && ib !== -1) return ia - ib;
|
||||
if (ia !== -1) return -1;
|
||||
if (ib !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a `ManagedAgentBackend` into renderable rows. These are the
|
||||
* *saved* settings — the config recorded when the agent was created — not
|
||||
* the provider's effective settings: optional fields a record omits are
|
||||
* defaulted by the provider at deploy time, and synthesizing today's plugin
|
||||
* defaults here could show values that drifted from what actually deployed.
|
||||
* (`backendAgentId` is deliberately not shown: it is deploy-time runtime
|
||||
* state written on start, not saved creation intent, and this section's
|
||||
* contract is the latter.) Rows follow the preferred key order with
|
||||
* alphabetical spillover, so rendering is deterministic regardless of the
|
||||
* JSON key order a given record happened to persist.
|
||||
*/
|
||||
export function summarizeRunOn(backend: ManagedAgentBackend): RunOnSummary {
|
||||
if (backend.type === "local") return { location: "local" };
|
||||
const rows = Object.entries(backend.config ?? {})
|
||||
.sort(([a], [b]) => compareKeys(a, b))
|
||||
.map(([key, value]): RunOnConfigRow => {
|
||||
const redacted = splitConfigKey(key).some((word) =>
|
||||
SECRET_WORDS.has(word),
|
||||
);
|
||||
return {
|
||||
key,
|
||||
label: humanizeConfigKey(key),
|
||||
value: redacted ? REDACTED_PLACEHOLDER : displayValue(value),
|
||||
redacted,
|
||||
};
|
||||
});
|
||||
return { location: "provider", providerId: backend.id, rows };
|
||||
}
|
||||
212
desktop/tests/e2e/edit-agent-run-on.spec.ts
Normal file
212
desktop/tests/e2e/edit-agent-run-on.spec.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/edit-agent-run-on";
|
||||
|
||||
/**
|
||||
* The exact persisted shape of a real kubernetes agent created through the
|
||||
* app ("Loni" in managed-agents.json, traced by Sami in review): eight keys,
|
||||
* `inactivity_seconds` a JSON number, everything else strings, and the
|
||||
* optional `service_account` ABSENT — no schema default means the create
|
||||
* flow never seeds it, so honest fixtures omit it too.
|
||||
*/
|
||||
const KUBERNETES_CONFIG = {
|
||||
context: "docker-desktop",
|
||||
cpu_limit: "1",
|
||||
cpu_request: "1",
|
||||
image:
|
||||
"ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76",
|
||||
inactivity_seconds: 7200,
|
||||
memory_limit: "1Gi",
|
||||
memory_request: "1Gi",
|
||||
namespace: "buzz-agents-gfq7aq",
|
||||
};
|
||||
|
||||
async function openEditDialog(
|
||||
page: import("@playwright/test").Page,
|
||||
agentName: string,
|
||||
) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page
|
||||
.getByRole("button", { name: `${agentName} agent profile` })
|
||||
.click();
|
||||
await page.getByTestId("user-profile-edit-agent").click();
|
||||
await expect(page.getByTestId("edit-agent-dialog")).toBeVisible();
|
||||
}
|
||||
|
||||
test("editing a kubernetes agent shows its saved run-on settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = TEST_IDENTITIES.charlie;
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: agent.pubkey,
|
||||
name: "Remote Helper",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
respondTo: "owner-only",
|
||||
backend: {
|
||||
type: "provider",
|
||||
id: "kubernetes",
|
||||
config: KUBERNETES_CONFIG,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await openEditDialog(page, "Remote Helper");
|
||||
|
||||
const runOn = page.getByTestId("edit-agent-run-on");
|
||||
await expect(runOn).toBeVisible();
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText(
|
||||
"kubernetes",
|
||||
);
|
||||
|
||||
// Every saved field renders as a labeled row with its stored value.
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-namespace")).toContainText(
|
||||
"buzz-agents-gfq7aq",
|
||||
);
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-context")).toContainText(
|
||||
"docker-desktop",
|
||||
);
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-image")).toContainText(
|
||||
"ghcr.io/block/buzz-sprig",
|
||||
);
|
||||
await expect(
|
||||
runOn.getByTestId("edit-agent-run-on-cpu_request"),
|
||||
).toContainText("CPU request");
|
||||
await expect(
|
||||
runOn.getByTestId("edit-agent-run-on-inactivity_seconds"),
|
||||
).toContainText("7200");
|
||||
// Real records omit the optional service_account (no schema default): the
|
||||
// section must show only what was saved, never synthesize a row for it.
|
||||
await expect(
|
||||
runOn.getByTestId("edit-agent-run-on-service_account"),
|
||||
).toHaveCount(0);
|
||||
|
||||
// The section explains immutability instead of pretending to be a form.
|
||||
await expect(runOn).toContainText("can't be changed afterwards");
|
||||
|
||||
await runOn.scrollIntoViewIfNeeded();
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.getByTestId("edit-agent-dialog")
|
||||
.screenshot({ path: `${SHOTS}/kubernetes-run-on.png` });
|
||||
});
|
||||
|
||||
test("editing a local agent names this computer, with no config rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = TEST_IDENTITIES.tyler;
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: agent.pubkey,
|
||||
name: "Local Helper",
|
||||
status: "stopped",
|
||||
channelNames: ["general"],
|
||||
respondTo: "owner-only",
|
||||
backend: { type: "local" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await openEditDialog(page, "Local Helper");
|
||||
|
||||
const runOn = page.getByTestId("edit-agent-run-on");
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText(
|
||||
"This computer",
|
||||
);
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-namespace")).toHaveCount(0);
|
||||
|
||||
await runOn.scrollIntoViewIfNeeded();
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.getByTestId("edit-agent-dialog")
|
||||
.screenshot({ path: `${SHOTS}/local-run-on.png` });
|
||||
});
|
||||
|
||||
test("a blox agent's single saved field renders with a humanized label", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The other real provider shape on developer machines: blox records
|
||||
// persist exactly one key, exercising provider-generic humanization.
|
||||
const agent = TEST_IDENTITIES.bob;
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: agent.pubkey,
|
||||
name: "Blox Helper",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
respondTo: "owner-only",
|
||||
backend: {
|
||||
type: "provider",
|
||||
id: "blox",
|
||||
config: { workstation_name: "tlongwell-sprout-home" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await openEditDialog(page, "Blox Helper");
|
||||
|
||||
const runOn = page.getByTestId("edit-agent-run-on");
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText(
|
||||
"blox",
|
||||
);
|
||||
const row = runOn.getByTestId("edit-agent-run-on-workstation_name");
|
||||
await expect(row).toContainText("Workstation name");
|
||||
await expect(row).toContainText("tlongwell-sprout-home");
|
||||
|
||||
await runOn.scrollIntoViewIfNeeded();
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.getByTestId("edit-agent-dialog")
|
||||
.screenshot({ path: `${SHOTS}/blox-run-on.png` });
|
||||
});
|
||||
|
||||
test("secret-shaped keys from an untrusted provider render redacted", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = TEST_IDENTITIES.charlie;
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: agent.pubkey,
|
||||
name: "Future Provider Agent",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
respondTo: "owner-only",
|
||||
backend: {
|
||||
type: "provider",
|
||||
id: "some-future-provider",
|
||||
config: {
|
||||
endpoint: "https://provider.example",
|
||||
api_token: "tok-do-not-show",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await openEditDialog(page, "Future Provider Agent");
|
||||
|
||||
const runOn = page.getByTestId("edit-agent-run-on");
|
||||
await expect(runOn.getByTestId("edit-agent-run-on-endpoint")).toContainText(
|
||||
"https://provider.example",
|
||||
);
|
||||
const tokenRow = runOn.getByTestId("edit-agent-run-on-api_token");
|
||||
await expect(tokenRow).toContainText("••••••••");
|
||||
await expect(tokenRow).not.toContainText("tok-do-not-show");
|
||||
// The raw secret must not appear anywhere in the dialog.
|
||||
await expect(page.getByTestId("edit-agent-dialog")).not.toContainText(
|
||||
"tok-do-not-show",
|
||||
);
|
||||
|
||||
await runOn.scrollIntoViewIfNeeded();
|
||||
await waitForAnimations(page);
|
||||
await page
|
||||
.getByTestId("edit-agent-dialog")
|
||||
.screenshot({ path: `${SHOTS}/redacted-run-on.png` });
|
||||
});
|
||||
Reference in New Issue
Block a user