phases 4-6 + 5b: agent core, host CLI, 24 subcommands, SSH-driven bootstrap#2
Conversation
agent/agent.py is a single-file Python 3.4 agent that listens on TCP+TLS 1.2,
verifies every envelope's HMAC, and dispatches tool.invoke calls through a
registered tool table. v0 ships:
* exec tool: spawns a subprocess (cmd / python / python_file shells), opens
stdout+stderr streams, pumps stream.chunk envelopes from per-stream reader
threads, and finishes with tool.result {exit_code, timed_out} + job.completed.
cancel envelope kills the subprocess and emits job.cancelled.
* agent.info tool: returns version/pid/python/uptime metadata.
* session.open handshake intersects client and server-side capabilities;
v0 server: streaming + binary_streams = true, durable_jobs/checkpoints/
agent_handoff = false.
* ping/pong, ack, nack with structured codes.
* HKLM Run-key install/remove/status sub-commands.
* RotatingFileHandler logging at C:\\xpc\\agent.log.
agent/tests/test_agent.py drives Connection.serve() via socketpair to cover
session lifecycle, auth failure, dispatch, and ToolError wrapping.
cmd/xpc-exec is the Go end-to-end client. Real-VM verification at
docs/sessions/phase-4-agent.md: ver, echo, dir C:\\Python34, and a
python-shell os.listdir(r"C:\\\\") all stream correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Core deliverables (per MASTER.md §9):
* internal/profile: AWS-style split storage at ~/.xpc/{config,credentials,state}
(0700 dir, 0600 files). Env-var overrides applied at load time. Round-trip,
list, delete, active-pointer, env-precedence tests all green.
* internal/cli: cobra command tree built around lazy Globals.ResolveProfile().
Subcommands:
- xpc version
- xpc configure (interactive prompt)
- xpc use / xpc profile {list,add,remove,use}; profile add accepts
--psk-hex / --psk-file for importing material from xpc bootstrap.
- xpc migrate-from-xpctl (~/.xpcli/config -> ~/.xpc/{config,creds})
- xpc bootstrap (generates RSA-2048 cert + 32-byte PSK at
~/.xpc/material/<profile>/ and prints
the manual deploy steps; SSH-driven
bootstrap is Phase 5b)
- xpc agent {ping,info} (ping latency / agent.info tool)
- xpc exec [--shell cmd|python|python_file] -- <cmd>
- xpc completion {bash,zsh,fish,powershell}
* internal/cli/session.go centralises TLS dial + session.open + tool.invoke +
stream-chunk pumping. Reused by exec and agent {ping,info}.
* Sentinel errors map to MASTER.md exit codes:
UsageError -> 2, ConnectionError -> 3, AuthError -> 4, RemoteError ->
remote exit code (or 5 if zero).
* cmd/xpc/main.go now delegates to internal/cli.Execute.
Real-VM verification (docs/sessions/phase-5-cli.md):
xpc exec ver
Microsoft Windows XP [Version 5.1.2600]
xpc exec 'dir C:\\Python34'
... 10 dirs + 5 files streamed via stream.chunk ...
xpc agent ping -> pong in 4.4 ms
xpc agent info -> xpc v0.1.0, python 3.4.10, pid + uptime
xpc completion bash, xpc completion zsh -> known-good cobra scripts
xpc migrate-from-xpctl -> reads synthetic ~/.xpcli/config, writes
~/.xpc/{config,credentials}
Deferred to Phase 5b/6:
SSH-driven xpc bootstrap, xpc agent {start,stop,redeploy,install,...},
xpc daemon, internal/output formatters package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dll, cat, head, tail, find, sum, cp, shot A broad slice of the Sysinternals-flat subcommand surface from docs/ARCHITECTURE.md §10. Every command shares the session-open + tool.invoke flow already proven in Phase 5; cmd.exe quoting bugs around backslashes and spaces are sidestepped by routing through python-subprocess argv form when the underlying tool needs structured invocation (reg, file ops, screenshot). * internal/cli/run.go centralises runRemoteCmd so subcommands stay tiny. * internal/cli/session.go's invokeExec is the single point of contact with the agent's exec tool; nothing else here required agent-side changes. * Registry (reg get/set/delete/export) now ships argv to python's subprocess.Popen with shell=False -- no more "Too many command-line parameters" on paths like 'HKLM\Software\Microsoft\Windows NT\CurrentVersion'. * xpc cp does bidirectional inline transfers via base64 (~30 MB cap). Atomic write via .xpc.tmp + os.rename. Drive-letter heuristic infers vm: for paths like 'C:\...' so explicit prefixes are only required for the occasional ambiguous case (e.g. host:/tmp/foo). * xpc shot uses BitBlt + GetDIBits via ctypes, packs a 24-bit BMP, and base64-transfers it to a local file (default ~/.xpc/shots/shot-<utc>.bmp). * xpc ps parses 'tasklist /v /fo csv' into a typed Process slice and emits --output json on demand. Real-VM verification (docs/sessions/phase-6-subcommands.md): xpc info, xpc ps --filter, xpc reg get with spaces, xpc cp host->vm and vm->host, xpc cat, xpc head, xpc sum, xpc shot (3.6 MB BMP, 1280x960). Deferred to follow-up sessions: send, tun, dump, inj, boot, snap, dbg, trace, ghidra, ida, fetch, edit, daemon, agent lifecycle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* xpc fetch <url> [vm:path]: download on the host, then push via cp; default
destination C:\\xpc\\downloads\\<basename derived from URL>. 5-minute fetch
timeout (configurable). Real-VM: pulled 528 bytes from example.com -> VM,
xpc head verified the HTML.
* xpc edit <vm:path>: cp pull -> $EDITOR (or --editor) -> cp push if the
bytes changed. Mirrors xpctl's edit command.
* xpc boot {reboot, shutdown}: shutdown.exe /r|/s /f /t 0 via cmd shell.
pause/resume return a usage error pointing at the Proxmox host TASKS
question. Dry-run verified.
* xpc send keys|click|move: synthetic input via ctypes user32. keys uses
VkKeyScanW + keybd_event; click uses SetCursorPos + mouse_event with
button selection (left/right/middle) and optional double-click; move
is plain SetCursorPos. --title focuses a target window before typing.
* xpc inj <pid> <vm:dll>: classic four-call CreateRemoteThread injection
(OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread on
LoadLibraryA). Adapted from xpctl/assets/scripts/dll_inject.py. Dry-run
verified; live injection waits for a target test DLL on the VM.
* xpc dump <pid> [-o] [--full]: MiniDumpWriteDump via dbghelp.dll. Default
is MiniDumpNormal; --full flips to MiniDumpWithFullMemory. Bytes flow
back base64 and write to ~/.xpc/dumps/pid-<n>-<utc>.dmp by default.
Real-VM: 22.8 KB normal-mode dump of the live xpc agent (pid 1864),
validated by `file` as "Mini DuMP crash report, 7 streams".
All six wrappers ride the python-shell + subprocess pattern -- no agent-
side changes required. The cmd.exe argv-quoting bug from earlier in Phase 6
is sidestepped because each command builds its argv inside a Python source
string (where backslashes are literal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… tail}
xpc bootstrap is no longer "print manual steps." End-to-end on a single
command:
$ xpc bootstrap
Generated bootstrap material ... fingerprint <hex>
Connecting via SSH to <user>@<host> ...
Uploading agent files to C:\xpc\ ...
Restarting xpc agent ...
Waiting for the agent on host:port ...
Pinning fingerprint and saving credentials to profile "<name>" ...
What landed:
* internal/sshlife: minimal Go SSH client wrapping x/crypto/ssh.
- Dial with password auth (TOFU host-key verification deferred).
- Run(cmd, timeout) returns stdout/stderr/exitStatus.
- PutFile / PutBytes upload via 'cat > <remote>' over a stdin pipe.
- Win32 paths (C:\xpc\foo) auto-translated to /cygdrive/c/xpc/foo for
the bash invocation.
* agent/embed.go: //go:embed agent.py + arcp.py, plus ManagePy constant
(kill / start / restart helper with os.dup2-to-NUL for proper
detachment from xpctl-style PIPE inheritance). All three files ship
inside the xpc Go binary; bootstrap uploads them via PutBytes.
* internal/cli/bootstrap.go: replaces the Phase 5 stub. Generates
RSA-2048 cert + 32-byte PSK locally at ~/.xpc/material/<profile>/,
SSHes to the VM, uploads six files, restarts via
/cygdrive/c/Python34/python.exe 'C:\xpc\manage.py' restart <port>
(POSIX for the binary so bash $PATH finds it; single-quoted Win32 path
for the script arg so bash preserves backslashes; manage.py then
re-spawns into Win32 land via DETACHED_PROCESS), polls
waitForListen, saves fingerprint + PSK into the profile.
--no-deploy retains the legacy manual-steps mode.
* internal/cli/agent_lifecycle.go: xpc agent {start, stop, restart, tail}
drive manage.py over SSH. start/restart wait for the agent's TCP
listener before returning so 'agent start; agent ping' chains without
a sleep. agent tail cats C:\xpc\agent.runlog.
Quoting wart documented in docs/sessions/phase-5b-ssh-bootstrap.md:
Cygwin bash strips backslashes from unquoted args, but Win32 python.exe
needs Win32 paths in argv. POSIX path for the binary, single-quoted
Win32 path for the script.
Real-VM verification:
bootstrap deploys cert + key + PSK + agent.py + arcp.py + manage.py
to xp-truvoice-w02; agent restart on port 9579 succeeds; ping returns
pong in ~5ms; full lifecycle (stop, start, ping) works back-to-back
with no artificial sleep.
Deferred: xpc daemon, TOFU host-key, --output formatters package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 426e9a1dc2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| p.Fingerprint = fingerprint | ||
| p.PSK = pskBytes | ||
| if err := profile.Save(p); err != nil { |
There was a problem hiding this comment.
Validate agent cert before saving new fingerprint
This path persists p.Fingerprint and p.PSK immediately after a TCP listen check, but it never verifies that the newly started agent is actually presenting the generated certificate. If restart returns success while an older process is still bound to the port, bootstrap will save credentials that do not match the live agent and subsequent xpc calls will fail auth until the profile is repaired. Add an explicit TLS handshake/session check against the generated fingerprint before profile.Save.
Useful? React with 👍 / 👎.
| } | ||
| p := Process{Name: strings.TrimSpace(row[0]), PID: pid} | ||
| if len(row) > 2 { | ||
| p.SessionID = strings.TrimSpace(row[2]) |
There was a problem hiding this comment.
Parse Session# column into session_id
parseTasklistCSV assigns row[2] to SessionID, but in tasklist /v /fo csv that column is the session name, while the numeric session id is in row[3]. This makes ps --output json emit incorrect session_id values (e.g., Console), which can break consumers expecting an actual session id.
Useful? React with 👍 / 👎.
| timedOut := false | ||
|
|
||
| for { | ||
| env, err := readSignedFrame(conn, psk, 0) |
There was a problem hiding this comment.
Respect context cancellation while waiting for ARCP frames
The receive loop reads frames with timeout=0, which means no read deadline is set; when the remote side stops emitting envelopes, arcp.ReadFrame can block indefinitely and the subsequent ctx.Err() check is never reached. In that state, Ctrl+C/context cancellation will not interrupt the command promptly, so the CLI appears hung. Use finite read deadlines (or close the connection on cancel) to make cancellation effective.
Useful? React with 👍 / 👎.
Phase 6 — Subcommand surface (first wave)
Date: 2026-05-08
Branch:
phase-5/host-cli(continuing; Phase 6 PR will branch from main once Phase 5 merges)What landed in this wave
Sysinternals-flat top-level commands plus a few groups, all driven by the
same
internal/cli/session.gosession-open + tool.invoke flow, with mostagent-side work avoided by routing through
--shell python+subprocess(argv form, no cmd.exe quoting).
Diagnostics (read-only)
xpc info--systeminfoxpc ps [--filter]-- structuredtasklist /v /fo csvparserxpc net,xpc net ipconfig | netstat | routeRegistry
xpc reg get <key> [--value] [--recurse]xpc reg set <key> <name> <data> [--type] [--force]xpc reg delete <key> [--value] [--force]xpc reg export <key> <vm-path>All four route via
python -c "subprocess.Popen(['reg', 'query', ...])"to bypass cmd.exe argv-escaping bugs (registry paths often contain spaces
Windows NT).Services / env / batch / events
xpc svc list | start | stop | status(idempotent already-running /already-stopped detection)
xpc env list | set(setxfor persistence)xpc bat run <vm-path> [args...]xpc evt query [--log] [--max] [--type]-- wraps XP'seventquery.vbsLoop / retry
xpc watch -- <cmd>-- repeats a remote command at--interval(replaces
xpctl watch).Python on the VM
xpc py run -- <source>-- one-shotpython -cxpc py local <local.py>-- ships a local file aspython -csource (replaces
xpctl script)xpc py pip [args...]--python -m pipFiles
xpc cp <src> <dst>-- bidirectional copy withhost:/vm:prefixes; v0 inline (~30 MB cap)
xpc cat <vm:path>-- python-shell-driven; backslash-safexpc head -n N <vm:path>xpc tail -n N <vm:path>xpc find <vm:path> [--glob] [--regex]xpc sum <vm:path> [--algo]-- md5 / sha1 / sha256Reverse-engineering
xpc dll list <pid>--tasklist /m /fi "PID eq ..."xpc dll regsvr32 <vm:path-to-dll> [--unregister]xpc shot [-o <host-bmp>]-- BitBlt + GetDIBits ctypes capture,24-bit BMP, base64 transfer back
Real-VM verifications (against
xp-truvoice-w02:9579)Deferred to follow-up sessions
Per MASTER.md §10 ordering, still needed:
xpc send keys/click/movexpc dump <pid>xpc inj <pid> <dll>xpc boot {shutdown,pause,resume}xpc snap {list,create,restore,delete}xpc dbg attach/run/serverxpc trace start/stop/pullxpc ghidra start/stopxpc ida start/stopxpc fetch <url>xpc edit <vm:path>xpc agent {start,stop,...}internal/sshlife/Go SSH packagexpc daemonPhase 6 (first wave) exit gate: PASSED for landed commands
golangci-lintclean.