@phnx-labs/agents-cli 1.18.6 → 1.19.1
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.
- package/CHANGELOG.md +13 -2
- package/README.md +22 -20
- package/dist/commands/browser.js +25 -2
- package/dist/commands/cloud.js +3 -3
- package/dist/commands/computer.d.ts +6 -0
- package/dist/commands/computer.js +477 -0
- package/dist/commands/doctor.js +19 -17
- package/dist/commands/exec.js +37 -59
- package/dist/commands/factory.js +12 -5
- package/dist/commands/import.js +6 -1
- package/dist/commands/mcp.js +9 -4
- package/dist/commands/packages.d.ts +3 -0
- package/dist/commands/packages.js +20 -12
- package/dist/commands/permissions.d.ts +2 -0
- package/dist/commands/permissions.js +20 -1
- package/dist/commands/plugins.d.ts +2 -0
- package/dist/commands/plugins.js +23 -4
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/pty.js +126 -112
- package/dist/commands/pull.js +29 -25
- package/dist/commands/repo.js +24 -26
- package/dist/commands/routines.js +29 -26
- package/dist/commands/secrets.js +66 -73
- package/dist/commands/sessions-tail.js +21 -22
- package/dist/commands/sessions.js +36 -68
- package/dist/commands/setup.js +20 -24
- package/dist/commands/teams.js +30 -39
- package/dist/commands/versions.js +60 -68
- package/dist/commands/worktree.d.ts +20 -0
- package/dist/commands/worktree.js +242 -0
- package/dist/computer.d.ts +2 -0
- package/dist/computer.js +7 -0
- package/dist/index.js +70 -26
- package/dist/lib/agents.d.ts +4 -1
- package/dist/lib/agents.js +23 -5
- package/dist/lib/browser/cdp.d.ts +15 -1
- package/dist/lib/browser/cdp.js +77 -8
- package/dist/lib/browser/chrome.js +17 -24
- package/dist/lib/browser/drivers/ssh.d.ts +1 -0
- package/dist/lib/browser/drivers/ssh.js +20 -8
- package/dist/lib/browser/ipc.js +38 -5
- package/dist/lib/browser/profiles.js +34 -2
- package/dist/lib/browser/runtime-state.d.ts +1 -2
- package/dist/lib/browser/runtime-state.js +11 -3
- package/dist/lib/browser/service.d.ts +5 -0
- package/dist/lib/browser/service.js +32 -4
- package/dist/lib/browser/types.d.ts +1 -1
- package/dist/lib/browser/upload.d.ts +2 -0
- package/dist/lib/browser/upload.js +34 -0
- package/dist/lib/cloud/rush.d.ts +2 -1
- package/dist/lib/cloud/rush.js +28 -9
- package/dist/lib/computer-rpc.d.ts +24 -0
- package/dist/lib/computer-rpc.js +263 -0
- package/dist/lib/daemon.js +7 -7
- package/dist/lib/exec.d.ts +2 -1
- package/dist/lib/exec.js +3 -2
- package/dist/lib/fs-atomic.d.ts +18 -0
- package/dist/lib/fs-atomic.js +76 -0
- package/dist/lib/git.js +2 -4
- package/dist/lib/help.d.ts +15 -0
- package/dist/lib/help.js +41 -0
- package/dist/lib/hooks/match.d.ts +1 -0
- package/dist/lib/hooks/match.js +57 -12
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +27 -10
- package/dist/lib/import.d.ts +1 -0
- package/dist/lib/import.js +7 -0
- package/dist/lib/manifest.js +27 -1
- package/dist/lib/mcp.d.ts +14 -0
- package/dist/lib/mcp.js +79 -14
- package/dist/lib/migrate.js +3 -3
- package/dist/lib/models.js +3 -1
- package/dist/lib/permissions.d.ts +5 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/plugin-marketplace.d.ts +3 -1
- package/dist/lib/plugin-marketplace.js +36 -1
- package/dist/lib/plugins.d.ts +19 -1
- package/dist/lib/plugins.js +99 -8
- package/dist/lib/redact.d.ts +4 -0
- package/dist/lib/redact.js +18 -0
- package/dist/lib/registry.d.ts +2 -0
- package/dist/lib/registry.js +15 -0
- package/dist/lib/sandbox.js +15 -5
- package/dist/lib/secrets/bundles.d.ts +7 -12
- package/dist/lib/secrets/bundles.js +45 -29
- package/dist/lib/secrets/index.js +4 -4
- package/dist/lib/session/cloud.d.ts +2 -0
- package/dist/lib/session/cloud.js +34 -6
- package/dist/lib/session/parse.js +7 -2
- package/dist/lib/session/render.d.ts +4 -1
- package/dist/lib/session/render.js +81 -35
- package/dist/lib/shims.d.ts +5 -2
- package/dist/lib/shims.js +29 -7
- package/dist/lib/state.d.ts +5 -5
- package/dist/lib/state.js +43 -13
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/types.js +0 -2
- package/dist/lib/versions.js +65 -40
- package/dist/lib/workflows.d.ts +7 -0
- package/dist/lib/workflows.js +42 -1
- package/npm-shrinkwrap.json +3162 -0
- package/package.json +32 -26
- package/scripts/postinstall.js +8 -2
package/dist/lib/agents.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/browser/cdp.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
29
|
+
this.ws = new WebSocket(endpoint);
|
|
30
|
+
this.transport = 'websocket';
|
|
9
31
|
this.ws.onopen = () => resolve();
|
|
10
|
-
this.ws.onerror = (
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
160
|
-
edge: '/Applications/Microsoft
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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();
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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?: {
|