@phnx-labs/agents-cli 1.22.65 → 1.22.66

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.66
4
+
5
+ - **Fleet usage/auth sync now reaches FQDN-only-pinned workers (PHNX-3505).** The
6
+ usage-sync and reserved-auth-sync push/pull planners judged a peer "pinned" by its
7
+ bare device name, but a tailnet-discovered worker's host key is pinned under its
8
+ FQDN (`yosemite-m6.tail….ts.net`). So every worker not also pinned under its short
9
+ name was silently skipped — its `claude-usage.json` went days stale and `balanced`
10
+ routed into weekly-maxed accounts, and its auth token never propagated. The pin
11
+ check now resolves the same host string the ssh connection dials
12
+ (`isDevicePinned`), matching either form. Source:
13
+ `cli/src/lib/devices/known-hosts.ts`, `cli/src/lib/accounting/usage-sync.ts`,
14
+ `cli/src/lib/secrets/reserved-sync.ts`.
15
+
3
16
  ## 1.22.65
4
17
 
5
18
  - **`agents view` shows the last-known usage number with its age instead of hiding it (PHNX-3453).** When the usage endpoint 429-backs-off an account so a window is not refreshed within its own lifetime (Claude's 5h session window) or a billing period rolls over (Grok's weekly credit window), the view now renders the last reading with a staleness suffix — `S: █▌░░░ 30% · 6h old`, `W: ██▏░░ 42% · stale (period ended 1h ago)` — instead of `S: ┄┄┄┄┄ unavailable` or a numberless `run grok once to refresh usage`. The dropped windows ride a new VIEW-ONLY `UsageSnapshot.staleWindows` field; routing still reads only `windows`, so `isUsageVerified`/`hasStaleUsage`/`deriveUsageStatusFromSnapshot` continue to treat a stale/expired number as unverified and never route on it. Source: `cli/src/lib/accounting/usage.ts`.
@@ -14,7 +14,7 @@ import chalk from 'chalk';
14
14
  import { setHelpSections } from '../lib/help.js';
15
15
  import { machineId } from '../lib/session/sync/config.js';
16
16
  import { loadDevices } from '../lib/devices/registry.js';
17
- import { isHostPinned, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
17
+ import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
18
18
  import { ensureDevicesRegistered } from '../lib/devices/sync.js';
19
19
  import { readFleetFile, resolveDesired, emptyTargetsMessage } from '../lib/fleet/manifest.js';
20
20
  import { snapshotAuth } from '../lib/fleet/auth-sync.js';
@@ -233,7 +233,14 @@ async function runApply(opts) {
233
233
  provisionSecrets: opts.provisionSecrets === true,
234
234
  forceSecrets: opts.force === true,
235
235
  // Portable secret values only ever go to a host whose key is already pinned.
236
- isHostPinned: (device) => isHostPinned(device, managedKnownHostsPath()),
236
+ // Resolve the device to its profile so the pin is checked against the FQDN the
237
+ // ssh connection dials (isDevicePinned), not the bare name — a tailnet worker
238
+ // pinned only under its FQDN would otherwise be refused as "not pinned"
239
+ // (PHNX-3505). A name with no profile falls back to the raw host-string check.
240
+ isHostPinned: (device) => {
241
+ const p = nameToProfile.get(device);
242
+ return p ? isDevicePinned(p) : isHostPinned(device, managedKnownHostsPath());
243
+ },
237
244
  });
238
245
  // --only filter.
239
246
  if (opts.only) {
@@ -29,7 +29,7 @@ import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShel
29
29
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
30
30
  import { loadDevicesSync } from '../devices/registry.js';
31
31
  import { sshTargetFor } from '../devices/connect.js';
32
- import { isHostPinned } from '../devices/known-hosts.js';
32
+ import { isHostPinned, isDevicePinned } from '../devices/known-hosts.js';
33
33
  import { machineId, normalizeHost } from '../session/sync/config.js';
34
34
  import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
35
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
@@ -154,7 +154,7 @@ export function pullUsageFromPrimary(deps = {}) {
154
154
  const primary = devices
155
155
  .filter((device) => roles[device.name] === 'personal')
156
156
  .concat(devices.filter((device) => roles[device.name] === 'desktop'))
157
- .find((device) => device.tailscale?.online !== false && isPinned(device.name));
157
+ .find((device) => device.tailscale?.online !== false && isDevicePinned(device, isPinned));
158
158
  if (!primary) {
159
159
  result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
160
160
  return result;
@@ -205,7 +205,7 @@ export function syncFleetUsageSnapshots(deps = {}) {
205
205
  name: d.name,
206
206
  role: roles[d.name],
207
207
  online: d.tailscale?.online !== false,
208
- pinned: isPinned(d.name),
208
+ pinned: isDevicePinned(d, isPinned),
209
209
  }));
210
210
  const rows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
211
211
  const plan = planUsagePush(isHeadedDeviceRole(selfRole), Object.keys(rows).length > 0, targets);
@@ -1,3 +1,4 @@
1
+ import type { DeviceProfile } from './registry.js';
1
2
  /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
2
3
  export declare function managedKnownHostsPath(): string;
3
4
  /** Ensure the parent directory of the managed store exists (mode 0700). */
@@ -12,6 +13,20 @@ export declare function readManagedKnownHosts(file?: string): string;
12
13
  export declare function isHostPinnedIn(content: string, host: string): boolean;
13
14
  /** True if `host` is pinned in the managed store on disk. */
14
15
  export declare function isHostPinned(host: string, file?: string): boolean;
16
+ /**
17
+ * True if a DEVICE's host key is pinned — checked against the SAME host string
18
+ * the ssh connection dials (`hostNameFor(device)`: the Tailscale dnsName/IP that
19
+ * `sshTargetFor` resolves), falling back to the bare device name.
20
+ *
21
+ * A device discovered over the tailnet is pinned under its FQDN
22
+ * (`yosemite-m6.tail….ts.net`), so a caller that checks the bare `device.name`
23
+ * misses the pin and wrongly treats a reachable, pinned peer as unpinned —
24
+ * silently excluding it from usage/auth fan-out while ssh to it would have
25
+ * succeeded (PHNX-3505). Accepting the bare name too keeps a device pinned only
26
+ * under its short name working. `isPinned` is injectable for tests; it defaults
27
+ * to the managed on-disk store.
28
+ */
29
+ export declare function isDevicePinned(device: DeviceProfile, isPinned?: (host: string) => boolean): boolean;
15
30
  /**
16
31
  * The host-key-checking ssh options for a connection.
17
32
  *
@@ -22,6 +22,7 @@ import { spawnSync } from 'child_process';
22
22
  import { getCacheDir } from '../state.js';
23
23
  import { assertValidSshTarget } from '../ssh-exec.js';
24
24
  import { parseKnownHosts } from '../hosts/ssh-config.js';
25
+ import { hostNameFor } from './ssh-config.js';
25
26
  /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
26
27
  export function managedKnownHostsPath() {
27
28
  return path.join(getCacheDir(), 'devices', 'known_hosts');
@@ -54,6 +55,23 @@ export function isHostPinnedIn(content, host) {
54
55
  export function isHostPinned(host, file = managedKnownHostsPath()) {
55
56
  return isHostPinnedIn(readManagedKnownHosts(file), host);
56
57
  }
58
+ /**
59
+ * True if a DEVICE's host key is pinned — checked against the SAME host string
60
+ * the ssh connection dials (`hostNameFor(device)`: the Tailscale dnsName/IP that
61
+ * `sshTargetFor` resolves), falling back to the bare device name.
62
+ *
63
+ * A device discovered over the tailnet is pinned under its FQDN
64
+ * (`yosemite-m6.tail….ts.net`), so a caller that checks the bare `device.name`
65
+ * misses the pin and wrongly treats a reachable, pinned peer as unpinned —
66
+ * silently excluding it from usage/auth fan-out while ssh to it would have
67
+ * succeeded (PHNX-3505). Accepting the bare name too keeps a device pinned only
68
+ * under its short name working. `isPinned` is injectable for tests; it defaults
69
+ * to the managed on-disk store.
70
+ */
71
+ export function isDevicePinned(device, isPinned = (host) => isHostPinned(host)) {
72
+ const host = device.address ? hostNameFor(device) : undefined;
73
+ return (host != null && isPinned(host)) || isPinned(device.name);
74
+ }
57
75
  /**
58
76
  * The host-key-checking ssh options for a connection.
59
77
  *
@@ -17,7 +17,7 @@ import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle } from './bundles.js';
17
17
  import { pushBundleToHost } from './push.js';
18
18
  import { loadDevicesSync } from '../devices/registry.js';
19
19
  import { sshTargetFor } from '../devices/connect.js';
20
- import { isHostPinned, managedKnownHostsPath } from '../devices/known-hosts.js';
20
+ import { isHostPinned, isDevicePinned, managedKnownHostsPath } from '../devices/known-hosts.js';
21
21
  import { machineId, normalizeHost } from '../session/sync/config.js';
22
22
  import { probeDevice } from '../fleet/apply.js';
23
23
  export function planAuthBundlePush(localAuthOk, devices) {
@@ -81,9 +81,9 @@ export function syncReservedAuthBundle(deps = {}) {
81
81
  // No live probe until we know a push is even possible: missing local auth
82
82
  // skips every device, and an unpinned host is refused before we SSH.
83
83
  if (!localOk) {
84
- return { name: d.name, reachable: true, pinned: pinned(d.name), remoteHasAuth: false };
84
+ return { name: d.name, reachable: true, pinned: isDevicePinned(d, pinned), remoteHasAuth: false };
85
85
  }
86
- const isPinnedDev = pinned(d.name);
86
+ const isPinnedDev = isDevicePinned(d, pinned);
87
87
  if (!isPinnedDev) {
88
88
  return { name: d.name, reachable: true, pinned: false, remoteHasAuth: false };
89
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.65",
3
+ "version": "1.22.66",
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",