@phnx-labs/agents-cli 1.22.73 → 1.22.75
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 +67 -0
- package/README.md +1 -1
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +26 -7
- package/dist/commands/sessions.d.ts +50 -1
- package/dist/commands/sessions.js +79 -5
- package/dist/lib/account-registry.js +27 -6
- package/dist/lib/cloud/rush.d.ts +1 -1
- package/dist/lib/cloud/rush.js +2 -2
- package/dist/lib/computer/computer-rpc.d.ts +6 -1
- package/dist/lib/computer/computer-rpc.js +23 -3
- package/dist/lib/computer/des.d.ts +1 -0
- package/dist/lib/computer/des.js +114 -0
- package/dist/lib/computer/rfb-client.d.ts +53 -0
- package/dist/lib/computer/rfb-client.js +562 -0
- package/dist/lib/daemon/auth-sync-service.js +3 -0
- package/dist/lib/daemon/daemon.js +9 -10
- package/dist/lib/daemon/usage-sync-service.js +3 -0
- package/dist/lib/daemon-webhooks.js +15 -2
- package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
- package/dist/lib/fleet-shared-repo-sync.js +101 -4
- package/dist/lib/project-resources.d.ts +6 -2
- package/dist/lib/project-resources.js +133 -44
- package/dist/lib/rush-session.d.ts +10 -2
- package/dist/lib/rush-session.js +12 -3
- package/dist/lib/secrets/drivers/rush.js +1 -1
- package/dist/lib/session/cloud.js +1 -1
- package/dist/lib/session/live-metadata.d.ts +24 -0
- package/dist/lib/session/live-metadata.js +55 -0
- package/dist/lib/session/remote/remote-list.d.ts +2 -0
- package/dist/lib/session/remote/remote-list.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.75
|
|
4
|
+
|
|
5
|
+
- **Account labels are per-harness, and survive a daemon publish (PHNX-3887).** One
|
|
6
|
+
identity signed into several harnesses can now carry the SAME label on each —
|
|
7
|
+
`claude#icloud`, `codex#icloud`, and `grok#icloud` all resolve to that harness's own
|
|
8
|
+
login, instead of the first-labelled harness squatting the name and forcing prefixes
|
|
9
|
+
(`cxicloud`, `gkicloud`) on the rest. Uniqueness is scoped to `(agent, label)`;
|
|
10
|
+
provider accounts, selected by bare name via `--account`, stay globally unique, and a
|
|
11
|
+
duplicate label within one harness is still refused. Separately, `agents accounts
|
|
12
|
+
label` wrote version-scoped rows into the central `agents.yaml` as a plain file write
|
|
13
|
+
with no commit, so the daemon's `publish <device> daemon state` tick — which committed
|
|
14
|
+
only the per-device doc and then rebased `--autostash` over the dirty central file —
|
|
15
|
+
silently destroyed them: every box lost its account labels on its next publish. The
|
|
16
|
+
publish now commits `agents.yaml` alongside the device doc, so labels persist and
|
|
17
|
+
actually sync fleet-wide as `accounts label --help` already promised. Source:
|
|
18
|
+
`cli/src/lib/account-registry.ts`, `cli/src/lib/fleet-shared-repo-sync.ts`.
|
|
19
|
+
|
|
20
|
+
- **Keep one daemon alive across concurrent stop/start (PHNX-3941).** Prevent a replacement daemon from exiting while stop holds the lifecycle lock, so concurrent stop/start cannot leave zero instances, and extend real-boot supervisor-wiring coverage to all 20 services. Source: `cli/src/lib/daemon/daemon.ts`.
|
|
21
|
+
|
|
22
|
+
- **`agents computer` can now drive a Linux GUI desktop over VNC/RFB (PHNX-3948).**
|
|
23
|
+
A third computer backend joins the macOS (Accessibility) and Windows (UIA) native
|
|
24
|
+
helpers — this one speaks the RFB/VNC protocol directly to an x11vnc/Xvnc server, so
|
|
25
|
+
a headless Linux desktop (or an LXD container exposing x11vnc on the host's Tailscale
|
|
26
|
+
IP) becomes drivable with no per-box native helper install. Select it with
|
|
27
|
+
`agents computer --vnc <host:port>` (port defaults to 5901; password via
|
|
28
|
+
`--vnc-password` or `COMPUTER_HELPER_VNC_PASSWORD`), then use the same verbs as the
|
|
29
|
+
other backends: `screenshot`, `click`, `right-click`, `type`, `type-text`, `key`,
|
|
30
|
+
`scroll`, `wait`. It is coordinate-based — RFB carries a framebuffer and pointer/key
|
|
31
|
+
events, not an accessibility tree — so the AX-tree verbs (`describe`, `get-text`,
|
|
32
|
+
`ax-action`, element-id targeting) and `launch` fail loud with a clear message rather
|
|
33
|
+
than silently no-op'ing. VNC authentication uses a bundled pure-JS DES (Node 24's
|
|
34
|
+
OpenSSL 3 keeps DES in the off-by-default legacy provider). Source:
|
|
35
|
+
`cli/src/lib/computer/rfb-client.ts`, `cli/src/lib/computer/des.ts`,
|
|
36
|
+
`cli/src/lib/computer/computer-rpc.ts`, `cli/src/commands/computer.ts`.
|
|
37
|
+
|
|
38
|
+
## 1.22.74
|
|
39
|
+
|
|
40
|
+
- **Launching an agent no longer dirties your tracked `.gitignore` (PHNX-3718).**
|
|
41
|
+
The per-harness resource sync self-manages ignore rules for the generated
|
|
42
|
+
`.claude/`, `.cursor/`, `.codex/`, … dirs it writes on every launch. Those
|
|
43
|
+
rules used to be written into the tracked `<project>/.gitignore`, but they are
|
|
44
|
+
never committed upstream — so every launch left the repo with a permanent
|
|
45
|
+
`M .gitignore` (one managed block per harness that had run there) that in turn
|
|
46
|
+
blocked `git pull` with *"local changes to .gitignore would be overwritten by
|
|
47
|
+
merge"*. The managed block now lives in `.git/info/exclude` (git's per-clone,
|
|
48
|
+
uncommitted ignore file, resolved via `git rev-parse --git-path info/exclude`
|
|
49
|
+
so it works from a subdir, linked worktree, or submodule), keeping the
|
|
50
|
+
generated dirs hidden while leaving the working tree clean. On the next launch,
|
|
51
|
+
a repo already dirtied by the old behavior self-heals: the leftover managed
|
|
52
|
+
block is stripped from the tracked `.gitignore` (hand-written rules untouched;
|
|
53
|
+
an untracked file that held only our block is removed). Source:
|
|
54
|
+
`cli/src/lib/project-resources.ts`.
|
|
55
|
+
|
|
56
|
+
- **`sessions preview` of a remote session no longer dead-ends on the dispatcher
|
|
57
|
+
(PHNX-3890).** A box that launched a session running on another device kept a
|
|
58
|
+
transcript-less live row for it whose machine defaulted to itself, so
|
|
59
|
+
`agents sessions preview <full-uuid>` — and the interactive browser's preview
|
|
60
|
+
pane — rendered `Live session — full transcript not indexed here.` instead of
|
|
61
|
+
fetching the owning peer's digest. A passive peer rendered the same session
|
|
62
|
+
fine. The launcher shim is now reconciled against the fleet's own view of which
|
|
63
|
+
device runs the session, and a full-UUID hit only skips the fleet fan-out when
|
|
64
|
+
this box can actually answer for it (a transcript on disk, or a row that already
|
|
65
|
+
names another device). Remote previews fill in asynchronously over SSH; a
|
|
66
|
+
locally readable transcript or synced mirror still renders with no hop, and an
|
|
67
|
+
unreachable owner still fails loud. Source: `cli/src/commands/sessions.ts`,
|
|
68
|
+
`cli/src/lib/session/live-metadata.ts`.
|
|
69
|
+
|
|
3
70
|
## 1.22.73
|
|
4
71
|
|
|
5
72
|
- **Ctrl-C works again while `agents sessions` is reaching other machines (PHNX-3791).** The static session list, `Searching the fleet…`, and the cloud session/fetch paths wrap their long SSH/network waits in `ora(...)`. ora defaults to `discardStdin: true`, which on a TTY flips stdin into raw mode for the spinner's lifetime (via `stdin-discarder`) — and raw mode disables the terminal's own ISIG, so a Ctrl-C keystroke no longer raises SIGINT. ora's compensating "re-emit SIGINT when I read a 0x03 byte" only fires when stdin is in *flowing* mode, but a fresh `process.stdin` has `flowing === null`, `isPaused()` returns false, the discarder skips its `resume()`, and `prependListener('data')` doesn't auto-resume — so the byte is never read and SIGINT is never raised. The global handler in `index.ts` (`process.exit(130)`) was correct; it simply never fired. The user-visible result: a sweep that stalls on offline/unreachable boxes (each burning a ~12s SSH `ConnectTimeout`) could not be aborted — measured, `agents sessions --flat` had to be SIGKILLed. New `lib/spinner.ts#interruptibleSpinner` is a drop-in for `ora(text)` built with `discardStdin: false`: the terminal stays in cooked mode, Ctrl-C raises SIGINT normally (and the terminal SIGINTs the whole foreground group, reaping the outstanding `ssh` children), and the process exits promptly — only cosmetic stray-keystroke discarding during the spin is lost. The five cross-machine/network spinners in `sessions.ts` route through it; short local spinners are unchanged. Source: `cli/src/lib/spinner.ts`, `cli/src/lib/spinner.test.ts`, `cli/src/commands/sessions.ts`.
|
package/README.md
CHANGED
|
@@ -1756,7 +1756,7 @@ Agents are defined in [src/lib/agents.ts](cli/src/lib/agents.ts) -- each is a co
|
|
|
1756
1756
|
|---|---|
|
|
1757
1757
|
| [`cli`](cli) | **The CLI** (this README) — version management, config sync, sessions, teams, cloud, browser, computer, secrets. |
|
|
1758
1758
|
| [phnx-labs/agi-ext](https://github.com/phnx-labs/agi-ext) | **AGI EXT** — the VS Code extension (agent terminals as tabs + the Fleet dashboard). Moved to its own repo (RUSH-3189); separate product, own publish identity. |
|
|
1759
|
-
| [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). |
|
|
1759
|
+
| [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). A third, install-free backend drives a **Linux GUI desktop over VNC/RFB**: `agents computer --vnc <host:port> screenshot` (coordinate-based — screenshot/click/type/key/scroll against any x11vnc/Xvnc server). |
|
|
1760
1760
|
| [`packages/session-tracker`](packages/session-tracker) | The `SessionStart` hook that writes live-session state **AGI EXT** reads back (agi-ext `src/core/liveSession.ts`) — not the CLI, which reads transcripts. |
|
|
1761
1761
|
|
|
1762
1762
|
## Contributing
|
|
@@ -10,6 +10,7 @@ import { resolveHelperExec, resolveSocketPath } from '../lib/computer/computer-r
|
|
|
10
10
|
export declare function shouldBlockOffPlatform(opts: {
|
|
11
11
|
platform: NodeJS.Platform;
|
|
12
12
|
tcpConfigured: boolean;
|
|
13
|
+
vncConfigured?: boolean;
|
|
13
14
|
device?: string;
|
|
14
15
|
}): boolean;
|
|
15
16
|
/**
|
|
@@ -3,7 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { registerCommandGroups } from '../lib/help.js';
|
|
6
|
-
import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
|
|
6
|
+
import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, resolveVncEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
|
|
7
7
|
import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/computer/ssh-tunnel.js';
|
|
8
8
|
import { sshExec } from '../lib/ssh-exec.js';
|
|
9
9
|
import { encodePowershell } from '../lib/hosts/remote-cmd.js';
|
|
@@ -41,6 +41,8 @@ export function shouldBlockOffPlatform(opts) {
|
|
|
41
41
|
return false;
|
|
42
42
|
if (opts.tcpConfigured)
|
|
43
43
|
return false; // remote (Windows) daemon over a tunnel
|
|
44
|
+
if (opts.vncConfigured)
|
|
45
|
+
return false; // RFB/VNC desktop (Linux GUI over the wire)
|
|
44
46
|
if (opts.device)
|
|
45
47
|
return false; // remote path resolves its own endpoint
|
|
46
48
|
return true;
|
|
@@ -84,20 +86,37 @@ export function reconcileScreenshotExt(outPath, buf) {
|
|
|
84
86
|
export function registerComputerCommand(program) {
|
|
85
87
|
const computer = program
|
|
86
88
|
.command('computer')
|
|
87
|
-
.description('Drive macOS apps via Accessibility, or a remote Windows device with --device —
|
|
89
|
+
.description('Drive macOS apps via Accessibility, a Linux GUI desktop with --vnc, or a remote Windows device with --device — screenshot, click, type')
|
|
90
|
+
// A VNC/RFB desktop is driven over the wire (--vnc host:port, e.g. an x11vnc
|
|
91
|
+
// server on a headless Linux box or an LXD container). Set it before the gate.
|
|
92
|
+
.option('--vnc <host:port>', 'Drive a GUI desktop over VNC/RFB (x11vnc/Xvnc; port defaults to 5901) instead of a native helper')
|
|
93
|
+
.option('--vnc-password <password>', 'VNC password for --vnc (or set COMPUTER_HELPER_VNC_PASSWORD)')
|
|
88
94
|
// The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
|
|
89
|
-
// macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set,
|
|
90
|
-
// a `--device <name>` invocation). Fail fast with a clear
|
|
91
|
-
//
|
|
95
|
+
// macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, a
|
|
96
|
+
// --vnc desktop, or a `--device <name>` invocation). Fail fast with a clear
|
|
97
|
+
// message only when no remote path is available, instead of a downstream error.
|
|
92
98
|
.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
93
|
-
const
|
|
99
|
+
const globals = actionCommand.optsWithGlobals();
|
|
100
|
+
// --vnc selects the RFB transport for every verb under this command.
|
|
101
|
+
if (globals.vnc) {
|
|
102
|
+
process.env.COMPUTER_HELPER_VNC = globals.vnc;
|
|
103
|
+
if (globals.vncPassword)
|
|
104
|
+
process.env.COMPUTER_HELPER_VNC_PASSWORD = globals.vncPassword;
|
|
105
|
+
}
|
|
106
|
+
const device = globals.device;
|
|
94
107
|
// Verbs with --device reconnect to the tunnel `start --device` recorded;
|
|
95
108
|
// this sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
|
|
96
109
|
if (device && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
|
|
97
110
|
hydrateRemoteEnvFromState(device);
|
|
98
111
|
}
|
|
99
|
-
if (shouldBlockOffPlatform({
|
|
112
|
+
if (shouldBlockOffPlatform({
|
|
113
|
+
platform: process.platform,
|
|
114
|
+
tcpConfigured: resolveTcpEndpoint() != null,
|
|
115
|
+
vncConfigured: resolveVncEndpoint() != null,
|
|
116
|
+
device,
|
|
117
|
+
})) {
|
|
100
118
|
console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
|
|
119
|
+
console.error('For a Linux GUI desktop over VNC: `agents computer --vnc <host:port> screenshot`.');
|
|
101
120
|
console.error('For a remote Windows device: register it with `agents devices`, then use --device (or set COMPUTER_HELPER_TCP).');
|
|
102
121
|
process.exit(1);
|
|
103
122
|
}
|
|
@@ -896,9 +896,58 @@ export declare function metadataResolveOutcome(localMatches: SessionMeta[], remo
|
|
|
896
896
|
sessions: SessionMeta[];
|
|
897
897
|
unreachable: string[];
|
|
898
898
|
}, selector: string): MetadataResolveOutcome;
|
|
899
|
-
/**
|
|
899
|
+
/**
|
|
900
|
+
* Whether a local candidate answers a full-UUID selector **definitively** — i.e.
|
|
901
|
+
* whether finding it on this box is the whole answer, so the fleet fan-out can be
|
|
902
|
+
* skipped (PHNX-3890).
|
|
903
|
+
*
|
|
904
|
+
* Two shapes qualify, and both are claims this box can actually back:
|
|
905
|
+
*
|
|
906
|
+
* - **A real transcript on this disk** (`filePath`). It renders locally, whoever
|
|
907
|
+
* owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
|
|
908
|
+
* - **A genuine peer attribution** (`machine` names another box). The row already
|
|
909
|
+
* routes the read to its owner through `transcriptOnPeerOf`, so the peer that
|
|
910
|
+
* would answer the fan-out is the peer the render already dials.
|
|
911
|
+
*
|
|
912
|
+
* What does NOT qualify is the launcher-shim shape: a transcript-less live row
|
|
913
|
+
* whose `machine` is this box only because it DEFAULTED there
|
|
914
|
+
* (`activeSessionToSessionMeta`'s `active.machine ?? self`,
|
|
915
|
+
* `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
|
|
916
|
+
* exactly that row for a session whose agent and transcript live on a peer: the
|
|
917
|
+
* launch process is here, the conversation is not. Treating it as definitive is
|
|
918
|
+
* what dead-ended `agents sessions preview <full-uuid>` on the local
|
|
919
|
+
* "Live session — full transcript not indexed here." stub while a passive peer —
|
|
920
|
+
* which has no local row at all, so it fans out — rendered the real digest.
|
|
921
|
+
* Process locality is not transcript locality.
|
|
922
|
+
*/
|
|
923
|
+
export declare function isLocallyDefinitiveMatch(session: SessionMeta, self: string): boolean;
|
|
924
|
+
/**
|
|
925
|
+
* Drop the launcher-shim local rows that a peer has since answered for
|
|
926
|
+
* (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
|
|
927
|
+
* machine and every consumer reads `hits[0]`, with local rows passed first — so
|
|
928
|
+
* simply fanning out is not enough: the self-defaulted shim would still win the
|
|
929
|
+
* attribution and route the read back to a box with no transcript. When the peer
|
|
930
|
+
* that actually owns the session has answered the sweep, its row is the strictly
|
|
931
|
+
* better one, and the shim carries no information the candidate loses (same
|
|
932
|
+
* logical id, so the candidate — and any ambiguity it is part of — survives
|
|
933
|
+
* through the remote hit).
|
|
934
|
+
*
|
|
935
|
+
* A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
|
|
936
|
+
* readable transcript or a synced mirror still renders here with no SSH hop. When
|
|
937
|
+
* no peer answered for that id, the shim is kept: with no fleet evidence, a
|
|
938
|
+
* transcript-less self-attributed row is indistinguishable from a session THIS box
|
|
939
|
+
* just started, and dropping it would re-break the cold-index lookup RUSH-2682
|
|
940
|
+
* fixed.
|
|
941
|
+
*/
|
|
942
|
+
export declare function preferOwnerAttribution(localMatches: SessionMeta[], remoteSessions: SessionMeta[], self: string): SessionMeta[];
|
|
943
|
+
/** Injectable dependencies for the live-registry cold-miss fallback (RUSH-2682)
|
|
944
|
+
* and the fleet-attribution reconciliation (PHNX-3890). */
|
|
900
945
|
export type LiveMetadataDeps = {
|
|
901
946
|
loadActive?: typeof loadLocalActiveSessions;
|
|
947
|
+
/** The fleet-active snapshot rows used to attribute a self-defaulted launcher
|
|
948
|
+
* shim to its true execution host. Defaults to the cached fleet snapshot; the
|
|
949
|
+
* cache is warmed by any fleet-wide `agents sessions --active`. */
|
|
950
|
+
loadFleetActive?: () => ActiveSession[];
|
|
902
951
|
};
|
|
903
952
|
/**
|
|
904
953
|
* Local metadata candidates for a selector: the indexed SQLite rows, plus — when
|
|
@@ -29,14 +29,14 @@ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
|
|
|
29
29
|
import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
|
|
30
30
|
import { machineId, normalizeHost } from '../lib/session/sync/config.js';
|
|
31
31
|
import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
|
|
32
|
-
import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
|
|
32
|
+
import { loadFleetActiveSessions, loadLocalActiveSessions, readActiveSessionsCache, } from '../lib/session/session-cache.js';
|
|
33
33
|
import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
|
|
34
34
|
import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
|
|
35
35
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
36
36
|
import { inferSessionState } from '../lib/session/state.js';
|
|
37
37
|
import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
|
|
38
38
|
import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
|
|
39
|
-
import { liveSessionMetas } from '../lib/session/live-metadata.js';
|
|
39
|
+
import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
|
|
40
40
|
import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
|
|
41
41
|
import { parseSession } from '../lib/session/parse.js';
|
|
42
42
|
import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
|
|
@@ -4844,6 +4844,59 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
|
|
|
4844
4844
|
return { kind: 'ambiguous', candidates };
|
|
4845
4845
|
return { kind: 'resolved', session: candidates[0].hits[0].session };
|
|
4846
4846
|
}
|
|
4847
|
+
/**
|
|
4848
|
+
* Whether a local candidate answers a full-UUID selector **definitively** — i.e.
|
|
4849
|
+
* whether finding it on this box is the whole answer, so the fleet fan-out can be
|
|
4850
|
+
* skipped (PHNX-3890).
|
|
4851
|
+
*
|
|
4852
|
+
* Two shapes qualify, and both are claims this box can actually back:
|
|
4853
|
+
*
|
|
4854
|
+
* - **A real transcript on this disk** (`filePath`). It renders locally, whoever
|
|
4855
|
+
* owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
|
|
4856
|
+
* - **A genuine peer attribution** (`machine` names another box). The row already
|
|
4857
|
+
* routes the read to its owner through `transcriptOnPeerOf`, so the peer that
|
|
4858
|
+
* would answer the fan-out is the peer the render already dials.
|
|
4859
|
+
*
|
|
4860
|
+
* What does NOT qualify is the launcher-shim shape: a transcript-less live row
|
|
4861
|
+
* whose `machine` is this box only because it DEFAULTED there
|
|
4862
|
+
* (`activeSessionToSessionMeta`'s `active.machine ?? self`,
|
|
4863
|
+
* `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
|
|
4864
|
+
* exactly that row for a session whose agent and transcript live on a peer: the
|
|
4865
|
+
* launch process is here, the conversation is not. Treating it as definitive is
|
|
4866
|
+
* what dead-ended `agents sessions preview <full-uuid>` on the local
|
|
4867
|
+
* "Live session — full transcript not indexed here." stub while a passive peer —
|
|
4868
|
+
* which has no local row at all, so it fans out — rendered the real digest.
|
|
4869
|
+
* Process locality is not transcript locality.
|
|
4870
|
+
*/
|
|
4871
|
+
export function isLocallyDefinitiveMatch(session, self) {
|
|
4872
|
+
if (session.filePath)
|
|
4873
|
+
return true;
|
|
4874
|
+
return !!session.machine && session.machine !== self;
|
|
4875
|
+
}
|
|
4876
|
+
/**
|
|
4877
|
+
* Drop the launcher-shim local rows that a peer has since answered for
|
|
4878
|
+
* (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
|
|
4879
|
+
* machine and every consumer reads `hits[0]`, with local rows passed first — so
|
|
4880
|
+
* simply fanning out is not enough: the self-defaulted shim would still win the
|
|
4881
|
+
* attribution and route the read back to a box with no transcript. When the peer
|
|
4882
|
+
* that actually owns the session has answered the sweep, its row is the strictly
|
|
4883
|
+
* better one, and the shim carries no information the candidate loses (same
|
|
4884
|
+
* logical id, so the candidate — and any ambiguity it is part of — survives
|
|
4885
|
+
* through the remote hit).
|
|
4886
|
+
*
|
|
4887
|
+
* A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
|
|
4888
|
+
* readable transcript or a synced mirror still renders here with no SSH hop. When
|
|
4889
|
+
* no peer answered for that id, the shim is kept: with no fleet evidence, a
|
|
4890
|
+
* transcript-less self-attributed row is indistinguishable from a session THIS box
|
|
4891
|
+
* just started, and dropping it would re-break the cold-index lookup RUSH-2682
|
|
4892
|
+
* fixed.
|
|
4893
|
+
*/
|
|
4894
|
+
export function preferOwnerAttribution(localMatches, remoteSessions, self) {
|
|
4895
|
+
if (remoteSessions.length === 0)
|
|
4896
|
+
return localMatches;
|
|
4897
|
+
const answeredByPeer = new Set(remoteSessions.map(session => session.id.toLowerCase()));
|
|
4898
|
+
return localMatches.filter(session => isLocallyDefinitiveMatch(session, self) || !answeredByPeer.has(session.id.toLowerCase()));
|
|
4899
|
+
}
|
|
4847
4900
|
/**
|
|
4848
4901
|
* Local metadata candidates for a selector: the indexed SQLite rows, plus — when
|
|
4849
4902
|
* an id-shaped selector misses the index entirely — the live-session registry
|
|
@@ -4881,7 +4934,20 @@ export async function computeLocalMetadataMatches(selector, scope, deps = {}) {
|
|
|
4881
4934
|
*/
|
|
4882
4935
|
export async function liveMetadataMatches(selector, scope, self, deps = {}) {
|
|
4883
4936
|
const load = deps.loadActive ?? loadLocalActiveSessions;
|
|
4884
|
-
const
|
|
4937
|
+
const loadFleet = deps.loadFleetActive ?? (() => readActiveSessionsCache('fleet')?.sessions ?? []);
|
|
4938
|
+
// A launcher-shim row whose `machine` self-defaulted to this box gets its true
|
|
4939
|
+
// EXECUTION host from the fleet snapshot, so a session dispatched to a peer is
|
|
4940
|
+
// read on that peer instead of dead-ending on the local stub (PHNX-3890). The
|
|
4941
|
+
// read is best-effort: an absent/cold snapshot leaves the row untouched and the
|
|
4942
|
+
// fleet fan-out in resolveSessionMetadataValue supplies the owner instead.
|
|
4943
|
+
let fleetExecMachine;
|
|
4944
|
+
try {
|
|
4945
|
+
fleetExecMachine = fleetExecutionMachineById(loadFleet());
|
|
4946
|
+
}
|
|
4947
|
+
catch {
|
|
4948
|
+
fleetExecMachine = new Map();
|
|
4949
|
+
}
|
|
4950
|
+
const match = (metas) => resolveIndexedMetadataRows(applyScopeFilters(reconcileLiveMetaMachine(metas, fleetExecMachine, self), scope), selector, scope);
|
|
4885
4951
|
try {
|
|
4886
4952
|
const cached = liveSessionMetas((await load()).sessions, self, Date.now());
|
|
4887
4953
|
const hit = match(cached);
|
|
@@ -4898,16 +4964,24 @@ export async function liveMetadataMatches(selector, scope, self, deps = {}) {
|
|
|
4898
4964
|
* Full UUIDs hit the local SQLite index without any SSH fan-out. */
|
|
4899
4965
|
export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
|
|
4900
4966
|
const localMatches = await computeLocalMetadataMatches(selector, scope, deps);
|
|
4967
|
+
const localMachine = machineId();
|
|
4901
4968
|
// A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
|
|
4902
4969
|
// finding it locally is the whole answer and no peer is dialed. (A label is
|
|
4903
4970
|
// NOT resolved local-only here — it can collide with a same-label session on a
|
|
4904
4971
|
// peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
|
|
4905
4972
|
// local index lookup is synchronous and completes before any fan-out spawns,
|
|
4906
4973
|
// so this local hit never waits on a peer.
|
|
4974
|
+
//
|
|
4975
|
+
// "Found it locally" must mean this box can actually ANSWER for it, though —
|
|
4976
|
+
// see isLocallyDefinitiveMatch. A transcript-less launcher shim for a session
|
|
4977
|
+
// executing on a peer is a local row that is not a local answer, and
|
|
4978
|
+
// short-circuiting on it skipped the one fan-out that could have named the
|
|
4979
|
+
// owner (PHNX-3890).
|
|
4907
4980
|
if (FULL_SESSION_ID_RE.test(selector)) {
|
|
4908
4981
|
const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
|
|
4909
|
-
if (localOutcome.kind === 'resolved')
|
|
4982
|
+
if (localOutcome.kind === 'resolved' && isLocallyDefinitiveMatch(localOutcome.session, localMachine)) {
|
|
4910
4983
|
return localOutcome;
|
|
4984
|
+
}
|
|
4911
4985
|
}
|
|
4912
4986
|
if (scope.local === true)
|
|
4913
4987
|
return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
|
|
@@ -4920,7 +4994,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
|
|
|
4920
4994
|
const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
|
|
4921
4995
|
? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
|
|
4922
4996
|
: undefined);
|
|
4923
|
-
return metadataResolveOutcome(localMatches, remote, selector);
|
|
4997
|
+
return metadataResolveOutcome(preferOwnerAttribution(localMatches, remote.sessions, localMachine), remote, selector);
|
|
4924
4998
|
}
|
|
4925
4999
|
catch (error) {
|
|
4926
5000
|
return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] }, selector);
|
|
@@ -187,11 +187,32 @@ function nativeRowsForNameOrId(meta, name) {
|
|
|
187
187
|
return [];
|
|
188
188
|
return nativeIdentityRows(meta, found.agent, found.identityKey);
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Native label names are unique per HARNESS, not globally (PHNX-3887).
|
|
192
|
+
*
|
|
193
|
+
* One human identity is commonly signed into several harnesses —
|
|
194
|
+
* `muqsitnawaz@icloud.com` is a claude login AND a codex login AND a grok login.
|
|
195
|
+
* A global namespace let whichever harness was labelled first squat the good
|
|
196
|
+
* name, forcing prefixed junk (`cxicloud`, `gkicloud`) on the rest. Nothing is
|
|
197
|
+
* actually ambiguous at the point of use: the selector is `<harness>#<label>`,
|
|
198
|
+
* and `findUnifiedAccount` already disambiguates via `preferAgent`.
|
|
199
|
+
*
|
|
200
|
+
* Pass `agent` to scope the check to that harness. Omit it for management
|
|
201
|
+
* lookups with no harness in hand (rename/remove), which keep the old
|
|
202
|
+
* fleet-wide check so a rename cannot collide with an unrelated harness's row.
|
|
203
|
+
*
|
|
204
|
+
* Provider (non-native) accounts stay globally unique — they are selected by
|
|
205
|
+
* bare name via `--account`, with no harness to scope them by.
|
|
206
|
+
*/
|
|
207
|
+
function assertUniqueUnifiedName(name, meta, doc, exceptIds, agent) {
|
|
191
208
|
const needle = name.toLowerCase();
|
|
192
|
-
const nativeHits = listNativeAccounts(meta).filter(account =>
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
const nativeHits = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent)
|
|
210
|
+
&& (account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle));
|
|
211
|
+
if (nativeHits.some(account => !exceptIds?.has(account.id))) {
|
|
212
|
+
throw new Error(agent === undefined
|
|
213
|
+
? `Account '${name}' already exists.`
|
|
214
|
+
: `Account '${name}' already exists for the ${agent} harness.`);
|
|
215
|
+
}
|
|
195
216
|
// Same laziness as findUnifiedAccount: a native row that already owns this
|
|
196
217
|
// name (even one we are mutating) means we never open the provider store.
|
|
197
218
|
if (nativeHits.length > 0)
|
|
@@ -203,7 +224,7 @@ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
|
|
|
203
224
|
export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
|
|
204
225
|
assertNativeLabel(name);
|
|
205
226
|
const meta = readMeta();
|
|
206
|
-
assertUniqueUnifiedName(name, meta);
|
|
227
|
+
assertUniqueUnifiedName(name, meta, undefined, undefined, agent);
|
|
207
228
|
const duplicate = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
|
|
208
229
|
if (duplicate)
|
|
209
230
|
throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
|
|
@@ -240,7 +261,7 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
|
|
|
240
261
|
assertNativeLabel(resolvedLabel);
|
|
241
262
|
const meta = readMeta();
|
|
242
263
|
const matches = nativeIdentityRows(meta, agent, identityKey);
|
|
243
|
-
assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
|
|
264
|
+
assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)), agent);
|
|
244
265
|
if (matches.length === 0)
|
|
245
266
|
return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
|
|
246
267
|
// Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
|
|
8
8
|
/**
|
|
9
9
|
* Returns true when ~/.rush/user.yaml exists, carries an access_token, and
|
|
10
|
-
* the token has not passed its expires_at timestamp (Unix
|
|
10
|
+
* the token has not passed its expires_at timestamp (Unix milliseconds). A missing
|
|
11
11
|
* expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
|
|
12
12
|
* treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
|
|
13
13
|
* to override the default path in tests.
|
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -19,7 +19,7 @@ const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
|
|
|
19
19
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
20
20
|
/**
|
|
21
21
|
* Returns true when ~/.rush/user.yaml exists, carries an access_token, and
|
|
22
|
-
* the token has not passed its expires_at timestamp (Unix
|
|
22
|
+
* the token has not passed its expires_at timestamp (Unix milliseconds). A missing
|
|
23
23
|
* expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
|
|
24
24
|
* treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
|
|
25
25
|
* to override the default path in tests.
|
|
@@ -58,7 +58,7 @@ export function readToken(yamlPath = USER_YAML) {
|
|
|
58
58
|
}
|
|
59
59
|
const expiresAt = data.session?.expires_at;
|
|
60
60
|
if (isRushSessionExpired(expiresAt)) {
|
|
61
|
-
const expiredAt = new Date(expiresAt
|
|
61
|
+
const expiredAt = new Date(expiresAt).toISOString();
|
|
62
62
|
throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
|
|
63
63
|
}
|
|
64
64
|
return token;
|
|
@@ -25,10 +25,15 @@ export declare function resolveTcpEndpoint(): {
|
|
|
25
25
|
port: number;
|
|
26
26
|
token: string | null;
|
|
27
27
|
} | null;
|
|
28
|
+
export declare function resolveVncEndpoint(): {
|
|
29
|
+
host: string;
|
|
30
|
+
port: number;
|
|
31
|
+
password: string;
|
|
32
|
+
} | null;
|
|
28
33
|
export declare function openComputerClient(): ComputerClient;
|
|
29
34
|
export declare const RPC_TIMEOUT_MS = 30000;
|
|
30
35
|
export declare function resolveRpcTimeoutMs(env: string | undefined): number;
|
|
31
36
|
export declare function describeTransport(): {
|
|
32
|
-
kind: 'socket' | 'stdio' | 'tcp' | 'none';
|
|
37
|
+
kind: 'socket' | 'stdio' | 'tcp' | 'vnc' | 'none';
|
|
33
38
|
path: string | null;
|
|
34
39
|
};
|
|
@@ -17,6 +17,7 @@ import * as fs from 'fs';
|
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
19
|
import { getHelpersDir, getLogsDir, getUserPermissionsDir, getPermissionsDir } from '../state.js';
|
|
20
|
+
import { RfbClient, parseVncEndpoint } from './rfb-client.js';
|
|
20
21
|
// Resolve the socket path used by the launchd-managed daemon. Internal
|
|
21
22
|
// scratch — lives under getHelpersDir() (~/.agents/.cache/helpers/),
|
|
22
23
|
// matching browser.sock.
|
|
@@ -209,11 +210,28 @@ export function resolveTcpEndpoint() {
|
|
|
209
210
|
const token = process.env.COMPUTER_HELPER_TOKEN;
|
|
210
211
|
return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
|
|
211
212
|
}
|
|
213
|
+
// Resolve the VNC/RFB endpoint for driving a remote GUI desktop over the RFB
|
|
214
|
+
// protocol (an x11vnc/Xvnc server — e.g. a headless Linux desktop or an LXD
|
|
215
|
+
// container exposing x11vnc on the host's Tailscale IP). COMPUTER_HELPER_VNC is
|
|
216
|
+
// "host:port" (port defaults to 5901); COMPUTER_HELPER_VNC_PASSWORD is the VNC
|
|
217
|
+
// password. Unlike the native helpers this transport carries a framebuffer, not
|
|
218
|
+
// an accessibility tree, so it is coordinate-based (see rfb-client.ts).
|
|
219
|
+
export function resolveVncEndpoint() {
|
|
220
|
+
const parsed = parseVncEndpoint(process.env.COMPUTER_HELPER_VNC);
|
|
221
|
+
if (!parsed)
|
|
222
|
+
return null;
|
|
223
|
+
return { ...parsed, password: process.env.COMPUTER_HELPER_VNC_PASSWORD ?? '' };
|
|
224
|
+
}
|
|
212
225
|
// Pick the best transport. Precedence:
|
|
213
|
-
// 1.
|
|
214
|
-
// 2. the
|
|
215
|
-
// 3.
|
|
226
|
+
// 1. COMPUTER_HELPER_VNC -> an RFB/VNC desktop (Linux GUI over the wire).
|
|
227
|
+
// 2. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
|
|
228
|
+
// 3. the macOS launchd socket if it exists.
|
|
229
|
+
// 4. spawning the helper as a subprocess (legacy/dev fallback).
|
|
216
230
|
export function openComputerClient() {
|
|
231
|
+
const vnc = resolveVncEndpoint();
|
|
232
|
+
if (vnc) {
|
|
233
|
+
return new RfbClient(vnc.host, vnc.port, vnc.password);
|
|
234
|
+
}
|
|
217
235
|
const tcp = resolveTcpEndpoint();
|
|
218
236
|
if (tcp) {
|
|
219
237
|
return new TcpClient(tcp.host, tcp.port, tcp.token);
|
|
@@ -415,6 +433,8 @@ class StdioClient extends BaseClient {
|
|
|
415
433
|
// .app) must not be told "no transport". `path` is null: the endpoint is an
|
|
416
434
|
// env-configured host:port, not an on-disk path.
|
|
417
435
|
export function describeTransport() {
|
|
436
|
+
if (resolveVncEndpoint())
|
|
437
|
+
return { kind: 'vnc', path: null };
|
|
418
438
|
if (resolveTcpEndpoint())
|
|
419
439
|
return { kind: 'tcp', path: null };
|
|
420
440
|
const sockPath = resolveSocketPath();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function desEncryptBlock(key8: Buffer, block8: Buffer): Buffer;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Minimal, dependency-free single-block DES (ECB) — used only for VNC
|
|
2
|
+
// authentication's challenge/response. Node 24 ships OpenSSL 3, whose DES lives
|
|
3
|
+
// in the legacy provider that is off by default, so `createCipheriv('des-ecb')`
|
|
4
|
+
// throws `digital envelope routines::unsupported`. VNC clients have always
|
|
5
|
+
// bundled their own DES for exactly this reason. This is a textbook FIPS-46 DES
|
|
6
|
+
// implementation over bit arrays — verified against the standard test vector in
|
|
7
|
+
// des.test.ts. It encrypts one 8-byte block; VNC auth encrypts the two halves
|
|
8
|
+
// of the 16-byte challenge independently (see rfb-client.ts).
|
|
9
|
+
const IP = [
|
|
10
|
+
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
|
|
11
|
+
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
|
|
12
|
+
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
|
|
13
|
+
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
|
|
14
|
+
];
|
|
15
|
+
const FP = [
|
|
16
|
+
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
|
|
17
|
+
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
|
|
18
|
+
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
|
|
19
|
+
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
|
|
20
|
+
];
|
|
21
|
+
const E = [
|
|
22
|
+
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17,
|
|
23
|
+
16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
|
|
24
|
+
];
|
|
25
|
+
const P = [
|
|
26
|
+
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
|
|
27
|
+
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
|
|
28
|
+
];
|
|
29
|
+
const PC1 = [
|
|
30
|
+
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
|
|
31
|
+
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
|
|
32
|
+
];
|
|
33
|
+
const PC2 = [
|
|
34
|
+
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
|
|
35
|
+
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
|
|
36
|
+
];
|
|
37
|
+
const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
|
|
38
|
+
const SBOX = [
|
|
39
|
+
[14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
|
|
40
|
+
[15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
|
|
41
|
+
[10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
|
|
42
|
+
[7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
|
|
43
|
+
[2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
|
|
44
|
+
[12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
|
|
45
|
+
[4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
|
|
46
|
+
[13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
|
|
47
|
+
];
|
|
48
|
+
function bytesToBits(buf) {
|
|
49
|
+
const bits = [];
|
|
50
|
+
for (let i = 0; i < buf.length; i++) {
|
|
51
|
+
for (let b = 7; b >= 0; b--)
|
|
52
|
+
bits.push((buf[i] >> b) & 1);
|
|
53
|
+
}
|
|
54
|
+
return bits;
|
|
55
|
+
}
|
|
56
|
+
function bitsToBytes(bits) {
|
|
57
|
+
const out = Buffer.alloc(bits.length / 8);
|
|
58
|
+
for (let i = 0; i < out.length; i++) {
|
|
59
|
+
let byte = 0;
|
|
60
|
+
for (let b = 0; b < 8; b++)
|
|
61
|
+
byte = (byte << 1) | bits[i * 8 + b];
|
|
62
|
+
out[i] = byte;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function permute(bits, table) {
|
|
67
|
+
return table.map((pos) => bits[pos - 1]);
|
|
68
|
+
}
|
|
69
|
+
function keySchedule(key8) {
|
|
70
|
+
const keyBits = bytesToBits(key8);
|
|
71
|
+
const pc1 = permute(keyBits, PC1);
|
|
72
|
+
let c = pc1.slice(0, 28);
|
|
73
|
+
let d = pc1.slice(28, 56);
|
|
74
|
+
const subkeys = [];
|
|
75
|
+
for (let round = 0; round < 16; round++) {
|
|
76
|
+
const s = SHIFTS[round];
|
|
77
|
+
c = c.slice(s).concat(c.slice(0, s));
|
|
78
|
+
d = d.slice(s).concat(d.slice(0, s));
|
|
79
|
+
subkeys.push(permute(c.concat(d), PC2));
|
|
80
|
+
}
|
|
81
|
+
return subkeys;
|
|
82
|
+
}
|
|
83
|
+
function feistel(r, subkey) {
|
|
84
|
+
const expanded = permute(r, E);
|
|
85
|
+
const xored = expanded.map((bit, i) => bit ^ subkey[i]);
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let box = 0; box < 8; box++) {
|
|
88
|
+
const chunk = xored.slice(box * 6, box * 6 + 6);
|
|
89
|
+
const row = (chunk[0] << 1) | chunk[5];
|
|
90
|
+
const col = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4];
|
|
91
|
+
const val = SBOX[box][row * 16 + col];
|
|
92
|
+
for (let b = 3; b >= 0; b--)
|
|
93
|
+
out.push((val >> b) & 1);
|
|
94
|
+
}
|
|
95
|
+
return permute(out, P);
|
|
96
|
+
}
|
|
97
|
+
// Encrypt one 8-byte block with DES-ECB under an 8-byte key.
|
|
98
|
+
export function desEncryptBlock(key8, block8) {
|
|
99
|
+
if (key8.length !== 8)
|
|
100
|
+
throw new Error(`des key must be 8 bytes, got ${key8.length}`);
|
|
101
|
+
if (block8.length !== 8)
|
|
102
|
+
throw new Error(`des block must be 8 bytes, got ${block8.length}`);
|
|
103
|
+
const subkeys = keySchedule(key8);
|
|
104
|
+
const bits = permute(bytesToBits(block8), IP);
|
|
105
|
+
let l = bits.slice(0, 32);
|
|
106
|
+
let r = bits.slice(32, 64);
|
|
107
|
+
for (let round = 0; round < 16; round++) {
|
|
108
|
+
const f = feistel(r, subkeys[round]);
|
|
109
|
+
const next = l.map((bit, i) => bit ^ f[i]);
|
|
110
|
+
l = r;
|
|
111
|
+
r = next;
|
|
112
|
+
}
|
|
113
|
+
return bitsToBytes(permute(r.concat(l), FP));
|
|
114
|
+
}
|