@phnx-labs/agents-cli 1.18.6 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.md +22 -20
  3. package/dist/commands/browser.js +25 -2
  4. package/dist/commands/cloud.js +3 -3
  5. package/dist/commands/computer.d.ts +6 -0
  6. package/dist/commands/computer.js +477 -0
  7. package/dist/commands/doctor.js +19 -17
  8. package/dist/commands/exec.js +37 -59
  9. package/dist/commands/factory.js +12 -5
  10. package/dist/commands/import.js +6 -1
  11. package/dist/commands/mcp.js +9 -4
  12. package/dist/commands/packages.d.ts +3 -0
  13. package/dist/commands/packages.js +20 -12
  14. package/dist/commands/permissions.d.ts +2 -0
  15. package/dist/commands/permissions.js +20 -1
  16. package/dist/commands/plugins.d.ts +2 -0
  17. package/dist/commands/plugins.js +23 -4
  18. package/dist/commands/profiles.js +1 -1
  19. package/dist/commands/pty.js +126 -112
  20. package/dist/commands/pull.js +29 -25
  21. package/dist/commands/repo.js +24 -26
  22. package/dist/commands/routines.js +29 -26
  23. package/dist/commands/secrets.js +66 -73
  24. package/dist/commands/sessions-tail.js +21 -22
  25. package/dist/commands/sessions.js +36 -68
  26. package/dist/commands/setup.js +20 -24
  27. package/dist/commands/teams.js +30 -39
  28. package/dist/commands/versions.js +60 -68
  29. package/dist/commands/worktree.d.ts +20 -0
  30. package/dist/commands/worktree.js +242 -0
  31. package/dist/computer.d.ts +2 -0
  32. package/dist/computer.js +7 -0
  33. package/dist/index.js +70 -26
  34. package/dist/lib/agents.d.ts +4 -1
  35. package/dist/lib/agents.js +23 -5
  36. package/dist/lib/browser/cdp.d.ts +15 -1
  37. package/dist/lib/browser/cdp.js +77 -8
  38. package/dist/lib/browser/chrome.js +17 -24
  39. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  40. package/dist/lib/browser/drivers/ssh.js +20 -8
  41. package/dist/lib/browser/ipc.js +38 -5
  42. package/dist/lib/browser/profiles.js +34 -2
  43. package/dist/lib/browser/runtime-state.d.ts +1 -2
  44. package/dist/lib/browser/runtime-state.js +11 -3
  45. package/dist/lib/browser/service.d.ts +5 -0
  46. package/dist/lib/browser/service.js +32 -4
  47. package/dist/lib/browser/types.d.ts +1 -1
  48. package/dist/lib/browser/upload.d.ts +2 -0
  49. package/dist/lib/browser/upload.js +34 -0
  50. package/dist/lib/cloud/rush.d.ts +2 -1
  51. package/dist/lib/cloud/rush.js +28 -9
  52. package/dist/lib/computer-rpc.d.ts +24 -0
  53. package/dist/lib/computer-rpc.js +263 -0
  54. package/dist/lib/daemon.js +7 -7
  55. package/dist/lib/exec.d.ts +2 -1
  56. package/dist/lib/exec.js +3 -2
  57. package/dist/lib/fs-atomic.d.ts +18 -0
  58. package/dist/lib/fs-atomic.js +76 -0
  59. package/dist/lib/git.js +2 -4
  60. package/dist/lib/help.d.ts +15 -0
  61. package/dist/lib/help.js +41 -0
  62. package/dist/lib/hooks/match.d.ts +1 -0
  63. package/dist/lib/hooks/match.js +57 -12
  64. package/dist/lib/hooks.d.ts +1 -0
  65. package/dist/lib/hooks.js +27 -10
  66. package/dist/lib/import.d.ts +1 -0
  67. package/dist/lib/import.js +7 -0
  68. package/dist/lib/manifest.js +27 -1
  69. package/dist/lib/mcp.d.ts +14 -0
  70. package/dist/lib/mcp.js +79 -14
  71. package/dist/lib/migrate.js +3 -3
  72. package/dist/lib/models.js +3 -1
  73. package/dist/lib/permissions.d.ts +5 -0
  74. package/dist/lib/permissions.js +35 -0
  75. package/dist/lib/plugin-marketplace.d.ts +3 -1
  76. package/dist/lib/plugin-marketplace.js +36 -1
  77. package/dist/lib/plugins.d.ts +19 -1
  78. package/dist/lib/plugins.js +99 -8
  79. package/dist/lib/redact.d.ts +4 -0
  80. package/dist/lib/redact.js +18 -0
  81. package/dist/lib/registry.d.ts +2 -0
  82. package/dist/lib/registry.js +15 -0
  83. package/dist/lib/sandbox.js +15 -5
  84. package/dist/lib/secrets/bundles.d.ts +7 -12
  85. package/dist/lib/secrets/bundles.js +45 -29
  86. package/dist/lib/secrets/index.js +4 -4
  87. package/dist/lib/session/cloud.d.ts +2 -0
  88. package/dist/lib/session/cloud.js +34 -6
  89. package/dist/lib/session/parse.js +7 -2
  90. package/dist/lib/session/render.d.ts +4 -1
  91. package/dist/lib/session/render.js +81 -35
  92. package/dist/lib/shims.d.ts +5 -2
  93. package/dist/lib/shims.js +29 -7
  94. package/dist/lib/state.d.ts +5 -5
  95. package/dist/lib/state.js +43 -13
  96. package/dist/lib/teams/agents.js +1 -1
  97. package/dist/lib/types.d.ts +4 -3
  98. package/dist/lib/types.js +0 -2
  99. package/dist/lib/versions.js +65 -40
  100. package/dist/lib/workflows.d.ts +7 -0
  101. package/dist/lib/workflows.js +42 -1
  102. package/npm-shrinkwrap.json +3256 -0
  103. package/package.json +32 -26
  104. package/scripts/postinstall.js +8 -2
@@ -169,11 +169,12 @@ export const AGENTS = {
169
169
  commandsSubdir: 'prompts',
170
170
  skillsDir: path.join(HOME, '.codex', 'skills'),
171
171
  hooksDir: 'hooks',
172
+ pluginManifestDir: '.codex-plugin',
172
173
  instructionsFile: 'AGENTS.md',
173
174
  format: 'markdown',
174
175
  variableSyntax: '$ARGUMENTS',
175
176
  supportsHooks: true,
176
- capabilities: { hooks: { since: '0.116.0' }, mcp: true, allowlist: false, skills: true, commands: { until: '0.117.0' }, plugins: false },
177
+ capabilities: { hooks: { since: '0.116.0' }, mcp: true, allowlist: false, skills: true, commands: { until: '0.117.0' }, plugins: { since: '0.128.0' } },
177
178
  },
178
179
  gemini: {
179
180
  id: 'gemini',
@@ -806,6 +807,12 @@ export async function registerMcp(agentId, name, command, scope = 'user', transp
806
807
  if (!agent.capabilities.mcp) {
807
808
  return { success: false, error: 'Agent does not support MCP' };
808
809
  }
810
+ if (transport === 'http' && agentId !== 'claude' && agentId !== 'codex' && agentId !== 'gemini') {
811
+ return { success: false, error: 'skipped: agent does not support HTTP MCP registration' };
812
+ }
813
+ if (transport === 'http' && options?.headers && Object.keys(options.headers).length > 0 && agentId !== 'claude') {
814
+ return { success: false, error: 'skipped: HTTP MCP headers are only supported for Claude registration' };
815
+ }
809
816
  if (!options?.binary && !(await isCliInstalled(agentId))) {
810
817
  return { success: false, error: 'CLI not installed' };
811
818
  }
@@ -813,11 +820,21 @@ export async function registerMcp(agentId, name, command, scope = 'user', transp
813
820
  // Use explicit binary path when provided (bypasses shim for version-managed agents)
814
821
  const bin = options?.binary || agent.cliCommand;
815
822
  let args;
816
- const commandArgs = splitCommandLine(command);
817
- if (agentId === 'claude') {
823
+ if (transport === 'http') {
824
+ if (agentId === 'codex') {
825
+ args = ['mcp', 'add', name, '--url', command];
826
+ }
827
+ else {
828
+ const headerArgs = Object.entries(options?.headers || {}).flatMap(([key, value]) => ['--header', `${key}: ${value}`]);
829
+ args = ['mcp', 'add', '--transport', 'http', '--scope', scope, name, command, ...headerArgs];
830
+ }
831
+ }
832
+ else if (agentId === 'claude') {
833
+ const commandArgs = splitCommandLine(command);
818
834
  args = ['mcp', 'add', '--transport', transport, '--scope', scope, name, '--', ...commandArgs];
819
835
  }
820
836
  else {
837
+ const commandArgs = splitCommandLine(command);
821
838
  args = ['mcp', 'add', name, '--', ...commandArgs];
822
839
  }
823
840
  // When home is specified, override HOME so MCP config writes to the version's config dir
@@ -852,15 +869,16 @@ export async function unregisterMcp(agentId, name, options) {
852
869
  * Register an MCP server across multiple agent targets, including both direct
853
870
  * (non-version-managed) agents and specific version-managed installs.
854
871
  */
855
- export async function registerMcpToTargets(targets, name, command, scope = 'user', transport = 'stdio') {
872
+ export async function registerMcpToTargets(targets, name, command, scope = 'user', transport = 'stdio', options = {}) {
856
873
  const results = [];
857
874
  for (const agentId of targets.directAgents) {
858
- const result = await registerMcp(agentId, name, command, scope, transport);
875
+ const result = await registerMcp(agentId, name, command, scope, transport, options);
859
876
  results.push({ agentId, success: result.success, error: result.error });
860
877
  }
861
878
  for (const [agentId, versions] of targets.versionSelections) {
862
879
  for (const version of versions) {
863
880
  const result = await registerMcp(agentId, name, command, scope, transport, {
881
+ ...options,
864
882
  home: getVersionHomePath(agentId, version),
865
883
  binary: getBinaryPath(agentId, version),
866
884
  });
@@ -1,16 +1,30 @@
1
+ import type { Readable, Writable } from 'stream';
2
+ export interface CDPPipeTransport {
3
+ read: Readable;
4
+ write: Writable;
5
+ }
1
6
  type EventHandler = (params: Record<string, unknown>) => void;
7
+ export declare function registerPipeTransport(transport: CDPPipeTransport): string;
2
8
  export declare class CDPClient {
3
9
  private ws;
10
+ private pipe;
11
+ private pipeBuffer;
12
+ private transport;
4
13
  private messageId;
5
14
  private pending;
6
15
  private eventHandlers;
7
- connect(wsUrl: string): Promise<void>;
16
+ private pipeDataHandler;
17
+ private pipeErrorHandler;
18
+ private pipeCloseHandler;
19
+ connect(endpoint: string): Promise<void>;
20
+ connectPipe(transport: CDPPipeTransport): void;
8
21
  send<T = unknown>(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<T>;
9
22
  on(event: string, handler: EventHandler): void;
10
23
  off(event: string, handler: EventHandler): void;
11
24
  close(): void;
12
25
  get connected(): boolean;
13
26
  get isOpen(): boolean;
27
+ private handlePipeData;
14
28
  private handleMessage;
15
29
  private handleClose;
16
30
  }
@@ -1,19 +1,54 @@
1
+ const pipeTransports = new Map();
2
+ export function registerPipeTransport(transport) {
3
+ const id = `pipe://${process.pid}/${pipeTransports.size + 1}`;
4
+ pipeTransports.set(id, transport);
5
+ return id;
6
+ }
1
7
  export class CDPClient {
2
8
  ws = null;
9
+ pipe = null;
10
+ pipeBuffer = Buffer.alloc(0);
11
+ transport = null;
3
12
  messageId = 0;
4
13
  pending = new Map();
5
14
  eventHandlers = new Map();
6
- async connect(wsUrl) {
15
+ pipeDataHandler = null;
16
+ pipeErrorHandler = null;
17
+ pipeCloseHandler = null;
18
+ async connect(endpoint) {
19
+ if (endpoint.startsWith('pipe://')) {
20
+ const transport = pipeTransports.get(endpoint);
21
+ if (!transport) {
22
+ throw new Error('CDP pipe transport is not available in this process');
23
+ }
24
+ pipeTransports.delete(endpoint);
25
+ this.connectPipe(transport);
26
+ return;
27
+ }
7
28
  return new Promise((resolve, reject) => {
8
- this.ws = new WebSocket(wsUrl);
29
+ this.ws = new WebSocket(endpoint);
30
+ this.transport = 'websocket';
9
31
  this.ws.onopen = () => resolve();
10
- this.ws.onerror = (ev) => reject(new Error('WebSocket error'));
32
+ this.ws.onerror = () => reject(new Error('WebSocket error'));
11
33
  this.ws.onclose = () => this.handleClose();
12
34
  this.ws.onmessage = (ev) => this.handleMessage(String(ev.data));
13
35
  });
14
36
  }
37
+ connectPipe(transport) {
38
+ this.pipe = transport;
39
+ this.transport = 'pipe';
40
+ this.pipeBuffer = Buffer.alloc(0);
41
+ this.pipeDataHandler = (chunk) => this.handlePipeData(chunk);
42
+ this.pipeErrorHandler = (err) => this.handleClose(err);
43
+ this.pipeCloseHandler = () => this.handleClose();
44
+ transport.read.on('data', this.pipeDataHandler);
45
+ transport.read.on('error', this.pipeErrorHandler);
46
+ transport.read.on('close', this.pipeCloseHandler);
47
+ transport.write.on('error', this.pipeErrorHandler);
48
+ transport.write.on('close', this.pipeCloseHandler);
49
+ }
15
50
  async send(method, params, sessionId) {
16
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
51
+ if (!this.isOpen) {
17
52
  // Reached when the underlying browser was killed externally between
18
53
  // the daemon establishing the connection and a CDP call going out.
19
54
  // The service-layer healthcheck normally catches this on the next
@@ -28,7 +63,12 @@ export class CDPClient {
28
63
  : JSON.stringify({ id, method, params });
29
64
  return new Promise((resolve, reject) => {
30
65
  this.pending.set(id, { resolve: resolve, reject });
31
- this.ws.send(message);
66
+ if (this.transport === 'pipe') {
67
+ this.pipe.write.write(message + '\0');
68
+ }
69
+ else {
70
+ this.ws.send(message);
71
+ }
32
72
  });
33
73
  }
34
74
  on(event, handler) {
@@ -45,13 +85,40 @@ export class CDPClient {
45
85
  this.ws.close();
46
86
  this.ws = null;
47
87
  }
88
+ if (this.pipe) {
89
+ if (this.pipeDataHandler)
90
+ this.pipe.read.off('data', this.pipeDataHandler);
91
+ if (this.pipeErrorHandler) {
92
+ this.pipe.read.off('error', this.pipeErrorHandler);
93
+ this.pipe.write.off('error', this.pipeErrorHandler);
94
+ }
95
+ if (this.pipeCloseHandler) {
96
+ this.pipe.read.off('close', this.pipeCloseHandler);
97
+ this.pipe.write.off('close', this.pipeCloseHandler);
98
+ }
99
+ this.pipe.write.end();
100
+ this.pipe = null;
101
+ }
102
+ this.transport = null;
48
103
  }
49
104
  get connected() {
50
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
105
+ return ((this.ws !== null && this.ws.readyState === WebSocket.OPEN) ||
106
+ (this.pipe !== null && !this.pipe.write.destroyed));
51
107
  }
52
108
  get isOpen() {
53
109
  return this.connected;
54
110
  }
111
+ handlePipeData(chunk) {
112
+ this.pipeBuffer = Buffer.concat([this.pipeBuffer, chunk]);
113
+ let idx = this.pipeBuffer.indexOf(0);
114
+ while (idx !== -1) {
115
+ const frame = this.pipeBuffer.subarray(0, idx).toString('utf8');
116
+ this.pipeBuffer = this.pipeBuffer.subarray(idx + 1);
117
+ if (frame.length > 0)
118
+ this.handleMessage(frame);
119
+ idx = this.pipeBuffer.indexOf(0);
120
+ }
121
+ }
55
122
  handleMessage(data) {
56
123
  const msg = JSON.parse(data);
57
124
  if ('id' in msg) {
@@ -75,12 +142,14 @@ export class CDPClient {
75
142
  }
76
143
  }
77
144
  }
78
- handleClose() {
145
+ handleClose(err) {
79
146
  for (const pending of this.pending.values()) {
80
- pending.reject(new Error('CDP connection closed'));
147
+ pending.reject(err ?? new Error('CDP connection closed'));
81
148
  }
82
149
  this.pending.clear();
83
150
  this.ws = null;
151
+ this.pipe = null;
152
+ this.transport = null;
84
153
  }
85
154
  }
86
155
  export async function discoverBrowserWsUrl(port, host = 'localhost') {
@@ -1,9 +1,9 @@
1
- import { spawn, execSync } from 'child_process';
1
+ import { spawn, execFileSync } from 'child_process';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import * as os from 'os';
5
5
  import { getProfileRuntimeDir } from './profiles.js';
6
- import { discoverBrowserWsUrl } from './cdp.js';
6
+ import { discoverBrowserWsUrl, registerPipeTransport } from './cdp.js';
7
7
  import { readBundle, resolveBundleEnv, bundleExists } from '../secrets/bundles.js';
8
8
  import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
9
9
  const BROWSER_PATHS = {
@@ -88,9 +88,8 @@ isElectron = false) {
88
88
  // instance, but `ps -ww` will show both.
89
89
  const viewport = options.viewport ?? { width: 1512, height: 982 };
90
90
  const args = [
91
- `--remote-debugging-port=${port}`,
91
+ '--remote-debugging-pipe',
92
92
  `--user-data-dir=${userDataDir}`,
93
- '--remote-allow-origins=*',
94
93
  '--disable-background-timer-throttling',
95
94
  '--disable-backgrounding-occluded-windows',
96
95
  '--disable-renderer-backgrounding',
@@ -120,34 +119,26 @@ isElectron = false) {
120
119
  }
121
120
  const child = spawn(browserPath, args, {
122
121
  detached: true,
123
- stdio: 'ignore',
122
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
124
123
  env,
125
124
  });
126
125
  child.unref();
126
+ child.stdout?.resume();
127
+ child.stderr?.resume();
127
128
  const pid = child.pid;
129
+ const writePipe = child.stdio[3];
130
+ const readPipe = child.stdio[4];
131
+ if (!writePipe || !readPipe) {
132
+ throw new Error('Chrome failed to expose CDP pipe file descriptors');
133
+ }
134
+ const wsUrl = registerPipeTransport({ read: readPipe, write: writePipe });
128
135
  writeProfileRuntime(profileName, {
129
136
  pid,
130
- port,
131
137
  command: path.basename(browserPath),
132
138
  userDataDir,
133
139
  kind: isElectron ? 'electron' : 'browser',
134
140
  });
135
- let wsUrl = null;
136
- for (let i = 0; i < 30; i++) {
137
- await sleep(200);
138
- try {
139
- const result = await discoverBrowserWsUrl(port);
140
- wsUrl = result.wsUrl;
141
- break;
142
- }
143
- catch {
144
- // Chrome still starting
145
- }
146
- }
147
- if (!wsUrl) {
148
- throw new Error('Chrome failed to start within 6 seconds');
149
- }
150
- return { pid, port, wsUrl };
141
+ return { pid, port: 0, wsUrl };
151
142
  }
152
143
  export async function attachToChrome(port) {
153
144
  const { wsUrl } = await discoverBrowserWsUrl(port);
@@ -168,6 +159,8 @@ export function getRunningChromeInfo(profileName) {
168
159
  const rt = readProfileRuntime(profileName);
169
160
  if (!rt)
170
161
  return null;
162
+ if (rt.port === undefined)
163
+ return null;
171
164
  return { pid: rt.pid, port: rt.port };
172
165
  }
173
166
  /**
@@ -196,7 +189,7 @@ export function allocatePort() {
196
189
  const max = 9300;
197
190
  for (let port = base; port < max; port++) {
198
191
  try {
199
- execSync(`lsof -i :${port}`, { stdio: 'ignore' });
192
+ execFileSync('lsof', ['-i', `:${port}`], { stdio: 'ignore' });
200
193
  }
201
194
  catch {
202
195
  return port;
@@ -211,7 +204,7 @@ export function allocatePort() {
211
204
  */
212
205
  export function getPortOccupant(port) {
213
206
  try {
214
- const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpcn`, {
207
+ const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn'], {
215
208
  encoding: 'utf8',
216
209
  stdio: ['ignore', 'pipe', 'ignore'],
217
210
  });
@@ -6,5 +6,6 @@ export interface SSHConnection {
6
6
  pid: number;
7
7
  cleanup: () => void;
8
8
  }
9
+ export declare function shellQuote(s: string): string;
9
10
  export declare function connectSSH(endpoint: string, profile: BrowserProfile): Promise<SSHConnection>;
10
11
  export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, customBinary?: string): Promise<void>;
@@ -1,9 +1,14 @@
1
- import { spawn, execSync } from 'child_process';
1
+ import { spawn, execFileSync } from 'child_process';
2
2
  import * as net from 'net';
3
3
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
4
4
  import { getPortOccupant } from '../chrome.js';
5
5
  import { parseEndpointUrl } from '../profiles.js';
6
6
  import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
7
+ export function shellQuote(s) {
8
+ if (/^[A-Za-z0-9_./:=@%+-]+$/.test(s))
9
+ return s;
10
+ return "'" + s.replace(/'/g, "'\\''") + "'";
11
+ }
7
12
  export async function connectSSH(endpoint, profile) {
8
13
  const url = new URL(endpoint);
9
14
  if (url.protocol !== 'ssh:') {
@@ -153,15 +158,15 @@ function tryConnect(port) {
153
158
  }
154
159
  async function ensureRemoteBrowser(user, host, browserType, port, customBinary) {
155
160
  const browserPaths = {
156
- chrome: '/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome',
161
+ chrome: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
157
162
  comet: '/Applications/Comet.app/Contents/MacOS/Comet',
158
163
  chromium: '/Applications/Chromium.app/Contents/MacOS/Chromium',
159
- brave: '/Applications/Brave\\ Browser.app/Contents/MacOS/Brave\\ Browser',
160
- edge: '/Applications/Microsoft\\ Edge.app/Contents/MacOS/Microsoft\\ Edge',
164
+ brave: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
165
+ edge: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
161
166
  };
162
167
  let browserPath;
163
168
  if (customBinary) {
164
- browserPath = customBinary.replace(/ /g, '\\ ');
169
+ browserPath = customBinary;
165
170
  }
166
171
  else if (browserType === 'custom') {
167
172
  throw new Error('browser: custom requires a binary path in the profile');
@@ -172,7 +177,14 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
172
177
  throw new Error(`Unknown browser type: ${browserType}`);
173
178
  }
174
179
  }
175
- const remoteCmd = `${browserPath} --remote-debugging-port=${port} '--remote-allow-origins=*' --disable-background-timer-throttling --user-data-dir=/tmp/agents-browser-${port} </dev/null >/dev/null 2>&1 &`;
180
+ const remoteCmd = [
181
+ shellQuote(browserPath),
182
+ `--remote-debugging-port=${port}`,
183
+ shellQuote('--remote-allow-origins=*'),
184
+ '--disable-background-timer-throttling',
185
+ `--user-data-dir=/tmp/agents-browser-${port}`,
186
+ '</dev/null >/dev/null 2>&1 &',
187
+ ].join(' ');
176
188
  return new Promise((resolve, reject) => {
177
189
  const child = spawn('ssh', [
178
190
  `${user}@${host}`,
@@ -190,7 +202,7 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
190
202
  }
191
203
  export async function restartRemoteBrowser(user, host, browserType, port, customBinary) {
192
204
  // Kill any process using the remote debugging port
193
- const killCmd = `lsof -ti :${port} | xargs kill -9 2>/dev/null || true`;
205
+ const killCmd = `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
194
206
  await runSSHCommand(user, host, killCmd);
195
207
  await sleep(500);
196
208
  await ensureRemoteBrowser(user, host, browserType, port, customBinary);
@@ -223,7 +235,7 @@ function sleep(ms) {
223
235
  */
224
236
  function isOwnTunnel(pid, host, remotePort) {
225
237
  try {
226
- const out = execSync(`ps -p ${pid} -o command=`, {
238
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
227
239
  encoding: 'utf-8',
228
240
  stdio: ['ignore', 'pipe', 'ignore'],
229
241
  }).toString().trim();
@@ -6,7 +6,7 @@ import { startDaemon } from '../daemon.js';
6
6
  import { getCliVersion } from '../version.js';
7
7
  const SOCKET_NAME = 'browser.sock';
8
8
  export function getSocketPath() {
9
- return path.join(getHelpersDir(), SOCKET_NAME);
9
+ return path.join(getHelpersDir(), 'browser', SOCKET_NAME);
10
10
  }
11
11
  export class BrowserIPCServer {
12
12
  server = null;
@@ -16,6 +16,9 @@ export class BrowserIPCServer {
16
16
  }
17
17
  async start() {
18
18
  const socketPath = getSocketPath();
19
+ const socketDir = path.dirname(socketPath);
20
+ fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
21
+ fs.chmodSync(socketDir, 0o700);
19
22
  if (fs.existsSync(socketPath)) {
20
23
  fs.unlinkSync(socketPath);
21
24
  }
@@ -44,11 +47,32 @@ export class BrowserIPCServer {
44
47
  });
45
48
  });
46
49
  return new Promise((resolve, reject) => {
50
+ // Lock down the browser socket dir before opening the socket; on macOS
51
+ // the parent dir is the real local-user boundary for AF_UNIX sockets.
52
+ const prevUmask = process.umask(0o077);
53
+ let restored = false;
54
+ const restoreUmask = () => {
55
+ if (restored)
56
+ return;
57
+ restored = true;
58
+ process.umask(prevUmask);
59
+ };
47
60
  this.server.listen(socketPath, () => {
48
- fs.chmodSync(socketPath, 0o600);
49
- resolve();
61
+ try {
62
+ fs.chmodSync(socketPath, 0o600);
63
+ resolve();
64
+ }
65
+ catch (err) {
66
+ reject(err);
67
+ }
68
+ finally {
69
+ restoreUmask();
70
+ }
71
+ });
72
+ this.server.on('error', (err) => {
73
+ restoreUmask();
74
+ reject(err);
50
75
  });
51
- this.server.on('error', reject);
52
76
  });
53
77
  }
54
78
  async stop() {
@@ -63,6 +87,14 @@ export class BrowserIPCServer {
63
87
  await this.service.shutdown();
64
88
  }
65
89
  async handleRequest(request) {
90
+ if (request.action === 'upload-stage') {
91
+ const source = request.source;
92
+ if (!source) {
93
+ return { ok: false, error: 'Source required' };
94
+ }
95
+ const result = this.service.stageUpload(source);
96
+ return { ok: true, path: result.path };
97
+ }
66
98
  switch (request.action) {
67
99
  case 'version': {
68
100
  return { ok: true, version: getCliVersion() };
@@ -392,7 +424,8 @@ export async function sendIPCRequest(request) {
392
424
  async function sendRawIPCRequest(request) {
393
425
  const socketPath = getSocketPath();
394
426
  if (!fs.existsSync(socketPath)) {
395
- await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
427
+ await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
428
+ await fs.promises.chmod(path.dirname(socketPath), 0o700);
396
429
  startDaemon();
397
430
  if (!fs.existsSync(socketPath)) {
398
431
  await new Promise((resolve, reject) => {
@@ -1,5 +1,5 @@
1
1
  import * as path from 'path';
2
- import { execSync } from 'child_process';
2
+ import { execFileSync } from 'child_process';
3
3
  import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, writeMeta, } from '../state.js';
4
4
  import { findBrowserPath } from './chrome.js';
5
5
  export function getBrowserRuntimeDir() {
@@ -9,6 +9,7 @@ export function getProfileRuntimeDir(name) {
9
9
  return path.join(getBrowserRuntimeDir(), name);
10
10
  }
11
11
  function configToProfile(name, config) {
12
+ validateRemoteBrowserBinaries(config);
12
13
  return {
13
14
  name,
14
15
  description: config.description,
@@ -26,6 +27,7 @@ function configToProfile(name, config) {
26
27
  };
27
28
  }
28
29
  function profileToConfig(profile) {
30
+ validateRemoteBrowserBinaries(profile);
29
31
  const config = {
30
32
  browser: profile.browser,
31
33
  endpoints: profile.endpoints,
@@ -112,7 +114,7 @@ export async function findFreeProfilePort() {
112
114
  if (usedByProfile.has(port))
113
115
  continue;
114
116
  try {
115
- execSync(`lsof -i :${port}`, { stdio: 'ignore' });
117
+ execFileSync('lsof', ['-i', `:${port}`], { stdio: 'ignore' });
116
118
  // lsof succeeded → something is listening → port is in use
117
119
  }
118
120
  catch {
@@ -122,6 +124,36 @@ export async function findFreeProfilePort() {
122
124
  }
123
125
  throw new Error('No available ports in range 9222-9399');
124
126
  }
127
+ function validateRemoteBrowserBinaries(profile) {
128
+ if (!hasSshEndpoint(profile.endpoints))
129
+ return;
130
+ validateRemoteBrowserBinary(profile.binary);
131
+ if (!Array.isArray(profile.endpoints)) {
132
+ for (const preset of Object.values(profile.endpoints)) {
133
+ validateRemoteBrowserBinary(preset.binary);
134
+ }
135
+ }
136
+ }
137
+ function validateRemoteBrowserBinary(binary) {
138
+ if (!binary)
139
+ return;
140
+ if (/[\0\r\n;&|`$<>]/.test(binary)) {
141
+ throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
142
+ }
143
+ }
144
+ function hasSshEndpoint(endpoints) {
145
+ const targets = Array.isArray(endpoints)
146
+ ? endpoints
147
+ : Object.values(endpoints).map((preset) => preset.target);
148
+ return targets.some((target) => {
149
+ try {
150
+ return new URL(target).protocol === 'ssh:';
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ });
156
+ }
125
157
  export async function createProfile(profile) {
126
158
  const meta = readMeta();
127
159
  if (meta.browser?.[profile.name]) {
@@ -4,7 +4,6 @@
4
4
  *
5
5
  * - `pid` — child process ID we spawned (or 0 if attached to an
6
6
  * already-running browser)
7
- * - `port` — CDP port we ended up speaking on
8
7
  * - `command` — basename of the executable so we can defend against pid
9
8
  * reuse (`process.kill(pid, 0)` only proves *some* process
10
9
  * with that id exists; if the OS recycled it for an
@@ -19,7 +18,7 @@
19
18
  */
20
19
  export interface ProfileRuntime {
21
20
  pid: number;
22
- port: number;
21
+ port?: number;
23
22
  command?: string;
24
23
  /** Full path of the user-data-dir we passed to --user-data-dir, used by the reaper to confirm. */
25
24
  userDataDir?: string;
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { execSync } from 'child_process';
3
+ import { execFileSync } from 'child_process';
4
4
  import { getProfileRuntimeDir, getBrowserRuntimeDir } from './profiles.js';
5
5
  const PID_FILE = 'pid';
6
6
  const PORT_FILE = 'port';
@@ -32,7 +32,15 @@ export function writeProfileRuntime(profileName, runtime) {
32
32
  const dir = getProfileRuntimeDir(profileName);
33
33
  fs.mkdirSync(dir, { recursive: true });
34
34
  fs.writeFileSync(path.join(dir, PID_FILE), String(runtime.pid));
35
- fs.writeFileSync(path.join(dir, PORT_FILE), String(runtime.port));
35
+ if (runtime.port !== undefined) {
36
+ fs.writeFileSync(path.join(dir, PORT_FILE), String(runtime.port));
37
+ }
38
+ else {
39
+ try {
40
+ fs.unlinkSync(path.join(dir, PORT_FILE));
41
+ }
42
+ catch { /* not present */ }
43
+ }
36
44
  if (runtime.command) {
37
45
  fs.writeFileSync(path.join(dir, COMMAND_FILE), runtime.command);
38
46
  }
@@ -241,7 +249,7 @@ export function reapOrphanedProcesses() {
241
249
  }
242
250
  function matchesCommand(pid, expectedCommand) {
243
251
  try {
244
- const out = execSync(`ps -p ${pid} -o comm=`, {
252
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
245
253
  encoding: 'utf-8',
246
254
  stdio: ['ignore', 'pipe', 'ignore'],
247
255
  }).trim();
@@ -2,6 +2,7 @@ import { type TabInfo, type ProfileStatus, type HistoricalTask } from './types.j
2
2
  import { type RefOpts, type RefNode } from './refs.js';
3
3
  import type { TargetFilter } from './types.js';
4
4
  export type UploadMode = 'auto' | 'input' | 'drop' | 'chooser';
5
+ export declare function resolveScreenshotOutputPath(outputPath: string | undefined, automaticPath: string): string;
5
6
  /**
6
7
  * Parse a `targetFilter` string into its kind + value, or return `null`
7
8
  * when the input is missing or malformed. Filter syntax:
@@ -39,6 +40,7 @@ export declare function pickWindowTarget<T extends {
39
40
  * or relative offsets like `30s`, `5m`, `2h`, `1d`.
40
41
  */
41
42
  export declare function parseSinceUntil(s: string): Date;
43
+ export declare function readNewestMatchingRemoteFileCommand(dir: string, prefix: string, tailLines: number): string;
42
44
  export declare function readNewestMatchingFile(dir: string, prefix: string, tailLines: number): string;
43
45
  export declare class BrowserService {
44
46
  private static readonly SOURCE_PREFIX;
@@ -140,6 +142,9 @@ export declare class BrowserService {
140
142
  }): Promise<{
141
143
  mode: 'input' | 'drop' | 'chooser';
142
144
  }>;
145
+ stageUpload(source: string): {
146
+ path: string;
147
+ };
143
148
  status(profileName?: string): Promise<ProfileStatus[]>;
144
149
  private reconcileFromDisk;
145
150
  setViewport(taskId: string, width: number, height: number, options?: {