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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Injected reply instructions now tell agents to answer only through this computer's Agent Relay route and never through another Agent Relay connector or app (such as Agent Relay in ChatGPT), which sends as a different identity.
- Claude Code sessions set up by `relay init` now read and answer relayed messages in Claude's default permission mode with no approval prompt: the SessionStart hook adds `permissions.allow` rules to `~/.claude/settings.json` that allow only the Agent Relay core's own read, reply, attachment-fetch and peer-list commands on its session socket, and Claude's reply hints are that single command instead of curl. The rules go when the hook is turned off; your own rules stay.
- `relay init --uninstall` now removes a `~/.claude/settings.json` it created (or restores yours byte for byte) and the OpenCode plugin's `agent-relay.js.off` marker when init installed the plugin.
- `relay init --uninstall` now removes the core's `~/.agentworkforce/desktop/pi-extension.off` marker and an empty `~/.pi/agent/extensions` folder when init installed the Pi extension, so the desktop app installs the extension and Pi sessions work again afterwards. A marker that was there before init is kept.
- `relay init --uninstall` now leaves `~/.codex` as it found it: it removes a `hooks.json` it created (or restores yours byte for byte), its `.relay-desktop-backup`, the Codex hook-trust entries it added to `config.toml`, and its sandbox rules file. Your own hooks, trust entries and rules are kept.

### Security
Expand Down
99 changes: 99 additions & 0 deletions packages/cli/src/cli/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,8 @@ function fakeCore(
claudeFiles?: string[];
/** The OpenCode plugin file `opencode_plugin` writes, and whose off marker it leaves. */
openCodePlugin?: string;
/** Pi's agent folder, where `pi_extension` writes the extension; off leaves the core's marker. */
piAgentDir?: string;
} = {}
) {
const settings: Record<string, unknown> = {
Expand Down Expand Up @@ -948,6 +950,21 @@ function fakeCore(
"Agent Relay's OpenCode plugin was turned off in Agent Relay's settings.\n"
);
};
/** As `PiSetup::set_extension`: on writes the extension, off removes it and leaves the marker. */
const writePiExtension = (key: string, value: unknown) => {
if (key !== 'pi_extension' || !options.piAgentDir) return;
const file = path.join(options.piAgentDir, 'extensions', 'agent-relay.js');
const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off');
if (value === true) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '// AGENT_RELAY_PI_EXTENSION=2\n');
fs.rmSync(marker, { force: true });
return;
}
fs.rmSync(file, { force: true });
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n");
};
const client: CoreClient = {
async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
calls.push([method, params]);
Expand All @@ -967,6 +984,7 @@ function fakeCore(
} else {
settings[key] = value;
writePlugin(key, value);
writePiExtension(key, value);
if (key === 'direct_delivery') {
userChoseInbound = false;
if (value === false) writeInbound('hold', 'accept');
Expand Down Expand Up @@ -1205,6 +1223,13 @@ describe('relay init message injection', () => {
expect(core.settings.opencode_auto_activate).toBe(false);
expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({
turnedOn: ['codex_stop_hook', 'auto_activate', 'pi_extension', 'pi_auto_activate'],
pi: {
file: path.join(toolHome, '.pi', 'agent', 'extensions', 'agent-relay.js'),
marker: path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off'),
extension: false,
off: false,
dir: false,
},
});

const toMcp = setup(core.deps);
Expand Down Expand Up @@ -1272,6 +1297,80 @@ describe('relay init message injection', () => {
expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false);
});

// The relay init end-to-end Pi run on 2026-10-30: --uninstall left
// ~/.agentworkforce/desktop/pi-extension.off, which keeps the desktop app
// from installing the extension (Pi sessions off), and an empty extensions folder.
it("leaves Pi's folder and the core's Pi opt-out as it found them on --uninstall", async () => {
installedProbe();
const agent = path.join(toolHome, '.pi', 'agent');
fs.mkdirSync(agent, { recursive: true });
fs.writeFileSync(path.join(agent, 'auth.json'), '{}');
const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off');
const core = fakeCore({ piAgentDir: agent });
await setup(core.deps).run('--tools', 'pi');
expect(fs.existsSync(path.join(agent, 'extensions', 'agent-relay.js'))).toBe(true);

await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall');
expect(core.settings).toMatchObject({
pi_extension: false,
pi_auto_activate: false,
auto_activate: false,
});
expect(fs.readdirSync(agent)).toEqual(['auth.json']);
expect(fs.existsSync(marker)).toBe(false);
expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false);
});

// Review on #2022: turning Pi off in the desktop app after init is the person's choice.
it('keeps a Pi opt-out marker the person left after init on --uninstall', async () => {
installedProbe();
const agent = path.join(toolHome, '.pi', 'agent');
fs.mkdirSync(agent, { recursive: true });
const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off');
const core = fakeCore({ piAgentDir: agent });
await setup(core.deps).run('--tools', 'pi');
// As the desktop app's Pi switch: the extension goes and the marker stays.
fs.rmSync(path.join(agent, 'extensions', 'agent-relay.js'));
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, 'off by me\n');
await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall');
expect(fs.readFileSync(marker, 'utf8')).toBe('off by me\n');
expect(fs.readdirSync(agent)).toEqual([]);
expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false);
});

it("keeps a Pi opt-out marker that links to the person's own file on --uninstall", async () => {
installedProbe();
const agent = path.join(toolHome, '.pi', 'agent');
fs.mkdirSync(agent, { recursive: true });
const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off');
const core = fakeCore({ piAgentDir: agent });
await setup(core.deps).run('--tools', 'pi');
fs.rmSync(path.join(agent, 'extensions', 'agent-relay.js'));
const mine = path.join(toolHome, 'pi-off.txt');
fs.writeFileSync(mine, 'off by me\n');
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.symlinkSync(mine, marker);
await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall');
expect(fs.lstatSync(marker).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(mine, 'utf8')).toBe('off by me\n');
});

it('keeps a Pi opt-out marker that was there before init', async () => {
installedProbe();
const agent = path.join(toolHome, '.pi', 'agent');
fs.mkdirSync(agent, { recursive: true });
const marker = path.join(toolHome, '.agentworkforce', 'desktop', 'pi-extension.off');
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, "Agent Relay's Pi extension was turned off (pi_extension).\n");
const core = fakeCore({ piAgentDir: agent });
await setup(core.deps).run('--tools', 'pi');
expect(fs.existsSync(marker)).toBe(false);
await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall');
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readdirSync(agent)).toEqual([]);
});

// Review on #2021: a tool rolled back in the same run is tidied too.
it('removes the Claude settings file it created when Claude is rolled back to MCP', async () => {
installedProbe();
Expand Down
59 changes: 56 additions & 3 deletions packages/cli/src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ import {
claudeFileStates,
openCodeConfigDir,
openCodeState,
piAgentDir,
piState,
tidyClaudeSettings,
tidyOpenCodePlugin,
tidyPiExtension,
type ClaudeFileState,
type OpenCodeState,
type PiState,
} from '../lib/init-leftovers.js';
import { persistWorkspaceSession } from '../lib/workspace-session.js';
import { readWorkspaceStore } from '../lib/workspace-store.js';
Expand Down Expand Up @@ -986,10 +990,12 @@ async function setupInjection(
// included, instead of the hold the core writes on each login.
const before = tools.includes('claude') ? claudeInboundValues(ctx) : {};
// The files the core is about to edit, as they are now: what --uninstall
// tidies back to (a settings.json or plugins folder init created).
// tidies back to (a settings.json, plugins or extensions folder, or off
// marker init created).
const files: Leftovers = {
claudeFiles: tools.includes('claude') ? claudeFileStates(claudeLoginSettings(ctx)) : undefined,
openCode: tools.includes('opencode') ? openCodeState(openCodeConfigDir(ctx.env, ctx.home)) : undefined,
pi: tools.includes('pi') ? piState(piAgentDir(ctx.env, ctx.home), ctx.home) : undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
const { results, turnedOn, turnedOff } = await enableDelivery(session.client, tools, record.turnedOn);
// Applied to the record as it is now, not as it was read: another
Expand Down Expand Up @@ -1019,6 +1025,7 @@ async function setupInjection(
openCode: touched.includes('opencode_plugin')
? (current.openCode ?? files.openCode)
: current.openCode,
pi: touched.includes('pi_extension') ? (current.pi ?? files.pi) : current.pi,
});
});
try {
Expand Down Expand Up @@ -1049,6 +1056,29 @@ async function setupInjection(
}
}

/** The bytes of the regular file `file` is or links to; undefined when there is none. */
function readIfFile(file: string): Buffer | undefined {
let fd: number | undefined;
try {
// One descriptor for the check and the read, so they see the same file.
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
return fs.fstatSync(fd).isFile() ? fs.readFileSync(fd) : undefined;
} catch {
return undefined;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}

/** Puts `bytes` back in `file`; a failure leaves the core's marker, which means the same. */
function restoreBytes(file: string, bytes: Buffer): void {
try {
fs.writeFileSync(file, bytes);
} catch {
// The core's own marker stays: Pi is still off.
}
}

/** Settings init turned on that are neither recorded nor turned off: setup stops. */
class InjectionStuckError extends Error {}

Expand Down Expand Up @@ -1076,6 +1106,9 @@ async function undoUnrecorded(
if (turnedOn.includes('opencode_plugin') && !failed('opencode_plugin') && files.openCode) {
tidyOpenCodePlugin(files.openCode);
}
if (turnedOn.includes('pi_extension') && !failed('pi_extension') && files.pi) {
tidyPiExtension(files.pi);
}
if (failures.length > 0) {
throw new InjectionStuckError(
`Could not save ${deliveryRecordPath()} (${cause.message}), and these settings relay init turned on ` +
Expand Down Expand Up @@ -1280,18 +1313,20 @@ const CLAUDE_FILE_KEYS = ['direct_delivery', 'claude_session_hook'];
interface Leftovers {
claudeFiles?: Record<string, ClaudeFileState>;
openCode?: OpenCodeState;
pi?: PiState;
}

/**
* Once nothing init turned on edits a tool's files any more, tidies them
* back to how init found them (a Claude settings.json init created, the
* OpenCode plugin's off marker) and drops their recorded state. Run after
* OpenCode plugin's and Pi extension's off markers) and drops their
* recorded state. Run after
* the core turned the settings off and the original crossSessionInbound is
* back.
* @returns The record without the states it used
*/
function tidyLeftovers(record: DeliveryRecord): DeliveryRecord {
let { claudeFiles, openCode } = record;
let { claudeFiles, openCode, pi } = record;
if (claudeFiles && !record.turnedOn.some((key) => CLAUDE_FILE_KEYS.includes(key))) {
tidyClaudeSettings(claudeFiles);
claudeFiles = undefined;
Expand All @@ -1300,11 +1335,16 @@ function tidyLeftovers(record: DeliveryRecord): DeliveryRecord {
tidyOpenCodePlugin(openCode);
openCode = undefined;
}
if (pi && !record.turnedOn.includes('pi_extension')) {
tidyPiExtension(pi);
pi = undefined;
}
return {
turnedOn: record.turnedOn,
claudeInbound: record.claudeInbound,
...(claudeFiles ? { claudeFiles } : {}),
...(openCode ? { openCode } : {}),
...(pi ? { pi } : {}),
};
}

Expand All @@ -1327,6 +1367,7 @@ function writeDeliveryRecord(record: DeliveryRecord): void {
...(Object.keys(record.claudeInbound).length > 0 ? { claudeInbound: record.claudeInbound } : {}),
...(record.claudeFiles ? { claudeFiles: record.claudeFiles } : {}),
...(record.openCode ? { openCode: record.openCode } : {}),
...(record.pi ? { pi: record.pi } : {}),
});
}

Expand Down Expand Up @@ -1358,13 +1399,25 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]):
throw new Error(`Could not reach the Agent Relay core to turn off message injection: ${session.reason}`);
}
try {
// A Pi off marker already there was the person's own opt-out after init
// (the desktop app's switch), not this run's: it is kept, byte for byte,
// though turning pi_extension off rewrites it.
const pi = readDeliveryRecord(deliveryRecordPath()).pi;
const piOptedOut =
keys.includes('pi_extension') &&
pi !== undefined &&
fs.lstatSync(pi.marker, { throwIfNoEntry: false }) !== undefined;
const piMarker = piOptedOut && pi ? readIfFile(pi.marker) : undefined;
const failures = await disableDelivery(session.client, keys);
if (pi && piMarker) restoreBytes(pi.marker, piMarker);
const done = keys.filter((key) => !failures.some((failure) => failure.startsWith(`${key}:`)));
const ctx = deps.toolContext();
updateDeliveryRecord((current) => {
if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx));
return tidyLeftovers({
...current,
// Kept as if it had been there before init; an extensions folder init created still goes.
...(piOptedOut && current.pi ? { pi: { ...current.pi, off: true } } : {}),
turnedOn: current.turnedOn.filter((key) => !done.includes(key)),
claudeInbound: done.includes('direct_delivery') ? {} : current.claudeInbound,
});
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/src/cli/lib/init-leftovers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
claudeFileStates,
openCodeConfigDir,
openCodeState,
piAgentDir,
piState,
tidyClaudeSettings,
tidyOpenCodePlugin,
tidyPiExtension,
} from './init-leftovers.js';

let root: string;
Expand Down Expand Up @@ -224,3 +227,87 @@ describe('tidyOpenCodePlugin', () => {
expect(fs.readFileSync(marker(state.file), 'utf8')).toBe('my note\n');
});
});

describe('tidyPiExtension', () => {
const coreOff = (marker: string) => {
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(
marker,
"Agent Relay's Pi extension was turned off (pi_extension). Delete this file, or turn " +
'Pi sessions on in Agent Relay, to allow it again.\n'
);
};

it('reads the Pi agent folder from an absolute PI_CODING_AGENT_DIR, else ~/.pi/agent, as the core does', () => {
expect(piAgentDir({ PI_CODING_AGENT_DIR: '/x/pi' }, '/home/me')).toBe('/x/pi');
expect(piAgentDir({ PI_CODING_AGENT_DIR: 'relative' }, '/home/me')).toBe('/home/me/.pi/agent');
expect(piAgentDir({}, '/home/me')).toBe('/home/me/.pi/agent');
});

// The relay init end-to-end Pi run on 2026-10-30: --uninstall left
// ~/.agentworkforce/desktop/pi-extension.off and an empty extensions folder.
it('removes the off marker an init-installed extension left, and an extensions folder init created', () => {
const agent = path.join(root, '.pi', 'agent');
fs.mkdirSync(agent, { recursive: true });
fs.writeFileSync(path.join(agent, 'auth.json'), '{}');
const before = tree(agent);
const state = piState(agent, root);
expect(state).toEqual({
file: path.join(agent, 'extensions', 'agent-relay.js'),
marker: path.join(root, '.agentworkforce', 'desktop', 'pi-extension.off'),
extension: false,
off: false,
dir: false,
});

fs.mkdirSync(path.dirname(state.file));
fs.writeFileSync(state.file, '// AGENT_RELAY_PI_EXTENSION=2\n');
fs.rmSync(state.file);
coreOff(state.marker);

tidyPiExtension(state);
expect(tree(agent)).toEqual(before);
expect(fs.existsSync(state.marker)).toBe(false);
});

it("keeps a marker that was there before init, the person's own extension and their extensions", () => {
const agent = path.join(root, '.pi', 'agent');
const extensions = path.join(agent, 'extensions');
fs.mkdirSync(extensions, { recursive: true });
const marker = path.join(root, '.agentworkforce', 'desktop', 'pi-extension.off');
coreOff(marker);
fs.rmdirSync(extensions);
const offBefore = piState(agent, root);
fs.mkdirSync(extensions);
tidyPiExtension(offBefore);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.existsSync(extensions)).toBe(false);
fs.mkdirSync(extensions);

fs.rmSync(marker);
const file = path.join(extensions, 'agent-relay.js');
fs.writeFileSync(file, '// mine\n');
const theirs = piState(agent, root);
coreOff(marker);
tidyPiExtension(theirs);
expect(fs.existsSync(marker)).toBe(true);

fs.rmSync(file);
fs.rmSync(marker);
fs.writeFileSync(path.join(extensions, 'other.ts'), '//\n');
const fresh = piState(agent, root);
coreOff(marker);
tidyPiExtension(fresh);
expect(fs.existsSync(marker)).toBe(false);
expect(fs.readdirSync(extensions)).toEqual(['other.ts']);
});

it('leaves a file at the marker name that the core did not write', () => {
const agent = path.join(root, '.pi', 'agent');
const state = piState(agent, root);
fs.mkdirSync(path.dirname(state.marker), { recursive: true });
fs.writeFileSync(state.marker, 'my note\n');
tidyPiExtension(state);
expect(fs.readFileSync(state.marker, 'utf8')).toBe('my note\n');
});
});
Loading
Loading