@phnx-labs/agents-cli 1.20.53 → 1.20.54
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 +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/sessions.js +5 -3
- package/dist/commands/ssh.js +18 -6
- package/dist/lib/devices/resolve-target.d.ts +21 -4
- package/dist/lib/devices/resolve-target.js +82 -14
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +34 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/session/remote.d.ts +13 -0
- package/dist/lib/session/remote.js +27 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.54
|
|
6
|
+
|
|
7
|
+
- **Unified fleet target resolution for `agents ssh` + `sessions --host`.** `agents ssh` now accepts the full target grammar the fan-out already used — a registered `name`, a `user@device` (same device, login user overridden, still dialed via its Tailscale route rather than raw LAN DNS), and an ad-hoc `user@host`/`host` literal — instead of only an exact device name (`agents ssh muqsit@mac-mini` no longer errors "Unknown device"). A bare unregistered alias still reports "Unknown device". `sessions --host user@device` now resolves the host part through the registry too, so it stops silently diverging onto the non-Tailscale route. New `resolveDeviceTarget`; `resolveSshTarget` shares one host-part matcher. Source: `apps/cli/src/lib/devices/resolve-target.ts`, `apps/cli/src/commands/ssh.ts`.
|
|
8
|
+
- **`agents sessions --host` searches the peer's whole index, not its login cwd.** A remote listing runs in the peer's SSH-login home dir and was silently cwd-scoped, so `sessions --host <box>` read as empty (`No sessions found for /home/<user>`) even when the box's index was full. `--host` now defaults to whole-index (`--all`) scope; an explicit path query / `--project` / `--since` / `--agent` filter still narrows on top. It also runs the peer once, for itself (`AGENTS_SESSIONS_LOCAL=1`), so it no longer re-sweeps the fleet and prints a spurious `<this-machine>: unreachable`. Source: `apps/cli/src/lib/session/remote.ts`, `apps/cli/src/commands/sessions.ts`.
|
|
9
|
+
- **`agents devices sync` pins each device's login user.** Tailscale status carries a node's OS + address but not the account you ssh in as, so sync now materializes the local operator's username onto newly-synced devices (never clobbering a user you pinned). This makes `--host <device>` dial the same account no matter which machine launches the fan-out, instead of leaning on ssh's implicit local-username default. Source: `apps/cli/src/lib/devices/sync.ts`.
|
|
10
|
+
|
|
5
11
|
## 1.20.53
|
|
6
12
|
|
|
7
13
|
- **`agents add <agent>@latest` resolves to a concrete version before installing (no install race).** `latest` (like `oldest`) is now resolved via `npm view` up front and installed as a pinned spec directly into `versions/<agent>/<version>/`. Previously `latest` installed into a shared, well-known `versions/<agent>/latest/` scratch dir and was renamed to the real version only after npm finished — so a concurrent `agents view` reconcile (`reconcileStaleLatestForAgent`) or a second `latest` install could rename that dir out from under npm mid-extraction, corrupting the install with `ENOENT` on the seeded `package.json`. A concrete dir per version has no shared name to race on. Source: `apps/cli/src/lib/versions.ts`.
|
package/dist/bin/agents
CHANGED
|
Binary file
|
|
@@ -28,7 +28,7 @@ import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/
|
|
|
28
28
|
import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
29
29
|
import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
30
30
|
import { parseSession } from '../lib/session/parse.js';
|
|
31
|
-
import { runRemoteSessions, buildForwardedArgs } from '../lib/session/remote.js';
|
|
31
|
+
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
32
32
|
import { formatRelativeTime } from '../lib/session/relative-time.js';
|
|
33
33
|
import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
|
|
34
34
|
import { renderMarkdown } from '../lib/markdown.js';
|
|
@@ -559,8 +559,10 @@ export function serializeSessionsJson(sessions) {
|
|
|
559
559
|
*/
|
|
560
560
|
async function runRemoteSessionsJson(hosts) {
|
|
561
561
|
// Forward the caller's own filters (query, --limit, --since, …) minus --host,
|
|
562
|
-
// and guarantee --json so each peer answers with a parseable array.
|
|
563
|
-
|
|
562
|
+
// and guarantee --json so each peer answers with a parseable array. Force
|
|
563
|
+
// whole-index scope: an explicit --host means "that box's index", not the
|
|
564
|
+
// slice that happens to sit under the peer's SSH-login home dir.
|
|
565
|
+
const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(hosts)));
|
|
564
566
|
if (!forwarded.includes('--json'))
|
|
565
567
|
forwarded.push('--json');
|
|
566
568
|
const { sessions } = await gatherRemoteList(forwarded, hosts);
|
package/dist/commands/ssh.js
CHANGED
|
@@ -19,7 +19,8 @@ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
|
|
|
19
19
|
import { machineId } from '../lib/session/sync/config.js';
|
|
20
20
|
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
|
|
21
21
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
|
|
22
|
-
import { planDeviceReconciliation, runDeviceSync } from '../lib/devices/sync.js';
|
|
22
|
+
import { localLoginUser, planDeviceReconciliation, runDeviceSync, withDefaultUser } from '../lib/devices/sync.js';
|
|
23
|
+
import { resolveDeviceTarget } from '../lib/devices/resolve-target.js';
|
|
23
24
|
import { clearPendingSentinel } from '../lib/devices/pending.js';
|
|
24
25
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
25
26
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
@@ -106,8 +107,11 @@ async function runInteractiveDeviceSync() {
|
|
|
106
107
|
}
|
|
107
108
|
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
108
109
|
const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const localUser = localLoginUser();
|
|
111
|
+
for (const name of plan.toRegister) {
|
|
112
|
+
const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), reg[name]?.user, localUser);
|
|
113
|
+
await upsertDevice(name, input);
|
|
114
|
+
}
|
|
111
115
|
for (const name of plan.toUnignore)
|
|
112
116
|
await removeIgnored(name);
|
|
113
117
|
for (const name of plan.toRemove)
|
|
@@ -332,16 +336,24 @@ secrets bundle via an askpass shim — the password never touches argv.
|
|
|
332
336
|
await runAskpass();
|
|
333
337
|
return;
|
|
334
338
|
}
|
|
335
|
-
|
|
339
|
+
// Accept the full fleet target grammar: a registered `name`, a
|
|
340
|
+
// `user@device` (same device, login user overridden — dialed via its
|
|
341
|
+
// Tailscale route, not LAN DNS), or an ad-hoc `user@host`/`host` literal.
|
|
342
|
+
// A bare unregistered alias still errors as "Unknown device".
|
|
343
|
+
const device = resolveDeviceTarget(name, await loadDevices());
|
|
344
|
+
if (!device) {
|
|
345
|
+
console.error(chalk.red(`Unknown device '${name}'. See 'agents devices list'.`));
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
336
348
|
// Preflight: a device Tailscale last saw offline would otherwise hang
|
|
337
349
|
// for the full ConnectTimeout. Fail fast with a clear message instead.
|
|
338
350
|
if (device.tailscale && !device.tailscale.online) {
|
|
339
|
-
console.error(chalk.red(`Device '${name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
|
|
351
|
+
console.error(chalk.red(`Device '${device.name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
|
|
340
352
|
console.error(chalk.gray("Run 'agents devices sync' to refresh reachability."));
|
|
341
353
|
process.exit(1);
|
|
342
354
|
}
|
|
343
355
|
if (device.tailscale?.online && !device.tailscale.direct) {
|
|
344
|
-
console.error(chalk.yellow(`Note: connection to '${name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
|
|
356
|
+
console.error(chalk.yellow(`Note: connection to '${device.name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
|
|
345
357
|
}
|
|
346
358
|
try {
|
|
347
359
|
const shim = writeAskpassShim();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type DeviceRegistry } from './registry.js';
|
|
1
|
+
import { type DeviceProfile, type DeviceRegistry } from './registry.js';
|
|
2
2
|
/** A dialable peer: the ssh target, the machine id used to tag its rows, a
|
|
3
3
|
* display name, and the OS family that picks the remote shell dialect. */
|
|
4
4
|
export interface ResolvedSshTarget {
|
|
@@ -7,14 +7,31 @@ export interface ResolvedSshTarget {
|
|
|
7
7
|
name: string;
|
|
8
8
|
os?: string;
|
|
9
9
|
}
|
|
10
|
+
/** Split a `user@host` / `host` token into its login user (if any) and host part. */
|
|
11
|
+
export declare function splitUserHost(token: string): {
|
|
12
|
+
user?: string;
|
|
13
|
+
host: string;
|
|
14
|
+
};
|
|
10
15
|
/**
|
|
11
16
|
* Resolve one `--host`/`--device` token to a concrete ssh target through the
|
|
12
17
|
* registry. Registry hit → the device's real address + platform (so the machine
|
|
13
|
-
* id, route, and OS all match the auto-discovery sweep)
|
|
14
|
-
* `user@host` fallback, its OS
|
|
15
|
-
*
|
|
18
|
+
* id, route, and OS all match the auto-discovery sweep), with any `user@`
|
|
19
|
+
* overriding the login account. Miss → a literal `user@host` fallback, its OS
|
|
20
|
+
* taken from the host overlay if enrolled. Returns undefined only when the token
|
|
21
|
+
* fails the shared ssh-target injection guard.
|
|
16
22
|
*/
|
|
17
23
|
export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
|
|
26
|
+
* grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
|
|
27
|
+
* shell, tailscale metadata) `buildSshInvocation` needs — not just a target
|
|
28
|
+
* string. A registered `name` or `user@device` yields that profile (with the
|
|
29
|
+
* login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
|
|
30
|
+
* yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
|
|
31
|
+
* returns undefined so the caller reports "Unknown device" rather than dialing a
|
|
32
|
+
* literal — the strict behaviour the interactive wrapper has always had.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveDeviceTarget(token: string, reg: DeviceRegistry): DeviceProfile | undefined;
|
|
18
35
|
/**
|
|
19
36
|
* Resolve an explicit `--host`/`--device` list to dialable targets, reading the
|
|
20
37
|
* registry once. A token that fails the injection guard is skipped with a
|
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
* `%C` hash → a cold dial every time) and could read a perfectly reachable box
|
|
14
14
|
* as "unreachable" when only the non-Tailscale route was down.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The grammar is uniform across the fleet: `mac-mini` (device name), and
|
|
17
|
+
* `muqsit@mac-mini` (same device, login user overridden) both resolve through
|
|
18
|
+
* the registry to the device's Tailscale route — the `user@` form no longer
|
|
19
|
+
* short-circuits to a bare `ssh muqsit@mac-mini` (LAN DNS). A `user@host` that
|
|
20
|
+
* matches no registered device falls back to a literal target so ad-hoc boxes
|
|
21
|
+
* still work.
|
|
18
22
|
*/
|
|
19
23
|
import chalk from 'chalk';
|
|
20
24
|
import { assertValidSshTarget } from '../ssh-exec.js';
|
|
@@ -22,12 +26,28 @@ import { normalizeHost } from '../machine-id.js';
|
|
|
22
26
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
23
27
|
import { sshTargetFor } from './connect.js';
|
|
24
28
|
import { loadDevices } from './registry.js';
|
|
29
|
+
/** Split a `user@host` / `host` token into its login user (if any) and host part. */
|
|
30
|
+
export function splitUserHost(token) {
|
|
31
|
+
const at = token.indexOf('@');
|
|
32
|
+
return at === -1 ? { host: token } : { user: token.slice(0, at), host: token.slice(at + 1) };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Match a host part (the piece after any `user@`) to a registered device: exact
|
|
36
|
+
* registry key first, then a normalized-host match so `yosemite-s0` and
|
|
37
|
+
* `yosemite-s0.<tailnet>.ts.net` land on the same profile. The single source of
|
|
38
|
+
* truth both `resolveSshTarget` (fan-out) and `resolveDeviceTarget` (`agents
|
|
39
|
+
* ssh`) share, so a `user@device` can never resolve two different routes.
|
|
40
|
+
*/
|
|
41
|
+
function matchDevice(host, reg) {
|
|
42
|
+
return reg[host] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(host));
|
|
43
|
+
}
|
|
25
44
|
/**
|
|
26
45
|
* Resolve one `--host`/`--device` token to a concrete ssh target through the
|
|
27
46
|
* registry. Registry hit → the device's real address + platform (so the machine
|
|
28
|
-
* id, route, and OS all match the auto-discovery sweep)
|
|
29
|
-
* `user@host` fallback, its OS
|
|
30
|
-
*
|
|
47
|
+
* id, route, and OS all match the auto-discovery sweep), with any `user@`
|
|
48
|
+
* overriding the login account. Miss → a literal `user@host` fallback, its OS
|
|
49
|
+
* taken from the host overlay if enrolled. Returns undefined only when the token
|
|
50
|
+
* fails the shared ssh-target injection guard.
|
|
31
51
|
*/
|
|
32
52
|
export function resolveSshTarget(token, reg) {
|
|
33
53
|
try {
|
|
@@ -36,22 +56,70 @@ export function resolveSshTarget(token, reg) {
|
|
|
36
56
|
catch {
|
|
37
57
|
return undefined;
|
|
38
58
|
}
|
|
39
|
-
const
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
const device =
|
|
44
|
-
? undefined
|
|
45
|
-
: reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
|
|
59
|
+
const { user, host } = splitUserHost(token);
|
|
60
|
+
// A device and a `user@device` are the same box; resolve the host part through
|
|
61
|
+
// the registry so both dial the Tailscale route, and let an explicit `user@`
|
|
62
|
+
// override only the login account.
|
|
63
|
+
const device = matchDevice(host, reg);
|
|
46
64
|
if (device) {
|
|
47
65
|
try {
|
|
48
|
-
|
|
66
|
+
const effective = user ? { ...device, user } : device;
|
|
67
|
+
return { target: sshTargetFor(effective), machine: normalizeHost(device.name), name: device.name, os: device.platform };
|
|
49
68
|
}
|
|
50
69
|
catch {
|
|
51
70
|
// Registered but has no address to dial — fall through to the literal token.
|
|
52
71
|
}
|
|
53
72
|
}
|
|
54
|
-
return { target: token, machine: normalizeHost(
|
|
73
|
+
return { target: token, machine: normalizeHost(host), name: token, os: resolveRemoteOsSync(token) };
|
|
74
|
+
}
|
|
75
|
+
/** Timestamps for a synthesized ad-hoc profile — never persisted, so a constant
|
|
76
|
+
* keeps the value deterministic (and side-effect free) without reading the clock. */
|
|
77
|
+
const SYNTH_TS = '1970-01-01T00:00:00.000Z';
|
|
78
|
+
/** True when a token is clearly a network target (a `user@`, or a dotted/IPv6
|
|
79
|
+
* host / IP) rather than a bare alias. A bare unknown word is a typo, so `agents
|
|
80
|
+
* ssh foo` still says "Unknown device" instead of dialing a literal `foo`. */
|
|
81
|
+
function looksLikeHostLiteral(token) {
|
|
82
|
+
return token.includes('@') || token.includes('.') || token.includes(':');
|
|
83
|
+
}
|
|
84
|
+
/** Synthesize a throwaway device profile for an ad-hoc `user@host` / `host`
|
|
85
|
+
* literal so `agents ssh` can dial a box that was never registered. */
|
|
86
|
+
function adHocDevice(token, host, user) {
|
|
87
|
+
const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
88
|
+
return {
|
|
89
|
+
name: token,
|
|
90
|
+
platform: 'unknown',
|
|
91
|
+
shell: 'posix',
|
|
92
|
+
user,
|
|
93
|
+
address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
|
|
94
|
+
auth: { method: 'key' },
|
|
95
|
+
createdAt: SYNTH_TS,
|
|
96
|
+
updatedAt: SYNTH_TS,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
|
|
101
|
+
* grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
|
|
102
|
+
* shell, tailscale metadata) `buildSshInvocation` needs — not just a target
|
|
103
|
+
* string. A registered `name` or `user@device` yields that profile (with the
|
|
104
|
+
* login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
|
|
105
|
+
* yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
|
|
106
|
+
* returns undefined so the caller reports "Unknown device" rather than dialing a
|
|
107
|
+
* literal — the strict behaviour the interactive wrapper has always had.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveDeviceTarget(token, reg) {
|
|
110
|
+
try {
|
|
111
|
+
assertValidSshTarget(token);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const { user, host } = splitUserHost(token);
|
|
117
|
+
const device = matchDevice(host, reg);
|
|
118
|
+
if (device)
|
|
119
|
+
return user ? { ...device, user } : device;
|
|
120
|
+
if (looksLikeHostLiteral(token))
|
|
121
|
+
return adHocDevice(token, host, user);
|
|
122
|
+
return undefined;
|
|
55
123
|
}
|
|
56
124
|
/**
|
|
57
125
|
* Resolve an explicit `--host`/`--device` list to dialable targets, reading the
|
|
@@ -1,5 +1,23 @@
|
|
|
1
|
+
import { type DeviceInput } from './registry.js';
|
|
1
2
|
import { type TailscaleNode } from './tailscale.js';
|
|
2
3
|
import type { PendingDevice } from './pending.js';
|
|
4
|
+
/**
|
|
5
|
+
* The login user to stamp onto newly-synced devices. Tailscale status carries a
|
|
6
|
+
* node's OS and address but NOT the account you ssh in as, so we materialize the
|
|
7
|
+
* local operator's username — tailnet devices are overwhelmingly one person's
|
|
8
|
+
* boxes, and this is exactly the account ssh would already fall back to. Pinning
|
|
9
|
+
* it in the registry makes `--host <device>` dial that account no matter which
|
|
10
|
+
* machine launches the fan-out (a peer whose local user differs otherwise dials
|
|
11
|
+
* the wrong account). Returns undefined when the username isn't a safe ssh
|
|
12
|
+
* identifier, so a weird value never lands in the registry. */
|
|
13
|
+
export declare function localLoginUser(): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
|
16
|
+
* the user pinned. Precedence: an existing registered user wins; else the local
|
|
17
|
+
* operator's username; else leave it unset (ssh's implicit local default still
|
|
18
|
+
* applies). Pure so the "never overwrite an explicit user" guard is unit-tested
|
|
19
|
+
* without a tailnet. */
|
|
20
|
+
export declare function withDefaultUser(input: DeviceInput, prevUser: string | undefined, localUser: string | undefined): DeviceInput;
|
|
3
21
|
/**
|
|
4
22
|
* bootstrap — register every non-ignored node (opt-out). First-run `agents
|
|
5
23
|
* setup` and manual `agents devices sync`, so the fleet is usable out of box.
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -14,8 +14,39 @@
|
|
|
14
14
|
* - soft (`soft: true`): auto-callers must never abort setup/sync because a
|
|
15
15
|
* machine has no tailscale — they get a result with `ok: false` instead.
|
|
16
16
|
*/
|
|
17
|
+
import * as os from 'os';
|
|
17
18
|
import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
|
|
18
19
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
|
|
20
|
+
/**
|
|
21
|
+
* The login user to stamp onto newly-synced devices. Tailscale status carries a
|
|
22
|
+
* node's OS and address but NOT the account you ssh in as, so we materialize the
|
|
23
|
+
* local operator's username — tailnet devices are overwhelmingly one person's
|
|
24
|
+
* boxes, and this is exactly the account ssh would already fall back to. Pinning
|
|
25
|
+
* it in the registry makes `--host <device>` dial that account no matter which
|
|
26
|
+
* machine launches the fan-out (a peer whose local user differs otherwise dials
|
|
27
|
+
* the wrong account). Returns undefined when the username isn't a safe ssh
|
|
28
|
+
* identifier, so a weird value never lands in the registry. */
|
|
29
|
+
export function localLoginUser() {
|
|
30
|
+
let u;
|
|
31
|
+
try {
|
|
32
|
+
u = os.userInfo().username;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
u = process.env.USER || process.env.USERNAME || undefined;
|
|
36
|
+
}
|
|
37
|
+
return u && /^[a-zA-Z0-9._-]+$/.test(u) ? u : undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fill in a device's login user during sync WITHOUT ever clobbering an account
|
|
41
|
+
* the user pinned. Precedence: an existing registered user wins; else the local
|
|
42
|
+
* operator's username; else leave it unset (ssh's implicit local default still
|
|
43
|
+
* applies). Pure so the "never overwrite an explicit user" guard is unit-tested
|
|
44
|
+
* without a tailnet. */
|
|
45
|
+
export function withDefaultUser(input, prevUser, localUser) {
|
|
46
|
+
if (input.user || prevUser || !localUser)
|
|
47
|
+
return input;
|
|
48
|
+
return { ...input, user: localUser };
|
|
49
|
+
}
|
|
19
50
|
/**
|
|
20
51
|
* Node names present on the tailnet but neither already in the registry nor on
|
|
21
52
|
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
@@ -68,8 +99,10 @@ export async function runDeviceSync(opts = {}) {
|
|
|
68
99
|
platform: byName.get(name)?.platform ?? 'unknown',
|
|
69
100
|
}));
|
|
70
101
|
const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
|
|
102
|
+
const localUser = localLoginUser();
|
|
71
103
|
for (const node of toUpsert) {
|
|
72
|
-
|
|
104
|
+
const input = withDefaultUser(nodeToDeviceInput(node), registeredBefore[node.name]?.user, localUser);
|
|
105
|
+
await upsertDevice(node.name, input);
|
|
73
106
|
}
|
|
74
107
|
return { ok: true, synced: toUpsert.length, pending };
|
|
75
108
|
}
|
|
Binary file
|
|
@@ -16,6 +16,19 @@ export declare function shellQuote(s: string): string;
|
|
|
16
16
|
* (`[runtime, script, 'sessions', ...]`).
|
|
17
17
|
*/
|
|
18
18
|
export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
|
|
21
|
+
*
|
|
22
|
+
* A remote listing runs in the peer's SSH-login cwd — its home dir — and the
|
|
23
|
+
* default listing is silently cwd-scoped, so `sessions --host box` reads as
|
|
24
|
+
* empty even when the box's index is full (`No sessions found for /home/<user>`).
|
|
25
|
+
* Across SSH a peer's cwd is meaningless, so `--host` defaults to `--all`
|
|
26
|
+
* (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
|
|
27
|
+
* query, `--project`, `--since`, or `--agent` filter still narrows on top, and
|
|
28
|
+
* a query that looks like a path takes precedence over `--all` on the remote.
|
|
29
|
+
* Idempotent: never adds a second `--all`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ensureWholeIndex(forwardedArgs: string[]): string[];
|
|
19
32
|
/**
|
|
20
33
|
* Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
|
|
21
34
|
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
@@ -30,6 +30,7 @@ import { getCacheDir } from '../state.js';
|
|
|
30
30
|
import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../ssh-exec.js';
|
|
31
31
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
32
32
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
33
|
+
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
33
34
|
import { formatRelativeTime } from './relative-time.js';
|
|
34
35
|
import { terminalWidth } from './width.js';
|
|
35
36
|
/**
|
|
@@ -81,6 +82,21 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
81
82
|
}
|
|
82
83
|
return out;
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
|
|
87
|
+
*
|
|
88
|
+
* A remote listing runs in the peer's SSH-login cwd — its home dir — and the
|
|
89
|
+
* default listing is silently cwd-scoped, so `sessions --host box` reads as
|
|
90
|
+
* empty even when the box's index is full (`No sessions found for /home/<user>`).
|
|
91
|
+
* Across SSH a peer's cwd is meaningless, so `--host` defaults to `--all`
|
|
92
|
+
* (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
|
|
93
|
+
* query, `--project`, `--since`, or `--agent` filter still narrows on top, and
|
|
94
|
+
* a query that looks like a path takes precedence over `--all` on the remote.
|
|
95
|
+
* Idempotent: never adds a second `--all`.
|
|
96
|
+
*/
|
|
97
|
+
export function ensureWholeIndex(forwardedArgs) {
|
|
98
|
+
return forwardedArgs.includes('--all') ? forwardedArgs : [...forwardedArgs, '--all'];
|
|
99
|
+
}
|
|
84
100
|
/**
|
|
85
101
|
* Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
|
|
86
102
|
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
@@ -93,16 +109,23 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
93
109
|
* remote renders its table to the local screen.
|
|
94
110
|
*/
|
|
95
111
|
export function buildRemoteCommand(forwardedArgs, columns, os) {
|
|
112
|
+
// `--host <box>` means "that box's own sessions" — so the peer must answer for
|
|
113
|
+
// ITSELF and not re-sweep its fleet. Without this the remote `agents sessions`
|
|
114
|
+
// fans back out to every device IT knows (including us), printing a spurious
|
|
115
|
+
// `<this-machine>: unreachable`. AGENTS_SESSIONS_LOCAL=1 pins the peer local,
|
|
116
|
+
// matching the JSON fan-out path (`remote-list.ts`).
|
|
96
117
|
if (remoteShellFor(os) === 'powershell') {
|
|
97
|
-
const env =
|
|
118
|
+
const env = { [NO_FANOUT_ENV]: '1' };
|
|
119
|
+
if (columns && columns > 0)
|
|
120
|
+
env.COLUMNS = String(columns);
|
|
98
121
|
return buildWindowsAgentsCommand({ args: forwardedArgs, env });
|
|
99
122
|
}
|
|
100
123
|
const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
|
|
101
124
|
// Forward the caller's terminal width so the remote renders the table to the
|
|
102
125
|
// local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
|
|
103
126
|
// cmd` scopes the env to that process — the remote's terminalWidth() reads it.
|
|
104
|
-
const
|
|
105
|
-
return `bash -lc ${shellQuote(
|
|
127
|
+
const envPrefix = `${NO_FANOUT_ENV}=1` + (columns && columns > 0 ? ` COLUMNS=${columns}` : '');
|
|
128
|
+
return `bash -lc ${shellQuote(`${envPrefix} ${inner}`)}`;
|
|
106
129
|
}
|
|
107
130
|
/**
|
|
108
131
|
* Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
|
|
@@ -182,7 +205,7 @@ function replayRemoteCache(host, forwardedArgs) {
|
|
|
182
205
|
export function runRemoteSessions(hosts, argv = process.argv) {
|
|
183
206
|
for (const host of hosts)
|
|
184
207
|
assertValidSshTarget(host); // fail fast on any bad target
|
|
185
|
-
const forwarded = buildForwardedArgs(argv, new Set(hosts));
|
|
208
|
+
const forwarded = ensureWholeIndex(buildForwardedArgs(argv, new Set(hosts)));
|
|
186
209
|
const cols = terminalWidth();
|
|
187
210
|
const multi = hosts.length > 1;
|
|
188
211
|
let failures = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.54",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|