@aicommander/agent 1.0.19

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/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @aicommander/agent — remote-machine agent for AI Commander
2
+
3
+ The **on-machine agent** for [AI Commander](https://aicommander.dev). Run it on a
4
+ server, laptop, container, or CI box and it dials out to the relay and listens for
5
+ commands — letting your AI client execute shell/bash commands on that machine
6
+ through the [`@aicommander/mcp`](https://www.npmjs.com/package/@aicommander/mcp)
7
+ server (or any MCP client pointed at the relay).
8
+
9
+ It's an **SSH alternative** with no exposed SSH, no open inbound ports, and no
10
+ VPN: the agent connects outbound, prints an `AIC-…` session code, and you drive it
11
+ from your AI by that code (or a saved alias, with an account API key).
12
+
13
+ Requires **Node ≥ 18** on the target machine.
14
+
15
+ > **Install this on the machine you want to manage/control** (the server, VM,
16
+ > container, or laptop you want your AI to run commands on) — **NOT** on the
17
+ > machine running your AI client. The client side is a different package,
18
+ > [`@aicommander/mcp`](https://www.npmjs.com/package/@aicommander/mcp) (the
19
+ > MCP server your editor/Claude talks to). Install `@aicommander/agent` on the
20
+ > target, `@aicommander/mcp` next to your AI client.
21
+
22
+ ## Quick run (ephemeral, foreground)
23
+
24
+ ```bash
25
+ npx @aicommander/agent
26
+ ```
27
+
28
+ Runs in the **foreground** and prints the **full session code** (you ran it
29
+ yourself in your own terminal, so there's nothing to reveal), then listens until
30
+ you press **Ctrl-C**. Nothing is installed and no service is created — ideal for
31
+ containers, CI, dev boxes, or a quick one-off connection.
32
+
33
+ ```bash
34
+ # Point at a different relay if you self-host:
35
+ AICOMMANDER_SERVER=https://relay.example.com npx @aicommander/agent
36
+ ```
37
+
38
+ The printed code is what you give to your AI client: *"execute df -h on AIC-…"*.
39
+ (`aicommander-agent status --reveal` is only for the systemd **service** below,
40
+ where logs/status keep the code masked.)
41
+
42
+ ## Persistent service (Linux, systemd)
43
+
44
+ Install globally and register a systemd unit that starts on boot and restarts on
45
+ failure:
46
+
47
+ ```bash
48
+ sudo npm i -g @aicommander/agent
49
+ sudo aicommander-agent install
50
+ ```
51
+
52
+ `install` writes `/etc/systemd/system/aicommander-agent.service`, enables it,
53
+ (re)starts it, and prints the session code. The unit runs `aicommander-agent run`
54
+ via the absolute path of the installed CLI and the Node binary that performed the
55
+ install (so it doesn't depend on `PATH`).
56
+
57
+ ```bash
58
+ # Bake a custom relay URL into the unit:
59
+ sudo aicommander-agent install --server https://relay.example.com
60
+ ```
61
+
62
+ Manage the service:
63
+
64
+ | Command | What it does |
65
+ |---|---|
66
+ | `sudo aicommander-agent status [--reveal]` | Show status, uptime, session code (masked unless `--reveal`) |
67
+ | `sudo aicommander-agent enable` / `disable` | Start+enable / stop+disable the service |
68
+ | `sudo aicommander-agent change-code` | Mint a new code and revoke all current access |
69
+ | `sudo aicommander-agent uninstall --force` | Stop, disable, and remove everything |
70
+ | `journalctl -u aicommander-agent -f` | Follow service logs |
71
+
72
+ ## npm channel vs the curl installer
73
+
74
+ | | `npx` / `npm i -g` (this package) | `curl -fsSL https://aicommander.dev/install \| sudo bash` |
75
+ |---|---|---|
76
+ | Runtime | Your Node ≥ 18 | Self-contained native binary (no Node) |
77
+ | Best for | Ephemeral / dev / CI / Node-managed hosts | Production root installs |
78
+ | Integrity | npm's tarball integrity + registry-side checksums | **SHA-256 checksum enforced** before install |
79
+ | Authenticity | — | **Ed25519 signature verified against a pinned key** |
80
+ | Update | `npm i -g @aicommander/agent@latest` | Re-run the curl command |
81
+
82
+ ## Security
83
+
84
+ This npm package relies on **npm's tarball integrity** for trust. The
85
+ `curl … | sudo bash` installer additionally verifies the downloaded binary
86
+ against a **pinned Ed25519 public key** before installing it as root — so the
87
+ **curl installer remains the canonical, recommended path for production root
88
+ installs**.
89
+
90
+ Prefer this npm package for **ephemeral / dev use** and Node-managed
91
+ environments. In all cases: the session code is a **root-exec credential** —
92
+ anyone who has it can run commands as root on the machine. Keep it secret, don't
93
+ paste it into shared chats/screenshots/tickets, and rotate it with
94
+ `aicommander-agent change-code` if it leaks.
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import{program as h}from"commander";var k="1.0.19";import Gt from"os";function Bt(e){try{let t=JSON.parse(e);return typeof t.code=="string"?t.code:null}catch{return null}}var fe=class extends Error{constructor(o,s){super(`Registration failed (${o}): ${s}`);this.status=o;this.body=s;this.name="RegistrationError",this.code=Bt(s)}status;body;code};async function pe(e,t,o={}){let s={hostname:Gt.hostname(),platform:process.platform,arch:process.arch,agentVersion:k,...t?{deviceId:t.deviceId,deviceSecret:t.deviceSecret}:{},...o.forceNew?{forceNew:!0}:o.currentCode?{currentCode:o.currentCode}:{}},i=await fetch(`${e}/api/register`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(!i.ok){let r=await i.text();throw new fe(i.status,r)}return i.json()}import Kt from"ws";import Jt from"os";import{spawn as Pe}from"child_process";var Z="https://aicommander.dev";var Le="ABCDEFGHJKMNPQRSTVWXYZ0123456789";var Dn=Math.floor(256/Le.length)*Le.length;var Wt=/^AIC-[ABCDEFGHJKMNPQRSTVWXYZ0-9]{4}-[ABCDEFGHJKMNPQRSTVWXYZ0-9]{4}-[ABCDEFGHJKMNPQRSTVWXYZ0-9]{4}$/;function Vt(e){return Wt.test(e.trim().toUpperCase())}function U(e){return Vt(e)?`AIC-${e.slice(4,8)}-***-***`:e}var $e=process.platform==="win32";function He(e,t,o,s){let i=Date.now(),r=Pe(e,[],{shell:!0,cwd:t??process.env.HOME,env:{...process.env,...o??{}},stdio:["ignore","pipe","pipe"],detached:!$e}),c=!1,a=null,l=()=>{a&&(clearTimeout(a),a=null)};r.stdout?.on("data",d=>{s.onOutput(d.toString("base64"),"stdout")}),r.stderr?.on("data",d=>{s.onOutput(d.toString("base64"),"stderr")}),r.on("close",d=>{c=!0,l(),s.onDone(d??-1,Date.now()-i)}),r.on("error",d=>{c=!0,l(),s.onError(d.message)});let m=d=>{if(!(c||r.pid==null))try{$e?Pe("taskkill",["/pid",String(r.pid),"/T","/F"],{stdio:"ignore"}):process.kill(-r.pid,d)}catch{}};return{kill:()=>{c||(m("SIGTERM"),l(),a=setTimeout(()=>{a=null,m("SIGKILL")},5e3),a.unref?.())}}}import g from"chalk";function Ue(e,t,o=!1){console.clear(),console.log(),console.log(g.bold.cyan(" AI Commander \u2014 Remote Agent")),console.log(),console.log(g.gray(" Session code:")),console.log(),console.log(g.bold.greenBright(` ${o?e:U(e)}`)),console.log(),o||console.log(g.gray(" Reveal the full code: sudo aicommander-agent status --reveal")),console.log(g.gray(" Then give it to your admin to connect.")),console.log(g.yellow(" \u26A0 Keep this code secret \u2014 anyone who has it can run commands as root here.")),console.log(g.gray(` Server: ${t}`)),console.log(g.gray(" Press Ctrl+C to disconnect.")),console.log(),console.log(g.yellow(" Waiting for admin connection...")),console.log()}function Fe(e,t){console.log(g.yellow(` Reconnecting in ${Math.round(t/1e3)}s (attempt ${e})...`))}function Ge(){console.log(g.green(" Admin connected."))}function Be(e){console.log(g.cyan(` > ${e}`))}function We(e,t){let o=e===0?g.green:g.red;console.log(o(` Done. Exit ${e} in ${t}ms`))}var ge={capable:!1,enabled:!1,expiresAt:null},Je=360*60*1e3;function Xt(e,t){return new Promise((o,s)=>{if(t.aborted){s(new DOMException("Aborted","AbortError"));return}let i=setTimeout(o,e);t.addEventListener("abort",()=>{clearTimeout(i),s(new DOMException("Aborted","AbortError"))},{once:!0})})}async function Xe(e){let t=1e3,o=3e4,s=0,i=e.agentToken;for(;!e.signal?.aborted;){s++;try{let r=await qt(e,i);if(t=1e3,s=0,r&&e.reauth&&!e.signal?.aborted)try{i=await e.reauth()}catch{}}catch(r){if(r.name==="AbortError")break;let c=.8+Math.random()*.4,a=Math.min(t*c,o);e.silent||Fe(s,a);try{await Xt(a,e.signal??new AbortController().signal)}catch(l){if(l.name==="AbortError")break}t=Math.min(t*2,o)}}}async function qt(e,t){let{serverUrl:o,sessionCode:s}=e,i=`${o.replace(/^http/,"ws")}/ws/agent/${s}?token=${t}`,r=l=>{try{e.onStatus?.(l)}catch{}},c=l=>{try{e.onActivity?.(l)}catch{}},a=()=>{try{e.onHeartbeat?.()}catch{}};return r("connecting"),new Promise((l,m)=>{let d=new Kt(i),f=null,S=null,D=null,$=!1,R=null,O=!1,X=()=>{D&&(clearTimeout(D),D=null)},y=()=>{R&&(clearInterval(R),R=null)},b=()=>{if(!e.reauthIntervalMs||!e.reauth)return;let u=()=>{if(f){D=setTimeout(u,3e4);return}$=!0,d.close(1e3,"reauth")};D=setTimeout(u,e.reauthIntervalMs)},H=()=>{O||(O=!0,X(),y(),l($))},N=u=>{O||(O=!0,X(),y(),m(u))},q=()=>{a(),S&&clearTimeout(S),S=setTimeout(()=>{d.terminate(),N(new Error("Ping timeout"))},9e4)};e.signal?.addEventListener("abort",()=>d.close(1e3,"disabled"),{once:!0});let de=()=>{if(O)return;let u={type:"agent:screen_state",screenShare:e.screenShare?.getState()??ge};try{d.send(JSON.stringify(u))}catch{}};d.on("open",()=>{r("connected"),q(),b();let u={type:"agent:register",hostname:Jt.hostname(),platform:process.platform,arch:process.arch,agentVersion:k,screenShare:e.screenShare?.getState()??ge};d.send(JSON.stringify(u)),e.screenShare?.on("change",de),e.silent||Ge()}),d.on("message",u=>{let v;try{v=JSON.parse(String(u))}catch{return}switch(v.type){case"do:ping":q();let $t={type:"agent:pong",ts:v.ts};d.send(JSON.stringify($t));break;case"do:exec":{Be(v.command),c(!0);let{commandId:Y,command:Ht,cwd:Ut,env:Ft}=v,Me=Date.now(),me=!1;y(),R=setInterval(()=>{let A=Date.now()-Me;if(A>=3e5&&!me){me=!0;let T={type:"agent:exec_idle",commandId:Y,idleMs:A};d.send(JSON.stringify(T))}},3e5),R.unref?.(),f=He(Ht,Ut,Ft,{onOutput:(A,T)=>{Me=Date.now(),me=!1;let ue={type:"agent:output",commandId:Y,chunk:A,stream:T};d.send(JSON.stringify(ue))},onDone:(A,T)=>{f=null,y(),c(!1),We(A,T);let ue={type:"agent:done",commandId:Y,exitCode:A,durationMs:T};d.send(JSON.stringify(ue))},onError:A=>{f=null,y(),c(!1);let T={type:"agent:error",commandId:Y,error:A};d.send(JSON.stringify(T))}}).kill;break}case"do:kill":f&&(f(),f=null);break;case"do:screenshot":Yt(d,v.requestId,e.screenShare);break}}),d.on("close",(u,v)=>{S&&clearTimeout(S),y(),e.screenShare?.off("change",de),f&&(f(),f=null,c(!1)),r("disconnected"),u===1e3||u===4001?H():N(new Error(`WS closed: ${u} ${String(v)}`))}),d.on("error",u=>{S&&clearTimeout(S),y(),e.screenShare?.off("change",de),r("disconnected"),N(u)}),d.on("unexpected-response",(u,v)=>{m(new Error(`Unexpected HTTP response: ${v.statusCode}`))})})}async function Yt(e,t,o){let s=c=>{try{e.send(JSON.stringify(c))}catch{}},i=c=>s({type:"agent:screenshot_error",requestId:t,error:c}),r=o?.getState()??ge;if(!o||!r.capable){i("This machine cannot share its screen (desktop macOS/Windows only).");return}if(!r.enabled){i("Screen sharing is turned off on this machine.");return}try{let{data:c,mimeType:a}=await o.capture();if(c.length>10485760){let m=(c.length/1048576).toFixed(1);i(`Screenshot is ${m} MB, which exceeds the ${10485760/(1024*1024)} MB limit.`);return}let l=c.toString("base64");for(let m=0;m<l.length;m+=524288)s({type:"agent:screenshot_chunk",requestId:t,chunk:l.slice(m,m+524288)});s({type:"agent:screenshot_done",requestId:t,mimeType:a,totalBytes:c.length})}catch(c){i(`Screenshot failed: ${c.message}`)}}import w from"node:fs/promises";import Ze from"node:path";var Q="/var/run/aicommander-agent",z=Ze.join(Q,"state.json"),qe=448,Ye=384;async function Qe(e){let t=null;try{await w.mkdir(Q,{recursive:!0,mode:qe}),await w.chmod(Q,qe),t=Ze.join(Q,`.state.${process.pid}.${Date.now()}.tmp`);let o=await w.open(t,"wx",Ye);try{await o.writeFile(JSON.stringify(e,null,2),"utf8")}finally{await o.close()}await w.rename(t,z),t=null,await w.chmod(z,Ye)}catch{}finally{t&&await w.rm(t,{force:!0}).catch(()=>{})}}async function ze(){try{await w.rm(z,{force:!0})}catch{}}async function E(){try{let e=await w.readFile(z,"utf8");return JSON.parse(e)}catch{return null}}import ye from"node:fs";import j from"node:path";import Zt from"node:os";import{randomUUID as Qt,randomBytes as zt}from"node:crypto";var et="/etc/aicommander-agent",tt=j.join(Zt.homedir(),".config","aicommander-agent"),ee="device.json";function nt(){return{deviceId:Qt(),deviceSecret:zt(32).toString("base64url")}}function en(e){return typeof e=="object"&&e!==null&&typeof e.deviceId=="string"&&e.deviceId.length>0&&typeof e.deviceSecret=="string"&&e.deviceSecret.length>0}function Se(e){try{let t=ye.readFileSync(e,"utf8"),o=JSON.parse(t);if(en(o))return{deviceId:o.deviceId,deviceSecret:o.deviceSecret}}catch{}return null}function he(e,t){try{return ye.mkdirSync(e,{recursive:!0,mode:448}),ye.writeFileSync(j.join(e,ee),JSON.stringify(t,null,2),{encoding:"utf8",mode:384}),!0}catch{return!1}}function F(e){if(e){let i=Se(j.join(e,ee));if(i)return i;let r=nt();return he(e,r),r}let t=Se(j.join(et,ee));if(t)return t;let o=Se(j.join(tt,ee));if(o)return o;let s=nt();return he(et,s)||he(tt,s),s}import I from"node:fs";import G from"node:path";import tn from"node:os";var nn="/etc/aicommander-agent",on=G.join(tn.homedir(),".config","aicommander-agent"),ve="session.json",ot=".rotate";function rn(e){return typeof e=="object"&&e!==null&&typeof e.sessionCode=="string"&&e.sessionCode.length>0&&typeof e.agentToken=="string"&&e.agentToken.length>0}function sn(e){try{let t=I.readFileSync(e,"utf8"),o=JSON.parse(t);if(rn(o))return{sessionCode:o.sessionCode,agentToken:o.agentToken}}catch{}return null}function an(e,t){try{return I.mkdirSync(e,{recursive:!0,mode:448}),I.writeFileSync(G.join(e,ve),JSON.stringify(t,null,2),{encoding:"utf8",mode:384}),!0}catch{return!1}}function K(e){return e?[e]:[nn,on]}function rt(e){for(let t of K(e)){let o=sn(G.join(t,ve));if(o)return o}return null}function Ee(e,t){for(let o of K(t))if(an(o,e))return}function st(e){for(let t of K(e))try{I.rmSync(G.join(t,ve),{force:!0})}catch{}}function it(e){for(let t of K(e))try{I.mkdirSync(t,{recursive:!0,mode:448}),I.writeFileSync(G.join(t,ot),"",{mode:384});return}catch{}}function at(e){let t=!1;for(let o of K(e)){let s=G.join(o,ot);try{I.existsSync(s)&&(t=!0,I.rmSync(s,{force:!0}))}catch{}}return t}import{spawn as pn}from"child_process";import te from"fs";import ct from"path";import ln from"os";function ne(e){if(e)return ct.join(e,"heartbeat");if(process.platform==="linux")try{return te.mkdirSync("/run/aicommander-agent",{recursive:!0}),"/run/aicommander-agent/heartbeat"}catch{}return ct.join(ln.tmpdir(),"aicommander-agent-heartbeat")}function lt(e){try{let t=`${e}.tmp`;te.writeFileSync(t,String(Date.now())),te.renameSync(t,e)}catch{}}var J=null;function dt(e,t=5e3){be(),lt(e),J=setInterval(()=>lt(e),t),J.unref?.()}function be(){J&&(clearInterval(J),J=null)}function mt(e){try{let t=te.readFileSync(e,"utf8").trim(),o=Number(t);return Number.isFinite(o)?o:null}catch{return null}}async function ft(e={}){let t=e.heartbeatPath??ne(e.configDir),o=e.spawnWorker??(d=>pn(process.execPath,process.argv.slice(1),{env:d,stdio:"inherit"})),s=null,i=0,r=!1,c=0,a=[],l=null,m=null;await new Promise(d=>{let f=null,S=null,D=()=>{l&&(clearInterval(l),l=null),m&&(clearTimeout(m),m=null),f&&(process.removeListener("SIGINT",f),f=null),S&&(process.removeListener("SIGTERM",S),S=null)},$=()=>{D(),d()},R=()=>{i=Date.now(),c++;let b={...process.env,AIC_ROLE:"worker",AIC_HEARTBEAT:t};s=o(b),s.on("exit",(H,N)=>{if(s=null,r){$();return}if(e.maxSpawns!=null&&c>=e.maxSpawns){$();return}O(`worker exited (code=${H??"null"} signal=${N??"null"})`)}),s.on("error",()=>{})},O=b=>{let H=Date.now();a=a.filter(q=>H-q<3e5),a.push(H);let N=a.length>5?6e4:0;m&&clearTimeout(m),m=setTimeout(()=>{m=null,r||R()},N),m.unref?.()},X=()=>{if(s)try{s.kill("SIGKILL")}catch{}};l=setInterval(()=>{if(r||!s||Date.now()-i<3e4)return;let b=mt(t);b!=null&&Date.now()-b>3e4&&X()},1e4),l.unref?.();let y=b=>{if(r=!0,l&&(clearInterval(l),l=null),m&&(clearTimeout(m),m=null),s)try{s.kill(b)}catch{}else $()};f=()=>y("SIGINT"),S=()=>y("SIGTERM"),process.on("SIGINT",f),process.on("SIGTERM",S),R()})}function gn(e,t){let o=e.split(".").map(r=>parseInt(r,10)||0),s=t.split(".").map(r=>parseInt(r,10)||0),i=Math.max(o.length,s.length);for(let r=0;r<i;r++){let c=(o[r]??0)-(s[r]??0);if(c!==0)return c>0?1:-1}return 0}function pt(e,t){return gn(e,t)>0}async function gt(e,t=1e4){let o=new AbortController,s=setTimeout(()=>o.abort(),t);try{let i=await fetch(`${e.replace(/\/+$/,"")}/dist/latest`,{signal:o.signal,headers:{Accept:"application/json"}});if(!i.ok)return null;let r=await i.json();return{version:typeof r.version=="string"?r.version:null,mac:typeof r.mac=="string"?r.mac:null,win:typeof r.win=="string"?r.win:null,agentLinuxX64:typeof r.agentLinuxX64=="string"?r.agentLinuxX64:null,agentLinuxArm64:typeof r.agentLinuxArm64=="string"?r.agentLinuxArm64:null}}catch{return null}finally{clearTimeout(s)}}var M=process.env.AICOMMANDER_SERVER??Z,Sn=1440*60*1e3;async function St(){let e=await gt(M);e?.version&&pt(e.version,k)&&console.warn(`
3
+ \u2B06 A newer AI Commander agent is available (v${e.version}; you have v${k}).
4
+ Update: curl -fsSL ${M}/install | sudo bash
5
+ `)}async function ht(){process.env.AIC_ROLE==="worker"?await hn():await ft()}async function hn(){let e=process.env.AIC_ROLE==="worker";e&&dt(process.env.AIC_HEARTBEAT||ne());let t=()=>{e&&be(),ze(),process.exit(0)};process.on("SIGINT",t),process.on("SIGTERM",t),typeof process.getuid=="function"&&process.getuid()!==0&&console.warn(`
6
+ \u26A0 Warning: not running as root.
7
+ Commands requiring sudo (apt, systemctl, brew cask\u2026) will fail.
8
+ Recommended: sudo aicommander-agent
9
+ `);let o=F(),s=at(),i=s?null:rt(),r,c;try{let l=await pe(M,o,{forceNew:s,...i?{currentCode:i.sessionCode}:{}});r=l.sessionCode,c=l.agentToken,Ee({sessionCode:r,agentToken:c})}catch(l){console.error(` Failed to register: ${String(l)}`),process.exit(1)}Ue(r,M,!0),Qe({sessionCode:r,pid:process.pid,startedAt:new Date().toISOString(),serverUrl:M}),St(),setInterval(()=>{St()},Sn).unref?.(),await Xe({serverUrl:M,sessionCode:r,agentToken:c,reauthIntervalMs:Je,reauth:async()=>{let l=await pe(M,o,{currentCode:r});return Ee({sessionCode:l.sessionCode,agentToken:l.agentToken}),l.agentToken}})}import ae from"node:fs";import bt from"chalk";import{execFileSync as Ae}from"node:child_process";var x="aicommander-agent";function L(e){Ae("systemctl",e,{stdio:"inherit"})}function B(){try{return Ae("systemctl",["is-active",x],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()}catch{return"inactive"}}function yt(){try{return Ae("systemctl",["is-enabled",x],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()==="enabled"}catch{return!1}}function oe(){L(["start",x])}function W(){L(["stop",x])}function re(){L(["enable",x])}function se(){L(["disable",x])}function V(){L(["restart",x])}function ie(){L(["daemon-reload"])}function vt(){L(["kill","--signal=SIGKILL",x])}import _ from"chalk";import yn from"node:readline";var n={header:e=>console.log(_.bold.cyan(`
10
+ ${e}
11
+ `)),ok:e=>console.log(_.green(` \u2713 ${e}`)),warn:e=>console.log(_.yellow(` \u26A0 ${e}`)),error:e=>console.error(_.red(` \u2717 ${e}`)),info:(e,t)=>console.log(` ${_.gray(e.padEnd(14))} ${_.white(t)}`),step:e=>console.log(_.gray(` ${e}`)),blank:()=>console.log()};function p(){process.getuid?.()!==0&&(n.error("This command must be run as root."),console.error(_.gray(" Re-run with: sudo aicommander-agent <command>")),process.exit(1))}async function Et(e){if(!process.stdin.isTTY)return!1;let t=yn.createInterface({input:process.stdin,output:process.stdout});try{let o=await new Promise(s=>t.question(` ${e} [y/N] `,s));return/^y(es)?$/i.test(o.trim())}finally{t.close()}}var At="/etc/systemd/system/aicommander-agent.service",ke="/etc/aicommander-agent";function vn(e){/[\u0000-\u001f\u007f]/.test(e)&&(n.error(`Invalid --server URL: contains control characters: ${JSON.stringify(e)}`),process.exit(1));let t;try{t=new URL(e)}catch{n.error(`Invalid --server URL: not a valid URL: ${JSON.stringify(e)}`),process.exit(1)}return t.protocol!=="http:"&&t.protocol!=="https:"&&(n.error(`Invalid --server URL: protocol must be http: or https: (got ${t.protocol})`),process.exit(1)),e.replace(/%/g,"%%")}function kt(e){return`"${e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/%/g,"%%")}"`}async function Re(e){p(),process.platform!=="linux"&&(n.error(`The systemd service installer supports Linux only. Detected: ${process.platform}`),n.blank(),n.step("For an ephemeral foreground run on this platform, use:"),n.step(" npx @aicommander/agent (or: aicommander-agent run)"),n.step("For a production root install on Linux, the canonical path is:"),n.step(" curl -fsSL https://aicommander.dev/install | sudo bash"),process.exit(1));let t=vn(e.server??process.env.AICOMMANDER_SERVER??Z),o=ae.realpathSync(process.argv[1]),s=`${kt(process.execPath)} ${kt(o)} run`;n.header("AI Commander Agent \u2014 systemd install"),n.info("Server",t),n.info("ExecStart",s),n.blank(),n.step("Creating device identity dir\u2026"),ae.mkdirSync(ke,{recursive:!0}),ae.chmodSync(ke,448),n.ok(`Device identity dir ready (${ke}).`),n.step(`Writing systemd service to ${At}\u2026`);let i=`[Unit]
12
+ Description=AI Commander Remote Agent
13
+ After=network-online.target
14
+ Wants=network-online.target
15
+
16
+ [Service]
17
+ Type=simple
18
+ User=root
19
+ RuntimeDirectory=aicommander-agent
20
+ # 0700: the runtime dir holds state.json with the live session code (a root-exec
21
+ # credential); owner-only blocks local unprivileged users from reading it.
22
+ RuntimeDirectoryMode=0700
23
+ Environment=AICOMMANDER_SERVER=${t}
24
+ ExecStart=${s}
25
+ Restart=always
26
+ RestartSec=10
27
+ StandardOutput=journal
28
+ StandardError=journal
29
+ SyslogIdentifier=aicommander-agent
30
+ TimeoutStopSec=30
31
+ KillSignal=SIGINT
32
+
33
+ [Install]
34
+ WantedBy=multi-user.target
35
+ `;ae.writeFileSync(At,i,{mode:420}),n.ok("Service file written."),n.step("Reloading systemd\u2026");try{ie()}catch{n.error("daemon-reload failed. Inspect: journalctl -u aicommander-agent -n 50"),process.exit(1)}n.step("Enabling service on boot\u2026");try{re()}catch{n.warn("Could not enable the service on boot (non-critical).")}n.step("Restarting service\u2026");try{V()}catch{n.warn("Graceful restart failed \u2014 forcing stop then start\u2026");try{W()}catch{}try{vt()}catch{}try{oe()}catch{n.error("Could not start aicommander-agent. Inspect: journalctl -u aicommander-agent -n 50"),process.exit(1)}}B()!=="active"&&(n.error("Service is not active after restart. Inspect: journalctl -u aicommander-agent -n 50"),process.exit(1)),n.ok("Service started."),n.blank(),n.step("Waiting for session code\u2026");let r=null;for(let c=0;c<30;c++){let a=await E();if(a?.sessionCode){r=a.sessionCode;break}await new Promise(l=>setTimeout(l,500))}n.blank(),r?(n.ok(bt.bold("AI Commander ready!")),n.blank(),n.info("Session code",bt.bold.greenBright(U(r))),n.blank(),n.step("Reveal full code: sudo aicommander-agent status --reveal"),n.step("Then use in Claude: 'execute df -h on <code>'"),n.step("Service logs: journalctl -u aicommander-agent -f"),n.step("Manage: sudo aicommander-agent status"),n.step("Uninstall: sudo aicommander-agent uninstall --force"),n.blank(),n.warn("Keep this code secret. Anyone who has it can run commands as root on this machine.")):(n.warn("The session code did not appear within 15 seconds."),n.step("The service is running \u2014 the code should appear shortly."),n.step("Status: sudo aicommander-agent status"),n.step("Logs: journalctl -u aicommander-agent -f")),n.blank()}import C from"chalk";function En(e){if(e<60)return`${e}s`;if(e<3600)return`${Math.floor(e/60)}m`;let t=Math.floor(e/3600),o=Math.floor(e%3600/60);return o>0?`${t}h ${o}m`:`${t}h`}async function Te(e={}){n.header("AI Commander \u2014 Agent Status");let t;try{t=B()}catch{n.error("systemd is not available on this system."),process.exit(1)}let o=yt(),s=await E();if(n.info("State",t==="active"?C.green("running"):C.red(t)),s){e.reveal?(p(),n.info("Session code",C.bold.cyan(s.sessionCode)),n.step(C.yellow(" \u26A0 Keep this secret \u2014 anyone with it can run commands as root here."))):(n.info("Session code",C.bold.cyan(U(s.sessionCode))),n.step(C.gray(" (run with --reveal to show the full code)"))),n.info("PID",String(s.pid));let i=new Date(s.startedAt),r=Math.floor((Date.now()-i.getTime())/1e3);n.info("Started",`${s.startedAt} (${En(r)} ago)`),n.info("Server",s.serverUrl)}else t==="active"&&n.warn("Session code not yet available (agent still starting)");n.info("Enabled",o?C.green("yes"):C.yellow("no")),n.blank()}function we(){p(),B()==="active"?(n.warn("Service is already running \u2014 restarting to load the current binary."),V()):oe(),re(),n.ok("Service started and enabled to run on boot."),n.blank()}function Ie(){p(),W(),se(),n.ok("Service stopped and disabled."),n.blank()}import bn from"chalk";async function xe(e={}){if(p(),!e.yes){if(!process.stdin.isTTY){n.error("Refusing to reset the code non-interactively."),n.info("To proceed","re-run with --yes (this unlinks ALL linked accounts)"),process.exitCode=1;return}if(!await Et("Resetting the access code will UNLINK ALL linked accounts. Continue?")){n.info("Cancelled","no changes made");return}}n.step("Restarting agent to get a new session code\u2026"),it(),st(),V();let t=Date.now()+1e4,o=null;for(;Date.now()<t&&(await new Promise(s=>setTimeout(s,500)),o=await E(),!o););o?n.ok(`New session code: ${bn.bold.greenBright(o.sessionCode)}`):(n.warn("Service restarted. Session code not yet available."),n.info("Check","aicommander-agent status")),n.blank()}import _e from"node:fs";import An from"chalk";var Rt="/etc/systemd/system/aicommander-agent.service",Tt="/usr/local/bin/aicommander-agent",wt="/usr/local/bin/aicommander-ctl",It="/var/run/aicommander-agent",xt="/etc/aicommander-agent",_t="/etc/aicommander-agent/session.json",Ct="/etc/aicommander-agent/.rotate";function P(e,t){try{if(_e.existsSync(e)||_e.lstatSync(e)){_e.rmSync(e,{recursive:!0,force:!0}),n.ok(`${t} removed.`);return}}catch{}n.warn(`${t} not found (already removed?).`)}function Ce(e){p(),e.force||(n.warn("This will fully remove the AI Commander agent."),n.error("Add --force to confirm: sudo aicommander-agent uninstall --force"),process.exit(1)),n.step("Stopping and disabling service\u2026");try{W()}catch{}try{se()}catch{}n.ok("Service stopped."),n.step("Removing service file\u2026"),P(Rt,Rt),n.step("Reloading systemd\u2026");try{ie(),n.ok("daemon-reload complete.")}catch{n.warn("daemon-reload failed (non-critical).")}n.step("Removing agent binary\u2026"),P(wt,wt),P(Tt,Tt),P(It,It),P(_t,_t),P(Ct,Ct),P(xt,xt),n.blank(),n.ok(An.bold("AI Commander agent fully removed.")),n.blank()}function Dt(e){return{"X-Device-Id":e.deviceId,"X-Device-Secret":e.deviceSecret}}async function ce(e,t){let o=await fetch(`${e}/api/device/admins`,{headers:Dt(t)});if(!o.ok)throw new Error(`Failed to list linked accounts (HTTP ${o.status}).`);return await o.json()}async function Ot(e,t,o,s){let i=await fetch(`${e}/api/device/${o}`,{method:"POST",headers:{...Dt(t),"Content-Type":"application/json"},body:JSON.stringify({userId:s})}),r;try{r=await i.json()}catch{r={ok:!1,error:`http_${i.status}`}}return{...r,ok:i.ok}}function Nt(e,t,o){return Ot(e,t,"block",o)}function Mt(e,t,o){return Ot(e,t,"unblock",o)}function le(e){return[...e.filter(t=>!t.blocked),...e.filter(t=>t.blocked)]}function Lt(e,t){let o=t.trim();if(!o)return{kind:"not_found"};if(/^\d+$/.test(o)){let r=Number(o)-1;return r>=0&&r<e.length?{kind:"ok",admin:e[r]}:{kind:"not_found"}}let s=e.find(r=>r.userId===o);if(s)return{kind:"ok",admin:s};let i=e.filter(r=>r.userId.startsWith(o));return i.length===1?{kind:"ok",admin:i[0]}:i.length>1?{kind:"ambiguous",matches:i}:{kind:"not_found"}}async function kn(){return(await E())?.serverUrl??process.env.AICOMMANDER_SERVER??"https://aicommander.dev"}async function De(){p();let e=await kn(),t=F(),o;try{o=await ce(e,t)}catch(a){n.error(a.message),process.exitCode=1;return}if(o.admins.length===0){n.header("Linked accounts"),n.info("None","no accounts are linked to this device"),n.blank();return}let s=le(o.admins),i=(a,l)=>{let m=String(l+1).padStart(2," "),d=a.linkedAt.slice(0,10);n.info(`${m}. ${a.maskedEmail}`,`${a.alias} \xB7 linked ${d} \xB7 id ${a.userId.slice(0,8)}\u2026`)},r=s.filter(a=>!a.blocked),c=s.filter(a=>a.blocked);n.header("Linked accounts"),r.forEach(a=>i(a,s.indexOf(a))),r.length===0&&n.info("None active","every linked account is blocked"),n.blank(),c.length>0&&(n.header("Blocked"),c.forEach(a=>i(a,s.indexOf(a))),n.blank()),n.step("Block one with: aicommander-agent block-admin <number|id>"),c.length>0&&n.step("Unblock one with: aicommander-agent unblock-admin <number|id>"),n.blank()}async function Rn(){return(await E())?.serverUrl??process.env.AICOMMANDER_SERVER??"https://aicommander.dev"}async function Pt(e,t){p();let o=await Rn(),s=F(),i;try{i=await ce(o,s)}catch(m){n.error(m.message),process.exitCode=1;return}let r=le(i.admins),c=Lt(r,e);if(c.kind==="not_found"){n.error(`No linked account matches "${e}".`),n.info("See options","aicommander-agent list-admins"),process.exitCode=1;return}if(c.kind==="ambiguous"){n.error(`"${e}" is ambiguous \u2014 it matches ${c.matches.length} accounts.`),n.info("Disambiguate","use the list number or a longer id"),process.exitCode=1;return}let a=c.admin,l=t==="block"?await Nt(o,s,a.userId):await Mt(o,s,a.userId);l.ok?t==="block"?(n.ok(`Blocked ${a.maskedEmail} (${a.alias}).`),n.info("Note","their access is cut off; run unblock-admin anytime to restore it")):n.ok(`Unblocked ${a.maskedEmail} (${a.alias}) \u2014 access restored.`):l.error==="not_found"?n.warn(t==="block"?"That account isn't linked (or is already blocked).":"That account isn't blocked."):(n.error(l.message??`Failed to ${t} the account.`),process.exitCode=1),n.blank()}function Oe(e){return Pt(e,"block")}function Ne(e){return Pt(e,"unblock")}h.name("aicommander-agent").description("AI Commander remote agent + service controller").version(k);h.command("run",{isDefault:!0}).description("Run the agent: register with the relay and listen for commands").action(async()=>{await ht()});h.command("install").description("Install and start the agent as a systemd service (Linux, root)").option("--server <url>","Relay server URL baked into the service unit").action(async e=>{await Re(e)});h.command("status").description("Show agent status, session code, and uptime").option("--reveal","Show the full session code (otherwise masked)").action(async e=>{await Te(e)});h.command("enable").description("Start and enable the agent service").action(()=>{we()});h.command("disable").description("Stop and disable the agent service").action(()=>{Ie()});h.command("change-code").aliases(["reset-code"]).description("Reset the access code \u2014 mint a new code and remove ALL access (unlinks every linked account)").option("-y, --yes","Skip the confirmation prompt").action(async e=>{await xe(e)});h.command("list-admins").description("List the accounts (admins) linked to this device").action(async()=>{await De()});h.command("block-admin <account>").aliases(["revoke-admin"]).description("Block one account on this device (by list number or id) \u2014 refuses access; unblock anytime").action(async e=>{await Oe(e)});h.command("unblock-admin <account>").description("Restore a blocked account's access (by list number or id)").action(async e=>{await Ne(e)});h.command("uninstall").description("Fully remove the agent (stop, disable, delete files)").option("-f, --force","Skip confirmation requirement").action(e=>{Ce(e)});h.parseAsync().catch(e=>{console.error("Fatal error:",e),process.exit(1)});
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent.js","sourceRoot":"","sources":["../../bin/agent.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,EACL,UAAU,EACV,SAAS,EACT,SAAS,EACT,UAAU,EACV,aAAa,EACb,YAAY,EACZ,aAAa,EACb,aAAa,EACb,eAAe,GAChB,MAAM,qBAAqB,CAAC;AAE7B,OAAO;KACJ,IAAI,CAAC,mBAAmB,CAAC;KACzB,WAAW,CAAC,gDAAgD,CAAC;KAC7D,OAAO,CAAC,aAAa,CAAC,CAAC;AAE1B,kEAAkE;AAClE,OAAO;KACJ,OAAO,CAAC,KAAK,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;KACnC,WAAW,CAAC,gEAAgE,CAAC;KAC7E,MAAM,CAAC,KAAK,IAAI,EAAE;IACjB,MAAM,UAAU,EAAE,CAAC;AACrB,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,SAAS,CAAC;KAClB,WAAW,CAAC,gEAAgE,CAAC;KAC7E,MAAM,CAAC,gBAAgB,EAAE,8CAA8C,CAAC;KACxE,MAAM,CAAC,KAAK,EAAE,IAAyB,EAAE,EAAE;IAC1C,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,QAAQ,CAAC;KACjB,WAAW,CAAC,6CAA6C,CAAC;KAC1D,MAAM,CAAC,UAAU,EAAE,+CAA+C,CAAC;KACnE,MAAM,CAAC,KAAK,EAAE,IAA0B,EAAE,EAAE;IAC3C,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,QAAQ,CAAC;KACjB,WAAW,CAAC,oCAAoC,CAAC;KACjD,MAAM,CAAC,GAAG,EAAE;IACX,SAAS,EAAE,CAAC;AACd,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,SAAS,CAAC;KAClB,WAAW,CAAC,oCAAoC,CAAC;KACjD,MAAM,CAAC,GAAG,EAAE;IACX,UAAU,EAAE,CAAC;AACf,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,aAAa,CAAC;KACtB,OAAO,CAAC,CAAC,YAAY,CAAC,CAAC;KACvB,WAAW,CAAC,8FAA8F,CAAC;KAC3G,MAAM,CAAC,WAAW,EAAE,8BAA8B,CAAC;KACnD,MAAM,CAAC,KAAK,EAAE,IAAuB,EAAE,EAAE;IACxC,MAAM,aAAa,CAAC,IAAI,CAAC,CAAC;AAC5B,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,aAAa,CAAC;KACtB,WAAW,CAAC,kDAAkD,CAAC;KAC/D,MAAM,CAAC,KAAK,IAAI,EAAE;IACjB,MAAM,aAAa,EAAE,CAAC;AACxB,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,uBAAuB,CAAC;KAChC,OAAO,CAAC,CAAC,cAAc,CAAC,CAAC;KACzB,WAAW,CAAC,2FAA2F,CAAC;KACxG,MAAM,CAAC,KAAK,EAAE,OAAe,EAAE,EAAE;IAChC,MAAM,aAAa,CAAC,OAAO,CAAC,CAAC;AAC/B,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,yBAAyB,CAAC;KAClC,WAAW,CAAC,2DAA2D,CAAC;KACxE,MAAM,CAAC,KAAK,EAAE,OAAe,EAAE,EAAE;IAChC,MAAM,eAAe,CAAC,OAAO,CAAC,CAAC;AACjC,CAAC,CAAC,CAAC;AAEL,OAAO;KACJ,OAAO,CAAC,WAAW,CAAC;KACpB,WAAW,CAAC,sDAAsD,CAAC;KACnE,MAAM,CAAC,aAAa,EAAE,+BAA+B,CAAC;KACtD,MAAM,CAAC,CAAC,IAAwB,EAAE,EAAE;IACnC,YAAY,CAAC,IAAI,CAAC,CAAC;AACrB,CAAC,CAAC,CAAC;AAEL,OAAO,CAAC,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACjC,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;IACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./src/index.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import je from"os";var D="1.0.19";var ue="device_secret_mismatch";function Ue(e){try{let t=JSON.parse(e);return typeof t.code=="string"?t.code:null}catch{return null}}var w=class extends Error{constructor(n,o){super(`Registration failed (${n}): ${o}`);this.status=n;this.body=o;this.name="RegistrationError",this.code=Ue(o)}status;body;code};async function F(e,t,n={}){let o={hostname:je.hostname(),platform:process.platform,arch:process.arch,agentVersion:D,...t?{deviceId:t.deviceId,deviceSecret:t.deviceSecret}:{},...n.forceNew?{forceNew:!0}:n.currentCode?{currentCode:n.currentCode}:{}},i=await fetch(`${e}/api/register`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)});if(!i.ok){let r=await i.text();throw new w(i.status,r)}return i.json()}import Ke from"ws";import Je from"os";import{spawn as me}from"child_process";var de="ABCDEFGHJKMNPQRSTVWXYZ0123456789";var Nt=Math.floor(256/de.length)*de.length;var ge=process.platform==="win32";function fe(e,t,n,o){let i=Date.now(),r=me(e,[],{shell:!0,cwd:t??process.env.HOME,env:{...process.env,...n??{}},stdio:["ignore","pipe","pipe"],detached:!ge}),s=!1,l=null,c=()=>{l&&(clearTimeout(l),l=null)};r.stdout?.on("data",a=>{o.onOutput(a.toString("base64"),"stdout")}),r.stderr?.on("data",a=>{o.onOutput(a.toString("base64"),"stderr")}),r.on("close",a=>{s=!0,c(),o.onDone(a??-1,Date.now()-i)}),r.on("error",a=>{s=!0,c(),o.onError(a.message)});let u=a=>{if(!(s||r.pid==null))try{ge?me("taskkill",["/pid",String(r.pid),"/T","/F"],{stdio:"ignore"}):process.kill(-r.pid,a)}catch{}};return{kill:()=>{s||(u("SIGTERM"),c(),l=setTimeout(()=>{l=null,u("SIGKILL")},5e3),l.unref?.())}}}import O from"chalk";function pe(e,t){console.log(O.yellow(` Reconnecting in ${Math.round(t/1e3)}s (attempt ${e})...`))}function Se(){console.log(O.green(" Admin connected."))}function he(e){console.log(O.cyan(` > ${e}`))}function ye(e,t){let n=e===0?O.green:O.red;console.log(n(` Done. Exit ${e} in ${t}ms`))}var X={capable:!1,enabled:!1,expiresAt:null},_e=360*60*1e3;function Y(e,t){return new Promise((n,o)=>{if(t.aborted){o(new DOMException("Aborted","AbortError"));return}let i=setTimeout(n,e);t.addEventListener("abort",()=>{clearTimeout(i),o(new DOMException("Aborted","AbortError"))},{once:!0})})}async function Z(e){let t=1e3,n=3e4,o=0,i=e.agentToken;for(;!e.signal?.aborted;){o++;try{let r=await Xe(e,i);if(t=1e3,o=0,r&&e.reauth&&!e.signal?.aborted)try{i=await e.reauth()}catch{}}catch(r){if(r.name==="AbortError")break;let s=.8+Math.random()*.4,l=Math.min(t*s,n);e.silent||pe(o,l);try{await Y(l,e.signal??new AbortController().signal)}catch(c){if(c.name==="AbortError")break}t=Math.min(t*2,n)}}}async function Xe(e,t){let{serverUrl:n,sessionCode:o}=e,i=`${n.replace(/^http/,"ws")}/ws/agent/${o}?token=${t}`,r=c=>{try{e.onStatus?.(c)}catch{}},s=c=>{try{e.onActivity?.(c)}catch{}},l=()=>{try{e.onHeartbeat?.()}catch{}};return r("connecting"),new Promise((c,u)=>{let a=new Ke(i),m=null,g=null,_=null,T=!1,y=null,E=!1,L=()=>{_&&(clearTimeout(_),_=null)},f=()=>{y&&(clearInterval(y),y=null)},S=()=>{if(!e.reauthIntervalMs||!e.reauth)return;let d=()=>{if(m){_=setTimeout(d,3e4);return}T=!0,a.close(1e3,"reauth")};_=setTimeout(d,e.reauthIntervalMs)},C=()=>{E||(E=!0,L(),f(),c(T))},b=d=>{E||(E=!0,L(),f(),u(d))},$=()=>{l(),g&&clearTimeout(g),g=setTimeout(()=>{a.terminate(),b(new Error("Ping timeout"))},9e4)};e.signal?.addEventListener("abort",()=>a.close(1e3,"disabled"),{once:!0});let V=()=>{if(E)return;let d={type:"agent:screen_state",screenShare:e.screenShare?.getState()??X};try{a.send(JSON.stringify(d))}catch{}};a.on("open",()=>{r("connected"),$(),S();let d={type:"agent:register",hostname:Je.hostname(),platform:process.platform,arch:process.arch,agentVersion:D,screenShare:e.screenShare?.getState()??X};a.send(JSON.stringify(d)),e.screenShare?.on("change",V),e.silent||Se()}),a.on("message",d=>{let p;try{p=JSON.parse(String(d))}catch{return}switch(p.type){case"do:ping":$();let Be={type:"agent:pong",ts:p.ts};a.send(JSON.stringify(Be));break;case"do:exec":{he(p.command),s(!0);let{commandId:B,command:Fe,cwd:Ge,env:We}=p,le=Date.now(),K=!1;f(),y=setInterval(()=>{let h=Date.now()-le;if(h>=3e5&&!K){K=!0;let v={type:"agent:exec_idle",commandId:B,idleMs:h};a.send(JSON.stringify(v))}},3e5),y.unref?.(),m=fe(Fe,Ge,We,{onOutput:(h,v)=>{le=Date.now(),K=!1;let J={type:"agent:output",commandId:B,chunk:h,stream:v};a.send(JSON.stringify(J))},onDone:(h,v)=>{m=null,f(),s(!1),ye(h,v);let J={type:"agent:done",commandId:B,exitCode:h,durationMs:v};a.send(JSON.stringify(J))},onError:h=>{m=null,f(),s(!1);let v={type:"agent:error",commandId:B,error:h};a.send(JSON.stringify(v))}}).kill;break}case"do:kill":m&&(m(),m=null);break;case"do:screenshot":Ye(a,p.requestId,e.screenShare);break}}),a.on("close",(d,p)=>{g&&clearTimeout(g),f(),e.screenShare?.off("change",V),m&&(m(),m=null,s(!1)),r("disconnected"),d===1e3||d===4001?C():b(new Error(`WS closed: ${d} ${String(p)}`))}),a.on("error",d=>{g&&clearTimeout(g),f(),e.screenShare?.off("change",V),r("disconnected"),b(d)}),a.on("unexpected-response",(d,p)=>{u(new Error(`Unexpected HTTP response: ${p.statusCode}`))})})}async function Ye(e,t,n){let o=s=>{try{e.send(JSON.stringify(s))}catch{}},i=s=>o({type:"agent:screenshot_error",requestId:t,error:s}),r=n?.getState()??X;if(!n||!r.capable){i("This machine cannot share its screen (desktop macOS/Windows only).");return}if(!r.enabled){i("Screen sharing is turned off on this machine.");return}try{let{data:s,mimeType:l}=await n.capture();if(s.length>10485760){let u=(s.length/1048576).toFixed(1);i(`Screenshot is ${u} MB, which exceeds the ${10485760/(1024*1024)} MB limit.`);return}let c=s.toString("base64");for(let u=0;u<c.length;u+=524288)o({type:"agent:screenshot_chunk",requestId:t,chunk:c.slice(u,u+524288)});o({type:"agent:screenshot_done",requestId:t,mimeType:l,totalBytes:s.length})}catch(s){i(`Screenshot failed: ${s.message}`)}}import ct from"node:events";import G from"node:fs";import R from"node:path";import Ze from"node:os";import{randomUUID as qe,randomBytes as Qe}from"node:crypto";var W="/etc/aicommander-agent",j=R.join(Ze.homedir(),".config","aicommander-agent"),M="device.json";function Q(){return{deviceId:qe(),deviceSecret:Qe(32).toString("base64url")}}function ze(e){return typeof e=="object"&&e!==null&&typeof e.deviceId=="string"&&e.deviceId.length>0&&typeof e.deviceSecret=="string"&&e.deviceSecret.length>0}function q(e){try{let t=G.readFileSync(e,"utf8"),n=JSON.parse(t);if(ze(n))return{deviceId:n.deviceId,deviceSecret:n.deviceSecret}}catch{}return null}function Ee(e){try{G.rmSync(R.join(e,M),{force:!0})}catch{}}function x(e,t){try{return G.mkdirSync(e,{recursive:!0,mode:448}),G.writeFileSync(R.join(e,M),JSON.stringify(t,null,2),{encoding:"utf8",mode:384}),!0}catch{return!1}}function N(e){if(e){let i=q(R.join(e,M));if(i)return i;let r=Q();return x(e,r),r}let t=q(R.join(W,M));if(t)return t;let n=q(R.join(j,M));if(n)return n;let o=Q();return x(W,o)||x(j,o),o}function be(e){let t=Q();return e?(x(e,t),t):x(W,t)?(Ee(j),t):(x(j,t),Ee(W),t)}function Te(e){return{"X-Device-Id":e.deviceId,"X-Device-Secret":e.deviceSecret}}async function z(e,t){let n=await fetch(`${e}/api/device/admins`,{headers:Te(t)});if(!n.ok)throw new Error(`Failed to list linked accounts (HTTP ${n.status}).`);return await n.json()}async function Ce(e,t,n,o){let i=await fetch(`${e}/api/device/${n}`,{method:"POST",headers:{...Te(t),"Content-Type":"application/json"},body:JSON.stringify({userId:o})}),r;try{r=await i.json()}catch{r={ok:!1,error:`http_${i.status}`}}return{...r,ok:i.ok}}function ee(e,t,n){return Ce(e,t,"block",n)}function te(e,t,n){return Ce(e,t,"unblock",n)}function et(e){return[...e.filter(t=>!t.blocked),...e.filter(t=>t.blocked)]}function tt(e,t){let n=t.trim();if(!n)return{kind:"not_found"};if(/^\d+$/.test(n)){let r=Number(n)-1;return r>=0&&r<e.length?{kind:"ok",admin:e[r]}:{kind:"not_found"}}let o=e.find(r=>r.userId===n);if(o)return{kind:"ok",admin:o};let i=e.filter(r=>r.userId.startsWith(n));return i.length===1?{kind:"ok",admin:i[0]}:i.length>1?{kind:"ambiguous",matches:i}:{kind:"not_found"}}import A from"node:fs";import I from"node:path";import nt from"node:os";var rt="/etc/aicommander-agent",ot=I.join(nt.homedir(),".config","aicommander-agent"),ne="session.json",we=".rotate";function it(e){return typeof e=="object"&&e!==null&&typeof e.sessionCode=="string"&&e.sessionCode.length>0&&typeof e.agentToken=="string"&&e.agentToken.length>0}function st(e){try{let t=A.readFileSync(e,"utf8"),n=JSON.parse(t);if(it(n))return{sessionCode:n.sessionCode,agentToken:n.agentToken}}catch{}return null}function at(e,t){try{return A.mkdirSync(e,{recursive:!0,mode:448}),A.writeFileSync(I.join(e,ne),JSON.stringify(t,null,2),{encoding:"utf8",mode:384}),!0}catch{return!1}}function P(e){return e?[e]:[rt,ot]}function Re(e){for(let t of P(e)){let n=st(I.join(t,ne));if(n)return n}return null}function re(e,t){for(let n of P(t))if(at(n,e))return}function oe(e){for(let t of P(e))try{A.rmSync(I.join(t,ne),{force:!0})}catch{}}function xe(e){for(let t of P(e))try{A.mkdirSync(t,{recursive:!0,mode:448}),A.writeFileSync(I.join(t,we),"",{mode:384});return}catch{}}function Ie(e){let t=!1;for(let n of P(e)){let o=I.join(n,we);try{A.existsSync(o)&&(t=!0,A.rmSync(o,{force:!0}))}catch{}}return t}var ie=class extends ct{serverUrl;configDir;screenShare;abortController=null;_status="disabled";_code=null;_active=!1;_lastHeartbeatAt=0;constructor(t,n){super(),this.serverUrl=t,this.configDir=n?.configDir,this.screenShare=n?.screenShare,this.on("error",()=>{})}get status(){return this._status}get sessionCode(){return this._code}get active(){return this._active}get lastHeartbeatAt(){return this._lastHeartbeatAt}start(){this.abortController||(this._setStatus("connecting"),this.abortController=new AbortController,this._runLoop(this.abortController))}async _runLoop(t){let n=1e3,o=3e4,i=N(this.configDir),r=!1,s=Ie(this.configDir);for(;!t.signal.aborted;){this._setStatus("connecting");try{let l=s?null:Re(this.configDir),{sessionCode:c,agentToken:u}=await F(this.serverUrl,i,{forceNew:s,...l?{currentCode:l.sessionCode}:{}});if(t.signal.aborted)break;re({sessionCode:c,agentToken:u},this.configDir),s=!1,this._code=c;try{this.emit("code",c)}catch{}await Z({serverUrl:this.serverUrl,sessionCode:c,agentToken:u,signal:t.signal,silent:!0,...this.screenShare?{screenShare:this.screenShare}:{},onStatus:a=>this._setStatus(a),onActivity:a=>this._setActive(a),onHeartbeat:()=>this._onHeartbeat(),reauthIntervalMs:_e,reauth:async()=>{let a=await F(this.serverUrl,i,{currentCode:c});if(t.signal.aborted||this.abortController!==t)throw new DOMException("Aborted","AbortError");return re({sessionCode:a.sessionCode,agentToken:a.agentToken},this.configDir),a.agentToken}});break}catch(l){if(t.signal.aborted)break;if(l instanceof w&&l.status===403&&l.code===ue&&!r){r=!0,console.error(`[AIC] Relay rejected this device: ${l.message}. Regenerating device identity and retrying as a new device.`),i=be(this.configDir),oe(this.configDir),s=!1;continue}l instanceof w&&console.error(`[AIC] ${l.message}`),this._setStatus("disconnected");try{await Y(n,t.signal)}catch{break}n=Math.min(n*2,o)}}this.abortController===t&&(this._setStatus("disabled"),this.abortController=null)}stop(){this.abortController?.abort(),this.abortController=null,this._code=null,this._setStatus("disabled")}async restart(){this.stop(),await Promise.resolve(),this.start()}async changeCode(){oe(this.configDir),xe(this.configDir),await this.restart()}async listAdmins(){let t=N(this.configDir);return z(this.serverUrl,t)}async blockAdmin(t){let n=N(this.configDir);return ee(this.serverUrl,n,t)}async unblockAdmin(t){let n=N(this.configDir);return te(this.serverUrl,n,t)}_setStatus(t){if(t!=="connected"&&this._active&&this._setActive(!1),this._status!==t){this._status=t;try{this.emit("status",t)}catch{}}}_onHeartbeat(){this._lastHeartbeatAt=Date.now();try{this.emit("heartbeat",this._lastHeartbeatAt)}catch{}}_setActive(t){if(this._active!==t){this._active=t;try{this.emit("activity",t)}catch{}}}};import{spawn as lt}from"child_process";import{promises as se}from"fs";import ut from"os";import dt from"path";import mt from"crypto";function gt(){return process.platform==="darwin"||process.platform==="win32"}async function ft(){if(process.platform==="darwin")return pt();if(process.platform==="win32")return St();throw new Error("Screen capture is only supported on the macOS and Windows desktop apps.")}function De(e){return dt.join(ut.tmpdir(),`aic-shot-${mt.randomUUID()}.${e}`)}async function pt(){let e=De("png");try{return await Me("screencapture",["-x","-t","png",e]),await Oe(e,"image/png")}finally{await ke(e)}}async function St(){let e=De("png"),n=["Add-Type -AssemblyName System.Windows.Forms,System.Drawing;","$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;","$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height);","$g = [System.Drawing.Graphics]::FromImage($bmp);","$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size);",`$bmp.Save('${e.replace(/'/g,"''")}', [System.Drawing.Imaging.ImageFormat]::Png);`,"$g.Dispose(); $bmp.Dispose();"].join(" ");try{return await Me("powershell",["-NoProfile","-NonInteractive","-Command",n]),await Oe(e,"image/png")}finally{await ke(e)}}async function Oe(e,t){let n=await se.stat(e);if(n.size>10485760){let i=(n.size/1048576).toFixed(1);throw new Error(`Screenshot is ${i} MB, which exceeds the ${10485760/(1024*1024)} MB limit.`)}return{data:await se.readFile(e),mimeType:t}}async function ke(e){try{await se.unlink(e)}catch{}}function Me(e,t){return new Promise((n,o)=>{let i=lt(e,t,{stdio:["ignore","ignore","pipe"]}),r="";i.stderr?.on("data",s=>{r+=s.toString()}),i.on("error",s=>o(s)),i.on("close",s=>{s===0?n():o(new Error(`${e} exited with code ${s}${r?`: ${r.trim()}`:""}`))})})}import{spawn as Tt}from"child_process";import U from"fs";import Ne from"path";import yt from"os";function ae(e){if(e)return Ne.join(e,"heartbeat");if(process.platform==="linux")try{return U.mkdirSync("/run/aicommander-agent",{recursive:!0}),"/run/aicommander-agent/heartbeat"}catch{}return Ne.join(yt.tmpdir(),"aicommander-agent-heartbeat")}function Pe(e){try{let t=`${e}.tmp`;U.writeFileSync(t,String(Date.now())),U.renameSync(t,e)}catch{}}var H=null;function vt(e,t=5e3){He(),Pe(e),H=setInterval(()=>Pe(e),t),H.unref?.()}function He(){H&&(clearInterval(H),H=null)}function ce(e){try{let t=U.readFileSync(e,"utf8").trim(),n=Number(t);return Number.isFinite(n)?n:null}catch{return null}}async function Ct(e={}){let t=e.heartbeatPath??ae(e.configDir),n=e.spawnWorker??(a=>Tt(process.execPath,process.argv.slice(1),{env:a,stdio:"inherit"})),o=null,i=0,r=!1,s=0,l=[],c=null,u=null;await new Promise(a=>{let m=null,g=null,_=()=>{c&&(clearInterval(c),c=null),u&&(clearTimeout(u),u=null),m&&(process.removeListener("SIGINT",m),m=null),g&&(process.removeListener("SIGTERM",g),g=null)},T=()=>{_(),a()},y=()=>{i=Date.now(),s++;let S={...process.env,AIC_ROLE:"worker",AIC_HEARTBEAT:t};o=n(S),o.on("exit",(C,b)=>{if(o=null,r){T();return}if(e.maxSpawns!=null&&s>=e.maxSpawns){T();return}E(`worker exited (code=${C??"null"} signal=${b??"null"})`)}),o.on("error",()=>{})},E=S=>{let C=Date.now();l=l.filter($=>C-$<3e5),l.push(C);let b=l.length>5?6e4:0;u&&clearTimeout(u),u=setTimeout(()=>{u=null,r||y()},b),u.unref?.()},L=()=>{if(o)try{o.kill("SIGKILL")}catch{}};c=setInterval(()=>{if(r||!o||Date.now()-i<3e4)return;let S=ce(t);S!=null&&Date.now()-S>3e4&&L()},1e4),c.unref?.();let f=S=>{if(r=!0,c&&(clearInterval(c),c=null),u&&(clearTimeout(u),u=null),o)try{o.kill(S)}catch{}else T()};m=()=>f("SIGINT"),g=()=>f("SIGTERM"),process.on("SIGINT",m),process.on("SIGTERM",g),y()})}function $e(e,t){let n=e.split(".").map(r=>parseInt(r,10)||0),o=t.split(".").map(r=>parseInt(r,10)||0),i=Math.max(n.length,o.length);for(let r=0;r<i;r++){let s=(n[r]??0)-(o[r]??0);if(s!==0)return s>0?1:-1}return 0}function wt(e,t){return $e(e,t)>0}async function Rt(e,t=1e4){let n=new AbortController,o=setTimeout(()=>n.abort(),t);try{let i=await fetch(`${e.replace(/\/+$/,"")}/dist/latest`,{signal:n.signal,headers:{Accept:"application/json"}});if(!i.ok)return null;let r=await i.json();return{version:typeof r.version=="string"?r.version:null,mac:typeof r.mac=="string"?r.mac:null,win:typeof r.win=="string"?r.win:null,agentLinuxX64:typeof r.agentLinuxX64=="string"?r.agentLinuxX64:null,agentLinuxArm64:typeof r.agentLinuxArm64=="string"?r.agentLinuxArm64:null}}catch{return null}finally{clearTimeout(o)}}export{D as AGENT_VERSION,ie as AgentController,ee as blockAdmin,gt as canCaptureScreenshot,ft as captureScreenshot,$e as compareVersions,ae as defaultHeartbeatPath,z as fetchAdmins,Rt as fetchLatestDist,wt as isNewerVersion,et as orderAdmins,ce as readHeartbeat,F as register,tt as resolveAdminIdentifier,Z as runConnectionLoop,Ct as runSupervisor,vt as startHeartbeat,He as stopHeartbeat,te as unblockAdmin};
@@ -0,0 +1,73 @@
1
+ import EventEmitter from "node:events";
2
+ import type { AdminsResult, BlockResult } from "./device-admin.js";
3
+ import type { ScreenShareState } from "@aicommander/protocol";
4
+ import type { CapturedScreenshot } from "./screenshot.js";
5
+ export type AgentStatus = "connecting" | "connected" | "disconnected" | "disabled";
6
+ /**
7
+ * Supplies the connection layer with the desktop screen-share grant state and a
8
+ * way to capture the screen. Only the desktop app provides one; the headless
9
+ * Linux agent leaves it undefined (so it reports `capable: false`). Implementers
10
+ * emit "change" whenever the grant flips (user toggle or 24h auto-expiry) so the
11
+ * live connection can push the new state to the relay without reconnecting.
12
+ */
13
+ export interface ScreenShareProvider {
14
+ getState(): ScreenShareState;
15
+ capture(): Promise<CapturedScreenshot>;
16
+ on(event: "change", listener: () => void): void;
17
+ off(event: "change", listener: () => void): void;
18
+ }
19
+ export interface AgentControllerOptions {
20
+ /**
21
+ * Directory for the durable device identity + session store. The desktop app
22
+ * passes its per-user data dir (e.g. Electron's `app.getPath("userData")`) so
23
+ * the STABLE session code survives app updates and is removed only on full
24
+ * uninstall. When omitted, the device/session-store default paths are used
25
+ * (/etc with ~/.config fallback) — the Linux CLI behavior.
26
+ */
27
+ configDir?: string;
28
+ /**
29
+ * Desktop-only screen-share provider. When supplied, the agent advertises
30
+ * screen-share capability/state and can answer screenshot requests.
31
+ */
32
+ screenShare?: ScreenShareProvider;
33
+ }
34
+ export declare class AgentController extends EventEmitter {
35
+ private serverUrl;
36
+ private configDir?;
37
+ private screenShare?;
38
+ private abortController;
39
+ private _status;
40
+ private _code;
41
+ private _active;
42
+ private _lastHeartbeatAt;
43
+ constructor(serverUrl: string, opts?: AgentControllerOptions);
44
+ get status(): AgentStatus;
45
+ get sessionCode(): string | null;
46
+ /** True while a remote command is actively executing on this machine. */
47
+ get active(): boolean;
48
+ /** Epoch-ms of the last proof the link is alive (open or server ping), or 0. */
49
+ get lastHeartbeatAt(): number;
50
+ start(): void;
51
+ private _runLoop;
52
+ stop(): void;
53
+ restart(): Promise<void>;
54
+ /**
55
+ * Force a brand-new session code (change-code path). Clears the stored session
56
+ * and writes the one-shot rotate marker, then restarts the connection so the
57
+ * register loop mints a fresh code with `forceNew`. Emits the new `code`.
58
+ */
59
+ changeCode(): Promise<void>;
60
+ /**
61
+ * List the accounts ("admins") linked to THIS device, with masked emails. Uses
62
+ * the controller's own device identity (configDir) so the relay sees the exact
63
+ * registered device. For the desktop "Linked Accounts" UI.
64
+ */
65
+ listAdmins(): Promise<AdminsResult>;
66
+ /** Block one linked account by userId — refuses its access, keeps it listed. */
67
+ blockAdmin(userId: string): Promise<BlockResult>;
68
+ /** Unblock one account by userId — restores its access immediately. */
69
+ unblockAdmin(userId: string): Promise<BlockResult>;
70
+ private _setStatus;
71
+ private _onHeartbeat;
72
+ private _setActive;
73
+ }
@@ -0,0 +1,32 @@
1
+ import type { ScreenShareProvider } from "./agent-controller.js";
2
+ interface ConnectionOpts {
3
+ serverUrl: string;
4
+ sessionCode: string;
5
+ agentToken: string;
6
+ signal?: AbortSignal;
7
+ /** Desktop-only screen-share provider; absent on the headless Linux agent. */
8
+ screenShare?: ScreenShareProvider;
9
+ onStatus?: (s: 'connecting' | 'connected' | 'disconnected') => void;
10
+ onActivity?: (active: boolean) => void;
11
+ /**
12
+ * Fired whenever the link proves alive (on open and on every server ping).
13
+ * Lets a supervisor watch link liveness independently of command workload —
14
+ * pings flow every 20s even during a long command, so a stalled heartbeat
15
+ * means a genuinely dead link, not a busy one.
16
+ */
17
+ onHeartbeat?: () => void;
18
+ silent?: boolean;
19
+ /**
20
+ * If set, the agent rotates its agentToken roughly this often: while idle it
21
+ * cleanly drops the connection, calls `reauth()` for a fresh token, and
22
+ * reconnects — bounding the lifetime of any single (potentially leaked) token.
23
+ */
24
+ reauthIntervalMs?: number;
25
+ /** Re-register and return a fresh agentToken (caller persists it). */
26
+ reauth?: () => Promise<string>;
27
+ }
28
+ /** Default agent-token rotation cadence (6h). */
29
+ export declare const AGENT_TOKEN_ROTATE_MS: number;
30
+ export declare function sleepAbortable(ms: number, signal: AbortSignal): Promise<void>;
31
+ export declare function runConnectionLoop(opts: ConnectionOpts): Promise<void>;
32
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare function cmdBlockAdmin(identifier: string): Promise<void>;
2
+ export declare function cmdUnblockAdmin(identifier: string): Promise<void>;
@@ -0,0 +1,3 @@
1
+ export declare function cmdChangeCode(opts?: {
2
+ yes?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1 @@
1
+ export declare function cmdDisable(): void;
@@ -0,0 +1 @@
1
+ export declare function cmdEnable(): void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Install the agent as a systemd service (Linux, root). Mirrors web/install — the
3
+ * shell installer served from the relay — but installs the LOCALLY running CLI as
4
+ * the service binary instead of downloading the signed standalone binary.
5
+ */
6
+ export declare function cmdInstall(opts: {
7
+ server?: string;
8
+ }): Promise<void>;
@@ -0,0 +1 @@
1
+ export declare function cmdListAdmins(): Promise<void>;
@@ -0,0 +1 @@
1
+ export declare function cmdRevokeAdmin(identifier: string): Promise<void>;
@@ -0,0 +1,3 @@
1
+ export declare function cmdStatus(opts?: {
2
+ reveal?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,3 @@
1
+ export declare function cmdUninstall(opts: {
2
+ force: boolean;
3
+ }): void;
@@ -0,0 +1,8 @@
1
+ export { cmdInstall } from "./commands/install.js";
2
+ export { cmdStatus } from "./commands/status.js";
3
+ export { cmdEnable } from "./commands/enable.js";
4
+ export { cmdDisable } from "./commands/disable.js";
5
+ export { cmdChangeCode } from "./commands/change-code.js";
6
+ export { cmdUninstall } from "./commands/uninstall.js";
7
+ export { cmdListAdmins } from "./commands/list-admins.js";
8
+ export { cmdBlockAdmin, cmdUnblockAdmin } from "./commands/block-admin.js";
@@ -0,0 +1,9 @@
1
+ export declare function systemctlActiveState(): string;
2
+ export declare function systemctlIsEnabled(): boolean;
3
+ export declare function systemctlStart(): void;
4
+ export declare function systemctlStop(): void;
5
+ export declare function systemctlEnable(): void;
6
+ export declare function systemctlDisable(): void;
7
+ export declare function systemctlRestart(): void;
8
+ export declare function daemonReload(): void;
9
+ export declare function systemctlKill(): void;
@@ -0,0 +1,15 @@
1
+ export declare const ui: {
2
+ header: (msg: string) => void;
3
+ ok: (msg: string) => void;
4
+ warn: (msg: string) => void;
5
+ error: (msg: string) => void;
6
+ info: (key: string, val: string) => void;
7
+ step: (msg: string) => void;
8
+ blank: () => void;
9
+ };
10
+ export declare function requireRoot(): void;
11
+ /**
12
+ * Yes/no prompt. Returns false when stdin isn't a TTY (so scripted callers must
13
+ * pass an explicit --yes flag rather than hanging or silently proceeding).
14
+ */
15
+ export declare function confirm(question: string): Promise<boolean>;
@@ -0,0 +1,50 @@
1
+ import type { DeviceIdentity } from "./device.js";
2
+ export interface AdminEntry {
3
+ userId: string;
4
+ /** Already masked by the server — a full address never leaves the relay. */
5
+ maskedEmail: string;
6
+ alias: string;
7
+ linkedAt: string;
8
+ lastSeenAt: string | null;
9
+ /** True when the owner has blocked this account on this device. */
10
+ blocked: boolean;
11
+ }
12
+ export interface AdminsResult {
13
+ /** True when the current code is still in its 1h no-account-needed window. */
14
+ codeFresh: boolean;
15
+ admins: AdminEntry[];
16
+ }
17
+ export interface BlockResult {
18
+ ok: boolean;
19
+ blocked?: number;
20
+ unblocked?: number;
21
+ /** Set on refusal/failure: "not_found" | other. */
22
+ error?: string;
23
+ message?: string;
24
+ }
25
+ /** List the accounts linked to this device (masked emails). Throws on transport/auth failure. */
26
+ export declare function fetchAdmins(serverUrl: string, device: DeviceIdentity): Promise<AdminsResult>;
27
+ /** Block one account by userId. Returns a structured result (never throws on a 4xx). */
28
+ export declare function blockAdmin(serverUrl: string, device: DeviceIdentity, userId: string): Promise<BlockResult>;
29
+ /** Unblock one account by userId, restoring its access. */
30
+ export declare function unblockAdmin(serverUrl: string, device: DeviceIdentity, userId: string): Promise<BlockResult>;
31
+ /**
32
+ * Stable display/resolution order: active accounts first, blocked ones last,
33
+ * each group preserving the server's order. Used so a list index means the same
34
+ * thing in `list-admins`, `block-admin`, and `unblock-admin`.
35
+ */
36
+ export declare function orderAdmins(admins: AdminEntry[]): AdminEntry[];
37
+ export type ResolveResult = {
38
+ kind: "ok";
39
+ admin: AdminEntry;
40
+ } | {
41
+ kind: "not_found";
42
+ } | {
43
+ kind: "ambiguous";
44
+ matches: AdminEntry[];
45
+ };
46
+ /**
47
+ * Resolve a user-typed identifier against a listing. Accepts (in order): a 1-based
48
+ * list index, an exact userId, or an unambiguous userId prefix. Pure — unit tested.
49
+ */
50
+ export declare function resolveAdminIdentifier(admins: AdminEntry[], identifier: string): ResolveResult;
@@ -0,0 +1,33 @@
1
+ export interface DeviceIdentity {
2
+ deviceId: string;
3
+ deviceSecret: string;
4
+ }
5
+ /**
6
+ * Load the persisted device identity, or create and persist a new one.
7
+ *
8
+ * The same deviceSecret must be sent on every registration so the relay can map
9
+ * a re-registering agent (after reboot or 24h session-code rotation) back to the
10
+ * same saved machine.
11
+ *
12
+ * When `configDir` is provided (e.g. the desktop app's per-user data dir), the
13
+ * identity is stored directly under it (`<configDir>/device.json`). Otherwise we
14
+ * try the primary (/etc) location first, then fall back to the user config dir if
15
+ * /etc is not writable (non-root / dev).
16
+ */
17
+ export declare function loadOrCreateDevice(configDir?: string): DeviceIdentity;
18
+ /**
19
+ * Mint and persist a BRAND-NEW device identity, overwriting any existing one.
20
+ *
21
+ * Recovery path for when the relay rejects the current identity (a 403 device
22
+ * secret mismatch — e.g. a stale relay record whose secret no longer matches).
23
+ * Retrying the same identity would 403 forever, so the agent regenerates once
24
+ * and re-registers as a fresh device. The machine then appears as new on the
25
+ * relay (any prior account/alias binding must be re-established).
26
+ *
27
+ * Crucially, we also remove any stale identity file in the NON-target directory.
28
+ * `loadOrCreateDevice` reads PRIMARY first, then FALLBACK — so a lingering old
29
+ * file in the directory we didn't write to could be read back on the next start,
30
+ * resurrecting the rejected identity and re-entering the 403 loop. Removal is
31
+ * best-effort (ENOENT / permission errors are ignored).
32
+ */
33
+ export declare function regenerateDevice(configDir?: string): DeviceIdentity;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Render the session code on screen for the running agent.
3
+ *
4
+ * `reveal` controls whether the FULL code or only the masked form is shown:
5
+ * - FOREGROUND interactive runs (`npx`, `aicommander-agent run` in a terminal)
6
+ * pass reveal=true. The user launched it in their own terminal to obtain the
7
+ * code there — there is no journald/screenshare persistence concern of the
8
+ * systemd service path, and no `status --reveal` to query (no service,
9
+ * no writable /var/run state). Showing the masked form would make the
10
+ * foreground flow unusable.
11
+ * - The masked form (reveal=false) is kept for any long-lived service/log
12
+ * context where the output could leak via journald/screenshare.
13
+ *
14
+ * The "keep secret" warning is shown in BOTH modes.
15
+ */
16
+ export declare function showCode(sessionCode: string, serverUrl: string, reveal?: boolean): void;
17
+ export declare function showReconnecting(attempt: number, delayMs: number): void;
18
+ export declare function showConnected(): void;
19
+ export declare function showExecuting(command: string): void;
20
+ export declare function showDone(exitCode: number, durationMs: number): void;
@@ -0,0 +1,9 @@
1
+ export interface CommandHandlers {
2
+ onOutput: (chunk: string, stream: "stdout" | "stderr") => void;
3
+ onDone: (exitCode: number, durationMs: number) => void;
4
+ onError: (error: string) => void;
5
+ }
6
+ export interface RunningCommand {
7
+ kill: () => void;
8
+ }
9
+ export declare function executeCommand(command: string, cwd: string | undefined, env: Record<string, string> | undefined, handlers: CommandHandlers): RunningCommand;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The worker stamps a heartbeat file from a steady timer. The stamp proves only
3
+ * that the event loop is turning — it is INTENTIONALLY independent of command
4
+ * workload, so a long or quiet command never looks "stuck". The supervisor reads
5
+ * this file and force-restarts the worker only when the stamp goes stale, which
6
+ * means a genuinely wedged loop (no benign explanation) → zero false positives.
7
+ */
8
+ /** Default heartbeat path. Prefers the systemd RuntimeDirectory (tmpfs). */
9
+ export declare function defaultHeartbeatPath(configDir?: string): string;
10
+ /** Begin stamping `file` every `intervalMs`. Idempotent. */
11
+ export declare function startHeartbeat(file: string, intervalMs?: number): void;
12
+ export declare function stopHeartbeat(): void;
13
+ /** Read the last heartbeat epoch-ms, or null if missing/unreadable. */
14
+ export declare function readHeartbeat(file: string): number | null;
@@ -0,0 +1,14 @@
1
+ export { register } from "./register.js";
2
+ export { runConnectionLoop } from "./connection.js";
3
+ export { AgentController } from "./agent-controller.js";
4
+ export type { AgentStatus, ScreenShareProvider } from "./agent-controller.js";
5
+ export { fetchAdmins, blockAdmin, unblockAdmin, orderAdmins, resolveAdminIdentifier } from "./device-admin.js";
6
+ export type { AdminEntry, AdminsResult, BlockResult } from "./device-admin.js";
7
+ export { captureScreenshot, canCaptureScreenshot } from "./screenshot.js";
8
+ export type { CapturedScreenshot } from "./screenshot.js";
9
+ export { runSupervisor } from "./supervisor.js";
10
+ export type { SupervisorOptions } from "./supervisor.js";
11
+ export { startHeartbeat, stopHeartbeat, readHeartbeat, defaultHeartbeatPath, } from "./heartbeat.js";
12
+ export { compareVersions, isNewerVersion, fetchLatestDist, } from "./update-check.js";
13
+ export type { LatestDist } from "./update-check.js";
14
+ export { AGENT_VERSION } from "./version.js";
@@ -0,0 +1,31 @@
1
+ import type { DeviceIdentity } from "./device.js";
2
+ export interface RegisterResult {
3
+ sessionCode: string;
4
+ agentToken: string;
5
+ }
6
+ /** Machine-readable `code` the relay sends for a stale/unknown device identity
7
+ * 403 — the ONLY 403 that should trigger identity regeneration. */
8
+ export declare const DEVICE_SECRET_MISMATCH = "device_secret_mismatch";
9
+ /**
10
+ * Thrown when the relay rejects a registration with a non-2xx status. Carries
11
+ * the HTTP `status` plus the relay's machine-readable `code` (parsed from the
12
+ * JSON body, when present) so callers can distinguish a fatal client error
13
+ * (e.g. a `device_secret_mismatch` 403 — retrying the same identity will fail
14
+ * forever, so the agent must regenerate) from a transient/server error or some
15
+ * OTHER 403 that must NOT discard the device identity.
16
+ */
17
+ export declare class RegistrationError extends Error {
18
+ readonly status: number;
19
+ readonly body: string;
20
+ /** Relay-supplied machine-readable error code, or null if the body had none. */
21
+ readonly code: string | null;
22
+ constructor(status: number, body: string);
23
+ }
24
+ export interface RegisterOptions {
25
+ /** The stored session code, so the server can restore it if its KV record
26
+ * was evicted. Ignored when `forceNew` is set. */
27
+ currentCode?: string;
28
+ /** Force a brand-new code (change-code path). */
29
+ forceNew?: boolean;
30
+ }
31
+ export declare function register(serverUrl: string, device?: DeviceIdentity, options?: RegisterOptions): Promise<RegisterResult>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Entry point for the `run` command. Splits into two roles for self-healing:
3
+ *
4
+ * - SUPERVISOR (default): a thin parent that spawns the real agent as a worker
5
+ * and force-restarts it if its event loop wedges (see supervisor.ts).
6
+ * - WORKER (AIC_ROLE=worker): the actual agent — registers, connects, executes,
7
+ * and stamps a heartbeat the supervisor watches.
8
+ *
9
+ * The supervisor sets AIC_ROLE=worker when spawning, so re-execing the binary
10
+ * lands in the worker branch.
11
+ */
12
+ export declare function startAgent(): Promise<void>;
13
+ /** Run the agent: register with the relay, show the session code, and listen for commands. */
14
+ export declare function runAgent(): Promise<void>;
@@ -0,0 +1,19 @@
1
+ export interface CapturedScreenshot {
2
+ /** Raw image bytes. */
3
+ data: Buffer;
4
+ /** MIME type of `data` (always image/png today). */
5
+ mimeType: string;
6
+ }
7
+ /** True on platforms where we know how to grab the screen (desktop mac/win). */
8
+ export declare function canCaptureScreenshot(): boolean;
9
+ /**
10
+ * Capture the primary display as a PNG using the OS's built-in tooling — no
11
+ * native dependencies. macOS uses `screencapture`; Windows shells out to
12
+ * PowerShell + System.Drawing. Throws on any other platform (headless Linux),
13
+ * on capture failure, or if the result exceeds SCREENSHOT_MAX_BYTES.
14
+ *
15
+ * On macOS the capture requires the app to hold the "Screen Recording" (TCC)
16
+ * permission; without it the system yields a black image rather than an error,
17
+ * so the desktop app is responsible for prompting the user to grant it.
18
+ */
19
+ export declare function captureScreenshot(): Promise<CapturedScreenshot>;
@@ -0,0 +1,18 @@
1
+ export interface StoredSession {
2
+ sessionCode: string;
3
+ agentToken: string;
4
+ }
5
+ /** Load the persisted session (primary first), or null if none. */
6
+ export declare function loadSession(configDir?: string): StoredSession | null;
7
+ /** Persist the session to the primary location, falling back to the user dir. */
8
+ export declare function saveSession(session: StoredSession, configDir?: string): void;
9
+ /** Remove the persisted session from both locations (best-effort). */
10
+ export declare function clearSession(configDir?: string): void;
11
+ /** Write the one-shot rotate marker (forces a new code on next startup). */
12
+ export declare function writeRotateMarker(configDir?: string): void;
13
+ /**
14
+ * If the rotate marker exists, delete it and return true. Used at startup to
15
+ * decide whether to force a brand-new code (change-code path) vs re-assert the
16
+ * existing one (reboot/restart path).
17
+ */
18
+ export declare function consumeRotateMarker(configDir?: string): boolean;
@@ -0,0 +1,9 @@
1
+ export interface AgentState {
2
+ sessionCode: string;
3
+ pid: number;
4
+ startedAt: string;
5
+ serverUrl: string;
6
+ }
7
+ export declare function writeState(state: AgentState): Promise<void>;
8
+ export declare function clearState(): Promise<void>;
9
+ export declare function readState(): Promise<AgentState | null>;
@@ -0,0 +1,19 @@
1
+ import { type ChildProcess } from "child_process";
2
+ export interface SupervisorOptions {
3
+ /** Override the heartbeat file location (defaults to the RuntimeDirectory). */
4
+ heartbeatPath?: string;
5
+ /** Override the data dir used to derive the default heartbeat path. */
6
+ configDir?: string;
7
+ /** Test seam: spawn a worker child. Defaults to re-execing this binary. */
8
+ spawnWorker?: (env: NodeJS.ProcessEnv) => ChildProcess;
9
+ /** Test seam: stop after the worker has been (re)spawned this many times. */
10
+ maxSpawns?: number;
11
+ }
12
+ /**
13
+ * Thin, long-lived parent that runs the real agent as a WORKER child and
14
+ * force-restarts it when its heartbeat goes stale (a wedged event loop) or it
15
+ * exits unexpectedly. The supervisor itself does no WebSocket or command work,
16
+ * so it virtually cannot wedge; systemd's `Restart=always` covers the rare case
17
+ * where the supervisor itself dies.
18
+ */
19
+ export declare function runSupervisor(opts?: SupervisorOptions): Promise<void>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Lightweight "is there a newer release?" check shared by the desktop tray and
3
+ * the headless Linux agent. Apps poll the small `/dist/latest` JSON (a few
4
+ * hundred bytes) — NOT the multi-MB binaries — and compare the published
5
+ * `version` against their own. Downloading the actual build only happens when
6
+ * the user explicitly clicks "Download" / runs the install script.
7
+ */
8
+ export interface LatestDist {
9
+ /** Latest published version, e.g. "1.0.18". null until a release is published. */
10
+ version: string | null;
11
+ /** Direct download paths (relative to the server origin), null if missing. */
12
+ mac: string | null;
13
+ win: string | null;
14
+ agentLinuxX64: string | null;
15
+ agentLinuxArm64: string | null;
16
+ }
17
+ /**
18
+ * Compare dotted numeric versions ("1.0.18" vs "1.0.9"). Returns >0 if a>b,
19
+ * <0 if a<b, 0 if equal. Missing/short segments count as 0; non-numeric parts
20
+ * (e.g. a "-beta" suffix) are coerced to 0 so a malformed tag never throws.
21
+ */
22
+ export declare function compareVersions(a: string, b: string): number;
23
+ /** True when `latest` is a strictly newer version than `current`. */
24
+ export declare function isNewerVersion(latest: string, current: string): boolean;
25
+ /**
26
+ * Fetch the `/dist/latest` metadata blob. Returns null on any network error,
27
+ * non-2xx, timeout, or malformed body — callers treat null as "couldn't check,
28
+ * assume up to date" and never surface an error to the user.
29
+ */
30
+ export declare function fetchLatestDist(serverUrl: string, timeoutMs?: number): Promise<LatestDist | null>;
@@ -0,0 +1 @@
1
+ export declare const AGENT_VERSION = "1.0.19";
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@aicommander/agent",
3
+ "version": "1.0.19",
4
+ "description": "Remote-machine agent for AI Commander — runs on your server/laptop and lets your AI agent execute shell commands on it via the @aicommander/mcp server. An SSH alternative with no exposed SSH, open ports, or VPN; drive it by AIC-… session code.",
5
+ "keywords": [
6
+ "remote",
7
+ "remote-execution",
8
+ "agent",
9
+ "devops",
10
+ "sysadmin",
11
+ "systemd",
12
+ "ai-agent",
13
+ "claude",
14
+ "aicommander",
15
+ "ssh-alternative"
16
+ ],
17
+ "homepage": "https://aicommander.dev",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/lksrz/aicommander.git",
22
+ "directory": "packages/agent"
23
+ },
24
+ "type": "module",
25
+ "bin": {
26
+ "aicommander-agent": "./dist/bin/agent.js"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "files": [
31
+ "dist/index.js",
32
+ "dist/index.d.ts",
33
+ "dist/bin",
34
+ "dist/src/**/*.d.ts",
35
+ "!dist/**/__tests__",
36
+ "!dist/**/*.test.*",
37
+ "!dist/**/*.map",
38
+ "README.md"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "chalk": "5.6.2",
45
+ "commander": "12.1.0",
46
+ "ora": "8.2.0",
47
+ "ws": "8.21.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "22.19.21",
51
+ "@types/ws": "8.18.1",
52
+ "esbuild": "0.28.1",
53
+ "tsx": "^4.22.4",
54
+ "typescript": "5.9.3",
55
+ "vitest": "3.2.6",
56
+ "@aicommander/protocol": "1.0.0"
57
+ },
58
+ "engines": {
59
+ "node": ">=18.0.0"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc && node scripts/build-npm.mjs",
63
+ "build:npm": "node scripts/build-npm.mjs",
64
+ "compile": "pnpm run compile:linux-x64 && pnpm run compile:linux-arm64",
65
+ "compile:linux-x64": "bun build ./bin/agent.ts --compile --minify --target=bun-linux-x64 --outfile dist-bin/aicommander-agent-linux-x64",
66
+ "compile:linux-arm64": "bun build ./bin/agent.ts --compile --minify --target=bun-linux-arm64 --outfile dist-bin/aicommander-agent-linux-arm64",
67
+ "dev": "tsx bin/agent.ts",
68
+ "typecheck": "tsc --noEmit",
69
+ "start": "AICOMMANDER_SERVER=https://aicommander.dev node dist/bin/agent.js",
70
+ "start:root": "sudo -E AICOMMANDER_SERVER=https://aicommander.dev node dist/bin/agent.js",
71
+ "test": "vitest run"
72
+ }
73
+ }