ssh-fleet-mcp-server 0.0.1 → 0.2.0

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/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import{createRequire as e}from"node:module";import{createHash as t,randomBytes as n,randomUUID as r}from"node:crypto";import{chmodSync as i,closeSync as a,constants as o,existsSync as s,fchmodSync as c,fstatSync as l,mkdirSync as u,openSync as d,readFileSync as f,readdirSync as p,rmdirSync as m,statSync as h,unlinkSync as g,writeFileSync as _,writeSync as v}from"node:fs";import{constants as y,homedir as b,uptime as x}from"node:os";import{basename as S,dirname as C,join as w}from"node:path";import{Either as T,Left as E,List as ee,Map as D,Option as O,Right as k,Set as te,Try as A}from"functype";import{Fs as ne,Path as j}from"functype-os";import{spawn as re}from"node:child_process";import{UserError as M}from"somamcp";import{Client as ie}from"ssh2";import{createServer as ae}from"somamcp/node";import{z as N}from"zod";import{lstat as oe,readFile as se}from"node:fs/promises";import{isIP as ce}from"node:net";import{parse as le}from"smol-toml";const ue=async e=>{if(e.password.isSome())return k({password:e.password.value});if(e.keyPath.isSome()){let t=j.expand(e.keyPath.value);if(t.isLeft())return E(`Invalid SSH key path ${e.keyPath.value}: ${t.value.message}`);let n=t.value;return(await ne.readFile(n)).fold(e=>E(`Failed to read SSH key ${n}: ${e.message}`),e=>k({privateKey:e}))}if(e.keyEnvVar.isSome()){let t=e.keyEnvVar.value,n=process.env[t];return n?k({privateKey:n}):E(`--key-env=${t} but environment variable is not set or empty`)}if(e.useAgent){let e=process.env.SSH_AUTH_SOCK;return e?k({agent:e}):E(`--agent set but SSH_AUTH_SOCK is not set; start ssh-agent or unlock your password manager's SSH agent`)}return k({})},de=[`PATH`,`HOME`,`USER`,`LOGNAME`,`LANG`],P=(e={})=>({...Object.fromEntries(de.flatMap(e=>{let t=process.env[e];return t===void 0?[]:[[e,t]]})),...e}),F={run:(e,t,{env:n,input:r,timeoutMs:i})=>new Promise((a,o)=>{let s=re(e,[...t],{env:{...n},stdio:[`pipe`,`pipe`,`pipe`],detached:i!==void 0}),c=[],l=[],u={done:!1},d=e=>{u.done||(u.done=!0,clearTimeout(f),a(e))},f=setTimeout(()=>{let{pid:e}=s;e!==void 0&&A(()=>process.kill(-e,`SIGKILL`)),d({code:-1,stdout:Buffer.concat(c).toString(),stderr:Buffer.concat(l).toString(),timedOut:!0})},i??2**31-1);s.stdout.on(`data`,e=>c.push(e)),s.stderr.on(`data`,e=>l.push(e)),s.on(`error`,e=>{clearTimeout(f),o(e)}),s.on(`close`,e=>d({code:e??-1,stdout:Buffer.concat(c).toString(),stderr:Buffer.concat(l).toString()})),s.stdin.on(`error`,()=>void 0),s.stdin.end(r??``)}),spawn:(e,t,{env:n})=>{let r=re(e,[...t],{env:{...n},stdio:[`ignore`,`ignore`,`pipe`]}),i={tail:``,exited:!1,code:null},a=[],o=e=>{i.exited||(i.exited=!0,i.code=e,a.splice(0).forEach(t=>t(e)))};return r.stderr.on(`data`,e=>{i.tail=(i.tail+e.toString()).slice(-4096)}),r.on(`exit`,e=>o(e)),r.on(`error`,e=>{i.tail=(i.tail+e.message).slice(-4096),o(null)}),{onExit:e=>{i.exited?queueMicrotask(()=>e(i.code)):a.push(e)},kill:()=>{i.exited||r.kill()},stderr:()=>i.tail,exited:()=>i.exited,pid:()=>r.pid}}},fe={SSH_ASKPASS:`/bin/false`,SSH_ASKPASS_REQUIRE:`force`},pe=[[`ssh`,[`-V`]],[`ssh-keygen`,[`-?`]],[`ssh-agent`,[`-?`]],[`ssh-add`,[`-?`]]],me=async e=>{let t=await Promise.all(pe.map(([t,n])=>e.run(t,n,{env:P()}).then(()=>!0,()=>!1))),n=pe.filter((e,n)=>!t[n]).map(([e])=>e);return n.length===0?k(void 0):E(`OpenSSH binaries missing: ${n.join(`, `)}`)},he=e=>new Promise(t=>setTimeout(t,e)),ge=(e,t)=>e()?Promise.resolve(!0):Date.now()>t?Promise.resolve(!1):he(50).then(()=>ge(e,t)),_e=e=>A(()=>process.kill(e,0)).fold(()=>!1,()=>!0),ve=e=>e===`agent.sock`||e===`agent.pid`||/^m-.*\.(sock|pid)$/.test(e)||e.startsWith(`k-`)||/^ca-.*\.pub$/.test(e),ye=`hub.lock`,be=e=>e===ye||ve(e),xe=e=>A(()=>Number(f(e,`utf8`).trim())).orElse(NaN),Se=e=>Number.isInteger(e)&&e>0&&_e(e),Ce=async(e,t,n,r)=>{if(!Se(e))return;let i=await r.run(`ps`,[`-ww`,`-p`,String(e),`-o`,`args=`],{env:P()}).catch(()=>({code:1,stdout:``,stderr:``})),a=i.stdout.trim(),o=S(a.split(` `)[0]??``).replace(/:$/,``);i.code===0&&o===t&&a.includes(n)&&(A(()=>process.kill(e,`SIGTERM`)),await ge(()=>!_e(e),Date.now()+2e3))},we=async(e,t)=>{await Ce(xe(w(e,`agent.pid`)),`ssh-agent`,w(e,`agent.sock`),t);let n=p(e).filter(e=>/^m-.*\.pid$/.test(e));await Promise.all(n.map(n=>Ce(xe(w(e,n)),`ssh`,w(e,n.replace(/\.pid$/,`.sock`)),t))),p(e).filter(ve).forEach(t=>A(()=>g(w(e,t))))},Te=e=>A(()=>(u(e,{recursive:!0,mode:448}),p(e).filter(e=>!be(e)))).fold(t=>E(`runtime_dir ${e}: ${t.message}`),t=>t.length>0?E(`runtime_dir ${e} holds files the hub did not create (${t.slice(0,3).join(`, `)}); give runtime_dir a directory of its own`):A(()=>i(e,448)).fold(t=>E(`runtime_dir ${e}: ${t.message}`),()=>k(void 0))),Ee=e=>A(()=>h(e).mtimeMs>=Date.now()-x()*1e3).orElse(!1),De=e=>{let t=w(e,ye),n=xe(t);return Se(n)&&Ee(t)?E(`runtime_dir ${e} is in use by pid ${n}; give each hub its own runtime_dir (delete ${t} if that process is not a hub)`):A(()=>{A(()=>g(t)),_(t,String(process.pid),{mode:384,flag:`wx`})}).fold(t=>E(`runtime_dir ${e}: can't take ${ye}: ${t.message}`),()=>k(void 0))},Oe=e=>{A(()=>p(e)).orElse([]).filter(be).forEach(t=>A(()=>g(w(e,t)))),A(()=>m(e))},ke=/^[\w-]+$/,Ae=async(e,t)=>{let n=await t.run(`ssh-add`,[`-L`],{env:P({SSH_AUTH_SOCK:e})});return n.code===0?n.stdout.split(`
3
+ `).filter(e=>e.startsWith(`ssh-`)):[]},je=async(e,t,n,r)=>{let i=(t,n)=>E(`CA "${e.name}": ${t} failed: ${n.trim()}`);if(!ke.test(e.name))return i(`naming`,`use letters, digits, hyphen or underscore`);let a=await e.key();if(a.isLeft())return i(`reading the key`,a.value);let o=P({...fe,SSH_AUTH_SOCK:n}),s=await Ae(n,r),c=await r.run(`ssh-add`,[`-`],{env:o,input:a.value});if(c.code!==0)return i(`loading into the agent`,c.stderr);let l=(await Ae(n,r)).filter(e=>!s.includes(e));if(l.length!==1)return i(`loading into the agent`,`the key is already loaded for another CA`);let u=w(t,`ca-${e.name}.pub`);return _(u,`${l[0]}\n`,{mode:384}),k([e.name,u])},Me=async e=>{e.kill(),await Promise.race([new Promise(t=>e.onExit(()=>t())),he(2e3)])},Ne=async(e,t,n)=>{let r=Te(e).flatMap(()=>De(e));if(r.isLeft())return E(r.value);await we(e,n);let i=w(e,`agent.sock`),a=n.spawn(`ssh-agent`,[`-D`,`-a`,i],{env:P()});if(!await ge(()=>s(i)||a.exited(),Date.now()+5e3)||a.exited())return await Me(a),Oe(e),E(`ssh-agent failed to start: ${a.stderr().trim()||`timed out`}`);let o=a.pid();o!==void 0&&_(w(e,`agent.pid`),String(o),{mode:384});let c=await T.traverseAsync(t,t=>je(t,e,i,n));if(c.isLeft())return await Me(a),Oe(e),E(c.value);let l=D.of(...c.value.map(([e,t])=>[e,t]));return k({runtimeDir:e,agentSock:i,pubPath:e=>l.get(e),shutdown:async()=>{await Me(a),Oe(e)}})},Pe=(e,n,r)=>{let i=t(`sha256`).update(`${n}\0${r}`).digest(`hex`).slice(0,12),a=w(e,`m-${i}.sock`);return Buffer.byteLength(a)+5<=86?k(a):E(`runtime_dir ${e} is too long for control sockets (socket paths must be at most 86 bytes)`)},I={last:0n},Fe=e=>{let t=BigInt(e)*1000n;return I.last=t>I.last?t:I.last+1n,I.last},Ie=e=>[`-O`,`no-port-forwarding`,`-O`,`no-agent-forwarding`,`-O`,`no-x11-forwarding`,`-O`,`no-user-rc`,...e.fold(()=>[],e=>[`-O`,`source-address=${e}`])],Le=async(e,t,r,i=()=>/* @__PURE__ */ new Date)=>{let a=(e,n)=>E(`CA "${t.ca}": ${e} failed: ${n.trim()}`),o=e.pubPath(t.ca);if(o.isNone())return a(`signing`,`no CA named "${t.ca}" in custody`);let s=w(e.runtimeDir,`k-${n(8).toString(`hex`)}`),c=`${s}-cert.pub`,l=[s,`${s}.pub`,c],u=()=>(l.forEach(e=>A(()=>g(e))),Promise.resolve()),d=await r.run(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-N`,``,`-C`,``,`-f`,s],{env:P()});if(d.code!==0)return await u(),a(`key generation`,d.stderr);let f=i(),p=Fe(f.getTime()),m=await r.run(`ssh-keygen`,[`-U`,`-s`,o.orElse(``),`-I`,t.agent,`-n`,t.principal,`-V`,`-5m:+10m`,`-z`,p.toString(),...Ie(t.sourceAddress),`${s}.pub`],{env:P({SSH_AUTH_SOCK:e.agentSock})});return m.code===0?k({keyPath:s,certPath:c,validUntil:new Date(f.getTime()+6e5),serial:p.toString(),dispose:u}):(await u(),a(`signing`,m.stderr))},Re=e=>e.timedOutMs===void 0?e.noExitStatus===!0?`exit ${e.code} (no exit status: channel closed)`:e.signal===void 0?`exit ${e.code}`:`exit ${e.code} (signal ${e.signal})`:`timed out after ${e.timedOutMs} ms`,ze=/^tmux[\w -]* timed out after \d+ ms$/,Be=e=>ze.test(e),Ve=/^[A-Za-z0-9_-]+$/,L=e=>Ve.test(e)?k(e):E(`Invalid session name "${e}": only letters, digits, hyphen, and underscore are allowed`),He=[`Invalid session name `,`Unsupported key `,`No keys provided`],Ue=e=>He.some(t=>e.startsWith(t)),We=e=>`'${e.replaceAll(`'`,`'\\''`)}'`,Ge=e=>Math.max(1,Math.min(2e3,Math.floor(e))),Ke=e=>e.replace(/\s+$/,``),qe=/* @__PURE__ */ new Set([`Enter`,`Escape`,`Tab`,`Space`,`BSpace`,`Up`,`Down`,`Left`,`Right`,`Home`,`End`,`PageUp`,`PageDown`,`C-c`,`C-d`,`C-z`,`C-l`,`C-u`,`C-a`,`C-e`,`C-r`]),Je=e=>qe.has(e)?k(e):E(`Unsupported key "${e}": allowed keys are ${[...qe].join(`, `)}`),R=e=>`PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"; ${e}`,Ye=()=>R(`tmux list-sessions -F '#{session_name}'`),Xe=(e,t,n)=>L(e).map(e=>{let r=`{ tmux has-session -t ${e} 2>/dev/null || tmux new-session -d -s ${e}; }`,i=`tmux send-keys -t ${e} -l -- ${We(t)}`,a=n?` && tmux send-keys -t ${e} Enter`:``;return R(`${r} && ${i}${a}`)}),Ze=(e,t)=>L(e).map(e=>R(`tmux capture-pane -t ${e} -p -J -S -${Ge(t)}`)),Qe=(e,t)=>L(e).flatMap(e=>{if(t.length===0)return E(`No keys provided`);let n=t.find(e=>Je(e).isLeft());return n===void 0?k(R(`tmux send-keys -t ${e} ${t.join(` `)}`)):Je(n)}),z=`tmux not found on the remote host — install tmux (e.g. apt/brew install tmux) or use the exec tool instead`,$e=/can't find session|can't find pane|session not found/i,et=/no server running|error connecting to \S+ \(No such file or directory\)/i,B=e=>e.code===127||/tmux: ([Cc]ommand )?not found/i.test(e.stderr),V=(e,t)=>t.timedOutMs===void 0?`${e} failed: ${t.stderr.trim()||Re(t)}`:`${e} ${Re(t)}`,tt=e=>e.timedOutMs===void 0?B(e)?E(z):e.code===0?k(e.stdout.split(`
4
+ `).map(e=>e.trim()).filter(e=>e.length>0)):et.test(e.stderr)?k([]):E(V(`tmux list-sessions`,e)):E(V(`tmux list-sessions`,e)),nt=e=>t=>t.timedOutMs===void 0?B(t)?E(z):t.code===0?k(void 0):E(V(e,t)):E(V(e,t)),rt=e=>t=>t.timedOutMs===void 0?B(t)?E(z):t.code===0?k(Ke(t.stdout)):$e.test(t.stderr)?E(`No tmux session "${e}" — list with tmux_list, or tmux_send creates one`):E(V(`tmux capture-pane`,t)):E(V(`tmux capture-pane`,t)),it=e=>t=>t.timedOutMs===void 0?B(t)?E(z):t.code===0?k(void 0):$e.test(t.stderr)?E(`No tmux session "${e}" — list with tmux_list, or tmux_send creates one`):E(V(`tmux send-keys`,t)):E(V(`tmux send-keys`,t)),at=async e=>tt(await e(Ye())),ot=async(e,t)=>{let n=Xe(t.session,t.input,t.submit);return n.isLeft()?E(n.value):nt(`tmux_send`)(await e(n.value))},st=async(e,t)=>{let n=Ze(t.session,t.lines);return n.isLeft()?E(n.value):rt(t.session)(await e(n.value))},ct=async(e,t)=>{let n=Qe(t.session,t.keys);return n.isLeft()?E(n.value):it(t.session)(await e(n.value))},lt=e=>O(y.signals[`SIG${e}`]).fold(()=>255,e=>128+e),ut=(e,t,n,r)=>{if(typeof n==`number`)return{stdout:e,stderr:t,code:n};if(typeof r!=`string`||r===``)return{stdout:e,stderr:t,code:255,noExitStatus:!0};let i=r.replace(/^SIG/,``);return{stdout:e,stderr:t,code:lt(i),signal:i}},dt=(e,t,n)=>({stdout:e,stderr:t,code:124,timedOutMs:n}),ft=e=>{if(e.code===0)return k(e.stderr?`${e.stdout}\n[stderr]\n${e.stderr}`:e.stdout);let t=e.stderr&&e.stdout?`${e.stderr}\n[stdout]\n${e.stdout}`:e.stderr||e.stdout;return E(`${Re(e)}:\n${t}`)},pt=e=>new Promise(t=>setTimeout(t,e)),mt=e=>[`-p`,String(e.port??22),`${e.username??``}@${e.host??``}`],ht=(e,t,n,r)=>[`-F`,`/dev/null`,`-M`,`-N`,`-S`,t,`-o`,`ControlPersist=no`,`-o`,`IdentitiesOnly=yes`,`-o`,`IdentityAgent=none`,`-o`,`IdentityFile=${r.keyPath}`,`-o`,`CertificateFile=${r.certPath}`,`-o`,`BatchMode=yes`,`-o`,`StrictHostKeyChecking=accept-new`,`-o`,`UserKnownHostsFile=${n}`,`-o`,`ConnectTimeout=10`,`-o`,`ServerAliveInterval=15`,`-o`,`ServerAliveCountMax=3`,...mt(e)],gt=(e,t,n)=>[`-F`,`/dev/null`,`-S`,t,`-o`,`ControlMaster=no`,`-o`,`ProxyCommand=false`,`-o`,`BatchMode=yes`,...mt(e),`--`,n],_t=e=>{let t={run:O.none()};return()=>t.run.fold(()=>{let n=e();return t.run=O(n),n},e=>e)},vt=e=>{let{proc:t,socketPath:r,knownHosts:i}=e,a=e.readyTimeoutMs??15e3,o=e.drainTimeoutMs??6e4,s=e.setTimer??((e,t)=>setTimeout(e,t)),c=e.clearTimer??(e=>clearTimeout(e)),l=(e,t)=>Promise.race([new Promise(t=>e.onExit(()=>t())),new Promise(e=>s(e,t))]),u=(e,t)=>l(e,o).then(()=>(e.kill(),t())),d=new globalThis.Set,f=(e,n,r)=>t.run(`ssh`,[`-F`,`/dev/null`,`-S`,n,`-O`,e,...mt(r)],{env:P()}),p=(e,t,n,r)=>e.exited()?Promise.resolve(E(`master for ${n.host} exited: ${e.stderr().trim()}`)):Date.now()>r?Promise.resolve(E(`master for ${n.host} did not come up: ${e.stderr().trim()}`)):f(`check`,t,n).then(i=>i.code===0&&!e.exited()?k(void 0):pt(100).then(()=>p(e,t,n,r)));return{connect:async o=>{let l=await e.mintFor();if(l.isLeft())throw Error(l.value);let m=l.value,h=_t(m.dispose),v=r.replace(/\.sock$/,`-${n(2).toString(`hex`)}.sock`),y=v.replace(/\.sock$/,`.pid`),b=t.spawn(`ssh`,ht(o,v,i,m),{env:P()}),x=b.pid();x!==void 0&&A(()=>_(y,String(x),{mode:384})),b.onExit(()=>A(()=>g(y)));let S=await p(b,v,o,Date.now()+a);if(S.isLeft())throw b.kill(),await h(),Error(S.value);let C={closing:!1,closed:!1,retired:!1},w=[],T=()=>{C.closed||(C.closed=!0,w.splice(0).forEach(e=>e()))};b.onExit(()=>{T(),h()});let E=s(()=>{C.closing||C.retired||(C.retired=!0,T(),f(`stop`,v,o).catch(()=>void 0).then(()=>u(b,h)))},e.retireAfterMs??Math.max(0,m.validUntil.getTime()-Date.now()));b.onExit(()=>c(E));let ee=()=>{c(E),!(C.closing||C.retired)&&(C.closing=!0,f(`exit`,v,o).catch(()=>void 0).then(()=>u(b,h)))},D={child:b,close:ee};return d.add(D),b.onExit(()=>d.delete(D)),{exec:async(e,n={})=>{if(b.exited()||C.closing||C.retired)throw Error(`master for ${o.host} is gone`);let r=await t.run(`ssh`,gt(o,v,e),{env:P(),timeoutMs:n.timeoutMs});return r.timedOut===!0?dt(r.stdout,r.stderr,n.timeoutMs??0):{stdout:r.stdout,stderr:r.stderr,code:r.code}},close:ee,onClose:e=>{w.push(e)}}},shutdown:async()=>{let e=[...d];e.forEach(e=>e.close()),await Promise.all(e.map(e=>e.child.exited()?Promise.resolve():l(e.child,o)))}}},yt=e=>new Promise((t,n)=>{let r=new ie,i=[];r.on(`ready`,()=>t({exec:(e,t={})=>new Promise((n,i)=>{r.exec(e,(e,r)=>{if(e){i(e);return}let a=[],o=[],s=t.timeoutMs===void 0?void 0:setTimeout(()=>{let e=t.timeoutMs??0;n(dt(Buffer.concat(a).toString(),Buffer.concat(o).toString(),e)),r.signal(`KILL`),r.close()},t.timeoutMs);r.on(`close`,(e,t)=>{clearTimeout(s),n(ut(Buffer.concat(a).toString(),Buffer.concat(o).toString(),e,t))}),r.on(`data`,e=>a.push(e)),r.stderr.on(`data`,e=>o.push(e))})}),close:()=>r.end(),onClose:e=>i.push(e)})),r.on(`error`,e=>{i.forEach(e=>e()),n(e)}),r.on(`close`,()=>i.forEach(e=>e())),r.connect({keepaliveInterval:15e3,...e})}),bt=(e,t,n=yt)=>{let r=D.of(...e.map(e=>[e.name,{name:e.name,sshConfig:e.sshConfig,inFlight:0,waiters:[]}])),i=()=>[...r].map(([,e])=>e),a=e=>{e.conn=void 0,e.idleTimer&&(clearTimeout(e.idleTimer),e.idleTimer=void 0)},o=async e=>e.conn?e.conn:(e.connecting||(e.connecting=n(e.sshConfig).then(t=>(t.onClose(()=>{e.conn===t&&a(e)}),e.conn=t,e.connecting=void 0,t)),e.connecting.catch(()=>{e.connecting=void 0})),e.connecting),s=e=>e.inFlight<t.maxPerHost?(e.inFlight++,Promise.resolve()):new Promise((n,r)=>{let i=()=>{clearTimeout(a),n()},a=setTimeout(()=>{let n=e.waiters.indexOf(i);n!==-1&&e.waiters.splice(n,1),r(/* @__PURE__ */ Error(`host "${e.name}" busy: no free connection slot after ${t.acquireTimeoutMs}ms`))},t.acquireTimeoutMs);e.waiters.push(i)}),c=e=>{let n=e.waiters.shift();n?n():(e.inFlight--,e.inFlight===0&&e.conn&&(e.idleTimer=setTimeout(()=>{e.conn?.close(),a(e)},t.idleTimeoutMs)))};return{run:async(e,t,n)=>{let i=r.get(e).orThrow(/* @__PURE__ */ Error(`unknown host "${e}"`));i.idleTimer&&(clearTimeout(i.idleTimer),i.idleTimer=void 0),await s(i);try{return await(await o(i)).exec(t,n).catch(async()=>(a(i),(await o(i)).exec(t,n)))}finally{c(i)}},status:()=>i().map(e=>({name:e.name,state:e.conn?e.inFlight>0?`connected`:`idle`:`disconnected`,inFlight:e.inFlight})),shutdown:()=>(i().forEach(e=>{e.idleTimer&&clearTimeout(e.idleTimer),e.conn?.close(),a(e)}),Promise.resolve())}},xt=`Call hosts_list for the configured host names.`,St=e=>e.startsWith(`unknown host`),Ct=e=>St(e)?new M(e,{category:`not_found`,suggestions:[xt]}):Ue(e)?new M(e,{category:`validation`,suggestions:[]}):Be(e)?new M(e,{category:`timeout`,suggestions:[`tmux took longer than [pool].exec_timeout_ms. Try again, or raise it.`]}):new M(e,{category:`execution`,suggestions:[]}),wt=(e,t)=>new M(`[${e}] ${t}`,{category:`timeout`,suggestions:[`The command may still be running on ${e}. For a long job, start it in tmux with tmux_send and check it with tmux_read, or pass a larger timeout_ms.`]}),Tt=e=>new M(e,{category:`auth`,suggestions:[`This host's policy doesn't allow it. Use a command or tool the policy allows, or ask the operator.`]}),H=e=>t=>{if(t instanceof M)throw t;if(t instanceof TypeError||t instanceof RangeError)throw new M(t.message,{category:`internal`,suggestions:[]});let n=t instanceof Error?t.message:String(t);throw St(n)?new M(n,{category:`not_found`,suggestions:[xt]}):n.includes(` busy: no free connection slot`)?new M(n,{category:`timeout`,suggestions:[`Every connection to ${e} is in use. Try again, or raise [pool].max_per_host.`]}):new M(`[${e}] ${n}`,{category:`execution`,suggestions:[`Call fleet_setup to see ${e}'s connection state and what to configure.`]})},U=`unknown`,Et=`~/.local/state/ssh-fleet/audit.jsonl`,Dt=e=>O(e.name).flatMap(t=>Ot(e.version===void 0?t:`${t}/${e.version}`)),Ot=e=>O(e).map(e=>Array.from(e.replace(/\p{Cc}/gu,``).trim()).slice(0,64).join(``)).filter(e=>e.length>0),kt=e=>Array.from(e).slice(0,1024).join(``),W=e=>e instanceof Error?e.message:String(e),At=(e,t)=>e.fold(()=>t,e=>`${e}:${t}`),jt=e=>e.verdict.decision===`allow`?{decision:`allow`,...e.verdict.rule.fold(()=>({}),t=>({rule:At(e.host,t)}))}:{decision:`deny`,rule:At(e.host,e.verdict.rule),reason:e.verdict.reason},Mt=({sink:e,now:t,newId:n,client:r})=>{let i=(t,n)=>A(()=>e(JSON.stringify(t))).fold(e=>{throw new M(`audit log write failed ${n}: ${W(e)}`,{category:`internal`,suggestions:[`The server refuses every call while it can't write its audit log. Check the [audit].path file.`]})},()=>void 0);return{audited:async(e,a,o)=>{let s=n(),c={agent:e.agent,...r===void 0?{}:{client:r},tool:e.tool,...e.host.fold(()=>({}),e=>({host:e}))},l=t();if(i({ts:l.toISOString(),id:s,event:`decision`,...c,...e.fields,...jt(e)},`— call not executed`),e.verdict.decision===`deny`){let t=e.host.fold(()=>``,e=>`[${e}] `);throw Tt(`${t}denied by rule ${e.verdict.rule}: ${e.verdict.reason}`)}let u=await Promise.resolve().then(a).then(e=>({ok:!0,value:e}),e=>({ok:!1,error:e})),d=t(),f={ts:d.toISOString(),id:s,event:`result`,...c},p=d.getTime()-l.getTime();if(u.ok)return i({...f,status:`ok`,ms:p,...o(u.value)},`after execution`),u.value;throw i({...f,status:`error`,ms:p,error:W(u.error)},`after execution`),u.error}}},Nt=o.O_WRONLY|o.O_APPEND|o.O_CREAT|o.O_NOFOLLOW,Pt=(e,t)=>{let n=d(e,Nt,384);try{t(n)}finally{a(n)}},Ft=(e,t)=>Pt(e,e=>{let n=`${t}\n`,r=Buffer.byteLength(n),i=v(e,n);if(i!==r)throw Error(`short write: ${i} of ${r} bytes`)}),It=e=>{(l(e).mode&511)!=384&&c(e,384)},Lt=e=>j.expand(e).fold(t=>E(`invalid audit path ${e}: ${W(t)}`),e=>A(()=>{u(C(e),{recursive:!0,mode:448}),Pt(e,It)}).fold(t=>E(`audit log ${e} is not writable: ${W(t)}`),()=>k(t=>Ft(e,t)))),Rt=()=>({commit:`3271986559c1`,branch:`fleet-v0.2.0`,date:`2026-11-04T22:11:05.426Z`}),zt=[`readonly`,`readwrite`,`deny`],Bt=[`approve`,`open`],Vt=[`mode`,`tmux`,`allow`,`needs_approval`,`agents`],G={mode:O.none(),tmux:O.none(),allow:O.none(),needsApproval:O.none(),agents:O.none()},Ht=e=>typeof e==`object`&&!!e&&!Array.isArray(e),Ut=(e,t,n)=>e===void 0?k(O.none()):typeof e==`string`&&t.includes(e)?k(O(e)):E(n),Wt=(e,t)=>e===void 0?k(O.none()):Array.isArray(e)&&e.every(e=>typeof e==`string`)?k(O(e)):E(t),Gt=e=>e.fold(e=>[e],()=>[]),Kt=(e,t)=>{if(e===void 0)return k(G);if(!Ht(e))return E([`${t} must be a table`]);let n=Ut(e.mode,zt,`${t}.mode must be one of readonly, readwrite, deny`),r=Ut(e.tmux,Bt,`${t}.tmux must be approve or open`),i=Wt(e.allow,`${t}.allow must be an array of strings`),a=Wt(e.needs_approval,`${t}.needs_approval must be an array of strings`),o=Wt(e.agents,`${t}.agents must be an array of strings`),s=[...Object.keys(e).filter(e=>!Vt.includes(e)).map(e=>`${t}: unknown key "${e}"`),...[n,r,i,a,o].flatMap(Gt)];return s.length>0?E(s):k({mode:n.orElse(G.mode),tmux:r.orElse(G.tmux),allow:i.orElse(G.allow),needsApproval:a.orElse(G.needsApproval),agents:o.orElse(G.agents)})},qt=e=>e.filter(e=>e.length>0).orElse([]),Jt=(e,t,n,r)=>{let i=n.map(n=>A(()=>new RegExp(r(new RegExp(n).source))).fold(r=>E(`${e}: invalid regex in ${t}: "${n}" (${r.message})`),e=>k(e))),a=i.flatMap(Gt);return a.length>0?E(a):k(i.flatMap(e=>e.fold(()=>[],e=>[e])))},Yt=e=>e.fold(e=>e,()=>[]),Xt=(e,t,n)=>{let r=n.mode.or(t.mode).orElse(`readwrite`),i=n.tmux.or(t.tmux).orElse(`open`),a=qt(n.allow.or(t.allow)),o=qt(n.needsApproval.or(t.needsApproval)),s=qt(n.agents.or(t.agents)),c=Jt(e,`allow`,a,e=>`^(?:${e})$`),l=Jt(e,`needs_approval`,o,e=>e),u=[...Yt(c),...Yt(l),...a.length>0&&r!==`readonly`?[`${e}: allow applies only to mode = "readonly"`]:[],...o.length>0&&r!==`readwrite`?[`${e}: needs_approval applies only to mode = "readwrite"`]:[]];return u.length>0?E(u):k({mode:r,tmux:i,allow:c.orElse([]),needsApproval:l.orElse([]).map((e,t)=>({pattern:o[t]??e.source,re:e})),agents:s.length>0?O(s):O.none()})},K={decision:`allow`,rule:O.none()},q=e=>({decision:`allow`,rule:O(e)}),J=(e,t)=>({decision:`deny`,rule:e,reason:t}),Zt=/[\n\r\0;&|$`()<>{}\\'"*?[~]/,Qt=`command is longer than 4096 characters`,$t=(e,t)=>e.some(e=>t===e||t.startsWith(`${e}@`)),en=e=>e.mode===`readonly`?J(`readonly`,`tmux input is not allowed on a readonly host`):e.tmux===`approve`?J(`tmux`,`tmux input requires approval, which is not available yet`):q(`readwrite`),tn=(e,t)=>{let n=t.length>4096&&Array.from(t).length>4096;if(e.mode===`readonly`)return n?J(`readonly`,Qt):Zt.test(t)?J(`readonly`,`command contains a newline or shell metacharacter`):e.allow.some(e=>e.test(t))?q(`readonly`):J(`readonly`,`command not in allow list`);if(n&&e.needsApproval.length>0)return J(`needs_approval`,Qt);let r=e.needsApproval.find(e=>e.re.test(t));return r===void 0?q(`readwrite`):J(`needs_approval`,`matches "${r.pattern}" and needs approval, which is not available yet`)},nn=(e,t)=>e.mode===`deny`?J(`deny`,`host is closed by policy`):e.agents.fold(()=>!1,e=>!$t(e,t.agent))?J(`agents`,`agent "${t.agent}" is not in agents`):t.tool===`exec`?tn(e,t.command):t.tool===`tmux_send`||t.tool===`tmux_keys`?en(e):q(e.mode),rn=6e5,an=36e5,on={maxPerHost:2,idleTimeoutMs:6e4,acquireTimeoutMs:3e4,execTimeoutMs:rn,maxExecTimeoutMs:an},Y=e=>typeof e==`string`&&e.length>0?O(e):O.none(),sn=e=>typeof e==`number`?O(e):O.none(),cn=(e,t)=>{if(t.password!==void 0||t.private_key!==void 0)return E(`host "${e}": secrets must not be inline; use key/key_env/agent/password_env/ca`);let n=[Y(t.key).map(()=>`key`),Y(t.key_env).map(()=>`key_env`),Y(t.password_env).map(()=>`password_env`),t.agent===!0?O(`agent`):O.none(),Y(t.ca).map(()=>`ca`)].flatMap(e=>e.toArray());if(n.length===0)return E(`host "${e}": no auth mode (need exactly one of key/key_env/agent/password_env/ca)`);if(n.length>1)return E(`host "${e}": exactly one auth mode allowed, found ${n.length}`);let r={password:Y(t.password_env).flatMap(e=>O(process.env[e]??``)).filter(e=>e.length>0),keyPath:Y(t.key),keyEnvVar:Y(t.key_env),useAgent:t.agent===!0};return k({auth:r,kind:n[0]})},ln=2**31-1,X=(e,t,n)=>{let r=e[t];return r===void 0?k(n):typeof r==`number`&&Number.isInteger(r)&&r>=1&&r<=ln?k(r):E(`pool.${t} must be a whole number from 1 to ${ln}`)},un=e=>{let t=e.pool??{},n=[X(t,`max_per_host`,2),X(t,`idle_timeout_ms`,6e4),X(t,`acquire_timeout_ms`,3e4),X(t,`exec_timeout_ms`,rn),X(t,`max_exec_timeout_ms`,an)],r=n.flatMap(e=>e.fold(e=>[e],()=>[])),[i,a,o,s,c]=n.map(e=>e.orElse(0));return r.length>0?E(r):k({maxPerHost:i,idleTimeoutMs:a,acquireTimeoutMs:o,execTimeoutMs:s,maxExecTimeoutMs:c})},dn=e=>e.length>0&&!e.includes(`,`),fn=e=>e.split(`.`).reduce((e,t)=>(e<<8n)+BigInt(t),0n),pn=e=>{if(e.includes(`.`))return O.none();let[t,n=``]=e.split(`::`),r=t?t.split(`:`):[],i=e.includes(`::`)&&n?n.split(`:`):[],a=e.includes(`::`)?8-r.length-i.length:0,o=[...r,...Array.from({length:a},()=>`0`),...i];return o.length===8?O(o.reduce((e,t)=>(e<<16n)+BigInt(`0x${t||`0`}`),0n)):O.none()},mn=(e,t,n)=>(e&(1n<<BigInt(t-n))-1n)==0n,hn=e=>{let t=e.split(`/`);if(t.length!==2)return!1;let[n,r]=t;if(!/^\d{1,3}$/.test(r))return!1;let i=Number(r),a=ce(n);return a===4?i<=32&&mn(fn(n),32,i):a===6&&i<=128&&pn(n).fold(()=>!1,e=>mn(e,128,i))},gn=e=>{let t=Y(e.name);if(t.isNone())return E(`[[cas]] entry has no name`);let n=t.orElse(``);if(e.key!==void 0||e.private_key!==void 0)return E(`ca "${n}": keys must not be inline; use key_cmd, key_env or key_file`);let r=[...Y(e.key_cmd).fold(()=>[],e=>[{kind:`cmd`,cmd:e}]),...Y(e.key_env).fold(()=>[],e=>[{kind:`env`,name:e}]),...Y(e.key_file).fold(()=>[],e=>[{kind:`file`,path:e}])];if(r.length!==1)return E(`ca "${n}": exactly one of key_cmd, key_env, key_file is required`);let i=r[0],a=e.principal===void 0?`fleet`:typeof e.principal==`string`?e.principal:``;if(!dn(a))return E(`ca "${n}": principal must be non-empty with no commas`);let o=Y(e.source_address);return e.source_address!==void 0&&!o.fold(()=>!1,hn)?E(`ca "${n}": source_address must be an IPv4 or IPv6 CIDR`):k({name:n,key:i,principal:a,sourceAddress:o})},_n=e=>{let t=e.audit??{};return t.path===void 0?k({path:Et}):Y(t.path).fold(()=>E(`audit.path must be a non-empty string`),e=>k({path:e}))},vn=e=>{let t=e,n=t.message.split(`
5
+ `)[0];return t.line===void 0?`Invalid TOML: ${n}`:`Invalid TOML at line ${t.line}, column ${t.column}: ${n}`},yn=e=>{let t=A(()=>le(e));if(t.isFailure())return E(vn(t.error));let n=t.orThrow(),r=n.defaults??{},i=sn(r.port).orElse(22),a=Y(r.user),o=Y(r.tmux_session).orElse(`agent`),s=Array.isArray(n.hosts)?n.hosts:[],c=Kt(r.policy,`defaults.policy`),l=c.orElse(G),u=s.map(e=>Y(e.name).orElse(``)),d=Array.isArray(n.cas)?n.cas:[],f=d.map(e=>Y(e.name).orElse(``)),p=(e,t)=>{let n=Y(e.name).orElse(``);if(!L(n).isRight())return E(`invalid host name "${n}": only letters, digits, hyphen, underscore`);if(u.indexOf(n)<t)return E(`duplicate host name "${n}"`);let r=Y(e.host);if(r.isNone())return E(`host "${n}": missing host address`);if(e.log_output!==void 0&&typeof e.log_output!=`boolean`)return E(`host "${n}": log_output must be true or false`);let s=Y(e.ca);if(s.isSome()&&!f.includes(s.orElse(``)))return E(`host "${n}": ca "${s.orElse(``)}" is not defined in [[cas]]`);let c=typeof e.principal==`string`?O(e.principal):O.none();if(e.principal!==void 0&&s.isNone())return E(`host "${n}": principal applies only to ca auth`);if(e.principal!==void 0&&!c.fold(()=>!1,dn))return E(`host "${n}": principal must be non-empty with no commas`);let d=`host "${n}": policy`,p=Kt(e.policy,d).flatMap(e=>Xt(d,l,e)).mapLeft(e=>e.join(`
6
+ `));return cn(n,e).flatMap(({auth:t,kind:l})=>p.map(u=>({name:n,host:r.orElse(``),port:sn(e.port).orElse(i),user:Y(e.user).or(a),auth:t,authKind:l,passwordEnv:Y(e.password_env),tmuxSession:Y(e.tmux_session).orElse(o),logOutput:e.log_output===!0,policy:u,ca:s,principal:c})))},m=_n(n),h=s.map(p),g=d.map(gn),_=[...g.flatMap(e=>e.fold(e=>[e],()=>[])),...f.filter((e,t)=>e!==``&&f.indexOf(e)<t).map(e=>`ca "${e}": duplicate name`)],v=un(n),y=[..._,...c.fold(e=>[...e],()=>[]),...v.fold(e=>[...e],()=>[]),...[m,...h].flatMap(e=>e.fold(e=>[e],()=>[]))],b=h.flatMap(e=>e.fold(()=>[],e=>[e]));return y.length>0?E(`Fleet config errors:\n${ee(y).toArray().join(`
7
+ `)}`):k({hosts:b,pool:v.orElse(on),audit:m.orElse({path:Et}),cas:g.flatMap(e=>e.fold(()=>[],e=>[e])),runtimeDir:Y(n.runtime_dir),knownHosts:Y(n.known_hosts)})},bn=e=>[...e.hosts.length===0?[`no hosts configured; add a [[hosts]] entry and restart`]:[],...e.hosts.flatMap(e=>e.passwordEnv.filter(e=>!process.env[e]).fold(()=>[],t=>[`host "${e.name}": password_env ${t} is not set`])),...e.hosts.filter(e=>e.policy.agents.isSome()).map(e=>`host "${e.name}": policy.agents is set, so every call is denied until agent identity is available`)],xn=e=>e.status().map(t=>t.state===`disconnected`?e.lastError(t.name.split(` `)[0]).fold(()=>t,e=>({...t,state:`error`,last_error:e})):t),Sn=()=>w(b(),`.local`,`state`,`ssh-fleet`),Cn=()=>{let e=process.env.XDG_RUNTIME_DIR;return e?w(e,`ssh-fleet`):w(Sn(),`run`)},wn=()=>w(Sn(),`known_hosts`),Tn=async e=>{let t=j.expand(e);if(t.isLeft())return E(`key_file ${e}: ${t.value.message}`);let n=t.value,r=await oe(n).catch(e=>e);return r instanceof Error?E(`key_file ${n}: ${r.message}`):r.isSymbolicLink()?E(`key_file ${n} is a symlink`):r.isFile()?r.mode&63?E(`key_file ${n} must be mode 0600 or stricter`):r.uid===process.getuid?.()?k(await se(n,`utf8`)):E(`key_file ${n} is not owned by the hub user`):E(`key_file ${n} is not a regular file`)},En=async(e,t)=>{switch(e.kind){case`cmd`:{let n=Object.fromEntries(Object.entries(process.env).flatMap(([e,t])=>t===void 0?[]:[[e,t]])),r=await F.run(`sh`,[`-c`,e.cmd],{env:n,timeoutMs:t}).catch(e=>({code:-1,stdout:``,stderr:e.message}));return r.code===0&&r.stdout.trim().length>0?k(`${r.stdout.trim()}\n`):E(r.timedOut===!0?`key_cmd timed out after ${t} ms`:`key_cmd exited ${r.code}: ${r.stderr.trim()||`no output`}`)}case`env`:{let t=process.env[e.name];return delete process.env[e.name],t?k(t):E(`environment variable ${e.name} is not set`)}case`file`:return Tn(e.path)}},Dn=(e,t={})=>({name:e.name,key:()=>En(e.key,t.timeoutMs??3e4)}),On=async(e,t,n)=>(await T.traverseAsync(t,async t=>(await Le(e,{ca:t.name,agent:`self-test`,principal:t.principal,sourceAddress:t.sourceAddress},n)).fold(e=>Promise.resolve(E(e)),e=>e.dispose().then(()=>k(void 0))))).map(()=>void 0),kn=e=>({host:e.host,port:e.port,username:e.user.orElse(process.env.USER??`root`)}),An=async e=>(await ue(e.auth)).map(t=>({...kn(e),...t})),jn=async(e,t,n=O.none())=>{let r=e.hosts.filter(e=>e.ca.isSome()),i=e.hosts.filter(e=>e.ca.isNone());if(r.length>0&&n.isNone())return E(`host "${r[0].name}" uses ca auth but CA custody is not running`);let a=await Promise.all(i.map(async e=>({name:e.name,sshConfig:await An(e)}))),o=a.flatMap(({name:e,sshConfig:t})=>t.fold(()=>[],t=>[{name:e,sshConfig:t}])),s=a.flatMap(({name:e,sshConfig:t})=>t.fold(t=>[[e,t]],()=>[]));return k(Nn(e,o,s,r,n,t))},Mn=(e,t,n,r)=>{let i=Pe(e.custody.runtimeDir,n,r.name);return vt({proc:e.proc,knownHosts:e.knownHosts,socketPath:i.orElse(``),mintFor:()=>i.fold(e=>Promise.resolve(E(e)),()=>Le(e.custody,{ca:t.name,agent:n,principal:r.principal.orElse(t.principal),sourceAddress:t.sourceAddress},e.proc))})},Nn=(e,t,n,r,i,a)=>{let o=D.of(...e.hosts.map(e=>[e.name,e.tmuxSession])),s=D.of(...e.hosts.map(e=>[e.name,e.logOutput])),c=D.of(...e.hosts.map(e=>[e.name,e.policy])),l=D.of(...r.map(e=>[e.name,e])),u=D.of(...e.cas.map(e=>[e.name,e])),d=bt(t,e.pool,a),f=te.of(...e.hosts.map(e=>e.name)),p=new globalThis.Map,m=(t,n,r)=>{let i=`${n}\0${t.name}`,a=p.get(i);if(a!==void 0)return a.pool;let o=u.get(t.ca.orElse(``)).orThrow(/* @__PURE__ */ Error(`ca "${t.ca.orElse(``)}" is not defined`)),s=(r.openFor??((e,t)=>Mn(r,o,e,t)))(n,t),c=bt([{name:t.name,sshConfig:kn(t)}],e.pool,s.connect);return p.set(i,{host:t.name,agent:n,pool:c,openssh:s}),c},h=(e,t,n,r)=>l.get(e).fold(()=>d.run(e,t,r),a=>i.fold(()=>Promise.reject(/* @__PURE__ */ Error(`CA custody is not running, and this host uses ca auth`)),i=>m(a,n,i).run(e,t,r))),g=D.of(...n.map(([e,t])=>[e,t])),_=(e,t,n,r)=>g.get(e).fold(()=>h(e,t,n,r),e=>Promise.reject(Error(e))),v=(/* @__PURE__ */ new Date()).toISOString(),y=new globalThis.Map(n.map(([e,t])=>[e,{message:t,at:v}])),b=t=>Math.min(t??e.pool.execTimeoutMs,e.pool.maxExecTimeoutMs),x=(e,t,n,r)=>_(e,t,n,{timeoutMs:b(r)}).then(t=>(y.delete(e),t),t=>(f.has(e)&&y.set(e,{message:t.message,at:(/* @__PURE__ */ new Date()).toISOString()}),Promise.reject(t))),S=()=>r.flatMap(e=>{let t=[...p.values()].filter(t=>t.host===e.name);return t.length===0?[{name:e.name,state:`disconnected`,inFlight:0}]:t.flatMap(t=>t.pool.status().map(n=>({...n,name:`${e.name} (${t.agent})`})))}),C=e=>f.size===0?`unknown host "${e}" — no hosts are configured`:`unknown host "${e}" — configured: ${[...f].join(`, `)}`;return{hostNames:e.hosts.map(e=>e.name),sessionFor:e=>o.get(e).orElse(`agent`),logOutputFor:e=>s.get(e).orElse(!1),policyFor:e=>c.get(e),tmuxRunnerFor:(e,t)=>f.has(e)?k(n=>x(e,n,t)):E(C(e)),exec:(e,t,n,r)=>f.has(e)?x(e,t,n,r):Promise.reject(Error(C(e))),status:()=>[...d.status(),...n.map(([e])=>({name:e,state:`disconnected`,inFlight:0})),...S()],lastError:e=>O(y.get(e)),shutdown:async()=>{let e=[...p.values()];await Promise.all([d.shutdown(),...e.map(e=>e.pool.shutdown())]),await Promise.all(e.map(e=>e.openssh.shutdown())),await i.fold(()=>Promise.resolve(),e=>e.custody.shutdown())}}},Pn=(e,t,n)=>{let r=e.find(e=>e.startsWith(`--config=`));return r?{path:r.slice(9),explicit:!0}:O(t.SSH_FLEET_CONFIG).filter(e=>e.length>0).fold(()=>({path:w(n,`.config`,`ssh-fleet`,`fleet.toml`),explicit:!1}),e=>({path:e,explicit:!0}))},Fn=(e,t)=>e.code===t,In=e=>{let{path:t}=e;return e.explicit?A(()=>h(t)).fold(e=>E(Fn(e,`ENOENT`)?`Cannot read fleet config at ${t}: no such file (only the default path is created on first start)`:`Cannot read fleet config at ${t}: ${e.message}`),()=>k({path:t,created:!1})):A(()=>{u(C(t),{recursive:!0,mode:448}),_(t,`# ssh-fleet-mcp-server config.
8
+ #
9
+ # Add at least one [[hosts]] entry, then restart the server. In Claude Code, use /mcp and
10
+ # reconnect. The fleet_setup tool shows what is configured and what is still missing.
11
+ #
12
+ # To use an example block, remove the leading "# " from each of its lines.
13
+ #
14
+ # Secrets never go in this file. Each host names one place its secret comes from:
15
+ # password_env = env var holding a password key = path to a private key
16
+ # key_env = env var holding a key (PEM) agent = true (uses SSH_AUTH_SOCK)
17
+ # ca = a [[cas]] entry: the server signs a 10-minute certificate per connection
18
+ #
19
+ # Reference: https://github.com/jordanburke/ssh-client-mcp-server/tree/main/packages/orchestrator#fleet-config-schema
20
+
21
+ # --- example: host with a password from the environment ---
22
+ # [[hosts]]
23
+ # name = "mini"
24
+ # host = "mini.local"
25
+ # user = "me"
26
+ # password_env = "MINI_SSH_PASSWORD"
27
+ # --- end ---
28
+
29
+ # --- example: host with a key file ---
30
+ # [[hosts]]
31
+ # name = "build"
32
+ # host = "build.example.com"
33
+ # key = "~/.ssh/id_ed25519"
34
+ # --- end ---
35
+
36
+ # --- example: read-only policy for every host ---
37
+ ## Only commands matching an allow pattern run. See the README's Policy section.
38
+ # [defaults.policy]
39
+ # mode = "readonly"
40
+ # allow = ['uptime', 'df -h']
41
+ # --- end ---
42
+
43
+ # --- example: exec timeouts ---
44
+ ## exec waits exec_timeout_ms unless a call passes timeout_ms, and never longer than
45
+ ## max_exec_timeout_ms. Run longer jobs in tmux (tmux_send, then tmux_read).
46
+ # [pool]
47
+ # exec_timeout_ms = 600000
48
+ # max_exec_timeout_ms = 3600000
49
+ # --- end ---
50
+
51
+ # --- example: certificate authority ---
52
+ ## If key_file doesn't exist, the server creates it on first start. fleet_setup then shows
53
+ ## the line to add to a root-owned authorized-keys file on each host. To keep the key off
54
+ ## disk, use key_cmd (a command that prints it, e.g. a keychain read) or key_env instead.
55
+ # [[cas]]
56
+ # name = "home"
57
+ # key_file = "~/.local/state/ssh-fleet/ca-home"
58
+ #
59
+ # [[hosts]]
60
+ # name = "nas"
61
+ # host = "nas.local"
62
+ # user = "fleet"
63
+ # ca = "home"
64
+ # --- end ---
65
+ `,{flag:`wx`,mode:384})}).fold(e=>Fn(e,`EEXIST`)?k({path:t,created:!1}):E(`Cannot create fleet config at ${t}: ${e.message}`),()=>k({path:t,created:!0}))},Ln=async e=>{if(e.key.kind!==`file`)return k(O.none());let t=j.expand(e.key.path);if(t.isLeft())return E(`CA "${e.name}": key_file ${e.key.path}: ${t.value.message}`);let n=t.value,r=await oe(n).then(()=>!1,e=>Fn(e,`ENOENT`));return k(r?O(n):O.none())},Rn=async(e,t)=>(await T.traverseAsync(e,async e=>{let n=await Ln(e);if(n.isLeft())return E(n.value);if(n.value.isNone())return k([]);let r=n.value.orElse(``),i=A(()=>u(C(r),{recursive:!0,mode:448}));if(i.isFailure())return E(`CA "${e.name}": creating key_file ${r} failed: ${i.error.message}`);let a=await t.run(`ssh-keygen`,[`-q`,`-t`,`ed25519`,`-N`,``,`-C`,`ssh-fleet-ca-${e.name}`,`-f`,r],{env:P(),input:``});return a.code===0?k([e.name]):s(r)?k([]):E(`CA "${e.name}": creating key_file ${r} failed: ${a.stderr.trim()||`exit ${a.code}`}`)})).map(e=>e.flat()),zn=(e,t)=>`cert-authority,principals="${e}" ${t.trim()}`,Bn=e=>e.user.fold(()=>`${e.host}:${e.port}`,t=>`${t}@${e.host}:${e.port}`),Vn={cmd:`key_cmd`,env:`key_env`,file:`key_file`},Hn=(e,t,n,r)=>{let i=e.ca.flatMap(e=>O(t.fleet.cas.find(t=>t.name===e))),a=e.principal.or(i.map(e=>e.principal)),o=i.flatMap(e=>t.caPublicKeys.get(e.name)).flatMap(e=>a.map(t=>zn(t,e)));return{name:e.name,address:Bn(e),auth:e.authKind,...e.passwordEnv.fold(()=>({}),e=>({password_env:e,env_set:!!r[e]})),...e.ca.fold(()=>({}),e=>({ca:e,principal:a.orElse(``)})),connections:xn(n).filter(t=>t.name===e.name||t.name.startsWith(`${e.name} (`)),...n.lastError(e.name).fold(()=>({}),e=>({last_error:e})),...o.fold(()=>({}),e=>({enroll_line:e}))}},Un=(e,t)=>{let n=t.caPublicKeys.get(e.name);return{name:e.name,key_source:Vn[e.key.kind],...e.key.kind===`file`?{key_file:e.key.path}:{},created_this_start:t.caCreated.includes(e.name),...n.fold(()=>({}),t=>({public_key:t.trim(),enroll_line:zn(e.principal,t)}))}},Wn=e=>{let t=e.readConfig();return t.isRight()&&t.value===e.loadedConfig?{changed:!1,fields:{changed_since_start:!1},step:[]}:t.flatMap(yn).fold(e=>({changed:!0,fields:{changed_since_start:!0,errors_on_disk:e},step:[`fleet.toml changed since the server started and now has errors; fix them before reconnecting: ${e}`]}),t=>({changed:!0,fields:{changed_since_start:!0,hosts_on_disk:t.hosts.length},step:[`fleet.toml changed since the server started (${t.hosts.length} hosts on disk, ${e.fleet.hosts.length} loaded): reconnect the server to load it (in Claude Code: /mcp, then reconnect).`]}))},Gn=(e,t,n=process.env)=>{let r=e.fleet.hosts.map(r=>Hn(r,e,t,n)),i=e.fleet.cas.map(t=>Un(t,e)),a=Wn(e),o=[...a.step,...r.length===0&&!a.changed?[`Add a [[hosts]] entry to ${e.configPath}, then restart the server.`]:[],...r.filter(e=>e.env_set===!1).map(e=>`${e.name}: set ${e.password_env} in the MCP client's env for this server, then restart.`),...i.filter(e=>e.created_this_start&&e.enroll_line!==void 0).map(e=>`Add CA "${e.name}"'s line to a root-owned authorized-keys file on each host that uses it: ${e.enroll_line}`),...r.flatMap(e=>e.last_error?[`${e.name}: ${e.last_error.message}`]:[])];return{config:{path:e.configPath,created_this_start:e.configCreated,...a.fields},next_steps:o,hosts:r,cas:i,audit_log:e.fleet.audit.path,...e.runtimeDir.fold(()=>({}),e=>({runtime_dir:e})),...e.knownHosts.fold(()=>({}),e=>({known_hosts:e}))}},Kn=e=>A(()=>f(e,`utf8`)).fold(t=>t.code===`ENOENT`?E(`No fleet config at ${e}. Start the server once to create one at the default path, or pass --config=.`):E(`Cannot read fleet config at ${e}: ${t.message}`),e=>k(e)),qn=e=>`${e}-check`,Jn=(e,t)=>e.code===0?{ok:!0,detail:`${Date.now()-t} ms`}:{ok:!1,detail:e.stderr.trim()||`exit ${e.code}`},Yn=async e=>{let{fleet:t,out:n}=e;if(t.hosts.length===0)return n(`No hosts configured; add a [[hosts]] entry to ${e.configPath}.`),0;let r=Math.max(...t.hosts.map(e=>e.name.length)),i=Math.max(...t.hosts.map(e=>Bn(e).length)),a=(e,t)=>[(t.ok?`ok`:`FAIL`).padEnd(5),e.name.padEnd(r),Bn(e).padEnd(i),e.authKind.padEnd(12),t.detail].join(` `),o=async t=>{if(t.ca.exists(t=>e.missingKeyCas.includes(t)))return{ok:!1,detail:`key_file missing: the server creates it on first start`};let n=Date.now();return e.auditor.audited({agent:`operator`,tool:`check`,host:O(t.name),fields:{bypass:`policy`},verdict:K},()=>e.runner.exec(t.name,`true`,`operator`),e=>({code:e.code})).then(e=>Jn(e,n),e=>({ok:!1,detail:e.message}))},s=await Promise.all(t.hosts.map(e=>o(e).then(t=>(n(a(e,t)),t))));return t.cas.forEach(t=>e.caPublicKeys.get(t.name).forEach(e=>n(`CA ${t.name}: ${zn(t.principal,e)}`))),+!s.every(e=>e.ok)},Z=e=>e.fold(e=>{throw Ct(e)},e=>e),Xn=e=>Buffer.byteLength(e,`utf8`),Q=()=>({}),Zn=(e,t,n)=>{let r=(e,t,n,r={})=>({agent:U,tool:e,host:t,fields:r,verdict:n}),i=(t,n,i={})=>r(n.tool,O(t),e.policyFor(t).fold(()=>K,e=>nn(e,n)),i),a=t=>n=>({code:n.code,...n.signal===void 0?{}:{signal:n.signal},...n.timedOutMs===void 0?{}:{timed_out_ms:n.timedOutMs},...n.noExitStatus===!0?{no_exit_status:!0}:{},stdout_bytes:Xn(n.stdout),stderr_bytes:Xn(n.stderr),...e.logOutputFor(t)?{stdout_excerpt:kt(n.stdout),stderr_excerpt:kt(n.stderr)}:{}});return{hostsList:()=>t.audited(r(`hosts_list`,O.none(),K),()=>Promise.resolve(JSON.stringify(xn(e))),Q),fleetSetup:()=>t.audited(r(`fleet_setup`,O.none(),K),()=>Promise.resolve(JSON.stringify(Gn(n,e))),Q),exec:async({host:n,command:r,timeout_ms:o})=>{let s=await t.audited(i(n,{tool:`exec`,agent:U,command:r},{command:r,...o===void 0?{}:{timeout_ms:o}}),()=>e.exec(n,r,U,o),a(n)).catch(H(n));return ft(s).fold(e=>{throw s.timedOutMs===void 0?Ct(`[${n}] ${e}`):wt(n,e)},e=>e)},tmuxList:({host:n})=>t.audited(i(n,{tool:`tmux_list`,agent:U}),async()=>JSON.stringify(Z(await at(Z(e.tmuxRunnerFor(n,U))))),Q).catch(H(n)),tmuxSend:({host:n,session:r,input:a,submit:o})=>{let s=r??e.sessionFor(n),c=o??!0;return t.audited(i(n,{tool:`tmux_send`,agent:U},{session:s,input:a,submit:c}),async()=>(Z(await ot(Z(e.tmuxRunnerFor(n,U)),{session:s,input:a,submit:c})),`[${n}] sent to tmux session "${s}".`),Q).catch(H(n))},tmuxRead:({host:n,session:r,lines:a})=>{let o=r??e.sessionFor(n),s=a??200;return t.audited(i(n,{tool:`tmux_read`,agent:U},{session:o,lines:s}),async()=>Z(await st(Z(e.tmuxRunnerFor(n,U)),{session:o,lines:s})),e=>({stdout_bytes:Xn(e)})).catch(H(n))},tmuxKeys:({host:n,session:r,keys:a})=>{let o=r??e.sessionFor(n);return t.audited(i(n,{tool:`tmux_keys`,agent:U},{session:o,keys:a}),async()=>(Z(await ct(Z(e.tmuxRunnerFor(n,U)),{session:o,keys:a})),`[${n}] sent keys [${a.join(`, `)}] to tmux session "${o}".`),Q).catch(H(n))}}},{version:Qn}=e(import.meta.url)(`../package.json`),$=e=>(console.error(e),process.exit(1)),$n=e=>j.expand(e).fold(t=>$(`invalid path ${e}: ${t.message}`),e=>e),er=async(e,t)=>{if(e.cas.length===0)return{wiring:O.none(),created:[]};(await me(F)).fold($,()=>void 0);let n=t===`serve`?(await Rn(e.cas,F)).fold($,e=>e):[];e.cas.filter(e=>n.includes(e.name)).forEach(e=>console.error(`created CA "${e.name}" key at ${e.key.kind===`file`?e.key.path:``}; add its line to each host (see fleet_setup)`));let r=e.runtimeDir.map($n).orElse(Cn()),i=t===`check`?qn(r):r,a=e.knownHosts.map($n).orElse(wn());Pe(i,`unknown`,`host`).fold($,()=>void 0),u(C(a),{recursive:!0,mode:448});let o=(await Ne(i,e.cas.map(e=>Dn(e)),F)).fold($,e=>e),s=await On(o,e.cas,F);return s.isLeft()&&(await o.shutdown(),$(s.value)),{wiring:O({custody:o,proc:F,knownHosts:a}),created:n}},tr=async(e,t)=>(await jn(e,void 0,t)).fold(async e=>(console.error(e),await t.fold(()=>Promise.resolve(),e=>e.custody.shutdown()),process.exit(1)),e=>Promise.resolve(e)),nr=(e,t)=>t.fold(()=>D.of(),t=>D.of(...e.cas.flatMap(e=>t.custody.pubPath(e.name).flatMap(e=>A(()=>f(e,`utf8`)).toOption()).fold(()=>[],t=>[[e.name,t.trim()]])))),rr=e=>{let t=yn(e).fold($,e=>e);return bn(t).forEach(e=>console.error(`warning: ${e}`)),t};async function ir(){let{path:e}=Pn(process.argv.slice(2),process.env,b()),t=rr(Kn(e).fold($,e=>e)),n=Lt(t.audit.path).fold($,e=>e),i=(await T.traverseAsync(t.cas,async e=>(await Ln(e)).map(t=>t.fold(()=>[],()=>[e.name])))).fold($,e=>e.flat()),a={...t,cas:t.cas.filter(e=>!i.includes(e.name)),hosts:t.hosts.filter(e=>!e.ca.exists(e=>i.includes(e)))},{wiring:o}=await er(a,`check`),s=await tr(a,o),c=await Yn({fleet:t,configPath:e,runner:s,auditor:Mt({sink:n,now:()=>/* @__PURE__ */ new Date,newId:r}),missingKeyCas:i,caPublicKeys:nr(a,o),out:e=>console.log(e)});await s.shutdown(),process.exit(c)}async function ar(){let e=In(Pn(process.argv.slice(2),process.env,b())).fold($,e=>e);e.created&&console.error(`created fleet config at ${e.path}`);let t=e.path,n=A(()=>f(t,`utf8`)).fold(e=>(console.error(`Cannot read fleet config at ${t}: ${e.message}`),process.exit(1)),e=>e),i=rr(n),a=Lt(i.audit.path).fold(e=>{console.error(e),process.exit(1)},e=>e),{wiring:o,created:s}=await er(i,`serve`),c=await tr(i,o);i.hosts.forEach(e=>c.lastError(e.name).forEach(e=>console.error(`warning: ${e.message}`)));let l={configPath:t,configCreated:e.created,fleet:i,loadedConfig:n,readConfig:()=>A(()=>f(t,`utf8`)).fold(e=>E(`Cannot read ${t}: ${e.message}`),e=>k(e)),caPublicKeys:nr(i,o),caCreated:s,runtimeDir:o.map(e=>e.custody.runtimeDir),knownHosts:o.map(e=>e.knownHosts)},u=Ot(process.env.SSH_FLEET_CLIENT),d=e=>Zn(c,Mt({sink:a,now:()=>/* @__PURE__ */ new Date,newId:r,client:u.or(Dt(e.client)).orUndefined()}),l),p=ae({name:`ssh-fleet-mcp-server`,version:Qn,build:Rt(),instructions:`Manage a fleet of SSH hosts: pooled connections, host-routed exec and tmux tools. Call fleet_setup first if hosts_list is empty or a host fails to connect; it says what to configure next.`}),m=N.string().describe(`Configured host name (see hosts_list)`);p.addTool({name:`hosts_list`,description:`List configured fleet hosts and their live connection status. If a host you expect is missing, call fleet_setup: the config may have changed since the server started.`,parameters:N.object({}),execute:(e,t)=>d(t).hostsList()}),p.addTool({name:`fleet_setup`,description:`Show setup state: config path, each host's connection state and last error, each CA's public key and the line to add on hosts, and what to do next. Read-only.`,parameters:N.object({}),execute:(e,t)=>d(t).fleetSetup()}),p.addTool({name:`exec`,description:`Run a one-shot shell command on a fleet host over SSH.`,parameters:N.object({host:m,command:N.string().min(1).describe(`Shell command`),timeout_ms:N.number().int().positive().optional().describe(`How long to wait, in ms. Default ${i.pool.execTimeoutMs}; at most ${i.pool.maxExecTimeoutMs}, and a larger value is lowered to it. On timeout the call fails with the output so far, and the command may still be running. For longer jobs, run them in tmux (tmux_send, then tmux_read).`)}),execute:({host:e,command:t,timeout_ms:n},r)=>d(r).exec({host:e,command:t,timeout_ms:n})}),p.addTool({name:`tmux_list`,description:`List tmux sessions on a fleet host. Returns a JSON array of session names.`,parameters:N.object({host:m}),execute:({host:e},t)=>d(t).tmuxList({host:e})}),p.addTool({name:`tmux_send`,description:`Type text into a persistent tmux session on a fleet host (creates it if absent).`,parameters:N.object({host:m,session:N.string().optional().describe(`tmux session (defaults to the host's configured session)`),input:N.string().min(1).describe(`Text to type`),submit:N.boolean().optional().describe(`Press Enter after the text (default true)`)}),execute:({host:e,session:t,input:n,submit:r},i)=>d(i).tmuxSend({host:e,session:t,input:n,submit:r})}),p.addTool({name:`tmux_read`,description:`Capture the recent pane transcript of a tmux session on a fleet host.`,parameters:N.object({host:m,session:N.string().optional().describe(`tmux session (defaults to the host's configured session)`),lines:N.number().int().min(1).optional().describe(`Lines of scrollback (default 200; capped at 2000)`)}),execute:({host:e,session:t,lines:n},r)=>d(r).tmuxRead({host:e,session:t,lines:n})}),p.addTool({name:`tmux_keys`,description:`Send control/special keys (e.g. C-c) to a tmux session on a fleet host.`,parameters:N.object({host:m,session:N.string().optional().describe(`tmux session (defaults to the host's configured session)`),keys:N.array(N.string()).min(1).describe(`tmux key names, e.g. ['C-c']`)}),execute:({host:e,session:t,keys:n},r)=>d(r).tmuxKeys({host:e,session:t,keys:n})}),await p.start({transportType:`stdio`}),console.error(`SSH Fleet MCP Server running on stdio (${c.hostNames.length} hosts); audit log: ${i.audit.path}`);let h=async()=>{await c.shutdown(),await p.stop(),process.exit(0)};process.on(`SIGINT`,()=>void h()),process.on(`SIGTERM`,()=>void h())}(process.argv[2]===`check`?ir:ar)().catch(e=>{console.error(`Fatal error in main():`,e),process.exit(1)});export{};
66
+ //# sourceMappingURL=index.js.map