@phnx-labs/agents-cli 1.20.28 → 1.20.30
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/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/exec.js +22 -10
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/secrets.js +93 -6
- package/dist/commands/sessions.js +157 -44
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.js +70 -14
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +54 -5
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/active.js +79 -18
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +12 -0
- package/dist/lib/session/db.js +66 -9
- package/dist/lib/session/discover.d.ts +7 -0
- package/dist/lib/session/discover.js +309 -0
- package/dist/lib/session/parse.d.ts +22 -0
- package/dist/lib/session/parse.js +132 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +10 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Operating-system family of a device, used to pick the remote shell. */
|
|
2
|
+
export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
|
|
3
|
+
/** Remote shell dialect derived from the platform. */
|
|
4
|
+
export type DeviceShell = 'powershell' | 'posix';
|
|
5
|
+
/** How `agents ssh` authenticates to a device. Both are first-class, fully
|
|
6
|
+
* non-interactive: `key` uses the ssh agent / on-disk keys, `password` pulls
|
|
7
|
+
* the secret from a Keychain-backed secrets bundle via an askpass shim. */
|
|
8
|
+
export type DeviceAuthMethod = 'key' | 'password';
|
|
9
|
+
/** How to reach a device on the network. */
|
|
10
|
+
export interface DeviceAddress {
|
|
11
|
+
/** Where the address came from: a Tailscale node, or a manual entry. */
|
|
12
|
+
via: 'tailscale' | 'manual';
|
|
13
|
+
/** Fully-qualified DNS name (Tailscale MagicDNS), without a trailing dot. */
|
|
14
|
+
dnsName?: string;
|
|
15
|
+
/** Raw IP address (IPv4 preferred). */
|
|
16
|
+
ip?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Authentication settings for a device. */
|
|
19
|
+
export interface DeviceAuth {
|
|
20
|
+
method: DeviceAuthMethod;
|
|
21
|
+
/** Secrets bundle holding the password (when method === 'password'). */
|
|
22
|
+
bundle?: string;
|
|
23
|
+
/** Key within the bundle whose value is the password. Defaults to 'password'. */
|
|
24
|
+
bundleKey?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Last-known Tailscale reachability snapshot for a device. */
|
|
27
|
+
export interface DeviceTailscale {
|
|
28
|
+
online: boolean;
|
|
29
|
+
/** True when the last handshake was a direct (non-relayed) connection. */
|
|
30
|
+
direct: boolean;
|
|
31
|
+
/** DERP relay region code (e.g. 'sfo'); empty when direct. */
|
|
32
|
+
relay?: string;
|
|
33
|
+
lastSeen?: string;
|
|
34
|
+
}
|
|
35
|
+
/** A single registered device. */
|
|
36
|
+
export interface DeviceProfile {
|
|
37
|
+
name: string;
|
|
38
|
+
platform: DevicePlatform;
|
|
39
|
+
shell: DeviceShell;
|
|
40
|
+
user?: string;
|
|
41
|
+
address: DeviceAddress;
|
|
42
|
+
auth: DeviceAuth;
|
|
43
|
+
tailscale?: DeviceTailscale;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
}
|
|
47
|
+
/** Map of device name to profile. */
|
|
48
|
+
export type DeviceRegistry = Record<string, DeviceProfile>;
|
|
49
|
+
/** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
|
|
50
|
+
export declare function assertValidDeviceName(name: string): void;
|
|
51
|
+
/** Map a Tailscale `OS` field to our platform enum. */
|
|
52
|
+
export declare function platformFromOs(os: string | undefined): DevicePlatform;
|
|
53
|
+
/** The remote shell a platform speaks. */
|
|
54
|
+
export declare function shellForPlatform(platform: DevicePlatform): DeviceShell;
|
|
55
|
+
/**
|
|
56
|
+
* Load all devices from the registry file. Returns an empty object only when
|
|
57
|
+
* the file does not exist. A malformed file is a hard error — silently
|
|
58
|
+
* returning {} would let the next write wipe the user's device list.
|
|
59
|
+
*/
|
|
60
|
+
export declare function loadDevices(): Promise<DeviceRegistry>;
|
|
61
|
+
/** Get a single device profile, or null if it is not registered. */
|
|
62
|
+
export declare function getDevice(name: string): Promise<DeviceProfile | null>;
|
|
63
|
+
/** Fields a caller may supply when creating or updating a device. */
|
|
64
|
+
export interface DeviceInput {
|
|
65
|
+
platform?: DevicePlatform;
|
|
66
|
+
user?: string;
|
|
67
|
+
address?: DeviceAddress;
|
|
68
|
+
auth?: DeviceAuth;
|
|
69
|
+
tailscale?: DeviceTailscale;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create the device if absent, otherwise merge the supplied fields into the
|
|
73
|
+
* existing profile. `shell` is always re-derived from the (possibly new)
|
|
74
|
+
* platform so the two can never drift. Returns the resulting profile.
|
|
75
|
+
*/
|
|
76
|
+
export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
|
|
77
|
+
/** Remove a device. Returns false if it was not registered. */
|
|
78
|
+
export declare function removeDevice(name: string): Promise<boolean>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device registry.
|
|
3
|
+
*
|
|
4
|
+
* Manages the persistent registry of SSH device profiles stored at
|
|
5
|
+
* ~/.agents/.history/devices/registry.json. Each profile records what we had
|
|
6
|
+
* to re-derive by hand the first time we reached a host: its platform (so we
|
|
7
|
+
* know PowerShell vs POSIX), the login user, how to address it (Tailscale
|
|
8
|
+
* DNS name / IP), and how to authenticate (pubkey, or a password pulled from
|
|
9
|
+
* a secrets bundle).
|
|
10
|
+
*
|
|
11
|
+
* Like the team registry this is per-machine runtime state (it embeds a host
|
|
12
|
+
* list + addresses) and lives under .history/ so it is NOT pulled in by
|
|
13
|
+
* `agents repo push`. The load/save/lock plumbing is a deliberate clone of
|
|
14
|
+
* src/lib/teams/registry.ts so the data-loss guarantees match exactly.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs/promises';
|
|
17
|
+
import * as fsSync from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { randomBytes } from 'crypto';
|
|
20
|
+
import lockfile from 'proper-lockfile';
|
|
21
|
+
import { getDevicesRegistryPath } from '../state.js';
|
|
22
|
+
function registryPath() {
|
|
23
|
+
return getDevicesRegistryPath();
|
|
24
|
+
}
|
|
25
|
+
/** Valid logical device name: the ssh-alias charset, so it renders into an
|
|
26
|
+
* unambiguous `Host` stanza and is safe as an ssh target. */
|
|
27
|
+
const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
28
|
+
/** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
|
|
29
|
+
export function assertValidDeviceName(name) {
|
|
30
|
+
if (!DEVICE_NAME_RE.test(name)) {
|
|
31
|
+
throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Map a Tailscale `OS` field to our platform enum. */
|
|
35
|
+
export function platformFromOs(os) {
|
|
36
|
+
switch ((os ?? '').toLowerCase()) {
|
|
37
|
+
case 'windows':
|
|
38
|
+
return 'windows';
|
|
39
|
+
case 'linux':
|
|
40
|
+
return 'linux';
|
|
41
|
+
case 'macos':
|
|
42
|
+
case 'darwin':
|
|
43
|
+
return 'macos';
|
|
44
|
+
default:
|
|
45
|
+
return 'unknown';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The remote shell a platform speaks. */
|
|
49
|
+
export function shellForPlatform(platform) {
|
|
50
|
+
return platform === 'windows' ? 'powershell' : 'posix';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Atomic JSON write: write to a unique sibling tmp file then rename over the
|
|
54
|
+
* target. rename(2) is atomic on POSIX, so a crashed write leaves the old file
|
|
55
|
+
* untouched instead of producing a half-written registry that loadDevices()
|
|
56
|
+
* would reject.
|
|
57
|
+
*/
|
|
58
|
+
async function atomicWriteJson(p, data) {
|
|
59
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
60
|
+
const tmp = `${p}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
61
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
|
62
|
+
try {
|
|
63
|
+
await fs.rename(tmp, p);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
await fs.unlink(tmp).catch(() => { });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Run `fn` while holding an exclusive cross-process lock on the registry file.
|
|
72
|
+
* proper-lockfile requires the target to exist, so we touch it first. Stale
|
|
73
|
+
* locks (from crashed callers) auto-expire after `stale` ms.
|
|
74
|
+
*/
|
|
75
|
+
async function withRegistryLock(p, fn) {
|
|
76
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
77
|
+
if (!fsSync.existsSync(p)) {
|
|
78
|
+
try {
|
|
79
|
+
await fs.writeFile(p, '{}', { flag: 'wx' });
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (err && err.code !== 'EEXIST')
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const release = await lockfile.lock(p, {
|
|
87
|
+
retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
|
|
88
|
+
stale: 10_000,
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
return await fn();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Load all devices from the registry file. Returns an empty object only when
|
|
99
|
+
* the file does not exist. A malformed file is a hard error — silently
|
|
100
|
+
* returning {} would let the next write wipe the user's device list.
|
|
101
|
+
*/
|
|
102
|
+
export async function loadDevices() {
|
|
103
|
+
const p = registryPath();
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err && err.code === 'ENOENT')
|
|
110
|
+
return {};
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function saveDevices(reg) {
|
|
121
|
+
await atomicWriteJson(registryPath(), reg);
|
|
122
|
+
}
|
|
123
|
+
/** Get a single device profile, or null if it is not registered. */
|
|
124
|
+
export async function getDevice(name) {
|
|
125
|
+
const reg = await loadDevices();
|
|
126
|
+
return reg[name] ?? null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Create the device if absent, otherwise merge the supplied fields into the
|
|
130
|
+
* existing profile. `shell` is always re-derived from the (possibly new)
|
|
131
|
+
* platform so the two can never drift. Returns the resulting profile.
|
|
132
|
+
*/
|
|
133
|
+
export async function upsertDevice(name, input) {
|
|
134
|
+
assertValidDeviceName(name);
|
|
135
|
+
const p = registryPath();
|
|
136
|
+
return withRegistryLock(p, async () => {
|
|
137
|
+
const reg = await loadDevices();
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
const prev = reg[name];
|
|
140
|
+
const platform = input.platform ?? prev?.platform ?? 'unknown';
|
|
141
|
+
const merged = {
|
|
142
|
+
name,
|
|
143
|
+
platform,
|
|
144
|
+
shell: shellForPlatform(platform),
|
|
145
|
+
user: input.user ?? prev?.user,
|
|
146
|
+
address: input.address ?? prev?.address ?? { via: 'manual' },
|
|
147
|
+
auth: input.auth ?? prev?.auth ?? { method: 'key' },
|
|
148
|
+
tailscale: input.tailscale ?? prev?.tailscale,
|
|
149
|
+
createdAt: prev?.createdAt ?? now,
|
|
150
|
+
updatedAt: now,
|
|
151
|
+
};
|
|
152
|
+
reg[name] = merged;
|
|
153
|
+
await saveDevices(reg);
|
|
154
|
+
return merged;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Remove a device. Returns false if it was not registered. */
|
|
158
|
+
export async function removeDevice(name) {
|
|
159
|
+
const p = registryPath();
|
|
160
|
+
return withRegistryLock(p, async () => {
|
|
161
|
+
const reg = await loadDevices();
|
|
162
|
+
if (!reg[name])
|
|
163
|
+
return false;
|
|
164
|
+
delete reg[name];
|
|
165
|
+
await saveDevices(reg);
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render the device registry into an OpenSSH `ssh_config` include block.
|
|
3
|
+
*
|
|
4
|
+
* Writing a managed include (e.g. `~/.ssh/config.d/agents`) makes every tool
|
|
5
|
+
* that speaks ssh — plain `ssh`/`scp`/`rsync`/`git`, and `agents sessions
|
|
6
|
+
* --host` — resolve the registry's logical device names transparently, without
|
|
7
|
+
* each of them learning about the registry. `agents ssh` stays the value-add
|
|
8
|
+
* layer (preflight, password-from-bundle auth, platform-aware exec) on top.
|
|
9
|
+
*
|
|
10
|
+
* `renderSshConfig` is a pure function (registry in, config text out) so the
|
|
11
|
+
* exact rendering is unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
import { type DeviceProfile, type DeviceRegistry } from './registry.js';
|
|
14
|
+
/** The HostName an ssh client should dial for a device: DNS name first, then IP. */
|
|
15
|
+
export declare function hostNameFor(device: DeviceProfile): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Render the whole registry into ssh_config text. Devices are emitted in
|
|
18
|
+
* stable alphabetical order (so the file does not churn between runs) and
|
|
19
|
+
* addressless devices are skipped.
|
|
20
|
+
*/
|
|
21
|
+
export declare function renderSshConfig(reg: DeviceRegistry): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const HEADER = [
|
|
2
|
+
'# Managed by `agents devices` — do not edit by hand.',
|
|
3
|
+
'# Regenerate with: agents devices render',
|
|
4
|
+
'# Include from ~/.ssh/config with: Include config.d/agents',
|
|
5
|
+
].join('\n');
|
|
6
|
+
/** The HostName an ssh client should dial for a device: DNS name first, then IP. */
|
|
7
|
+
export function hostNameFor(device) {
|
|
8
|
+
return device.address.dnsName ?? device.address.ip;
|
|
9
|
+
}
|
|
10
|
+
/** Render a single device into an ssh_config `Host` stanza, or null if it has no address. */
|
|
11
|
+
function renderHost(device) {
|
|
12
|
+
const hostName = hostNameFor(device);
|
|
13
|
+
if (!hostName)
|
|
14
|
+
return null;
|
|
15
|
+
const lines = [`Host ${device.name}`, ` HostName ${hostName}`];
|
|
16
|
+
if (device.user)
|
|
17
|
+
lines.push(` User ${device.user}`);
|
|
18
|
+
return lines.join('\n');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render the whole registry into ssh_config text. Devices are emitted in
|
|
22
|
+
* stable alphabetical order (so the file does not churn between runs) and
|
|
23
|
+
* addressless devices are skipped.
|
|
24
|
+
*/
|
|
25
|
+
export function renderSshConfig(reg) {
|
|
26
|
+
const stanzas = [];
|
|
27
|
+
for (const name of Object.keys(reg).sort()) {
|
|
28
|
+
const stanza = renderHost(reg[name]);
|
|
29
|
+
if (stanza)
|
|
30
|
+
stanzas.push(stanza);
|
|
31
|
+
}
|
|
32
|
+
return [HEADER, '', ...stanzas, ''].join('\n');
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type DeviceInput, type DevicePlatform } from './registry.js';
|
|
2
|
+
/** A single node distilled from `tailscale status --json`. */
|
|
3
|
+
export interface TailscaleNode {
|
|
4
|
+
name: string;
|
|
5
|
+
platform: DevicePlatform;
|
|
6
|
+
dnsName?: string;
|
|
7
|
+
ip?: string;
|
|
8
|
+
online: boolean;
|
|
9
|
+
direct: boolean;
|
|
10
|
+
relay?: string;
|
|
11
|
+
lastSeen?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
|
|
15
|
+
* charset). Used only as a fallback — when a node has a DNSName we prefer its
|
|
16
|
+
* first label, which is the canonical slug Tailscale itself derived.
|
|
17
|
+
*/
|
|
18
|
+
export declare function slugifyHostName(hostName: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Parse `tailscale status --json` output into one node per tailnet device,
|
|
21
|
+
* including Self. Throws on malformed JSON. Nodes without a HostName are
|
|
22
|
+
* skipped (they cannot be addressed by a logical name).
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseTailscaleStatus(json: string): TailscaleNode[];
|
|
25
|
+
/** Turn a parsed Tailscale node into the registry fields it can populate. */
|
|
26
|
+
export declare function nodeToDeviceInput(node: TailscaleNode): DeviceInput;
|
|
27
|
+
/**
|
|
28
|
+
* Run `tailscale status --json` and return its raw stdout. Throws a clear
|
|
29
|
+
* error when the binary is missing or the daemon is not reachable.
|
|
30
|
+
*/
|
|
31
|
+
export declare function tailscaleStatusJson(): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailscale ingestion for the device registry.
|
|
3
|
+
*
|
|
4
|
+
* `tailscale status --json` already hands us most of a device registry for
|
|
5
|
+
* free — per node: `OS` (→ platform), `Online`/`LastSeen` (→ reachability),
|
|
6
|
+
* `Relay` vs `CurAddr` (→ direct-vs-relayed latency hint), `DNSName`, and
|
|
7
|
+
* `TailscaleIPs`. `parseTailscaleStatus` turns that JSON into draft device
|
|
8
|
+
* profiles so `agents devices sync` can self-populate instead of you
|
|
9
|
+
* hand-entering hosts. Kept a pure function (JSON in, profiles out) so it is
|
|
10
|
+
* unit-testable without a live tailnet.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import { platformFromOs, } from './registry.js';
|
|
14
|
+
/** Strip MagicDNS's trailing dot so the name is usable as an ssh HostName. */
|
|
15
|
+
function trimDnsDot(dns) {
|
|
16
|
+
if (!dns)
|
|
17
|
+
return undefined;
|
|
18
|
+
return dns.endsWith('.') ? dns.slice(0, -1) : dns;
|
|
19
|
+
}
|
|
20
|
+
/** First IPv4 in the node's address list (preferred over IPv6 for ssh). */
|
|
21
|
+
function firstIpv4(ips) {
|
|
22
|
+
if (!ips)
|
|
23
|
+
return undefined;
|
|
24
|
+
return ips.find((ip) => /^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) ?? ips[0];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
|
|
28
|
+
* charset). Used only as a fallback — when a node has a DNSName we prefer its
|
|
29
|
+
* first label, which is the canonical slug Tailscale itself derived.
|
|
30
|
+
*/
|
|
31
|
+
export function slugifyHostName(hostName) {
|
|
32
|
+
return hostName
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/['’"]/g, '') // drop quotes/apostrophes (so "Bisma's" → "bismas", matching MagicDNS)
|
|
35
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
36
|
+
.replace(/^-+|-+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The logical name for a node. macOS computer names contain spaces and
|
|
40
|
+
* apostrophes ("Bisma's MacBook Pro") and iOS devices all report HostName
|
|
41
|
+
* "localhost" — both break as ssh aliases and the latter collides in the
|
|
42
|
+
* registry. The MagicDNS label (first segment of DNSName) is already a unique,
|
|
43
|
+
* valid slug per device, so prefer it; fall back to a slugified HostName.
|
|
44
|
+
*/
|
|
45
|
+
function deviceNameFor(raw, dnsName) {
|
|
46
|
+
const label = dnsName?.split('.')[0];
|
|
47
|
+
if (label && label.length > 0)
|
|
48
|
+
return label;
|
|
49
|
+
const host = raw.HostName?.trim();
|
|
50
|
+
if (!host)
|
|
51
|
+
return null;
|
|
52
|
+
const slug = slugifyHostName(host);
|
|
53
|
+
return slug.length > 0 ? slug : null;
|
|
54
|
+
}
|
|
55
|
+
function toNode(raw) {
|
|
56
|
+
const dnsName = trimDnsDot(raw.DNSName);
|
|
57
|
+
const name = deviceNameFor(raw, dnsName);
|
|
58
|
+
if (!name)
|
|
59
|
+
return null;
|
|
60
|
+
// A non-empty CurAddr means the last handshake was a direct connection;
|
|
61
|
+
// an empty CurAddr with a Relay means traffic is going through DERP.
|
|
62
|
+
const direct = Boolean(raw.CurAddr && raw.CurAddr.length > 0);
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
platform: platformFromOs(raw.OS),
|
|
66
|
+
dnsName,
|
|
67
|
+
ip: firstIpv4(raw.TailscaleIPs),
|
|
68
|
+
online: Boolean(raw.Online),
|
|
69
|
+
direct,
|
|
70
|
+
relay: raw.Relay || undefined,
|
|
71
|
+
lastSeen: raw.LastSeen,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse `tailscale status --json` output into one node per tailnet device,
|
|
76
|
+
* including Self. Throws on malformed JSON. Nodes without a HostName are
|
|
77
|
+
* skipped (they cannot be addressed by a logical name).
|
|
78
|
+
*/
|
|
79
|
+
export function parseTailscaleStatus(json) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(json);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new Error(`Could not parse tailscale status JSON: ${err?.message ?? err}`);
|
|
86
|
+
}
|
|
87
|
+
const out = [];
|
|
88
|
+
if (parsed.Self) {
|
|
89
|
+
const self = toNode(parsed.Self);
|
|
90
|
+
if (self)
|
|
91
|
+
out.push(self);
|
|
92
|
+
}
|
|
93
|
+
for (const raw of Object.values(parsed.Peer ?? {})) {
|
|
94
|
+
const node = toNode(raw);
|
|
95
|
+
if (node)
|
|
96
|
+
out.push(node);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** Turn a parsed Tailscale node into the registry fields it can populate. */
|
|
101
|
+
export function nodeToDeviceInput(node) {
|
|
102
|
+
return {
|
|
103
|
+
platform: node.platform,
|
|
104
|
+
address: { via: 'tailscale', dnsName: node.dnsName, ip: node.ip },
|
|
105
|
+
tailscale: {
|
|
106
|
+
online: node.online,
|
|
107
|
+
direct: node.direct,
|
|
108
|
+
relay: node.relay,
|
|
109
|
+
lastSeen: node.lastSeen,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Run `tailscale status --json` and return its raw stdout. Throws a clear
|
|
115
|
+
* error when the binary is missing or the daemon is not reachable.
|
|
116
|
+
*/
|
|
117
|
+
export function tailscaleStatusJson() {
|
|
118
|
+
const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8' });
|
|
119
|
+
if (res.error && res.error.code === 'ENOENT') {
|
|
120
|
+
throw new Error('tailscale not found on PATH. Install Tailscale, or add devices manually with `agents devices add`.');
|
|
121
|
+
}
|
|
122
|
+
if (res.status !== 0) {
|
|
123
|
+
throw new Error(`tailscale status failed: ${(res.stderr || res.stdout || '').trim() || `exit ${res.status}`}`);
|
|
124
|
+
}
|
|
125
|
+
return res.stdout ?? '';
|
|
126
|
+
}
|
package/dist/lib/exec.js
CHANGED
|
@@ -534,6 +534,20 @@ export function buildExecCommand(options) {
|
|
|
534
534
|
cmd.push('--dangerously-bypass-approvals-and-sandbox');
|
|
535
535
|
}
|
|
536
536
|
}
|
|
537
|
+
else if (options.agent === 'kimi' && !interactive) {
|
|
538
|
+
// kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
|
|
539
|
+
// to be combined with any startup-mode flag: `--plan`, `--auto`, and `--yolo`
|
|
540
|
+
// all abort with "Cannot combine --prompt with --X" (verified against the live
|
|
541
|
+
// kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
|
|
542
|
+
// default `-p` behavior, which already auto-approves tool calls, so we emit no
|
|
543
|
+
// mode flag. Plan (read-only) has no headless equivalent, so fail closed rather
|
|
544
|
+
// than silently letting a plan-mode run mutate the workspace.
|
|
545
|
+
if (resolvedMode === 'plan') {
|
|
546
|
+
throw new Error('kimi has no headless read-only mode: `--prompt` cannot be combined with `--plan`. ' +
|
|
547
|
+
'Run kimi in plan mode interactively (omit the prompt), or use --mode edit, auto, or skip.');
|
|
548
|
+
}
|
|
549
|
+
// edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
|
|
550
|
+
}
|
|
537
551
|
else {
|
|
538
552
|
cmd.push(...modeFlags);
|
|
539
553
|
}
|
package/dist/lib/models.js
CHANGED
|
@@ -165,6 +165,26 @@ export function locateModelSource(agent, version) {
|
|
|
165
165
|
return { path: pathBin, kind: 'cli' };
|
|
166
166
|
return null;
|
|
167
167
|
}
|
|
168
|
+
if (agent === 'antigravity') {
|
|
169
|
+
// The `agy` shim under node_modules/.bin exposes `agy models`. We don't parse
|
|
170
|
+
// any bundle; the CLI produces its own (display-name-only) catalog.
|
|
171
|
+
const cli = path.join(versionDir, 'node_modules', '.bin', 'agy');
|
|
172
|
+
if (fs.existsSync(cli))
|
|
173
|
+
return { path: cli, kind: 'cli' };
|
|
174
|
+
const pathBin = findOnPath('agy');
|
|
175
|
+
if (pathBin)
|
|
176
|
+
return { path: pathBin, kind: 'cli' };
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (agent === 'kimi') {
|
|
180
|
+
const cli = path.join(versionDir, 'node_modules', '.bin', 'kimi');
|
|
181
|
+
if (fs.existsSync(cli))
|
|
182
|
+
return { path: cli, kind: 'cli' };
|
|
183
|
+
const pathBin = findOnPath('kimi');
|
|
184
|
+
if (pathBin)
|
|
185
|
+
return { path: pathBin, kind: 'cli' };
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
168
188
|
if (agent === 'cursor') {
|
|
169
189
|
// cursor-agent is installed via curl script, not agents-cli. Version argument
|
|
170
190
|
// is accepted for API symmetry but ignored -- cursor lives on PATH.
|
|
@@ -606,6 +626,114 @@ function extractOpenClawCatalog(binaryPath) {
|
|
|
606
626
|
}));
|
|
607
627
|
return { models, aliases: {} };
|
|
608
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Extract Antigravity's catalog via `agy models`. Antigravity is unusual: it
|
|
631
|
+
* prints DISPLAY NAMES ONLY, one per line, with no machine ids and no --json:
|
|
632
|
+
* Gemini 3.5 Flash (Medium)
|
|
633
|
+
* Claude Sonnet 4.6 (Thinking)
|
|
634
|
+
* Verified (agy 1.0.11) that those display strings ARE the accepted `--model`
|
|
635
|
+
* values -- `agy --model "Claude Opus 4.6 (Thinking)"` routes to that model,
|
|
636
|
+
* and an unknown value silently falls back to the first row. So we use each
|
|
637
|
+
* display string as both id and displayName, and mark the first row default.
|
|
638
|
+
*/
|
|
639
|
+
function extractAntigravityCatalog(binaryPath) {
|
|
640
|
+
let stdout;
|
|
641
|
+
try {
|
|
642
|
+
stdout = execFileSync(binaryPath, ['models'], {
|
|
643
|
+
encoding: 'utf-8',
|
|
644
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
645
|
+
timeout: 15_000,
|
|
646
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
return { models: [], aliases: {} };
|
|
651
|
+
}
|
|
652
|
+
// Strip ANSI in case a spinner or color codes slip through.
|
|
653
|
+
// eslint-disable-next-line no-control-regex
|
|
654
|
+
const plain = stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
655
|
+
const models = [];
|
|
656
|
+
const seen = new Set();
|
|
657
|
+
for (const raw of plain.split('\n')) {
|
|
658
|
+
const name = raw.trim();
|
|
659
|
+
if (!name)
|
|
660
|
+
continue;
|
|
661
|
+
// Guard against any stray banner/usage lines: real rows look like
|
|
662
|
+
// "<Vendor> <Model> (<Level>)". Require an alphanumeric start and a
|
|
663
|
+
// parenthesized suffix, which every observed model row has.
|
|
664
|
+
if (!/^[A-Za-z0-9].*\([^)]+\)\s*$/.test(name))
|
|
665
|
+
continue;
|
|
666
|
+
if (seen.has(name))
|
|
667
|
+
continue;
|
|
668
|
+
seen.add(name);
|
|
669
|
+
models.push({
|
|
670
|
+
id: name,
|
|
671
|
+
displayName: name,
|
|
672
|
+
// Antigravity's first listed model is its default (unknown --model values
|
|
673
|
+
// fall back to it), so flag the first row we accept.
|
|
674
|
+
isDefault: models.length === 0,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return { models, aliases: {} };
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Extract Kimi's catalog via `kimi provider list --json`, which emits the raw
|
|
681
|
+
* providers/models config. Model ids are the `models` object keys (e.g.
|
|
682
|
+
* `kimi-code/kimi-for-coding`). The default is reported on a separate plain
|
|
683
|
+
* `Default model: <id>` line by `kimi provider list` (no flags), so we run that
|
|
684
|
+
* too to flag the default row.
|
|
685
|
+
*/
|
|
686
|
+
function extractKimiCatalog(binaryPath) {
|
|
687
|
+
let jsonOut;
|
|
688
|
+
try {
|
|
689
|
+
jsonOut = execFileSync(binaryPath, ['provider', 'list', '--json'], {
|
|
690
|
+
encoding: 'utf-8',
|
|
691
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
692
|
+
timeout: 15_000,
|
|
693
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return { models: [], aliases: {} };
|
|
698
|
+
}
|
|
699
|
+
const firstBrace = jsonOut.indexOf('{');
|
|
700
|
+
if (firstBrace === -1)
|
|
701
|
+
return { models: [], aliases: {} };
|
|
702
|
+
let parsed;
|
|
703
|
+
try {
|
|
704
|
+
parsed = JSON.parse(jsonOut.slice(firstBrace));
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return { models: [], aliases: {} };
|
|
708
|
+
}
|
|
709
|
+
// Resolve the default model id from the plain listing's "Default model:" line.
|
|
710
|
+
let defaultId = null;
|
|
711
|
+
try {
|
|
712
|
+
const plain = execFileSync(binaryPath, ['provider', 'list'], {
|
|
713
|
+
encoding: 'utf-8',
|
|
714
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
715
|
+
timeout: 10_000,
|
|
716
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
717
|
+
});
|
|
718
|
+
const m = plain.match(/Default model:\s*(\S+)/);
|
|
719
|
+
if (m)
|
|
720
|
+
defaultId = m[1];
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
/* default flag is best-effort */
|
|
724
|
+
}
|
|
725
|
+
const modelsObj = parsed?.models && typeof parsed.models === 'object' ? parsed.models : {};
|
|
726
|
+
const models = [];
|
|
727
|
+
for (const id of Object.keys(modelsObj)) {
|
|
728
|
+
const info = modelsObj[id] ?? {};
|
|
729
|
+
models.push({
|
|
730
|
+
id,
|
|
731
|
+
displayName: typeof info.displayName === 'string' ? info.displayName : undefined,
|
|
732
|
+
isDefault: defaultId != null && id === defaultId,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
return { models, aliases: {} };
|
|
736
|
+
}
|
|
609
737
|
/**
|
|
610
738
|
* Build (or load from cache) the model catalog for a specific (agent, version).
|
|
611
739
|
* Cache is keyed on source-file mtime (binary or js module), so re-extracts
|
|
@@ -654,6 +782,10 @@ export function getModelCatalog(agent, version) {
|
|
|
654
782
|
({ models, aliases } = extractCursorCatalog(src.path));
|
|
655
783
|
else if (agent === 'openclaw')
|
|
656
784
|
({ models, aliases } = extractOpenClawCatalog(src.path));
|
|
785
|
+
else if (agent === 'antigravity')
|
|
786
|
+
({ models, aliases } = extractAntigravityCatalog(src.path));
|
|
787
|
+
else if (agent === 'kimi')
|
|
788
|
+
({ models, aliases } = extractKimiCatalog(src.path));
|
|
657
789
|
}
|
|
658
790
|
const catalog = {
|
|
659
791
|
agent,
|
|
@@ -663,11 +795,12 @@ export function getModelCatalog(agent, version) {
|
|
|
663
795
|
models,
|
|
664
796
|
aliases,
|
|
665
797
|
};
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
798
|
+
// Never cache an empty extraction, regardless of source kind. A 0-model
|
|
799
|
+
// result is always suspect: the CLI may have been mid-install, network-
|
|
800
|
+
// dependent, or transiently failing, and a js/bundle/binary extractor that
|
|
801
|
+
// regex-misses would otherwise pin an empty catalog forever (mtime won't
|
|
802
|
+
// change until the source file does). Only persist a non-empty catalog.
|
|
803
|
+
if (models.length > 0) {
|
|
671
804
|
cache.entries[key] = { sourcePath: src.path, mtime, catalog };
|
|
672
805
|
saveCache();
|
|
673
806
|
}
|
package/dist/lib/runner.js
CHANGED
|
@@ -95,14 +95,14 @@ export function buildJobCommand(config, resolvedPrompt) {
|
|
|
95
95
|
appendModelAndReasoning(cmd, config);
|
|
96
96
|
}
|
|
97
97
|
if (config.agent === 'kimi') {
|
|
98
|
+
// kimi daemon jobs always run headless via `--prompt`, which cannot be
|
|
99
|
+
// combined with any startup-mode flag (--plan/--auto/--yolo all abort with
|
|
100
|
+
// "Cannot combine --prompt with --X"). edit/auto/skip reduce to kimi's default
|
|
101
|
+
// headless auto-run, so emit no flag; plan has no headless read-only
|
|
102
|
+
// equivalent, so fail closed rather than silently allowing writes.
|
|
98
103
|
if (mode === 'plan') {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
else if (mode === 'auto') {
|
|
102
|
-
cmd.push('--auto');
|
|
103
|
-
}
|
|
104
|
-
else if (mode === 'skip') {
|
|
105
|
-
cmd.push('--yolo');
|
|
104
|
+
throw new Error('kimi has no headless read-only mode: routine jobs cannot run kimi with --mode plan ' +
|
|
105
|
+
'(kimi rejects --prompt + --plan). Use --mode edit, auto, or skip.');
|
|
106
106
|
}
|
|
107
107
|
appendModelAndReasoning(cmd, config);
|
|
108
108
|
}
|