@phnx-labs/agents-cli 1.18.4 → 1.18.5
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 +18 -7
- package/README.md +1 -1
- package/dist/commands/browser.js +275 -141
- package/dist/commands/factory.js +13 -1
- package/dist/commands/rules.js +14 -0
- package/dist/commands/secrets.js +66 -11
- package/dist/lib/browser/cdp.js +7 -1
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +52 -26
- package/dist/lib/browser/drivers/local.js +29 -2
- package/dist/lib/browser/drivers/ssh.js +82 -7
- package/dist/lib/browser/ipc.js +55 -0
- package/dist/lib/browser/profiles.d.ts +46 -2
- package/dist/lib/browser/profiles.js +123 -19
- package/dist/lib/browser/runtime-state.d.ts +117 -0
- package/dist/lib/browser/runtime-state.js +259 -0
- package/dist/lib/browser/service.d.ts +16 -0
- package/dist/lib/browser/service.js +163 -16
- package/dist/lib/browser/types.d.ts +13 -1
- package/dist/lib/daemon.js +36 -3
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/secrets/bundles.d.ts +20 -0
- package/dist/lib/secrets/bundles.js +56 -0
- package/dist/lib/secrets/index.js +8 -8
- package/dist/lib/types.d.ts +4 -0
- package/dist/lib/version.d.ts +7 -0
- package/dist/lib/version.js +25 -0
- package/package.json +1 -1
package/dist/commands/factory.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
|
|
6
|
+
import { insertTask } from '../lib/cloud/store.js';
|
|
6
7
|
const FACTORY_URL = process.env.FACTORY_FLOOR_URL ?? 'https://agents.427yosemite.com';
|
|
7
8
|
function die(msg, code = 1) {
|
|
8
9
|
console.error(chalk.red(msg));
|
|
@@ -63,9 +64,20 @@ Examples:
|
|
|
63
64
|
console.log(JSON.stringify(result, null, 2));
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
67
|
+
// Register locally so `agents cloud logs <id>` can find it.
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
insertTask({
|
|
70
|
+
id: result.cloud_execution_id,
|
|
71
|
+
provider: 'rush',
|
|
72
|
+
status: 'queued',
|
|
73
|
+
agent: 'claude',
|
|
74
|
+
prompt: result.linear_identifier,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
});
|
|
66
78
|
console.log(chalk.green(`Submitted ${result.linear_identifier} (${result.label})`));
|
|
67
79
|
console.log(` ticket ${result.ticket_id}`);
|
|
68
80
|
console.log(` execution ${result.cloud_execution_id}`);
|
|
69
|
-
console.log(` tail output agents cloud
|
|
81
|
+
console.log(` tail output agents cloud logs ${result.cloud_execution_id}`);
|
|
70
82
|
});
|
|
71
83
|
}
|
package/dist/commands/rules.js
CHANGED
|
@@ -41,6 +41,20 @@ When to use:
|
|
|
41
41
|
- Team onboarding: share rules via 'agents rules add gh:team/standards'
|
|
42
42
|
- Project setup: add project-specific rules with '--scope project'
|
|
43
43
|
- Version testing: install different rule sets per version to A/B test approaches
|
|
44
|
+
|
|
45
|
+
Project rules & @-imports:
|
|
46
|
+
Project rules live in <repo>/.agents/rules/. On every agent launch, the shim compiles
|
|
47
|
+
them into <repo>/AGENTS.md (with CLAUDE.md, GEMINI.md, .cursorrules symlinked to it).
|
|
48
|
+
A hand-authored AGENTS.md is preserved — the compile pipeline only overwrites files
|
|
49
|
+
it owns (those starting with the auto-compile header). Delete the file to migrate.
|
|
50
|
+
|
|
51
|
+
@path imports inside AGENTS.md/CLAUDE.md are resolved at session start by the agent
|
|
52
|
+
itself, not by agents-cli. Support is per-agent:
|
|
53
|
+
Inlined natively: claude, gemini
|
|
54
|
+
Literal text: codex, cursor, opencode, copilot, amp, kiro, goose, roo
|
|
55
|
+
|
|
56
|
+
For rules that need to work across all agents, inline the content rather than using
|
|
57
|
+
@-imports — the second group will load '@path/to/file.md' as a literal string.
|
|
44
58
|
`);
|
|
45
59
|
rulesCmd
|
|
46
60
|
.command('list [agent]')
|
package/dist/commands/secrets.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
|
-
import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
10
|
+
import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
11
11
|
import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
12
12
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
13
13
|
import { registerCommandGroups } from '../lib/help.js';
|
|
@@ -314,7 +314,7 @@ function countExpiringSoon(meta) {
|
|
|
314
314
|
export function registerSecretsCommands(program) {
|
|
315
315
|
const cmd = program
|
|
316
316
|
.command('secrets')
|
|
317
|
-
.description('Named bundles of env variables backed by macOS Keychain (
|
|
317
|
+
.description('Named bundles of env variables backed by macOS Keychain (iCloud-synced by default). Inject into agents via `agents run --secrets <name>`.')
|
|
318
318
|
.hook('preAction', () => { migrateLegacyBundles(); })
|
|
319
319
|
.addHelpText('after', `
|
|
320
320
|
Workflow:
|
|
@@ -323,16 +323,24 @@ Workflow:
|
|
|
323
323
|
run with --secrets <name>. Keychain-backed values never touch disk in
|
|
324
324
|
plaintext.
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
New bundles store values in the iCloud-synced keychain by default so they
|
|
327
|
+
appear automatically on your other Macs (same iCloud account, iCloud
|
|
328
|
+
Keychain enabled). Pass --no-icloud-sync at create time to keep values
|
|
329
|
+
device-local instead.
|
|
329
330
|
|
|
330
331
|
Examples:
|
|
331
|
-
# Create a bundle for production credentials
|
|
332
|
+
# Create a bundle for production credentials (iCloud-synced by default)
|
|
332
333
|
agents secrets create prod --description "Production keys for the api stack"
|
|
333
334
|
|
|
334
|
-
# Create a bundle that
|
|
335
|
-
agents secrets create
|
|
335
|
+
# Create a bundle that never leaves this Mac
|
|
336
|
+
agents secrets create local-only --no-icloud-sync
|
|
337
|
+
|
|
338
|
+
# Rename a bundle (moves metadata + every keychain value)
|
|
339
|
+
agents secrets rename prod production
|
|
340
|
+
|
|
341
|
+
# Edit the description of an existing bundle
|
|
342
|
+
agents secrets describe prod "Production keys for the api stack"
|
|
343
|
+
agents secrets describe prod --clear
|
|
336
344
|
|
|
337
345
|
# Add a keychain-backed secret (prompts for the value)
|
|
338
346
|
agents secrets add prod STRIPE_API_KEY
|
|
@@ -387,7 +395,7 @@ Examples:
|
|
|
387
395
|
agents secrets generate 32 --hex
|
|
388
396
|
`);
|
|
389
397
|
registerCommandGroups(cmd, [
|
|
390
|
-
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'delete'] },
|
|
398
|
+
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
|
|
391
399
|
{ title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
|
|
392
400
|
{ title: 'Utilities', names: ['exec', 'generate'] },
|
|
393
401
|
]);
|
|
@@ -484,7 +492,7 @@ Examples:
|
|
|
484
492
|
.description('Create an empty bundle')
|
|
485
493
|
.option('--description <text>', 'Free-form description')
|
|
486
494
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
487
|
-
.option('--icloud-sync', 'Store keychain values
|
|
495
|
+
.option('--no-icloud-sync', 'Store keychain values device-local instead of syncing them via iCloud Keychain')
|
|
488
496
|
.option('--force', 'Overwrite an existing bundle')
|
|
489
497
|
.action(async (name, opts) => {
|
|
490
498
|
try {
|
|
@@ -498,7 +506,7 @@ Examples:
|
|
|
498
506
|
name: resolvedName,
|
|
499
507
|
description: opts.description,
|
|
500
508
|
allow_exec: opts.allowExec,
|
|
501
|
-
icloud_sync: opts.icloudSync,
|
|
509
|
+
icloud_sync: opts.icloudSync !== false,
|
|
502
510
|
vars: {},
|
|
503
511
|
};
|
|
504
512
|
writeBundle(bundle);
|
|
@@ -512,6 +520,53 @@ Examples:
|
|
|
512
520
|
process.exit(1);
|
|
513
521
|
}
|
|
514
522
|
});
|
|
523
|
+
cmd
|
|
524
|
+
.command('rename <old> <new>')
|
|
525
|
+
.alias('mv')
|
|
526
|
+
.description('Rename a bundle. Moves the metadata and every keychain-backed value to the new name.')
|
|
527
|
+
.option('--force', 'Overwrite the destination bundle if it already exists (purges its keychain items first)')
|
|
528
|
+
.action((oldName, newName, opts) => {
|
|
529
|
+
try {
|
|
530
|
+
renameBundle(oldName, newName, { force: opts.force });
|
|
531
|
+
console.log(chalk.green(`Bundle '${oldName}' renamed to '${newName}'.`));
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
console.error(chalk.red(err.message));
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
cmd
|
|
539
|
+
.command('describe <name> [text...]')
|
|
540
|
+
.description('Update the description of a bundle. Pass --clear to remove it.')
|
|
541
|
+
.option('--clear', 'Remove the existing description')
|
|
542
|
+
.action((name, textParts, opts) => {
|
|
543
|
+
try {
|
|
544
|
+
const bundle = readBundle(name);
|
|
545
|
+
const text = textParts.join(' ').trim();
|
|
546
|
+
if (opts.clear) {
|
|
547
|
+
if (text) {
|
|
548
|
+
console.error(chalk.red('Pass either description text or --clear, not both.'));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
bundle.description = undefined;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
if (!text) {
|
|
555
|
+
console.error(chalk.red('Description text is required. Pass it as an argument or use --clear.'));
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
bundle.description = text;
|
|
559
|
+
}
|
|
560
|
+
writeBundle(bundle);
|
|
561
|
+
console.log(chalk.green(opts.clear
|
|
562
|
+
? `Bundle '${name}' description cleared.`
|
|
563
|
+
: `Bundle '${name}' description updated.`));
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
console.error(chalk.red(err.message));
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
515
570
|
cmd
|
|
516
571
|
.command('add [bundle] [key]')
|
|
517
572
|
.description('Add a variable to a bundle. Defaults to keychain-backed; pass --value for literal, --env/--file/--exec for refs.')
|
package/dist/lib/browser/cdp.js
CHANGED
|
@@ -14,7 +14,13 @@ export class CDPClient {
|
|
|
14
14
|
}
|
|
15
15
|
async send(method, params, sessionId) {
|
|
16
16
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
17
|
-
|
|
17
|
+
// Reached when the underlying browser was killed externally between
|
|
18
|
+
// the daemon establishing the connection and a CDP call going out.
|
|
19
|
+
// The service-layer healthcheck normally catches this on the next
|
|
20
|
+
// `start`, so seeing this in the wild means a request landed against
|
|
21
|
+
// an in-flight conn that just died — tell the user how to recover.
|
|
22
|
+
throw new Error('CDP connection not open — the browser was likely closed externally. ' +
|
|
23
|
+
'Run `agents browser stop --profile <name>` (or restart the daemon) and try again.');
|
|
18
24
|
}
|
|
19
25
|
const id = ++this.messageId;
|
|
20
26
|
const message = sessionId
|
|
@@ -6,7 +6,7 @@ export interface LaunchResult {
|
|
|
6
6
|
port: number;
|
|
7
7
|
wsUrl: string;
|
|
8
8
|
}
|
|
9
|
-
export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string): Promise<LaunchResult>;
|
|
9
|
+
export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string, isElectron?: boolean): Promise<LaunchResult>;
|
|
10
10
|
export declare function attachToChrome(port: number): Promise<string>;
|
|
11
11
|
export declare function killChrome(pid: number): void;
|
|
12
12
|
export declare function getRunningChromeInfo(profileName: string): {
|
|
@@ -5,6 +5,7 @@ import * as os from 'os';
|
|
|
5
5
|
import { getProfileRuntimeDir } from './profiles.js';
|
|
6
6
|
import { discoverBrowserWsUrl } from './cdp.js';
|
|
7
7
|
import { readBundle, resolveBundleEnv, bundleExists } from '../secrets/bundles.js';
|
|
8
|
+
import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
|
|
8
9
|
const BROWSER_PATHS = {
|
|
9
10
|
darwin: {
|
|
10
11
|
chrome: [
|
|
@@ -64,11 +65,27 @@ export function findBrowserPath(browserType, customBinary) {
|
|
|
64
65
|
}
|
|
65
66
|
throw new Error(`Browser "${browserType}" not found. Install it first.`);
|
|
66
67
|
}
|
|
67
|
-
export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary
|
|
68
|
+
export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
|
|
69
|
+
// `electron: true` distinguishes Notion / VS Code-style apps from
|
|
70
|
+
// regular Chrome — purely informational, stored in meta.json so the
|
|
71
|
+
// orphan reaper and `agents browser status` can label processes.
|
|
72
|
+
isElectron = false) {
|
|
68
73
|
const browserPath = findBrowserPath(browserType, customBinary);
|
|
69
74
|
const runtimeDir = getProfileRuntimeDir(profileName);
|
|
70
75
|
const userDataDir = path.join(runtimeDir, 'chrome-data');
|
|
71
76
|
fs.mkdirSync(userDataDir, { recursive: true });
|
|
77
|
+
// First-launch seed: stamp the user-data-dir's Default/Preferences with
|
|
78
|
+
// the agents-cli profile name so Chromium's UI shows "<profile>" instead
|
|
79
|
+
// of its default "Person 1". Done only when the file doesn't exist —
|
|
80
|
+
// subsequent launches inherit whatever Chrome wrote in the meantime.
|
|
81
|
+
seedDefaultProfileName(userDataDir, profileName);
|
|
82
|
+
// Chromium on macOS coordinates instances via the SingletonLock file
|
|
83
|
+
// *inside* each user-data-dir. Direct binary spawn with a fresh
|
|
84
|
+
// --user-data-dir creates a fully independent process — the user's
|
|
85
|
+
// normal browser (running under their default user-data-dir) and our
|
|
86
|
+
// sandboxed one coexist as two real processes. The macOS Dock collapses
|
|
87
|
+
// them into one icon per .app bundle, which makes it look like a single
|
|
88
|
+
// instance, but `ps -ww` will show both.
|
|
72
89
|
const viewport = options.viewport ?? { width: 1512, height: 982 };
|
|
73
90
|
const args = [
|
|
74
91
|
`--remote-debugging-port=${port}`,
|
|
@@ -77,6 +94,12 @@ export async function launchBrowser(profileName, browserType, port, options = {}
|
|
|
77
94
|
'--disable-background-timer-throttling',
|
|
78
95
|
'--disable-backgrounding-occluded-windows',
|
|
79
96
|
'--disable-renderer-backgrounding',
|
|
97
|
+
// First-run + default-browser modals block automation: when targetFilter
|
|
98
|
+
// matches by URL, the onboarding page (`chrome://welcome/`) isn't a
|
|
99
|
+
// match and start fails with "no page target". Suppress them.
|
|
100
|
+
'--no-first-run',
|
|
101
|
+
'--no-default-browser-check',
|
|
102
|
+
'--disable-features=DefaultBrowserSetting,ChromeWhatsNewUI',
|
|
80
103
|
...(options.headless ? ['--headless=new'] : []),
|
|
81
104
|
`--window-size=${viewport.width},${viewport.height}`,
|
|
82
105
|
...(viewport.x !== undefined && viewport.y !== undefined
|
|
@@ -102,8 +125,13 @@ export async function launchBrowser(profileName, browserType, port, options = {}
|
|
|
102
125
|
});
|
|
103
126
|
child.unref();
|
|
104
127
|
const pid = child.pid;
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
writeProfileRuntime(profileName, {
|
|
129
|
+
pid,
|
|
130
|
+
port,
|
|
131
|
+
command: path.basename(browserPath),
|
|
132
|
+
userDataDir,
|
|
133
|
+
kind: isElectron ? 'electron' : 'browser',
|
|
134
|
+
});
|
|
107
135
|
let wsUrl = null;
|
|
108
136
|
for (let i = 0; i < 30; i++) {
|
|
109
137
|
await sleep(200);
|
|
@@ -134,33 +162,31 @@ export function killChrome(pid) {
|
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
164
|
export function getRunningChromeInfo(profileName) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
144
|
-
const port = parseInt(fs.readFileSync(portFile, 'utf-8').trim(), 10);
|
|
145
|
-
if (!isProcessRunning(pid)) {
|
|
146
|
-
fs.unlinkSync(pidFile);
|
|
147
|
-
fs.unlinkSync(portFile);
|
|
165
|
+
// Delegate to runtime-state, which auto-cleans stale files and verifies
|
|
166
|
+
// the live pid still runs the command we recorded — so a recycled pid
|
|
167
|
+
// doesn't masquerade as our browser.
|
|
168
|
+
const rt = readProfileRuntime(profileName);
|
|
169
|
+
if (!rt)
|
|
148
170
|
return null;
|
|
149
|
-
}
|
|
150
|
-
return { pid, port };
|
|
171
|
+
return { pid: rt.pid, port: rt.port };
|
|
151
172
|
}
|
|
152
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Stamp `<userDataDir>/Default/Preferences` with our profile name so
|
|
175
|
+
* Chrome's UI labels the window with the agents-cli name rather than the
|
|
176
|
+
* default "Person 1". Only writes when the file is absent (first launch).
|
|
177
|
+
* Best-effort: any I/O hiccup is silently ignored; missing the rename is
|
|
178
|
+
* cosmetic, not functional.
|
|
179
|
+
*/
|
|
180
|
+
function seedDefaultProfileName(userDataDir, profileName) {
|
|
181
|
+
const defaultDir = path.join(userDataDir, 'Default');
|
|
182
|
+
const prefsPath = path.join(defaultDir, 'Preferences');
|
|
183
|
+
if (fs.existsSync(prefsPath))
|
|
184
|
+
return;
|
|
153
185
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
catch (err) {
|
|
158
|
-
// EPERM means the process exists but we lack permission to signal it —
|
|
159
|
-
// treat as alive. ESRCH means the process does not exist.
|
|
160
|
-
if (err && err.code === 'EPERM')
|
|
161
|
-
return true;
|
|
162
|
-
return false;
|
|
186
|
+
fs.mkdirSync(defaultDir, { recursive: true });
|
|
187
|
+
fs.writeFileSync(prefsPath, JSON.stringify({ profile: { name: profileName } }));
|
|
163
188
|
}
|
|
189
|
+
catch { /* not critical */ }
|
|
164
190
|
}
|
|
165
191
|
function sleep(ms) {
|
|
166
192
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
|
|
2
2
|
import { launchBrowser, getPortOccupant } from '../chrome.js';
|
|
3
|
+
import { parseEndpointUrl } from '../profiles.js';
|
|
4
|
+
/**
|
|
5
|
+
* Local-port listeners we refuse to attach through. These forward CDP traffic
|
|
6
|
+
* to a remote host — silently using them would let a `cdp://127.0.0.1:N`
|
|
7
|
+
* profile drive a browser on a different machine without the caller realizing.
|
|
8
|
+
*/
|
|
9
|
+
const TUNNEL_PROCESS_NAMES = new Set(['ssh', 'autossh', 'mosh-client', 'socat']);
|
|
10
|
+
function isTunnelProcess(command) {
|
|
11
|
+
return TUNNEL_PROCESS_NAMES.has(command.toLowerCase());
|
|
12
|
+
}
|
|
3
13
|
export async function connectLocal(endpoint, profile) {
|
|
4
14
|
const url = new URL(endpoint);
|
|
5
15
|
if (url.protocol !== 'cdp:') {
|
|
6
16
|
throw new Error(`Invalid local endpoint: ${endpoint}`);
|
|
7
17
|
}
|
|
8
|
-
|
|
18
|
+
// Share the parser with the SSH driver and the collision-detection code
|
|
19
|
+
// path so `cdp://host:N` and `cdp://host?port=N` behave identically.
|
|
20
|
+
const parsed = parseEndpointUrl(endpoint);
|
|
21
|
+
const port = parsed?.port ?? 9222;
|
|
22
|
+
// Refuse to attach through an SSH tunnel before we even try to speak CDP.
|
|
23
|
+
// `verifyBrowserIdentity` only inspects what comes back over the wire — it
|
|
24
|
+
// can't tell whether the browser actually lives on this machine or on the
|
|
25
|
+
// far end of an `ssh -L` tunnel. A stale tunnel from a prior session
|
|
26
|
+
// (common when an SSH-driven profile is deleted before the daemon exits)
|
|
27
|
+
// will silently hijack any "local" profile bound to the same port.
|
|
28
|
+
const preOccupant = getPortOccupant(port);
|
|
29
|
+
if (preOccupant && isTunnelProcess(preOccupant.command)) {
|
|
30
|
+
throw new Error(`Port ${port} is held by ${preOccupant.command} (pid ${preOccupant.pid}), an SSH ` +
|
|
31
|
+
`tunnel forwarding to a remote host. Profile "${profile.name}" is configured as ` +
|
|
32
|
+
`local (${endpoint}) but traffic would round-trip to another machine. Either kill ` +
|
|
33
|
+
`the tunnel (\`kill ${preOccupant.pid}\`) and retry, or switch the profile to an ` +
|
|
34
|
+
`ssh:// endpoint to drive the remote browser explicitly.`);
|
|
35
|
+
}
|
|
9
36
|
try {
|
|
10
37
|
const { wsUrl, browser } = await discoverBrowserWsUrl(port);
|
|
11
38
|
verifyBrowserIdentity(browser, profile.browser, port);
|
|
@@ -30,7 +57,7 @@ export async function connectLocal(endpoint, profile) {
|
|
|
30
57
|
}
|
|
31
58
|
const newPort = port;
|
|
32
59
|
const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
|
|
33
|
-
const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary);
|
|
60
|
+
const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
|
|
34
61
|
const cdp = new CDPClient();
|
|
35
62
|
await cdp.connect(wsUrl);
|
|
36
63
|
return { cdp, port: newPort, pid };
|
|
@@ -1,23 +1,56 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
2
|
import * as net from 'net';
|
|
3
3
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getPortOccupant } from '../chrome.js';
|
|
5
|
+
import { parseEndpointUrl } from '../profiles.js';
|
|
6
|
+
import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
|
|
5
7
|
export async function connectSSH(endpoint, profile) {
|
|
6
8
|
const url = new URL(endpoint);
|
|
7
9
|
if (url.protocol !== 'ssh:') {
|
|
8
10
|
throw new Error(`Invalid SSH endpoint: ${endpoint}`);
|
|
9
11
|
}
|
|
10
12
|
const user = url.username || process.env.USER || 'root';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// Use the shared parser so the documented `ssh://host?port=N` form works
|
|
14
|
+
// identically to `ssh://host:N`. Previously `url.port` alone meant every
|
|
15
|
+
// `?port=`-style profile silently fell back to 9222.
|
|
16
|
+
const parsed = parseEndpointUrl(endpoint);
|
|
17
|
+
if (!parsed) {
|
|
18
|
+
throw new Error(`Could not extract host:port from SSH endpoint: ${endpoint}`);
|
|
19
|
+
}
|
|
20
|
+
const host = parsed.host;
|
|
21
|
+
const remotePort = parsed.port;
|
|
22
|
+
// Bind the tunnel to the SAME local port the user configured. Using an
|
|
23
|
+
// allocated port instead made `status` print confusing rows like
|
|
24
|
+
// `port 9200 (configured 10005)` and made it impossible to predict which
|
|
25
|
+
// local port a profile would land on. Now `ssh://host?port=N` => local N.
|
|
26
|
+
const localPort = remotePort;
|
|
27
|
+
// Preflight: if the local port is busy with something that isn't our
|
|
28
|
+
// own SSH tunnel for this very target, bail with a clear error. Letting
|
|
29
|
+
// ssh -L race ahead would either silently succeed (binding to a second
|
|
30
|
+
// port via fail-safe) or fail with cryptic stderr.
|
|
31
|
+
const occupant = getPortOccupant(localPort);
|
|
32
|
+
if (occupant && !isOwnTunnel(occupant.pid, host, remotePort)) {
|
|
33
|
+
throw new Error(`Local port ${localPort} (needed for SSH tunnel to ${host}:${remotePort}) ` +
|
|
34
|
+
`is already in use by ${occupant.command} (pid ${occupant.pid}). ` +
|
|
35
|
+
`Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
|
|
36
|
+
}
|
|
14
37
|
try {
|
|
15
38
|
await ensureRemoteBrowser(user, host, profile.browser, remotePort, profile.binary);
|
|
16
39
|
}
|
|
17
40
|
catch {
|
|
18
41
|
// Browser may already be running, continue
|
|
19
42
|
}
|
|
20
|
-
let tunnel
|
|
43
|
+
let tunnel;
|
|
44
|
+
if (occupant) {
|
|
45
|
+
// Reuse the existing tunnel rather than spawning a duplicate.
|
|
46
|
+
tunnel = { pid: occupant.pid, kill: () => { try {
|
|
47
|
+
process.kill(occupant.pid);
|
|
48
|
+
}
|
|
49
|
+
catch { /* gone */ } } };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
tunnel = await startSSHTunnel(user, host, localPort, remotePort);
|
|
53
|
+
}
|
|
21
54
|
try {
|
|
22
55
|
await waitForPort(localPort, 8000);
|
|
23
56
|
}
|
|
@@ -35,13 +68,28 @@ export async function connectSSH(endpoint, profile) {
|
|
|
35
68
|
}
|
|
36
69
|
const cdp = new CDPClient();
|
|
37
70
|
await cdp.connect(wsUrl);
|
|
71
|
+
// Record the tunnel in the profile's runtime so a future daemon — or
|
|
72
|
+
// the orphan reaper after a crash — can find and clean it up. The
|
|
73
|
+
// `kind: 'tunnel'` flag distinguishes it from a locally-launched
|
|
74
|
+
// browser process.
|
|
75
|
+
const tunnelPid = tunnel.pid ?? 0;
|
|
76
|
+
if (tunnelPid > 0) {
|
|
77
|
+
writeProfileRuntime(profile.name, {
|
|
78
|
+
pid: 0,
|
|
79
|
+
port: localPort,
|
|
80
|
+
command: 'ssh',
|
|
81
|
+
kind: 'tunnel',
|
|
82
|
+
tunnelPid,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
38
85
|
return {
|
|
39
86
|
cdp,
|
|
40
87
|
port: localPort,
|
|
41
|
-
pid:
|
|
88
|
+
pid: tunnelPid,
|
|
42
89
|
cleanup: () => {
|
|
43
90
|
cdp.close();
|
|
44
91
|
tunnel.kill();
|
|
92
|
+
clearProfileRuntime(profile.name);
|
|
45
93
|
},
|
|
46
94
|
};
|
|
47
95
|
}
|
|
@@ -164,3 +212,30 @@ function runSSHCommand(user, host, cmd) {
|
|
|
164
212
|
function sleep(ms) {
|
|
165
213
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Identify whether a pid listening on our target local port is an SSH
|
|
217
|
+
* tunnel WE would have spawned for `host:remotePort`. Used so that two
|
|
218
|
+
* agents-browser invocations of the same SSH profile share a tunnel
|
|
219
|
+
* rather than failing the second one with "port in use".
|
|
220
|
+
*
|
|
221
|
+
* Best-effort match against the ssh -L command line via `ps`. If we
|
|
222
|
+
* can't read the cmd or the args don't look like ours, treat as not-ours.
|
|
223
|
+
*/
|
|
224
|
+
function isOwnTunnel(pid, host, remotePort) {
|
|
225
|
+
try {
|
|
226
|
+
const out = execSync(`ps -p ${pid} -o command=`, {
|
|
227
|
+
encoding: 'utf-8',
|
|
228
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
229
|
+
}).toString().trim();
|
|
230
|
+
if (!out.startsWith('ssh'))
|
|
231
|
+
return false;
|
|
232
|
+
if (!out.includes(host))
|
|
233
|
+
return false;
|
|
234
|
+
if (!out.includes(`:${remotePort}`) && !out.includes(`:127.0.0.1:${remotePort}`))
|
|
235
|
+
return false;
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { getHelpersDir } from '../state.js';
|
|
5
5
|
import { startDaemon } from '../daemon.js';
|
|
6
|
+
import { getCliVersion } from '../version.js';
|
|
6
7
|
const SOCKET_NAME = 'browser.sock';
|
|
7
8
|
export function getSocketPath() {
|
|
8
9
|
return path.join(getHelpersDir(), SOCKET_NAME);
|
|
@@ -63,6 +64,9 @@ export class BrowserIPCServer {
|
|
|
63
64
|
}
|
|
64
65
|
async handleRequest(request) {
|
|
65
66
|
switch (request.action) {
|
|
67
|
+
case 'version': {
|
|
68
|
+
return { ok: true, version: getCliVersion() };
|
|
69
|
+
}
|
|
66
70
|
case 'start': {
|
|
67
71
|
if (!request.profile) {
|
|
68
72
|
return { ok: false, error: 'Profile required' };
|
|
@@ -316,6 +320,21 @@ export class BrowserIPCServer {
|
|
|
316
320
|
const downloadPath = await this.service.waitForDownload(request.task, request.timeout);
|
|
317
321
|
return { ok: true, downloadPath };
|
|
318
322
|
}
|
|
323
|
+
case 'getAppLogs': {
|
|
324
|
+
if (!request.task) {
|
|
325
|
+
return { ok: false, error: 'Task required' };
|
|
326
|
+
}
|
|
327
|
+
const appLogs = await this.service.getAppLogs(request.task, {
|
|
328
|
+
lines: request.lines,
|
|
329
|
+
level: request.appLevel,
|
|
330
|
+
filter: request.filter,
|
|
331
|
+
message: request.message,
|
|
332
|
+
source: request.source,
|
|
333
|
+
since: request.since,
|
|
334
|
+
until: request.until,
|
|
335
|
+
});
|
|
336
|
+
return { ok: true, appLogs };
|
|
337
|
+
}
|
|
319
338
|
case 'upload': {
|
|
320
339
|
if (!request.task || !request.files || request.files.length === 0) {
|
|
321
340
|
return { ok: false, error: 'Task and at least one file required' };
|
|
@@ -334,7 +353,43 @@ export class BrowserIPCServer {
|
|
|
334
353
|
}
|
|
335
354
|
}
|
|
336
355
|
}
|
|
356
|
+
let versionCheckedThisProcess = false;
|
|
357
|
+
/**
|
|
358
|
+
* Check the daemon's version against ours and warn loudly when they
|
|
359
|
+
* differ. Fires at most once per CLI process — successive calls in the
|
|
360
|
+
* same `agents browser ...` invocation are cheap. The whole reason this
|
|
361
|
+
* code exists: a launchd-managed registry daemon kept serving stale code
|
|
362
|
+
* to a dev-build CLI for an entire session and nothing surfaced it.
|
|
363
|
+
*/
|
|
364
|
+
async function maybeWarnVersionMismatch() {
|
|
365
|
+
if (versionCheckedThisProcess)
|
|
366
|
+
return;
|
|
367
|
+
versionCheckedThisProcess = true;
|
|
368
|
+
try {
|
|
369
|
+
const resp = await sendRawIPCRequest({ action: 'version' });
|
|
370
|
+
const daemon = resp.version;
|
|
371
|
+
const client = getCliVersion();
|
|
372
|
+
if (!daemon || daemon === 'unknown' || daemon === client)
|
|
373
|
+
return;
|
|
374
|
+
process.stderr.write(`\nwarning: browser daemon is on ${daemon} but this CLI is on ${client}.\n` +
|
|
375
|
+
` Run \`agents daemon restart\` to load the current code.\n\n`);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// daemon might be an older build that doesn't speak 'version' — that's
|
|
379
|
+
// itself a hint, but a noisy one. Stay silent on this path.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
337
382
|
export async function sendIPCRequest(request) {
|
|
383
|
+
const result = await sendRawIPCRequest(request);
|
|
384
|
+
// Run the version check after the user's request returns — keeps the
|
|
385
|
+
// critical path zero-overhead and ensures `start` doesn't get blocked
|
|
386
|
+
// on a daemon-restart warning that the user hasn't read yet.
|
|
387
|
+
if (request.action !== 'version') {
|
|
388
|
+
maybeWarnVersionMismatch().catch(() => { });
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
async function sendRawIPCRequest(request) {
|
|
338
393
|
const socketPath = getSocketPath();
|
|
339
394
|
if (!fs.existsSync(socketPath)) {
|
|
340
395
|
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
|
|
@@ -5,8 +5,21 @@ export declare function getProfileRuntimeDir(name: string): string;
|
|
|
5
5
|
export declare function listProfiles(): Promise<BrowserProfile[]>;
|
|
6
6
|
export declare function getProfile(name: string): Promise<BrowserProfile | null>;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Compute the LOCAL port a profile will occupy at runtime:
|
|
9
|
+
* - `cdp://127.0.0.1:N` → N (we listen on N directly)
|
|
10
|
+
* - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
|
|
11
|
+
* - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
|
|
12
|
+
*
|
|
13
|
+
* This is what callers should compare to detect collisions; the (host,
|
|
14
|
+
* port) tuple is no longer enough because SSH profiles do compete with
|
|
15
|
+
* cdp:// profiles for local ports under the new tunnel scheme.
|
|
16
|
+
*/
|
|
17
|
+
export declare function effectiveLocalPort(profile: BrowserProfile): number | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Find a port in 9222–9399 that is not already claimed by ANY existing
|
|
20
|
+
* profile (cdp:// or ssh://) and is not in use by any OS process. The
|
|
21
|
+
* SSH change to bind locally on `?port=N` means we no longer get to
|
|
22
|
+
* skip remote profiles in this scan.
|
|
10
23
|
*/
|
|
11
24
|
export declare function findFreeProfilePort(): Promise<number>;
|
|
12
25
|
export declare function createProfile(profile: BrowserProfile): Promise<void>;
|
|
@@ -34,8 +47,39 @@ export declare function resolveEndpoint(profile: BrowserProfile, endpointName?:
|
|
|
34
47
|
binary?: string;
|
|
35
48
|
targetFilter?: string;
|
|
36
49
|
};
|
|
50
|
+
/**
|
|
51
|
+
* Extract the (host, port) pair intended by the profile's default endpoint.
|
|
52
|
+
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
53
|
+
*
|
|
54
|
+
* Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
|
|
55
|
+
* this machine) and an `ssh://mac-mini:9222` profile (Comet on mac-mini)
|
|
56
|
+
* point at different physical ports — the host disambiguates them.
|
|
57
|
+
*
|
|
58
|
+
* Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
|
|
59
|
+
* latter is the documented form in `types.ts` for `ssh://`). Without this,
|
|
60
|
+
* `ssh://mac-mini?port=18805` would silently fall back to 9222 and every
|
|
61
|
+
* `?port=`-style SSH profile would collide on creation.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractConfiguredEndpoint(profile: BrowserProfile): {
|
|
64
|
+
host: string;
|
|
65
|
+
port: number;
|
|
66
|
+
} | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Shared endpoint parser used by both the collision-detection code path and
|
|
69
|
+
* the connection drivers. Returning a single normalized `(host, port)` here
|
|
70
|
+
* keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
|
|
71
|
+
* conventions (which is how `?port=N` ended up being silently ignored).
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseEndpointUrl(endpoint: string): {
|
|
74
|
+
host: string;
|
|
75
|
+
port: number;
|
|
76
|
+
} | undefined;
|
|
37
77
|
/**
|
|
38
78
|
* Extract the port intended by the profile's default endpoint.
|
|
39
79
|
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
80
|
+
*
|
|
81
|
+
* Note: this loses the host dimension — for collision detection use
|
|
82
|
+
* `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
|
|
40
83
|
*/
|
|
41
84
|
export declare function extractConfiguredPort(profile: BrowserProfile): number | undefined;
|
|
85
|
+
export declare function isLocalHost(host: string): boolean;
|