@phnx-labs/agents-cli 1.20.68 → 1.20.69
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 +45 -0
- package/README.md +11 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/computer.d.ts +26 -0
- package/dist/commands/computer.js +173 -149
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +88 -6
- package/dist/commands/fork.d.ts +9 -0
- package/dist/commands/fork.js +67 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +131 -0
- package/dist/commands/setup-browser.d.ts +18 -0
- package/dist/commands/setup-browser.js +142 -0
- package/dist/commands/setup-computer.d.ts +19 -0
- package/dist/commands/setup-computer.js +133 -0
- package/dist/commands/setup-share.d.ts +17 -0
- package/dist/commands/setup-share.js +85 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +58 -1
- package/dist/commands/share.d.ts +15 -0
- package/dist/commands/share.js +10 -4
- package/dist/commands/ssh.js +164 -0
- package/dist/commands/view.d.ts +3 -14
- package/dist/commands/view.js +27 -28
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +10 -0
- package/dist/lib/agents.js +32 -0
- package/dist/lib/auth-health.d.ts +103 -0
- package/dist/lib/auth-health.js +232 -0
- package/dist/lib/browser/chrome.d.ts +9 -0
- package/dist/lib/browser/chrome.js +22 -0
- package/dist/lib/computer/download.d.ts +51 -0
- package/dist/lib/computer/download.js +145 -0
- package/dist/lib/computer-rpc.js +9 -3
- package/dist/lib/devices/connect.d.ts +15 -0
- package/dist/lib/devices/connect.js +17 -5
- package/dist/lib/devices/terminfo.d.ts +44 -0
- package/dist/lib/devices/terminfo.js +167 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/rotate.d.ts +12 -8
- package/dist/lib/rotate.js +22 -23
- package/dist/lib/session/fork.d.ts +32 -0
- package/dist/lib/session/fork.js +101 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/usage.d.ts +23 -0
- package/dist/lib/usage.js +84 -0
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand download + verification of the macOS `agents computer` helper
|
|
3
|
+
* ("ComputerHelper.app").
|
|
4
|
+
*
|
|
5
|
+
* The helper is a signed + notarized universal `.app` bundle published as a
|
|
6
|
+
* GitHub release asset per tagged CLI version — the same distribution model as
|
|
7
|
+
* the Windows helper (see `lib/ssh-tunnel.ts`). A fresh `npm i -g` machine has
|
|
8
|
+
* no local build, so `agents computer setup` / `agents setup computer` fetch the
|
|
9
|
+
* asset for the running CLI version, verify its sha256 against the published
|
|
10
|
+
* `.sha256`, then verify the code signature (Developer ID Team + notarization)
|
|
11
|
+
* before it is ever copied to /Applications.
|
|
12
|
+
*
|
|
13
|
+
* A `.app` is a directory, so the asset is a zip (`ditto -c -k --keepParent`);
|
|
14
|
+
* we extract it with `ditto -x -k` after the checksum passes.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { Readable } from 'node:stream';
|
|
21
|
+
import { pipeline } from 'node:stream/promises';
|
|
22
|
+
import { getCacheDir } from '../state.js';
|
|
23
|
+
import { getCliVersion } from '../version.js';
|
|
24
|
+
import { parseSha256Asset, sha256File } from '../ssh-tunnel.js';
|
|
25
|
+
import { resolveHelperApp } from '../computer-rpc.js';
|
|
26
|
+
/** GitHub repo whose `v<version>` releases carry the helper asset. */
|
|
27
|
+
export const HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
|
|
28
|
+
/** The zipped `.app` release asset name. */
|
|
29
|
+
export const MAC_HELPER_ASSET = 'ComputerHelper.app.zip';
|
|
30
|
+
/** The bundle directory name once extracted. */
|
|
31
|
+
export const MAC_HELPER_APP_NAME = 'ComputerHelper.app';
|
|
32
|
+
/** Apple Developer ID Team the helper must be signed by (defense in depth on top
|
|
33
|
+
* of `spctl` notarization assessment). "Developer ID Application: Muqit Nawaz". */
|
|
34
|
+
export const EXPECTED_TEAM_ID = '2HTP252L87';
|
|
35
|
+
/** Cache dir for the downloaded helper, one subdir per release tag. */
|
|
36
|
+
export function macHelperCacheDir(version) {
|
|
37
|
+
return path.join(getCacheDir(), 'computer', 'mac-helper', `v${version}`);
|
|
38
|
+
}
|
|
39
|
+
/** Release-asset URLs for the helper zip + its checksum at one `v<version>` tag. */
|
|
40
|
+
export function macHelperAssetUrls(version) {
|
|
41
|
+
const base = `https://github.com/${HELPER_RELEASE_REPO}/releases/download/v${version}`;
|
|
42
|
+
return { zip: `${base}/${MAC_HELPER_ASSET}`, sha256: `${base}/${MAC_HELPER_ASSET}.sha256` };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Verify a helper `.app` bundle is intact, signed by the expected Developer ID
|
|
46
|
+
* Team, and notarized (Gatekeeper-accepted). Throws with an actionable message
|
|
47
|
+
* on any failure — a downloaded bundle is never trusted without this.
|
|
48
|
+
*/
|
|
49
|
+
export function verifyMacHelper(appPath) {
|
|
50
|
+
// 1. Structural + signature integrity.
|
|
51
|
+
try {
|
|
52
|
+
execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'pipe' });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
throw new Error(`code signature invalid for ${appPath}: ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
// 2. Team identity — must be our Developer ID, not some other valid signer.
|
|
58
|
+
let info = '';
|
|
59
|
+
try {
|
|
60
|
+
info = execFileSync('/usr/bin/codesign', ['-dv', '--verbose=4', appPath], { stdio: 'pipe' }).toString();
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
// codesign -dv writes to stderr; capture whatever it emitted.
|
|
64
|
+
info = (e.stderr?.toString() ?? '') + (e.stdout?.toString() ?? '');
|
|
65
|
+
if (!info)
|
|
66
|
+
throw new Error(`could not read code signature of ${appPath}: ${e.message}`);
|
|
67
|
+
}
|
|
68
|
+
const team = info.match(/TeamIdentifier=([A-Z0-9]+)/)?.[1];
|
|
69
|
+
if (team !== EXPECTED_TEAM_ID) {
|
|
70
|
+
throw new Error(`helper signed by unexpected Team (${team ?? 'none'}), expected ${EXPECTED_TEAM_ID}. Refusing to install.`);
|
|
71
|
+
}
|
|
72
|
+
// 3. Notarization / Gatekeeper — confirms Apple stapled a notarization ticket.
|
|
73
|
+
try {
|
|
74
|
+
execFileSync('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose', appPath], { stdio: 'pipe' });
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
throw new Error(`helper is not notarized / rejected by Gatekeeper: ${e.message}. Refusing to install.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Download the helper release asset for `version`, verify sha256, extract the
|
|
82
|
+
* `.app`, and verify its signature. Returns the path to the extracted
|
|
83
|
+
* `ComputerHelper.app`. A missing asset is a hard error naming the exact tag —
|
|
84
|
+
* never a silent fallback to another release.
|
|
85
|
+
*/
|
|
86
|
+
export async function downloadMacHelperApp(version) {
|
|
87
|
+
const dir = macHelperCacheDir(version);
|
|
88
|
+
const cachedApp = path.join(dir, MAC_HELPER_APP_NAME);
|
|
89
|
+
if (fs.existsSync(cachedApp)) {
|
|
90
|
+
// Re-verify a cached bundle cheaply; a tampered cache must not be trusted.
|
|
91
|
+
verifyMacHelper(cachedApp);
|
|
92
|
+
return cachedApp;
|
|
93
|
+
}
|
|
94
|
+
const tag = `v${version}`;
|
|
95
|
+
const { zip: zipUrl, sha256: shaUrl } = macHelperAssetUrls(version);
|
|
96
|
+
const missing = (status, url) => new Error(`no ${MAC_HELPER_ASSET} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
|
|
97
|
+
`The macOS helper ships as a GitHub release asset per tagged CLI version; ` +
|
|
98
|
+
`from a repo checkout you can build it locally instead: ` +
|
|
99
|
+
`bash native/computer-mac/scripts/build.sh release`);
|
|
100
|
+
// Checksum first: it is tiny and 404s fast when the tag has no assets.
|
|
101
|
+
const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
|
|
102
|
+
if (!shaRes.ok)
|
|
103
|
+
throw missing(shaRes.status, shaUrl);
|
|
104
|
+
const expected = parseSha256Asset(await shaRes.text());
|
|
105
|
+
console.error(`Downloading ${MAC_HELPER_ASSET} ${tag} from GitHub releases...`);
|
|
106
|
+
const zipRes = await fetch(zipUrl, { signal: AbortSignal.timeout(15 * 60_000) });
|
|
107
|
+
if (!zipRes.ok || !zipRes.body)
|
|
108
|
+
throw missing(zipRes.status, zipUrl);
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
110
|
+
const partial = path.join(dir, `${MAC_HELPER_ASSET}.download`);
|
|
111
|
+
try {
|
|
112
|
+
await pipeline(Readable.fromWeb(zipRes.body), fs.createWriteStream(partial));
|
|
113
|
+
const actual = await sha256File(partial);
|
|
114
|
+
if (actual !== expected) {
|
|
115
|
+
throw new Error(`sha256 mismatch for ${zipUrl}: expected ${expected}, got ${actual}`);
|
|
116
|
+
}
|
|
117
|
+
// Extract the zip (created with `ditto -c -k --keepParent`, so it contains
|
|
118
|
+
// ComputerHelper.app/ at top level) into the version cache dir.
|
|
119
|
+
fs.rmSync(cachedApp, { recursive: true, force: true });
|
|
120
|
+
execFileSync('/usr/bin/ditto', ['-x', '-k', partial, dir], { stdio: 'pipe' });
|
|
121
|
+
if (!fs.existsSync(cachedApp)) {
|
|
122
|
+
throw new Error(`extracted asset did not contain ${MAC_HELPER_APP_NAME}`);
|
|
123
|
+
}
|
|
124
|
+
verifyMacHelper(cachedApp);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
fs.rmSync(partial, { force: true });
|
|
128
|
+
}
|
|
129
|
+
return cachedApp;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the helper `.app` to install from: a local build / bundled copy first
|
|
133
|
+
* (repo checkout), else the checksum + signature-verified release-asset download
|
|
134
|
+
* for the running CLI version. Throws with the tag it checked when neither
|
|
135
|
+
* exists. macOS only.
|
|
136
|
+
*/
|
|
137
|
+
export async function ensureMacHelperApp(version = getCliVersion()) {
|
|
138
|
+
if (os.platform() !== 'darwin') {
|
|
139
|
+
throw new Error('The macOS computer helper is only available on macOS.');
|
|
140
|
+
}
|
|
141
|
+
const local = resolveHelperApp();
|
|
142
|
+
if (local)
|
|
143
|
+
return local;
|
|
144
|
+
return downloadMacHelperApp(version);
|
|
145
|
+
}
|
package/dist/lib/computer-rpc.js
CHANGED
|
@@ -163,14 +163,20 @@ export function writeComputerPeers(allowedExecPaths) {
|
|
|
163
163
|
}
|
|
164
164
|
fs.writeFileSync(resolvePeersPath(), JSON.stringify({ allow: allowedExecPaths }, null, 2), { mode: 0o600 });
|
|
165
165
|
}
|
|
166
|
-
// Resolve the helper executable inside the dist .app bundle. Used by the
|
|
167
|
-
//
|
|
166
|
+
// Resolve the helper executable inside the dist .app bundle. Used by the stdio
|
|
167
|
+
// fallback and by install-helper to find the source bundle. Only TRUSTED sources
|
|
168
|
+
// are listed here: a local checkout build and the bundled tarball copy. The
|
|
169
|
+
// download cache is deliberately NOT a candidate — a cached bundle is
|
|
170
|
+
// user-writable and must only ever be read through downloadMacHelperApp(), which
|
|
171
|
+
// re-verifies its signature + Team ID + notarization on every cache hit
|
|
172
|
+
// (lib/computer/download.ts). Listing the cache here would let a same-user
|
|
173
|
+
// process plant a differently-signed .app that install-helper trusts.
|
|
168
174
|
export function resolveHelperExec() {
|
|
169
175
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
170
176
|
const candidates = [
|
|
171
177
|
// Local build (running from the agents-cli checkout). apps/cli/dist/lib -> repo root (4 up) -> native/computer-mac.
|
|
172
178
|
path.resolve(here, '..', '..', '..', '..', 'native', 'computer-mac', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
|
|
173
|
-
// Bundled with the npm package
|
|
179
|
+
// Bundled with the npm package.
|
|
174
180
|
path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
|
|
175
181
|
];
|
|
176
182
|
for (const c of candidates) {
|
|
@@ -43,6 +43,21 @@ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[],
|
|
|
43
43
|
args: string[];
|
|
44
44
|
env: Record<string, string>;
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
|
|
48
|
+
* as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
|
|
49
|
+
* never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
|
|
50
|
+
* standalone binary `process.argv[1]` is the *virtual* embedded entry
|
|
51
|
+
* `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
|
|
52
|
+
* (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
|
|
53
|
+
* empty password. `getCliLaunch` resolves the physical executable so the shim
|
|
54
|
+
* works on both the standalone and JS/dev builds. Every argv element is
|
|
55
|
+
* shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildAskpassShimBody(launch?: {
|
|
58
|
+
command: string;
|
|
59
|
+
args: string[];
|
|
60
|
+
}): string;
|
|
46
61
|
/**
|
|
47
62
|
* Write (idempotently) the askpass shim — a tiny executable that re-invokes
|
|
48
63
|
* this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { getCliLaunch } from '../cli-entry.js';
|
|
19
20
|
import { encodePwshBase64 } from '../pwsh.js';
|
|
20
21
|
import { getCacheDir } from '../state.js';
|
|
21
22
|
import { hostKeyCheckingOpts } from './known-hosts.js';
|
|
@@ -92,6 +93,21 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
|
|
|
92
93
|
args.push(remote);
|
|
93
94
|
return { args, env };
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Build the askpass shim's `#!/bin/sh` body: a script that re-invokes this CLI
|
|
98
|
+
* as `agents ssh __askpass`. The relaunch argv comes from {@link getCliLaunch},
|
|
99
|
+
* never a hand-rolled `[process.execPath, process.argv[1], …]` — on a Bun
|
|
100
|
+
* standalone binary `process.argv[1]` is the *virtual* embedded entry
|
|
101
|
+
* `/$bunfs/root/agents`, which the CLI would then receive as a bogus subcommand
|
|
102
|
+
* (`unknown command '/$bunfs/root/agents'`), print nothing, and hand ssh an
|
|
103
|
+
* empty password. `getCliLaunch` resolves the physical executable so the shim
|
|
104
|
+
* works on both the standalone and JS/dev builds. Every argv element is
|
|
105
|
+
* shell-quoted. Pure (takes the launch as a parameter) so it is unit-testable.
|
|
106
|
+
*/
|
|
107
|
+
export function buildAskpassShimBody(launch = getCliLaunch(['ssh', '__askpass'])) {
|
|
108
|
+
const exec = [launch.command, ...launch.args].map(shellQuote).join(' ');
|
|
109
|
+
return `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${exec}\n`;
|
|
110
|
+
}
|
|
95
111
|
/**
|
|
96
112
|
* Write (idempotently) the askpass shim — a tiny executable that re-invokes
|
|
97
113
|
* this CLI as `agents ssh __askpass`. ssh execs `SSH_ASKPASS` with no usable
|
|
@@ -102,10 +118,6 @@ export function writeAskpassShim() {
|
|
|
102
118
|
const dir = path.join(getCacheDir(), 'devices');
|
|
103
119
|
fs.mkdirSync(dir, { recursive: true });
|
|
104
120
|
const shimPath = path.join(dir, 'askpass.sh');
|
|
105
|
-
|
|
106
|
-
const node = process.execPath;
|
|
107
|
-
const entry = process.argv[1] ?? '';
|
|
108
|
-
const body = `#!/bin/sh\n# Generated by agents-cli — bridges ssh SSH_ASKPASS back into the CLI.\nexec ${shellQuote(node)} ${shellQuote(entry)} ssh __askpass\n`;
|
|
109
|
-
fs.writeFileSync(shimPath, body, { mode: 0o700 });
|
|
121
|
+
fs.writeFileSync(shimPath, buildAskpassShimBody(), { mode: 0o700 });
|
|
110
122
|
return shimPath;
|
|
111
123
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type DeviceProfile } from './registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Decide whether an interactive login warrants a terminfo push. Pure so the
|
|
4
|
+
* gating logic is unit-testable without touching ssh or the filesystem.
|
|
5
|
+
*
|
|
6
|
+
* @param interactive true only for a real login (no remote command) on a human tty.
|
|
7
|
+
*/
|
|
8
|
+
export declare function shouldSyncTerminfo(params: {
|
|
9
|
+
term?: string;
|
|
10
|
+
shell: DeviceProfile['shell'];
|
|
11
|
+
interactive: boolean;
|
|
12
|
+
}): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Cache key for a device: the remote **user@host**, not just the host. terminfo
|
|
15
|
+
* is compiled into the *per-user* `~/.terminfo`, so `alice@box` and `bob@box`
|
|
16
|
+
* are distinct sync targets — keying on host alone would let the first user's
|
|
17
|
+
* stamp suppress the second user's (never-installed) sync. Mirrors
|
|
18
|
+
* {@link sshTargetFor}'s user handling; falls back to the device name when the
|
|
19
|
+
* address is unresolved.
|
|
20
|
+
*/
|
|
21
|
+
export declare function terminfoHostKey(device: Pick<DeviceProfile, 'user' | 'name'>, addr: string | undefined): string;
|
|
22
|
+
/** True when a fresh successful-sync stamp exists for this host+TERM. */
|
|
23
|
+
export declare function terminfoSynced(host: string, term: string, cacheRoot?: string): boolean;
|
|
24
|
+
/** Record a successful sync so repeat logins skip the push. Best-effort. */
|
|
25
|
+
export declare function markTerminfoSynced(host: string, term: string, cacheRoot?: string): void;
|
|
26
|
+
/** Export the local terminfo source for a TERM, or null if it can't be produced. */
|
|
27
|
+
export declare function localTerminfoSource(term: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Best-effort: ensure `device` has the terminfo entry for the local `$TERM`
|
|
30
|
+
* before an interactive login. Never throws; returns whether a push succeeded
|
|
31
|
+
* (false when skipped, already-cached, or failed). `sshArgs`/`sshEnv` are the
|
|
32
|
+
* SAME host-key + auth options the real login uses, minus the interactive tty —
|
|
33
|
+
* so a password-auth device resolves through the askpass shim without a second
|
|
34
|
+
* human prompt.
|
|
35
|
+
*/
|
|
36
|
+
export declare function syncTerminfoToDevice(opts: {
|
|
37
|
+
device: DeviceProfile;
|
|
38
|
+
host: string;
|
|
39
|
+
term: string | undefined;
|
|
40
|
+
/** ssh argv for a non-interactive `tic -x -` exec against this device. */
|
|
41
|
+
sshArgs: string[];
|
|
42
|
+
/** Env overlay for that ssh (askpass wiring for password devices). */
|
|
43
|
+
sshEnv: Record<string, string>;
|
|
44
|
+
}): boolean;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminfo propagation for `agents ssh` interactive logins.
|
|
3
|
+
*
|
|
4
|
+
* Modern terminals (Ghostty, kitty, Alacritty, WezTerm, foot, rio) advertise a
|
|
5
|
+
* custom `TERM` (e.g. `xterm-ghostty`) whose terminfo entry ships with the
|
|
6
|
+
* terminal, not with the remote host's ncurses. SSH into a box that lacks the
|
|
7
|
+
* entry and the session is subtly broken: wrong backspace, missing colors, a
|
|
8
|
+
* garbled clear/alt-screen. Ghostty's own shell integration fixes this for the
|
|
9
|
+
* bare `ssh` command, but `agents ssh` (Tailscale-relayed, spawned directly)
|
|
10
|
+
* bypasses that wrapper — so we handle it here.
|
|
11
|
+
*
|
|
12
|
+
* Strategy: on an interactive POSIX login, if the local `$TERM` is one the
|
|
13
|
+
* remote is unlikely to have, export it locally (`infocmp -x`) and compile it on
|
|
14
|
+
* the remote (`tic -x -`, writes to the user's `~/.terminfo`, no sudo). This is
|
|
15
|
+
* the canonical, terminal-agnostic technique.
|
|
16
|
+
*
|
|
17
|
+
* Two invariants keep it safe and cheap:
|
|
18
|
+
* - **Fail-safe.** Every failure is swallowed. A push that errors, times out,
|
|
19
|
+
* or hits a remote without `tic` leaves the user exactly where they are today
|
|
20
|
+
* — it never blocks or delays the actual login beyond the short push timeout.
|
|
21
|
+
* - **Cached.** A successful sync stamps a local marker keyed by host+TERM, so
|
|
22
|
+
* only the first login to each host pays the one extra round-trip; every
|
|
23
|
+
* repeat login is zero-cost.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'child_process';
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
import { getCacheDir } from '../state.js';
|
|
29
|
+
/**
|
|
30
|
+
* Terminfo names that ncurses ships essentially everywhere, so pushing them is
|
|
31
|
+
* wasted work. Anything NOT in this set (the exotic terminal entries) is a sync
|
|
32
|
+
* candidate. Kept deliberately conservative: when unsure, we push — `tic` is
|
|
33
|
+
* idempotent and the result is cached.
|
|
34
|
+
*/
|
|
35
|
+
const UNIVERSAL_TERMS = new Set([
|
|
36
|
+
'dumb',
|
|
37
|
+
'ansi',
|
|
38
|
+
'vt100',
|
|
39
|
+
'vt102',
|
|
40
|
+
'vt220',
|
|
41
|
+
'linux',
|
|
42
|
+
'cygwin',
|
|
43
|
+
'xterm',
|
|
44
|
+
'xterm-color',
|
|
45
|
+
'xterm-16color',
|
|
46
|
+
'xterm-256color',
|
|
47
|
+
'screen',
|
|
48
|
+
'screen-256color',
|
|
49
|
+
'tmux',
|
|
50
|
+
'tmux-256color',
|
|
51
|
+
'rxvt',
|
|
52
|
+
'rxvt-unicode',
|
|
53
|
+
'rxvt-unicode-256color',
|
|
54
|
+
]);
|
|
55
|
+
/** How long a successful sync suppresses re-syncing the same host+TERM. */
|
|
56
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
57
|
+
/** Cap on the push so a stalled remote can't delay the login indefinitely. */
|
|
58
|
+
const PUSH_TIMEOUT_MS = 8000;
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether an interactive login warrants a terminfo push. Pure so the
|
|
61
|
+
* gating logic is unit-testable without touching ssh or the filesystem.
|
|
62
|
+
*
|
|
63
|
+
* @param interactive true only for a real login (no remote command) on a human tty.
|
|
64
|
+
*/
|
|
65
|
+
export function shouldSyncTerminfo(params) {
|
|
66
|
+
if (!params.interactive)
|
|
67
|
+
return false;
|
|
68
|
+
if (params.shell === 'powershell')
|
|
69
|
+
return false; // Windows console ignores terminfo
|
|
70
|
+
const term = params.term?.trim();
|
|
71
|
+
if (!term)
|
|
72
|
+
return false;
|
|
73
|
+
if (UNIVERSAL_TERMS.has(term))
|
|
74
|
+
return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Cache key for a device: the remote **user@host**, not just the host. terminfo
|
|
79
|
+
* is compiled into the *per-user* `~/.terminfo`, so `alice@box` and `bob@box`
|
|
80
|
+
* are distinct sync targets — keying on host alone would let the first user's
|
|
81
|
+
* stamp suppress the second user's (never-installed) sync. Mirrors
|
|
82
|
+
* {@link sshTargetFor}'s user handling; falls back to the device name when the
|
|
83
|
+
* address is unresolved.
|
|
84
|
+
*/
|
|
85
|
+
export function terminfoHostKey(device, addr) {
|
|
86
|
+
const host = addr ?? device.name;
|
|
87
|
+
return device.user ? `${device.user}@${host}` : host;
|
|
88
|
+
}
|
|
89
|
+
/** Directory holding per-host+TERM sync stamps. `cacheRoot` override is for tests. */
|
|
90
|
+
function stampDir(cacheRoot) {
|
|
91
|
+
return path.join(cacheRoot ?? getCacheDir(), 'devices', 'terminfo');
|
|
92
|
+
}
|
|
93
|
+
/** Filesystem-safe stamp name for a host+TERM pair. */
|
|
94
|
+
function stampFile(host, term, cacheRoot) {
|
|
95
|
+
const safe = `${host}__${term}`.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
96
|
+
return path.join(stampDir(cacheRoot), safe);
|
|
97
|
+
}
|
|
98
|
+
/** True when a fresh successful-sync stamp exists for this host+TERM. */
|
|
99
|
+
export function terminfoSynced(host, term, cacheRoot) {
|
|
100
|
+
try {
|
|
101
|
+
const st = fs.statSync(stampFile(host, term, cacheRoot));
|
|
102
|
+
return Date.now() - st.mtimeMs < CACHE_TTL_MS;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Record a successful sync so repeat logins skip the push. Best-effort. */
|
|
109
|
+
export function markTerminfoSynced(host, term, cacheRoot) {
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(stampDir(cacheRoot), { recursive: true });
|
|
112
|
+
fs.writeFileSync(stampFile(host, term, cacheRoot), `${new Date().toISOString()}\n`);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* a cache write failure just means we re-sync next time — never fatal */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Export the local terminfo source for a TERM, or null if it can't be produced. */
|
|
119
|
+
export function localTerminfoSource(term) {
|
|
120
|
+
try {
|
|
121
|
+
const res = spawnSync('infocmp', ['-x', term], {
|
|
122
|
+
encoding: 'utf8',
|
|
123
|
+
timeout: 4000,
|
|
124
|
+
});
|
|
125
|
+
if (res.status === 0 && res.stdout && res.stdout.trim().length > 0) {
|
|
126
|
+
return res.stdout;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* infocmp missing or errored — nothing we can push */
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Best-effort: ensure `device` has the terminfo entry for the local `$TERM`
|
|
136
|
+
* before an interactive login. Never throws; returns whether a push succeeded
|
|
137
|
+
* (false when skipped, already-cached, or failed). `sshArgs`/`sshEnv` are the
|
|
138
|
+
* SAME host-key + auth options the real login uses, minus the interactive tty —
|
|
139
|
+
* so a password-auth device resolves through the askpass shim without a second
|
|
140
|
+
* human prompt.
|
|
141
|
+
*/
|
|
142
|
+
export function syncTerminfoToDevice(opts) {
|
|
143
|
+
const term = opts.term?.trim();
|
|
144
|
+
if (!term)
|
|
145
|
+
return false;
|
|
146
|
+
if (terminfoSynced(opts.host, term))
|
|
147
|
+
return false;
|
|
148
|
+
const source = localTerminfoSource(term);
|
|
149
|
+
if (!source)
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
const res = spawnSync('ssh', opts.sshArgs, {
|
|
153
|
+
input: source,
|
|
154
|
+
env: { ...process.env, ...opts.sshEnv },
|
|
155
|
+
timeout: PUSH_TIMEOUT_MS,
|
|
156
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
157
|
+
});
|
|
158
|
+
if (res.status === 0) {
|
|
159
|
+
markTerminfoSynced(opts.host, term);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* connection failed / timed out — fail-safe, login proceeds unaffected */
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
Binary file
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { type UsageSnapshot } from './usage.js';
|
|
|
11
11
|
export interface RotateCandidate {
|
|
12
12
|
agent: AgentId;
|
|
13
13
|
version: string;
|
|
14
|
+
accountKey: string | null;
|
|
15
|
+
accountLabel: string;
|
|
14
16
|
email: string | null;
|
|
15
17
|
/**
|
|
16
18
|
* Per-org usage/quota key (e.g. `claude:org=<orgUuid>`) — the unit rate
|
|
@@ -21,7 +23,9 @@ export interface RotateCandidate {
|
|
|
21
23
|
usageKey: string | null;
|
|
22
24
|
usageStatus: AccountInfo['usageStatus'];
|
|
23
25
|
usageSnapshot: UsageSnapshot | null;
|
|
24
|
-
|
|
26
|
+
usageError: string | null;
|
|
27
|
+
plan: string | null;
|
|
28
|
+
signedIn: boolean;
|
|
25
29
|
lastActive: Date | null;
|
|
26
30
|
}
|
|
27
31
|
export interface RotateResult {
|
|
@@ -58,7 +62,7 @@ export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: str
|
|
|
58
62
|
export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
|
|
59
63
|
/**
|
|
60
64
|
* Whether a specific account can serve a run right now, and — when it can't —
|
|
61
|
-
* why. `signed_out` covers
|
|
65
|
+
* why. `signed_out` covers a missing usable credential; `rate_limited` and
|
|
62
66
|
* `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
|
|
63
67
|
* teammate whose account rotation won't route around (a pin IS the target).
|
|
64
68
|
*/
|
|
@@ -71,7 +75,7 @@ export type AccountReadiness = {
|
|
|
71
75
|
};
|
|
72
76
|
/**
|
|
73
77
|
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
74
|
-
* +
|
|
78
|
+
* + canonical signed-in state, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
75
79
|
* disagree with what rotation would actually do. The `reason` combines the two
|
|
76
80
|
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
77
81
|
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
@@ -100,17 +104,16 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
|
|
|
100
104
|
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
101
105
|
* callers naturally fan out via the random roll.
|
|
102
106
|
*
|
|
103
|
-
* Eligibility: signed in
|
|
107
|
+
* Eligibility: signed in according to AccountInfo and not currently
|
|
104
108
|
* rate-limited — no blocking window (session OR weekly) at 100%, matching the
|
|
105
109
|
* `agents view` badge; or the local cached status is usable when no live
|
|
106
110
|
* snapshot exists. Note the split: eligibility considers the session window
|
|
107
111
|
* (a session-maxed account can't run now), but the capacity *weight* above is
|
|
108
112
|
* driven by weekly headroom so a brief session spike doesn't distort routing.
|
|
109
113
|
*
|
|
110
|
-
* Dedupe: when multiple versions share
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* account and both 429ing.
|
|
114
|
+
* Dedupe: when multiple versions share a usage/account identity, collapse to
|
|
115
|
+
* one candidate (the least-recently-active version). The org-scoped usage key
|
|
116
|
+
* wins over email so same-email personal and Team accounts remain distinct.
|
|
114
117
|
*
|
|
115
118
|
* Returns null if no candidate is eligible — callers fall back to the pinned
|
|
116
119
|
* version so behavior stays predictable.
|
|
@@ -122,6 +125,7 @@ export declare function pickBalancedCandidate(candidates: RotateCandidate[]): Ro
|
|
|
122
125
|
* usage headroom.
|
|
123
126
|
*/
|
|
124
127
|
export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
|
|
128
|
+
export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
|
|
125
129
|
/**
|
|
126
130
|
* Pick a healthy version for `agent` using weighted random by remaining
|
|
127
131
|
* capacity. See `pickBalancedCandidate` for algorithm details.
|
package/dist/lib/rotate.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { getAccountInfo } from './agents.js';
|
|
9
|
+
import { accountDisplayLabel, getAccountInfo } from './agents.js';
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
@@ -65,14 +65,10 @@ export function setGlobalRunStrategy(agent, strategy) {
|
|
|
65
65
|
writeMeta(meta);
|
|
66
66
|
}
|
|
67
67
|
function isRotationEligible(candidate) {
|
|
68
|
-
return
|
|
69
|
-
&& candidate.authValid
|
|
70
|
-
&& hasUsageAvailable(candidate);
|
|
68
|
+
return candidate.signedIn && hasUsageAvailable(candidate);
|
|
71
69
|
}
|
|
72
70
|
function isAvailableEligible(candidate) {
|
|
73
|
-
return
|
|
74
|
-
&& candidate.authValid
|
|
75
|
-
&& hasUsageAvailable(candidate);
|
|
71
|
+
return isRotationEligible(candidate);
|
|
76
72
|
}
|
|
77
73
|
function hasUsageAvailable(candidate) {
|
|
78
74
|
const snapshot = candidate.usageSnapshot;
|
|
@@ -95,7 +91,7 @@ function hasUsageAvailable(candidate) {
|
|
|
95
91
|
}
|
|
96
92
|
/**
|
|
97
93
|
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
98
|
-
* +
|
|
94
|
+
* + canonical signed-in state, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
99
95
|
* disagree with what rotation would actually do. The `reason` combines the two
|
|
100
96
|
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
101
97
|
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
@@ -104,7 +100,7 @@ function hasUsageAvailable(candidate) {
|
|
|
104
100
|
* reported while the account is actually serving requests.
|
|
105
101
|
*/
|
|
106
102
|
export function readinessFromCandidate(candidate) {
|
|
107
|
-
if (!candidate.
|
|
103
|
+
if (!candidate.signedIn) {
|
|
108
104
|
return { ready: false, reason: 'signed_out', email: candidate.email };
|
|
109
105
|
}
|
|
110
106
|
if (hasUsageAvailable(candidate)) {
|
|
@@ -162,7 +158,7 @@ function compareCandidates(a, b) {
|
|
|
162
158
|
* key; fall back to email only when no usage identity is available.
|
|
163
159
|
*/
|
|
164
160
|
function candidateIdentity(c) {
|
|
165
|
-
return c.usageKey ?? c.email
|
|
161
|
+
return c.usageKey ?? c.accountKey ?? c.email ?? `${c.agent}@${c.version}`;
|
|
166
162
|
}
|
|
167
163
|
function dedupeAndSortCandidates(candidates) {
|
|
168
164
|
const byIdentity = new Map();
|
|
@@ -189,17 +185,16 @@ function dedupeAndSortCandidates(candidates) {
|
|
|
189
185
|
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
190
186
|
* callers naturally fan out via the random roll.
|
|
191
187
|
*
|
|
192
|
-
* Eligibility: signed in
|
|
188
|
+
* Eligibility: signed in according to AccountInfo and not currently
|
|
193
189
|
* rate-limited — no blocking window (session OR weekly) at 100%, matching the
|
|
194
190
|
* `agents view` badge; or the local cached status is usable when no live
|
|
195
191
|
* snapshot exists. Note the split: eligibility considers the session window
|
|
196
192
|
* (a session-maxed account can't run now), but the capacity *weight* above is
|
|
197
193
|
* driven by weekly headroom so a brief session spike doesn't distort routing.
|
|
198
194
|
*
|
|
199
|
-
* Dedupe: when multiple versions share
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* account and both 429ing.
|
|
195
|
+
* Dedupe: when multiple versions share a usage/account identity, collapse to
|
|
196
|
+
* one candidate (the least-recently-active version). The org-scoped usage key
|
|
197
|
+
* wins over email so same-email personal and Team accounts remain distinct.
|
|
203
198
|
*
|
|
204
199
|
* Returns null if no candidate is eligible — callers fall back to the pinned
|
|
205
200
|
* version so behavior stays predictable.
|
|
@@ -278,12 +273,11 @@ export function pickAvailableCandidate(candidates, preferredVersion) {
|
|
|
278
273
|
: undefined;
|
|
279
274
|
return { picked: preferred ?? sorted[0], healthy: sorted, excluded };
|
|
280
275
|
}
|
|
281
|
-
async function collectRunCandidates(agent) {
|
|
276
|
+
export async function collectRunCandidates(agent) {
|
|
282
277
|
const versions = listInstalledVersions(agent);
|
|
283
278
|
const rows = await Promise.all(versions.map(async (version) => {
|
|
284
279
|
const home = getVersionHomePath(agent, version);
|
|
285
280
|
const info = await getAccountInfo(agent, home);
|
|
286
|
-
// `info.email` (from .claude.json's oauthAccount) is the auth heuristic.
|
|
287
281
|
// We used to additionally call isClaudeAuthValid(home), which reads
|
|
288
282
|
// "Claude Code-credentials-<hash>" from the system keychain. That item is
|
|
289
283
|
// written by Claude Code itself with its own process in the ACL, so our
|
|
@@ -291,15 +285,17 @@ async function collectRunCandidates(agent) {
|
|
|
291
285
|
// one per installed version, every time `agents run` cold-starts. If
|
|
292
286
|
// claude's stored token has actually expired, the spawned agent detects
|
|
293
287
|
// it at its own startup and re-auths; that's the correct UX.
|
|
294
|
-
const authValid = info.email != null;
|
|
295
288
|
return {
|
|
296
289
|
agent,
|
|
297
290
|
version,
|
|
298
291
|
home,
|
|
299
292
|
info,
|
|
293
|
+
accountKey: info.accountKey,
|
|
294
|
+
accountLabel: accountDisplayLabel(info),
|
|
300
295
|
email: info.email,
|
|
301
296
|
usageStatus: info.usageStatus,
|
|
302
|
-
|
|
297
|
+
plan: info.plan,
|
|
298
|
+
signedIn: info.signedIn,
|
|
303
299
|
lastActive: info.lastActive,
|
|
304
300
|
};
|
|
305
301
|
}));
|
|
@@ -311,10 +307,13 @@ async function collectRunCandidates(agent) {
|
|
|
311
307
|
})));
|
|
312
308
|
return rows.map(({ home: _home, info, ...candidate }) => {
|
|
313
309
|
const usageKey = getUsageLookupKey(info);
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
310
|
+
const usage = usageKey ? usageByKey.get(usageKey) : undefined;
|
|
311
|
+
return {
|
|
312
|
+
...candidate,
|
|
313
|
+
usageKey,
|
|
314
|
+
usageSnapshot: usage?.snapshot ?? null,
|
|
315
|
+
usageError: usage?.error ?? null,
|
|
316
|
+
};
|
|
318
317
|
});
|
|
319
318
|
}
|
|
320
319
|
/**
|