From b74849fa552e36615e1d6fcf39c0ae55e2ea9c30 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:07:44 -0700 Subject: [PATCH 1/6] Offer agent move after machine revocation Co-authored-by: rivet Agent-Model: codex/gpt-6-sol/medium --- .../unit/routes/agentBinding.phase2.test.js | 22 ++++++ backend/routes/agentBinding.ts | 42 +++++++++++- frontend/src/i18n/locales/en.json | 11 ++- frontend/src/i18n/locales/zh-CN.json | 11 ++- .../__tests__/V2AgentRuntimeGrants.test.tsx | 19 +++++- .../v2/components/V2AgentRuntimeGrants.tsx | 67 +++++++++++++++++++ 6 files changed, 167 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index fa8af0092b..7faf46361e 100644 --- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js +++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js @@ -135,6 +135,28 @@ const runtimeAuthProbe = (token) => new Promise((resolve, reject) => { }); describe('placement request', () => { + it('lets only the sole installer view a revoked binding and request a live replacement after release', async () => { + await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); + await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } }); + const placement = () => request(app).get('/api/agent-binding/placement') + .query({ agentName: 'wren-test', instanceId: 'default' }); + + global.__CALLER_ID = String(new mongoose.Types.ObjectId()); + await placement().expect(403); + await request(app).post('/api/agent-binding/release') + .send({ agentName: 'wren-test', instanceId: 'default' }).expect(403); + global.__CALLER_ID = String(owner._id); + + const state = await placement().expect(200); + expect(state.body).toMatchObject({ boundMachineId: 'machine-a', boundMachineRevoked: true, + activeMachines: [{ machineId: 'machine-b', name: 'Mac B' }] }); + await requestPlacement('machine-a').expect(409); + await request(app).post('/api/agent-binding/release') + .send({ agentName: 'wren-test', instanceId: 'default' }).expect(200); + await requestPlacement('machine-b').expect(200); + expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' }); + }); + it('records a directive without binding, and null withdraws it', async () => { const res = await requestPlacement('machine-a'); expect(res.status).toBe(200); diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index 4ec38fefb3..2d752b0d9f 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -154,6 +154,40 @@ const ownershipFailureMessage: Record = { unknown_installer: 'An active legacy installation has no recorded installer', }; +// Owner-only view for Settings. A removed machine can still be named by the +// identity binding; keep that fact visible while offering only live targets. +router.get('/placement', bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => { + try { + const agentName = normalize(req.query.agentName); + const instanceId = normalize(req.query.instanceId) || 'default'; + if (!agentName) return res.status(400).json({ message: 'agentName required' }); + const ownership = await ownsAgent(req.user?.id, agentName, instanceId); + if (!ownership.owned) return res.status(403).json({ message: ownershipFailureMessage[ownership.failure], code: ownership.failure }); + const identity = await User.findOne({ + isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId, + }).select('botMetadata.machineId botMetadata.requestedMachineId').lean(); + if (!identity) return res.status(404).json({ message: 'Agent identity not found' }); + const machines = await Machine.find({ ownerUserId: req.user?.id }).select('machineId name').lean(); + const activeMachines = (await Promise.all(machines.map(async (machine: { machineId: string; name: string }) => ({ + machineId: machine.machineId, + name: machine.name, + active: Boolean(await AgentCredential.exists({ kind: 'daemon', ownerUserId: req.user?.id, + machineId: machine.machineId, status: 'active' })) + && await machineHasLiveAuthorization(machine.machineId, req.user!.id!), + })))).filter((machine) => machine.active).map(({ machineId, name }) => ({ machineId, name })); + const boundMachineId = identity.botMetadata?.machineId || null; + return res.json({ + boundMachineId, + requestedMachineId: identity.botMetadata?.requestedMachineId || null, + boundMachineRevoked: Boolean(boundMachineId && !activeMachines.some((machine) => machine.machineId === boundMachineId)), + activeMachines, + }); + } catch (err) { + console.error('placement status error:', err); + return res.status(500).json({ message: 'Server error' }); + } +}); + router.post('/adopt', bindingRateLimit, daemonAuth('agents:adopt'), async (req: DaemonAuthedRequest, res: express.Response) => { try { const agentName = normalize(req.body?.agentName); @@ -238,10 +272,14 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr } if (requestedMachineId) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const Machine = require('../models/Machine'); const machine = await Machine.findOne({ machineId: requestedMachineId, ownerUserId: req.user?.id }).select('_id').lean(); if (!machine) return res.status(404).json({ message: 'Machine not found' }); + const daemonActive = await AgentCredential.exists({ + kind: 'daemon', machineId: requestedMachineId, ownerUserId: req.user?.id, status: 'active', + }); + if (!daemonActive || !await machineHasLiveAuthorization(requestedMachineId, req.user!.id!)) { + return res.status(409).json({ message: 'Machine access revoked' }); + } } const identity = await User.findOne({ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5824c870cb..0273605e3b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2316,7 +2316,16 @@ "changeEffort": "Effort", "effortUnset": "Choose effort", "save": "Save runtime", - "saveError": "Could not save the model." + "saveError": "Could not save the model.", + "machineRevoked": "This computer was revoked. Choose another computer to run this agent.", + "currentMachine": "Running on {{name}}", + "machineRequested": "Waiting for {{name}} to start this agent", + "moveLabel": "Run on", + "move": "Move agent", + "moving": "Moving…", + "noActiveMachine": "Register another computer to move this agent.", + "placementError": "Could not load this agent’s computer placement.", + "moveError": "Could not move this agent. Try again." }, "grants": { "title": "Connector grants", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index a74df2e5d7..dfb90d30e1 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2307,7 +2307,16 @@ "changeEffort": "推理强度", "effortUnset": "选择推理强度", "save": "保存运行设置", - "saveError": "无法保存模型。" + "saveError": "无法保存模型。", + "machineRevoked": "这台电脑的访问权限已撤销。请选择另一台电脑运行此智能体。", + "currentMachine": "正在 {{name}} 上运行", + "machineRequested": "等待 {{name}} 启动此智能体", + "moveLabel": "运行于", + "move": "迁移智能体", + "moving": "正在迁移…", + "noActiveMachine": "请先注册另一台电脑,再迁移此智能体。", + "placementError": "无法加载此智能体的电脑分配信息。", + "moveError": "无法迁移此智能体,请重试。" }, "grants": { "title": "连接器授权", diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index b6e44cf7a9..bb1c123ad1 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -35,7 +35,9 @@ const renderSection = (runtime = { runtimeType: 'wrapper', adapter: 'claude', mo beforeEach(() => { jest.clearAllMocks(); - axios.get.mockResolvedValue({ data: response }); + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } + : response })); axios.post.mockResolvedValue({ data: { revoked: 1 } }); saveRuntime.mockResolvedValue(undefined); }); @@ -48,6 +50,21 @@ test('runtime labels only recognize walked adapters and distinguish daemon from expect(describeRuntime({ runtimeType: 'webhook', host: 'byo' })).toEqual({ mode: 'unknown', adapter: null }); }); +test('revoked Runtime row offers an active computer and releases before requesting placement', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, + activeMachines: [{ machineId: 'new', name: 'New Mac' }] } : response })); + renderSection(); + expect(await screen.findByText('This computer was revoked. Choose another computer to run this agent.')).toBeInTheDocument(); + expect(screen.getByLabelText('Run on')).toHaveValue('new'); + fireEvent.click(screen.getByRole('button', { name: 'Move agent' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/agent-binding/request', + { agentName: 'nib', instanceId: 'default', machineId: 'new' }, expect.anything())); + expect(axios.post.mock.calls.slice(0, 2).map(([url]) => url)).toEqual([ + '/api/agent-binding/release', '/api/agent-binding/request', + ]); +}); + test('shows this agent grant and requires a second click to revoke through the existing route', async () => { renderSection(); expect(await screen.findByText('On a computer, via the daemon')).toBeInTheDocument(); diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index 4bf289789c..e043287efd 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -27,6 +27,13 @@ interface AgentGrantsResponse { grants: AgentGrant[]; } +interface Placement { + boundMachineId: string | null; + requestedMachineId: string | null; + boundMachineRevoked: boolean; + activeMachines: Array<{ machineId: string; name: string }>; +} + interface Props { name: string; instanceId: string; @@ -72,6 +79,18 @@ const V2AgentRuntimeGrants: React.FC = ({ const [modelDraft, setModelDraft] = useState(runtime.model || ''); const [effortDraft, setEffortDraft] = useState(runtime.effort || ''); const [confirmGrantId, setConfirmGrantId] = useState(null); + const [placement, setPlacement] = useState(null); + const [machineChoice, setMachineChoice] = useState(''); + const [placementError, setPlacementError] = useState(''); + const [moving, setMoving] = useState(false); + + const loadPlacement = useCallback(async () => { + const response = await api.get('/api/agent-binding/placement', { params: { agentName: name, instanceId } }); + const next = { ...response, activeMachines: response.activeMachines || [] }; + setPlacement(next); + setMachineChoice((current) => next.activeMachines.some((machine) => machine.machineId === current) + ? current : next.activeMachines.find((machine) => machine.machineId !== next.boundMachineId)?.machineId || ''); + }, [api, name, instanceId]); const load = useCallback(async () => { setLoading(true); @@ -89,6 +108,12 @@ const V2AgentRuntimeGrants: React.FC = ({ }, [api, name, instanceId, t]); useEffect(() => { void load(); }, [load]); + useEffect(() => { + if (runtime.runtimeType !== 'wrapper') return; + setPlacement(null); + setPlacementError(''); + void loadPlacement().catch(() => setPlacementError(t('agentManage.runtime.placementError'))); + }, [runtime.runtimeType, loadPlacement, t]); useEffect(() => setModelDraft(runtime.model || ''), [runtime.model]); useEffect(() => setEffortDraft(runtime.effort || ''), [runtime.effort]); @@ -139,6 +164,25 @@ const V2AgentRuntimeGrants: React.FC = ({ } }; + const moveAgent = async () => { + if (!placement || !machineChoice || moving) return; + setMoving(true); + setPlacementError(''); + try { + if (placement.boundMachineId) { + await api.post('/api/agent-binding/release', { agentName: name, instanceId }); + setPlacement((current) => current && { ...current, boundMachineId: null, boundMachineRevoked: false }); + } + await api.post('/api/agent-binding/request', { agentName: name, instanceId, machineId: machineChoice }); + await loadPlacement(); + } catch { + setPlacementError(t('agentManage.runtime.moveError')); + void loadPlacement().catch(() => {}); + } finally { + setMoving(false); + } + }; + return (
@@ -149,6 +193,29 @@ const V2AgentRuntimeGrants: React.FC = ({
{t('agentManage.runtime.model', { defaultValue: 'Model' })}
{model || t('agentManage.runtime.modelUnknown', { defaultValue: 'Set by the runtime' })}
{runtime.effort &&
{t('agentManage.runtime.effort', { defaultValue: 'Effort' })}
{runtime.effort}
} + {details.mode === 'daemon' && placement && ( +
+ {placement.boundMachineRevoked &&

{t('agentManage.runtime.machineRevoked')}

} + {!placement.boundMachineRevoked && placement.boundMachineId &&

{t('agentManage.runtime.currentMachine', { + name: placement.activeMachines.find((machine) => machine.machineId === placement.boundMachineId)?.name || placement.boundMachineId, + })}

} + {placement.requestedMachineId && !placement.boundMachineId &&

{t('agentManage.runtime.machineRequested', { + name: placement.activeMachines.find((machine) => machine.machineId === placement.requestedMachineId)?.name || placement.requestedMachineId, + })}

} + {(placement.boundMachineRevoked || !placement.boundMachineId) && ( + placement.activeMachines.length > 0 ? <> + + + + :

{t('agentManage.runtime.noActiveMachine')}

+ )} +
+ )} + {placementError &&

{placementError}

} {mayEditRuntime && (
From 4cdddfdc32da0948d13265a8c5befc3779a4daf5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:13:42 -0700 Subject: [PATCH 2/6] Keep agent DM projections out of runtime ownership Co-authored-by: rivet Agent-Model: codex/gpt-6-sol/medium --- .../unit/routes/agentBinding.phase2.test.js | 38 +++++++++++ .../registry.runtimeTokenOwnership.test.js | 9 ++- .../registry.webhookUrl-ownership.test.js | 20 +++--- backend/routes/agentBinding.ts | 16 +++-- backend/services/runtimeIdentityOwnership.ts | 63 +++++++++++++++---- frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + .../__tests__/V2AgentRuntimeGrants.test.tsx | 17 +++++ .../v2/components/V2AgentRuntimeGrants.tsx | 23 +++++-- 9 files changed, 160 insertions(+), 30 deletions(-) diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index 7faf46361e..799a710d32 100644 --- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js +++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js @@ -157,6 +157,44 @@ describe('placement request', () => { expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' }); }); + it('ignores agent-DM projections but names a second human installation before releasing', async () => { + await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); + await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } }); + const peer = await User.create({ username: `peer${Date.now() % 1e6}`, email: `peer${Date.now()}@agents.commonly.local`, + password: 'x'.repeat(12), isBot: true }); + const peer2 = await User.create({ username: `peer2${Date.now() % 1e6}`, email: `peer2${Date.now()}@agents.commonly.local`, + password: 'x'.repeat(12), isBot: true }); + for (const installer of [peer, bot]) { + // eslint-disable-next-line no-await-in-loop + const dm = await Pod.create({ name: `DM ${installer.username}`, type: 'agent-dm', + createdBy: installer._id, members: installer === bot ? [bot._id, peer2._id] : [peer._id, bot._id] }); + // eslint-disable-next-line no-await-in-loop + await AgentInstallation.create({ agentName: 'wren-test', instanceId: 'default', podId: dm._id, + version: '1.0.0', status: 'active', installedBy: installer._id }); + } + const placement = () => request(app).get('/api/agent-binding/placement') + .query({ agentName: 'wren-test', instanceId: 'default' }); + await placement().expect(200); + await request(app).post('/api/agent-binding/release') + .send({ agentName: 'wren-test', instanceId: 'default' }).expect(200); + await requestPlacement('machine-b').expect(200); + await adopt(DAEMON_B).expect(200); + + await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); + const secondHuman = await User.create({ username: `second${Date.now() % 1e6}`, + email: `second${Date.now()}@x.com`, password: 'x'.repeat(12) }); + const otherPod = await Pod.create({ name: 'Other human pod', type: 'team', + createdBy: secondHuman._id, members: [secondHuman._id, bot._id] }); + await AgentInstallation.create({ agentName: 'wren-test', instanceId: 'default', podId: otherPod._id, + version: '1.0.0', status: 'active', installedBy: secondHuman._id }); + const refusal = await placement().expect(403); + expect(refusal.body.code).toBe('another_installer'); + expect(refusal.body.message).toContain(`Other human pod (${secondHuman.username})`); + await request(app).post('/api/agent-binding/release') + .send({ agentName: 'wren-test', instanceId: 'default' }).expect(403); + expect((await User.findById(bot._id)).botMetadata.machineId).toBe('machine-a'); + }); + it('records a directive without binding, and null withdraws it', async () => { const res = await requestPlacement('machine-a'); expect(res.status).toBe(200); diff --git a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js index 35b27702f7..c1c09b014e 100644 --- a/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js +++ b/backend/__tests__/unit/routes/registry.runtimeTokenOwnership.test.js @@ -26,8 +26,8 @@ const matches = (doc, filter) => Object.entries(filter).every(([key, want]) => { }); jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { - find: jest.fn(() => ({ - collation: () => ({ select: () => ({ lean: () => Promise.resolve([]) }) }), + find: jest.fn((filter) => ({ + collation: () => ({ select: () => ({ lean: () => Promise.resolve(mockInstalls.filter((doc) => matches(doc, filter))) }) }), })), findOne: jest.fn((filter) => { const hit = mockInstalls.find((doc) => matches(doc, filter)) || null; @@ -42,7 +42,10 @@ jest.mock('../../../models/AgentRegistry', () => ({ }, })); -jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/Pod', () => ({ + findById: jest.fn(), + find: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve([]) }) })), +})); jest.mock('../../../models/User', () => ({ findById: jest.fn(), findOne: jest.fn() })); jest.mock('../../../models/AgentCredential', () => ({ updateOne: jest.fn().mockResolvedValue({}) })); diff --git a/backend/__tests__/unit/routes/registry.webhookUrl-ownership.test.js b/backend/__tests__/unit/routes/registry.webhookUrl-ownership.test.js index 1e76fd8d6e..7781cbcfd8 100644 --- a/backend/__tests__/unit/routes/registry.webhookUrl-ownership.test.js +++ b/backend/__tests__/unit/routes/registry.webhookUrl-ownership.test.js @@ -46,7 +46,9 @@ jest.mock('../../../models/AgentRegistry', () => ({ findOne: jest.fn((filter) => queryResult( 'installedBy' in filter ? (mockInstalls.find((doc) => matches(doc, filter)) || null) : mockPrimary, )), - find: jest.fn(), + find: jest.fn((filter) => ( + filter.status === 'active' ? queryResult(mockInstalls) : queryResult([mockPrimary]) + )), install: jest.fn(), }, })); @@ -112,6 +114,7 @@ beforeEach(() => { mockPrimary = null; setCallerRole('user'); setPod(); + Pod.find.mockReturnValue(queryResult([])); }); describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => { @@ -132,7 +135,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => { test('the sole installer can change it', async () => { mockPrimary = row(); mockInstalls = [activeInstall(INSTALLER, 'pod-1')]; - AgentInstallation.find.mockResolvedValue([mockPrimary]); const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } }); @@ -144,7 +146,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => { test('this row\'s installer is refused when another user also has the agent installed, and nothing is written', async () => { mockPrimary = row(); mockInstalls = [activeInstall(INSTALLER, 'pod-1'), activeInstall(OTHER, 'pod-9')]; - AgentInstallation.find.mockResolvedValue([mockPrimary]); const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } }); @@ -159,7 +160,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => { mockCaller = MEMBER; mockPrimary = row(); mockInstalls = [activeInstall(INSTALLER, 'pod-1')]; - AgentInstallation.find.mockResolvedValue([mockPrimary]); const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } }); @@ -175,7 +175,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => { setCallerRole('admin'); mockPrimary = row(); mockInstalls = [activeInstall(INSTALLER, 'pod-1'), activeInstall(OTHER, 'pod-9')]; - AgentInstallation.find.mockResolvedValue([mockPrimary]); const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: NEW_URL } }); @@ -186,7 +185,6 @@ describe('PATCH /pods/:podId/agents/:name — setting the webhook URL', () => { test('resending the stored URL is not a change, so a shared agent\'s installer can still patch its config', async () => { mockPrimary = row(); mockInstalls = [activeInstall(INSTALLER, 'pod-1'), activeInstall(OTHER, 'pod-9')]; - AgentInstallation.find.mockResolvedValue([mockPrimary]); const res = await patch({ runtime: { runtimeType: 'webhook', webhookUrl: STORED_URL }, note: 'renamed' }); @@ -215,7 +213,15 @@ describe('POST /install — supplying a webhook URL', () => { manifest: { context: { required: [] }, runtime: { type: 'standalone' } }, }); AgentRegistry.incrementInstalls.mockResolvedValue({ acknowledged: true }); - AgentInstallation.find.mockReturnValue({ lean: () => Promise.resolve([]) }); + AgentInstallation.find.mockImplementation(() => { + let ownershipRead = false; + const chain = { + collation: () => { ownershipRead = true; return chain; }, + select: () => chain, + lean: () => Promise.resolve(ownershipRead ? mockInstalls : []), + }; + return chain; + }); AgentInstallation.install.mockImplementation(async (agentName, podId, options) => ({ _id: { toString: () => 'install-1' }, agentName, diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index 2d752b0d9f..afd49cd5c5 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -19,7 +19,7 @@ import { projectSeatEnvironments, seatEnvironmentKey, } from '../services/seatEnvironmentProjection'; -import { ownsRuntimeIdentity, OwnershipFailure } from '../services/runtimeIdentityOwnership'; +import { ownsRuntimeIdentity, OwnershipFailure, describeRuntimeIdentityBlockers } from '../services/runtimeIdentityOwnership'; import { logAgentInstallRefusal } from '../middleware/logAgentInstallRefusal'; import { machineHasLiveAuthorization } from '../services/machineService'; @@ -154,6 +154,12 @@ const ownershipFailureMessage: Record = { unknown_installer: 'An active legacy installation has no recorded installer', }; +const ownershipRefusal = async (ownerUserId: unknown, agentName: string, instanceId: string, failure: OwnershipFailure) => { + if (failure !== 'another_installer') return ownershipFailureMessage[failure]; + const blockers = await describeRuntimeIdentityBlockers(ownerUserId, agentName, instanceId); + return `This agent is also installed in ${blockers.join(', ') || 'another pod'}. Ask that installer to remove their installation before moving it.`; +}; + // Owner-only view for Settings. A removed machine can still be named by the // identity binding; keep that fact visible while offering only live targets. router.get('/placement', bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => { @@ -162,7 +168,9 @@ router.get('/placement', bindingRateLimit, auth, async (req: express.Request & { const instanceId = normalize(req.query.instanceId) || 'default'; if (!agentName) return res.status(400).json({ message: 'agentName required' }); const ownership = await ownsAgent(req.user?.id, agentName, instanceId); - if (!ownership.owned) return res.status(403).json({ message: ownershipFailureMessage[ownership.failure], code: ownership.failure }); + if (!ownership.owned) return res.status(403).json({ + message: await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure), code: ownership.failure, + }); const identity = await User.findOne({ isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId, }).select('botMetadata.machineId botMetadata.requestedMachineId').lean(); @@ -252,7 +260,7 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr const ownership = await ownsAgent(req.user?.id, agentName, instanceId); if (!ownership.owned) { return res.status(403).json({ - message: ownershipFailureMessage[ownership.failure], + message: await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure), code: ownership.failure, }); } @@ -522,7 +530,7 @@ router.post('/release', bindingRateLimit, auth, async (req: express.Request & { const caller = await User.findById(req.user?.id).select('role').lean(); if (caller?.role !== 'admin') { return res.status(403).json({ - message: ownershipFailureMessage[ownership.failure], + message: await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure), code: ownership.failure, }); } diff --git a/backend/services/runtimeIdentityOwnership.ts b/backend/services/runtimeIdentityOwnership.ts index 36de83e0f3..83cf39f85d 100644 --- a/backend/services/runtimeIdentityOwnership.ts +++ b/backend/services/runtimeIdentityOwnership.ts @@ -12,7 +12,8 @@ * adoption). Two clauses, both must hold: * - an active installation of the identity installedBy the owner exists; * - no active installation of it exists installedBy anyone else. - * The negative clause is what stops a shared identity (two humans each + * Agent-DM installations are conversation projections, not ownership. They + * do not satisfy either clause. The negative clause stops a shared identity (two humans each * installed it) from being bound to, or minted by, one person. Once #609 gives * per-owner identities it becomes redundant rather than wrong, so it stays. * @@ -29,17 +30,56 @@ export type OwnershipCheck = const RUNTIME_INSTALLATION_COLLATION = { locale: 'en', strength: 2 }; +type RuntimeInstallation = { installedBy?: unknown; podId?: unknown }; + +const ownershipInstallations = async (agentName: string, instanceId: string): Promise => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const { AgentInstallation } = require('../models/AgentRegistry'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const Pod = require('../models/Pod'); + const installations: RuntimeInstallation[] = await AgentInstallation.find({ + agentName, instanceId, status: 'active', + }).collation(RUNTIME_INSTALLATION_COLLATION).select('installedBy podId').lean(); + const dmPods = await Pod.find({ + _id: { $in: installations.map((installation) => installation.podId).filter(Boolean) }, type: 'agent-dm', + }).select('_id').lean(); + const dmPodIds = new Set(dmPods.map((pod: { _id: unknown }) => String(pod._id))); + return installations.filter((installation) => !dmPodIds.has(String(installation.podId))); +}; + +/** Explain a genuine second installer to the first installer in Settings. */ +export const describeRuntimeIdentityBlockers = async ( + ownerUserId: unknown, + agentName: string, + instanceId: string, +): Promise => { + const others = (await ownershipInstallations(agentName, instanceId)) + .filter((installation) => String(installation.installedBy) !== String(ownerUserId)); + if (!others.length) return []; + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const Pod = require('../models/Pod'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const User = require('../models/User'); + const [pods, users] = await Promise.all([ + Pod.find({ _id: { $in: others.map((installation) => installation.podId).filter(Boolean) } }).select('name').lean(), + User.find({ _id: { $in: others.map((installation) => installation.installedBy).filter(Boolean) } }) + .select('username displayName').lean(), + ]); + const podNames = new Map(pods.map((pod: { _id: unknown; name: string }) => [String(pod._id), pod.name])); + const userNames = new Map(users.map((user: { _id: unknown; username: string; displayName?: string }) => [ + String(user._id), user.displayName || user.username, + ])); + return others.map((installation) => `${podNames.get(String(installation.podId)) || 'unknown pod'} (${userNames.get(String(installation.installedBy)) || 'unknown installer'})`); +}; + /** The negative clause: does an active installation by anyone else exist? */ const otherInstallerFailure = async ( ownerUserId: unknown, agentName: string, instanceId: string, ): Promise => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require - const { AgentInstallation } = require('../models/AgentRegistry'); - const others = await AgentInstallation.findOne({ - agentName, instanceId, status: 'active', installedBy: { $ne: ownerUserId }, - }).collation(RUNTIME_INSTALLATION_COLLATION).select('installedBy').lean(); + const others = (await ownershipInstallations(agentName, instanceId)) + .find((installation) => String(installation.installedBy) !== String(ownerUserId)); if (!others) return null; // MongoDB $ne also matches a missing field. Keep that legacy state safely // non-adoptable, but report it separately so an owner can repair it. @@ -52,13 +92,12 @@ export const ownsRuntimeIdentity = async ( agentName: string, instanceId: string, ): Promise => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require - const { AgentInstallation } = require('../models/AgentRegistry'); - const mine = await AgentInstallation.findOne({ - agentName, instanceId, installedBy: ownerUserId, status: 'active', - }).collation(RUNTIME_INSTALLATION_COLLATION).select('_id').lean(); + const installations = await ownershipInstallations(agentName, instanceId); + const mine = installations + .find((installation) => installation.installedBy && String(installation.installedBy) === String(ownerUserId)); if (!mine) return { owned: false, failure: 'owner_installation_missing' }; - const failure = await otherInstallerFailure(ownerUserId, agentName, instanceId); + const other = installations.find((installation) => String(installation.installedBy) !== String(ownerUserId)); + const failure = other ? (other.installedBy ? 'another_installer' : 'unknown_installer') : null; return failure ? { owned: false, failure } : { owned: true }; }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0273605e3b..ef725dfcc6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2324,6 +2324,8 @@ "move": "Move agent", "moving": "Moving…", "noActiveMachine": "Register another computer to move this agent.", + "targetUnavailable": "That computer is no longer available. Choose another computer.", + "placementChanged": "This agent’s computer changed. Review its current placement before moving it.", "placementError": "Could not load this agent’s computer placement.", "moveError": "Could not move this agent. Try again." }, diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index dfb90d30e1..125c1878c2 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2315,6 +2315,8 @@ "move": "迁移智能体", "moving": "正在迁移…", "noActiveMachine": "请先注册另一台电脑,再迁移此智能体。", + "targetUnavailable": "这台电脑已不可用,请选择另一台电脑。", + "placementChanged": "此智能体的电脑分配已变更。请先查看当前分配,再重新迁移。", "placementError": "无法加载此智能体的电脑分配信息。", "moveError": "无法迁移此智能体,请重试。" }, diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index bb1c123ad1..2eac413eb0 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -65,6 +65,23 @@ test('revoked Runtime row offers an active computer and releases before requesti ]); }); +test('a changed installer refuses the move before release and explains the blocking installation', async () => { + let placementReads = 0; + axios.get.mockImplementation((url) => { + if (!url.includes('/agent-binding/placement')) return Promise.resolve({ data: response }); + placementReads += 1; + if (placementReads > 1) return Promise.reject({ response: { data: { + message: 'This agent is also installed in Other human pod (alex). Ask that installer to remove their installation before moving it.', + } } }); + return Promise.resolve({ data: { boundMachineId: 'old', requestedMachineId: null, + boundMachineRevoked: true, activeMachines: [{ machineId: 'new', name: 'New Mac' }] } }); + }); + renderSection(); + fireEvent.click(await screen.findByRole('button', { name: 'Move agent' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Other human pod (alex)'); + expect(axios.post).not.toHaveBeenCalled(); +}); + test('shows this agent grant and requires a second click to revoke through the existing route', async () => { renderSection(); expect(await screen.findByText('On a computer, via the daemon')).toBeInTheDocument(); diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index e043287efd..fbd3c45c9c 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -90,6 +90,7 @@ const V2AgentRuntimeGrants: React.FC = ({ setPlacement(next); setMachineChoice((current) => next.activeMachines.some((machine) => machine.machineId === current) ? current : next.activeMachines.find((machine) => machine.machineId !== next.boundMachineId)?.machineId || ''); + return next; }, [api, name, instanceId]); const load = useCallback(async () => { @@ -112,7 +113,10 @@ const V2AgentRuntimeGrants: React.FC = ({ if (runtime.runtimeType !== 'wrapper') return; setPlacement(null); setPlacementError(''); - void loadPlacement().catch(() => setPlacementError(t('agentManage.runtime.placementError'))); + void loadPlacement().catch((error) => setPlacementError( + (error as { response?: { data?: { message?: string } } })?.response?.data?.message + || t('agentManage.runtime.placementError'), + )); }, [runtime.runtimeType, loadPlacement, t]); useEffect(() => setModelDraft(runtime.model || ''), [runtime.model]); useEffect(() => setEffortDraft(runtime.effort || ''), [runtime.effort]); @@ -169,14 +173,25 @@ const V2AgentRuntimeGrants: React.FC = ({ setMoving(true); setPlacementError(''); try { - if (placement.boundMachineId) { + // Recheck sole ownership and the chosen target before releasing the old + // binding. An agent-created DM is not a competing installer, while a + // second human installation must refuse before any state changes. + const current = await loadPlacement(); + if (current.boundMachineId !== placement.boundMachineId || (current.boundMachineId && !current.boundMachineRevoked)) { + throw new Error(t('agentManage.runtime.placementChanged')); + } + if (!current.activeMachines.some((machine) => machine.machineId === machineChoice)) { + throw new Error(t('agentManage.runtime.targetUnavailable')); + } + if (current.boundMachineId) { await api.post('/api/agent-binding/release', { agentName: name, instanceId }); setPlacement((current) => current && { ...current, boundMachineId: null, boundMachineRevoked: false }); } await api.post('/api/agent-binding/request', { agentName: name, instanceId, machineId: machineChoice }); await loadPlacement(); - } catch { - setPlacementError(t('agentManage.runtime.moveError')); + } catch (error) { + setPlacementError((error as { response?: { data?: { message?: string } } })?.response?.data?.message + || (error instanceof Error && error.message) || t('agentManage.runtime.moveError')); void loadPlacement().catch(() => {}); } finally { setMoving(false); From d106197d7d33892e1b7066e71699905696a4fe73 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:20:36 -0700 Subject: [PATCH 3/6] Make agent machine move atomic and redact private blockers Co-authored-by: rivet Agent-Model: codex/gpt-6-sol/medium --- .../unit/routes/agentBinding.phase2.test.js | 37 ++++++++- backend/routes/agentBinding.ts | 76 +++++++++++++++---- backend/services/runtimeIdentityOwnership.ts | 26 ++++--- frontend/src/i18n/locales/en.json | 10 ++- frontend/src/i18n/locales/zh-CN.json | 10 ++- .../__tests__/V2AgentRuntimeGrants.test.tsx | 26 +++++-- .../v2/components/V2AgentRuntimeGrants.tsx | 43 +++++++---- 7 files changed, 177 insertions(+), 51 deletions(-) diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index 799a710d32..3d32543654 100644 --- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js +++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js @@ -104,6 +104,10 @@ const requestPlacement = (machineId) => request(app) .post('/api/agent-binding/request') .send({ agentName: 'wren-test', instanceId: 'default', machineId }); +const movePlacement = (machineId, fromMachineId = 'machine-a') => request(app) + .post('/api/agent-binding/move') + .send({ agentName: 'wren-test', instanceId: 'default', machineId, fromMachineId }); + const requestReconnect = () => request(app) .post('/api/agent-binding/reconnect') .send({ agentName: 'wren-test', instanceId: 'default' }); @@ -135,6 +139,29 @@ const runtimeAuthProbe = (token) => new Promise((resolve, reject) => { }); describe('placement request', () => { + it('moves a revoked binding in one update and preserves it if the target is revoked after preflight', async () => { + await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); + await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } }); + await request(app).get('/api/agent-binding/placement').query({ agentName: 'wren-test' }).expect(200); + await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { status: 'revoked', revokedAt: new Date() } }); + const refused = await movePlacement('machine-b').expect(409); + expect(refused.body.code).toBe('target_revoked'); + expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: 'machine-a' }); + await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { status: 'active', revokedAt: null } }); + await movePlacement('machine-b').expect(200); + expect((await User.findById(bot._id)).botMetadata).toMatchObject({ machineId: null, requestedMachineId: 'machine-b' }); + }); + + it('does not offer or select an expired daemon credential', async () => { + await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); + await AgentCredential.updateOne({ machineId: 'machine-b' }, { $set: { expiresAt: new Date(Date.now() - 1000) } }); + const placement = await request(app).get('/api/agent-binding/placement').query({ agentName: 'wren-test' }).expect(200); + expect(placement.body.activeMachines).not.toContainEqual(expect.objectContaining({ machineId: 'machine-b' })); + const refused = await movePlacement('machine-b').expect(409); + expect(refused.body.code).toBe('target_revoked'); + expect((await User.findById(bot._id)).botMetadata.machineId).toBe('machine-a'); + }); + it('lets only the sole installer view a revoked binding and request a live replacement after release', async () => { await User.updateOne({ _id: bot._id }, { $set: { 'botMetadata.machineId': 'machine-a' } }); await AgentCredential.updateOne({ _id: daemonCredA._id }, { $set: { status: 'revoked', revokedAt: new Date() } }); @@ -189,7 +216,15 @@ describe('placement request', () => { version: '1.0.0', status: 'active', installedBy: secondHuman._id }); const refusal = await placement().expect(403); expect(refusal.body.code).toBe('another_installer'); - expect(refusal.body.message).toContain(`Other human pod (${secondHuman.username})`); + expect(refusal.body.message).toContain('another pod'); + expect(JSON.stringify(refusal.body)).not.toContain('Other human pod'); + expect(JSON.stringify(refusal.body)).not.toContain(secondHuman.username); + expect(refusal.body.blockers).toEqual([]); + await Pod.updateOne({ _id: otherPod._id }, { $addToSet: { members: owner._id } }); + const visibleRefusal = await placement().expect(403); + expect(visibleRefusal.body.message).toContain(`Other human pod (${secondHuman.username})`); + expect(visibleRefusal.body.blockers).toContainEqual({ podName: 'Other human pod', installerName: secondHuman.username }); + await movePlacement('machine-b').expect(403); await request(app).post('/api/agent-binding/release') .send({ agentName: 'wren-test', instanceId: 'default' }).expect(403); expect((await User.findById(bot._id)).botMetadata.machineId).toBe('machine-a'); diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index afd49cd5c5..4eb11dbb3d 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -155,9 +155,16 @@ const ownershipFailureMessage: Record = { }; const ownershipRefusal = async (ownerUserId: unknown, agentName: string, instanceId: string, failure: OwnershipFailure) => { - if (failure !== 'another_installer') return ownershipFailureMessage[failure]; + if (failure !== 'another_installer') return { message: ownershipFailureMessage[failure], code: failure }; const blockers = await describeRuntimeIdentityBlockers(ownerUserId, agentName, instanceId); - return `This agent is also installed in ${blockers.join(', ') || 'another pod'}. Ask that installer to remove their installation before moving it.`; + const names = blockers.map((blocker) => `${blocker.podName} (${blocker.installerName})`).join(', '); + return { + message: names + ? `This agent is also installed in ${names}. Ask that installer to remove their installation before moving it.` + : 'This agent is also installed in another pod. Its installer must remove that installation before it can move.', + code: failure, + blockers, + }; }; // Owner-only view for Settings. A removed machine can still be named by the @@ -168,9 +175,7 @@ router.get('/placement', bindingRateLimit, auth, async (req: express.Request & { const instanceId = normalize(req.query.instanceId) || 'default'; if (!agentName) return res.status(400).json({ message: 'agentName required' }); const ownership = await ownsAgent(req.user?.id, agentName, instanceId); - if (!ownership.owned) return res.status(403).json({ - message: await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure), code: ownership.failure, - }); + if (!ownership.owned) return res.status(403).json(await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure)); const identity = await User.findOne({ isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId, }).select('botMetadata.machineId botMetadata.requestedMachineId').lean(); @@ -180,7 +185,8 @@ router.get('/placement', bindingRateLimit, auth, async (req: express.Request & { machineId: machine.machineId, name: machine.name, active: Boolean(await AgentCredential.exists({ kind: 'daemon', ownerUserId: req.user?.id, - machineId: machine.machineId, status: 'active' })) + machineId: machine.machineId, status: 'active', + $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }] })) && await machineHasLiveAuthorization(machine.machineId, req.user!.id!), })))).filter((machine) => machine.active).map(({ machineId, name }) => ({ machineId, name })); const boundMachineId = identity.botMetadata?.machineId || null; @@ -259,10 +265,7 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr const ownership = await ownsAgent(req.user?.id, agentName, instanceId); if (!ownership.owned) { - return res.status(403).json({ - message: await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure), - code: ownership.failure, - }); + return res.status(403).json(await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure)); } // An agent Commonly runs in-process (the person's hosted agent) can't also @@ -284,6 +287,7 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr if (!machine) return res.status(404).json({ message: 'Machine not found' }); const daemonActive = await AgentCredential.exists({ kind: 'daemon', machineId: requestedMachineId, ownerUserId: req.user?.id, status: 'active', + $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }], }); if (!daemonActive || !await machineHasLiveAuthorization(requestedMachineId, req.user!.id!)) { return res.status(409).json({ message: 'Machine access revoked' }); @@ -315,6 +319,53 @@ router.post('/request', logAgentInstallRefusal('/api/agent-binding/request', 'wr } }); +// Owner-approved handoff: release the old binding and write the placement +// directive in ONE identity update. Two HTTP calls can strand an agent when +// the target is revoked between them. The target daemon still owns adopt CAS. +router.post('/move', bindingRateLimit, auth, async (req: express.Request & { user?: { id?: string } }, res: express.Response) => { + try { + const agentName = normalize(req.body?.agentName); + const instanceId = normalize(req.body?.instanceId) || 'default'; + const machineId = String(req.body?.machineId || '').trim(); + const fromMachineId = req.body?.fromMachineId === null ? null : String(req.body?.fromMachineId || '').trim(); + if (!agentName || !machineId || (fromMachineId === '' && req.body?.fromMachineId !== null)) { + return res.status(400).json({ message: 'agentName, machineId and fromMachineId required' }); + } + const ownership = await ownsAgent(req.user?.id, agentName, instanceId); + if (!ownership.owned) return res.status(403).json(await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure)); + if (await runsInProcess(null, { agentName, instanceId })) { + return res.status(409).json({ code: 'in_process_agent_no_token', message: 'A hosted agent cannot run on a computer' }); + } + const target = await Machine.findOne({ machineId, ownerUserId: req.user?.id }).select('_id').lean(); + if (!target) return res.status(404).json({ code: 'target_missing', message: 'Machine not found' }); + const daemonActive = await AgentCredential.exists({ + kind: 'daemon', machineId, ownerUserId: req.user?.id, status: 'active', + $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }], + }); + if (!daemonActive || !await machineHasLiveAuthorization(machineId, req.user!.id!)) { + return res.status(409).json({ code: 'target_revoked', message: 'Machine access revoked' }); + } + const binding = fromMachineId === null + ? { $or: [{ 'botMetadata.machineId': null }, { 'botMetadata.machineId': { $exists: false } }] } + : { 'botMetadata.machineId': fromMachineId }; + const changed = await User.updateOne({ + isBot: true, 'botMetadata.agentName': agentName, 'botMetadata.instanceId': instanceId, ...binding, + }, { $set: { 'botMetadata.machineId': null, 'botMetadata.requestedMachineId': machineId } }); + if (!changed.matchedCount) { + const exists = await User.exists({ isBot: true, 'botMetadata.agentName': agentName, + 'botMetadata.instanceId': instanceId }); + return res.status(exists ? 409 : 404).json({ + code: exists ? 'binding_changed' : 'agent_missing', + message: exists ? 'Agent binding changed; refresh before moving' : 'Agent identity not found', + }); + } + return res.json({ requested: true, agentName, instanceId, machineId }); + } catch (err) { + console.error('placement move error:', err); + return res.status(500).json({ message: 'Server error' }); + } +}); + // 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. @@ -529,10 +580,7 @@ router.post('/release', bindingRateLimit, auth, async (req: express.Request & { // eslint-disable-next-line @typescript-eslint/no-require-imports const caller = await User.findById(req.user?.id).select('role').lean(); if (caller?.role !== 'admin') { - return res.status(403).json({ - message: await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure), - code: ownership.failure, - }); + return res.status(403).json(await ownershipRefusal(req.user?.id, agentName, instanceId, ownership.failure)); } } const result = await User.updateOne( diff --git a/backend/services/runtimeIdentityOwnership.ts b/backend/services/runtimeIdentityOwnership.ts index 83cf39f85d..a48ec740a8 100644 --- a/backend/services/runtimeIdentityOwnership.ts +++ b/backend/services/runtimeIdentityOwnership.ts @@ -48,11 +48,12 @@ const ownershipInstallations = async (agentName: string, instanceId: string): Pr }; /** Explain a genuine second installer to the first installer in Settings. */ +export type RuntimeIdentityBlocker = { podName: string; installerName: string }; export const describeRuntimeIdentityBlockers = async ( ownerUserId: unknown, agentName: string, instanceId: string, -): Promise => { +): Promise => { const others = (await ownershipInstallations(agentName, instanceId)) .filter((installation) => String(installation.installedBy) !== String(ownerUserId)); if (!others.length) return []; @@ -60,16 +61,23 @@ export const describeRuntimeIdentityBlockers = async ( const Pod = require('../models/Pod'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const User = require('../models/User'); - const [pods, users] = await Promise.all([ - Pod.find({ _id: { $in: others.map((installation) => installation.podId).filter(Boolean) } }).select('name').lean(), - User.find({ _id: { $in: others.map((installation) => installation.installedBy).filter(Boolean) } }) - .select('username displayName').lean(), - ]); - const podNames = new Map(pods.map((pod: { _id: unknown; name: string }) => [String(pod._id), pod.name])); - const userNames = new Map(users.map((user: { _id: unknown; username: string; displayName?: string }) => [ + // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require + const DMService = require('./dmService'); + const pods = await Pod.find({ _id: { $in: others.map((installation) => installation.podId).filter(Boolean) } }); + const visiblePods = (await Promise.all(pods.map(async (pod: { _id: unknown; name: string }) => ( + await DMService.canViewPod(ownerUserId, pod) ? pod : null + )))).filter((pod: { _id: unknown; name: string } | null): pod is { _id: unknown; name: string } => pod !== null); + const podNames = new Map(visiblePods.map((pod) => [String(pod._id), pod.name])); + const visibleOthers = others.filter((installation) => podNames.has(String(installation.podId))); + const users = await User.find({ _id: { $in: visibleOthers.map((installation) => installation.installedBy).filter(Boolean) } }) + .select('username displayName').lean(); + const userNames = new Map(users.map((user: { _id: unknown; username: string; displayName?: string }) => [ String(user._id), user.displayName || user.username, ])); - return others.map((installation) => `${podNames.get(String(installation.podId)) || 'unknown pod'} (${userNames.get(String(installation.installedBy)) || 'unknown installer'})`); + return visibleOthers.map((installation) => ({ + podName: podNames.get(String(installation.podId))!, + installerName: userNames.get(String(installation.installedBy)) || 'unknown installer', + })); }; /** The negative clause: does an active installation by anyone else exist? */ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ef725dfcc6..f5e0200876 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2317,13 +2317,19 @@ "effortUnset": "Choose effort", "save": "Save runtime", "saveError": "Could not save the model.", - "machineRevoked": "This computer was revoked. Choose another computer to run this agent.", + "machineRevoked": "This computer’s key was revoked. Choose another computer to run this agent.", "currentMachine": "Running on {{name}}", "machineRequested": "Waiting for {{name}} to start this agent", "moveLabel": "Run on", "move": "Move agent", "moving": "Moving…", - "noActiveMachine": "Register another computer to move this agent.", + "noActiveMachine": "Register another computer to move this agent:", + "blocker": "{{podName}} ({{installerName}})", + "blockedBy": "This agent is also installed in {{blockers}}. Ask that installer to remove their installation before moving it.", + "blockedGeneric": "This agent is also installed in another pod. Its installer must remove that installation before it can move.", + "placementOwnerMissing": "You need an active installation of this agent to move it.", + "placementUnknownInstaller": "An older installation has no recorded installer, so this agent cannot be moved yet.", + "hostedCannotMove": "A hosted agent cannot run on a computer.", "targetUnavailable": "That computer is no longer available. Choose another computer.", "placementChanged": "This agent’s computer changed. Review its current placement before moving it.", "placementError": "Could not load this agent’s computer placement.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 125c1878c2..6a4391f1ae 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2308,13 +2308,19 @@ "effortUnset": "选择推理强度", "save": "保存运行设置", "saveError": "无法保存模型。", - "machineRevoked": "这台电脑的访问权限已撤销。请选择另一台电脑运行此智能体。", + "machineRevoked": "这台电脑的密钥已撤销。请选择另一台电脑运行此智能体。", "currentMachine": "正在 {{name}} 上运行", "machineRequested": "等待 {{name}} 启动此智能体", "moveLabel": "运行于", "move": "迁移智能体", "moving": "正在迁移…", - "noActiveMachine": "请先注册另一台电脑,再迁移此智能体。", + "noActiveMachine": "请先注册另一台电脑,再迁移此智能体:", + "blocker": "{{podName}}({{installerName}})", + "blockedBy": "此智能体也安装在 {{blockers}}。请联系安装者移除该安装,然后再迁移。", + "blockedGeneric": "此智能体也安装在其他空间。该空间的安装者需要先移除安装,然后才能迁移。", + "placementOwnerMissing": "您需要先安装此智能体,才能迁移它。", + "placementUnknownInstaller": "旧版安装未记录安装者,因此暂时无法迁移此智能体。", + "hostedCannotMove": "托管智能体无法在您的电脑上运行。", "targetUnavailable": "这台电脑已不可用,请选择另一台电脑。", "placementChanged": "此智能体的电脑分配已变更。请先查看当前分配,再重新迁移。", "placementError": "无法加载此智能体的电脑分配信息。", diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index 2eac413eb0..13ff2d107b 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -50,19 +50,17 @@ test('runtime labels only recognize walked adapters and distinguish daemon from expect(describeRuntime({ runtimeType: 'webhook', host: 'byo' })).toEqual({ mode: 'unknown', adapter: null }); }); -test('revoked Runtime row offers an active computer and releases before requesting placement', async () => { +test('revoked Runtime row offers an active computer and moves in one request', async () => { axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, activeMachines: [{ machineId: 'new', name: 'New Mac' }] } : response })); renderSection(); - expect(await screen.findByText('This computer was revoked. Choose another computer to run this agent.')).toBeInTheDocument(); + expect(await screen.findByText('This computer’s key was revoked. Choose another computer to run this agent.')).toBeInTheDocument(); expect(screen.getByLabelText('Run on')).toHaveValue('new'); fireEvent.click(screen.getByRole('button', { name: 'Move agent' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/agent-binding/request', - { agentName: 'nib', instanceId: 'default', machineId: 'new' }, expect.anything())); - expect(axios.post.mock.calls.slice(0, 2).map(([url]) => url)).toEqual([ - '/api/agent-binding/release', '/api/agent-binding/request', - ]); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/agent-binding/move', + { agentName: 'nib', instanceId: 'default', machineId: 'new', fromMachineId: 'old' }, expect.anything())); + expect(axios.post).toHaveBeenCalledTimes(1); }); test('a changed installer refuses the move before release and explains the blocking installation', async () => { @@ -71,7 +69,7 @@ test('a changed installer refuses the move before release and explains the block if (!url.includes('/agent-binding/placement')) return Promise.resolve({ data: response }); placementReads += 1; if (placementReads > 1) return Promise.reject({ response: { data: { - message: 'This agent is also installed in Other human pod (alex). Ask that installer to remove their installation before moving it.', + code: 'another_installer', blockers: [{ podName: 'Other human pod', installerName: 'alex' }], } } }); return Promise.resolve({ data: { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, activeMachines: [{ machineId: 'new', name: 'New Mac' }] } }); @@ -82,6 +80,18 @@ test('a changed installer refuses the move before release and explains the block expect(axios.post).not.toHaveBeenCalled(); }); +test('a revoked target after preflight keeps the old placement visible and shows localized error', async () => { + axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement') + ? { boundMachineId: 'old', requestedMachineId: null, boundMachineRevoked: true, + activeMachines: [{ machineId: 'new', name: 'New Mac' }] } : response })); + axios.post.mockRejectedValue({ response: { data: { code: 'target_revoked', message: 'Machine access revoked' } } }); + renderSection(); + fireEvent.click(await screen.findByRole('button', { name: 'Move agent' })); + expect(await screen.findByRole('alert')).toHaveTextContent('That computer is no longer available.'); + expect(screen.getByText('This computer’s key was revoked. Choose another computer to run this agent.')).toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledTimes(1); +}); + test('shows this agent grant and requires a second click to revoke through the existing route', async () => { renderSection(); expect(await screen.findByText('On a computer, via the daemon')).toBeInTheDocument(); diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index fbd3c45c9c..be6477134a 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -34,6 +34,10 @@ interface Placement { activeMachines: Array<{ machineId: string; name: string }>; } +interface PlacementFailure { + response?: { data?: { code?: string; blockers?: Array<{ podName: string; installerName: string }> } }; +} + interface Props { name: string; instanceId: string; @@ -84,6 +88,21 @@ const V2AgentRuntimeGrants: React.FC = ({ const [placementError, setPlacementError] = useState(''); const [moving, setMoving] = useState(false); + const placementFailure = (error: unknown, fallback: string): string => { + const data = (error as PlacementFailure)?.response?.data; + if (data?.code === 'another_installer') { + const blockers = (data.blockers || []).map(({ podName, installerName }) => t('agentManage.runtime.blocker', { podName, installerName })).join(', '); + return blockers ? t('agentManage.runtime.blockedBy', { blockers }) : t('agentManage.runtime.blockedGeneric'); + } + const key: Record = { + owner_installation_missing: 'placementOwnerMissing', unknown_installer: 'placementUnknownInstaller', + target_revoked: 'targetUnavailable', target_missing: 'targetUnavailable', + binding_changed: 'placementChanged', agent_missing: 'placementError', + in_process_agent_no_token: 'hostedCannotMove', + }; + return data?.code && key[data.code] ? t(`agentManage.runtime.${key[data.code]}`) : t(fallback); + }; + const loadPlacement = useCallback(async () => { const response = await api.get('/api/agent-binding/placement', { params: { agentName: name, instanceId } }); const next = { ...response, activeMachines: response.activeMachines || [] }; @@ -113,10 +132,7 @@ const V2AgentRuntimeGrants: React.FC = ({ if (runtime.runtimeType !== 'wrapper') return; setPlacement(null); setPlacementError(''); - void loadPlacement().catch((error) => setPlacementError( - (error as { response?: { data?: { message?: string } } })?.response?.data?.message - || t('agentManage.runtime.placementError'), - )); + void loadPlacement().catch((error) => setPlacementError(placementFailure(error, 'agentManage.runtime.placementError'))); }, [runtime.runtimeType, loadPlacement, t]); useEffect(() => setModelDraft(runtime.model || ''), [runtime.model]); useEffect(() => setEffortDraft(runtime.effort || ''), [runtime.effort]); @@ -173,9 +189,8 @@ const V2AgentRuntimeGrants: React.FC = ({ setMoving(true); setPlacementError(''); try { - // Recheck sole ownership and the chosen target before releasing the old - // binding. An agent-created DM is not a competing installer, while a - // second human installation must refuse before any state changes. + // Recheck sole ownership and the target for immediate feedback. The + // server move route checks again and updates the binding in one write. const current = await loadPlacement(); if (current.boundMachineId !== placement.boundMachineId || (current.boundMachineId && !current.boundMachineRevoked)) { throw new Error(t('agentManage.runtime.placementChanged')); @@ -183,15 +198,13 @@ const V2AgentRuntimeGrants: React.FC = ({ if (!current.activeMachines.some((machine) => machine.machineId === machineChoice)) { throw new Error(t('agentManage.runtime.targetUnavailable')); } - if (current.boundMachineId) { - await api.post('/api/agent-binding/release', { agentName: name, instanceId }); - setPlacement((current) => current && { ...current, boundMachineId: null, boundMachineRevoked: false }); - } - await api.post('/api/agent-binding/request', { agentName: name, instanceId, machineId: machineChoice }); + await api.post('/api/agent-binding/move', { + agentName: name, instanceId, machineId: machineChoice, fromMachineId: current.boundMachineId, + }); await loadPlacement(); } catch (error) { - setPlacementError((error as { response?: { data?: { message?: string } } })?.response?.data?.message - || (error instanceof Error && error.message) || t('agentManage.runtime.moveError')); + setPlacementError((error instanceof Error && !('response' in error) && error.message) + || placementFailure(error, 'agentManage.runtime.moveError')); void loadPlacement().catch(() => {}); } finally { setMoving(false); @@ -226,7 +239,7 @@ const V2AgentRuntimeGrants: React.FC = ({ - :

{t('agentManage.runtime.noActiveMachine')}

+ :

{t('agentManage.runtime.noActiveMachine')} commonly daemon register

)}
)} From 88e35b51bcbcb12707842f5e851e2b7273d6f46c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:24:51 -0700 Subject: [PATCH 4/6] Complete machine setup hint and localized move copy Co-authored-by: rivet Agent-Model: codex/gpt-6-sol/medium --- frontend/src/i18n/locales/en.json | 3 ++- frontend/src/i18n/locales/zh-CN.json | 7 ++++--- frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx | 6 ++++++ frontend/src/v2/components/V2AgentRuntimeGrants.tsx | 6 ++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f5e0200876..c9e5fbb6cc 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2325,9 +2325,10 @@ "moving": "Moving…", "noActiveMachine": "Register another computer to move this agent:", "blocker": "{{podName}} ({{installerName}})", + "blockerSeparator": ", ", "blockedBy": "This agent is also installed in {{blockers}}. Ask that installer to remove their installation before moving it.", "blockedGeneric": "This agent is also installed in another pod. Its installer must remove that installation before it can move.", - "placementOwnerMissing": "You need an active installation of this agent to move it.", + "placementOwnerMissing": "You need to add this agent to a pod you own before moving it.", "placementUnknownInstaller": "An older installation has no recorded installer, so this agent cannot be moved yet.", "hostedCannotMove": "A hosted agent cannot run on a computer.", "targetUnavailable": "That computer is no longer available. Choose another computer.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 6a4391f1ae..7b6c39a8dc 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2316,11 +2316,12 @@ "moving": "正在迁移…", "noActiveMachine": "请先注册另一台电脑,再迁移此智能体:", "blocker": "{{podName}}({{installerName}})", + "blockerSeparator": "、", "blockedBy": "此智能体也安装在 {{blockers}}。请联系安装者移除该安装,然后再迁移。", - "blockedGeneric": "此智能体也安装在其他空间。该空间的安装者需要先移除安装,然后才能迁移。", - "placementOwnerMissing": "您需要先安装此智能体,才能迁移它。", + "blockedGeneric": "此智能体也安装在其他 Pod 中。需要该 Pod 的安装者先移除安装,才能迁移。", + "placementOwnerMissing": "你需要先将此智能体添加到你管理的 Pod,才能迁移它。", "placementUnknownInstaller": "旧版安装未记录安装者,因此暂时无法迁移此智能体。", - "hostedCannotMove": "托管智能体无法在您的电脑上运行。", + "hostedCannotMove": "托管智能体无法在你的电脑上运行。", "targetUnavailable": "这台电脑已不可用,请选择另一台电脑。", "placementChanged": "此智能体的电脑分配已变更。请先查看当前分配,再重新迁移。", "placementError": "无法加载此智能体的电脑分配信息。", diff --git a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx index 13ff2d107b..29e0adf1a3 100644 --- a/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx @@ -63,6 +63,12 @@ test('revoked Runtime row offers an active computer and moves in one request', a expect(axios.post).toHaveBeenCalledTimes(1); }); +test('without an active computer, the setup hint includes daemon installation', async () => { + renderSection(); + expect(await screen.findByText(/Register another computer to move this agent/)).toBeInTheDocument(); + expect(screen.getByText('npm i -g @commonlyai/cli@latest && commonly login && commonly daemon register && commonly daemon install')).toBeInTheDocument(); +}); + test('a changed installer refuses the move before release and explains the blocking installation', async () => { let placementReads = 0; axios.get.mockImplementation((url) => { diff --git a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx index be6477134a..7dad9b30e0 100644 --- a/frontend/src/v2/components/V2AgentRuntimeGrants.tsx +++ b/frontend/src/v2/components/V2AgentRuntimeGrants.tsx @@ -54,6 +54,7 @@ const WALKED_ADAPTERS: Record = { hermes: 'Hermes', dsh: 'DeepSeek Harness', }; +const DAEMON_SETUP_COMMAND = 'npm i -g @commonlyai/cli@latest && commonly login && commonly daemon register && commonly daemon install'; export const describeRuntime = (runtime: Runtime): { mode: 'hosted' | 'daemon' | 'cli' | 'unknown'; adapter: string | null } => { const type = String(runtime.runtimeType || '').toLowerCase(); @@ -91,7 +92,8 @@ const V2AgentRuntimeGrants: React.FC = ({ const placementFailure = (error: unknown, fallback: string): string => { const data = (error as PlacementFailure)?.response?.data; if (data?.code === 'another_installer') { - const blockers = (data.blockers || []).map(({ podName, installerName }) => t('agentManage.runtime.blocker', { podName, installerName })).join(', '); + const blockers = (data.blockers || []).map(({ podName, installerName }) => t('agentManage.runtime.blocker', { podName, installerName })) + .join(t('agentManage.runtime.blockerSeparator')); return blockers ? t('agentManage.runtime.blockedBy', { blockers }) : t('agentManage.runtime.blockedGeneric'); } const key: Record = { @@ -239,7 +241,7 @@ const V2AgentRuntimeGrants: React.FC = ({ - :

{t('agentManage.runtime.noActiveMachine')} commonly daemon register

+ :

{t('agentManage.runtime.noActiveMachine')} {DAEMON_SETUP_COMMAND}

)}
)} From 4c1ae15aa9c2e61884ac2f4846a4657084d9c18f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:33:42 -0700 Subject: [PATCH 5/6] Give self-serve placement fixture a live daemon credential Co-authored-by: rivet Agent-Model: codex/gpt-6-sol/medium --- backend/__tests__/service/self-serve-install.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/__tests__/service/self-serve-install.test.js b/backend/__tests__/service/self-serve-install.test.js index 3d20dcf9f7..96936c7f1e 100644 --- a/backend/__tests__/service/self-serve-install.test.js +++ b/backend/__tests__/service/self-serve-install.test.js @@ -21,6 +21,7 @@ const Pod = require('../../models/Pod'); const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry'); const AgentProfile = require('../../models/AgentProfile').default || require('../../models/AgentProfile'); const Machine = require('../../models/Machine'); +const AgentCredential = require('../../models/AgentCredential'); const registryRoutes = require('../../routes/registry'); const agentsRuntimeRoutes = require('../../routes/agentsRuntime'); @@ -164,6 +165,11 @@ describe('self-serve BYO install', () => { const machine = await Machine.create({ ownerUserId: podMember._id, machineId: 'self-serve-machine', name: 'My computer', }); + // Placement now requires a live daemon key, as a real registration mints. + await AgentCredential.create({ + kind: 'daemon', ownerUserId: podMember._id, machineId: machine.machineId, + tokenHash: 'a'.repeat(64), scopes: ['machine:heartbeat', 'agents:adopt'], + }); const installRes = await request(app) .post('/api/registry/install') .set('Authorization', `Bearer ${memberToken}`) From 27801e47647e271b207bf89c024d05b9e0b97838 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:38:34 -0700 Subject: [PATCH 6/6] Declare runtime blockers as opaque zh interpolation Co-authored-by: rivet Agent-Model: codex/gpt-6-sol/medium --- frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index ac382c1bbb..165638da27 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -44,6 +44,8 @@ const OPAQUE_PLACEHOLDERS = [ // common.aboutPage receives a page title, currently Latin, not a count. 'adapter', 'title', 'holder', + // Runtime ownership names the visible blocking Pods and installers. + 'blockers', ]; // The exception runs the safe way: a name that is usually opaque and is a number