Skip to content
Draft
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: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,12 @@ 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 - Minor]

### Added

- `relay codex run` starts a Relay-managed local Codex app-server/thread. Live session teleport is dark-launched behind the default-off local `RELAY_LIVE_SESSION_TELEPORT_ENABLED=true` switch, captured strictly from the ambient process environment before dotenv loading and reported by `relay codex status`; when enabled, `relay codex teleport` seals its active Relayfile poll mount at the next turn boundary, binds exact Relayfile destination verification to the source checkpoint, requires a 40-minute Cloud turn lease, and requires confirmed fencing plus ownership handback and mount readiness before local rollback. After notification loss, Relay never replays model execution: it durably redelivers reconciled output at least once, so an already-rendered live prefix can repeat, and it transparently runs the same prompt locally only after a confirmed pre-submission cutover failure.

## [Unreleased - Patch]

### Fixed
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,31 @@ agent-relay message post --channel general --text "hello"
agent-relay workspace list
```

## Relay-managed Codex teleport

`relay codex run` always supports a local managed Codex app-server session. Live
session teleport is dark-launched and remains disabled unless the controller
process starts with the exact local opt-in below:

```bash
RELAY_LIVE_SESSION_TELEPORT_ENABLED=true relay codex run
relay codex teleport
```

Unset, `false`, `TRUE`, whitespace-padded values, `1`, and all other values keep
new execution local: a fresh or already-local controller does not authenticate
to Cloud, prewarm, acquire, or route a turn remotely. The opt-in must come from
the ambient process environment; a cwd `.env` file cannot enable it. The
`teleport`, `rollback`, and `status` commands remain discoverable, but a disabled
controller rejects teleport requests. `relay codex status` reports the effective
startup switch and why it is enabled or disabled.

The flag is read when `relay codex run` starts. To roll back the capability,
stop the controller and restart it with the variable unset or set to `false`.
If the prior process left a prewarm or remote environment behind, startup first
revokes that Cloud generation and restores the same thread and Relayfile mount
locally; it does not prewarm again while disabled.

## This machine's node

The `node` command group manages the broker on your machine and the agents it runs:
Expand Down
30 changes: 29 additions & 1 deletion packages/cli/src/cli/bootstrap.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,8 @@ import path from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { createProgram, propagateTelemetryContextToChildren } from './bootstrap.js';
import { createProgram, loadCliEnvironment, propagateTelemetryContextToChildren } from './bootstrap.js';
import { RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV } from './lib/codex-live-controller.js';

const expectedLeafCommands = [
// node broker + agent group (local is a hidden alias, filtered out below)
Expand DownExpand Up@@ -40,6 +41,11 @@ const expectedLeafCommands = [
'reflex off',
'reflex status',
'session replay',
// Relay-managed Codex execution teleport
'codex run',
'codex teleport',
'codex rollback',
'codex status',
// fleet (serve is a hidden error stub, filtered out below)
'fleet agent list',
'fleet config',
Expand DownExpand Up@@ -198,6 +204,27 @@ describe('createProgram output redaction', () => {
});

describe('bootstrap CLI', () => {
it('does not let a cwd .env enable live teleport after the trusted startup capture', () => {
const originalCwd = process.cwd();
const originalValue = process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV];
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-codex-dotenv-'));
fs.writeFileSync(path.join(cwd, '.env'), `${RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV}=true\n`);
delete process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV];
process.chdir(cwd);

try {
const startupSwitch = loadCliEnvironment();

expect(startupSwitch).toEqual({ enabled: false, reason: 'ambient-unset' });
expect(process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV]).toBe('true');
} finally {
process.chdir(originalCwd);
if (originalValue === undefined) delete process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV];
else process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV] = originalValue;
fs.rmSync(cwd, { recursive: true, force: true });
}
});

it('uses the expected program name', () => {
const program = createProgram();
expect(program.name()).toBe('agent-relay');
Expand All@@ -221,6 +248,7 @@ describe('bootstrap CLI', () => {
'fleet',
'reflex',
'session',
'codex',
'status',
'observer',
'version',
Expand Down
30 changes: 27 additions & 3 deletions packages/cli/src/cli/bootstrap.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,11 +50,28 @@
import { registerFleetCommands } from './commands/fleet.js';
import { registerSkillsCommands } from './commands/skills.js';
import { registerSessionCommands } from './commands/session.js';
import { registerCodexCommands } from './commands/codex.js';
import {
DEFAULT_CODEX_LIVE_TELEPORT_STARTUP_SWITCH,
RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV,
resolveCodexLiveTeleportStartupSwitch,
type CodexLiveTeleportStartupSwitch,
} from './lib/codex-live-controller.js';

dotenvConfig({ quiet: true });
/**
* Capture the trusted local teleport switch before cwd dotenv loading can
* mutate process.env. Production calls this once, at the start of runCli.
*/
export function loadCliEnvironment(): CodexLiveTeleportStartupSwitch {
const liveTeleportStartupSwitch = resolveCodexLiveTeleportStartupSwitch(
process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV]
);
dotenvConfig({ quiet: true });
return liveTeleportStartupSwitch;
}

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

Check warning on line 73 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 74 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 DownExpand Up@@ -132,7 +149,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 152 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 DownExpand Up@@ -373,7 +390,9 @@
});
}

export function createProgram(options: { name?: string } = {}): Command {
export function createProgram(
options: { name?: string; liveTeleportStartupSwitch?: CodexLiveTeleportStartupSwitch } = {}
): Command {
const program = new Command();

// Commander echoes offending tokens verbatim (`error: unknown option
Expand DownExpand Up@@ -423,6 +442,10 @@
registerCapabilitiesCommands(program);
registerSkillsCommands(program);
registerSessionCommands(program);
registerCodexCommands(program, {
liveTeleportStartupSwitch:
options.liveTeleportStartupSwitch ?? DEFAULT_CODEX_LIVE_TELEPORT_STARTUP_SWITCH,
});

program
.command('mcp')
Expand DownExpand Up@@ -459,7 +482,7 @@
* tree so we can't drift if a new verb is added without updating both
* places.
*/
function collectTopLevelVerbs(program: Command): Set<string> {

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

View workflow job for this annotation

GitHub Actions/ lint

'collectTopLevelVerbs' is defined but never used. Allowed unused vars must match /^_/u
const verbs = new Set<string>();
for (const command of program.commands) {
verbs.add(command.name());
Expand All@@ -471,6 +494,7 @@
}

export async function runCli(argv: string[] = process.argv): Promise<Command> {
const liveTeleportStartupSwitch = loadCliEnvironment();
assertSupportedNodeVersion();
ensureWebSocketGlobal();
maybeRunUpdateCheck(VERSION, argv);
Expand All@@ -487,7 +511,7 @@
});
}

const program = createProgram({ name: resolveProgramName(argv) });
const program = createProgram({ name: resolveProgramName(argv), liveTeleportStartupSwitch });
installTelemetryHooks(program);
installExitHooks();

Expand Down
138 changes: 138 additions & 0 deletions packages/cli/src/cli/commands/codex-defaults.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';

import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => {
const cloudClient = {
prewarm: vi.fn(),
status: vi.fn(),
acquire: vi.fn(),
revoke: vi.fn(),
};
const appServer = {
initialize: vi.fn(async () => undefined),
startThread: vi.fn(async () => 'thread-local-1'),
resumeThread: vi.fn(async () => undefined),
addEnvironment: vi.fn(async () => undefined),
environmentStatus: vi.fn(async () => ({ status: 'ready' })),
runTurn: vi.fn(async () => {
const turn = {
id: 'turn-local-1',
status: 'completed',
itemsView: 'full',
items: [{ id: 'answer-1', type: 'agentMessage', text: 'local answer' }],
};
return {
turnId: turn.id,
response: { turn },
completed: { method: 'turn/completed', params: { threadId: 'thread-local-1', turn } },
};
}),
turnOutcome: vi.fn(async () => ({ status: 'absent' as const })),
close: vi.fn(async () => undefined),
};
const checkpointAndSeal = vi.fn();
return {
appServer,
checkpointAndSeal,
cloudClient,
cloudConstructor: vi.fn(function () {
return cloudClient;
}),
ensureCloudSession: vi.fn(),
probeCapability: vi.fn(),
resumePersistedLocalMount: vi.fn(async () => undefined),
};
});

vi.mock('@agent-relay/cloud', async (importOriginal) => ({
...(await importOriginal<typeof import('@agent-relay/cloud')>()),
CloudLiveTeleportClient: mocks.cloudConstructor,
ensureCloudSession: mocks.ensureCloudSession,
}));

vi.mock('../lib/codex-app-server.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/codex-app-server.js')>()),
probeCodexEnvironmentCapability: mocks.probeCapability,
StdioCodexAppServerSession: { spawn: vi.fn(async () => mocks.appServer) },
}));

vi.mock('../lib/codex-relayfile-seal.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/codex-relayfile-seal.js')>()),
createRelayfileSealLifecycle: () => ({
checkpointAndSeal: mocks.checkpointAndSeal,
resumePersistedLocalMount: mocks.resumePersistedLocalMount,
}),
}));

import { registerCodexCommands } from './codex.js';
import { RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV } from '../lib/codex-live-controller.js';

describe('default Relay-managed Codex command wiring', () => {
const temporaryRoots: string[] = [];
const originalFlag = process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV];
const originalStateDir = process.env.AGENT_RELAY_STATE_DIR;

afterEach(() => {
vi.clearAllMocks();
if (originalFlag === undefined) delete process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV];
else process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV] = originalFlag;
if (originalStateDir === undefined) delete process.env.AGENT_RELAY_STATE_DIR;
else process.env.AGENT_RELAY_STATE_DIR = originalStateDir;
for (const root of temporaryRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});

it('keeps a fresh durable run local when the captured startup switch is disabled', async () => {
// Unix-domain socket paths are short on macOS; /tmp keeps the production
// control socket below that limit while still exercising the real server.
const root = fs.mkdtempSync(path.join('/tmp', 'relay-codex-'));
temporaryRoots.push(root);
const workspace = path.join(root, 'workspace');
const stateDir = path.join(root, 'state');
fs.mkdirSync(workspace);
const workspaceRoot = fs.realpathSync(workspace);
process.env.AGENT_RELAY_STATE_DIR = stateDir;

// Simulate dotenv or later bootstrap code mutating process.env after the
// trusted ambient value was captured. Production wiring must use only the
// immutable switch passed into registration.
process.env[RELAY_LIVE_SESSION_TELEPORT_ENABLED_ENV] = 'true';
const program = new Command().exitOverride();
registerCodexCommands(program, {
liveTeleportStartupSwitch: { enabled: false, reason: 'ambient-unset' },
cwd: () => workspace,
input: Readable.from([]),
log: vi.fn(),
});
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

try {
await program.parseAsync(['node', 'relay', 'codex', 'run', 'stay local']);
} finally {
stderr.mockRestore();
}

expect(mocks.ensureCloudSession).not.toHaveBeenCalled();
expect(mocks.cloudConstructor).not.toHaveBeenCalled();
expect(mocks.probeCapability).not.toHaveBeenCalled();
expect(mocks.cloudClient.prewarm).not.toHaveBeenCalled();
expect(mocks.cloudClient.status).not.toHaveBeenCalled();
expect(mocks.cloudClient.acquire).not.toHaveBeenCalled();
expect(mocks.cloudClient.revoke).not.toHaveBeenCalled();
expect(mocks.checkpointAndSeal).not.toHaveBeenCalled();
expect(mocks.resumePersistedLocalMount).not.toHaveBeenCalled();
expect(mocks.appServer.initialize).toHaveBeenCalledOnce();
expect(mocks.appServer.startThread).toHaveBeenCalledWith({ cwd: workspaceRoot });
expect(mocks.appServer.addEnvironment).not.toHaveBeenCalled();
expect(mocks.appServer.runTurn).toHaveBeenCalledWith(
expect.objectContaining({
text: 'stay local',
execution: { kind: 'local', workspaceRoot },
})
);
expect(fs.existsSync(path.join(stateDir, 'codex-live', 'active.json'))).toBe(true);
});
});
Loading
Loading