From 13125ae98f5f1065f6c63fb900afa0f12c5fc7f6 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 11 Oct 2026 01:13:47 +0300 Subject: [PATCH] Machines are saved by OpenAgent, and a run names its project The saved machines move from the browser's storage to the home file, and a machine's key is never handed to a browser. A run sent to another machine names its project by the address its repository was cloned from, and starts in that machine's own copy of it. Co-Authored-By: Claude Opus 5.5 --- packages/openagent/DECISIONS.md | 20 +- packages/openagent/dashboard/App.tsx | 2 +- .../components/AddDeviceDialog.test.tsx | 39 --- .../components/AddMachineDialog.test.tsx | 57 +++++ ...dDeviceDialog.tsx => AddMachineDialog.tsx} | 46 ++-- .../dashboard/components/AgentActionBar.tsx | 2 +- .../components/AgentHistory.test.tsx | 6 +- .../dashboard/components/AgentHistory.tsx | 10 +- .../dashboard/components/AgentView.test.tsx | 6 +- .../dashboard/components/AgentView.tsx | 20 +- .../dashboard/components/Composer.test.tsx | 75 +++--- .../dashboard/components/Composer.tsx | 82 ++++--- .../components/ConnectionIndicator.tsx | 32 +-- .../components/DevicesSettings.test.tsx | 73 ------ .../components/MachinesSettings.test.tsx | 84 +++++++ ...vicesSettings.tsx => MachinesSettings.tsx} | 72 +++--- .../components/RemoteAgentNotice.tsx | 10 +- .../dashboard/components/RunOnMenu.test.tsx | 72 ++++-- .../dashboard/components/RunOnMenu.tsx | 116 +++++---- .../dashboard/components/SessionLine.tsx | 2 +- .../components/SettingsPage.test.tsx | 5 +- .../dashboard/components/SettingsPage.tsx | 6 +- .../components/StartAgentButton.test.tsx | 2 +- .../components/StartAgentForm.test.tsx | 37 ++- .../dashboard/components/StartAgentForm.tsx | 51 ++-- .../dashboard/lib/connection.test.ts | 10 + .../openagent/dashboard/lib/connection.ts | 19 ++ .../dashboard/lib/draft-handoff.test.ts | 29 +-- .../openagent/dashboard/lib/draft-handoff.ts | 32 +-- .../openagent/dashboard/lib/machines.test.ts | 93 ++++++++ packages/openagent/dashboard/lib/machines.ts | 77 ++++++ .../openagent/dashboard/lib/profiles.test.ts | 96 -------- packages/openagent/dashboard/lib/profiles.ts | 173 -------------- .../openagent/dashboard/lib/remote-target.ts | 31 ++- .../dashboard/lib/use-device-status.test.tsx | 46 ---- .../dashboard/lib/use-device-status.ts | 39 --- .../dashboard/lib/use-live-events.ts | 2 +- .../dashboard/lib/use-machine-status.test.tsx | 45 ++++ .../dashboard/lib/use-machine-status.ts | 34 +++ packages/openagent/dashboard/rpc/devices.ts | 13 - packages/openagent/dashboard/rpc/machines.ts | 14 ++ packages/openagent/dashboard/test-machines.ts | 40 ++++ packages/openagent/src/client.ts | 2 + packages/openagent/src/daemon-runtime.test.ts | 84 ++++++- packages/openagent/src/daemon-runtime.ts | 128 ++++++---- packages/openagent/src/daemon.ts | 8 +- .../openagent/src/dashboard-rpc/context.ts | 8 +- .../openagent/src/dashboard-rpc/control.ts | 14 +- .../src/dashboard-rpc/devices.test.ts | 38 --- .../openagent/src/dashboard-rpc/devices.ts | 25 -- .../openagent/src/dashboard-rpc/events.ts | 2 +- packages/openagent/src/dashboard-rpc/index.ts | 6 +- .../src/dashboard-rpc/machines.test.ts | 92 +++++++ .../openagent/src/dashboard-rpc/machines.ts | 45 ++++ .../openagent/src/dashboard-rpc/modules.ts | 4 +- .../src/dashboard-rpc/projects.test.ts | 3 +- .../openagent/src/dashboard-rpc/projects.ts | 7 +- packages/openagent/src/dashboard-rpc/reads.ts | 2 +- .../src/dashboard-rpc/relay-agent.ts | 6 +- .../src/dashboard-rpc/relay-dispatch.test.ts | 12 +- .../src/dashboard-rpc/relay-dispatch.ts | 28 +-- packages/openagent/src/dashboard/http.ts | 2 +- .../src/dashboard/relay-endpoints.ts | 73 ++++-- .../dashboard/remote-run.integration.test.ts | 224 +++++++++++++----- .../src/dashboard/remote-run.test.ts | 77 +++--- .../openagent/src/dashboard/remote-run.ts | 88 ++++--- .../openagent/src/dashboard/rpc-serve.test.ts | 2 +- packages/openagent/src/dashboard/rpc-serve.ts | 13 +- .../openagent/src/dashboard/server.test.ts | 77 ++++-- packages/openagent/src/dashboard/server.ts | 24 +- packages/openagent/src/dashboard/types.ts | 16 +- packages/openagent/src/machine-url.test.ts | 16 ++ packages/openagent/src/machine-url.ts | 19 ++ packages/openagent/src/module-server.d.ts | 2 +- packages/openagent/src/registry.test.ts | 69 ++++++ packages/openagent/src/registry.ts | 108 ++++++++- .../openagent/src/repository-address.test.ts | 53 +++++ packages/openagent/src/repository-address.ts | 37 +++ packages/openagent/src/store/agent-store.ts | 4 +- 79 files changed, 1936 insertions(+), 1202 deletions(-) delete mode 100644 packages/openagent/dashboard/components/AddDeviceDialog.test.tsx create mode 100644 packages/openagent/dashboard/components/AddMachineDialog.test.tsx rename packages/openagent/dashboard/components/{AddDeviceDialog.tsx => AddMachineDialog.tsx} (53%) delete mode 100644 packages/openagent/dashboard/components/DevicesSettings.test.tsx create mode 100644 packages/openagent/dashboard/components/MachinesSettings.test.tsx rename packages/openagent/dashboard/components/{DevicesSettings.tsx => MachinesSettings.tsx} (50%) create mode 100644 packages/openagent/dashboard/lib/connection.test.ts create mode 100644 packages/openagent/dashboard/lib/connection.ts create mode 100644 packages/openagent/dashboard/lib/machines.test.ts create mode 100644 packages/openagent/dashboard/lib/machines.ts delete mode 100644 packages/openagent/dashboard/lib/profiles.test.ts delete mode 100644 packages/openagent/dashboard/lib/profiles.ts delete mode 100644 packages/openagent/dashboard/lib/use-device-status.test.tsx delete mode 100644 packages/openagent/dashboard/lib/use-device-status.ts create mode 100644 packages/openagent/dashboard/lib/use-machine-status.test.tsx create mode 100644 packages/openagent/dashboard/lib/use-machine-status.ts delete mode 100644 packages/openagent/dashboard/rpc/devices.ts create mode 100644 packages/openagent/dashboard/rpc/machines.ts create mode 100644 packages/openagent/dashboard/test-machines.ts delete mode 100644 packages/openagent/src/dashboard-rpc/devices.test.ts delete mode 100644 packages/openagent/src/dashboard-rpc/devices.ts create mode 100644 packages/openagent/src/dashboard-rpc/machines.test.ts create mode 100644 packages/openagent/src/dashboard-rpc/machines.ts create mode 100644 packages/openagent/src/machine-url.test.ts create mode 100644 packages/openagent/src/machine-url.ts create mode 100644 packages/openagent/src/repository-address.test.ts create mode 100644 packages/openagent/src/repository-address.ts diff --git a/packages/openagent/DECISIONS.md b/packages/openagent/DECISIONS.md index a838b0adc..db144d992 100644 --- a/packages/openagent/DECISIONS.md +++ b/packages/openagent/DECISIONS.md @@ -11,7 +11,7 @@ already made, and lists it in its pull request; for anything else it proposes an - A projection of files: it shows a project's runs by reading what the runs write, and it runs no agent of its own. Picked over the dashboard owning the agent it shows, which is what made every capability a person wanted — another coding agent, a schedule, a run on - a device — a feature of OpenAgent. + another machine — a feature of OpenAgent. - It ships no prompt text. What a project can be asked to do is what the project's own skills say. Picked over the built-in presets: a prompt that lives here cannot be read, changed or run by the agent working the repository. @@ -186,6 +186,24 @@ already made, and lists it in its pull request; for anything else it proposes an - No cap on a person's runs: the click is the brake. Picked over the one-run-per-checkout guard, which existed because runs shared a working tree and they no longer do. +## Running on another machine +- The machines a person saved are kept in their home file, beside their projects and their + settings, and a machine's key is never handed to a browser. Picked over the browser's own + storage, where each browser had its own list and the daemon, which makes the calls, did + not know the machines. +- A run sent to another machine names its project by the address its repository was cloned + from, and starts in that machine's own copy of it. Picked over the folder's name, where + two projects named alike are mixed up and a renamed folder breaks the link, and over + linking the two folders by hand, a step for every project on every machine. +- A machine that does not have the project starts nothing, and the person is told which + project to add there. Picked over starting in the folder that machine's OpenAgent was + started in, which may be another project. +- A project with no repository address runs on this machine only, and "Run on" says so. + Picked over sending the repository straight to the other machine, a second road for + code beside the git host. +- On screen, a computer that runs agents is a machine. Device is kept for a phone or a + browser that gets in. Picked over device for both. + ## Saying something to a run - What a person says reaches a run through what the run's tool reads: a line in the run's inbox file while the run works, the project's `resume` line once it has ended. Picked diff --git a/packages/openagent/dashboard/App.tsx b/packages/openagent/dashboard/App.tsx index b2c513a03..632909882 100644 --- a/packages/openagent/dashboard/App.tsx +++ b/packages/openagent/dashboard/App.tsx @@ -119,7 +119,7 @@ export function App() { // with the typed prompt at once, before the run's tool writes its card. `id` is the one the // project's start hook answered (#761) — the URL already points there, and this is what tells // the main pane that a session missing from the list is starting, not gone. - // `runsOn` names the device a just-started remote agent executes on (#1067), so the live view can + // `runsOn` names the machine a just-started remote agent executes on (#1067), so the live view can // mark where it runs and degrade the panels that are local-only. Undefined for a local agent. const [agentStart, setAgentStart] = useState<{ tick: number; intent: string; id: string | null; projectId: string | null; runsOn?: string }>({ tick: 0, intent: '', id: null, projectId: null }) // The agents of the project on screen, for its pages, or of the picked project, for the sidebar: diff --git a/packages/openagent/dashboard/components/AddDeviceDialog.test.tsx b/packages/openagent/dashboard/components/AddDeviceDialog.test.tsx deleted file mode 100644 index 11795d99d..000000000 --- a/packages/openagent/dashboard/components/AddDeviceDialog.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { AddDeviceDialog } from './AddDeviceDialog.js' -import { listProfiles } from '../lib/profiles.js' - -afterEach(() => { - cleanup() - localStorage.clear() -}) - -describe('AddDeviceDialog (#1052)', () => { - test('pasting a ?token= URL saves a profile and closes', () => { - const onClose = vi.fn() - const onAdded = vi.fn() - render() - fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200/?token=abc123' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add device' })) - expect(listProfiles()).toEqual([ - { id: 'http://192.168.1.5:4200', label: '192.168.1.5:4200', url: 'http://192.168.1.5:4200', token: 'abc123' }, - ]) - expect(onAdded).toHaveBeenCalled() - expect(onClose).toHaveBeenCalled() - }) - - test('a URL without a token cannot be saved', () => { - render() - fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200' } }) - expect((screen.getByRole('button', { name: 'Add device' }) as HTMLButtonElement).disabled).toBe(true) - expect(screen.getByText(/no token/i)).toBeTruthy() - }) - - test('an optional label overrides the host default', () => { - render() - fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://box:4200/?token=xyz' } }) - fireEvent.change(screen.getByPlaceholderText(/Name/), { target: { value: 'Workshop' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add device' })) - expect(listProfiles()[0]!.label).toBe('Workshop') - }) -}) diff --git a/packages/openagent/dashboard/components/AddMachineDialog.test.tsx b/packages/openagent/dashboard/components/AddMachineDialog.test.tsx new file mode 100644 index 000000000..6e7f887b6 --- /dev/null +++ b/packages/openagent/dashboard/components/AddMachineDialog.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' + +vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc) + +import { daemon, machinesRpc, saveMachines } from '../test-machines.js' +import { AddMachineDialog } from './AddMachineDialog.js' + +afterEach(async () => { + cleanup() + localStorage.clear() + await saveMachines() +}) + +describe('AddMachineDialog (#1052)', () => { + test('pasting a ?token= URL hands it to the daemon, which saves the machine, and closes', async () => { + const onClose = vi.fn() + const onAdded = vi.fn() + render() + fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200/?token=abc123' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add machine' })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(daemon.machines).toEqual([{ id: 'http://192.168.1.5:4200', label: '192.168.1.5:4200', url: 'http://192.168.1.5:4200' }]) + expect(daemon.tokens['http://192.168.1.5:4200']).toBe('abc123') + expect(onAdded).toHaveBeenCalled() + // The key is the daemon's to keep: none of it stays in the browser. + expect(localStorage.length).toBe(0) + }) + + test('a URL without a token cannot be saved', () => { + render() + fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200' } }) + expect((screen.getByRole('button', { name: 'Add machine' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByText(/no token/i)).toBeTruthy() + expect(machinesRpc.sendAddMachine).not.toHaveBeenCalled() + }) + + test('an optional label overrides the host default', async () => { + const onClose = vi.fn() + render() + fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://box:4200/?token=xyz' } }) + fireEvent.change(screen.getByPlaceholderText(/Name/), { target: { value: 'Workshop' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add machine' })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(daemon.machines[0]!.label).toBe('Workshop') + }) + + test('a save the daemon refuses is said, and the dialog stays open', async () => { + machinesRpc.sendAddMachine.mockResolvedValueOnce({ ok: false, error: 'The home file could not be written.' }) + const onClose = vi.fn() + render() + fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://box:4200/?token=xyz' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add machine' })) + expect((await screen.findByRole('alert')).textContent).toBe('The home file could not be written.') + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/packages/openagent/dashboard/components/AddDeviceDialog.tsx b/packages/openagent/dashboard/components/AddMachineDialog.tsx similarity index 53% rename from packages/openagent/dashboard/components/AddDeviceDialog.tsx rename to packages/openagent/dashboard/components/AddMachineDialog.tsx index d3f5249c9..9e008627d 100644 --- a/packages/openagent/dashboard/components/AddDeviceDialog.tsx +++ b/packages/openagent/dashboard/components/AddMachineDialog.tsx @@ -1,23 +1,30 @@ import { useState, type KeyboardEvent } from 'react' -import { addProfile, parseDeviceUrl } from '../lib/profiles.js' +import { parseMachineUrl } from '../../src/client.js' +import { addMachine } from '../lib/machines.js' import { Button } from './ui/button.js' import { Dialog } from './ui/dialog.js' -// The "Add a device" modal off the gear (#1052). A device is any reachable daemon — a LAN IP, a -// tailnet name, a tunnel URL — so the input is not a LAN-IP model but the full `?token=` URL the -// box prints on its non-loopback bind (cli.ts). We parse the origin and token out of one paste; -// the token is a per-browser secret, so it lands in localStorage (profiles.ts), never a server file. +// The "Add a machine" modal (#1052). A machine is any reachable daemon — a LAN IP, a tailnet name, +// a tunnel URL — so the input is not a LAN-IP model but the full `?token=` URL the other machine +// prints when it is opened to the network (cli.ts). The address and the key are read out of one +// paste and handed to the daemon, which keeps them: the key is not kept in the browser. -export function AddDeviceDialog({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) { +export function AddMachineDialog({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) { const [url, setUrl] = useState('') const [label, setLabel] = useState('') - const parsed = parseDeviceUrl(url) - // A pasted URL with no `?token=` cannot authenticate against a guarded box, so it is not savable. + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const parsed = parseMachineUrl(url) + // A pasted URL with no `?token=` cannot authenticate against a guarded machine, so it is not savable. const valid = parsed !== null && parsed.token !== '' - const save = () => { - if (!parsed || !valid) return - addProfile({ url: parsed.url, token: parsed.token, ...(label.trim() ? { label } : {}) }) + const save = async () => { + if (!valid || saving) return + setSaving(true) + setError(null) + const result = await addMachine(url, label.trim() || undefined).catch(() => ({ ok: false as const, error: 'The machine could not be saved.' })) + setSaving(false) + if (!result.ok) return setError(result.error) onAdded() onClose() } @@ -25,15 +32,15 @@ export function AddDeviceDialog({ onClose, onAdded }: { onClose: () => void; onA const onKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault() - save() + void save() } } return ( - { if (!next) onClose() }} title="Add a device"> + { if (!next) onClose() }} title="Add a machine">

- Paste the URL the box printed on its network bind (it looks like http://host:port/?token=…). + Paste the URL the other machine printed when it was opened to the network (it looks like http://host:port/?token=…).

void; onA /> {url.trim() !== '' && !valid && (

- {parsed === null ? 'That is not a valid URL.' : 'This URL has no token, so the box could not authenticate you.'} + {parsed === null ? 'That is not a valid URL.' : 'This URL has no token, so the machine would refuse every call.'} +

+ )} + {error && ( +

+ {error}

)}
-
diff --git a/packages/openagent/dashboard/components/AgentActionBar.tsx b/packages/openagent/dashboard/components/AgentActionBar.tsx index 205e08710..99fde8a94 100644 --- a/packages/openagent/dashboard/components/AgentActionBar.tsx +++ b/packages/openagent/dashboard/components/AgentActionBar.tsx @@ -34,7 +34,7 @@ export function AgentActionBar({ label?: string | undefined /** The session's project, said in the chip beside the name. */ projectName?: string | null | undefined - /** Where the session runs, in a few words: this machine, a device by its name, the cloud. */ + /** Where the session runs, in a few words: this machine, another machine by its name, the cloud. */ runsOn?: string /** True when this finished agent still has a worktree on disk, so it can be removed (#737). */ retainedWorktree?: boolean diff --git a/packages/openagent/dashboard/components/AgentHistory.test.tsx b/packages/openagent/dashboard/components/AgentHistory.test.tsx index 2837377c0..18405caca 100644 --- a/packages/openagent/dashboard/components/AgentHistory.test.tsx +++ b/packages/openagent/dashboard/components/AgentHistory.test.tsx @@ -17,7 +17,7 @@ const onAgent = vi.hoisted(() => vi.fn((_projectId: string, _agentId: string) => vi.mock('../rpc/reads.js', () => ({ onAgent })) // The rail now also carries the app chrome moved off the top navbar (#772 follow-up). Three of -// those pull the preferences/devices RPC stubs into jsdom, which this suite deliberately +// those pull the preferences/machines RPC stubs into jsdom, which this suite deliberately // avoids. It is about the agents list, not the chrome (each has its own suite), so stub them out. vi.mock('./ThemeToggle.js', () => ({ ThemeToggle: () => null })) vi.mock('./NotificationsMenu.js', () => ({ NotificationsMenu: () => null })) @@ -196,7 +196,7 @@ describe('AgentHistory (#785)', () => { // bespoke collapsing