@phnx-labs/agents-cli 1.17.1 → 1.17.3
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 +16 -0
- package/README.md +2 -0
- package/dist/commands/browser.js +33 -1
- package/dist/commands/secrets.js +75 -7
- package/dist/index.js +4 -2
- package/dist/lib/browser/profiles.js +3 -0
- package/dist/lib/browser/service.d.ts +33 -0
- package/dist/lib/browser/service.js +120 -2
- package/dist/lib/browser/types.d.ts +10 -0
- package/dist/lib/secrets/bundles.d.ts +6 -0
- package/dist/lib/secrets/bundles.js +40 -0
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.3
|
|
4
|
+
|
|
5
|
+
**Browser**
|
|
6
|
+
|
|
7
|
+
- `agents browser profiles create` gains `--electron`, `--binary`, and `--target-filter` for driving Electron desktop apps (Canva, Slack, etc.) that expose multiple CDP page targets. The picker matches by `url:<substring>` or `title:<substring>` (case-insensitive) and falls back to a skip-invisible heuristic when no filter is set; misses against an explicit filter throw with the full candidate list. `BrowserService.evaluate` now uses `awaitPromise: true` and surfaces `exceptionDetails` so async script errors propagate as thrown errors. ([#14](https://github.com/phnx-labs/agents-cli/pull/14))
|
|
8
|
+
|
|
9
|
+
**Secrets**
|
|
10
|
+
|
|
11
|
+
- `agents secrets list` rework — drop the misleading `SENSITIVE` column and add `SYNC` (iCloud yes/no) plus `CREATED` / `UPDATED` / `USED` relative-age columns. Timestamps live inside the keychain bundle JSON, are stamped on write (created sticky, updated always advances), and on resolve via a 60s throttle. Set `AGENTS_NO_USAGE_TRACK=1` to disable the usage stamp. `agents secrets view` shows the matching absolute ISO + relative age fields. ([#18](https://github.com/phnx-labs/agents-cli/pull/18))
|
|
12
|
+
|
|
13
|
+
## 1.17.2
|
|
14
|
+
|
|
15
|
+
**Fixes**
|
|
16
|
+
|
|
17
|
+
- Auto-update prompt no longer hangs in non-interactive environments (CI, k8s pods, cloud sandbox factories). The TTY check now requires both stdin and stdout to be terminals before prompting, and `AGENTS_CLI_DISABLE_AUTO_UPDATE=1` forces the check off entirely for headless deploys. ([#15](https://github.com/phnx-labs/agents-cli/issues/15))
|
|
18
|
+
|
|
3
19
|
## 1.17.1
|
|
4
20
|
|
|
5
21
|
**Agent management**
|
package/README.md
CHANGED
|
@@ -588,6 +588,8 @@ The installer tries Bun first (faster), falls back to npm. Node 18+ required at
|
|
|
588
588
|
|
|
589
589
|
Yes -- `agents run` is non-interactive by default. `--yes` auto-accepts prompts, `--json` for structured output. Pass explicit names and IDs instead of relying on interactive pickers.
|
|
590
590
|
|
|
591
|
+
The auto-update prompt is suppressed automatically when stdin or stdout isn't a TTY. For headless environments where TTY detection misfires (k8s pods that allocate a PTY for stdout, cloud sandbox factories), set `AGENTS_CLI_DISABLE_AUTO_UPDATE=1` to skip the update check entirely -- no prompt, no network call.
|
|
592
|
+
|
|
591
593
|
### What happens to my config when I switch versions?
|
|
592
594
|
|
|
593
595
|
Each version has its own isolated config directory. Switching just repoints a symlink — your per-version config stays untouched. On first migration (if you had a real `~/.claude/` directory before using agents-cli), that gets backed up once to `~/.agents-system/backups/`.
|
package/dist/commands/browser.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from 'path';
|
|
|
3
3
|
import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, } from '../lib/browser/profiles.js';
|
|
4
4
|
import { findBrowserPath, getPortOccupant } from '../lib/browser/chrome.js';
|
|
5
5
|
import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
|
|
6
|
+
import { parseTargetFilter } from '../lib/browser/service.js';
|
|
6
7
|
import { sendIPCRequest } from '../lib/browser/ipc.js';
|
|
7
8
|
import { browserTaskPicker } from './browser-picker.js';
|
|
8
9
|
import { isInteractiveTerminal } from './utils.js';
|
|
@@ -51,7 +52,7 @@ function registerProfilesCommands(browser) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
|
-
const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge'];
|
|
55
|
+
const VALID_BROWSERS = ['chrome', 'comet', 'chromium', 'brave', 'edge', 'custom'];
|
|
55
56
|
profiles
|
|
56
57
|
.command('create <name>')
|
|
57
58
|
.description('Create a new browser profile')
|
|
@@ -62,6 +63,9 @@ function registerProfilesCommands(browser) {
|
|
|
62
63
|
.option('--headless', 'Run in headless mode')
|
|
63
64
|
.option('--window <WxH>', 'Window size, e.g. 1512x982')
|
|
64
65
|
.option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
|
|
66
|
+
.option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
|
|
67
|
+
.option('--electron', 'Treat this profile as an Electron desktop app: never call Target.createTarget; bind to the visible window using --target-filter or the skip-invisible heuristic')
|
|
68
|
+
.option('--target-filter <expr>', 'Pick the visible CDP page target when the app exposes more than one. Format: url:<substring> or title:<substring>')
|
|
65
69
|
.action(async (name, opts) => {
|
|
66
70
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
67
71
|
console.error('Profile name must be lowercase alphanumeric with hyphens');
|
|
@@ -71,6 +75,25 @@ function registerProfilesCommands(browser) {
|
|
|
71
75
|
console.error(`Invalid browser type. Must be one of: ${VALID_BROWSERS.join(', ')}`);
|
|
72
76
|
process.exit(1);
|
|
73
77
|
}
|
|
78
|
+
if (opts.browser === 'custom' && !opts.binary) {
|
|
79
|
+
console.error('--browser custom requires --binary <path>');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
if (opts.targetFilter) {
|
|
83
|
+
// Route through the same parser the runtime uses so the CLI gate matches
|
|
84
|
+
// the runtime contract — `url:` (empty value) and `url: foo` (leading
|
|
85
|
+
// whitespace) both pass a naive `kind` check but produce a silent
|
|
86
|
+
// heuristic fallback at runtime.
|
|
87
|
+
const parsed = parseTargetFilter(String(opts.targetFilter));
|
|
88
|
+
if (!parsed) {
|
|
89
|
+
console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
if (!opts.electron) {
|
|
93
|
+
console.error('--target-filter requires --electron (the filter is only consulted on Electron profiles)');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
74
97
|
// Auto-assign a free port if no endpoint was provided
|
|
75
98
|
let endpoints = opts.endpoint;
|
|
76
99
|
if (endpoints.length === 0) {
|
|
@@ -104,6 +127,9 @@ function registerProfilesCommands(browser) {
|
|
|
104
127
|
name,
|
|
105
128
|
description: opts.description,
|
|
106
129
|
browser: opts.browser,
|
|
130
|
+
binary: opts.binary,
|
|
131
|
+
electron: opts.electron || undefined,
|
|
132
|
+
targetFilter: opts.targetFilter,
|
|
107
133
|
endpoints,
|
|
108
134
|
secrets: opts.secrets,
|
|
109
135
|
chrome: opts.headless ? { headless: true } : undefined,
|
|
@@ -133,6 +159,12 @@ function registerProfilesCommands(browser) {
|
|
|
133
159
|
}
|
|
134
160
|
console.log(`Name: ${profile.name}`);
|
|
135
161
|
console.log(`Browser: ${profile.browser}`);
|
|
162
|
+
if (profile.binary)
|
|
163
|
+
console.log(`Binary: ${profile.binary}`);
|
|
164
|
+
if (profile.electron)
|
|
165
|
+
console.log(`Electron: true`);
|
|
166
|
+
if (profile.targetFilter)
|
|
167
|
+
console.log(`Target filter: ${profile.targetFilter}`);
|
|
136
168
|
if (profile.description)
|
|
137
169
|
console.log(`Description: ${profile.description}`);
|
|
138
170
|
console.log(`Endpoints:`);
|
package/dist/commands/secrets.js
CHANGED
|
@@ -128,16 +128,78 @@ function readStdinSync() {
|
|
|
128
128
|
}
|
|
129
129
|
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
130
130
|
}
|
|
131
|
+
/** Strip ANSI escape sequences so padding can be computed on visible width. */
|
|
132
|
+
function visibleWidth(s) {
|
|
133
|
+
// eslint-disable-next-line no-control-regex
|
|
134
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
135
|
+
}
|
|
136
|
+
/** padEnd that respects ANSI color codes (chalk-wrapped strings have invisible bytes). */
|
|
137
|
+
function padVisible(s, n) {
|
|
138
|
+
const w = visibleWidth(s);
|
|
139
|
+
if (w >= n)
|
|
140
|
+
return s;
|
|
141
|
+
return s + ' '.repeat(n - w);
|
|
142
|
+
}
|
|
143
|
+
/** Render an ISO-8601 timestamp as a compact relative age: "now", "5m", "1h", "3d", "2w", "4mo", "1y". */
|
|
144
|
+
function relativeAge(iso) {
|
|
145
|
+
const t = Date.parse(iso);
|
|
146
|
+
if (!Number.isFinite(t))
|
|
147
|
+
return '-';
|
|
148
|
+
const deltaMs = Date.now() - t;
|
|
149
|
+
if (deltaMs < 0)
|
|
150
|
+
return 'now';
|
|
151
|
+
const sec = Math.floor(deltaMs / 1000);
|
|
152
|
+
if (sec < 60)
|
|
153
|
+
return 'now';
|
|
154
|
+
const min = Math.floor(sec / 60);
|
|
155
|
+
if (min < 60)
|
|
156
|
+
return `${min}m`;
|
|
157
|
+
const hr = Math.floor(min / 60);
|
|
158
|
+
if (hr < 24)
|
|
159
|
+
return `${hr}h`;
|
|
160
|
+
const day = Math.floor(hr / 24);
|
|
161
|
+
if (day < 7)
|
|
162
|
+
return `${day}d`;
|
|
163
|
+
if (day < 30)
|
|
164
|
+
return `${Math.floor(day / 7)}w`;
|
|
165
|
+
const mo = Math.floor(day / 30);
|
|
166
|
+
if (mo < 12)
|
|
167
|
+
return `${mo}mo`;
|
|
168
|
+
return `${Math.floor(day / 365)}y`;
|
|
169
|
+
}
|
|
170
|
+
/** Long-form relative age for the `view` command. "now" stays as "now"; otherwise appends " ago". */
|
|
171
|
+
function humanAge(iso) {
|
|
172
|
+
const age = relativeAge(iso);
|
|
173
|
+
if (age === 'now' || age === '-')
|
|
174
|
+
return age;
|
|
175
|
+
return `${age} ago`;
|
|
176
|
+
}
|
|
131
177
|
/** Format a single bundle as a table row for the `secrets list` output. */
|
|
132
178
|
function renderBundleRow(b) {
|
|
133
179
|
const entries = describeBundle(b);
|
|
134
180
|
const keys = entries.length;
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
181
|
+
const sync = b.icloud_sync ? chalk.cyan('icloud') : '';
|
|
182
|
+
const expiringCount = countExpiringSoon(b.meta);
|
|
183
|
+
const expiring = expiringCount > 0 ? chalk.yellow(String(expiringCount)) : chalk.gray('-');
|
|
184
|
+
// Timestamp distinction:
|
|
185
|
+
// "?" -> legacy bundle, never written under the timestamping code.
|
|
186
|
+
// "never" -> bundle has been written but the action never happened
|
|
187
|
+
// (currently only used for USED — CREATED/UPDATED are always
|
|
188
|
+
// set together by writeBundle).
|
|
189
|
+
// <age> -> real data.
|
|
190
|
+
const created = b.created_at ? relativeAge(b.created_at) : chalk.gray('?');
|
|
191
|
+
const updated = b.updated_at ? relativeAge(b.updated_at) : chalk.gray('?');
|
|
192
|
+
const used = b.last_used
|
|
193
|
+
? relativeAge(b.last_used)
|
|
194
|
+
: (b.created_at ? chalk.gray('never') : chalk.gray('?'));
|
|
195
|
+
const head = `${chalk.cyan(b.name.padEnd(20))} ` +
|
|
196
|
+
`${String(keys).padEnd(5)} ` +
|
|
197
|
+
`${padVisible(sync, 6)} ` +
|
|
198
|
+
`${padVisible(expiring, 9)} ` +
|
|
199
|
+
`${padVisible(created, 9)} ` +
|
|
200
|
+
`${padVisible(updated, 9)} ` +
|
|
201
|
+
`${padVisible(used, 7)}`;
|
|
202
|
+
return b.description ? `${head} ${chalk.gray(b.description)}` : head.trimEnd();
|
|
141
203
|
}
|
|
142
204
|
/** Colorize a variable source kind (literal, keychain, env, file, exec). */
|
|
143
205
|
function kindLabel(kind) {
|
|
@@ -340,7 +402,7 @@ Examples:
|
|
|
340
402
|
console.log(chalk.gray('Try: agents secrets create <name>'));
|
|
341
403
|
return;
|
|
342
404
|
}
|
|
343
|
-
console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(
|
|
405
|
+
console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'SYNC'.padEnd(6)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
|
|
344
406
|
for (const b of bundles) {
|
|
345
407
|
console.log(renderBundleRow(b));
|
|
346
408
|
}
|
|
@@ -363,6 +425,12 @@ Examples:
|
|
|
363
425
|
console.log(chalk.yellow('allow_exec: true'));
|
|
364
426
|
if (bundle.icloud_sync)
|
|
365
427
|
console.log(chalk.cyan('icloud_sync: true'));
|
|
428
|
+
if (bundle.created_at)
|
|
429
|
+
console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
|
|
430
|
+
if (bundle.updated_at)
|
|
431
|
+
console.log(chalk.gray(`updated_at: ${bundle.updated_at} (${humanAge(bundle.updated_at)})`));
|
|
432
|
+
if (bundle.last_used)
|
|
433
|
+
console.log(chalk.gray(`last_used: ${bundle.last_used} (${humanAge(bundle.last_used)})`));
|
|
366
434
|
console.log();
|
|
367
435
|
if (entries.length === 0) {
|
|
368
436
|
console.log(chalk.gray('(no keys)'));
|
package/dist/index.js
CHANGED
|
@@ -60,7 +60,7 @@ import { registerUsageCommand } from './commands/usage.js';
|
|
|
60
60
|
import { registerAliasCommand } from './commands/alias.js';
|
|
61
61
|
import { registerBetaCommands } from './commands/beta.js';
|
|
62
62
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
63
|
-
import { isPromptCancelled } from './commands/utils.js';
|
|
63
|
+
import { isInteractiveTerminal, isPromptCancelled } from './commands/utils.js';
|
|
64
64
|
import { AGENTS } from './lib/agents.js';
|
|
65
65
|
import { getGlobalDefault, listInstalledVersions } from './lib/versions.js';
|
|
66
66
|
import { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, hasAliasShadowingShim, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } from './lib/shims.js';
|
|
@@ -240,7 +240,7 @@ function saveUpdateCheck(latestVersion) {
|
|
|
240
240
|
}
|
|
241
241
|
/** Present an interactive upgrade prompt (TTY) or a one-line hint (non-TTY). */
|
|
242
242
|
async function promptUpgrade(latestVersion) {
|
|
243
|
-
if (!
|
|
243
|
+
if (!isInteractiveTerminal()) {
|
|
244
244
|
console.error(chalk.yellow(`Update available: ${VERSION} -> ${latestVersion}. Run: npm install -g @phnx-labs/agents-cli@latest`));
|
|
245
245
|
return;
|
|
246
246
|
}
|
|
@@ -309,6 +309,8 @@ function refreshUpdateCacheInBackground() {
|
|
|
309
309
|
}
|
|
310
310
|
/** Check for available updates using the local cache. Triggers a background refresh if stale. */
|
|
311
311
|
async function checkForUpdates() {
|
|
312
|
+
if (process.env.AGENTS_CLI_DISABLE_AUTO_UPDATE)
|
|
313
|
+
return;
|
|
312
314
|
const cache = readUpdateCache();
|
|
313
315
|
// Kick off network refresh in background if stale. Does not block.
|
|
314
316
|
if (shouldFetchLatest(cache)) {
|
|
@@ -15,6 +15,7 @@ function configToProfile(name, config) {
|
|
|
15
15
|
browser: config.browser,
|
|
16
16
|
binary: config.binary,
|
|
17
17
|
electron: config.electron,
|
|
18
|
+
targetFilter: config.targetFilter,
|
|
18
19
|
endpoints: config.endpoints,
|
|
19
20
|
chrome: config.chrome,
|
|
20
21
|
secrets: config.secrets,
|
|
@@ -32,6 +33,8 @@ function profileToConfig(profile) {
|
|
|
32
33
|
config.binary = profile.binary;
|
|
33
34
|
if (profile.electron)
|
|
34
35
|
config.electron = profile.electron;
|
|
36
|
+
if (profile.targetFilter)
|
|
37
|
+
config.targetFilter = profile.targetFilter;
|
|
35
38
|
if (profile.chrome)
|
|
36
39
|
config.chrome = profile.chrome;
|
|
37
40
|
if (profile.secrets)
|
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
import { type TabInfo, type ProfileStatus, type HistoricalTask } from './types.js';
|
|
2
2
|
import { type RefOpts, type RefNode } from './refs.js';
|
|
3
|
+
import type { TargetFilter } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parse a `targetFilter` string into its kind + value, or return `null`
|
|
6
|
+
* when the input is missing or malformed. Filter syntax:
|
|
7
|
+
* - `url:<substring>` — picks the first page target whose URL contains the substring
|
|
8
|
+
* - `title:<substring>` — picks the first page target whose title contains the substring
|
|
9
|
+
*
|
|
10
|
+
* The match is case-insensitive on both sides because Electron apps
|
|
11
|
+
* frequently lowercase or title-case their target metadata in unpredictable ways.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseTargetFilter(filter: string | undefined): TargetFilter | null;
|
|
14
|
+
/**
|
|
15
|
+
* Choose the CDP page target that represents the visible UI.
|
|
16
|
+
*
|
|
17
|
+
* Order:
|
|
18
|
+
* 1. If `filter` is set and parseable, narrow to page targets matching it
|
|
19
|
+
* (case-insensitive substring). Among matches, prefer one that is not in
|
|
20
|
+
* `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
|
|
21
|
+
* filter like `url:https://www.canva.com/` skip the background service
|
|
22
|
+
* (`https://www.canva.com/_desktop-background-service` *also* matches the
|
|
23
|
+
* substring). If every match is invisible, return the first match so the
|
|
24
|
+
* caller still gets something rather than silently falling through.
|
|
25
|
+
* An explicit filter that finds *no* match returns `undefined` — callers
|
|
26
|
+
* should surface this as an error rather than create an orphan window.
|
|
27
|
+
* 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
|
|
28
|
+
* across all page targets.
|
|
29
|
+
* 3. As a last resort, return the first page target.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pickWindowTarget<T extends {
|
|
32
|
+
type: string;
|
|
33
|
+
url?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
}>(targets: T[], filter: string | undefined): T | undefined;
|
|
3
36
|
export declare class BrowserService {
|
|
4
37
|
private connections;
|
|
5
38
|
private forkingProfiles;
|
|
@@ -9,6 +9,87 @@ import { generateTaskId, generateShortId, generateFunName, } from './types.js';
|
|
|
9
9
|
import { getRefs, resolveRefToCoords } from './refs.js';
|
|
10
10
|
import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
11
11
|
import { emit } from '../events.js';
|
|
12
|
+
/**
|
|
13
|
+
* Parse a `targetFilter` string into its kind + value, or return `null`
|
|
14
|
+
* when the input is missing or malformed. Filter syntax:
|
|
15
|
+
* - `url:<substring>` — picks the first page target whose URL contains the substring
|
|
16
|
+
* - `title:<substring>` — picks the first page target whose title contains the substring
|
|
17
|
+
*
|
|
18
|
+
* The match is case-insensitive on both sides because Electron apps
|
|
19
|
+
* frequently lowercase or title-case their target metadata in unpredictable ways.
|
|
20
|
+
*/
|
|
21
|
+
export function parseTargetFilter(filter) {
|
|
22
|
+
if (!filter)
|
|
23
|
+
return null;
|
|
24
|
+
const idx = filter.indexOf(':');
|
|
25
|
+
if (idx <= 0)
|
|
26
|
+
return null;
|
|
27
|
+
const kind = filter.slice(0, idx).trim().toLowerCase();
|
|
28
|
+
// Strip whitespace around the value so `url: https://x` (with a copy-pasted
|
|
29
|
+
// space after the colon) doesn't silently fail to match — `.includes(' x')`
|
|
30
|
+
// never hits a URL because URLs don't contain spaces.
|
|
31
|
+
const value = filter.slice(idx + 1).trim();
|
|
32
|
+
if (kind !== 'url' && kind !== 'title')
|
|
33
|
+
return null;
|
|
34
|
+
if (!value)
|
|
35
|
+
return null;
|
|
36
|
+
return { kind, value };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* URLs that the skip-invisible heuristic excludes when no explicit filter
|
|
40
|
+
* matches. These are page targets Electron apps ship for housekeeping;
|
|
41
|
+
* picking one means screenshots come back blank.
|
|
42
|
+
*/
|
|
43
|
+
const INVISIBLE_URL_PATTERNS = [
|
|
44
|
+
/^about:blank$/i,
|
|
45
|
+
/^file:\/\//i,
|
|
46
|
+
/\/_desktop-background-service(\?|$|\/)/i,
|
|
47
|
+
/\/_internal(\?|$|\/)/i,
|
|
48
|
+
/\/_background(\?|$|\/)/i,
|
|
49
|
+
];
|
|
50
|
+
function isLikelyInvisible(url) {
|
|
51
|
+
if (!url)
|
|
52
|
+
return true;
|
|
53
|
+
return INVISIBLE_URL_PATTERNS.some((re) => re.test(url));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Choose the CDP page target that represents the visible UI.
|
|
57
|
+
*
|
|
58
|
+
* Order:
|
|
59
|
+
* 1. If `filter` is set and parseable, narrow to page targets matching it
|
|
60
|
+
* (case-insensitive substring). Among matches, prefer one that is not in
|
|
61
|
+
* `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
|
|
62
|
+
* filter like `url:https://www.canva.com/` skip the background service
|
|
63
|
+
* (`https://www.canva.com/_desktop-background-service` *also* matches the
|
|
64
|
+
* substring). If every match is invisible, return the first match so the
|
|
65
|
+
* caller still gets something rather than silently falling through.
|
|
66
|
+
* An explicit filter that finds *no* match returns `undefined` — callers
|
|
67
|
+
* should surface this as an error rather than create an orphan window.
|
|
68
|
+
* 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
|
|
69
|
+
* across all page targets.
|
|
70
|
+
* 3. As a last resort, return the first page target.
|
|
71
|
+
*/
|
|
72
|
+
export function pickWindowTarget(targets, filter) {
|
|
73
|
+
const pages = targets.filter((t) => t.type === 'page');
|
|
74
|
+
if (pages.length === 0)
|
|
75
|
+
return undefined;
|
|
76
|
+
const parsed = parseTargetFilter(filter);
|
|
77
|
+
if (parsed) {
|
|
78
|
+
const needle = parsed.value.toLowerCase();
|
|
79
|
+
const matches = pages.filter((t) => {
|
|
80
|
+
const hay = (parsed.kind === 'url' ? t.url : t.title) ?? '';
|
|
81
|
+
return hay.toLowerCase().includes(needle);
|
|
82
|
+
});
|
|
83
|
+
if (matches.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
const visible = matches.find((t) => !isLikelyInvisible(t.url));
|
|
86
|
+
return visible ?? matches[0];
|
|
87
|
+
}
|
|
88
|
+
const visible = pages.find((t) => !isLikelyInvisible(t.url));
|
|
89
|
+
if (visible)
|
|
90
|
+
return visible;
|
|
91
|
+
return pages[0];
|
|
92
|
+
}
|
|
12
93
|
export class BrowserService {
|
|
13
94
|
connections = new Map();
|
|
14
95
|
forkingProfiles = new Set();
|
|
@@ -351,7 +432,25 @@ export class BrowserService {
|
|
|
351
432
|
throw new Error(`Tab ${shortId} not found`);
|
|
352
433
|
}
|
|
353
434
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
354
|
-
|
|
435
|
+
// `awaitPromise: true` lets callers write `evaluate '(async () => {...})()'`
|
|
436
|
+
// and get the resolved value back instead of a stringified Promise. This
|
|
437
|
+
// is essential for any flow that needs sub-step waits inside the page
|
|
438
|
+
// (e.g. driving a multi-step modal where each step needs React to settle
|
|
439
|
+
// before the next call). Without it, the shell-side workaround is to
|
|
440
|
+
// chain N separate `evaluate` calls with `sleep` between them, which
|
|
441
|
+
// races against the page's own state machine.
|
|
442
|
+
//
|
|
443
|
+
// `exceptionDetails` is surfaced as a thrown error so a rejected promise
|
|
444
|
+
// or a thrown error inside the expression doesn't silently return `undefined`.
|
|
445
|
+
const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId));
|
|
446
|
+
if (result.exceptionDetails) {
|
|
447
|
+
const ex = result.exceptionDetails;
|
|
448
|
+
const msg = ex.exception?.description ??
|
|
449
|
+
(typeof ex.exception?.value === 'string' ? ex.exception.value : undefined) ??
|
|
450
|
+
ex.text ??
|
|
451
|
+
'evaluate failed';
|
|
452
|
+
throw new Error(msg);
|
|
453
|
+
}
|
|
355
454
|
return result.result.value;
|
|
356
455
|
}
|
|
357
456
|
async screenshot(taskId, tabHint, outputPath) {
|
|
@@ -839,6 +938,7 @@ export class BrowserService {
|
|
|
839
938
|
port,
|
|
840
939
|
pid,
|
|
841
940
|
electron: true,
|
|
941
|
+
targetFilter: profile.targetFilter,
|
|
842
942
|
forkedFrom: profile.name,
|
|
843
943
|
tasks: new Map(),
|
|
844
944
|
sessionCache: new Map(),
|
|
@@ -860,6 +960,7 @@ export class BrowserService {
|
|
|
860
960
|
port: existingInfo.port,
|
|
861
961
|
pid: existingInfo.pid,
|
|
862
962
|
electron: profile.electron,
|
|
963
|
+
targetFilter: profile.targetFilter,
|
|
863
964
|
tasks,
|
|
864
965
|
sessionCache: new Map(),
|
|
865
966
|
};
|
|
@@ -889,6 +990,7 @@ export class BrowserService {
|
|
|
889
990
|
port: conn.port,
|
|
890
991
|
pid: conn.pid,
|
|
891
992
|
electron: profile.electron,
|
|
993
|
+
targetFilter: profile.targetFilter,
|
|
892
994
|
tasks: conn.pid === 0 ? this.loadTaskState(profile.name) : new Map(),
|
|
893
995
|
sessionCache: new Map(),
|
|
894
996
|
};
|
|
@@ -901,6 +1003,7 @@ export class BrowserService {
|
|
|
901
1003
|
port: conn.port,
|
|
902
1004
|
pid: conn.pid,
|
|
903
1005
|
electron: profile.electron,
|
|
1006
|
+
targetFilter: profile.targetFilter,
|
|
904
1007
|
tasks: new Map(),
|
|
905
1008
|
sessionCache: new Map(),
|
|
906
1009
|
};
|
|
@@ -914,6 +1017,7 @@ export class BrowserService {
|
|
|
914
1017
|
port: 0,
|
|
915
1018
|
pid: 0,
|
|
916
1019
|
electron: profile.electron,
|
|
1020
|
+
targetFilter: profile.targetFilter,
|
|
917
1021
|
tasks: this.loadTaskState(profile.name),
|
|
918
1022
|
sessionCache: new Map(),
|
|
919
1023
|
};
|
|
@@ -930,6 +1034,7 @@ export class BrowserService {
|
|
|
930
1034
|
port,
|
|
931
1035
|
pid: 0,
|
|
932
1036
|
electron: profile.electron,
|
|
1037
|
+
targetFilter: profile.targetFilter,
|
|
933
1038
|
tasks: this.loadTaskState(profile.name),
|
|
934
1039
|
sessionCache: new Map(),
|
|
935
1040
|
};
|
|
@@ -951,11 +1056,24 @@ export class BrowserService {
|
|
|
951
1056
|
}
|
|
952
1057
|
// Check if browser already has a page target we can use
|
|
953
1058
|
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
954
|
-
const existing = targetInfos
|
|
1059
|
+
const existing = pickWindowTarget(targetInfos, conn.targetFilter);
|
|
955
1060
|
if (existing) {
|
|
956
1061
|
conn.windowId = existing.targetId;
|
|
957
1062
|
return existing.targetId;
|
|
958
1063
|
}
|
|
1064
|
+
// If we have an explicit filter, `pickWindowTarget` returns undefined when nothing
|
|
1065
|
+
// matches. That almost always means the profile is misconfigured (typo in the
|
|
1066
|
+
// filter, target hasn't loaded yet, app version moved the URL). Falling through
|
|
1067
|
+
// to `Target.createTarget` would silently create an orphan tab the user can't see.
|
|
1068
|
+
// Surface the failure instead, with the candidate list so the fix is obvious.
|
|
1069
|
+
if (parseTargetFilter(conn.targetFilter)) {
|
|
1070
|
+
const candidates = targetInfos
|
|
1071
|
+
.filter((t) => t.type === 'page')
|
|
1072
|
+
.map((t) => ` - url=${t.url ?? ''} title=${t.title ?? ''}`)
|
|
1073
|
+
.join('\n');
|
|
1074
|
+
throw new Error(`Target filter ${JSON.stringify(conn.targetFilter)} matched no page target.\n` +
|
|
1075
|
+
`Available page targets:\n${candidates || ' (none)'}`);
|
|
1076
|
+
}
|
|
959
1077
|
// First ever use - create window
|
|
960
1078
|
const result = (await conn.cdp.send('Target.createTarget', {
|
|
961
1079
|
url: 'about:blank',
|
|
@@ -5,6 +5,11 @@ export interface BrowserProfile {
|
|
|
5
5
|
browser: BrowserType;
|
|
6
6
|
binary?: string;
|
|
7
7
|
electron?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* `url:<substring>` or `title:<substring>`. Picks which CDP page target
|
|
10
|
+
* represents the visible UI for Electron apps with multiple WebContents.
|
|
11
|
+
*/
|
|
12
|
+
targetFilter?: string;
|
|
8
13
|
endpoints: string[];
|
|
9
14
|
chrome?: ChromeOptions;
|
|
10
15
|
secrets?: string;
|
|
@@ -15,6 +20,11 @@ export interface BrowserProfile {
|
|
|
15
20
|
y?: number;
|
|
16
21
|
};
|
|
17
22
|
}
|
|
23
|
+
/** Parsed form of `BrowserProfile.targetFilter`. */
|
|
24
|
+
export interface TargetFilter {
|
|
25
|
+
kind: 'url' | 'title';
|
|
26
|
+
value: string;
|
|
27
|
+
}
|
|
18
28
|
export interface ChromeOptions {
|
|
19
29
|
headless?: boolean;
|
|
20
30
|
args?: string[];
|
|
@@ -30,6 +30,12 @@ export interface SecretsBundle {
|
|
|
30
30
|
allow_exec?: boolean;
|
|
31
31
|
/** When true, keychain-backed values and bundle metadata sync via iCloud Keychain. */
|
|
32
32
|
icloud_sync?: boolean;
|
|
33
|
+
/** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
|
|
34
|
+
created_at?: string;
|
|
35
|
+
/** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
|
|
36
|
+
updated_at?: string;
|
|
37
|
+
/** ISO 8601 UTC timestamp. Stamped by resolveBundleEnv (throttled). */
|
|
38
|
+
last_used?: string;
|
|
33
39
|
vars: Record<string, BundleValue>;
|
|
34
40
|
/** Optional per-var metadata, keyed by var name (parallel to `vars`). */
|
|
35
41
|
meta?: Record<string, VarMeta>;
|
|
@@ -29,6 +29,8 @@ export const SECRET_TYPES = [
|
|
|
29
29
|
'webhook',
|
|
30
30
|
'note',
|
|
31
31
|
];
|
|
32
|
+
/** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
|
|
33
|
+
const LAST_USED_THROTTLE_MS = 60_000;
|
|
32
34
|
const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
33
35
|
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
34
36
|
const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
|
|
@@ -109,6 +111,12 @@ export function readBundle(name) {
|
|
|
109
111
|
icloud_sync: Boolean(parsed.icloud_sync),
|
|
110
112
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
111
113
|
};
|
|
114
|
+
if (typeof parsed.created_at === 'string')
|
|
115
|
+
bundle.created_at = parsed.created_at;
|
|
116
|
+
if (typeof parsed.updated_at === 'string')
|
|
117
|
+
bundle.updated_at = parsed.updated_at;
|
|
118
|
+
if (typeof parsed.last_used === 'string')
|
|
119
|
+
bundle.last_used = parsed.last_used;
|
|
112
120
|
if (parsed.meta && typeof parsed.meta === 'object') {
|
|
113
121
|
bundle.meta = parsed.meta;
|
|
114
122
|
}
|
|
@@ -140,10 +148,20 @@ export function writeBundle(bundle) {
|
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
}
|
|
151
|
+
// Stamp timestamps on the bundle so callers see what got persisted. created_at
|
|
152
|
+
// is sticky — once set we never overwrite it, including on legacy bundles
|
|
153
|
+
// that already carry one. updated_at always advances.
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
if (!bundle.created_at)
|
|
156
|
+
bundle.created_at = now;
|
|
157
|
+
bundle.updated_at = now;
|
|
143
158
|
const payload = {
|
|
144
159
|
description: bundle.description,
|
|
145
160
|
allow_exec: bundle.allow_exec ? true : undefined,
|
|
146
161
|
icloud_sync: bundle.icloud_sync ? true : undefined,
|
|
162
|
+
created_at: bundle.created_at,
|
|
163
|
+
updated_at: bundle.updated_at,
|
|
164
|
+
last_used: bundle.last_used,
|
|
147
165
|
vars: bundle.vars,
|
|
148
166
|
meta,
|
|
149
167
|
};
|
|
@@ -194,11 +212,33 @@ export function describeBundle(bundle) {
|
|
|
194
212
|
}
|
|
195
213
|
return out;
|
|
196
214
|
}
|
|
215
|
+
// Bump `bundle.last_used` and persist the bundle, but no more than once per
|
|
216
|
+
// throttle window so we don't pay a keychain write on every agent run. Failures
|
|
217
|
+
// are swallowed — usage tracking is never allowed to break secret resolution.
|
|
218
|
+
// Set AGENTS_NO_USAGE_TRACK=1 to disable the stamp entirely (used by tests).
|
|
219
|
+
function stampLastUsed(bundle) {
|
|
220
|
+
if (process.env.AGENTS_NO_USAGE_TRACK)
|
|
221
|
+
return;
|
|
222
|
+
const nowMs = Date.now();
|
|
223
|
+
if (bundle.last_used) {
|
|
224
|
+
const prev = Date.parse(bundle.last_used);
|
|
225
|
+
if (Number.isFinite(prev) && nowMs - prev < LAST_USED_THROTTLE_MS)
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
bundle.last_used = new Date(nowMs).toISOString();
|
|
230
|
+
writeBundle(bundle);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Swallow — telemetry must never block secret resolution.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
197
236
|
// Walk the bundle and produce a flat env map. Keychain refs are translated via
|
|
198
237
|
// the bundle-scoped naming scheme so two bundles with the same short ID never
|
|
199
238
|
// collide. Throws on the first missing secret so `agents run` fails loudly
|
|
200
239
|
// rather than silently injecting empty strings.
|
|
201
240
|
export function resolveBundleEnv(bundle) {
|
|
241
|
+
stampLastUsed(bundle);
|
|
202
242
|
const env = {};
|
|
203
243
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
204
244
|
const parsed = parseBundleValue(raw);
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -426,6 +426,16 @@ export interface BrowserProfileConfig {
|
|
|
426
426
|
browser: 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'custom';
|
|
427
427
|
binary?: string;
|
|
428
428
|
electron?: boolean;
|
|
429
|
+
/**
|
|
430
|
+
* Selects which CDP page target represents the visible UI when the
|
|
431
|
+
* browser/app exposes more than one. Format: `url:<substring>` or
|
|
432
|
+
* `title:<substring>`. Recommended for Electron apps that ship hidden
|
|
433
|
+
* helper WebContents (background services, OAuth windows, file://
|
|
434
|
+
* shells); without an explicit filter the connector falls back to a
|
|
435
|
+
* skip-invisible heuristic before picking the first page target.
|
|
436
|
+
* Only consulted when `electron` is true.
|
|
437
|
+
*/
|
|
438
|
+
targetFilter?: string;
|
|
429
439
|
endpoints: string[];
|
|
430
440
|
chrome?: {
|
|
431
441
|
headless?: boolean;
|
package/package.json
CHANGED