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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export default defineConfig({
"**/signout-confirmation.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
"**/agent-access-warning.spec.ts",
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
"**/observer-archive-policy.spec.ts",
Expand Down
39 changes: 39 additions & 0 deletions desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,39 @@ with a TypeScript lookup table or an id comparison in a component.
published or removed. A queued update must stay visibly queued, and the
catalog itself must render only relay-confirmed publications — never an
optimistic local persona.
11. **Shared agent access names the consequence where it is selected.** The
shared respond-to field shows a persistent warning whenever `anyone` **or**
`allowlist` is selected — both hand the host's access to someone other than
the owner, so both disclose it and only the audience phrase differs. This
covers persona-backed create and edit surfaces. Keep that disclosure in
the shared field instead of adding surface-specific flags. It renders
directly below the selector for `anyone` but *after* the people picker for
`allowlist`, so it never sits between the user and the selection they came
to make. The copy leads with the audience ("Anyone can use this agent to
access…") so it reads as a warning rather than an explanation, and stays one
sentence — don't split the mechanism into a second sentence. Both the machine
and the stakes it names come from `lib/agentAccessWarning.ts`, keyed on an
optional `runLocation`: instance surfaces resolve it from
`ManagedAgent.backend` via `runLocationForBackend`, and the create flow from
`WhereToRunDraft.runOn` via `runLocationForRunOn`. `AgentDialog` is the one
place that resolves it for dialog surfaces and publishes it through
`ui/AgentRunLocationContext.tsx`; the field reads that context and lets an
explicit `runLocation` prop win. Do **not** thread the value as a prop
through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — both are
already over the 1000-line ceiling, and neither uses the value itself.
Surfaces rendered outside `AgentDialog` (e.g. `EditRespondToDialog`) pass the
prop directly. Local names "your
computer, including files, accounts, and connected tools"; remote names "the
server it runs on, including any accounts and tools available there" —
deliberately *not* the owner's files, which aren't theirs to describe on a
host they don't own. **An unknown location falls back to the local wording —
never hedge with "computer or server".** A remote host requires an
installed `buzz-backend-*` provider, and without one `WhereToRunSection`
never renders, so "server" would name a concept the owner has never been
shown; when it *is* remote they picked that host from the selector
themselves. Never synthesize a run location a surface doesn't have. Don't
expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI
copy.

## The tests that enforce this

Expand All @@ -128,6 +161,12 @@ with a TypeScript lookup table or an id comparison in a component.
`isCacheableDiscoveryResponse`, `deriveModelDiscoveryPending`,
`isSuccessfulEmptyDiscovery`. If the "reopen to retry" copy becomes inert
again, these tests will catch it.
- `ui/respondToFieldContract.test.mjs` — plain-language mode labels, the
persistent warning contract for shared agent access, and its two render
positions (after the people picker for `allowlist`).
- `lib/agentAccessWarning.test.mjs` — every mode × run-location copy variant
plus both resolvers, including unknown-reads-as-local and
blank-`runOn`-is-not-a-provider.
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
acceptance coverage for readiness, failure states, defaults, navigation,
successful-empty vs failed optional-model discovery, and persistence races.
Expand Down
89 changes: 89 additions & 0 deletions desktop/src/features/agents/lib/agentAccessWarning.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
agentAccessWarningText,
runLocationForBackend,
runLocationForRunOn,
} from "./agentAccessWarning.ts";

test("only the modes that share access warn", () => {
assert.equal(agentAccessWarningText("owner-only", "local"), null);
assert.ok(agentAccessWarningText("anyone", "local"));
assert.ok(agentAccessWarningText("allowlist", "local"));
});

test("a local agent names this computer and what is reachable on it", () => {
assert.equal(
agentAccessWarningText("anyone", "local"),
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
);
assert.equal(
agentAccessWarningText("allowlist", "local"),
"Selected people can use this agent to access your computer, including files, accounts, and connected tools.",
);
});

test("a provider-backed agent names the server, and not the owner's files", () => {
// A remote host's files aren't the owner's to describe, so the tail narrows
// to the accounts and tools provisioned there.
assert.equal(
agentAccessWarningText("anyone", "remote"),
"Anyone can use this agent to access the server it runs on, including any accounts and tools available there.",
);
assert.equal(
agentAccessWarningText("allowlist", "remote"),
"Selected people can use this agent to access the server it runs on, including any accounts and tools available there.",
);
assert.doesNotMatch(
agentAccessWarningText("anyone", "remote"),
/your computer/,
);
});

test("an unknown run location reads as local, not as a hedge", () => {
// "computer or server" names a concept most owners have never been shown:
// the Run on selector only renders when a buzz-backend-* provider exists.
for (const unknown of [undefined, null]) {
assert.equal(
agentAccessWarningText("anyone", unknown),
"Anyone can use this agent to access your computer, including files, accounts, and connected tools.",
);
}
});

test("every variant leads with the audience and stays jargon-free", () => {
for (const mode of ["anyone", "allowlist"]) {
for (const runLocation of [null, "local", "remote"]) {
const text = agentAccessWarningText(mode, runLocation);
assert.match(
text,
/^(Anyone|Selected people) can use this agent to access/,
);
assert.doesNotMatch(text, /respond-to|allowlist|pubkey|Nostr|harness/i);
}
}
});

test("runLocationForBackend maps the backend union", () => {
assert.equal(runLocationForBackend({ type: "local" }), "local");
assert.equal(
runLocationForBackend({ type: "provider", id: "blox", config: {} }),
"remote",
);
assert.equal(runLocationForBackend(null), null);
assert.equal(runLocationForBackend(undefined), null);
});

test("runLocationForRunOn treats a provider id as remote", () => {
assert.equal(runLocationForRunOn("local"), "local");
assert.equal(runLocationForRunOn("blox"), "remote");
});

test("runLocationForRunOn treats a blank value as unknown", () => {
// `runOn` is typed `"local" | string`, so a blank must not read as a
// provider id and produce the server wording.
assert.equal(runLocationForRunOn(""), null);
assert.equal(runLocationForRunOn(null), null);
assert.equal(runLocationForRunOn(undefined), null);
});
63 changes: 63 additions & 0 deletions desktop/src/features/agents/lib/agentAccessWarning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ManagedAgentBackend, RespondToMode } from "@/shared/api/types";

/**
* Where an agent's process runs, as far as the calling surface can tell.
*
* Deliberately coarser than `ManagedAgentBackend`: the warning copy only needs
* to know "this machine" vs "somewhere else", so surfaces resolve their own
* backend shape down to this before handing it over. `null` means the surface
* genuinely cannot tell — see `agentAccessWarningText` for how that is
* treated.
*/
export type AgentRunLocation = "local" | "remote";

/** Resolve a running agent's backend record. `null` when the backend is unknown. */
export function runLocationForBackend(
backend: ManagedAgentBackend | null | undefined,
): AgentRunLocation | null {
if (!backend) return null;
return backend.type === "local" ? "local" : "remote";
}

/**
* Resolve the create flow's `WhereToRunDraft.runOn`, which is `"local"` or a
* discovered provider id. An empty string is treated as unknown rather than as
* a provider, since `runOn` is typed `"local" | string`.
*/
export function runLocationForRunOn(
runOn: string | null | undefined,
): AgentRunLocation | null {
if (!runOn) return null;
return runOn === "local" ? "local" : "remote";
}

/**
* Copy for the shared-access warning in the respond-to field, or `null` for
* modes that share nothing.
*
* Both `anyone` and `allowlist` hand the host's access to someone other than
* the owner, so both warn; only the audience phrase differs.
*
* An unknown run location falls back to the same "your computer" wording as
* `local` rather than hedging with "computer or server". A remote host is only
* reachable when a `buzz-backend-*` provider binary is installed — without one
* `WhereToRunSection`'s "Run on" selector never renders and every agent is
* local — so hedging would name a concept most owners have never been shown.
* When it *is* remote the owner picked that host from the selector
* deliberately, so naming a server is meaningful there.
*/
export function agentAccessWarningText(
mode: RespondToMode,
runLocation?: AgentRunLocation | null,
): string | null {
if (mode !== "anyone" && mode !== "allowlist") return null;
const audience = mode === "anyone" ? "Anyone" : "Selected people";
// The two locations differ in more than the noun: a local agent reaches the
// owner's own files, while a remote host's files aren't theirs to describe —
// only the accounts and tools provisioned there.
const target =
runLocation === "remote"
? "the server it runs on, including any accounts and tools available there"
: "your computer, including files, accounts, and connected tools";
return `${audience} can use this agent to access ${target}.`;
}
91 changes: 54 additions & 37 deletions desktop/src/features/agents/ui/AgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type {
ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
import {
runLocationForBackend,
runLocationForRunOn,
} from "../lib/agentAccessWarning";
import { AgentRunLocationProvider } from "./AgentRunLocationContext";
import type { BackendIntent } from "../lib/instanceInputForDefinition";
import type { AgentCreateIntent } from "./agentCreateIntent";
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
Expand Down Expand Up @@ -89,17 +94,25 @@ type AgentDialogProps =
export function AgentDialog(props: AgentDialogProps) {
if (props.mode === "instance-edit") {
return (
<AgentInstanceEditDialog
agent={props.agent}
onEditLinkedPersona={props.onEditLinkedPersona}
onOpenChange={props.onOpenChange}
onUpdated={props.onUpdated}
open={props.open}
initialFocus={props.initialFocus}
/>
// A running instance knows its own backend, so the respond-to warning can
// name the machine it will actually run on.
<AgentRunLocationProvider
runLocation={runLocationForBackend(props.agent.backend)}
>
<AgentInstanceEditDialog
agent={props.agent}
onEditLinkedPersona={props.onEditLinkedPersona}
onOpenChange={props.onOpenChange}
onUpdated={props.onUpdated}
open={props.open}
initialFocus={props.initialFocus}
/>
</AgentRunLocationProvider>
);
}
if (props.mode === "definition-edit") {
// A definition has no instance and no run draft, so the run location stays
// unknown and the warning uses its local-wording fallback.
const { mode: _mode, ...definitionProps } = props;
return <AgentDefinitionDialog {...definitionProps} />;
}
Expand All @@ -124,35 +137,39 @@ function AgentCreateDialogRouter({
const copy = createPersonaDialogState();

return (
<AgentDefinitionDialog
createRunSection={
<WhereToRunSection
draft={runDraft}
isPending={isDefinitionPending}
onDraftChange={setRunDraft}
/>
}
createSubmitBlocked={!canSubmitWhereToRun(runDraft)}
description={copy.description}
error={definitionError}
initialValues={initialValues}
isPending={isDefinitionPending}
onOpenChange={onOpenChange}
onSubmit={async (input) => {
const submitted = await onSubmitDefinition(
input,
"definition_start",
resolveBackendIntent(runDraft),
);
if (submitted) {
onOpenChange(false);
// The create flow is the one surface that knows where the agent will run,
// because it owns the "Run on" draft.
<AgentRunLocationProvider runLocation={runLocationForRunOn(runDraft.runOn)}>
<AgentDefinitionDialog
createRunSection={
<WhereToRunSection
draft={runDraft}
isPending={isDefinitionPending}
onDraftChange={setRunDraft}
/>
}
}}
open
runtimes={runtimes}
runtimesLoading={runtimesLoading}
submitLabel={copy.submitLabel}
title={copy.title}
/>
createSubmitBlocked={!canSubmitWhereToRun(runDraft)}
description={copy.description}
error={definitionError}
initialValues={initialValues}
isPending={isDefinitionPending}
onOpenChange={onOpenChange}
onSubmit={async (input) => {
const submitted = await onSubmitDefinition(
input,
"definition_start",
resolveBackendIntent(runDraft),
);
if (submitted) {
onOpenChange(false);
}
}}
open
runtimes={runtimes}
runtimesLoading={runtimesLoading}
submitLabel={copy.submitLabel}
title={copy.title}
/>
</AgentRunLocationProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,7 @@ export function AgentInstanceEditDialog({
</div>
</div>

{/* Who can talk to this agent */}
{/* Who can send instructions */}
<CreateAgentRespondToField
allowlist={respondToAllowlist}
disabled={updateMutation.isPending}
Expand Down
43 changes: 43 additions & 0 deletions desktop/src/features/agents/ui/AgentRunLocationContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as React from "react";

import type { AgentRunLocation } from "../lib/agentAccessWarning";

/**
* Where the agent a dialog subtree is editing will run.
*
* Context rather than a prop on purpose: the only consumer is the respond-to
* warning, buried several levels inside `AgentDefinitionDialog` (1000+ lines)
* and `AgentInstanceEditDialog` (1200+ lines). Threading a prop through them
* would grow two files that are already over the 1000-line ceiling enforced by
* `desktop/scripts/check-file-sizes.mjs`, for a value neither of them uses.
*
* `AgentDialog` is the single entry point for all three dialog modes, so it is
* the one place that provides this. Surfaces that render the respond-to field
* outside that tree (e.g. `EditRespondToDialog`) pass the `runLocation` prop
* directly instead.
*
* The default is `null` — unknown. Never provide a guessed value; the copy
* falls back to the local wording, which is correct for any owner who has not
* installed a `buzz-backend-*` provider.
*/
const AgentRunLocationContext = React.createContext<AgentRunLocation | null>(
null,
);

export function AgentRunLocationProvider({
children,
runLocation,
}: {
children: React.ReactNode;
runLocation: AgentRunLocation | null;
}) {
return (
<AgentRunLocationContext.Provider value={runLocation}>
{children}
</AgentRunLocationContext.Provider>
);
}

export function useAgentRunLocation(): AgentRunLocation | null {
return React.useContext(AgentRunLocationContext);
}
Loading