From 14cd0c6e3e0d821b51cddd0e003609d2eb139613 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:26:06 -0700
Subject: [PATCH 01/18] Show BYO agent connection status and reconnect action
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../registry.pod-agents-activity.test.js | 22 ++++++
.../agentConnectionStateService.test.js | 35 +++++++++
backend/routes/registry/pod-agents.ts | 50 ++++++++++++-
.../services/agentConnectionStateService.ts | 37 ++++++++++
frontend/src/i18n/locales/en.json | 7 ++
frontend/src/i18n/locales/zh-CN.json | 7 ++
.../src/v2/__tests__/V2AgentBYOName.test.tsx | 18 ++++-
.../v2/__tests__/V2YourTeamSignal.test.tsx | 16 +++-
.../src/v2/__tests__/agentConnection.test.ts | 17 +++++
frontend/src/v2/components/V2AgentBYO.tsx | 73 +++++++++++++++++--
frontend/src/v2/components/V2YourTeamPage.tsx | 23 ++++--
frontend/src/v2/utils/agentConnection.ts | 17 +++++
12 files changed, 305 insertions(+), 17 deletions(-)
create mode 100644 backend/__tests__/unit/services/agentConnectionStateService.test.js
create mode 100644 backend/services/agentConnectionStateService.ts
create mode 100644 frontend/src/v2/__tests__/agentConnection.test.ts
create mode 100644 frontend/src/v2/utils/agentConnection.ts
diff --git a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
index ac51764e48..e36e5252e0 100644
--- a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
+++ b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
@@ -44,6 +44,8 @@ jest.mock('../../../models/AgentEvent', () => ({
jest.mock('../../../models/AgentRun', () => ({
aggregate: jest.fn().mockResolvedValue([]),
}));
+jest.mock('../../../models/AgentCredential', () => ({ find: jest.fn() }));
+jest.mock('../../../models/Machine', () => ({ find: jest.fn() }));
jest.mock('../../../models/User', () => ({
find: jest.fn().mockReturnValue({
@@ -76,6 +78,8 @@ const AgentTemplate = require('../../../models/AgentTemplate');
const AgentEvent = require('../../../models/AgentEvent');
const AgentRun = require('../../../models/AgentRun');
const User = require('../../../models/User');
+const AgentCredential = require('../../../models/AgentCredential');
+const Machine = require('../../../models/Machine');
const { AgentRegistry, AgentInstallation } = require('../../../models/AgentRegistry');
const registryRoutes = require('../../../routes/registry');
@@ -103,6 +107,7 @@ const baseInstallation = (over = {}) => ({
});
const stubLeanChains = () => {
+ User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) });
AgentRegistry.find.mockReturnValue({
select: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([]),
@@ -123,6 +128,8 @@ const stubLeanChains = () => {
members: ['user-1'],
}),
});
+ AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) });
+ Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([]) }) });
};
describe('pod agents lastActiveAt derivation (#915)', () => {
@@ -131,6 +138,21 @@ describe('pod agents lastActiveAt derivation (#915)', () => {
stubLeanChains();
});
+ it('projects a revoked BYO credential to the roster even when the bearer was removed', async () => {
+ AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({
+ agentName: 'local', config: new Map([['runtime', { runtimeType: 'webhook' }]]),
+ })]);
+ User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
+ _id: 'bot-1', username: 'local-default', botMetadata: {}, agentRuntimeTokens: [],
+ }]) }) });
+ AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
+ kind: 'runtime', agentUserId: 'bot-1', status: 'revoked',
+ }]) }) });
+ const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`);
+ expect(res.status).toBe(200);
+ expect(res.body.agents[0].connection).toEqual({ state: 'revoked', lastSeenAt: null });
+ });
+
it('surfaces AgentRun activity for native agents with no heartbeats and no tokens', async () => {
AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation()]);
AgentRun.aggregate.mockResolvedValue([
diff --git a/backend/__tests__/unit/services/agentConnectionStateService.test.js b/backend/__tests__/unit/services/agentConnectionStateService.test.js
new file mode 100644
index 0000000000..f380e2fff1
--- /dev/null
+++ b/backend/__tests__/unit/services/agentConnectionStateService.test.js
@@ -0,0 +1,35 @@
+const { deriveAgentConnection } = require('../../../services/agentConnectionStateService');
+
+const now = Date.parse('2026-10-23T16:00:00Z');
+const seen = (minutes) => new Date(now - minutes * 60_000);
+
+describe('agent connection verdict', () => {
+ it('calls a revoked BYO key disconnected even after its bearer is removed', () => {
+ expect(deriveAgentConnection({ runtimeType: 'webhook', credentials: [{ status: 'revoked' }], now }))
+ .toEqual({ state: 'revoked', lastSeenAt: null });
+ });
+
+ it('uses the newest active key when an old key was revoked', () => {
+ expect(deriveAgentConnection({ runtimeType: 'webhook', credentials: [
+ { status: 'revoked' }, { status: 'active', lastUsedAt: seen(2) },
+ ], now }).state).toBe('connected');
+ });
+
+ it('marks a listener unseen after five minutes', () => {
+ expect(deriveAgentConnection({ runtimeType: 'webhook', credentials: [
+ { status: 'active', lastUsedAt: seen(6) },
+ ], now }).state).toBe('stale');
+ });
+
+ it('uses machine heartbeat for a wrapper', () => {
+ expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [
+ { status: 'active' },
+ ], machineLastSeenAt: seen(2), now }).state).toBe('stale');
+ });
+
+ it('preserves pre-ledger embedded tokens', () => {
+ expect(deriveAgentConnection({ runtimeType: 'webhook', credentials: [], embeddedTokens: [
+ { lastUsedAt: seen(2) },
+ ], now }).state).toBe('connected');
+ });
+});
diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts
index 1086b9f2e2..0eb7558223 100644
--- a/backend/routes/registry/pod-agents.ts
+++ b/backend/routes/registry/pod-agents.ts
@@ -7,6 +7,8 @@ const AgentProfile = require('../../models/AgentProfile');
const AgentTemplate = require('../../models/AgentTemplate');
const AgentEvent = require('../../models/AgentEvent');
const AgentCredential = require('../../models/AgentCredential');
+const Machine = require('../../models/Machine');
+const { deriveAgentConnection } = require('../../services/agentConnectionStateService');
const Pod = require('../../models/Pod');
const User = require('../../models/User');
const AgentIdentityService = require('../../services/agentIdentityService');
@@ -265,6 +267,36 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => {
(userRows as any[]).map((u: any) => [u.username, u]),
);
+ // Connection health is advisory, like activity. Read credentials in one
+ // batch; a revoked row survives removal of the embedded bearer.
+ let credentialByAgent = new Map();
+ let credentialByMachine = new Map();
+ let machineById = new Map();
+ let connectionAvailable = true;
+ try {
+ if ((userRows as any[]).length > 0) {
+ const agentIds = (userRows as any[]).map((u: any) => u._id);
+ const machineIds = Array.from(new Set((userRows as any[])
+ .map((u: any) => u.botMetadata?.machineId).filter(Boolean)));
+ const [credentials, machines] = await Promise.all([
+ AgentCredential.find({ $or: [
+ { kind: 'runtime', agentUserId: { $in: agentIds } },
+ { kind: 'daemon', machineId: { $in: machineIds } },
+ ] }).select('kind agentUserId machineId status lastUsedAt expiresAt').lean(),
+ Machine.find({ machineId: { $in: machineIds } }).select('machineId lastSeenAt').lean(),
+ ]);
+ for (const credential of credentials as any[]) {
+ const key = credential.kind === 'daemon' ? String(credential.machineId) : String(credential.agentUserId);
+ const target = credential.kind === 'daemon' ? credentialByMachine : credentialByAgent;
+ target.set(key, [...(target.get(key) || []), credential]);
+ }
+ machineById = new Map((machines as any[]).map((machine: any) => [machine.machineId, machine]));
+ }
+ } catch (connectionErr) {
+ connectionAvailable = false;
+ console.warn('[pod-agents] connection lookup failed:', (connectionErr as Error).message);
+ }
+
// Last thing each agent said in THIS pod (Wren spec §1.1 line 2).
// Advisory like the activity map: a PG hiccup must never fail the roster.
let lastMessageByUserId = new Map();
@@ -307,7 +339,7 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => {
const username = AgentIdentityService.buildAgentUsername(i.agentName, i.instanceId || 'default');
const user = userByUsername.get(username) || null;
const lastActiveAt = activityMap.get(instanceKey) || null;
- return buildAgentInstallationPayload(i, {
+ const payload = buildAgentInstallationPayload(i, {
profile,
iconUrl: templateIcon || iconMap.get(i.agentName) || '',
lastHeartbeatAt: heartbeatMap.get(instanceKey) || null,
@@ -315,6 +347,22 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => {
user,
lastMessage: (user && lastMessageByUserId.get(String(user._id))) || null,
});
+ const runtimeType = payload?.runtime?.runtimeType;
+ if (!connectionAvailable || !user || !['webhook', 'wrapper'].includes(runtimeType)
+ || (runtimeType === 'webhook' && payload?.runtime?.webhookUrl)) return payload;
+ const machineId = user.botMetadata?.machineId;
+ return {
+ ...payload,
+ connection: deriveAgentConnection({
+ runtimeType,
+ credentials: runtimeType === 'wrapper'
+ ? credentialByMachine.get(String(machineId)) || []
+ : credentialByAgent.get(String(user._id)) || [],
+ embeddedTokens: runtimeType === 'wrapper' ? []
+ : [...(user.agentRuntimeTokens || []), ...(i.runtimeTokens || [])],
+ machineLastSeenAt: machineById.get(String(machineId))?.lastSeenAt || null,
+ }),
+ };
}),
});
} catch (error) {
diff --git a/backend/services/agentConnectionStateService.ts b/backend/services/agentConnectionStateService.ts
new file mode 100644
index 0000000000..644a13837f
--- /dev/null
+++ b/backend/services/agentConnectionStateService.ts
@@ -0,0 +1,37 @@
+// A connection is proven by the credential's use, not by an agent message or
+// a task claim. Keep this verdict separate from the activity shown in pods.
+export const BYO_STALE_MS = 5 * 60 * 1000;
+export const MACHINE_STALE_MS = 90 * 1000;
+
+type Credential = { status?: string; lastUsedAt?: Date | string | null; expiresAt?: Date | string | null };
+
+export const deriveAgentConnection = ({
+ runtimeType, credentials, embeddedTokens = [], machineLastSeenAt = null, now = Date.now(),
+}: {
+ runtimeType?: string;
+ credentials: Credential[];
+ embeddedTokens?: Credential[];
+ machineLastSeenAt?: Date | string | null;
+ now?: number;
+}) => {
+ const isMachine = runtimeType === 'wrapper';
+ const active = credentials.filter((credential) => credential.status === 'active'
+ && (!credential.expiresAt || new Date(credential.expiresAt).getTime() > now));
+ // Embedded tokens predate the ledger. They remain valid on the auth path.
+ const hasEmbedded = embeddedTokens.length > 0;
+ if (!active.length && !hasEmbedded && credentials.some((credential) => credential.status === 'revoked')) {
+ return { state: 'revoked', lastSeenAt: null };
+ }
+ const dates = isMachine
+ ? [machineLastSeenAt]
+ : [...active, ...embeddedTokens].map((credential) => credential.lastUsedAt);
+ const lastSeen = dates.reduce((latest, value) => {
+ const time = value ? new Date(value).getTime() : 0;
+ return Number.isFinite(time) ? Math.max(latest, time) : latest;
+ }, 0);
+ if (!lastSeen) return { state: 'never', lastSeenAt: null };
+ return {
+ state: now - lastSeen > (isMachine ? MACHINE_STALE_MS : BYO_STALE_MS) ? 'stale' : 'connected',
+ lastSeenAt: new Date(lastSeen).toISOString(),
+ };
+};
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index d9be75cbae..0b5273e837 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1360,6 +1360,13 @@
"dsh": "DeepSeek Harness"
}
},
+ "agentConnection": {
+ "connected": "Connected",
+ "revoked": "Disconnected: key revoked",
+ "stale": "Not seen in {{count}} min",
+ "never": "Not connected yet",
+ "reconnect": "Reconnect"
+ },
"agentByo": {
"title": "Bring your own agent",
"description": "A running agent answers @mentions in its pod.",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 5435a68dd4..0219c2b26a 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1420,6 +1420,13 @@
"license": "许可证(Apache-2.0)"
}
},
+ "agentConnection": {
+ "connected": "已连接",
+ "revoked": "密钥已撤销,连接已断开",
+ "stale": "已 {{count}} 分钟未连接",
+ "never": "尚未连接",
+ "reconnect": "重新连接"
+ },
"agentByo": {
"title": "接入你自己的智能体",
"description": "智能体运行期间会在所在 Pod 回复 @提及。",
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index ba5f6c9e65..e231c9e8de 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -45,8 +45,9 @@ const authValue = {
const pods = [{ _id: 'p1', name: 'Workspace', type: 'chat' }];
// Hosting on, so the page asks where the agent runs.
-const mockGet = () => {
+const mockGet = (roster = []) => {
axios.get.mockImplementation((url) => {
+ if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: roster } });
if (url.startsWith('/api/registry/agents/next-free-name?')) return Promise.resolve({ data: { name: new URLSearchParams(url.split('?')[1]).get('base') } });
if (url.startsWith('/api/hosted/availability')) {
return Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } });
@@ -76,6 +77,21 @@ afterEach(() => {
});
describe('BYO default agent name follows where it runs', () => {
+ test('reconnect deep link keeps the name and rotates a revoked key with one action', async () => {
+ mockGet([{ name: 'local', installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null } }]);
+ axios.post.mockResolvedValue({ data: { token: 'cm_agent_new' } });
+ renderAt('/v2/agents/byo?pod=p1&name=local&reconnect=1');
+ await waitFor(() => expect(screen.getByTestId('byo-connection-state'))
+ .toHaveTextContent('Disconnected: key revoked'));
+ expect(nameInput()).toHaveValue('local');
+ expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/registry/agents/next-free-name?'))).toBe(false);
+ fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/registry/pods/p1/agents/local/runtime-tokens',
+ { label: 'BYO MCP — replacement', force: true }, expect.anything(),
+ ));
+ });
test('trims a long username before asking for a name suggestion', async () => {
authValue.currentUser.username = 'a'.repeat(60);
mockGet();
diff --git a/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
index 752d4ddff2..ebdfd2c335 100644
--- a/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
+++ b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
@@ -36,7 +36,7 @@ const renderPage = (over = {}) => {
const queue = over.queue ?? [{ id: 'd-1', kind: 'decision', title: 'Slack defaults', podId: 'p2', messageId: '900', actorUserId: 'wren-user' }];
axios.get.mockImplementation((url) => {
if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Sharpen — pod model, attention routing, hardening' }, { _id: 'p2', name: 'Connectors v2 — channel routing' }] });
- if (url.startsWith('/api/registry/pods/p1/agents')) return Promise.resolve({ data: { agents: agents.p1 } });
+ if (url.startsWith('/api/registry/pods/p1/agents')) return Promise.resolve({ data: { agents: over.p1Agents || agents.p1 } });
if (url.startsWith('/api/registry/pods/p2/agents')) return Promise.resolve({ data: { agents: agents.p2 } });
// `claimedBy` is the claimer's User id (never a name); `assignee` is a name
// written by PATCH and is not a claim.
@@ -62,6 +62,20 @@ describe('Your Team (direction C)', () => {
fireEvent.click(screen.getByRole('button', { name: 'Hire an agent' }));
expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/byo?mode=hosted');
});
+
+ test('a revoked BYO card shows the reason and one Reconnect action', async () => {
+ renderPage({ p1Agents: [{
+ name: 'local', instanceId: 'default', displayName: 'Local', userId: 'local-user',
+ installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null },
+ }] });
+ await waitFor(() => expect(screen.getByText('Local')).toBeInTheDocument());
+ const card = cardOf('Local');
+ expect(card).toHaveTextContent('Disconnected: key revoked');
+ expect(within(card).queryByRole('button', { name: /Talk to/ })).toBeNull();
+ fireEvent.click(within(card).getByRole('button', { name: 'Reconnect' }));
+ expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/byo?pod=p1&name=local&reconnect=1');
+ });
test('head: display title, one mono meta line with agents · working · needs you, Bring your own bordered + Hire an agent ink', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('Wren')).toBeInTheDocument());
diff --git a/frontend/src/v2/__tests__/agentConnection.test.ts b/frontend/src/v2/__tests__/agentConnection.test.ts
new file mode 100644
index 0000000000..5058ed4940
--- /dev/null
+++ b/frontend/src/v2/__tests__/agentConnection.test.ts
@@ -0,0 +1,17 @@
+import { connectionLabel } from '../utils/agentConnection';
+
+const labels: Record = {
+ 'agentConnection.revoked': 'Disconnected: key revoked',
+ 'agentConnection.connected': 'Connected',
+ 'agentConnection.never': 'Not connected yet',
+};
+const t = (key: string, options?: Record) => labels[key] || `Not seen in ${options?.count} min`;
+
+it('shows the revoked key reason', () => {
+ expect(connectionLabel({ state: 'revoked', lastSeenAt: null }, t)).toBe('Disconnected: key revoked');
+});
+
+it('shows elapsed minutes from the last poll', () => {
+ expect(connectionLabel({ state: 'stale', lastSeenAt: '2026-10-23T15:54:00Z' }, t,
+ Date.parse('2026-10-23T16:00:00Z'))).toBe('Not seen in 6 min');
+});
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index b5c0fb8c9a..9188f31e08 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -28,6 +28,7 @@ import { V2Pod } from '../hooks/useV2Pods';
import { PERSONA_CARDS } from '../agents/personaCatalogData';
import { DEFAULT_INSTALL_SCOPES } from '../utils/agentInstallScopes';
import { readPodVisits } from '../lib/podRecency';
+import { AgentConnection, connectionLabel } from '../utils/agentConnection';
const DEFAULT_AGENT_NAME = 'my-mcp-agent';
// The token routes refuse a name that belongs to an agent Commonly runs (the
@@ -135,6 +136,8 @@ const V2AgentBYO: React.FC = () => {
// chose their own agent, its name and its pod. ?adapter= is the tool
// they told the proposer they run.
fromCard: params.get('from') === 'card',
+ reconnect: params.get('reconnect') === '1',
+ machine: params.get('mode') === 'machine',
adapter: ((a): 'claude' | 'codex' | null => (a === 'claude' || a === 'codex' ? a : null))(params.get('adapter')),
};
})();
@@ -166,6 +169,9 @@ const V2AgentBYO: React.FC = () => {
const [nameLookupError, setNameLookupError] = useState(false);
const [nameLookupRetry, setNameLookupRetry] = useState(0);
const [pendingReplacement, setPendingReplacement] = useState<{ agentName: string; podId: string } | null>(null);
+ const [existingConnection, setExistingConnection] = useState<{
+ connection: AgentConnection; runtimeType: string; installedBy?: string;
+ } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [errorField, setErrorField] = useState<'machine' | 'name' | 'pod' | null>(null);
@@ -204,6 +210,7 @@ const V2AgentBYO: React.FC = () => {
// it starts on "Run it here".
const [chosenMode, setMode] = useState<'hosted' | 'byo' | 'machine' | null>(() => {
if (prefill.hosted) return 'hosted';
+ if (prefill.machine) return 'machine';
return prefill.name ? 'byo' : null;
});
const [hosted, setHosted] = useState<{ agentName: string; podId: string; ready?: boolean } | null>(null);
@@ -255,6 +262,29 @@ const V2AgentBYO: React.FC = () => {
// back to Claude or auto-detect, and vice versa.
const [adapter, setAdapter] = useState('');
const [model, setModel] = useState('');
+ // The same roster verdict powers Your Team and this reconnection screen.
+ // Limit the lookup to an intentional name to avoid probing suggested names.
+ useEffect(() => {
+ if (!podId || !nameTouched || !sanitizeAgentName(name)) {
+ setExistingConnection(null);
+ return undefined;
+ }
+ let cancelled = false;
+ const timer = setTimeout(() => {
+ api.get<{ agents?: Array<{ name: string; runtime?: { runtimeType?: string }; connection?: AgentConnection; installedBy?: string }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`)
+ .then((result) => {
+ if (cancelled) return;
+ const match = result.agents?.find((agent) => agent.name === sanitizeAgentName(name) && agent.connection);
+ setExistingConnection(match?.connection ? {
+ connection: match.connection,
+ runtimeType: match.runtime?.runtimeType || '',
+ installedBy: match.installedBy,
+ } : null);
+ })
+ .catch(() => { if (!cancelled) setExistingConnection(null); });
+ }, 250);
+ return () => { cancelled = true; clearTimeout(timer); };
+ }, [api, name, nameTouched, podId]);
// Re-default while the person hasn't typed a name: when the user resolves
// after mount, and when the mode or the runner changes.
const defaultAgentName = defaultNameFor(mode, mode === 'machine' ? adapter : listenAdapter);
@@ -267,6 +297,10 @@ const V2AgentBYO: React.FC = () => {
useEffect(() => {
if (!choicesKnown) return undefined;
+ if (prefill.reconnect && prefill.name) {
+ setNamesReady(true);
+ return undefined;
+ }
let cancelled = false;
const base = sanitizeAgentName(prefill.name || defaultAgentName);
setNamesReady(false);
@@ -280,7 +314,7 @@ const V2AgentBYO: React.FC = () => {
})
.catch(() => { if (!cancelled) setNameLookupError(true); });
return () => { cancelled = true; };
- }, [api, choicesKnown, defaultAgentName, nameLookupRetry, nameTouched, prefill.name]);
+ }, [api, choicesKnown, defaultAgentName, nameLookupRetry, nameTouched, prefill.name, prefill.reconnect]);
const [placed, setPlaced] = useState<{ agentName: string; machineId: string; machineName: string } | null>(null);
const [placedState, setPlacedState] = useState<'waiting' | 'running' | 'slow'>('waiting');
@@ -624,12 +658,12 @@ const V2AgentBYO: React.FC = () => {
}
};
- const replaceRunningKey = async () => {
- if (!pendingReplacement) return;
+ const replaceRunningKey = async (target = pendingReplacement) => {
+ if (!target) return;
setSubmitting(true);
setError(null);
try {
- const { agentName, podId: targetPodId } = pendingReplacement;
+ const { agentName, podId: targetPodId } = target;
const tokenRes = await api.post<{ token?: string }>(
`/api/registry/pods/${encodeURIComponent(targetPodId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens`,
{ label: 'BYO MCP — replacement', force: true },
@@ -894,17 +928,27 @@ const V2AgentBYO: React.FC = () => {
)}
{error && {error}
}
+ {existingConnection && (
+
+ {connectionLabel(existingConnection.connection, t)}
+
+ )}
{nameLookupError && mode === 'byo' && {t('agentByo.errors.nameLookupFailed')} setNameLookupRetry((n) => n + 1)}>{t('agentByo.actions.retry')}
}
{/* Enabled once the pod list confirms the pod: a link's pod the person
cannot install into is replaced by their own, and that is the line
above the button, so the click never lands somewhere unshown. */}
{
+ if (existingConnection?.connection.state === 'revoked' || existingConnection?.connection.state === 'stale') {
+ void replaceRunningKey({ agentName: sanitizeAgentName(name), podId });
+ } else void submit();
+ }}
disabled={submitting || !namesReady || !pods.some((p) => p._id === podId)}
className="v2-byo__submit"
>
- {submitting ? t('agentByo.actions.issuing') : t('agentByo.actions.install')}
+ {submitting ? t('agentByo.actions.issuing') : existingConnection && existingConnection.connection.state !== 'connected'
+ ? t('agentConnection.reconnect') : t('agentByo.actions.install')}
{
fell under the fold at 1440x900 with "Run it here" chosen (QA 2026-10-16:
its top edge at y=882), so a stranger saw a selected card and no button. */}
{error && {error}
}
+ {existingConnection && mode !== 'hosted' && (
+
+ {connectionLabel(existingConnection.connection, t)}
+
+ )}
{nameLookupError && mode === 'byo' && {t('agentByo.errors.nameLookupFailed')} setNameLookupRetry((n) => n + 1)}>{t('agentByo.actions.retry')}
}
{mode === null ? (
// Signal §3 not yet: a dashed slot where the act will be, until a
@@ -1093,7 +1142,15 @@ const V2AgentBYO: React.FC = () => {
{t('agentByo.actions.chooseFirst')}
- ) : (
+ ) : existingConnection && existingConnection.connection.state !== 'connected'
+ && (existingConnection.installedBy === String(currentUser?._id) || currentUser?.role === 'admin') ? (
+ {
+ if (existingConnection.runtimeType === 'wrapper') void submitMachine(sanitizeAgentName(name));
+ else void replaceRunningKey({ agentName: sanitizeAgentName(name), podId });
+ }}>
+ {t('agentConnection.reconnect')}
+
+ ) : (
{
{t('agentByo.rekey.warning', { name: pendingReplacement.agentName })}
-
+ { void replaceRunningKey(); }}>
{submitting ? t('agentByo.actions.issuing') : t('agentByo.rekey.replace')}
setPendingReplacement(null)}>
diff --git a/frontend/src/v2/components/V2YourTeamPage.tsx b/frontend/src/v2/components/V2YourTeamPage.tsx
index 4e9a15b25e..01ddf8a252 100644
--- a/frontend/src/v2/components/V2YourTeamPage.tsx
+++ b/frontend/src/v2/components/V2YourTeamPage.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { useAuth } from '../../context/AuthContext';
import V2Avatar from './V2Avatar';
+import { AgentConnection, connectionLabel } from '../utils/agentConnection';
/**
* Your Team — direction C, built to `YourTeam.dc.html` (canvas: Workspace in C)
@@ -50,6 +51,8 @@ interface AgentInstallationSummary {
internal?: boolean;
userId?: string | null;
description?: string | null;
+ installedBy?: string | null;
+ connection?: AgentConnection;
}
interface TaskRow {
@@ -326,7 +329,10 @@ const V2YourTeamPage: React.FC = () => {
const key = agentKey(a);
const ask = a.userId ? asksByActor.get(a.userId)?.[0] : undefined;
const task = claimedTaskFor(a);
- const statusText = state === 'needsYou'
+ const disconnected = a.connection && a.connection.state !== 'connected';
+ const statusText = disconnected
+ ? connectionLabel(a.connection!, t)
+ : state === 'needsYou'
? `${t('yourTeam.card.needsYou')} · ${ask?.title || ''}`.replace(/ · $/, '')
: state === 'working'
? `${t('yourTeam.card.working')} · ${task?.taskId || shortPodName(task?.podName || a.podName || '')}`.replace(/ · $/, '')
@@ -350,8 +356,8 @@ const V2YourTeamPage: React.FC = () => {
{display}
- {state !== 'idle' && {STATE_DOT} }
- {state !== 'idle' ? ' ' : ''}
+ {!disconnected && state !== 'idle' && {STATE_DOT} }
+ {!disconnected && state !== 'idle' ? ' ' : ''}
{statusText}
@@ -365,12 +371,17 @@ const V2YourTeamPage: React.FC = () => {
)}
- {state === 'needsYou' && (
+ {disconnected && (a.installedBy === String(user?._id) || user?.role === 'admin') ? (
+ navigate(`/v2/agents/byo?pod=${encodeURIComponent(a.podId || '')}&name=${encodeURIComponent(a.name)}&reconnect=1${a.runtime?.runtimeType === 'wrapper' ? '&mode=machine' : ''}`)}>
+ {t('agentConnection.reconnect')}
+
+ ) : null}
+ {!disconnected && state === 'needsYou' && (
handleAnswer(a)}>
{t('yourTeam.card.answer')}
)}
- handleTalk(a, e)}
@@ -378,7 +389,7 @@ const V2YourTeamPage: React.FC = () => {
aria-label={t('yourTeam.card.talkToAria', { name: display })}
>
{opening === key ? t('yourTeam.card.opening') : t('yourTeam.card.talk')}
-
+ }
);
diff --git a/frontend/src/v2/utils/agentConnection.ts b/frontend/src/v2/utils/agentConnection.ts
new file mode 100644
index 0000000000..3666f225fb
--- /dev/null
+++ b/frontend/src/v2/utils/agentConnection.ts
@@ -0,0 +1,17 @@
+export type AgentConnection = {
+ state: 'connected' | 'revoked' | 'stale' | 'never';
+ lastSeenAt: string | null;
+};
+
+export const connectionLabel = (
+ connection: AgentConnection,
+ t: (key: string, options?: Record) => string,
+ now = Date.now(),
+): string => {
+ if (connection.state === 'revoked') return t('agentConnection.revoked');
+ if (connection.state === 'never') return t('agentConnection.never');
+ if (connection.state === 'connected') return t('agentConnection.connected');
+ const seen = connection.lastSeenAt ? new Date(connection.lastSeenAt).getTime() : NaN;
+ const minutes = Number.isFinite(seen) ? Math.max(1, Math.floor((now - seen) / 60000)) : 1;
+ return t('agentConnection.stale', { count: minutes });
+};
From f192fab2b7b2d0e8a324b1ba133d45af8a1e5633 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:27:56 -0700
Subject: [PATCH 02/18] Show private connection state on agent profile
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../V2AgentProfileMemoryWrite.test.tsx | 20 +++++++++++
frontend/src/v2/agents/V2AgentProfile.tsx | 36 ++++++++++++++++++-
2 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
index 41ac67da46..73d39b97e0 100644
--- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
@@ -86,6 +86,26 @@ describe('V2AgentProfile memory-write visibility', () => {
expect(screen.queryByText(/Runtime metadata|Deduplication state|Long-term memory/)).toBeNull();
});
+ it('shows an owner the revoked connection and a single Reconnect action', async () => {
+ window.localStorage.setItem('token', 'owner-token');
+ profileClient.get.mockImplementation((url: string) => {
+ if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: {
+ viewerRole: 'owner', totalEntries: 0, sections: [], pods: [{ id: 'pod-1', name: 'Workspace' }],
+ } });
+ if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{
+ name: 'claude-code', instanceId: 'observer', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null },
+ }] } });
+ return Promise.resolve(profilePayload(null));
+ });
+ renderProfile();
+ expect(await screen.findByTestId('agent-profile-connection'))
+ .toHaveTextContent('Disconnected: key revoked');
+ expect(screen.getByRole('link', { name: 'Reconnect' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=claude-code&reconnect=1');
+ expect(screen.queryByRole('button', { name: 'Talk to Observer' })).toBeNull();
+ });
+
it('opens the same 1:1 room endpoint for a signed-in visitor', async () => {
window.localStorage.setItem('token', 'viewer-token');
renderProfile();
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index eef443167f..ce98fc37af 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -5,6 +5,7 @@ import axios from 'axios';
import getApiBaseUrl from '../../utils/apiBaseUrl';
import V2Avatar from '../components/V2Avatar';
import { presetCharacterOptions } from '../../utils/avatarUtils';
+import { AgentConnection, connectionLabel } from '../utils/agentConnection';
import '../v2.css';
import './v2-agent-profile.css';
import './v2-agent-settings.css';
@@ -119,6 +120,9 @@ const V2AgentProfile: React.FC = () => {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [memIndex, setMemIndex] = useState(null);
const memoryRequestId = useRef(0);
+ const [ownedConnection, setOwnedConnection] = useState<{
+ connection: AgentConnection; podId: string; runtimeType?: string;
+ } | null>(null);
const [openingRoom, setOpeningRoom] = useState(false);
const [roomError, setRoomError] = useState(null);
@@ -182,6 +186,28 @@ const V2AgentProfile: React.FC = () => {
useEffect(() => { fetchProfile(); fetchMemoryIndex(); }, [fetchProfile, fetchMemoryIndex]);
+ // The public profile is deliberately tokenless. Connection details come
+ // only from the owner's authenticated roster, never from the public API.
+ useEffect(() => {
+ const podId = memIndex?.pods?.[0]?.id;
+ const token = localStorage.getItem('token');
+ if (!podId || !token || !agentName) { setOwnedConnection(null); return undefined; }
+ let cancelled = false;
+ getClient().get<{ agents?: Array<{
+ name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
+ }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`, {
+ headers: { Authorization: `Bearer ${token}` },
+ }).then((response) => {
+ if (cancelled) return;
+ const match = response.data.agents?.find((row) => row.name === agentName
+ && row.instanceId === (instanceId || 'default') && row.connection);
+ setOwnedConnection(match?.connection ? {
+ connection: match.connection, podId, runtimeType: match.runtime?.runtimeType,
+ } : null);
+ }).catch(() => { if (!cancelled) setOwnedConnection(null); });
+ return () => { cancelled = true; };
+ }, [agentName, instanceId, memIndex]);
+
// Owner-editable avatar (Sam, 2026-08-20): visible only when the caller is
// the agent's installer or an admin — the backend is the real gate; this
// check only decides whether to show the affordance.
@@ -352,6 +378,9 @@ const V2AgentProfile: React.FC = () => {
{agent.officialAgent && {t('agentProfile.hero.official')} }
{agent.description && {agent.description}
}
+ {ownedConnection &&
+ {connectionLabel(ownedConnection.connection, t)}
+
}
{t('agentProfile.hero.activeInPods', { count: pods.count })}
{memory.has && · {t('agentProfile.hero.memories', { count: memory.entryCount })} }
@@ -364,7 +393,12 @@ const V2AgentProfile: React.FC = () => {
)}
- {authed ? (
+ {ownedConnection && ownedConnection.connection.state !== 'connected' ? (
+
+ {t('agentConnection.reconnect')}
+
+ ) : authed ? (
Date: Fri, 23 Oct 2026 09:44:09 -0700
Subject: [PATCH 03/18] Repair BYO reconnect and add Settings control section
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../unit/routes/agentBinding.phase2.test.js | 41 ++++++++++
.../registry.pod-agents-activity.test.js | 19 +++++
.../agentConnectionStateService.test.js | 9 ++-
backend/models/User.ts | 3 +
backend/routes/agentBinding.ts | 45 ++++++++++-
backend/routes/registry/pod-agents.ts | 8 +-
.../services/agentConnectionStateService.ts | 8 ++
cli/__tests__/daemon-supervisor.test.mjs | 24 ++++++
cli/src/lib/daemon-supervisor.js | 24 ++++++
frontend/src/i18n/locales/en.json | 6 +-
frontend/src/i18n/locales/zh-CN.json | 8 +-
.../src/v2/__tests__/V2AgentBYOName.test.tsx | 25 ++++++
.../__tests__/V2AgentControlSection.test.tsx | 26 ++++++
.../V2AgentProfileMemoryWrite.test.tsx | 12 +++
.../v2/__tests__/V2YourTeamSignal.test.tsx | 25 ++++++
frontend/src/v2/agents/V2AgentProfile.tsx | 7 +-
frontend/src/v2/components/V2AgentBYO.tsx | 81 ++++++++++++++++---
.../v2/components/V2AgentControlSection.tsx | 32 ++++++++
frontend/src/v2/components/V2YourTeamPage.tsx | 23 +++---
19 files changed, 392 insertions(+), 34 deletions(-)
create mode 100644 frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
create mode 100644 frontend/src/v2/components/V2AgentControlSection.tsx
diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
index 6b257568e2..7d6cbdc985 100644
--- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
@@ -102,6 +102,10 @@ const requestPlacement = (machineId) => request(app)
.post('/api/agent-binding/request')
.send({ agentName: 'wren-test', instanceId: 'default', machineId });
+const requestReconnect = () => request(app)
+ .post('/api/agent-binding/reconnect')
+ .send({ agentName: 'wren-test', instanceId: 'default' });
+
const adopt = (tok) => request(app)
.post('/api/agent-binding/adopt')
.set('Authorization', `Bearer ${tok}`)
@@ -210,6 +214,43 @@ describe('placement request', () => {
});
});
+describe('owner-requested reconnect', () => {
+ it('projects one owner-approved rekey directive to the bound daemon', async () => {
+ await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
+ const requested = await requestReconnect();
+ expect(requested.status).toBe(200);
+ expect(requested.body.requested).toBe(true);
+ const assignedRow = (await assigned(DAEMON_A)).body.agents[0];
+ expect(new Date(assignedRow.reconnectRequestedAt).getTime()).toBeGreaterThan(0);
+ expect((await assigned(DAEMON_B)).body.agents).toEqual([]);
+ });
+
+ it('lets the bound daemon replace a revoked agent key after the owner request', async () => {
+ await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
+ const original = await mint(DAEMON_A);
+ expect(original.status).toBe(200);
+ await AgentCredential.updateOne({ tokenHash: hash(original.body.token) }, { $set: { status: 'revoked' } });
+ expect((await runtimeAuthProbe(original.body.token)).status).toBe(401);
+ expect((await requestReconnect()).status).toBe(200);
+ expect((await assigned(DAEMON_A)).body.agents[0].reconnectRequestedAt).toBeTruthy();
+ const refreshed = await mint(DAEMON_A, { rotate: true });
+ expect(refreshed.status).toBe(200);
+ expect((await runtimeAuthProbe(refreshed.body.token)).status).toBe(200);
+ expect((await runtimeAuthProbe(original.body.token)).status).toBe(401);
+ });
+
+ it('refuses a different human and a revoked daemon key', async () => {
+ await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
+ global.__CALLER_ID = String(new mongoose.Types.ObjectId());
+ expect((await requestReconnect()).status).toBe(403);
+ global.__CALLER_ID = String(owner._id);
+ await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked' } });
+ const refused = await requestReconnect();
+ expect(refused.status).toBe(409);
+ expect(refused.body.code).toBe('machine_key_revoked');
+ });
+});
+
describe('daemon work list', () => {
it('shows a requested agent only to the requested machine, with its runtime config', async () => {
await requestPlacement('machine-a');
diff --git a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
index e36e5252e0..75a49558ef 100644
--- a/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
+++ b/backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
@@ -153,6 +153,25 @@ describe('pod agents lastActiveAt derivation (#915)', () => {
expect(res.body.agents[0].connection).toEqual({ state: 'revoked', lastSeenAt: null });
});
+ it('keeps a revoked wrapper key disconnected despite a live machine', async () => {
+ AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation({
+ agentName: 'local', config: new Map([['runtime', { runtimeType: 'wrapper' }]]),
+ })]);
+ User.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
+ _id: 'bot-1', username: 'local-default', botMetadata: { machineId: 'machine-1' }, agentRuntimeTokens: [],
+ }]) }) });
+ AgentCredential.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([
+ { kind: 'runtime', agentUserId: 'bot-1', status: 'revoked' },
+ { kind: 'daemon', machineId: 'machine-1', status: 'active' },
+ ]) }) });
+ Machine.find.mockReturnValue({ select: jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue([{
+ machineId: 'machine-1', lastSeenAt: new Date(),
+ }]) }) });
+ const res = await request(app).get(`/api/registry/pods/${POD_ID}/agents`);
+ expect(res.status).toBe(200);
+ expect(res.body.agents[0].connection.state).toBe('revoked');
+ });
+
it('surfaces AgentRun activity for native agents with no heartbeats and no tokens', async () => {
AgentInstallation.getInstalledAgents.mockResolvedValue([baseInstallation()]);
AgentRun.aggregate.mockResolvedValue([
diff --git a/backend/__tests__/unit/services/agentConnectionStateService.test.js b/backend/__tests__/unit/services/agentConnectionStateService.test.js
index f380e2fff1..5397cf164e 100644
--- a/backend/__tests__/unit/services/agentConnectionStateService.test.js
+++ b/backend/__tests__/unit/services/agentConnectionStateService.test.js
@@ -24,7 +24,14 @@ describe('agent connection verdict', () => {
it('uses machine heartbeat for a wrapper', () => {
expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [
{ status: 'active' },
- ], machineLastSeenAt: seen(2), now }).state).toBe('stale');
+ ], machineCredentials: [{ status: 'active' }], machineLastSeenAt: seen(2), now }).state).toBe('stale');
+ });
+
+ it('does not let a live machine mask the agent runtime key being revoked', () => {
+ expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [
+ { status: 'revoked' },
+ ], machineCredentials: [{ status: 'active' }], machineLastSeenAt: seen(0), now }).state)
+ .toBe('revoked');
});
it('preserves pre-ledger embedded tokens', () => {
diff --git a/backend/models/User.ts b/backend/models/User.ts
index 80f770e805..566dba67b2 100644
--- a/backend/models/User.ts
+++ b/backend/models/User.ts
@@ -144,6 +144,8 @@ export interface IUser extends Document {
// daemon on X sees it in its work list and performs the adopt CAS; it is
// a directive, never a binding — the CAS remains the only binding writer.
requestedMachineId?: string | null;
+ // Owner-approved request for the bound daemon to rotate the agent key.
+ reconnectRequestedAt?: Date | null;
runtime?: string;
icon?: string;
};
@@ -302,6 +304,7 @@ const userSchema = new Schema({
machineId: { type: String, default: null },
// ADR-026 Phase 2 — same lesson: declared or stripped.
requestedMachineId: { type: String, default: null },
+ reconnectRequestedAt: { type: Date, default: null },
},
avatarMetadata: {
style: {
diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts
index 9c5bbe08e8..362326d597 100644
--- a/backend/routes/agentBinding.ts
+++ b/backend/routes/agentBinding.ts
@@ -27,6 +27,10 @@ const auth = require('../middleware/auth');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const User = require('../models/User');
// eslint-disable-next-line @typescript-eslint/no-require-imports
+const Machine = require('../models/Machine');
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const AgentCredential = require('../models/AgentCredential');
+// eslint-disable-next-line @typescript-eslint/no-require-imports
const {
getRuntimeTokenHashesForAgent,
revokeRuntimeTokensForAgent,
@@ -264,6 +268,43 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr
}
});
+// The owner explicitly asks the already-bound daemon to rotate this agent's
+// runtime key. A placement request cannot do that: the daemon keeps its local
+// token until a distinct reconnect directive arrives.
+router.post('/reconnect', bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => {
+ try {
+ const agentName = normalize(req.body?.agentName);
+ const instanceId = normalize(req.body?.instanceId) || 'default';
+ if (!agentName) return res.status(400).json({ message: 'agentName required' });
+ const ownership = await ownsAgent(req.user?.id, agentName, instanceId);
+ if (!ownership.owned) return res.status(403).json({
+ message: ownershipFailureMessage[ownership.failure], code: ownership.failure,
+ });
+ const identity = await User.findOne({
+ isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId,
+ }).select('_id botMetadata.machineId').lean();
+ const machineId = identity?.botMetadata?.machineId;
+ if (!machineId) return res.status(409).json({ message: 'Agent is not bound to a computer' });
+ const machine = await Machine.findOne({ machineId, ownerUserId: req.user?.id }).select('_id').lean();
+ if (!machine) return res.status(403).json({ message: 'Computer is not owned by this installer' });
+ const daemonCanReconnect = await AgentCredential.exists({
+ kind: 'daemon', machineId, ownerUserId: req.user?.id, status: 'active',
+ });
+ if (!daemonCanReconnect) return res.status(409).json({
+ message: 'Computer key revoked. Register the computer again before reconnecting this agent.',
+ code: 'machine_key_revoked',
+ });
+ const requestedAt = new Date();
+ await User.updateOne({ _id: identity._id, 'botMetadata.machineId': machineId }, {
+ $set: { 'botMetadata.reconnectRequestedAt': requestedAt },
+ });
+ return res.json({ requested: true, agentName, instanceId, machineId, requestedAt });
+ } catch (err) {
+ console.error('agent reconnect request error:', err);
+ return res.status(500).json({ message: 'Server error' });
+ }
+});
+
// ADR-026 Phase 2: the daemon's work list. Ownership authority is the
// installation set (same sole-installer basis as ownsAgent): start from the
// owner's active installations, then join to identities bound to — or
@@ -283,7 +324,7 @@ router.get('/assigned', bindingRateLimit, daemonAuth('agents:adopt'), async (req
{ 'botMetadata.machineId': machine.machineId },
{ 'botMetadata.requestedMachineId': machine.machineId },
],
- }).select('_id botMetadata.agentName botMetadata.instanceId botMetadata.machineId botMetadata.requestedMachineId').lean() as AssignedIdentity[];
+ }).select('_id botMetadata.agentName botMetadata.instanceId botMetadata.machineId botMetadata.requestedMachineId botMetadata.reconnectRequestedAt').lean() as AssignedIdentity[];
const grantServers = await grantServersForIdentities(identities, byIdentity);
@@ -317,6 +358,8 @@ router.get('/assigned', bindingRateLimit, daemonAuth('agents:adopt'), async (req
state: meta.machineId === machine.machineId ? 'bound' : 'requested',
podIds: entry.podIds,
runtime: entry.runtime,
+ ...(meta.machineId === machine.machineId && meta.reconnectRequestedAt
+ ? { reconnectRequestedAt: meta.reconnectRequestedAt } : {}),
...(environment ? { environment } : {}),
// Why a live grant is not on this row, for the daemon that would
// otherwise have to guess: server-side refusals only (a daemon-side
diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts
index 0eb7558223..521ffdd568 100644
--- a/backend/routes/registry/pod-agents.ts
+++ b/backend/routes/registry/pod-agents.ts
@@ -355,11 +355,9 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => {
...payload,
connection: deriveAgentConnection({
runtimeType,
- credentials: runtimeType === 'wrapper'
- ? credentialByMachine.get(String(machineId)) || []
- : credentialByAgent.get(String(user._id)) || [],
- embeddedTokens: runtimeType === 'wrapper' ? []
- : [...(user.agentRuntimeTokens || []), ...(i.runtimeTokens || [])],
+ credentials: credentialByAgent.get(String(user._id)) || [],
+ machineCredentials: credentialByMachine.get(String(machineId)) || [],
+ embeddedTokens: [...(user.agentRuntimeTokens || []), ...(i.runtimeTokens || [])],
machineLastSeenAt: machineById.get(String(machineId))?.lastSeenAt || null,
}),
};
diff --git a/backend/services/agentConnectionStateService.ts b/backend/services/agentConnectionStateService.ts
index 644a13837f..63c57252d5 100644
--- a/backend/services/agentConnectionStateService.ts
+++ b/backend/services/agentConnectionStateService.ts
@@ -7,9 +7,11 @@ type Credential = { status?: string; lastUsedAt?: Date | string | null; expiresA
export const deriveAgentConnection = ({
runtimeType, credentials, embeddedTokens = [], machineLastSeenAt = null, now = Date.now(),
+ machineCredentials = [],
}: {
runtimeType?: string;
credentials: Credential[];
+ machineCredentials?: Credential[];
embeddedTokens?: Credential[];
machineLastSeenAt?: Date | string | null;
now?: number;
@@ -17,11 +19,17 @@ export const deriveAgentConnection = ({
const isMachine = runtimeType === 'wrapper';
const active = credentials.filter((credential) => credential.status === 'active'
&& (!credential.expiresAt || new Date(credential.expiresAt).getTime() > now));
+ const activeMachine = machineCredentials.filter((credential) => credential.status === 'active'
+ && (!credential.expiresAt || new Date(credential.expiresAt).getTime() > now));
// Embedded tokens predate the ledger. They remain valid on the auth path.
const hasEmbedded = embeddedTokens.length > 0;
if (!active.length && !hasEmbedded && credentials.some((credential) => credential.status === 'revoked')) {
return { state: 'revoked', lastSeenAt: null };
}
+ if (isMachine && !activeMachine.length
+ && machineCredentials.some((credential) => credential.status === 'revoked')) {
+ return { state: 'revoked', lastSeenAt: null };
+ }
const dates = isMachine
? [machineLastSeenAt]
: [...active, ...embeddedTokens].map((credential) => credential.lastUsedAt);
diff --git a/cli/__tests__/daemon-supervisor.test.mjs b/cli/__tests__/daemon-supervisor.test.mjs
index 85eaccf569..44a20b20a8 100644
--- a/cli/__tests__/daemon-supervisor.test.mjs
+++ b/cli/__tests__/daemon-supervisor.test.mjs
@@ -79,6 +79,30 @@ describe('backoff', () => {
});
describe('tick', () => {
+ test('an owner reconnect rotates the bound local key and restarts exactly once', async () => {
+ const tokens = { 'wren-test': {
+ agentName: 'wren-test', runtimeToken: 'cm_agent_revoked', adapter: 'claude',
+ } };
+ let reconnectRequestedAt = null;
+ const { supervisor, client, children, saveToken } = makeHarness({
+ rows: () => [boundRow({ reconnectRequestedAt })],
+ tokens,
+ mintResponses: [{ token: 'cm_agent_fresh' }],
+ });
+ await supervisor.tick();
+ expect(children).toHaveLength(1);
+ reconnectRequestedAt = '2026-10-23T16:30:00.000Z';
+ await supervisor.tick();
+ expect(client.post).toHaveBeenCalledWith('/api/agent-binding/runtime-token', {
+ agentName: 'wren-test', instanceId: 'default', rotate: true,
+ });
+ expect(saveToken).toHaveBeenCalledWith('wren-test', expect.objectContaining({
+ runtimeToken: 'cm_agent_fresh', reconnectRequestAppliedAt: '2026-10-23T16:30:00.000Z',
+ }));
+ expect(children[0].child.kill).toHaveBeenCalledWith('SIGTERM');
+ await supervisor.tick();
+ expect(client.post.mock.calls.filter(([path]) => path === '/api/agent-binding/runtime-token')).toHaveLength(1);
+ });
test('adopts a requested row, mints its token, writes the record, and spawns', async () => {
const { supervisor, client, children, saveToken } = makeHarness({
rows: () => [boundRow({ state: 'requested' })],
diff --git a/cli/src/lib/daemon-supervisor.js b/cli/src/lib/daemon-supervisor.js
index 09b39c5e88..8ae354e1a6 100644
--- a/cli/src/lib/daemon-supervisor.js
+++ b/cli/src/lib/daemon-supervisor.js
@@ -208,6 +208,30 @@ export const createDaemonSupervisor = ({
const existing = loadToken(row.agentName);
if (existing) {
+ // A human owner can request a fresh key from Settings after revocation.
+ // The daemon owns its local token file, so the browser only stamps the
+ // directive; this host rotates the key and restarts its child once.
+ const requestedAt = row.reconnectRequestedAt && new Date(row.reconnectRequestedAt).getTime();
+ const appliedAt = existing.reconnectRequestAppliedAt
+ && new Date(existing.reconnectRequestAppliedAt).getTime();
+ if (Number.isFinite(requestedAt) && requestedAt > (appliedAt || 0)) {
+ try {
+ const minted = await client.post('/api/agent-binding/runtime-token', {
+ agentName: row.agentName, instanceId: row.instanceId, rotate: true,
+ });
+ if (!minted?.token) throw new Error('mint returned no token');
+ saveToken(row.agentName, {
+ ...existing,
+ runtimeToken: minted.token,
+ reconnectRequestAppliedAt: row.reconnectRequestedAt,
+ });
+ log(`[${row.agentName}] owner-requested key rotation applied; restarting the seat`);
+ return 'changed';
+ } catch (error) {
+ log(`[${row.agentName}] owner-requested key rotation failed: ${error.message}`);
+ return false;
+ }
+ }
// A model changed in the UI reaches the seat here: update the record,
// and let the caller restart the child (`agent run` reads its record
// once at boot). A row with NO declared model leaves the record alone —
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 0b5273e837..aae635c311 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1361,11 +1361,15 @@
}
},
"agentConnection": {
+ "control": "Control",
"connected": "Connected",
"revoked": "Disconnected: key revoked",
"stale": "Not seen in {{count}} min",
"never": "Not connected yet",
- "reconnect": "Reconnect"
+ "reconnect": "Reconnect",
+ "reconnectWaiting": "Waiting for your computer to reconnect this agent.",
+ "reconnectDone": "Connected.",
+ "reconnectSlow": "Your computer has not reconnected yet. Check that its daemon is running."
},
"agentByo": {
"title": "Bring your own agent",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 0219c2b26a..4067f4cfe1 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1421,11 +1421,15 @@
}
},
"agentConnection": {
+ "control": "控制",
"connected": "已连接",
"revoked": "密钥已撤销,连接已断开",
- "stale": "已 {{count}} 分钟未连接",
+ "stale": "已{{count}}分钟未连接",
"never": "尚未连接",
- "reconnect": "重新连接"
+ "reconnect": "重新连接",
+ "reconnectWaiting": "正在等待电脑重新连接此智能体。",
+ "reconnectDone": "已连接。",
+ "reconnectSlow": "电脑尚未重新连接。请检查守护进程是否在运行。"
},
"agentByo": {
"title": "接入你自己的智能体",
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index e231c9e8de..8338f06134 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -92,6 +92,31 @@ describe('BYO default agent name follows where it runs', () => {
{ label: 'BYO MCP — replacement', force: true }, expect.anything(),
));
});
+
+ test('compact BYO never offers a non-owner a forced key rotation', async () => {
+ mockGet([{ name: 'local', installedBy: 'someone-else', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null } }]);
+ renderAt('/v2/agents/byo?pod=p1&name=local&from=card');
+ await waitFor(() => expect(screen.getByTestId('byo-connection-state'))
+ .toHaveTextContent('Disconnected: key revoked'));
+ expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Connect agent' })).toBeNull();
+ });
+
+ test('wrapper Reconnect requests a daemon rekey instead of repeating placement', async () => {
+ mockGet([{ name: 'local', installedBy: 'u1', runtime: { runtimeType: 'wrapper' },
+ connection: { state: 'revoked', lastSeenAt: null } }]);
+ axios.post.mockResolvedValue({ data: { requestedAt: '2026-10-23T16:30:00Z' } });
+ renderAt('/v2/agents/byo?pod=p1&name=local&mode=machine&reconnect=1');
+ const action = await screen.findByRole('button', { name: 'Reconnect' });
+ fireEvent.click(action);
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/agent-binding/reconnect',
+ { agentName: 'local', instanceId: 'default' }, expect.anything(),
+ ));
+ expect(axios.post.mock.calls.some(([url]) => url === '/api/agent-binding/request')).toBe(false);
+ expect(await screen.findByTestId('byo-reconnect-result')).toHaveTextContent('Waiting for your computer');
+ });
test('trims a long username before asking for a name suggestion', async () => {
authValue.currentUser.username = 'a'.repeat(60);
mockGet();
diff --git a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
new file mode 100644
index 0000000000..1d9a514947
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import V2AgentControlSection from '../components/V2AgentControlSection';
+import i18n, { i18nReady } from '../../i18n';
+
+beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); });
+
+const show = (state: 'revoked' | 'stale' | 'never' | 'connected') => render(
+
+ ,
+);
+
+it('shows a revoked wrapper key and its one reconnect route', () => {
+ show('revoked');
+ expect(screen.getByTestId('agent-control-section')).toHaveTextContent('Disconnected: key revoked');
+ expect(screen.getByRole('link', { name: 'Reconnect' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=local&reconnect=1&mode=machine');
+});
+
+it('keeps a never-connected key off the reconnect path', () => {
+ show('never');
+ expect(screen.getByTestId('agent-control-section')).toHaveTextContent('Not connected yet');
+ expect(screen.queryByRole('link', { name: 'Reconnect' })).toBeNull();
+});
diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
index 73d39b97e0..a9cf5bf767 100644
--- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
@@ -106,6 +106,18 @@ describe('V2AgentProfile memory-write visibility', () => {
expect(screen.queryByRole('button', { name: 'Talk to Observer' })).toBeNull();
});
+ it('does not offer Reconnect to a signed-in pod member without management access', async () => {
+ window.localStorage.setItem('token', 'member-token');
+ renderProfile();
+ expect(await screen.findByRole('heading', { name: 'Observer' })).toBeInTheDocument();
+ await waitFor(() => expect(profileClient.get).toHaveBeenCalledWith(
+ '/api/agent-memory/claude-code/observer',
+ { headers: { Authorization: 'Bearer member-token' } },
+ ));
+ expect(profileClient.get.mock.calls.some(([url]) => url.startsWith('/api/registry/pods/'))).toBe(false);
+ expect(screen.queryByRole('link', { name: 'Reconnect' })).toBeNull();
+ });
+
it('opens the same 1:1 room endpoint for a signed-in visitor', async () => {
window.localStorage.setItem('token', 'viewer-token');
renderProfile();
diff --git a/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
index ebdfd2c335..47e3e44c97 100644
--- a/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
+++ b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
@@ -72,10 +72,35 @@ describe('Your Team (direction C)', () => {
await waitFor(() => expect(screen.getByText('Local')).toBeInTheDocument());
const card = cardOf('Local');
expect(card).toHaveTextContent('Disconnected: key revoked');
+ expect(card).toHaveClass('v2-team-card--idle');
+ expect(card.querySelector('.v2-team-card__status--working')).toBeNull();
expect(within(card).queryByRole('button', { name: /Talk to/ })).toBeNull();
fireEvent.click(within(card).getByRole('button', { name: 'Reconnect' }));
expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/byo?pod=p1&name=local&reconnect=1');
});
+
+ test('a never-connected agent is offered the normal Talk path, not Reconnect', async () => {
+ renderPage({ p1Agents: [{
+ name: 'local', instanceId: 'default', displayName: 'Local', userId: 'local-user',
+ installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'never', lastSeenAt: null },
+ }] });
+ await waitFor(() => expect(screen.getByText('Local')).toBeInTheDocument());
+ expect(cardOf('Local')).toHaveTextContent('Not connected yet');
+ expect(within(cardOf('Local')).queryByRole('button', { name: 'Reconnect' })).toBeNull();
+ expect(within(cardOf('Local')).getByRole('button', { name: 'Talk to Local one-to-one' })).toBeInTheDocument();
+ });
+
+ test('a stale BYO listener shows elapsed minutes and Reconnect', async () => {
+ renderPage({ p1Agents: [{
+ name: 'local', instanceId: 'default', displayName: 'Local', userId: 'local-user',
+ installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'stale', lastSeenAt: minutesAgo(8) },
+ }] });
+ await waitFor(() => expect(screen.getByText('Local')).toBeInTheDocument());
+ expect(cardOf('Local')).toHaveTextContent('Not seen in 8 min');
+ expect(within(cardOf('Local')).getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
+ });
test('head: display title, one mono meta line with agents · working · needs you, Bring your own bordered + Hire an agent ink', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('Wren')).toBeInTheDocument());
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index ce98fc37af..2dce94db3f 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -191,7 +191,10 @@ const V2AgentProfile: React.FC = () => {
useEffect(() => {
const podId = memIndex?.pods?.[0]?.id;
const token = localStorage.getItem('token');
- if (!podId || !token || !agentName) { setOwnedConnection(null); return undefined; }
+ if (!podId || !token || !agentName || !['owner', 'admin'].includes(memIndex?.viewerRole || '')) {
+ setOwnedConnection(null);
+ return undefined;
+ }
let cancelled = false;
getClient().get<{ agents?: Array<{
name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
@@ -393,7 +396,7 @@ const V2AgentProfile: React.FC = () => {
)}
- {ownedConnection && ownedConnection.connection.state !== 'connected' ? (
+ {ownedConnection && ['revoked', 'stale'].includes(ownedConnection.connection.state) ? (
{t('agentConnection.reconnect')}
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 9188f31e08..20b7ca833c 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -262,6 +262,11 @@ const V2AgentBYO: React.FC = () => {
// back to Claude or auto-detect, and vice versa.
const [adapter, setAdapter] = useState('');
const [model, setModel] = useState('');
+ const canReconnect = Boolean(existingConnection
+ && ['revoked', 'stale'].includes(existingConnection.connection.state)
+ && (existingConnection.installedBy === String(currentUser?._id) || currentUser?.role === 'admin'));
+ const isOtherOwner = Boolean(existingConnection && existingConnection.installedBy
+ && existingConnection.installedBy !== String(currentUser?._id) && currentUser?.role !== 'admin');
// The same roster verdict powers Your Team and this reconnection screen.
// Limit the lookup to an intentional name to avoid probing suggested names.
useEffect(() => {
@@ -317,6 +322,36 @@ const V2AgentBYO: React.FC = () => {
}, [api, choicesKnown, defaultAgentName, nameLookupRetry, nameTouched, prefill.name, prefill.reconnect]);
const [placed, setPlaced] = useState<{ agentName: string; machineId: string; machineName: string } | null>(null);
const [placedState, setPlacedState] = useState<'waiting' | 'running' | 'slow'>('waiting');
+ const [reconnectRequest, setReconnectRequest] = useState<{
+ agentName: string; requestedAt: string; state: 'waiting' | 'connected' | 'slow';
+ } | null>(null);
+
+ useEffect(() => {
+ if (!reconnectRequest || reconnectRequest.state !== 'waiting' || !podId) return undefined;
+ let cancelled = false;
+ let ticks = 0;
+ const check = async () => {
+ ticks += 1;
+ try {
+ const roster = await api.get<{ agents?: Array<{ name: string; connection?: AgentConnection }> }>(
+ `/api/registry/pods/${encodeURIComponent(podId)}/agents`,
+ );
+ if (cancelled) return;
+ const status = roster.agents?.find((agent) => agent.name === reconnectRequest.agentName)?.connection;
+ if (status?.state === 'connected' && status.lastSeenAt
+ && Date.parse(status.lastSeenAt) >= Date.parse(reconnectRequest.requestedAt)) {
+ setReconnectRequest((current) => current && { ...current, state: 'connected' });
+ return;
+ }
+ } catch { /* a transient roster error does not erase the request */ }
+ if (!cancelled && ticks >= MACHINE_STATUS_MAX_TICKS) {
+ setReconnectRequest((current) => current && { ...current, state: 'slow' });
+ }
+ };
+ void check();
+ const timer = setInterval(() => { void check(); }, MACHINE_STATUS_POLL_MS);
+ return () => { cancelled = true; clearInterval(timer); };
+ }, [api, podId, reconnectRequest]);
// Watch the placement come up: the machine heartbeat carries per-agent
// supervisor states, so "live" here means the daemon actually spawned it.
@@ -581,6 +616,22 @@ const V2AgentBYO: React.FC = () => {
}
};
+ const requestMachineReconnect = async (cleanName: string) => {
+ setSubmitting(true);
+ setError(null);
+ try {
+ const response = await api.post<{ requestedAt: string }>('/api/agent-binding/reconnect', {
+ agentName: cleanName, instanceId: 'default',
+ });
+ setReconnectRequest({ agentName: cleanName, requestedAt: response.requestedAt, state: 'waiting' });
+ } catch (err) {
+ const e = err as { response?: { data?: { message?: string } }; message?: string };
+ setError(e.response?.data?.message || e.message || t('agentByo.errors.installFailed'));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
const submit = async () => {
setError(null);
setErrorField(null);
@@ -913,7 +964,7 @@ const V2AgentBYO: React.FC = () => {
)}
- {showCompactByo && !issued && !hosted && !placed && (
+ {showCompactByo && !issued && !hosted && !placed && !reconnectRequest && (
{pods.some((p) => p._id === podId) && (
@@ -937,19 +988,20 @@ const V2AgentBYO: React.FC = () => {
{/* Enabled once the pod list confirms the pod: a link's pod the person
cannot install into is replaced by their own, and that is the line
above the button, so the click never lands somewhere unshown. */}
- {
- if (existingConnection?.connection.state === 'revoked' || existingConnection?.connection.state === 'stale') {
- void replaceRunningKey({ agentName: sanitizeAgentName(name), podId });
+ if (canReconnect) {
+ if (existingConnection?.runtimeType === 'wrapper') void requestMachineReconnect(sanitizeAgentName(name));
+ else void replaceRunningKey({ agentName: sanitizeAgentName(name), podId });
} else void submit();
}}
disabled={submitting || !namesReady || !pods.some((p) => p._id === podId)}
className="v2-byo__submit"
>
- {submitting ? t('agentByo.actions.issuing') : existingConnection && existingConnection.connection.state !== 'connected'
+ {submitting ? t('agentByo.actions.issuing') : canReconnect
? t('agentConnection.reconnect') : t('agentByo.actions.install')}
-
+ }
{
)}
- {!showCompactHosted && !showCompactByo && !issued && !hosted && !placed && (
+ {!showCompactHosted && !showCompactByo && !issued && !hosted && !placed && !reconnectRequest && (
{(hosting?.configured || machines.length > 0) && (
<>
@@ -1142,15 +1194,14 @@ const V2AgentBYO: React.FC = () => {
{t('agentByo.actions.chooseFirst')}
- ) : existingConnection && existingConnection.connection.state !== 'connected'
- && (existingConnection.installedBy === String(currentUser?._id) || currentUser?.role === 'admin') ? (
+ ) : canReconnect ? (
{
- if (existingConnection.runtimeType === 'wrapper') void submitMachine(sanitizeAgentName(name));
+ if (existingConnection.runtimeType === 'wrapper') void requestMachineReconnect(sanitizeAgentName(name));
else void replaceRunningKey({ agentName: sanitizeAgentName(name), podId });
}}>
{t('agentConnection.reconnect')}
- ) : (
+ ) : isOtherOwner ? null : (
{
)}
+ {reconnectRequest && (
+
+
{reconnectRequest.agentName}
+
{t(`agentConnection.reconnect${reconnectRequest.state === 'connected' ? 'Done'
+ : reconnectRequest.state === 'slow' ? 'Slow' : 'Waiting'}`)}
+
+ )}
+
{placed && (
diff --git a/frontend/src/v2/components/V2AgentControlSection.tsx b/frontend/src/v2/components/V2AgentControlSection.tsx
new file mode 100644
index 0000000000..98f4cb1ccb
--- /dev/null
+++ b/frontend/src/v2/components/V2AgentControlSection.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { AgentConnection, connectionLabel } from '../utils/agentConnection';
+
+interface Props {
+ agentName: string;
+ podId: string;
+ runtimeType?: string;
+ connection: AgentConnection;
+}
+
+// Owner/admin-only: the parent must obtain these props from an authenticated
+// roster read after its server-gated Settings access check.
+const V2AgentControlSection: React.FC
= ({ agentName, podId, runtimeType, connection }) => {
+ const { t } = useTranslation();
+ const needsReconnect = connection.state === 'revoked' || connection.state === 'stale';
+ return (
+
+ {t('agentConnection.control')}
+ {connectionLabel(connection, t)}
+ {needsReconnect && (
+
+ {t('agentConnection.reconnect')}
+
+ )}
+
+ );
+};
+
+export default V2AgentControlSection;
diff --git a/frontend/src/v2/components/V2YourTeamPage.tsx b/frontend/src/v2/components/V2YourTeamPage.tsx
index 01ddf8a252..d46b0eeedc 100644
--- a/frontend/src/v2/components/V2YourTeamPage.tsx
+++ b/frontend/src/v2/components/V2YourTeamPage.tsx
@@ -329,8 +329,9 @@ const V2YourTeamPage: React.FC = () => {
const key = agentKey(a);
const ask = a.userId ? asksByActor.get(a.userId)?.[0] : undefined;
const task = claimedTaskFor(a);
- const disconnected = a.connection && a.connection.state !== 'connected';
- const statusText = disconnected
+ const needsReconnect = a.connection && ['revoked', 'stale'].includes(a.connection.state);
+ const visualState = a.connection && a.connection.state !== 'connected' ? 'idle' : state;
+ const statusText = a.connection && a.connection.state !== 'connected'
? connectionLabel(a.connection!, t)
: state === 'needsYou'
? `${t('yourTeam.card.needsYou')} · ${ask?.title || ''}`.replace(/ · $/, '')
@@ -340,14 +341,14 @@ const V2YourTeamPage: React.FC = () => {
return (
goToProfile(a)}
aria-label={t('yourTeam.card.viewProfileAria', { name: display })}
>
@@ -355,9 +356,9 @@ const V2YourTeamPage: React.FC = () => {
{display}
-
- {!disconnected && state !== 'idle' &&
{STATE_DOT} }
- {!disconnected && state !== 'idle' ? ' ' : ''}
+
+ {(!a.connection || a.connection.state === 'connected') && state !== 'idle' && {STATE_DOT} }
+ {(!a.connection || a.connection.state === 'connected') && state !== 'idle' ? ' ' : ''}
{statusText}
@@ -371,17 +372,17 @@ const V2YourTeamPage: React.FC = () => {
)}
- {disconnected && (a.installedBy === String(user?._id) || user?.role === 'admin') ? (
+ {needsReconnect && (a.installedBy === String(user?._id) || user?.role === 'admin') ? (
navigate(`/v2/agents/byo?pod=${encodeURIComponent(a.podId || '')}&name=${encodeURIComponent(a.name)}&reconnect=1${a.runtime?.runtimeType === 'wrapper' ? '&mode=machine' : ''}`)}>
{t('agentConnection.reconnect')}
) : null}
- {!disconnected && state === 'needsYou' && (
+ {!needsReconnect && state === 'needsYou' && (
handleAnswer(a)}>
{t('yourTeam.card.answer')}
)}
- {!disconnected &&
handleTalk(a, e)}
From 5772dcbeb6a7a518d380caa5d6e7c6e8d1d4cb73 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:46:56 -0700
Subject: [PATCH 04/18] Stabilize reconnect regression tests
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
backend/__tests__/unit/routes/agentBinding.phase2.test.js | 4 ++--
frontend/src/v2/__tests__/V2AgentBYOName.test.tsx | 6 +++---
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
index 7d6cbdc985..fcb94b056f 100644
--- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
@@ -228,13 +228,13 @@ describe('owner-requested reconnect', () => {
it('lets the bound daemon replace a revoked agent key after the owner request', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
const original = await mint(DAEMON_A);
- expect(original.status).toBe(200);
+ expect(original.status).toBe(201);
await AgentCredential.updateOne({ tokenHash: hash(original.body.token) }, { $set: { status: 'revoked' } });
expect((await runtimeAuthProbe(original.body.token)).status).toBe(401);
expect((await requestReconnect()).status).toBe(200);
expect((await assigned(DAEMON_A)).body.agents[0].reconnectRequestedAt).toBeTruthy();
const refreshed = await mint(DAEMON_A, { rotate: true });
- expect(refreshed.status).toBe(200);
+ expect(refreshed.status).toBe(201);
expect((await runtimeAuthProbe(refreshed.body.token)).status).toBe(200);
expect((await runtimeAuthProbe(original.body.token)).status).toBe(401);
});
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index 8338f06134..566f4a45bd 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -83,7 +83,7 @@ describe('BYO default agent name follows where it runs', () => {
axios.post.mockResolvedValue({ data: { token: 'cm_agent_new' } });
renderAt('/v2/agents/byo?pod=p1&name=local&reconnect=1');
await waitFor(() => expect(screen.getByTestId('byo-connection-state'))
- .toHaveTextContent('Disconnected: key revoked'));
+ .toHaveTextContent('Disconnected: key revoked'), { timeout: 5000 });
expect(nameInput()).toHaveValue('local');
expect(axios.get.mock.calls.some(([url]) => url.startsWith('/api/registry/agents/next-free-name?'))).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
@@ -98,7 +98,7 @@ describe('BYO default agent name follows where it runs', () => {
connection: { state: 'revoked', lastSeenAt: null } }]);
renderAt('/v2/agents/byo?pod=p1&name=local&from=card');
await waitFor(() => expect(screen.getByTestId('byo-connection-state'))
- .toHaveTextContent('Disconnected: key revoked'));
+ .toHaveTextContent('Disconnected: key revoked'), { timeout: 5000 });
expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Connect agent' })).toBeNull();
});
@@ -108,7 +108,7 @@ describe('BYO default agent name follows where it runs', () => {
connection: { state: 'revoked', lastSeenAt: null } }]);
axios.post.mockResolvedValue({ data: { requestedAt: '2026-10-23T16:30:00Z' } });
renderAt('/v2/agents/byo?pod=p1&name=local&mode=machine&reconnect=1');
- const action = await screen.findByRole('button', { name: 'Reconnect' });
+ const action = await screen.findByRole('button', { name: 'Reconnect' }, { timeout: 5000 });
fireEvent.click(action);
await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
'/api/agent-binding/reconnect',
From e10051d21eb84d358af036b614b4f9f149a683e7 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:49:46 -0700
Subject: [PATCH 05/18] Honor admin reconnect and owner-scoped pod target
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../unit/routes/agentBinding.phase2.test.js | 11 +++++++-
backend/routes/agentBinding.ts | 24 ++++++++++++------
.../V2AgentProfileMemoryWrite.test.tsx | 25 +++++++++++++++++++
frontend/src/v2/agents/V2AgentProfile.tsx | 24 +++++++++++-------
4 files changed, 66 insertions(+), 18 deletions(-)
diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
index fcb94b056f..3e74336c7a 100644
--- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
@@ -9,7 +9,7 @@ const request = require('supertest');
const express = require('express');
jest.mock('../../../middleware/auth', () => {
- const mw = (req, res, next) => { req.user = { id: global.__CALLER_ID }; next(); };
+ const mw = (req, res, next) => { req.user = { id: global.__CALLER_ID, role: global.__CALLER_ROLE }; next(); };
mw.touchLastActive = jest.fn();
return mw;
});
@@ -45,6 +45,7 @@ beforeEach(async () => {
]);
owner = await User.create({ username: `o${Date.now() % 1e6}`, email: `o${Date.now()}@x.com`, password: 'x'.repeat(12) });
global.__CALLER_ID = String(owner._id);
+ global.__CALLER_ROLE = undefined;
bot = await User.create({
username: `b${Date.now() % 1e6}`, email: `b${Date.now()}@agents.commonly.local`, password: 'x'.repeat(12),
isBot: true, botMetadata: { agentName: 'wren-test', instanceId: 'default' },
@@ -249,6 +250,14 @@ describe('owner-requested reconnect', () => {
expect(refused.status).toBe(409);
expect(refused.body.code).toBe('machine_key_revoked');
});
+
+ it('lets an instance admin request a bound computer reconnect', async () => {
+ await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
+ global.__CALLER_ID = String(new mongoose.Types.ObjectId());
+ global.__CALLER_ROLE = 'admin';
+ expect((await requestReconnect()).status).toBe(200);
+ expect((await assigned(DAEMON_A)).body.agents[0].reconnectRequestedAt).toBeTruthy();
+ });
});
describe('daemon work list', () => {
diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts
index 362326d597..467d814ca1 100644
--- a/backend/routes/agentBinding.ts
+++ b/backend/routes/agentBinding.ts
@@ -271,33 +271,41 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr
// The owner explicitly asks the already-bound daemon to rotate this agent's
// runtime key. A placement request cannot do that: the daemon keeps its local
// token until a distinct reconnect directive arrives.
-router.post('/reconnect', bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => {
+router.post('/reconnect', bindingRateLimit, auth, async (
+ req: express.Request & { user?: { id?: string; role?: string } },
+ res: express.Response,
+) => {
try {
const agentName = normalize(req.body?.agentName);
const instanceId = normalize(req.body?.instanceId) || 'default';
if (!agentName) return res.status(400).json({ message: 'agentName required' });
- const ownership = await ownsAgent(req.user?.id, agentName, instanceId);
- if (!ownership.owned) return res.status(403).json({
- message: ownershipFailureMessage[ownership.failure], code: ownership.failure,
- });
+ const isAdmin = req.user?.role === 'admin';
+ if (!isAdmin) {
+ const ownership = await ownsAgent(req.user?.id, agentName, instanceId);
+ if (!ownership.owned) return res.status(403).json({
+ message: ownershipFailureMessage[ownership.failure], code: ownership.failure,
+ });
+ }
const identity = await User.findOne({
isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId,
}).select('_id botMetadata.machineId').lean();
const machineId = identity?.botMetadata?.machineId;
if (!machineId) return res.status(409).json({ message: 'Agent is not bound to a computer' });
- const machine = await Machine.findOne({ machineId, ownerUserId: req.user?.id }).select('_id').lean();
+ const machine = await Machine.findOne(isAdmin ? { machineId } : { machineId, ownerUserId: req.user?.id })
+ .select('_id ownerUserId').lean();
if (!machine) return res.status(403).json({ message: 'Computer is not owned by this installer' });
const daemonCanReconnect = await AgentCredential.exists({
- kind: 'daemon', machineId, ownerUserId: req.user?.id, status: 'active',
+ kind: 'daemon', machineId, ownerUserId: machine.ownerUserId, status: 'active',
});
if (!daemonCanReconnect) return res.status(409).json({
message: 'Computer key revoked. Register the computer again before reconnecting this agent.',
code: 'machine_key_revoked',
});
const requestedAt = new Date();
- await User.updateOne({ _id: identity._id, 'botMetadata.machineId': machineId }, {
+ const update = await User.updateOne({ _id: identity._id, 'botMetadata.machineId': machineId }, {
$set: { 'botMetadata.reconnectRequestedAt': requestedAt },
});
+ if (!update.matchedCount) return res.status(409).json({ message: 'Agent binding changed. Reload and retry.' });
return res.json({ requested: true, agentName, instanceId, machineId, requestedAt });
} catch (err) {
console.error('agent reconnect request error:', err);
diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
index a9cf5bf767..d00a178c29 100644
--- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
@@ -92,6 +92,9 @@ describe('V2AgentProfile memory-write visibility', () => {
if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: {
viewerRole: 'owner', totalEntries: 0, sections: [], pods: [{ id: 'pod-1', name: 'Workspace' }],
} });
+ if (url.startsWith('/api/registry/agents/claude-code/manage-settings')) return Promise.resolve({ data: {
+ installations: [{ podId: 'pod-1', podName: 'Workspace' }],
+ } });
if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{
name: 'claude-code', instanceId: 'observer', runtime: { runtimeType: 'webhook' },
connection: { state: 'revoked', lastSeenAt: null },
@@ -106,6 +109,28 @@ describe('V2AgentProfile memory-write visibility', () => {
expect(screen.queryByRole('button', { name: 'Talk to Observer' })).toBeNull();
});
+ it('uses the installer-owned pod when another pod appears first in the memory index', async () => {
+ window.localStorage.setItem('token', 'owner-token');
+ profileClient.get.mockImplementation((url: string) => {
+ if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: {
+ viewerRole: 'owner', totalEntries: 0, sections: [],
+ pods: [{ id: 'other-pod', name: 'Other team' }, { id: 'own-pod', name: 'Workspace' }],
+ } });
+ if (url.startsWith('/api/registry/agents/claude-code/manage-settings')) return Promise.resolve({ data: {
+ installations: [{ podId: 'own-pod', podName: 'Workspace' }],
+ } });
+ if (url === '/api/registry/pods/own-pod/agents') return Promise.resolve({ data: { agents: [{
+ name: 'claude-code', instanceId: 'observer', runtime: { runtimeType: 'wrapper' },
+ connection: { state: 'stale', lastSeenAt: new Date(Date.now() - 7 * 60000).toISOString() },
+ }] } });
+ return Promise.resolve(profilePayload(null));
+ });
+ renderProfile();
+ expect(await screen.findByRole('link', { name: 'Reconnect' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=own-pod&name=claude-code&reconnect=1&mode=machine');
+ expect(profileClient.get.mock.calls.some(([url]) => url === '/api/registry/pods/other-pod/agents')).toBe(false);
+ });
+
it('does not offer Reconnect to a signed-in pod member without management access', async () => {
window.localStorage.setItem('token', 'member-token');
renderProfile();
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index 2dce94db3f..c7a01620e4 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -189,23 +189,29 @@ const V2AgentProfile: React.FC = () => {
// The public profile is deliberately tokenless. Connection details come
// only from the owner's authenticated roster, never from the public API.
useEffect(() => {
- const podId = memIndex?.pods?.[0]?.id;
const token = localStorage.getItem('token');
- if (!podId || !token || !agentName || !['owner', 'admin'].includes(memIndex?.viewerRole || '')) {
+ if (!token || !agentName || !['owner', 'admin'].includes(memIndex?.viewerRole || '')) {
setOwnedConnection(null);
return undefined;
}
let cancelled = false;
- getClient().get<{ agents?: Array<{
- name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
- }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`, {
- headers: { Authorization: `Bearer ${token}` },
- }).then((response) => {
+ const headers = { Authorization: `Bearer ${token}` };
+ getClient().get<{ installations?: Array<{ podId: string }> }>(
+ `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings?instanceId=${encodeURIComponent(instanceId || 'default')}`,
+ { headers },
+ ).then(async (writable) => {
+ const podId = writable.data.installations?.[0]?.podId;
+ if (!podId) return null;
+ const roster = await getClient().get<{ agents?: Array<{
+ name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
+ }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`, { headers });
+ return { podId, roster };
+ }).then((result) => {
if (cancelled) return;
- const match = response.data.agents?.find((row) => row.name === agentName
+ const match = result?.roster.data.agents?.find((row) => row.name === agentName
&& row.instanceId === (instanceId || 'default') && row.connection);
setOwnedConnection(match?.connection ? {
- connection: match.connection, podId, runtimeType: match.runtime?.runtimeType,
+ connection: match.connection, podId: result!.podId, runtimeType: match.runtime?.runtimeType,
} : null);
}).catch(() => { if (!cancelled) setOwnedConnection(null); });
return () => { cancelled = true; };
From 1b54938eb23f750cfe81a539325ebbf50394f367 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:51:22 -0700
Subject: [PATCH 06/18] Localize revoked computer reconnect guidance
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
frontend/src/i18n/locales/en.json | 3 ++-
frontend/src/i18n/locales/zh-CN.json | 3 ++-
frontend/src/v2/__tests__/V2AgentBYOName.test.tsx | 9 +++++++++
frontend/src/v2/components/V2AgentBYO.tsx | 6 ++++--
4 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index aae635c311..f1d21ad9f9 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1369,7 +1369,8 @@
"reconnect": "Reconnect",
"reconnectWaiting": "Waiting for your computer to reconnect this agent.",
"reconnectDone": "Connected.",
- "reconnectSlow": "Your computer has not reconnected yet. Check that its daemon is running."
+ "reconnectSlow": "Your computer has not reconnected yet. Check that its daemon is running.",
+ "machineKeyRevoked": "This computer's key was revoked. Register the computer again to reconnect the agent."
},
"agentByo": {
"title": "Bring your own agent",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 4067f4cfe1..63d4937714 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1429,7 +1429,8 @@
"reconnect": "重新连接",
"reconnectWaiting": "正在等待电脑重新连接此智能体。",
"reconnectDone": "已连接。",
- "reconnectSlow": "电脑尚未重新连接。请检查守护进程是否在运行。"
+ "reconnectSlow": "电脑尚未重新连接。请检查守护进程是否在运行。",
+ "machineKeyRevoked": "这台电脑的密钥已撤销。请重新注册电脑,再连接智能体。"
},
"agentByo": {
"title": "接入你自己的智能体",
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index 566f4a45bd..e2bd6f651c 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -117,6 +117,15 @@ describe('BYO default agent name follows where it runs', () => {
expect(axios.post.mock.calls.some(([url]) => url === '/api/agent-binding/request')).toBe(false);
expect(await screen.findByTestId('byo-reconnect-result')).toHaveTextContent('Waiting for your computer');
});
+
+ test('a revoked computer key explains the registration step', async () => {
+ mockGet([{ name: 'local', installedBy: 'u1', runtime: { runtimeType: 'wrapper' },
+ connection: { state: 'revoked', lastSeenAt: null } }]);
+ axios.post.mockRejectedValue({ response: { data: { code: 'machine_key_revoked' } } });
+ renderAt('/v2/agents/byo?pod=p1&name=local&mode=machine&reconnect=1');
+ fireEvent.click(await screen.findByRole('button', { name: 'Reconnect' }, { timeout: 5000 }));
+ expect(await screen.findByText(/Register the computer again/)).toBeInTheDocument();
+ });
test('trims a long username before asking for a name suggestion', async () => {
authValue.currentUser.username = 'a'.repeat(60);
mockGet();
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index 20b7ca833c..d9605d2646 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -625,8 +625,10 @@ const V2AgentBYO: React.FC = () => {
});
setReconnectRequest({ agentName: cleanName, requestedAt: response.requestedAt, state: 'waiting' });
} catch (err) {
- const e = err as { response?: { data?: { message?: string } }; message?: string };
- setError(e.response?.data?.message || e.message || t('agentByo.errors.installFailed'));
+ const e = err as { response?: { data?: { code?: string; message?: string } }; message?: string };
+ setError(e.response?.data?.code === 'machine_key_revoked'
+ ? t('agentConnection.machineKeyRevoked')
+ : e.response?.data?.message || e.message || t('agentByo.errors.installFailed'));
} finally {
setSubmitting(false);
}
From 975e72aa4cd71809f5aea20d33f5c3e31b104fa3 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:53:26 -0700
Subject: [PATCH 07/18] Load Settings control from manageable installation
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
frontend/src/i18n/locales/en.json | 4 +-
frontend/src/i18n/locales/zh-CN.json | 4 +-
.../__tests__/V2AgentControlSection.test.tsx | 26 +++++++++-
.../v2/components/V2AgentControlSection.tsx | 50 ++++++++++++++++++-
4 files changed, 79 insertions(+), 5 deletions(-)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index f1d21ad9f9..52d35456ba 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1370,7 +1370,9 @@
"reconnectWaiting": "Waiting for your computer to reconnect this agent.",
"reconnectDone": "Connected.",
"reconnectSlow": "Your computer has not reconnected yet. Check that its daemon is running.",
- "machineKeyRevoked": "This computer's key was revoked. Register the computer again to reconnect the agent."
+ "machineKeyRevoked": "This computer's key was revoked. Register the computer again to reconnect the agent.",
+ "checking": "Checking connection…",
+ "unavailable": "Connection status unavailable."
},
"agentByo": {
"title": "Bring your own agent",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 63d4937714..30000f5912 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1430,7 +1430,9 @@
"reconnectWaiting": "正在等待电脑重新连接此智能体。",
"reconnectDone": "已连接。",
"reconnectSlow": "电脑尚未重新连接。请检查守护进程是否在运行。",
- "machineKeyRevoked": "这台电脑的密钥已撤销。请重新注册电脑,再连接智能体。"
+ "machineKeyRevoked": "这台电脑的密钥已撤销。请重新注册电脑,再连接智能体。",
+ "checking": "正在检查连接…",
+ "unavailable": "暂时无法获取连接状态。"
},
"agentByo": {
"title": "接入你自己的智能体",
diff --git a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
index 1d9a514947..51ad1c9205 100644
--- a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
@@ -1,9 +1,15 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
-import V2AgentControlSection from '../components/V2AgentControlSection';
+import V2AgentControlSection, { AgentControlLoader } from '../components/V2AgentControlSection';
import i18n, { i18nReady } from '../../i18n';
+jest.mock('../../utils/axiosConfig', () => ({
+ __esModule: true,
+ default: { get: jest.fn() },
+}));
+const api = jest.requireMock('../../utils/axiosConfig').default;
+
beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); });
const show = (state: 'revoked' | 'stale' | 'never' | 'connected') => render(
@@ -24,3 +30,21 @@ it('keeps a never-connected key off the reconnect path', () => {
expect(screen.getByTestId('agent-control-section')).toHaveTextContent('Not connected yet');
expect(screen.queryByRole('link', { name: 'Reconnect' })).toBeNull();
});
+
+it('loads connection state from an installer-owned pod', async () => {
+ api.get.mockImplementation((url: string) => {
+ if (url.endsWith('/manage-settings')) return Promise.resolve({ data: {
+ installations: [{ podId: 'owned-pod' }],
+ } });
+ if (url === '/api/registry/pods/owned-pod/agents') return Promise.resolve({ data: { agents: [{
+ name: 'local', instanceId: 'default', runtime: { runtimeType: 'wrapper' },
+ connection: { state: 'revoked', lastSeenAt: null },
+ }] } });
+ return Promise.reject(new Error('Unexpected request'));
+ });
+ render( );
+ expect(await screen.findByRole('link', { name: 'Reconnect' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=owned-pod&name=local&reconnect=1&mode=machine');
+ expect(api.get).toHaveBeenCalledWith('/api/registry/agents/local/manage-settings',
+ { params: { instanceId: 'default' } });
+});
diff --git a/frontend/src/v2/components/V2AgentControlSection.tsx b/frontend/src/v2/components/V2AgentControlSection.tsx
index 98f4cb1ccb..0a902410fb 100644
--- a/frontend/src/v2/components/V2AgentControlSection.tsx
+++ b/frontend/src/v2/components/V2AgentControlSection.tsx
@@ -1,6 +1,7 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
+import axios from '../../utils/axiosConfig';
import { AgentConnection, connectionLabel } from '../utils/agentConnection';
interface Props {
@@ -16,7 +17,7 @@ const V2AgentControlSection: React.FC = ({ agentName, podId, runtimeType,
const { t } = useTranslation();
const needsReconnect = connection.state === 'revoked' || connection.state === 'stale';
return (
-
+
{t('agentConnection.control')}
{connectionLabel(connection, t)}
{needsReconnect && (
@@ -30,3 +31,48 @@ const V2AgentControlSection: React.FC = ({ agentName, podId, runtimeType,
};
export default V2AgentControlSection;
+
+// Settings mounts after its server-gated access read. Resolve a writable pod
+// rather than assuming the first pod in the public-facing activity list is
+// this viewer's installation.
+export const AgentControlLoader: React.FC<{ agentName: string; instanceId: string }> = ({ agentName, instanceId }) => {
+ const { t } = useTranslation();
+ const [status, setStatus] = useState<(Props & { podId: string }) | null>(null);
+ const [unavailable, setUnavailable] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ setStatus(null);
+ setUnavailable(false);
+ const load = async () => {
+ try {
+ const manageable = await axios.get<{ installations?: Array<{ podId: string }> }>(
+ `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`,
+ { params: { instanceId } },
+ );
+ const podId = manageable.data.installations?.[0]?.podId;
+ if (!podId) throw new Error('No manageable installation');
+ const roster = await axios.get<{ agents?: Array<{
+ name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
+ }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`);
+ const match = roster.data.agents?.find((row) => row.name === agentName
+ && row.instanceId === instanceId && row.connection);
+ if (!match?.connection) throw new Error('No connection status');
+ if (active) setStatus({ agentName, podId, runtimeType: match.runtime?.runtimeType,
+ connection: match.connection });
+ } catch {
+ if (active) setUnavailable(true);
+ }
+ };
+ void load();
+ return () => { active = false; };
+ }, [agentName, instanceId]);
+
+ if (status) return ;
+ return
+ {t('agentConnection.control')}
+
+ {t(unavailable ? 'agentConnection.unavailable' : 'agentConnection.checking')}
+
+ ;
+};
From f2c9fefddce322e7d4930df9ad4a4a0ff309db0c Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:56:38 -0700
Subject: [PATCH 08/18] Support proactive wrapper rekey from Settings
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../src/v2/__tests__/V2AgentBYOName.test.tsx | 12 ++++++++++++
frontend/src/v2/components/V2AgentBYO.tsx | 3 ++-
.../src/v2/components/V2AgentControlSection.tsx | 16 +++++++++-------
3 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index e2bd6f651c..44075627ae 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -118,6 +118,18 @@ describe('BYO default agent name follows where it runs', () => {
expect(await screen.findByTestId('byo-reconnect-result')).toHaveTextContent('Waiting for your computer');
});
+ test('a Settings key link can proactively rekey a connected wrapper', async () => {
+ mockGet([{ name: 'local', installedBy: 'u1', runtime: { runtimeType: 'wrapper' },
+ connection: { state: 'connected', lastSeenAt: new Date().toISOString() } }]);
+ axios.post.mockResolvedValue({ data: { requestedAt: new Date().toISOString() } });
+ renderAt('/v2/agents/byo?pod=p1&name=local&mode=machine&reconnect=1');
+ fireEvent.click(await screen.findByRole('button', { name: 'Reconnect' }, { timeout: 5000 }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/agent-binding/reconnect',
+ { agentName: 'local', instanceId: 'default' }, expect.anything(),
+ ));
+ });
+
test('a revoked computer key explains the registration step', async () => {
mockGet([{ name: 'local', installedBy: 'u1', runtime: { runtimeType: 'wrapper' },
connection: { state: 'revoked', lastSeenAt: null } }]);
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index d9605d2646..f23798e562 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -263,7 +263,8 @@ const V2AgentBYO: React.FC = () => {
const [adapter, setAdapter] = useState('');
const [model, setModel] = useState('');
const canReconnect = Boolean(existingConnection
- && ['revoked', 'stale'].includes(existingConnection.connection.state)
+ && (['revoked', 'stale'].includes(existingConnection.connection.state)
+ || (prefill.reconnect && existingConnection.runtimeType === 'wrapper'))
&& (existingConnection.installedBy === String(currentUser?._id) || currentUser?.role === 'admin'));
const isOtherOwner = Boolean(existingConnection && existingConnection.installedBy
&& existingConnection.installedBy !== String(currentUser?._id) && currentUser?.role !== 'admin');
diff --git a/frontend/src/v2/components/V2AgentControlSection.tsx b/frontend/src/v2/components/V2AgentControlSection.tsx
index 0a902410fb..098611694f 100644
--- a/frontend/src/v2/components/V2AgentControlSection.tsx
+++ b/frontend/src/v2/components/V2AgentControlSection.tsx
@@ -18,14 +18,16 @@ const V2AgentControlSection: React.FC = ({ agentName, podId, runtimeType,
const needsReconnect = connection.state === 'revoked' || connection.state === 'stale';
return (
- {t('agentConnection.control')}
+
+
{t('agentConnection.control')}
+ {needsReconnect && (
+
+ {t('agentConnection.reconnect')}
+
+ )}
+
{connectionLabel(connection, t)}
- {needsReconnect && (
-
- {t('agentConnection.reconnect')}
-
- )}
);
};
From 5ca7e89531cc67e9a1945627b6c133d5eccd156b Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:58:24 -0700
Subject: [PATCH 09/18] Preserve instance identity in reconnect flows
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../src/v2/__tests__/V2AgentBYOName.test.tsx | 12 +++++++-----
.../__tests__/V2AgentControlSection.test.tsx | 6 +++---
.../V2AgentProfileMemoryWrite.test.tsx | 4 ++--
.../v2/__tests__/V2YourTeamSignal.test.tsx | 2 +-
frontend/src/v2/agents/V2AgentProfile.tsx | 2 +-
frontend/src/v2/components/V2AgentBYO.tsx | 19 +++++++++++--------
.../v2/components/V2AgentControlSection.tsx | 7 ++++---
frontend/src/v2/components/V2YourTeamPage.tsx | 2 +-
8 files changed, 30 insertions(+), 24 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index 44075627ae..97d1e5f842 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -47,7 +47,9 @@ const pods = [{ _id: 'p1', name: 'Workspace', type: 'chat' }];
// Hosting on, so the page asks where the agent runs.
const mockGet = (roster = []) => {
axios.get.mockImplementation((url) => {
- if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: roster } });
+ if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: {
+ agents: roster.map((row) => ({ instanceId: 'default', ...row })),
+ } });
if (url.startsWith('/api/registry/agents/next-free-name?')) return Promise.resolve({ data: { name: new URLSearchParams(url.split('?')[1]).get('base') } });
if (url.startsWith('/api/hosted/availability')) {
return Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } });
@@ -89,7 +91,7 @@ describe('BYO default agent name follows where it runs', () => {
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
'/api/registry/pods/p1/agents/local/runtime-tokens',
- { label: 'BYO MCP — replacement', force: true }, expect.anything(),
+ { label: 'BYO MCP — replacement', force: true, instanceId: 'default' }, expect.anything(),
));
});
@@ -119,14 +121,14 @@ describe('BYO default agent name follows where it runs', () => {
});
test('a Settings key link can proactively rekey a connected wrapper', async () => {
- mockGet([{ name: 'local', installedBy: 'u1', runtime: { runtimeType: 'wrapper' },
+ mockGet([{ name: 'local', instanceId: 'alpha', installedBy: 'u1', runtime: { runtimeType: 'wrapper' },
connection: { state: 'connected', lastSeenAt: new Date().toISOString() } }]);
axios.post.mockResolvedValue({ data: { requestedAt: new Date().toISOString() } });
- renderAt('/v2/agents/byo?pod=p1&name=local&mode=machine&reconnect=1');
+ renderAt('/v2/agents/byo?pod=p1&name=local&instanceId=alpha&mode=machine&reconnect=1');
fireEvent.click(await screen.findByRole('button', { name: 'Reconnect' }, { timeout: 5000 }));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
'/api/agent-binding/reconnect',
- { agentName: 'local', instanceId: 'default' }, expect.anything(),
+ { agentName: 'local', instanceId: 'alpha' }, expect.anything(),
));
});
diff --git a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
index 51ad1c9205..b41f638317 100644
--- a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
@@ -13,7 +13,7 @@ const api = jest.requireMock('../../utils/axiosConfig').default;
beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); });
const show = (state: 'revoked' | 'stale' | 'never' | 'connected') => render(
-
,
);
@@ -22,7 +22,7 @@ it('shows a revoked wrapper key and its one reconnect route', () => {
show('revoked');
expect(screen.getByTestId('agent-control-section')).toHaveTextContent('Disconnected: key revoked');
expect(screen.getByRole('link', { name: 'Reconnect' }))
- .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=local&reconnect=1&mode=machine');
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=local&instanceId=default&reconnect=1&mode=machine');
});
it('keeps a never-connected key off the reconnect path', () => {
@@ -44,7 +44,7 @@ it('loads connection state from an installer-owned pod', async () => {
});
render( );
expect(await screen.findByRole('link', { name: 'Reconnect' }))
- .toHaveAttribute('href', '/v2/agents/byo?pod=owned-pod&name=local&reconnect=1&mode=machine');
+ .toHaveAttribute('href', '/v2/agents/byo?pod=owned-pod&name=local&instanceId=default&reconnect=1&mode=machine');
expect(api.get).toHaveBeenCalledWith('/api/registry/agents/local/manage-settings',
{ params: { instanceId: 'default' } });
});
diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
index d00a178c29..19afea33a2 100644
--- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
@@ -105,7 +105,7 @@ describe('V2AgentProfile memory-write visibility', () => {
expect(await screen.findByTestId('agent-profile-connection'))
.toHaveTextContent('Disconnected: key revoked');
expect(screen.getByRole('link', { name: 'Reconnect' }))
- .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=claude-code&reconnect=1');
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=claude-code&instanceId=observer&reconnect=1');
expect(screen.queryByRole('button', { name: 'Talk to Observer' })).toBeNull();
});
@@ -127,7 +127,7 @@ describe('V2AgentProfile memory-write visibility', () => {
});
renderProfile();
expect(await screen.findByRole('link', { name: 'Reconnect' }))
- .toHaveAttribute('href', '/v2/agents/byo?pod=own-pod&name=claude-code&reconnect=1&mode=machine');
+ .toHaveAttribute('href', '/v2/agents/byo?pod=own-pod&name=claude-code&instanceId=observer&reconnect=1&mode=machine');
expect(profileClient.get.mock.calls.some(([url]) => url === '/api/registry/pods/other-pod/agents')).toBe(false);
});
diff --git a/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
index 47e3e44c97..b1c60a30e9 100644
--- a/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
+++ b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx
@@ -76,7 +76,7 @@ describe('Your Team (direction C)', () => {
expect(card.querySelector('.v2-team-card__status--working')).toBeNull();
expect(within(card).queryByRole('button', { name: /Talk to/ })).toBeNull();
fireEvent.click(within(card).getByRole('button', { name: 'Reconnect' }));
- expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/byo?pod=p1&name=local&reconnect=1');
+ expect(mockNavigate).toHaveBeenCalledWith('/v2/agents/byo?pod=p1&name=local&instanceId=default&reconnect=1');
});
test('a never-connected agent is offered the normal Talk path, not Reconnect', async () => {
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index c7a01620e4..cd665bfc1a 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -404,7 +404,7 @@ const V2AgentProfile: React.FC = () => {
{ownedConnection && ['revoked', 'stale'].includes(ownedConnection.connection.state) ? (
+ to={`/v2/agents/byo?pod=${encodeURIComponent(ownedConnection.podId)}&name=${encodeURIComponent(agent.agentName)}&instanceId=${encodeURIComponent(instanceId || 'default')}&reconnect=1${ownedConnection.runtimeType === 'wrapper' ? '&mode=machine' : ''}`}>
{t('agentConnection.reconnect')}
) : authed ? (
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index f23798e562..daaa5017fc 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -128,6 +128,7 @@ const V2AgentBYO: React.FC = () => {
return {
pod: (params.get('pod') || '').replace(/[^a-f0-9]/gi, ''),
name: sanitizeAgentName(params.get('name') || ''),
+ instanceId: sanitizeAgentName(params.get('instanceId') || 'default') || 'default',
persona: sanitizeAgentName(params.get('persona') || ''),
// ?mode=hosted comes from Scout's "Run it here" button: the person
// already asked Commonly to host their agent.
@@ -277,10 +278,11 @@ const V2AgentBYO: React.FC = () => {
}
let cancelled = false;
const timer = setTimeout(() => {
- api.get<{ agents?: Array<{ name: string; runtime?: { runtimeType?: string }; connection?: AgentConnection; installedBy?: string }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`)
+ api.get<{ agents?: Array<{ name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection; installedBy?: string }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`)
.then((result) => {
if (cancelled) return;
- const match = result.agents?.find((agent) => agent.name === sanitizeAgentName(name) && agent.connection);
+ const match = result.agents?.find((agent) => agent.name === sanitizeAgentName(name)
+ && (!prefill.reconnect || agent.instanceId === prefill.instanceId) && agent.connection);
setExistingConnection(match?.connection ? {
connection: match.connection,
runtimeType: match.runtime?.runtimeType || '',
@@ -290,7 +292,7 @@ const V2AgentBYO: React.FC = () => {
.catch(() => { if (!cancelled) setExistingConnection(null); });
}, 250);
return () => { cancelled = true; clearTimeout(timer); };
- }, [api, name, nameTouched, podId]);
+ }, [api, name, nameTouched, podId, prefill.instanceId, prefill.reconnect]);
// Re-default while the person hasn't typed a name: when the user resolves
// after mount, and when the mode or the runner changes.
const defaultAgentName = defaultNameFor(mode, mode === 'machine' ? adapter : listenAdapter);
@@ -334,11 +336,12 @@ const V2AgentBYO: React.FC = () => {
const check = async () => {
ticks += 1;
try {
- const roster = await api.get<{ agents?: Array<{ name: string; connection?: AgentConnection }> }>(
+ const roster = await api.get<{ agents?: Array<{ name: string; instanceId: string; connection?: AgentConnection }> }>(
`/api/registry/pods/${encodeURIComponent(podId)}/agents`,
);
if (cancelled) return;
- const status = roster.agents?.find((agent) => agent.name === reconnectRequest.agentName)?.connection;
+ const status = roster.agents?.find((agent) => agent.name === reconnectRequest.agentName
+ && agent.instanceId === prefill.instanceId)?.connection;
if (status?.state === 'connected' && status.lastSeenAt
&& Date.parse(status.lastSeenAt) >= Date.parse(reconnectRequest.requestedAt)) {
setReconnectRequest((current) => current && { ...current, state: 'connected' });
@@ -352,7 +355,7 @@ const V2AgentBYO: React.FC = () => {
void check();
const timer = setInterval(() => { void check(); }, MACHINE_STATUS_POLL_MS);
return () => { cancelled = true; clearInterval(timer); };
- }, [api, podId, reconnectRequest]);
+ }, [api, podId, prefill.instanceId, reconnectRequest]);
// Watch the placement come up: the machine heartbeat carries per-agent
// supervisor states, so "live" here means the daemon actually spawned it.
@@ -622,7 +625,7 @@ const V2AgentBYO: React.FC = () => {
setError(null);
try {
const response = await api.post<{ requestedAt: string }>('/api/agent-binding/reconnect', {
- agentName: cleanName, instanceId: 'default',
+ agentName: cleanName, instanceId: prefill.instanceId,
});
setReconnectRequest({ agentName: cleanName, requestedAt: response.requestedAt, state: 'waiting' });
} catch (err) {
@@ -720,7 +723,7 @@ const V2AgentBYO: React.FC = () => {
const { agentName, podId: targetPodId } = target;
const tokenRes = await api.post<{ token?: string }>(
`/api/registry/pods/${encodeURIComponent(targetPodId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens`,
- { label: 'BYO MCP — replacement', force: true },
+ { label: 'BYO MCP — replacement', force: true, instanceId: prefill.instanceId },
);
if (!tokenRes?.token) {
setError(t('agentByo.errors.tokenEmpty'));
diff --git a/frontend/src/v2/components/V2AgentControlSection.tsx b/frontend/src/v2/components/V2AgentControlSection.tsx
index 098611694f..ffb82d5731 100644
--- a/frontend/src/v2/components/V2AgentControlSection.tsx
+++ b/frontend/src/v2/components/V2AgentControlSection.tsx
@@ -6,6 +6,7 @@ import { AgentConnection, connectionLabel } from '../utils/agentConnection';
interface Props {
agentName: string;
+ instanceId: string;
podId: string;
runtimeType?: string;
connection: AgentConnection;
@@ -13,7 +14,7 @@ interface Props {
// Owner/admin-only: the parent must obtain these props from an authenticated
// roster read after its server-gated Settings access check.
-const V2AgentControlSection: React.FC
= ({ agentName, podId, runtimeType, connection }) => {
+const V2AgentControlSection: React.FC = ({ agentName, instanceId, podId, runtimeType, connection }) => {
const { t } = useTranslation();
const needsReconnect = connection.state === 'revoked' || connection.state === 'stale';
return (
@@ -22,7 +23,7 @@ const V2AgentControlSection: React.FC = ({ agentName, podId, runtimeType,
{t('agentConnection.control')}
{needsReconnect && (
+ to={`/v2/agents/byo?pod=${encodeURIComponent(podId)}&name=${encodeURIComponent(agentName)}&instanceId=${encodeURIComponent(instanceId)}&reconnect=1${runtimeType === 'wrapper' ? '&mode=machine' : ''}`}>
{t('agentConnection.reconnect')}
)}
@@ -60,7 +61,7 @@ export const AgentControlLoader: React.FC<{ agentName: string; instanceId: strin
const match = roster.data.agents?.find((row) => row.name === agentName
&& row.instanceId === instanceId && row.connection);
if (!match?.connection) throw new Error('No connection status');
- if (active) setStatus({ agentName, podId, runtimeType: match.runtime?.runtimeType,
+ if (active) setStatus({ agentName, instanceId, podId, runtimeType: match.runtime?.runtimeType,
connection: match.connection });
} catch {
if (active) setUnavailable(true);
diff --git a/frontend/src/v2/components/V2YourTeamPage.tsx b/frontend/src/v2/components/V2YourTeamPage.tsx
index d46b0eeedc..43692f6837 100644
--- a/frontend/src/v2/components/V2YourTeamPage.tsx
+++ b/frontend/src/v2/components/V2YourTeamPage.tsx
@@ -373,7 +373,7 @@ const V2YourTeamPage: React.FC = () => {
)}
{needsReconnect && (a.installedBy === String(user?._id) || user?.role === 'admin') ? (
-
navigate(`/v2/agents/byo?pod=${encodeURIComponent(a.podId || '')}&name=${encodeURIComponent(a.name)}&reconnect=1${a.runtime?.runtimeType === 'wrapper' ? '&mode=machine' : ''}`)}>
+ navigate(`/v2/agents/byo?pod=${encodeURIComponent(a.podId || '')}&name=${encodeURIComponent(a.name)}&instanceId=${encodeURIComponent(a.instanceId || 'default')}&reconnect=1${a.runtime?.runtimeType === 'wrapper' ? '&mode=machine' : ''}`)}>
{t('agentConnection.reconnect')}
) : null}
From 2d4bc82bda6f2c8fcb653384dfcbf4aace0de071 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:59:23 -0700
Subject: [PATCH 10/18] Guard same-name daemon instance tokens
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
cli/__tests__/daemon-supervisor.test.mjs | 13 +++++++++++++
cli/src/lib/daemon-supervisor.js | 7 +++++++
2 files changed, 20 insertions(+)
diff --git a/cli/__tests__/daemon-supervisor.test.mjs b/cli/__tests__/daemon-supervisor.test.mjs
index 44a20b20a8..18f97106c5 100644
--- a/cli/__tests__/daemon-supervisor.test.mjs
+++ b/cli/__tests__/daemon-supervisor.test.mjs
@@ -103,6 +103,19 @@ describe('tick', () => {
await supervisor.tick();
expect(client.post.mock.calls.filter(([path]) => path === '/api/agent-binding/runtime-token')).toHaveLength(1);
});
+ test('a same-name local record never rotates another instance key', async () => {
+ const tokens = { 'wren-test': {
+ agentName: 'wren-test', instanceId: 'default', runtimeToken: 'cm_agent_default', adapter: 'claude',
+ } };
+ const { supervisor, client, children } = makeHarness({
+ rows: () => [boundRow({ instanceId: 'alpha', reconnectRequestedAt: '2026-10-23T16:30:00.000Z' })],
+ tokens,
+ });
+ await supervisor.tick();
+ expect(client.post).not.toHaveBeenCalledWith('/api/agent-binding/runtime-token', expect.anything());
+ expect(tokens['wren-test'].runtimeToken).toBe('cm_agent_default');
+ expect(children).toHaveLength(0);
+ });
test('adopts a requested row, mints its token, writes the record, and spawns', async () => {
const { supervisor, client, children, saveToken } = makeHarness({
rows: () => [boundRow({ state: 'requested' })],
diff --git a/cli/src/lib/daemon-supervisor.js b/cli/src/lib/daemon-supervisor.js
index 8ae354e1a6..7553ed7cd0 100644
--- a/cli/src/lib/daemon-supervisor.js
+++ b/cli/src/lib/daemon-supervisor.js
@@ -208,6 +208,13 @@ export const createDaemonSupervisor = ({
const existing = loadToken(row.agentName);
if (existing) {
+ // Local records are still keyed by agent name. Never rotate or launch
+ // another instance through a same-name record: that would overwrite a
+ // different seat's bearer until instance-scoped local records land.
+ if ((existing.instanceId || 'default') !== (row.instanceId || 'default')) {
+ log(`[${row.agentName}] local token belongs to instance '${existing.instanceId || 'default'}'; refusing '${row.instanceId || 'default'}'`);
+ return false;
+ }
// A human owner can request a fresh key from Settings after revocation.
// The daemon owns its local token file, so the browser only stamps the
// directive; this host rotates the key and restarts its child once.
From 87709a121363f0d891c8a07ccd9277b5c07db21c Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:00:20 -0700
Subject: [PATCH 11/18] test: preserve BYO instance on webhook key replacement
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../src/v2/__tests__/V2AgentBYOName.test.tsx | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index 97d1e5f842..6e900cd349 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -95,6 +95,22 @@ describe('BYO default agent name follows where it runs', () => {
));
});
+ test('a nondefault webhook reconnect selects and rotates only that instance', async () => {
+ mockGet([
+ { name: 'local', instanceId: 'default', installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'connected', lastSeenAt: new Date().toISOString() } },
+ { name: 'local', instanceId: 'alpha', installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null } },
+ ]);
+ axios.post.mockResolvedValue({ data: { token: 'cm_agent_alpha' } });
+ renderAt('/v2/agents/byo?pod=p1&name=local&instanceId=alpha&reconnect=1');
+ fireEvent.click(await screen.findByRole('button', { name: 'Reconnect' }, { timeout: 5000 }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
+ '/api/registry/pods/p1/agents/local/runtime-tokens',
+ { label: 'BYO MCP — replacement', force: true, instanceId: 'alpha' }, expect.anything(),
+ ));
+ });
+
test('compact BYO never offers a non-owner a forced key rotation', async () => {
mockGet([{ name: 'local', installedBy: 'someone-else', runtime: { runtimeType: 'webhook' },
connection: { state: 'revoked', lastSeenAt: null } }]);
From 24743149b43c6e5e7acd310ec2ed414c38042a47 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:07:16 -0700
Subject: [PATCH 12/18] fix: refuse reconnect through expired daemon key
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../unit/routes/agentBinding.phase2.test.js | 12 ++++++++++++
backend/routes/agentBinding.ts | 1 +
2 files changed, 13 insertions(+)
diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
index 3e74336c7a..8eb16eadf3 100644
--- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js
+++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js
@@ -251,6 +251,18 @@ describe('owner-requested reconnect', () => {
expect(refused.body.code).toBe('machine_key_revoked');
});
+ it('refuses an expired daemon key without spending the reconnect directive', async () => {
+ await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
+ await AgentCredential.updateOne({ _id: daemonCredA._id }, {
+ $set: { expiresAt: new Date(Date.now() - 60_000) },
+ });
+ const refused = await requestReconnect();
+ expect(refused.status).toBe(409);
+ expect(refused.body.code).toBe('machine_key_revoked');
+ const identity = await User.findById(bot._id).lean();
+ expect(identity.botMetadata.reconnectRequestedAt ?? null).toBeNull();
+ });
+
it('lets an instance admin request a bound computer reconnect', async () => {
await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } });
global.__CALLER_ID = String(new mongoose.Types.ObjectId());
diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts
index 467d814ca1..e06dda0046 100644
--- a/backend/routes/agentBinding.ts
+++ b/backend/routes/agentBinding.ts
@@ -296,6 +296,7 @@ router.post('/reconnect', bindingRateLimit, auth, async (
if (!machine) return res.status(403).json({ message: 'Computer is not owned by this installer' });
const daemonCanReconnect = await AgentCredential.exists({
kind: 'daemon', machineId, ownerUserId: machine.ownerUserId, status: 'active',
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }],
});
if (!daemonCanReconnect) return res.status(409).json({
message: 'Computer key revoked. Register the computer again before reconnecting this agent.',
From 4d38e913d7dbbb151d1ca7865d46ee6923da6891 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:16:46 -0700
Subject: [PATCH 13/18] feat: target writable instance for Control recovery
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../__tests__/V2AgentControlSection.test.tsx | 22 +++++++++++++++--
.../V2AgentProfileMemoryWrite.test.tsx | 4 ++--
frontend/src/v2/agents/V2AgentProfile.tsx | 4 ++--
.../v2/components/V2AgentControlSection.tsx | 24 ++++++++++++-------
4 files changed, 40 insertions(+), 14 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
index b41f638317..9a8a180e5c 100644
--- a/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
@@ -31,10 +31,26 @@ it('keeps a never-connected key off the reconnect path', () => {
expect(screen.queryByRole('link', { name: 'Reconnect' })).toBeNull();
});
+it('offers the connected wrapper one owner-directed key regeneration route', () => {
+ show('connected');
+ expect(screen.getByRole('link', { name: 'Regenerate key' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=local&instanceId=default&reconnect=1&mode=machine');
+ expect(screen.queryByRole('link', { name: 'Reconnect' })).toBeNull();
+});
+
+it('keeps a nondefault instance on the connected wrapper key route', () => {
+ render(
+ );
+ expect(screen.getByRole('link', { name: 'Regenerate key' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=local&instanceId=alpha&reconnect=1&mode=machine');
+});
+
it('loads connection state from an installer-owned pod', async () => {
+ const onConnectionState = jest.fn();
api.get.mockImplementation((url: string) => {
if (url.endsWith('/manage-settings')) return Promise.resolve({ data: {
- installations: [{ podId: 'owned-pod' }],
+ installations: [{ podId: 'read-only-pod' }], writablePodId: 'owned-pod',
} });
if (url === '/api/registry/pods/owned-pod/agents') return Promise.resolve({ data: { agents: [{
name: 'local', instanceId: 'default', runtime: { runtimeType: 'wrapper' },
@@ -42,9 +58,11 @@ it('loads connection state from an installer-owned pod', async () => {
}] } });
return Promise.reject(new Error('Unexpected request'));
});
- render( );
+ render( );
expect(await screen.findByRole('link', { name: 'Reconnect' }))
.toHaveAttribute('href', '/v2/agents/byo?pod=owned-pod&name=local&instanceId=default&reconnect=1&mode=machine');
expect(api.get).toHaveBeenCalledWith('/api/registry/agents/local/manage-settings',
{ params: { instanceId: 'default' } });
+ expect(onConnectionState).toHaveBeenCalledWith('revoked');
});
diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
index 19afea33a2..b11e788b1b 100644
--- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
@@ -93,7 +93,7 @@ describe('V2AgentProfile memory-write visibility', () => {
viewerRole: 'owner', totalEntries: 0, sections: [], pods: [{ id: 'pod-1', name: 'Workspace' }],
} });
if (url.startsWith('/api/registry/agents/claude-code/manage-settings')) return Promise.resolve({ data: {
- installations: [{ podId: 'pod-1', podName: 'Workspace' }],
+ writablePodId: 'pod-1',
} });
if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{
name: 'claude-code', instanceId: 'observer', runtime: { runtimeType: 'webhook' },
@@ -117,7 +117,7 @@ describe('V2AgentProfile memory-write visibility', () => {
pods: [{ id: 'other-pod', name: 'Other team' }, { id: 'own-pod', name: 'Workspace' }],
} });
if (url.startsWith('/api/registry/agents/claude-code/manage-settings')) return Promise.resolve({ data: {
- installations: [{ podId: 'own-pod', podName: 'Workspace' }],
+ installations: [{ podId: 'other-pod', podName: 'Other team' }], writablePodId: 'own-pod',
} });
if (url === '/api/registry/pods/own-pod/agents') return Promise.resolve({ data: { agents: [{
name: 'claude-code', instanceId: 'observer', runtime: { runtimeType: 'wrapper' },
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index cd665bfc1a..ae5d3623b4 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -196,11 +196,11 @@ const V2AgentProfile: React.FC = () => {
}
let cancelled = false;
const headers = { Authorization: `Bearer ${token}` };
- getClient().get<{ installations?: Array<{ podId: string }> }>(
+ getClient().get<{ writablePodId?: string | null }>(
`/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings?instanceId=${encodeURIComponent(instanceId || 'default')}`,
{ headers },
).then(async (writable) => {
- const podId = writable.data.installations?.[0]?.podId;
+ const podId = writable.data.writablePodId;
if (!podId) return null;
const roster = await getClient().get<{ agents?: Array<{
name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
diff --git a/frontend/src/v2/components/V2AgentControlSection.tsx b/frontend/src/v2/components/V2AgentControlSection.tsx
index ffb82d5731..21c2ebd03c 100644
--- a/frontend/src/v2/components/V2AgentControlSection.tsx
+++ b/frontend/src/v2/components/V2AgentControlSection.tsx
@@ -17,14 +17,15 @@ interface Props {
const V2AgentControlSection: React.FC = ({ agentName, instanceId, podId, runtimeType, connection }) => {
const { t } = useTranslation();
const needsReconnect = connection.state === 'revoked' || connection.state === 'stale';
+ const canRegenerateMachineKey = runtimeType === 'wrapper' && connection.state === 'connected';
return (
{t('agentConnection.control')}
- {needsReconnect && (
+ {(needsReconnect || canRegenerateMachineKey) && (
- {t('agentConnection.reconnect')}
+ {t(needsReconnect ? 'agentConnection.reconnect' : 'agentSettings.regenerate')}
)}
@@ -38,7 +39,11 @@ export default V2AgentControlSection;
// Settings mounts after its server-gated access read. Resolve a writable pod
// rather than assuming the first pod in the public-facing activity list is
// this viewer's installation.
-export const AgentControlLoader: React.FC<{ agentName: string; instanceId: string }> = ({ agentName, instanceId }) => {
+export const AgentControlLoader: React.FC<{
+ agentName: string;
+ instanceId: string;
+ onConnectionState?: (state: AgentConnection['state']) => void;
+}> = ({ agentName, instanceId, onConnectionState }) => {
const { t } = useTranslation();
const [status, setStatus] = useState<(Props & { podId: string }) | null>(null);
const [unavailable, setUnavailable] = useState(false);
@@ -49,11 +54,11 @@ export const AgentControlLoader: React.FC<{ agentName: string; instanceId: strin
setUnavailable(false);
const load = async () => {
try {
- const manageable = await axios.get<{ installations?: Array<{ podId: string }> }>(
+ const manageable = await axios.get<{ writablePodId?: string | null }>(
`/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`,
{ params: { instanceId } },
);
- const podId = manageable.data.installations?.[0]?.podId;
+ const podId = manageable.data.writablePodId;
if (!podId) throw new Error('No manageable installation');
const roster = await axios.get<{ agents?: Array<{
name: string; instanceId: string; runtime?: { runtimeType?: string }; connection?: AgentConnection;
@@ -61,15 +66,18 @@ export const AgentControlLoader: React.FC<{ agentName: string; instanceId: strin
const match = roster.data.agents?.find((row) => row.name === agentName
&& row.instanceId === instanceId && row.connection);
if (!match?.connection) throw new Error('No connection status');
- if (active) setStatus({ agentName, instanceId, podId, runtimeType: match.runtime?.runtimeType,
- connection: match.connection });
+ if (active) {
+ setStatus({ agentName, instanceId, podId, runtimeType: match.runtime?.runtimeType,
+ connection: match.connection });
+ onConnectionState?.(match.connection.state);
+ }
} catch {
if (active) setUnavailable(true);
}
};
void load();
return () => { active = false; };
- }, [agentName, instanceId]);
+ }, [agentName, instanceId, onConnectionState]);
if (status) return ;
return
From 8119f15c53ce79b2044d86afbdd8d2ac5e2b5e2c Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:22:52 -0700
Subject: [PATCH 14/18] feat: mount connection Control in agent Settings
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../v2/__tests__/V2AgentSettingsPage.test.tsx | 42 +++++++++++++++++++
.../v2/__tests__/v2-layout-invariants.test.ts | 8 ++++
.../src/v2/agents/V2AgentSettingsPage.tsx | 14 ++++++-
frontend/src/v2/agents/v2-agent-settings.css | 19 +++++++++
4 files changed, 82 insertions(+), 1 deletion(-)
diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
index 171d78d9b3..01cc82cb9c 100644
--- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
@@ -60,6 +60,48 @@ test('wrapper key replacement cannot bypass the daemon reconnect flow', async ()
expect(axios.post).not.toHaveBeenCalled();
});
+test('revoked webhook key has one Reconnect action in Control', async () => {
+ axios.get.mockImplementation((url) => {
+ if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: {
+ displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }],
+ } });
+ if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } });
+ if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{
+ name: 'maya-claude', instanceId: 'default', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null },
+ }] } });
+ if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: {
+ name: 'maya-claude', instanceId: 'default', displayName: 'Maya', runtime: { runtimeType: 'webhook' },
+ } } });
+ return Promise.resolve({ data: [] });
+ });
+ renderPage();
+ expect(await screen.findByRole('link', { name: 'Reconnect' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&instanceId=default&reconnect=1');
+ expect(screen.queryByRole('button', { name: 'Regenerate key' })).toBeNull();
+});
+
+test('connected nondefault wrapper regenerates from Control without a second Key action', async () => {
+ axios.get.mockImplementation((url) => {
+ if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: {
+ displayName: 'Maya', pods: [{ id: 'pod-1', name: 'Workspace' }],
+ } });
+ if (url.endsWith('/manage-settings')) return Promise.resolve({ data: { writablePodId: 'pod-1' } });
+ if (url === '/api/registry/pods/pod-1/agents') return Promise.resolve({ data: { agents: [{
+ name: 'maya-claude', instanceId: 'second', runtime: { runtimeType: 'wrapper' },
+ connection: { state: 'connected', lastSeenAt: new Date().toISOString() },
+ }] } });
+ if (url.startsWith('/api/registry/pods/')) return Promise.resolve({ data: { agent: {
+ name: 'maya-claude', instanceId: 'second', displayName: 'Maya', runtime: { runtimeType: 'wrapper' },
+ } } });
+ return Promise.resolve({ data: [] });
+ });
+ renderPage('/v2/agents/maya-claude/second');
+ expect(await screen.findByRole('link', { name: 'Regenerate key' }))
+ .toHaveAttribute('href', '/v2/agents/byo?pod=pod-1&name=maya-claude&instanceId=second&reconnect=1&mode=machine');
+ expect(screen.queryByRole('button', { name: 'Regenerate key' })).toBeNull();
+});
+
test('nondefault wrapper instances never link to a default-instance reconnect', async () => {
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/agent-memory/')) return Promise.resolve({ data: {
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 9cae0aabb6..a0d2b394e0 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -192,6 +192,14 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(ruleBody(agentSettings, '.v2-root .v2-agent-settings .v2-agent-settings__section-head button')).toContain('background: var(--v2-ink)');
});
+ test('agent Control reconnect remains an ink action with a focus edge', () => {
+ const action = ruleBody(agentSettings, '.v2-root .v2-agent-settings a.v2-agent-settings__action');
+ expect(action).toContain('background: var(--v2-ink)');
+ expect(action).toContain('color: var(--v2-on-ink)');
+ expect(ruleBody(agentSettings, '.v2-root .v2-agent-settings a.v2-agent-settings__action:focus-visible'))
+ .toContain('box-shadow: var(--v2-focus-ring)');
+ });
+
test('BYO hints do not add paragraph margins to flex gaps', () => {
expect(ruleBody(v2, '.v2-byo__hint')).toContain('margin: 0');
});
diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx
index cbaa6feed0..2b554961b9 100644
--- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx
+++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx
@@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next';
import axios from '../../utils/axiosConfig';
import V2PageHead from '../components/V2PageHead';
import V2AgentRuntimeGrants, { Runtime } from '../components/V2AgentRuntimeGrants';
+import { AgentControlLoader } from '../components/V2AgentControlSection';
+import { AgentConnection } from '../utils/agentConnection';
import { useAgentSettings } from './useAgentSettings';
import V2AgentWakePods from './V2AgentWakePods';
import './v2-agent-settings.css';
@@ -40,9 +42,16 @@ const V2AgentSettingsPage: React.FC = () => {
const [pendingAction, setPendingAction] = useState<'replace' | string | null>(null);
const [busy, setBusy] = useState(false);
const [oneTimeKey, setOneTimeKey] = useState(null);
+ const [connectionState, setConnectionState] = useState(null);
const identityKey = `${agentName}:${instanceId}:${podId || ''}`;
const currentIdentity = useRef(identityKey);
currentIdentity.current = identityKey;
+ const disconnected = connectionState === 'revoked' || connectionState === 'stale';
+
+ const onConnectionState = useCallback((state: AgentConnection['state']) => {
+ setConnectionState(state);
+ if (state === 'revoked' || state === 'stale') setPendingAction(null);
+ }, []);
const keyUrl = useCallback((podId: string) => (
`/api/registry/pods/${encodeURIComponent(podId)}/agents/${encodeURIComponent(agentName)}/runtime-tokens`
@@ -63,6 +72,7 @@ const V2AgentSettingsPage: React.FC = () => {
setOneTimeKey(null);
setPendingAction(null);
setBusy(false);
+ setConnectionState(null);
if (access && podId) {
void fetchKeys().then((rows) => {
if (active) {
@@ -156,12 +166,14 @@ const V2AgentSettingsPage: React.FC = () => {
{t('agentSettings.profile')}
{t('agentSettings.settings')}
+
{t('agentSettings.key')}
{installation?.runtime?.runtimeType === 'wrapper' ? (
{t('agentSettings.wrapperReconnect')}
- ) : (
+ ) : !disconnected && (
setPendingAction('replace')} disabled={busy}>
{t('agentSettings.regenerate')}
diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css
index 42b702a7e0..c44dbc01b0 100644
--- a/frontend/src/v2/agents/v2-agent-settings.css
+++ b/frontend/src/v2/agents/v2-agent-settings.css
@@ -47,6 +47,25 @@
.v2-root .v2-agent-settings .v2-agent-settings__actions button:first-child { background: var(--v2-ink); color: var(--v2-on-ink); border-color: var(--v2-ink); }
.v2-root .v2-agent-settings .v2-agent-settings__section-head button:hover,
.v2-root .v2-agent-settings .v2-agent-settings__actions button:first-child:hover { background: var(--v2-ink-hover); }
+.v2-root .v2-agent-settings a.v2-agent-settings__action {
+ display: inline-flex;
+ align-items: center;
+ min-height: 34px;
+ padding: 6px 12px;
+ border: 1px solid var(--v2-ink);
+ border-radius: var(--v2-radius);
+ background: var(--v2-ink);
+ color: var(--v2-on-ink);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ text-decoration: none;
+}
+.v2-root .v2-agent-settings a.v2-agent-settings__action:hover { background: var(--v2-ink-hover); }
+.v2-root .v2-agent-settings a.v2-agent-settings__action:focus-visible {
+ box-shadow: var(--v2-focus-ring);
+ outline: 1px solid var(--v2-accent);
+}
.v2-agent-settings__error { color: var(--v2-danger); }
.v2-agent-settings__empty { margin-top: 24px; color: var(--v2-text-secondary); }
.v2-agent-settings__keys { list-style: none; padding: 0; margin: 20px 0 0; }
From 8876970fe7107829f794b223d1cdbd038e0f531d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:35:31 -0700
Subject: [PATCH 15/18] fix: require agent key before showing computer
connection
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../unit/services/agentConnectionStateService.test.js | 6 ++++++
backend/services/agentConnectionStateService.ts | 5 +++++
2 files changed, 11 insertions(+)
diff --git a/backend/__tests__/unit/services/agentConnectionStateService.test.js b/backend/__tests__/unit/services/agentConnectionStateService.test.js
index 5397cf164e..202f528105 100644
--- a/backend/__tests__/unit/services/agentConnectionStateService.test.js
+++ b/backend/__tests__/unit/services/agentConnectionStateService.test.js
@@ -34,6 +34,12 @@ describe('agent connection verdict', () => {
.toBe('revoked');
});
+ it('does not call a live daemon an agent connection before a runtime key exists', () => {
+ expect(deriveAgentConnection({ runtimeType: 'wrapper', credentials: [],
+ machineCredentials: [{ status: 'active' }], machineLastSeenAt: seen(0), now }).state)
+ .toBe('never');
+ });
+
it('preserves pre-ledger embedded tokens', () => {
expect(deriveAgentConnection({ runtimeType: 'webhook', credentials: [], embeddedTokens: [
{ lastUsedAt: seen(2) },
diff --git a/backend/services/agentConnectionStateService.ts b/backend/services/agentConnectionStateService.ts
index 63c57252d5..be043756f9 100644
--- a/backend/services/agentConnectionStateService.ts
+++ b/backend/services/agentConnectionStateService.ts
@@ -30,6 +30,11 @@ export const deriveAgentConnection = ({
&& machineCredentials.some((credential) => credential.status === 'revoked')) {
return { state: 'revoked', lastSeenAt: null };
}
+ // A live daemon heartbeat only proves that the computer is online. The
+ // agent itself is not connected until it holds a usable runtime key.
+ if (isMachine && !active.length && !hasEmbedded) {
+ return { state: 'never', lastSeenAt: null };
+ }
const dates = isMachine
? [machineLastSeenAt]
: [...active, ...embeddedTokens].map((credential) => credential.lastUsedAt);
From b28930fa2949ad3fc2a06b67e338320bd9f64c36 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:50:43 -0700
Subject: [PATCH 16/18] chore(cli): reserve version 0.1.134 for reconnect
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
cli/package-lock.json | 4 ++--
cli/package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/cli/package-lock.json b/cli/package-lock.json
index f7f87ac264..0fede37d39 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@commonlyai/cli",
- "version": "0.1.133",
+ "version": "0.1.134",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@commonlyai/cli",
- "version": "0.1.133",
+ "version": "0.1.134",
"license": "Apache-2.0",
"dependencies": {
"commander": "^12.0.0"
diff --git a/cli/package.json b/cli/package.json
index 1c4ad89f7b..9feb56db5d 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@commonlyai/cli",
- "version": "0.1.133",
+ "version": "0.1.134",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"type": "module",
From 935e2b36feb30c7591f9d2043b509e9618805b1d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:53:47 -0700
Subject: [PATCH 17/18] fix: align agent reconnect chrome and key guidance
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
frontend/src/i18n/locales/en.json | 2 +-
frontend/src/i18n/locales/zh-CN.json | 2 +-
frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx | 4 ++--
frontend/src/v2/__tests__/v2-layout-invariants.test.ts | 9 +++++++++
frontend/src/v2/agents/V2AgentProfile.tsx | 2 +-
frontend/src/v2/agents/v2-agent-profile.css | 2 ++
6 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 52d35456ba..b7f9c85f0e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2335,7 +2335,7 @@
"settings": "Settings",
"key": "Key",
"regenerate": "Regenerate key",
- "wrapperReconnect": "Key replacement is unavailable here for locally connected agents.",
+ "wrapperReconnect": "Replace this computer's key from Control.",
"copyNow": "Copy this key now. It will not be shown again.",
"done": "Done",
"empty": "No runtime keys yet.",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 30000f5912..4d81b7f885 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2326,7 +2326,7 @@
"settings": "设置",
"key": "密钥",
"regenerate": "重新生成密钥",
- "wrapperReconnect": "此处暂不支持更换本地连接的智能体密钥。",
+ "wrapperReconnect": "请在「控制」中替换这台电脑的密钥。",
"copyNow": "请立即复制此密钥。之后不会再次显示。",
"done": "完成",
"empty": "暂无运行时密钥。",
diff --git a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
index 01cc82cb9c..916104db8b 100644
--- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
@@ -54,7 +54,7 @@ test('wrapper key replacement cannot bypass the daemon reconnect flow', async ()
return Promise.resolve({ data: [] });
});
renderPage();
- await screen.findByText('Key replacement is unavailable here for locally connected agents.');
+ await screen.findByText("Replace this computer's key from Control.");
expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument();
expect(axios.post).not.toHaveBeenCalled();
@@ -113,7 +113,7 @@ test('nondefault wrapper instances never link to a default-instance reconnect',
return Promise.resolve({ data: [] });
});
renderPage('/v2/agents/maya-claude/second');
- await screen.findByText('Key replacement is unavailable here for locally connected agents.');
+ await screen.findByText("Replace this computer's key from Control.");
expect(screen.queryByRole('link', { name: 'Reconnect to regenerate key' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Regenerate key' })).not.toBeInTheDocument();
});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index a0d2b394e0..34cec8f189 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -200,6 +200,15 @@ describe('v2 layout invariants (CSS rule presence)', () => {
.toContain('box-shadow: var(--v2-focus-ring)');
});
+ test('agent profile Reconnect keeps ink chrome over the primary and link rules', () => {
+ const profile = read('../agents/v2-agent-profile.css');
+ const reconnect = ruleBody(profile, '.v2-root a.v2-aprofile__btn--reconnect');
+ expect(reconnect).toContain('background: var(--v2-ink)');
+ expect(reconnect).toContain('color: var(--v2-on-ink)');
+ expect(ruleBody(profile, '.v2-root a.v2-aprofile__btn--reconnect:hover'))
+ .toContain('background: var(--v2-ink-hover)');
+ });
+
test('BYO hints do not add paragraph margins to flex gaps', () => {
expect(ruleBody(v2, '.v2-byo__hint')).toContain('margin: 0');
});
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index ae5d3623b4..f99d34a027 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -403,7 +403,7 @@ const V2AgentProfile: React.FC = () => {
)}
{ownedConnection && ['revoked', 'stale'].includes(ownedConnection.connection.state) ? (
-
{t('agentConnection.reconnect')}
diff --git a/frontend/src/v2/agents/v2-agent-profile.css b/frontend/src/v2/agents/v2-agent-profile.css
index 03fdb65dc2..3b7e06cbf0 100644
--- a/frontend/src/v2/agents/v2-agent-profile.css
+++ b/frontend/src/v2/agents/v2-agent-profile.css
@@ -58,6 +58,8 @@
/* `.v2-root a { color: inherit }` (0,1,1) beats the single-class rules — re-assert. */
.v2-root .v2-aprofile__btn--primary { color: #ffffff; }
.v2-root .v2-aprofile__btn--ghost { color: var(--v2-text-primary); }
+.v2-root a.v2-aprofile__btn--reconnect { background: var(--v2-ink); border-color: var(--v2-ink); color: var(--v2-on-ink); }
+.v2-root a.v2-aprofile__btn--reconnect:hover { background: var(--v2-ink-hover); border-color: var(--v2-ink-hover); }
.v2-root button.v2-aprofile__btn:disabled { cursor: wait; opacity: 0.72; }
.v2-aprofile__room-error { margin: 12px 0 0; color: var(--v2-danger); font-size: 14px; }
From b3d418cf8b15a99503e5576295bc4ae937fdc6bc Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:56:31 -0700
Subject: [PATCH 18/18] Pin reconnect instance and browser admin authorization
Co-authored-by: rivet
Agent-Model: codex/gpt-6-sol/medium
---
.../service/agentReconnectBrowserJwt.test.js | 67 +++++++++++++++++++
.../src/v2/__tests__/V2AgentBYOName.test.tsx | 9 +++
frontend/src/v2/components/V2AgentBYO.tsx | 2 +-
3 files changed, 77 insertions(+), 1 deletion(-)
create mode 100644 backend/__tests__/service/agentReconnectBrowserJwt.test.js
diff --git a/backend/__tests__/service/agentReconnectBrowserJwt.test.js b/backend/__tests__/service/agentReconnectBrowserJwt.test.js
new file mode 100644
index 0000000000..9b9bbdbbd9
--- /dev/null
+++ b/backend/__tests__/service/agentReconnectBrowserJwt.test.js
@@ -0,0 +1,67 @@
+const request = require('supertest');
+const express = require('express');
+const User = require('../../models/User');
+const Machine = require('../../models/Machine');
+const AgentCredential = require('../../models/AgentCredential');
+const agentBindingRoutes = require('../../routes/agentBinding');
+const { hash } = require('../../utils/secret');
+const {
+ setupMongoDb, closeMongoDb, clearMongoDb, generateTestToken,
+} = require('../utils/testUtils');
+
+// Exercise the browser JWT through the real auth middleware. A fabricated
+// req.user would miss a dropped role field and make the admin path look green.
+describe('agent reconnect with a browser JWT', () => {
+ let app;
+ let seq = 0;
+
+ beforeAll(async () => {
+ await setupMongoDb();
+ process.env.JWT_SECRET = 'agent-reconnect-browser-jwt-test-secret';
+ app = express();
+ app.use(express.json());
+ app.use('/api/agent-binding', agentBindingRoutes);
+ });
+
+ afterAll(async () => { await closeMongoDb(); });
+ afterEach(async () => { await clearMongoDb(); });
+
+ const user = async (role, isBot = false) => {
+ seq += 1;
+ return User.create({
+ username: `reconnect-jwt-${seq}`,
+ email: `reconnect-jwt-${seq}@example.com`,
+ password: 'hashed-pass',
+ role,
+ isBot,
+ ...(isBot ? { botMetadata: { agentName: 'local', instanceId: 'alpha', machineId: 'machine-owner' } } : {}),
+ });
+ };
+
+ it('allows an admin to request a bound agent rekey and refuses a non-owner', async () => {
+ const owner = await user('user');
+ const admin = await user('admin');
+ const stranger = await user('user');
+ const bot = await user('user', true);
+ await Machine.create({ ownerUserId: owner._id, machineId: 'machine-owner', name: 'Owner computer' });
+ await AgentCredential.create({
+ tokenHash: hash(`cm_daemon_${'a'.repeat(32)}`), kind: 'daemon', ownerUserId: owner._id,
+ machineId: 'machine-owner', scopes: ['agents:adopt'],
+ });
+
+ const call = (actor) => request(app)
+ .post('/api/agent-binding/reconnect')
+ .set('Authorization', `Bearer ${generateTestToken(String(actor._id))}`)
+ .send({ agentName: 'local', instanceId: 'alpha' });
+
+ const allowed = await call(admin);
+ expect(allowed.status).toBe(200);
+ expect(allowed.body).toEqual(expect.objectContaining({
+ requested: true, agentName: 'local', instanceId: 'alpha', machineId: 'machine-owner',
+ }));
+ expect((await User.findById(bot._id)).botMetadata.reconnectRequestedAt).toBeTruthy();
+
+ const denied = await call(stranger);
+ expect(denied.status).toBe(403);
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
index 6e900cd349..bdbd6d5597 100644
--- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx
@@ -111,6 +111,15 @@ describe('BYO default agent name follows where it runs', () => {
));
});
+ test('ordinary BYO does not offer a nondefault instance as a default reconnect', async () => {
+ mockGet([{ name: 'local', instanceId: 'alpha', installedBy: 'u1', runtime: { runtimeType: 'webhook' },
+ connection: { state: 'revoked', lastSeenAt: null } }]);
+ renderAt('/v2/agents/byo?pod=p1&name=local&from=card');
+ await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/registry/pods/p1/agents', expect.anything()));
+ expect(screen.queryByTestId('byo-connection-state')).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull();
+ });
+
test('compact BYO never offers a non-owner a forced key rotation', async () => {
mockGet([{ name: 'local', installedBy: 'someone-else', runtime: { runtimeType: 'webhook' },
connection: { state: 'revoked', lastSeenAt: null } }]);
diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx
index daaa5017fc..5d34d92787 100644
--- a/frontend/src/v2/components/V2AgentBYO.tsx
+++ b/frontend/src/v2/components/V2AgentBYO.tsx
@@ -282,7 +282,7 @@ const V2AgentBYO: React.FC = () => {
.then((result) => {
if (cancelled) return;
const match = result.agents?.find((agent) => agent.name === sanitizeAgentName(name)
- && (!prefill.reconnect || agent.instanceId === prefill.instanceId) && agent.connection);
+ && agent.instanceId === prefill.instanceId && agent.connection);
setExistingConnection(match?.connection ? {
connection: match.connection,
runtimeType: match.runtime?.runtimeType || '',