@phnx-labs/agents-cli 1.20.28 → 1.20.29
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/exec.js +22 -10
- package/dist/commands/secrets.js +93 -6
- package/dist/commands/sessions.js +1 -0
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/index.js +2 -1
- 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/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/session/db.d.ts +1 -0
- package/dist/lib/session/db.js +4 -4
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +228 -0
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- 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 +2 -0
- package/dist/lib/state.js +2 -0
- 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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote secrets — read and use `agents secrets` bundles that live on another
|
|
3
|
+
* host, over the same hardened SSH path that `agents secrets export --host`
|
|
4
|
+
* (the write inverse) already uses.
|
|
5
|
+
*
|
|
6
|
+
* This is the READ / USE direction:
|
|
7
|
+
* - browse: drive the remote `agents secrets list|view` and stream its
|
|
8
|
+
* stdout back verbatim (lossless, no parsing).
|
|
9
|
+
* - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
|
|
10
|
+
* inject it ephemerally — never written to this machine's keychain.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
|
+
* boundary as `export --host` / `run --host`). Bundle names are shell-quoted
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout; a forwarded
|
|
15
|
+
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
|
+
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
|
+
*/
|
|
18
|
+
import { type SshExecResult } from '../ssh-exec.js';
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
|
|
21
|
+
* registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
|
|
22
|
+
* treats the value as a raw ssh target and validates it against injection.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveSshTarget(nameOrAlias: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
|
|
27
|
+
* list. Both flags compose; either alone works. Empty when neither is set.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseHostsOption(opts: {
|
|
30
|
+
host?: string;
|
|
31
|
+
hosts?: string;
|
|
32
|
+
}): string[];
|
|
33
|
+
/**
|
|
34
|
+
* Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
|
|
35
|
+
* Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
|
|
36
|
+
* separates the bundle from the ssh target — and the target itself may be a
|
|
37
|
+
* `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
|
|
38
|
+
* `muqsit@box`).
|
|
39
|
+
*/
|
|
40
|
+
export declare function splitBundleRef(ref: string): {
|
|
41
|
+
bundle: string;
|
|
42
|
+
host?: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Run `agents secrets <args>` on a remote host over ssh and return the raw
|
|
46
|
+
* result. Used by the browse commands — the remote's human-readable stdout is
|
|
47
|
+
* streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
|
|
48
|
+
* remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
|
|
49
|
+
*/
|
|
50
|
+
export declare function remoteSecretsRaw(target: string, args: string[], opts?: {
|
|
51
|
+
tty?: boolean;
|
|
52
|
+
input?: string;
|
|
53
|
+
}): SshExecResult;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
56
|
+
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
57
|
+
* ssh stdout (encrypted in transit), parsed in memory, never persisted.
|
|
58
|
+
*
|
|
59
|
+
* The remote unlocks the bundle with ITS OWN credentials — the owner host's
|
|
60
|
+
* keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
|
|
61
|
+
* env) for a file-backed bundle. We deliberately do NOT forward this machine's
|
|
62
|
+
* passphrase: the remote bundle is encrypted with the remote's passphrase, so
|
|
63
|
+
* overriding it would break the read. (A macOS remote under non-interactive
|
|
64
|
+
* SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
|
|
65
|
+
* an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
|
|
66
|
+
*/
|
|
67
|
+
export declare function remoteResolveEnv(target: string, bundle: string): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote secrets — read and use `agents secrets` bundles that live on another
|
|
3
|
+
* host, over the same hardened SSH path that `agents secrets export --host`
|
|
4
|
+
* (the write inverse) already uses.
|
|
5
|
+
*
|
|
6
|
+
* This is the READ / USE direction:
|
|
7
|
+
* - browse: drive the remote `agents secrets list|view` and stream its
|
|
8
|
+
* stdout back verbatim (lossless, no parsing).
|
|
9
|
+
* - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
|
|
10
|
+
* inject it ephemerally — never written to this machine's keychain.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
|
+
* boundary as `export --host` / `run --host`). Bundle names are shell-quoted
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout; a forwarded
|
|
15
|
+
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
|
+
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
|
+
*/
|
|
18
|
+
import { sshExec, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { resolveHost } from '../hosts/registry.js';
|
|
20
|
+
import { sshTargetFor } from '../hosts/types.js';
|
|
21
|
+
const REMOTE_TIMEOUT_MS = 30_000;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
|
|
24
|
+
* registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
|
|
25
|
+
* treats the value as a raw ssh target and validates it against injection.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveSshTarget(nameOrAlias) {
|
|
28
|
+
const host = await resolveHost(nameOrAlias);
|
|
29
|
+
if (host)
|
|
30
|
+
return sshTargetFor(host);
|
|
31
|
+
assertValidSshTarget(nameOrAlias);
|
|
32
|
+
return nameOrAlias;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
|
|
36
|
+
* list. Both flags compose; either alone works. Empty when neither is set.
|
|
37
|
+
*/
|
|
38
|
+
export function parseHostsOption(opts) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const push = (h) => {
|
|
42
|
+
const t = h.trim();
|
|
43
|
+
if (t && !seen.has(t)) {
|
|
44
|
+
seen.add(t);
|
|
45
|
+
out.push(t);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
if (opts.host)
|
|
49
|
+
push(opts.host);
|
|
50
|
+
if (opts.hosts)
|
|
51
|
+
for (const h of opts.hosts.split(','))
|
|
52
|
+
push(h);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
|
|
57
|
+
* Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
|
|
58
|
+
* separates the bundle from the ssh target — and the target itself may be a
|
|
59
|
+
* `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
|
|
60
|
+
* `muqsit@box`).
|
|
61
|
+
*/
|
|
62
|
+
export function splitBundleRef(ref) {
|
|
63
|
+
const at = ref.indexOf('@');
|
|
64
|
+
if (at === -1)
|
|
65
|
+
return { bundle: ref };
|
|
66
|
+
const bundle = ref.slice(0, at);
|
|
67
|
+
const host = ref.slice(at + 1);
|
|
68
|
+
if (!bundle || !host) {
|
|
69
|
+
throw new Error(`Invalid remote bundle reference ${JSON.stringify(ref)}. Expected 'bundle@host'.`);
|
|
70
|
+
}
|
|
71
|
+
return { bundle, host };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `agents secrets <args>` on a remote host over ssh and return the raw
|
|
75
|
+
* result. Used by the browse commands — the remote's human-readable stdout is
|
|
76
|
+
* streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
|
|
77
|
+
* remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
|
|
78
|
+
*/
|
|
79
|
+
export function remoteSecretsRaw(target, args, opts = {}) {
|
|
80
|
+
const inner = ['agents', 'secrets', ...args].map(shellQuote).join(' ');
|
|
81
|
+
const remoteCmd = `bash -lc ${shellQuote(inner)}`;
|
|
82
|
+
return sshExec(target, remoteCmd, {
|
|
83
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
84
|
+
input: opts.input,
|
|
85
|
+
extraSshArgs: opts.tty ? ['-tt'] : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
90
|
+
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
91
|
+
* ssh stdout (encrypted in transit), parsed in memory, never persisted.
|
|
92
|
+
*
|
|
93
|
+
* The remote unlocks the bundle with ITS OWN credentials — the owner host's
|
|
94
|
+
* keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
|
|
95
|
+
* env) for a file-backed bundle. We deliberately do NOT forward this machine's
|
|
96
|
+
* passphrase: the remote bundle is encrypted with the remote's passphrase, so
|
|
97
|
+
* overriding it would break the read. (A macOS remote under non-interactive
|
|
98
|
+
* SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
|
|
99
|
+
* an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
|
|
100
|
+
*/
|
|
101
|
+
export async function remoteResolveEnv(target, bundle) {
|
|
102
|
+
assertValidSshTarget(target);
|
|
103
|
+
const exportCmd = `agents secrets export ${shellQuote(bundle)} --plaintext --format json`;
|
|
104
|
+
const res = sshExec(target, `bash -lc ${shellQuote(exportCmd)}`, {
|
|
105
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
106
|
+
});
|
|
107
|
+
if (res.code !== 0) {
|
|
108
|
+
const msg = (res.stderr || res.stdout || '').trim();
|
|
109
|
+
const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
|
|
110
|
+
throw new Error(`Failed to resolve '${bundle}' on ${target} (${why})${msg ? `: ${msg}` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
// Tolerate login-shell banner noise on stdout: take the outer { … } object.
|
|
113
|
+
const raw = res.stdout;
|
|
114
|
+
const start = raw.indexOf('{');
|
|
115
|
+
const end = raw.lastIndexOf('}');
|
|
116
|
+
const jsonText = start >= 0 && end >= start ? raw.slice(start, end + 1) : raw.trim();
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(jsonText);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`Could not parse secrets JSON from '${bundle}' on ${target}. ` +
|
|
123
|
+
`Is the remote agents-cli new enough for 'secrets export --format json'?`);
|
|
124
|
+
}
|
|
125
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
126
|
+
throw new Error(`Unexpected payload resolving '${bundle}' on ${target}.`);
|
|
127
|
+
}
|
|
128
|
+
const env = {};
|
|
129
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
130
|
+
env[k] = typeof v === 'string' ? v : String(v);
|
|
131
|
+
}
|
|
132
|
+
return env;
|
|
133
|
+
}
|
package/dist/lib/session/db.d.ts
CHANGED