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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased - Major]

### Added

- `relay init` lets two agents communicate without an account: it starts a private Relaycast server on this machine and creates a workspace. `relay init --hosted` creates a hosted workspace and prints the one command other machines run to join, and `relay init --url <url> --key <key>` connects to your own server on any HTTPS port. See `docs/quickstart.md`.
- `relay agent register` saves each agent's token, and `--as <name>` uses it on any messaging command, so agents no longer need `RELAY_AGENT_TOKEN` exported in every shell.

### Fixed

- `relay agent rotate <name>`, and `agent register` for a name that already exists, now issue a new token through the audited owner takeover instead of failing with "already exists", so a lost token can always be replaced.

### Security

- `agent-relay` no longer sends a self-hosted workspace key or agent token to the hosted service when `RELAY_BASE_URL` is unset in a later shell. A credential used with a self-hosted server is remembered (as a one-way fingerprint in `~/.agentworkforce/relay/relaycast-origins.json`, path prefix included) and sent back only to that server, and a persisted project route can no longer redirect it; `workspace create` now honours `RELAY_BASE_URL`; and a malformed `RELAY_BASE_URL` or `--base-url` is an error instead of a silent fallback to `https://cast.agentrelay.com`.
- Spawned Cursor, OpenCode, Gemini, and Grok agents no longer get the workspace key, agent token, or `RELAY_WORKSPACES_JSON` written into their config files (`.cursor/mcp.json`, `opencode.json`, `.gemini/settings.json`, `~/.grok/config.toml`). The files name each credential by environment variable, and the broker sets the values on the agent process. A later commit of the project can no longer publish live Relay credentials. The broker refuses to write a credential-shaped value into a project file, and it adds each project file it writes to `.git/info/exclude`. Rotate any key or token that an earlier version may have written into a repository.
- Muse workers' Relay MCP settings move from `<project>/.agent-relay/` to `~/.agentworkforce/relay/sessions/` (or `$AGENT_RELAY_HOME/sessions/`), and the broker removes them when the worker is released. A Muse worker that uses `RELAY_MUSE_ISOLATED_AUTH` has to log in again once.
- `agent-relay-broker mcp-args` output adds `requiredEnv`, which lists the credential variables (names only) that the caller must set on the agent process.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ Or install with npm:
npm install -g agent-relay
```

Then get two agents talking on your machine, with no account:

```bash
relay init
relay agent register alice && relay agent register bob
relay message dm send bob "hello" --as alice
```

The [quickstart](docs/quickstart.md) covers this, moving to the hosted service when agents on other machines need to join, and running your own server.

### Self Hosting

Agent Relay has self hosting options for each primitive; start with the [self-hosting guide](docs/self-hosting/README.md). We're happy to help you set up the whole system on your environment, just reach out to our team hi(at)agentrelay.com and we'll walk you through it.
Expand Down
96 changes: 96 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Quickstart: two agents talking in five commands

Relay runs the same way in three places. Start on your own machine with no
account, move to the hosted service when agents on other machines need to
join, or point at your own server. The commands after `relay init` are the
same everywhere.

**You need:** Node.js 22 or 24, and npm. (The local server's SQLite module has no prebuilt binary for newer Node releases yet.)

```bash
npm install -g agent-relay
```

## 1. On this machine (no account)

```bash
relay init
relay agent register alice
relay agent register bob
relay message dm send bob "hello" --as alice
relay message inbox check --as bob
relay message dm send alice "hi back" --as bob
```

`inbox check` lists unread messages with their ids and conversation ids. A
reply is just a DM in the other direction. Mark a message read with
`relay message inbox mark_read <message-id> --as bob`, and read a whole
conversation with `relay message dm list <conversation-id> --as bob`.

`relay init` starts a private Relaycast server on `http://127.0.0.1:8787`,
stores its data under `~/.agentworkforce/relay/local`. The server itself is
the standalone `relaycast-engine` in `~/.agentworkforce/relay/bin` when the
install script put one there, or is installed once under
`~/.agentworkforce/relay/engine`, and creates a workspace
called `local`. Nothing leaves your machine and there are no limits.

`relay agent register` prints the agent's token and also saves it, so
`--as <name>` works in any shell without exporting anything. If you lose a
token, run `relay agent rotate <name>` (or `register` again): it issues a new
one and the old one stops working.

Stop the server with `relay init --stop`. Run `relay init` again to restart it;
your workspace and agents are kept.

## 2. Across machines (hosted)

On the first machine:

```bash
relay init --hosted
```

It creates a hosted workspace and prints one command. Run that command on each
other machine:

```bash
relay workspace join <name> <key>
```

Then register agents and send messages exactly as above. Treat the key like a
password: anyone with it can join the workspace.

The hosted service has a free tier, enough to try agents across machines.

## 3. Your own server (self-hosted)

Run the Relaycast server ([self-hosting guide](self-hosting/README.md)), create
a workspace on it, then on each machine:

```bash
relay init --url https://relay.example.com:8443 --key <workspace-key>
```

Any HTTPS port works. The URL is saved, so later commands need no environment
variables, and the key is only ever sent to that server: Relay never falls back
to the hosted service, even if `RELAY_BASE_URL` is unset. If your server uses
a private certificate authority, set `NODE_EXTRA_CA_CERTS` to its certificate
file, and keep it set for every `relay` command (for example in your shell
profile): the CLI does not save it.

To run coding agents on a node (`agent-relay node up`) against your own server,
follow the [self-hosting guide](self-hosting/README.md); nodes need extra
setup today.

## Reading delivery status

A successful send reports `"status": "queued_unconfirmed"`. That is not an
error: Relay stored the message and it is in the recipient's inbox. "Unconfirmed"
only means nobody has read it yet. Check with
`relay message inbox get_readers <message-id>`; once the recipient has read it,
they are listed there. Do not resend a message just because of this status.

## Next

- Give your coding agents these tools through MCP: `relay mcp`.
- [Messaging docs](https://agentrelay.com/docs/introduction)
26 changes: 24 additions & 2 deletions docs/self-hosting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,30 @@ so read the limitations section before you depend on it for scale.
## Point Agent Relay at your Relaycast

Once your engine is running and you have created a workspace (the guide's
"Create your first workspace" step returns an `rk_live_…` workspace key), point
Agent Relay at it with environment variables. Keep the key out of shell history
"Create your first workspace" step returns an `rk_live_…` workspace key),
connect each machine's CLI to it once:

```bash
npm install -g agent-relay
relay init --url https://relay.example.com --key <workspace-key>
```

`relay init` checks the key against your server and saves the URL, so later
`relay agent …` and `relay message …` commands need no environment variables,
and the key is only ever sent to that server. See the
[quickstart](../quickstart.md) for the messaging commands.

### Running a node (`agent-relay node up`)

A node is the broker that spawns coding agents on this machine. It does not yet
read the URL `relay init` saves: it takes it from the environment, and **when
`RELAY_BASE_URL` is not set it falls back to the hosted
`https://cast.agentrelay.com` and sends your workspace key there.** Always start
it with the environment file below loaded. The broker also does not yet trust a
private certificate authority, so serve your engine with a certificate from a
public CA if you run nodes against it.

Point the node at your engine with environment variables. Keep the key out of shell history
and argv: prompt for it without echo and store it in a mode-600 env file (or
load it from your secret manager):

Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,21 @@ describe('registerAgentWithRebind', () => {
});

describe('optionsFromEnv', () => {
it('sends a key bound by relay init to its own server, not the hosted default', () => {
vi.stubEnv('RELAY_WORKSPACE_KEY', 'rk_live_mcp_bound');
vi.stubEnv('RELAY_BASE_URL', 'http://127.0.0.1:8787');
try {
// The first resolution binds the key, as relay init or workspace join does.
expect(optionsFromEnv().baseUrl).toBe('http://127.0.0.1:8787');
vi.stubEnv('RELAY_BASE_URL', '');
expect(optionsFromEnv().baseUrl).toBe('http://127.0.0.1:8787');
vi.stubEnv('RELAY_BASE_URL', 'not a url');
expect(() => optionsFromEnv()).toThrow(/Relaycast base URL/);
} finally {
vi.unstubAllEnvs();
}
});

it('auto-selects an orchestrator identity when a workspace key is configured', () => {
const previous = {
workspaceKey: process.env.RELAY_WORKSPACE_KEY,
Expand Down
21 changes: 15 additions & 6 deletions packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import { z } from 'zod';
import { declaredWorkforceMetadata } from './lib/registration-metadata.js';
import { isBundledBunEntrypointPath } from './lib/agent-relay-mcp-command.js';
import { guardCredentialOrigin } from './lib/relaycast-origin-binding.js';
import {
DEFAULT_AGENT_REGISTRATION_TIMEOUT_MS,
withAgentRegistrationDeadline,
Expand Down Expand Up @@ -319,7 +320,7 @@
return `${VERIFIED_SPAWN_MISSING_READY_MESSAGE} Resolved handler node: ${handler}; invocation: ${invocationId}.`;
}

async function waitForVerifiedSpawn(

Check warning on line 323 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'waitForVerifiedSpawn' has a complexity of 21. Maximum allowed is 15
actions: InvocationReader,
ackValue: unknown,
timeoutMs = VERIFIED_SPAWN_TIMEOUT_MS
Expand Down Expand Up @@ -643,7 +644,7 @@
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new Error(`Agent Relay result submission timed out after ${timeoutMs}ms`);

Check warning on line 647 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw err;
} finally {
Expand Down Expand Up @@ -696,7 +697,7 @@
return { agentName, agentToken };
}

export async function registerAgentWithRebind({

Check warning on line 700 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'registerAgentWithRebind' has a complexity of 29. Maximum allowed is 15
session,
setSession,
getRelay,
Expand Down Expand Up @@ -882,7 +883,7 @@
};
}

async function invokeVerifiedSpawn(

Check warning on line 886 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'invokeVerifiedSpawn' has a complexity of 23. Maximum allowed is 15
session: SessionState,
asIdentity: string | undefined,
baseUrl: string | undefined,
Expand Down Expand Up @@ -1611,7 +1612,7 @@
outputSchema: jsonResult,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
},
async ({ name, reason, delete_agent, wait, wait_timeout_ms, as }) => {

Check warning on line 1615 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async arrow function has a complexity of 22. Maximum allowed is 15
const session = getSession();
const selected = as ? session.agents.get(as) : undefined;
if (as && !selected) {
Expand Down Expand Up @@ -2009,14 +2010,22 @@
resolveEnv('RELAY_AGENT_NAME') ??
resolveEnv('RELAY_CLAW_NAME') ??
(workspaceKey ? 'orchestrator' : undefined);
// An agent token has no workspace identity encoded locally. Only reuse it
// when the workspace was selected alongside it through the environment;
// a persisted project/store fallback must register into that workspace
// instead of silently pairing it with a possibly stale ambient token.
const agentToken = resumedPersistedWorkspace ? undefined : resolveEnv('RELAY_AGENT_TOKEN');
// The same rule as the CLI: a credential bound to a self-hosted or local
// server (by `relay init`, `workspace join`, …) only ever goes back to it,
// so the MCP server never sends it to the hosted default.
const credential = workspaceKey ?? agentToken;
const baseUrl = credential
? guardCredentialOrigin(credential, resolveEnv('RELAY_BASE_URL'))
Comment on lines +2021 to +2023

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Token goes to wrong server

If both RELAY_WORKSPACE_KEY and RELAY_AGENT_TOKEN are set, optionsFromEnv checks only the key's saved server but sends the token there too. A hosted token alongside a stale self-hosted key previously used hosted; it now goes to the self-hosted server without checking the token's binding. This exposes the token to the wrong server and breaks messaging.

Check both credentials against the chosen destination and reject conflicts before creating clients.

How this was verified: The key alone selects baseUrl, while getAgentClient passes the unchanged token to that URL without another server check.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/src/cli/agent-relay-mcp.ts
Line: 2021-2023

Comment:
**Token goes to wrong server**

If both `RELAY_WORKSPACE_KEY` and `RELAY_AGENT_TOKEN` are set, `optionsFromEnv` checks only the key's saved server but sends the token there too. A hosted token alongside a stale self-hosted key previously used hosted; it now goes to the self-hosted server without checking the token's binding. This exposes the token to the wrong server and breaks messaging.

Check both credentials against the chosen destination and reject conflicts before creating clients.

**How this was verified:** The key alone selects `baseUrl`, while `getAgentClient` passes the unchanged token to that URL without another server check.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

: resolveEnv('RELAY_BASE_URL');
return {
workspaceKey,
baseUrl: resolveEnv('RELAY_BASE_URL'),
// An agent token has no workspace identity encoded locally. Only reuse it
// when the workspace was selected alongside it through the environment;
// a persisted project/store fallback must register into that workspace
// instead of silently pairing it with a possibly stale ambient token.
agentToken: resumedPersistedWorkspace ? undefined : resolveEnv('RELAY_AGENT_TOKEN'),
baseUrl,
agentToken,
agentName,
agentType: normalizeAgentType(resolveEnv('RELAY_AGENT_TYPE')),
strictAgentName: envFlagEnabled(resolveEnv('RELAY_STRICT_AGENT_NAME')),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const expectedLeafCommands = [
'cloud integration disconnect',
// workspace
'workspace create',
'init',
'workspace active',
'workspace list',
'workspace set_key',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import { registerLocalWorkflowCommands } from './commands/local-workflow.js';
import { registerCloudCommands } from './commands/cloud.js';
import { registerWorkspaceCommands } from './commands/workspace.js';
import { registerInitCommand } from './commands/init.js';
import { registerObserverCommands } from './commands/observer.js';
import { registerAgentCommands } from './commands/agent.js';
import { registerChannelCommands } from './commands/channel.js';
Expand All @@ -52,8 +53,8 @@

dotenvConfig({ quiet: true });

const __filename = fileURLToPath(import.meta.url);

Check warning on line 56 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

Variable name `__filename` trimmed as `_filename` must match one of the following formats: camelCase, UPPER_CASE, PascalCase
const __dirname = path.dirname(__filename);

Check warning on line 57 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

Variable name `__dirname` trimmed as `_dirname` must match one of the following formats: camelCase, UPPER_CASE, PascalCase

function findPackageJson(startDir: string): string {
let dir = startDir;
Expand Down Expand Up @@ -131,7 +132,7 @@
// Inherited from a parent process: leave it exactly as-is.
if (process.env[IDENTITY_ENV_KEYS.userId]) return;

let identity: CloudIdentity | null = null;

Check warning on line 135 in packages/cli/src/cli/bootstrap.ts

View workflow job for this annotation

GitHub Actions / lint

The value assigned to 'identity' is not used in subsequent statements
try {
identity = readStoredIdentitySync();
} catch {
Expand Down Expand Up @@ -417,6 +418,7 @@
registerStatusCommand(program);
registerSetupCommands(program);
registerCloudCommands(program);
registerInitCommand(program);
registerWorkspaceCommands(program);
registerObserverCommands(program);
registerAgentCommands(program);
Expand Down
89 changes: 87 additions & 2 deletions packages/cli/src/cli/commands/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@ function createHarness() {
};
const createAgentRelay = vi.fn(() => agentRelay);
const createWorkspaceRelay = vi.fn(() => workspaceRelay);
const takeOverAgent = vi.fn(async (_transport: unknown, name: string) => ({
id: 'agent_rotated',
name,
token: 'at_live_rotated',
}));
const log = vi.fn();
const error = vi.fn();
const program = new Command();
program.exitOverride();
registerAgentCommands(program, {
createAgentRelay: createAgentRelay as never,
createWorkspaceRelay: createWorkspaceRelay as never,
takeOverAgent: takeOverAgent as never,
log,
error,
exit: ((code: number) => {
Expand All @@ -45,6 +51,7 @@ function createHarness() {
workspaceRelay,
createAgentRelay,
createWorkspaceRelay,
takeOverAgent,
log,
error,
};
Expand Down Expand Up @@ -153,7 +160,7 @@ describe('agent identity lifecycle commands', () => {
});

it('exposes an explicit token rotation command for an existing name', async () => {
const { program, workspaceRelay } = createHarness();
const { program, workspaceRelay, takeOverAgent } = createHarness();

await program.parseAsync([
'node',
Expand All @@ -166,7 +173,85 @@ describe('agent identity lifecycle commands', () => {
]);

expect(workspaceRelay.agents.get).toHaveBeenCalledWith('chief');
expect(workspaceRelay.workspace.register).toHaveBeenCalledWith({ name: 'chief' });
// Relaycast refuses to re-register a taken name; rotation is the owner takeover.
expect(workspaceRelay.workspace.register).not.toHaveBeenCalled();
expect(takeOverAgent).toHaveBeenCalledWith(
expect.objectContaining({ workspaceKey: 'rk_live_test' }),
'chief',
'agent-relay agent rotate',
'agent_existing'
);
});

it('says when the old token could not be retired at once', async () => {
const { program, takeOverAgent, error } = createHarness();
takeOverAgent.mockResolvedValueOnce({
id: 'agent_rotated',
name: 'chief',
token: 'at_live_rotated',
oldTokenGraceRemains: true,
} as never);

await program.parseAsync([
'node',
'agent-relay',
'agent',
'rotate',
'chief',
'--workspace-key',
'rk_live_test',
]);

expect(error.mock.calls.flat().join('\n')).toContain('stops working within a minute');
});

it('register recovers an existing name with a new token instead of failing', async () => {
const { program, workspaceRelay, takeOverAgent, log } = createHarness();
workspaceRelay.workspace.register.mockRejectedValueOnce(
Object.assign(new Error('Agent "chief" already exists in this workspace'), {
code: 'agent_already_exists',
})
);

await program.parseAsync([
'node',
'agent-relay',
'agent',
'register',
'chief',
'--workspace-key',
'rk_live_test',
]);

expect(takeOverAgent).toHaveBeenCalledWith(
expect.objectContaining({ workspaceKey: 'rk_live_test' }),
'chief',
expect.stringContaining('agent register')
);
expect(log.mock.calls.flat().join('\n')).toContain('at_live_rotated');
});

it('register --strict still fails on an existing name', async () => {
const { program, workspaceRelay, takeOverAgent } = createHarness();
workspaceRelay.workspace.register.mockRejectedValueOnce(
Object.assign(new Error('Agent "chief" already exists in this workspace'), {
code: 'agent_already_exists',
})
);

await expect(
program.parseAsync([
'node',
'agent-relay',
'agent',
'register',
'chief',
'--strict',
'--workspace-key',
'rk_live_test',
])
).rejects.toThrow('exit:1');
expect(takeOverAgent).not.toHaveBeenCalled();
});

it('rejects rotation of a name that does not already exist instead of minting a new identity', async () => {
Expand Down
Loading
Loading