Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
14cd0c6
Show BYO agent connection status and reconnect action
lilyshen0722 Oct 23, 2026
f192fab
Show private connection state on agent profile
lilyshen0722 Oct 23, 2026
44e8d7c
Repair BYO reconnect and add Settings control section
lilyshen0722 Oct 23, 2026
5772dcb
Stabilize reconnect regression tests
lilyshen0722 Oct 23, 2026
e10051d
Honor admin reconnect and owner-scoped pod target
lilyshen0722 Oct 23, 2026
1b54938
Localize revoked computer reconnect guidance
lilyshen0722 Oct 23, 2026
975e72a
Load Settings control from manageable installation
lilyshen0722 Oct 23, 2026
f2c9fef
Support proactive wrapper rekey from Settings
lilyshen0722 Oct 23, 2026
5ca7e89
Preserve instance identity in reconnect flows
lilyshen0722 Oct 23, 2026
2d4bc82
Guard same-name daemon instance tokens
lilyshen0722 Oct 23, 2026
87709a1
test: preserve BYO instance on webhook key replacement
lilyshen0722 Oct 23, 2026
2474314
fix: refuse reconnect through expired daemon key
lilyshen0722 Oct 23, 2026
4d38e91
feat: target writable instance for Control recovery
lilyshen0722 Oct 23, 2026
8119f15
feat: mount connection Control in agent Settings
lilyshen0722 Oct 23, 2026
8876970
fix: require agent key before showing computer connection
lilyshen0722 Oct 23, 2026
b28930f
chore(cli): reserve version 0.1.134 for reconnect
lilyshen0722 Oct 23, 2026
935e2b3
fix: align agent reconnect chrome and key guidance
lilyshen0722 Oct 23, 2026
b3d418c
Pin reconnect instance and browser admin authorization
lilyshen0722 Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions backend/__tests__/service/agentReconnectBrowserJwt.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
64 changes: 63 additions & 1 deletion backend/__tests__/unit/routes/agentBinding.phase2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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');
Expand Down
41 changes: 41 additions & 0 deletions backend/__tests__/unit/routes/registry.pod-agents-activity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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([]),
Expand All @@ -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)', () => {
Expand All @@ -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([
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
3 changes: 3 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -302,6 +304,7 @@ const userSchema = new Schema<IUser>({
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: {
Expand Down
54 changes: 53 additions & 1 deletion backend/routes/agentBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading