Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased - Patch]
## [Unreleased - Minor]

### Changed

- `npx -y @agent-relay/connect install` installs a signed standalone probe on a clean Mac without installing the GUI app.

### Fixed

Expand Down
22 changes: 14 additions & 8 deletions packages/connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ or a preinstalled skill:
Run this for me: `npx -y @agent-relay/connect join <link>`
```

The command reuses a live Agent Relay Desktop probe at version 2026.10.5 or
The command reuses a live Agent Relay probe at version 2026.10.5 or
newer on macOS, or 2026.10.4 or newer on Linux, when its pointer and socket are
owned by the current user and the pointer is not group- or world-writable.
Unsafe pointer or socket metadata fails closed.
If an existing socket or Relay process may be restarting, the command retries
liveness for up to 15 seconds. When no eligible probe is available, it
downloads the current release from `AgentWorkforce/relay-desktop-releases`,
verifies the adjacent SHA-256 file, and starts the probe without signing in.
A responsive old Linux probe instead fails with an update-required error so the
CLI never starts a second headless probe beside it.
A responsive old standalone probe fails with an update-required error so the
CLI never starts a second headless probe beside it. An installed macOS app keeps
its existing app update path.
If a Linux 2026.10.4 probe cannot identify the calling session during join,
send, status, or leave, the CLI tells the user to update Agent Relay rather than retrying the
same request. A live Linux headless probe cannot be replaced by this CLI while
Expand All @@ -26,9 +27,13 @@ it is running; update the running service separately.
`~/.local/lib/agent-relay/current`, a symlink at
`~/.local/bin/agent-relay-probe`, and a detached headless process. No `sudo`
is used.
- macOS x64 and arm64 use the signed DMG. The app is staged, verified with
`codesign --verify --deep --strict`, and installed in `/Applications` when
writable. `~/Applications` is an explicitly untested fallback.
- macOS x64 and arm64 use a standalone probe tarball notarized by the
publisher. The installer verifies its SHA-256 and the extracted binary's
pinned Developer ID before swapping it into `~/.local/lib/agent-relay/current` and starting
it headless. A clean Mac does not install the GUI app. If an app is already
installed but its probe needs updating, the existing signed DMG path updates
that app. An app in `/Applications` that this user cannot replace requires
an administrator update or moving the app to `~/Applications`.
- For Claude Code, starting the probe sets `"crossSessionInbound": "accept"`
in `~/.claude/settings.json` so replies can be injected into the live
session. The command never signs the user in.
Expand Down Expand Up @@ -57,9 +62,10 @@ curl -fsS --unix-socket "$S" -X POST http://relay/setup/direct-delivery \
-H 'content-type: application/json' -d '{"enabled":false}'
```

On Linux, stop the `agent-relay-probe` process, then remove only
For a standalone install on Linux or macOS, stop the `agent-relay-probe`
process, then remove only
`~/.local/bin/agent-relay-probe` and `~/.local/lib/agent-relay/current`. Remove
the pointer under `~/.agentworkforce/desktop/relay-socket` only after the probe
has stopped. On macOS, quit Agent Relay and move the installed app from
has stopped. For an installed macOS app, quit Agent Relay and move the app from
`/Applications` (or `~/Applications`) to Trash. The CLI never removes Relay
account data, keys, or other Relay installations.
136 changes: 114 additions & 22 deletions packages/connect/src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import { basename, join, relative } from 'node:path';
import { basename, dirname, join, relative } from 'node:path';
import { requestJson } from './http.js';

const RELEASE = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download';
Expand Down Expand Up @@ -54,13 +54,19 @@ export function getPlatformAsset(platform = process.platform, arch = process.arc
throw new InstallError(`Unsupported Linux architecture: ${arch}`, 2);
}
if (platform === 'darwin') {
if (arch === 'x64') return 'AgentRelay-macOS-x64.dmg';
if (arch === 'arm64') return 'AgentRelay-macOS-arm64.dmg';
if (arch === 'x64') return 'AgentRelay-macOS-x64-probe.tar.gz';
if (arch === 'arm64') return 'AgentRelay-macOS-arm64-probe.tar.gz';
throw new InstallError(`Unsupported macOS architecture: ${arch}`, 2);
}
throw new InstallError(`Unsupported platform: ${platform}`, 2);
}

export function getMacAppAsset(arch = process.arch) {
if (arch === 'x64') return 'AgentRelay-macOS-x64.dmg';
if (arch === 'arm64') return 'AgentRelay-macOS-arm64.dmg';
throw new InstallError(`Unsupported macOS architecture: ${arch}`, 2);
}

export function downloadCommands(platform, tmpDir, asset) {
const destination = join(tmpDir, asset);
const checksum = `${destination}.sha256`;
Expand Down Expand Up @@ -92,6 +98,14 @@ export async function verifyMacSignature(staged, run = runCommand) {
}
}

export async function verifyMacProbeSignature(staged, run = runCommand) {
try {
await run('codesign', ['--verify', '--strict', `-R=${MAC_SIGNING_REQUIREMENT}`, staged]);
} catch {
throw new InstallError('Agent Relay probe is not signed by Agent Workforce; refusing installation.');
}
}

export async function runCommand(file, args, { cwd, capture = false, allowFailure = false } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(file, args, {
Expand Down Expand Up @@ -371,15 +385,17 @@ export async function finishSwap(destination, swap, ready, options = {}) {
}
}

export async function installLinux({
async function installHeadless({
home,
platform,
arch,
run,
start = startDetachedProbe,
wait = waitForLiveSocket,
require = requireCommands,
}) {
await requireCommands(['curl', 'sha256sum', 'tar']);
const asset = getPlatformAsset('linux', arch);
await require(platform === 'darwin' ? ['codesign', 'curl', 'shasum', 'tar'] : ['curl', 'sha256sum', 'tar']);
const asset = getPlatformAsset(platform, arch);
const tmpDir = await mkdtemp(join(os.tmpdir(), 'agent-relay-connect-'));
let child;
let ready = false;
Expand All @@ -392,14 +408,23 @@ export async function installLinux({
const installDir = join(installRoot, 'current');
const stagedDir = join(installRoot, 'current.new');
try {
await downloadAndVerify('linux', tmpDir, asset, run);
await downloadAndVerify(platform, tmpDir, asset, run);
await mkdir(installRoot, { recursive: true });
await rm(stagedDir, { recursive: true, force: true });
await mkdir(stagedDir, { recursive: true });
await run('tar', ['-xzf', join(tmpDir, asset), '-C', stagedDir]);
const archive = join(tmpDir, asset);
// The Mac archive needs only its signed helper. Extracting a named member
// prevents any additional archive entries from writing into this HOME.
await run(
'tar',
platform === 'darwin'
? ['-xzf', archive, '-C', stagedDir, 'agent_relay/helpers/agent-relay-probe']
: ['-xzf', archive, '-C', stagedDir]
);

const stagedProbe = await findProbe(stagedDir);
if (!stagedProbe) throw new InstallError(`agent-relay-probe not found or not executable in ${asset}.`);
if (platform === 'darwin') await verifyMacProbeSignature(stagedProbe, run);
const probeRelativePath = relative(stagedDir, stagedProbe);
swap = await swapWithBackup(installDir, stagedDir);
const probe = join(installDir, probeRelativePath);
Expand All @@ -413,7 +438,10 @@ export async function installLinux({
try {
previousLinkTarget = await readlink(link);
} catch (error) {
if (error?.code !== 'ENOENT' && error?.code !== 'EINVAL') throw error;
if (error?.code === 'EINVAL') {
throw new InstallError(`Refusing to replace a non-symlink at ${link}.`);
}
if (error?.code !== 'ENOENT') throw error;
Comment thread
miyaontherelay marked this conversation as resolved.
}
await rm(link, { force: true });
await symlink(probe, link);
Expand All @@ -429,7 +457,7 @@ export async function installLinux({
await logHandle.close();
}

const result = await wait({ home });
const result = await wait({ home, minimumVersion: minimumProbeVersion(platform) });
ready = true;
await finishSwap(installDir, swap, true);
return result;
Expand Down Expand Up @@ -473,6 +501,14 @@ export async function installLinux({
}
}

export async function installLinux(options) {
return installHeadless({ ...options, platform: 'linux' });
}

export async function installMacProbe(options) {
return installHeadless({ ...options, platform: 'darwin' });
}

async function processRunning(run) {
const result = await run('pgrep', ['-x', 'RelayDesktop'], { allowFailure: true });
return result.code === 0;
Expand Down Expand Up @@ -642,11 +678,22 @@ export async function installMac({
arch,
run,
warn,
appPath = null,
wait = waitForLiveSocket,
require = requireCommands,
accessPath = access,
}) {
if (appPath) {
try {
await accessPath(dirname(appPath), constants.W_OK);
} catch {
throw new InstallError(
`Cannot replace Agent Relay at ${appPath}; ask an administrator to update it or move the app to ~/Applications.`
);
}
}
await require(['codesign', 'curl', 'ditto', 'hdiutil', 'open', 'osascript', 'pgrep', 'shasum']);
const asset = getPlatformAsset('darwin', arch);
const asset = getMacAppAsset(arch);
const tmpDir = await mkdtemp(join(os.tmpdir(), 'agent-relay-connect-'));
let volume = '';
let staged = '';
Expand All @@ -667,14 +714,16 @@ export async function installMac({

await quitRelayDesktop(run);

app = '/Applications/Agent Relay.app';
try {
await access('/Applications', constants.W_OK);
} catch {
const applications = join(home, 'Applications');
await mkdir(applications, { recursive: true });
app = join(applications, 'Agent Relay.app');
warn(`Using the untested per-user Applications fallback: ${app}`);
app = appPath || '/Applications/Agent Relay.app';
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (!appPath) {
Comment thread
miyaontherelay marked this conversation as resolved.
try {
await access('/Applications', constants.W_OK);
} catch {
const applications = join(home, 'Applications');
await mkdir(applications, { recursive: true });
app = join(applications, 'Agent Relay.app');
warn(`Using the untested per-user Applications fallback: ${app}`);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

staged = `${app}.new`;
Expand Down Expand Up @@ -718,6 +767,30 @@ export async function installMac({
}
}

export async function findInstalledMacApp(home = os.homedir()) {
for (const app of ['/Applications/Agent Relay.app', join(home, 'Applications/Agent Relay.app')]) {
try {
if ((await stat(app)).isDirectory()) return app;
} catch (error) {
if (error?.code !== 'ENOENT') {
throw new InstallError(`Could not inspect the Agent Relay app: ${error.message}`);
}
}
}
return null;
}

export async function hasStandaloneMacProbe(home = os.homedir()) {
const link = join(home, '.local/bin/agent-relay-probe');
try {
const target = await readlink(link);
return target.startsWith(`${join(home, '.local/lib/agent-relay')}/`);
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw new InstallError(`Could not inspect the standalone Agent Relay probe: ${error.message}`);
}
}

export async function ensureProbe({
home = os.homedir(),
platform = process.platform,
Expand All @@ -731,7 +804,10 @@ export async function ensureProbe({
start = startDetachedProbe,
wait = waitForLiveSocket,
installLinuxFn = installLinux,
installMacProbeFn = installMacProbe,
installMacFn = installMac,
installedMacApp = findInstalledMacApp,
standaloneMacProbe = hasStandaloneMacProbe,
acquire = acquireInstallLock,
} = {}) {
const existing = await findRecoveringProbe({ home, find, run, active, now, sleep });
Expand All @@ -747,17 +823,33 @@ export async function ensureProbe({

const installLock = await acquire({ home, platform, now, sleep });
try {
const appPath = platform === 'darwin' ? await installedMacApp(home) : null;
const afterLock = await find(home, 1_000);
if (probeSupportedOnPlatform(afterLock, platform)) {
return { ...afterLock, installed: false };
} else if (afterLock && platform === 'linux') {
requireSupportedProbe(afterLock, platform);
}
// A short lookup can miss a core observed immediately before the lock.
// Keep that unsupported observation until the guarded install decision.
const unsupported = afterLock || existing;
if (platform === 'linux') {
if (unsupported) requireSupportedProbe(unsupported, platform);
} else if (
platform === 'darwin' &&
unsupported &&
!probeSupportedOnPlatform(unsupported, platform) &&
(!appPath || (await standaloneMacProbe(home)))
) {
// An old standalone core may still own the socket even if the app is
// installed. Updating the app would leave that core running beside it.
Comment thread
cursor[bot] marked this conversation as resolved.
requireSupportedProbe(unsupported, platform);
}

const result =
platform === 'linux'
? await installLinuxFn({ home, arch, run, start, wait })
: await installMacFn({ home, arch, run, warn, wait });
: appPath
? await installMacFn({ home, arch, run, warn, wait, appPath })
: await installMacProbeFn({ home, arch, run, start, wait });
return { ...result, installed: true };
} finally {
await installLock.release();
Expand Down
Loading
Loading