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/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index 6b257568e2..8eb16eadf3 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' }, @@ -102,6 +103,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 +215,63 @@ 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(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(201); + 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'); + }); + + 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()); + global.__CALLER_ROLE = 'admin'; + expect((await requestReconnect()).status).toBe(200); + expect((await assigned(DAEMON_A)).body.agents[0].reconnectRequestedAt).toBeTruthy(); + }); +}); + 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 ac51764e48..75a49558ef 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,40 @@ 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('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 new file mode 100644 index 0000000000..202f528105 --- /dev/null +++ b/backend/__tests__/unit/services/agentConnectionStateService.test.js @@ -0,0 +1,48 @@ +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' }, + ], 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('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) }, + ], now }).state).toBe('connected'); + }); +}); 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..e06dda0046 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,52 @@ 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; 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 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(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: 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.', + code: 'machine_key_revoked', + }); + const requestedAt = new Date(); + 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); + 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 +333,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 +367,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 1086b9f2e2..521ffdd568 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,20 @@ 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: credentialByAgent.get(String(user._id)) || [], + machineCredentials: credentialByMachine.get(String(machineId)) || [], + embeddedTokens: [...(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..be043756f9 --- /dev/null +++ b/backend/services/agentConnectionStateService.ts @@ -0,0 +1,50 @@ +// 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(), + machineCredentials = [], +}: { + runtimeType?: string; + credentials: Credential[]; + machineCredentials?: 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)); + 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 }; + } + // 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); + 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/cli/__tests__/daemon-supervisor.test.mjs b/cli/__tests__/daemon-supervisor.test.mjs index 85eaccf569..18f97106c5 100644 --- a/cli/__tests__/daemon-supervisor.test.mjs +++ b/cli/__tests__/daemon-supervisor.test.mjs @@ -79,6 +79,43 @@ 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('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/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", diff --git a/cli/src/lib/daemon-supervisor.js b/cli/src/lib/daemon-supervisor.js index 09b39c5e88..7553ed7cd0 100644 --- a/cli/src/lib/daemon-supervisor.js +++ b/cli/src/lib/daemon-supervisor.js @@ -208,6 +208,37 @@ 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. + 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 d9be75cbae..b7f9c85f0e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1360,6 +1360,20 @@ "dsh": "DeepSeek Harness" } }, + "agentConnection": { + "control": "Control", + "connected": "Connected", + "revoked": "Disconnected: key revoked", + "stale": "Not seen in {{count}} min", + "never": "Not connected yet", + "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.", + "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", "description": "A running agent answers @mentions in its pod.", @@ -2321,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 5435a68dd4..4d81b7f885 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1420,6 +1420,20 @@ "license": "许可证(Apache-2.0)" } }, + "agentConnection": { + "control": "控制", + "connected": "已连接", + "revoked": "密钥已撤销,连接已断开", + "stale": "已{{count}}分钟未连接", + "never": "尚未连接", + "reconnect": "重新连接", + "reconnectWaiting": "正在等待电脑重新连接此智能体。", + "reconnectDone": "已连接。", + "reconnectSlow": "电脑尚未重新连接。请检查守护进程是否在运行。", + "machineKeyRevoked": "这台电脑的密钥已撤销。请重新注册电脑,再连接智能体。", + "checking": "正在检查连接…", + "unavailable": "暂时无法获取连接状态。" + }, "agentByo": { "title": "接入你自己的智能体", "description": "智能体运行期间会在所在 Pod 回复 @提及。", @@ -2312,7 +2326,7 @@ "settings": "设置", "key": "密钥", "regenerate": "重新生成密钥", - "wrapperReconnect": "此处暂不支持更换本地连接的智能体密钥。", + "wrapperReconnect": "请在「控制」中替换这台电脑的密钥。", "copyNow": "请立即复制此密钥。之后不会再次显示。", "done": "完成", "empty": "暂无运行时密钥。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx index ba5f6c9e65..bdbd6d5597 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx @@ -45,8 +45,11 @@ 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.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 } } }); @@ -76,6 +79,92 @@ 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'), { 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' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/registry/pods/p1/agents/local/runtime-tokens', + { label: 'BYO MCP — replacement', force: true, instanceId: 'default' }, expect.anything(), + )); + }); + + 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('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 } }]); + renderAt('/v2/agents/byo?pod=p1&name=local&from=card'); + await waitFor(() => expect(screen.getByTestId('byo-connection-state')) + .toHaveTextContent('Disconnected: key revoked'), { timeout: 5000 }); + 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' }, { timeout: 5000 }); + 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('a Settings key link can proactively rekey a connected wrapper', async () => { + 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&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: 'alpha' }, 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 } }]); + 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/__tests__/V2AgentControlSection.test.tsx b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx new file mode 100644 index 0000000000..9a8a180e5c --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentControlSection.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +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( + + , +); + +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&instanceId=default&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(); +}); + +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: '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' }, + 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&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 41ac67da46..b11e788b1b 100644 --- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx @@ -86,6 +86,63 @@ 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.startsWith('/api/registry/agents/claude-code/manage-settings')) return Promise.resolve({ data: { + writablePodId: 'pod-1', + } }); + 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&instanceId=observer&reconnect=1'); + 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: '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' }, + 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&instanceId=observer&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(); + 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__/V2AgentSettingsPage.test.tsx b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx index 171d78d9b3..916104db8b 100644 --- a/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx @@ -54,12 +54,54 @@ 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(); }); +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: { @@ -71,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__/V2YourTeamSignal.test.tsx b/frontend/src/v2/__tests__/V2YourTeamSignal.test.tsx index 752d4ddff2..b1c60a30e9 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,45 @@ 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(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&instanceId=default&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/__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/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 9cae0aabb6..34cec8f189 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -192,6 +192,23 @@ 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('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 eef443167f..f99d34a027 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,37 @@ 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 token = localStorage.getItem('token'); + if (!token || !agentName || !['owner', 'admin'].includes(memIndex?.viewerRole || '')) { + setOwnedConnection(null); + return undefined; + } + let cancelled = false; + const headers = { Authorization: `Bearer ${token}` }; + getClient().get<{ writablePodId?: string | null }>( + `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings?instanceId=${encodeURIComponent(instanceId || 'default')}`, + { headers }, + ).then(async (writable) => { + 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; + }> }>(`/api/registry/pods/${encodeURIComponent(podId)}/agents`, { headers }); + return { podId, roster }; + }).then((result) => { + if (cancelled) return; + const match = result?.roster.data.agents?.find((row) => row.name === agentName + && row.instanceId === (instanceId || 'default') && row.connection); + setOwnedConnection(match?.connection ? { + connection: match.connection, podId: result!.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 +387,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 +402,12 @@ const V2AgentProfile: React.FC = () => {
)} - {authed ? ( + {ownedConnection && ['revoked', 'stale'].includes(ownedConnection.connection.state) ? ( + + {t('agentConnection.reconnect')} + + ) : authed ? ( 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; } 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; } diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index b5c0fb8c9a..5d34d92787 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 @@ -127,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. @@ -135,6 +137,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 +170,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 +211,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 +263,36 @@ 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) + || (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'); + // 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; 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.instanceId === prefill.instanceId && 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, 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); @@ -267,6 +305,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,9 +322,40 @@ 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'); + 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; instanceId: string; connection?: AgentConnection }> }>( + `/api/registry/pods/${encodeURIComponent(podId)}/agents`, + ); + if (cancelled) return; + 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' }); + 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, 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. @@ -547,6 +620,24 @@ 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: prefill.instanceId, + }); + setReconnectRequest({ agentName: cleanName, requestedAt: response.requestedAt, state: 'waiting' }); + } catch (err) { + 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); + } + }; + const submit = async () => { setError(null); setErrorField(null); @@ -624,15 +715,15 @@ 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 }, + { label: 'BYO MCP — replacement', force: true, instanceId: prefill.instanceId }, ); if (!tokenRes?.token) { setError(t('agentByo.errors.tokenEmpty')); @@ -879,7 +970,7 @@ const V2AgentBYO: React.FC = () => { )} - {showCompactByo && !issued && !hosted && !placed && ( + {showCompactByo && !issued && !hosted && !placed && !reconnectRequest && (
{pods.some((p) => p._id === podId) && (

@@ -894,18 +985,29 @@ const V2AgentBYO: React.FC = () => {

)} {error &&
{error}
} + {existingConnection && ( +
+ {connectionLabel(existingConnection.connection, t)} +
+ )} {nameLookupError && mode === 'byo' &&
{t('agentByo.errors.nameLookupFailed')}
} {/* 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. */} - + {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) && ( <> @@ -1086,6 +1188,11 @@ const V2AgentBYO: React.FC = () => { 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')}
} {mode === null ? ( // Signal §3 not yet: a dashed slot where the act will be, until a @@ -1093,7 +1200,14 @@ const V2AgentBYO: React.FC = () => {
{t('agentByo.actions.chooseFirst')}
- ) : ( + ) : canReconnect ? ( + + ) : 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..21c2ebd03c --- /dev/null +++ b/frontend/src/v2/components/V2AgentControlSection.tsx @@ -0,0 +1,89 @@ +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 { + agentName: string; + instanceId: 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, 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 || canRegenerateMachineKey) && ( + + {t(needsReconnect ? 'agentConnection.reconnect' : 'agentSettings.regenerate')} + + )} +
+

{connectionLabel(connection, t)}

+
+ ); +}; + +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; + 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); + + useEffect(() => { + let active = true; + setStatus(null); + setUnavailable(false); + const load = async () => { + try { + const manageable = await axios.get<{ writablePodId?: string | null }>( + `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`, + { params: { instanceId } }, + ); + 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; + }> }>(`/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, 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, onConnectionState]); + + if (status) return ; + return
+

{t('agentConnection.control')}

+

+ {t(unavailable ? 'agentConnection.unavailable' : 'agentConnection.checking')} +

+
; +}; diff --git a/frontend/src/v2/components/V2YourTeamPage.tsx b/frontend/src/v2/components/V2YourTeamPage.tsx index 4e9a15b25e..43692f6837 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,11 @@ 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 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(/ · $/, '') : state === 'working' ? `${t('yourTeam.card.working')} · ${task?.taskId || shortPodName(task?.podName || a.podName || '')}`.replace(/ · $/, '') @@ -334,14 +341,14 @@ const V2YourTeamPage: React.FC = () => { return (
{display}
-
- {state !== 'idle' && } - {state !== 'idle' ? ' ' : ''} +
+ {(!a.connection || a.connection.state === 'connected') && state !== 'idle' && } + {(!a.connection || a.connection.state === 'connected') && state !== 'idle' ? ' ' : ''} {statusText}
@@ -365,12 +372,17 @@ const V2YourTeamPage: React.FC = () => {
)}
- {state === 'needsYou' && ( + {needsReconnect && (a.installedBy === String(user?._id) || user?.role === 'admin') ? ( + + ) : null} + {!needsReconnect && state === 'needsYou' && ( )} - + }
); 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 }); +};