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
33 changes: 33 additions & 0 deletions .changeset/multi-agent-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@boardstate/schema": minor
"@boardstate/core": minor
"@boardstate/server": minor
"@boardstate/lit": minor
---

Multi-agent workspaces (#59, SPEC §17.3): several agents sharing one board, distinguishable
and separately governed.

- **Per-agent grant scoping (schema + engine).** A capability grant gains an optional
operator-set `agents?: string[]` — the ACTOR dimension of the AND-gate. Absent ⇒ all agents
(back-compat, zero migration); present ⇒ only those agent actors pass, at BOTH tool-set
assembly (the agent-tool adapter surfaces a scoped grant only to a bound, listed agent —
covering the direct `readOnly` path) and invoke/read time (`dashboard.action.invoke` /
`dashboard.connector.read` fail-safe recheck). Operator-set ONLY (the approve verb);
`tool_search` REQUEST / `workspace.replace` / import can never write or widen it — any scope
drift on a still-granted grant re-pends the whole grant, and every re-pend (manifest drift,
replace/import, REQUEST, TTL expiry, revoke) strips it, exactly like `autoConfirm`/`expiresAt`.
- **Actor authenticity (load-bearing).** The acting agent is bound from the server-side
session/tool-registration identity (threaded `RequestContext → RpcHandlerContext`), NEVER a
request param. A parked mutation records the server-bound requester and the confirm-time
re-gate re-checks scope against IT. The WS transport threads no identity, so a scoped grant
FAILS CLOSED for an unauthenticated networked caller (`capability_pending`) — a client-claimed
`actor` can never pass another agent's scope (wire-contract tested).
- **Per-agent rate budgets.** The per-connector invoke limit gains an optional
`perAgentInvokeRateMax`: an agent's ceiling becomes `min(connector, per-agent)`. Unset ⇒
connector-only, byte-identical to prior behavior.
- **Provenance chips + filter (lit).** On a board with ≥2 distinct agent authors, each widget
header shows a compact deterministically-coloured chip (short id, full actor on hover) and a
toolbar affordance filters/highlights one agent's widgets. The approvals widget renders each
grant's per-agent scope. Zero schema change; single-agent boards are unchanged. New i18n keys
added to the five complete locales.
5 changes: 3 additions & 2 deletions packages/core/src/distribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ describe("sanitizeImportedWorkspace", () => {
streams: [],
tools: ["officecli:send_mail"],
toolsHash: "hash-x",
// The operator-only auto-run + TTL (SPEC §17.2/§17 TTLs) must NOT survive import —
// a foreign board carries no active lease.
// The operator-only auto-run + TTL + per-agent scope (SPEC §17.2/§17.3) must NOT
// survive import — a foreign board carries no active lease and no scope claims.
autoConfirm: ["officecli:send_mail"],
expiresAt: "2026-12-31T00:00:00.000Z",
agents: ["agent:alice"],
grantedBy: "user",
grantedAt: "2026-01-01T00:00:00.000Z",
},
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/distribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,17 @@ export function sanitizeImportedWorkspace(parsed: unknown): Record<string, unkno
const caps: Record<string, unknown> = {};
for (const [name, entry] of Object.entries(capsInput)) {
if (isRecord(entry)) {
// Strip who/when-granted AND the operator-only auto-run + TTL fields (SPEC §17.2/§17
// TTLs): an imported board is foreign authoring and carries no active lease.
// Strip who/when-granted AND every operator-only privilege field — auto-run,
// TTL lease, and per-agent scope (SPEC §17.2/§17.3): an imported board is
// foreign authoring; scope claims from a foreign doc are as untrustworthy as
// a granted status (adversarial verify 2026-07-11 — the import path had been
// missed when the other re-pend sites gained the agents strip).
const {
grantedBy: _grantedBy,
grantedAt: _grantedAt,
autoConfirm: _autoConfirm,
expiresAt: _expiresAt,
agents: _agents,
...rest
} = entry;
caps[name] = { ...rest, status: "requested" };
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ function normalizeCapabilityGrant(value: unknown): DashboardCapabilityGrant | nu
// pre-#62/#64 grant normalizes byte-identically (no new keys invented on output).
...(Array.isArray(value.autoConfirm) ? { autoConfirm: strings(value.autoConfirm) } : {}),
...(typeof value.expiresAt === "string" ? { expiresAt: value.expiresAt } : {}),
// `agents` (SPEC §17.3, #59) carried only when present, so an unscoped grant
// normalizes byte-identically (no new key invented on output).
...(Array.isArray(value.agents) ? { agents: strings(value.agents) } : {}),
...(typeof value.description === "string" ? { description: value.description } : {}),
...(typeof value.grantedBy === "string" ? { grantedBy: value.grantedBy } : {}),
...(typeof value.grantedAt === "string" ? { grantedAt: value.grantedAt } : {}),
Expand Down
43 changes: 42 additions & 1 deletion packages/core/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ describe("DashboardStore", () => {
toolsHash: "hash-send",
autoConfirm: ["officecli:send_mail"],
expiresAt: new Date(1_000).toISOString(),
agents: ["agent:alice"],
grantedBy: "user",
grantedAt: new Date(500).toISOString(),
},
Expand All @@ -330,7 +331,7 @@ describe("DashboardStore", () => {
return result.doc.workspaceVersion;
}

it("re-pends a lapsed grant on read — clears autoConfirm + expiresAt, bumps version once", async () => {
it("re-pends a lapsed grant on read — clears autoConfirm + expiresAt + agents, bumps version once", async () => {
await withTempStateDir(async (stateDir) => {
const seededVersion = await seedGrant(stateDir);
const reader = storeAt(stateDir, { now: () => 2_000 });
Expand All @@ -339,6 +340,7 @@ describe("DashboardStore", () => {
expect(grant.status).toBe("requested");
expect(grant.autoConfirm).toBeUndefined();
expect(grant.expiresAt).toBeUndefined();
expect(grant.agents).toBeUndefined();
expect(grant.grantedBy).toBeUndefined();
// Tools survive (the operator re-approves the SAME surface); version bumped once.
expect(grant.tools).toEqual(["officecli:send_mail"]);
Expand Down Expand Up @@ -584,6 +586,45 @@ describe("replaceSanitized (approval gate, SPEC §8.2)", () => {
expect(out.capabilitiesRegistry!.officecli!.status).toBe("requested");
expect(out.capabilitiesRegistry!.officecli!.grantedBy).toBeUndefined();
});

it("re-pends + strips a granted grant whose per-agent scope drifts via replace (SPEC §17.3, #59)", () => {
// Both a WIDEN (dropping the scope entirely) and a re-scope are silent widenings
// through the agent path — the single reconcile gate re-pends and strips `agents`.
const grant = (agents: string[] | undefined) => ({
capabilitiesRegistry: {
officecli: {
status: "granted",
methods: [],
streams: [],
tools: ["officecli:read_mail"],
toolsHash: "hash-x",
grantedBy: "user",
...(agents ? { agents } : {}),
},
},
});
// A) dropping the scope (scoped -> all agents) re-pends.
const widened = reconcileReplaceApproval(
grant(undefined) as never,
grant(["agent:alice"]) as never,
);
expect(widened.capabilitiesRegistry!.officecli!.status).toBe("requested");
expect(widened.capabilitiesRegistry!.officecli!.grantedBy).toBeUndefined();
// B) re-scoping to a different agent re-pends + strips.
const rescoped = reconcileReplaceApproval(
grant(["agent:bob"]) as never,
grant(["agent:alice"]) as never,
);
expect(rescoped.capabilitiesRegistry!.officecli!.status).toBe("requested");
expect(rescoped.capabilitiesRegistry!.officecli!.agents).toBeUndefined();
// An UNCHANGED scope survives untouched (no false re-pend).
const noop = reconcileReplaceApproval(
grant(["agent:alice"]) as never,
grant(["agent:alice"]) as never,
);
expect(noop.capabilitiesRegistry!.officecli!.status).toBe("granted");
expect(noop.capabilitiesRegistry!.officecli!.agents).toEqual(["agent:alice"]);
});
});

async function viWaitFor(assertion: () => void): Promise<void> {
Expand Down
40 changes: 28 additions & 12 deletions packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,15 @@ export function reconcileReplaceApproval(
// run through here, so this single gate closes both.
//
// The OPERATOR-ONLY surface of a granted grant is (a) the authorized `tools`/`toolsHash`
// (SPEC §17.1), (b) the `autoConfirm` "always allow" set (SPEC §17.2, #62), and (c) the
// `expiresAt` TTL (SPEC §17 TTLs, #64). None may be MUTATED through replace/import: an
// agent appending a tool, ticking an auto-confirm, or extending a lease is a silent
// widening. Any drift in ANY of these on a still-granted grant re-pends the whole grant
// to `requested` so the operator re-approves the new surface — the single gate closes
// every no-agent-write path (agent workspace.replace, raw RPC replace, import).
// (SPEC §17.1), (b) the `autoConfirm` "always allow" set (SPEC §17.2, #62), (c) the
// `expiresAt` TTL (SPEC §17 TTLs, #64), and (d) the `agents` per-agent scope (SPEC §17.3,
// #59). None may be MUTATED through replace/import: an agent appending a tool, ticking an
// auto-confirm, extending a lease, or WIDENING the agent scope (dropping `agents`, or
// adding an actor) is a silent widening. Any drift in ANY of these on a still-granted
// grant re-pends the whole grant to `requested` so the operator re-approves the new
// surface — the single gate closes every no-agent-write path (agent workspace.replace,
// raw RPC replace, import). Operator-side narrowing is done through the approve verb, not
// here, so re-pend-on-any-drift is correct: this path never carries operator intent.
const currentCaps = current.capabilitiesRegistry ?? {};
const incomingCaps = incoming.capabilitiesRegistry ?? {};
for (const [name, grant] of Object.entries(incomingCaps)) {
Expand All @@ -114,15 +117,18 @@ export function reconcileReplaceApproval(
(!sameStringSet(grant.tools ?? [], currentGrant.tools ?? []) ||
(grant.toolsHash ?? "") !== (currentGrant.toolsHash ?? "") ||
!sameStringSet(grant.autoConfirm ?? [], currentGrant.autoConfirm ?? []) ||
(grant.expiresAt ?? "") !== (currentGrant.expiresAt ?? ""));
(grant.expiresAt ?? "") !== (currentGrant.expiresAt ?? "") ||
!sameStringSet(grant.agents ?? [], currentGrant.agents ?? []));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip agent scopes from untrusted requested grants

This scope comparison only runs when the incoming grant is still status:"granted", so an untrusted dashboard.workspace.replace can write agents onto a requested or revoked capability and have it persist. That operator-only scope is then rendered in the approvals row as if it were operator-authored, violating the new single-writer invariant for agents; strip agents from non-granted/re-pended grants on the replace path as well.

Useful? React with 👍 / 👎.

if (selfElevated || surfaceMutated) {
grant.status = "requested";
delete grant.grantedBy;
delete grant.grantedAt;
// A re-pended grant carries no active auto-run or lease — strip the operator-only
// fields so a self-grant smuggled through replace can never resurrect them.
// A re-pended grant carries no active auto-run, lease, or scope — strip the
// operator-only fields so a self-grant smuggled through replace can never resurrect
// them (and a widened scope never rides along on a re-pended grant).
delete grant.autoConfirm;
delete grant.expiresAt;
delete grant.agents;
}
}
return incoming;
Expand Down Expand Up @@ -210,8 +216,9 @@ function sweepExpiredEphemeral(doc: WorkspaceDoc, nowMs: number): WorkspaceDoc |
* Re-pend capability grants whose TTL has lapsed (SPEC §17 grant TTLs, #64). A
* `granted` grant with an `expiresAt` at or before `nowMs` flips back to `requested`
* — the standard re-pend: tools drop from the agent's set next turn, mcp bindings
* surface `capability_pending`, and (crucially) `autoConfirm` is cleared so a lapsed
* lease can never keep auto-running. `grantedBy`/`grantedAt`/`expiresAt` are stripped,
* surface `capability_pending`, and (crucially) `autoConfirm` + `agents` are cleared so a
* lapsed lease can never keep auto-running or stay scoped. `grantedBy`/`grantedAt`/`expiresAt`
* are stripped,
* exactly like import re-pend. Returns a version-bumped doc when anything expired, or
* null when nothing did — so `read()` writes exactly once, folded with the ephemeral
* sweep. Mirrors `sweepExpiredEphemeral`: SWEEP ON READ is the universal, fail-closed
Expand All @@ -238,7 +245,16 @@ function sweepExpiredGrants(doc: WorkspaceDoc, nowMs: number): WorkspaceDoc | nu
continue;
}
expired = true;
const { grantedBy: _by, grantedAt: _at, expiresAt: _exp, autoConfirm: _auto, ...rest } = grant;
// A lapsed lease re-pends bare: strip provenance, the lease, the auto-run set, AND the
// per-agent scope (SPEC §17.3, #59) — the operator re-scopes on re-approval.
const {
grantedBy: _by,
grantedAt: _at,
expiresAt: _exp,
autoConfirm: _auto,
agents: _agents,
...rest
} = grant;
next[name] = { ...rest, status: "requested" };
}
if (!expired) {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/transforms/approvals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export type PendingApprovalItem = {
* it expires, so the widget can render a live countdown.
*/
expiresAt?: string;
/**
* For a per-agent-scoped `capability` grant (SPEC §17.3, #59): the agent actors the
* grant is scoped to. Absent ⇒ all agents (the grant is unscoped); present ⇒ the widget
* renders the scope so the operator sees WHICH agents may use the granted tools.
*/
agents?: string[];
};

/**
Expand Down Expand Up @@ -205,6 +211,7 @@ export function buildApprovalsSource(
...((grant.tools ?? []).length ? { tools: grant.tools } : {}),
...((grant.autoConfirm ?? []).length ? { autoConfirm: grant.autoConfirm } : {}),
...(grant.expiresAt ? { expiresAt: grant.expiresAt } : {}),
...((grant.agents ?? []).length ? { agents: grant.agents } : {}),
}));
const pendingActions: PendingApprovalItem[] = (actions?.pending ?? []).map((action) => ({
id: action.id,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/transforms/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,35 @@ describe("approvals mapping", () => {
expect(source.pending[0]!.tools).toEqual(["officecli:send_mail"]);
});

it("surfaces a granted grant's per-agent scope on the management row (SPEC §17.3, #59)", () => {
const ws = workspace();
ws.capabilitiesRegistry = {
officecli: {
status: "granted",
methods: [],
streams: [],
tools: ["officecli:send_mail"],
agents: ["agent:alice", "agent:bob"],
},
};
const source = buildApprovalsSource(
ws,
() => {},
() => {},
);
const row = source.pending.find((item) => item.id === "officecli");
expect(row?.granted).toBe(true);
expect(row?.agents).toEqual(["agent:alice", "agent:bob"]);
// An unscoped granted grant carries no `agents` (the row renders "All agents").
ws.capabilitiesRegistry.officecli!.agents = undefined;
const source2 = buildApprovalsSource(
ws,
() => {},
() => {},
);
expect(source2.pending.find((item) => item.id === "officecli")?.agents).toBeUndefined();
});

it("threads a partial-grant tools subset through onDecide to resolveCapability (§17.1)", () => {
const ws = workspace();
ws.capabilitiesRegistry = {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export type DashboardCapabilityGrant = {
autoConfirm?: string[];
/** ISO-8601 instant after which the grant re-pends to `requested` (SPEC §17, #64). */
expiresAt?: string;
/** Operator-set per-agent scope (SPEC §17.3, #59): agent actors this grant is usable by. */
agents?: string[];
description?: string;
grantedBy?: DashboardCreatedBy;
grantedAt?: string;
Expand Down
86 changes: 86 additions & 0 deletions packages/lit/src/agent-provenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Render-model coverage for the multi-agent provenance chips (SPEC §17.3, #59): the pure
// helpers the view + cell consume. No DOM here — the view integration is exercised in
// boardstate-view.test.ts.

import { describe, expect, it } from "vitest";
import type { DashboardWorkspace } from "@boardstate/core";
import {
agentChipFor,
agentHue,
distinctAgentActors,
isMultiAgentBoard,
shortAgentId,
} from "./agent-provenance.js";

function board(...createdBy: (string | undefined)[]): DashboardWorkspace {
return {
schemaVersion: 1,
workspaceVersion: 1,
capabilitiesRegistry: {},
prefs: { tabOrder: ["t"] },
tabs: [
{
slug: "t",
title: "T",
hidden: false,
widgets: createdBy.map((actor, i) => ({
id: `w${i}`,
kind: "builtin:markdown",
title: "W",
grid: { x: 0, y: 0, w: 1, h: 1 },
collapsed: false,
...(actor ? { createdBy: actor } : {}),
})),
},
],
} as unknown as DashboardWorkspace;
}

describe("agentHue", () => {
it("is deterministic and in [0, 360)", () => {
const a = agentHue("agent:alice");
expect(a).toBe(agentHue("agent:alice"));
expect(a).toBeGreaterThanOrEqual(0);
expect(a).toBeLessThan(360);
});

it("spreads distinct actors to (usually) distinct hues", () => {
expect(agentHue("agent:alice")).not.toBe(agentHue("agent:bob"));
});
});

describe("shortAgentId", () => {
it("passes through short ids and ellipsizes long ones", () => {
expect(shortAgentId("alice")).toBe("alice");
expect(shortAgentId("a-very-long-agent-identifier")).toHaveLength(10);
expect(shortAgentId("a-very-long-agent-identifier").endsWith("…")).toBe(true);
});
});

describe("distinctAgentActors + isMultiAgentBoard", () => {
it("counts only distinct agent actors, ignoring user/system/unstamped", () => {
const ws = board("agent:alice", "agent:bob", "agent:alice", "user", "system", undefined);
expect(distinctAgentActors(ws)).toEqual(["agent:alice", "agent:bob"]);
expect(isMultiAgentBoard(ws)).toBe(true);
});

it("is NOT multi-agent with a single agent (chips stay hidden)", () => {
const ws = board("agent:alice", "agent:alice", "user");
expect(isMultiAgentBoard(ws)).toBe(false);
});
});

describe("agentChipFor", () => {
it("returns null for a non-agent author", () => {
expect(agentChipFor("user", null)).toBeNull();
expect(agentChipFor("system", null)).toBeNull();
});

it("builds a chip and marks it dimmed only when another agent is highlighted", () => {
const chip = agentChipFor("agent:alice", "agent:bob");
expect(chip).toMatchObject({ actor: "agent:alice", agentId: "alice", short: "alice" });
expect(chip!.dimmed).toBe(true);
expect(agentChipFor("agent:alice", "agent:alice")!.dimmed).toBe(false);
expect(agentChipFor("agent:alice", null)!.dimmed).toBe(false);
});
});
Loading
Loading