thinkncollab-shell 2.0.0-beta.4 → 2.0.0-beta.6

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.
Files changed (77) hide show
  1. package/Readme.md +35 -18
  2. package/dist/native/brand/tnc-256.png +0 -0
  3. package/dist/src/commands/dock/cluster.js +1 -1
  4. package/dist/src/commands/dock/containers.js +1 -1
  5. package/dist/src/commands/room/sync.js +1 -1
  6. package/dist/src/commands/sandbox/common.js +7 -0
  7. package/dist/src/commands/sandbox/console.js +1 -1
  8. package/dist/src/commands/sandbox/dep.js +1 -1
  9. package/dist/src/commands/sandbox/deploy.js +1 -1
  10. package/dist/src/commands/sandbox/dev.js +1 -1
  11. package/dist/src/commands/sandbox/exec.js +1 -1
  12. package/dist/src/commands/sandbox/push.js +1 -1
  13. package/dist/src/commands/sandbox/sandbox.js +1 -1
  14. package/dist/src/commands/sandbox/share.js +1 -1
  15. package/dist/src/commands/sandbox/snapshot.js +1 -1
  16. package/dist/src/commands/shell/doctor.js +1 -1
  17. package/dist/src/commands/tasks/build.js +1 -1
  18. package/dist/src/commands/terminal/share.js +1 -1
  19. package/dist/src/core/crypto/seal.js +7 -0
  20. package/dist/src/core/dependency-tracker.js +1 -1
  21. package/dist/src/core/foreground.js +7 -0
  22. package/dist/src/core/platform-socket.js +7 -0
  23. package/dist/src/core/room/sync-manager.js +7 -0
  24. package/dist/src/core/shell.js +1 -1
  25. package/dist/src/core/{sandbox/snapshot-manager.js → snapshot-manager.js} +1 -1
  26. package/dist/src/core/tunnel/client.js +7 -0
  27. package/dist/src/core/tunnel/daemon.js +7 -0
  28. package/dist/src/core/tunnel/index.js +7 -0
  29. package/dist/src/core/websocket.js +1 -1
  30. package/dist/src/core/window-launcher.js +7 -0
  31. package/dist/src/dock/bench/memory.js +1 -1
  32. package/dist/src/dock/cli/commands.js +1 -1
  33. package/dist/src/dock/cli/k8s.js +7 -0
  34. package/dist/src/dock/cli/services.js +1 -1
  35. package/dist/src/dock/cli/session.js +1 -1
  36. package/dist/src/dock/cli/workspace.js +1 -1
  37. package/dist/src/dock/engine.js +1 -1
  38. package/dist/src/dock/image/certs.js +7 -0
  39. package/dist/src/dock/image/registry.js +1 -1
  40. package/dist/src/dock/linux/host.js +1 -1
  41. package/dist/src/dock/room-data.js +7 -0
  42. package/dist/src/dock/runtime/network.js +1 -1
  43. package/dist/src/dock/sandbox.js +7 -0
  44. package/dist/src/dock/selftest.js +1 -1
  45. package/dist/src/dock/setup/autostart.js +1 -1
  46. package/dist/src/dock/setup/index.js +1 -1
  47. package/dist/src/dock/tray/install.js +1 -1
  48. package/dist/src/dock/workspace/share.js +7 -0
  49. package/dist/src/dock/workspace/workspace.js +1 -1
  50. package/package.json +1 -1
  51. package/dist/src/core/sandbox/arch-shim.js +0 -7
  52. package/dist/src/core/sandbox/capsule-builder.js +0 -7
  53. package/dist/src/core/sandbox/cgroups.js +0 -7
  54. package/dist/src/core/sandbox/container-monitor.js +0 -7
  55. package/dist/src/core/sandbox/crun-manager.js +0 -7
  56. package/dist/src/core/sandbox/engine.js +0 -7
  57. package/dist/src/core/sandbox/image-manager.js +0 -7
  58. package/dist/src/core/sandbox/image-store.js +0 -7
  59. package/dist/src/core/sandbox/index.js +0 -7
  60. package/dist/src/core/sandbox/macos-sandbox.js +0 -7
  61. package/dist/src/core/sandbox/microvm-manager.js +0 -7
  62. package/dist/src/core/sandbox/native-service-manager.js +0 -7
  63. package/dist/src/core/sandbox/network.js +0 -7
  64. package/dist/src/core/sandbox/oci-exporter.js +0 -7
  65. package/dist/src/core/sandbox/oci-puller.js +0 -7
  66. package/dist/src/core/sandbox/oci-runtime-spec.js +0 -7
  67. package/dist/src/core/sandbox/port-proxy-daemon.js +0 -7
  68. package/dist/src/core/sandbox/room-sync-manager.js +0 -7
  69. package/dist/src/core/sandbox/runtime-manager.js +0 -7
  70. package/dist/src/core/sandbox/storage.js +0 -7
  71. package/dist/src/core/sandbox/supervisor.js +0 -7
  72. package/dist/src/core/sandbox/tunnel-daemon.js +0 -7
  73. package/dist/src/core/sandbox/tunnel.js +0 -7
  74. package/dist/src/core/sandbox/window-launcher.js +0 -7
  75. package/dist/src/core/sandbox/windows-wsl2.js +0 -7
  76. package/dist/src/core/sandbox-window.js +0 -8
  77. /package/dist/src/core/{sandbox/policy-engine.js → policy-engine.js} +0 -0
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const fs=require("fs"),path=require("path"),{DockApi,demuxer,enc}=require("./cli/api"),DUMPS={postgres:{file:"postgres.sql",dump:["pg_dumpall","-U","postgres","--clean","--if-exists"],load:["psql","-U","postgres","-q","-v","ON_ERROR_STOP=0","-d","postgres","-f","-"]},mysql:{file:"mysql.sql",dump:["sh","-c","mysqldump -uroot --all-databases --add-drop-database --routines --events --single-transaction 2>/dev/null"],load:["mysql","-uroot"]},mongo:{file:"mongo.archive",dump:["mongodump","--archive","--quiet"],load:["mongorestore","--archive","--drop","--quiet"]},redis:{file:"redis.rdb",dump:["sh","-c","redis-cli --rdb /tmp/tnc-share.rdb >/dev/null 2>&1 && cat /tmp/tnc-share.rdb && rm -f /tmp/tnc-share.rdb"],load:["sh","-c","cat > /data/tnc-incoming.rdb && mv /data/tnc-incoming.rdb /data/dump.rdb && redis-cli shutdown nosave >/dev/null 2>&1; true"],restarts:!0}},containerName=e=>`tnc-svc-${e}`;async function execIn(e,t,r,{stdinFile:n=null,outFile:s=null,user:i=null}={}){const{Id:a}=await e.json("POST",`/containers/${enc(t)}/exec`,{Cmd:r,AttachStdin:!!n,AttachStdout:!0,AttachStderr:!0,Tty:!1,...i?{User:i}:{}}),{socket:o}=await e.hijack(`/exec/${a}/start`,{Detach:!1,Tty:!1}),c=s?fs.createWriteStream(s):null;let d="";const u=demuxer(e=>{c&&c.write(e)},e=>{d.length<8192&&(d+=e)});return await new Promise((e,t)=>{if(o.on("data",u),o.on("end",e),o.on("close",e),o.on("error",t),n){const e=fs.createReadStream(n);e.on("error",t),e.on("data",e=>o.write(e)),e.on("end",()=>o.end())}}),c&&await new Promise(e=>c.end(e)),{code:(await e.json("GET",`/exec/${a}/json`)).ExitCode??0,stderr:d.trim()}}async function inspectOrNull(e,t){try{return await e.json("GET",`/containers/${enc(t)}/json`)}catch(e){return null}}async function captureServices(e,{api:t=new DockApi,log:r=()=>{}}={}){try{await t.ensureRunning({quiet:!0})}catch(e){return[]}const n=path.join(e,"data","dock"),s=[];for(const[e,i]of Object.entries(DUMPS)){const a=await inspectOrNull(t,containerName(e));if(!a?.State?.Running)continue;fs.mkdirSync(n,{recursive:!0});const o=path.join(n,i.file);r(`dumping ${e} (${containerName(e)})`);const{code:c,stderr:d}=await execIn(t,containerName(e),i.dump,{outFile:o});0===c&&fs.statSync(o).size?s.push(e):(fs.rmSync(o,{force:!0}),r(`${e} skipped: ${d||`exit ${c}`}`))}return s}async function restoreServices(e,{api:t=new DockApi,log:r=()=>{}}={}){const n=path.join(e,"data","dock");if(!fs.existsSync(n))return[];await t.ensureRunning({quiet:!0});const{service:s}=require("./cli/services"),i={write(){}},a=[];for(const[e,o]of Object.entries(DUMPS)){const c=path.join(n,o.file);if(!fs.existsSync(c))continue;r(`starting ${e}`),await s(["start",e],{api:t,out:i,err:i}),r(`loading ${e}`);const{code:d,stderr:u}=await execIn(t,containerName(e),o.load,{stdinFile:c});if(0!==d&&!o.restarts)throw new Error(`${e} restore failed: ${u||`exit ${d}`}`);for(let r=0;o.restarts;r++){await new Promise(e=>setTimeout(e,1e3));try{await s(["start",e],{api:t,out:i,err:i});break}catch(e){if(r>=4)throw e}}a.push(e)}return a}module.exports={captureServices,restoreServices,execIn,DUMPS};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const crypto=require("crypto"),DEFAULT_BRIDGE={id:"bridge",name:"bridge",driver:"bridge",builtin:!0,dns:!1,bridgeIf:"tnc0",subnet:"172.28.0.0/16",gateway:"172.28.0.1",prefixLen:16,poolStart:"172.28.128.1",poolEnd:"172.28.255.254"},HOST={id:"host",name:"host",driver:"host",builtin:!0},NONE={id:"none",name:"none",driver:"null",builtin:!0},USER_POOL={base:"172.29",first:0,last:255},ipToInt=e=>e.split(".").reduce((e,n)=>(e<<8|Number(n))>>>0,0),intToIp=e=>[e>>>24,e>>>16&255,e>>>8&255,255&e].join(".");function httpError(e,n){const t=new Error(n);return t.statusCode=e,t}function ipv4Range(e){const n=/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(e.Subnet);if(!n||Number(n[2])<8||Number(n[2])>30)throw httpError(400,`invalid subnet ${e.Subnet}`);const t=Number(n[2]),i=-1<<32-t>>>0,r=(ipToInt(n[1])&i)>>>0,s=(r|~i)>>>0;let[a,o]=[r+1,s-1];if(e.IPRange){const n=/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(e.IPRange);if(!n)throw httpError(400,`invalid ip range ${e.IPRange}`);const t=-1<<32-Number(n[2])>>>0,s=(ipToInt(n[1])&t)>>>0;if((s&i)>>>0!==r)throw httpError(400,`ip range ${e.IPRange} is not inside ${e.Subnet}`);[a,o]=[Math.max(a,s),Math.min(o,(s|~t)>>>0)]}return{netInt:r,plen:t,lo:a,hi:o}}const NETNS_DEFAULTS='ip netns exec "$NS" sh -c \'echo 0 > /proc/sys/net/ipv4/ip_unprivileged_port_start; echo "0 2147483647" > /proc/sys/net/ipv4/ping_group_range\' 2>/dev/null || true',BASE_SETUP="\nmkdir -p /var/run/netns\necho 1 > /proc/sys/net/ipv4/ip_forward\niptables -N TNC-ISOLATION 2>/dev/null || true\niptables -C FORWARD -j TNC-ISOLATION 2>/dev/null || iptables -I FORWARD 1 -j TNC-ISOLATION\niptables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -i tnc+ -j ACCEPT\niptables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -o tnc+ -j ACCEPT\n",BRIDGE_SETUP=`\n${BASE_SETUP}\nbr="$1"; gw="$2"; plen="$3"; subnet="$4"; chain="$5"; internal="$6"\nip link show "$br" >/dev/null 2>&1 || ip link add "$br" type bridge\nip -4 addr show dev "$br" | grep -q "$gw/$plen" || ip addr add "$gw/$plen" dev "$br"\nip link set "$br" up\nif [ "$internal" = 1 ]; then\n iptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || true\nelse\n iptables -t nat -C POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE\nfi\nif ! iptables -L "$chain" -n >/dev/null 2>&1; then\n iptables -N "$chain"\n iptables -A "$chain" -o "$br" -j RETURN\n iptables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then iptables -A "$chain" -j DROP; fi\n iptables -A "$chain" -j RETURN\nfi\niptables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || iptables -A TNC-ISOLATION -i "$br" -j "$chain"\n# IPv6 (network create --ipv6): $7 subnet6, $8 gateway6\nsubnet6="$7"; gw6="$8"\nif [ -n "$gw6" ]; then\n echo 0 > "/proc/sys/net/ipv6/conf/$br/disable_ipv6"\n echo 1 > /proc/sys/net/ipv6/conf/all/forwarding\n ip -6 addr show dev "$br" | grep -q "$gw6/64" || ip -6 addr add "$gw6/64" dev "$br" nodad\n if command -v ip6tables >/dev/null 2>&1; then\n ip6tables -N TNC-ISOLATION 2>/dev/null || true\n ip6tables -C FORWARD -j TNC-ISOLATION 2>/dev/null || ip6tables -I FORWARD 1 -j TNC-ISOLATION\n ip6tables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || ip6tables -A FORWARD -i tnc+ -j ACCEPT\n ip6tables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || ip6tables -A FORWARD -o tnc+ -j ACCEPT\n if ! ip6tables -L "$chain" -n >/dev/null 2>&1; then\n ip6tables -N "$chain"\n ip6tables -A "$chain" -o "$br" -j RETURN\n ip6tables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then ip6tables -A "$chain" -j DROP; fi\n ip6tables -A "$chain" -j RETURN\n fi\n ip6tables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || ip6tables -A TNC-ISOLATION -i "$br" -j "$chain"\n # outbound IPv6 through this machine's address; best effort (a kernel without IPv6 NAT keeps it inside)\n if [ "$internal" != 1 ]; then\n ip6tables -t nat -C POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null || ip6tables -t nat -A POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null || true\n fi\n fi\nfi\n`,BRIDGE_REMOVE='\nbr="$1"; subnet="$2"; chain="$3"; subnet6="$4"\niptables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\niptables -F "$chain" 2>/dev/null; iptables -X "$chain" 2>/dev/null\niptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null\nif [ -n "$subnet6" ] && command -v ip6tables >/dev/null 2>&1; then\n ip6tables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\n ip6tables -F "$chain" 2>/dev/null; ip6tables -X "$chain" 2>/dev/null\n ip6tables -t nat -D POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null\nfi\nip link del "$br" 2>/dev/null\ntrue\n';function ipv6Subnet(e,n={}){const t=(n.Config||[]).map(e=>e.Subnet).find(e=>e&&e.includes(":"));if(t){const[e,n]=t.split("/");if(64!==Number(n))throw httpError(400,`IPv6 subnet ${t}: only /64 subnets are supported`);const i=expand6(e);if(!i)throw httpError(400,`invalid IPv6 subnet ${t}`);return i.slice(0,4).map(e=>e.replace(/^0+(?=.)/,"")).join(":")}return`fd${e.slice(0,2)}:${e.slice(2,6)}:${e.slice(6,10)}:0`}function expand6(e){if(!/^[0-9a-fA-F:]+$/.test(e)||(e.match(/::/g)||[]).length>1)return null;const[n,t]=e.split("::"),i=n?n.split(":"):[],r=void 0!==t&&t?t.split(":"):[],s=e.includes("::")?8-i.length-r.length:0,a=[...i,...Array(Math.max(0,s)).fill("0"),...r];return 8!==a.length||a.some(e=>!/^[0-9a-fA-F]{1,4}$/.test(e))?null:a.map(e=>e.toLowerCase().padStart(4,"0"))}const ATTACH=`\nset -e\nns="$1"; br="$2"; vh="$3"; vp="$4"; ifn="$5"; ip="$6"; plen="$7"; mac="$8"; gw="$9"; route="\${10}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$ns")}; }\nip link del "$vh" 2>/dev/null || true\nip link add "$vh" type veth peer name "$vp"\nip link set "$vh" master "$br"\nip link set "$vh" up\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nip -n "$ns" link set "$ifn" address "$mac"\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\n# an address may have belonged to a container that just left: drop the bridge's\n# stale neighbour entry and announce the new MAC (gratuitous ARP on link up)\nip neigh flush to "$ip" dev "$br" 2>/dev/null || true\nip netns exec "$ns" sh -c "echo 1 > /proc/sys/net/ipv4/conf/$ifn/arp_notify" 2>/dev/null || true\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ]; then ip -n "$ns" route replace default via "$gw"; fi\n# \${13}: MTU of an overlay network (the tunnel takes 50 bytes of every packet)\nif [ -n "\${13}" ]; then ip link set "$vh" mtu "\${13}"; ip -n "$ns" link set "$ifn" mtu "\${13}"; fi\n# IPv6: \${11} address, \${12} gateway (empty: an IPv4-only network)\nip6="\${11}"; gw6="\${12}"\nif [ -n "$ip6" ]; then\n ip netns exec "$ns" sh -c "echo 0 > /proc/sys/net/ipv6/conf/$ifn/disable_ipv6"\n ip -n "$ns" -6 addr add "$ip6/64" dev "$ifn" nodad\n if [ "$route" = 1 ]; then ip -n "$ns" -6 route replace default via "$gw6" dev "$ifn"; fi\nfi\n`,LAN_ATTACH=`\nset -e\nns="$1"; parent="$2"; kind="$3"; mode="$4"; vp="$5"; ifn="$6"; ip="$7"; plen="$8"; gw="$9"; route="\${10}"; mac="\${11}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$ns")}; }\n[ -n "$parent" ] || parent=$(ip -4 route show default | awk '{print $5; exit}')\nip link show "$parent" >/dev/null 2>&1 || { echo "parent interface \\"$parent\\" does not exist on this machine" >&2; exit 1; }\nip link del "$vp" 2>/dev/null || true\nif [ "$kind" = macvlan ]; then ip link add "$vp" link "$parent" type macvlan mode "$mode"; else ip link add "$vp" link "$parent" type ipvlan mode "$mode"; fi\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nif [ "$kind" = macvlan ]; then ip -n "$ns" link set "$ifn" address "$mac"; fi\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ] && [ -n "$gw" ]; then ip -n "$ns" route replace default via "$gw"; fi\n`,VXLAN_SETUP='\nset -e\nbr="$1"; vx="$2"; vni="$3"; port="$4"; local="$5"; mtu="$6"; shift 6\nif ! ip link show "$vx" >/dev/null 2>&1; then\n if [ -n "$local" ]; then ip link add "$vx" type vxlan id "$vni" dstport "$port" local "$local"; else ip link add "$vx" type vxlan id "$vni" dstport "$port"; fi\nfi\nip link set "$vx" mtu "$mtu"\nip link set "$br" mtu "$mtu"\nip link set "$vx" master "$br"\nip link set "$vx" up\nfor peer; do\n bridge fdb show dev "$vx" | grep -q "dst $peer " || bridge fdb append 00:00:00:00:00:00 dev "$vx" dst "$peer"\ndone\niptables -C INPUT -p udp --dport "$port" -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport "$port" -j ACCEPT\n# the engines\' name announcements on the overlay itself (runtime/overlaydns.js)\niptables -C INPUT -i "$br" -p udp --dport 7946 -j ACCEPT 2>/dev/null || iptables -I INPUT -i "$br" -p udp --dport 7946 -j ACCEPT\n',NETNS_ONLY=`set -e; mkdir -p /var/run/netns; [ -e "/var/run/netns/$1" ] || { ip netns add "$1"; ip -n "$1" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$1")}; }`,DETACH_ONE='ip -n "$1" link del "$2" 2>/dev/null; ip link del "$3" 2>/dev/null; true',DETACH_ALL='ip netns del "$1" 2>/dev/null; true';class Network{constructor({host:e,store:n}){this.host=e,this.store=n,this.reserved=new Set,this.lastIp=new Map}builtins(){return[DEFAULT_BRIDGE,HOST,NONE]}resolve(e){if(!e||"default"===e||"bridge"===e)return DEFAULT_BRIDGE;if("host"===e)return HOST;if("none"===e)return NONE;const n=this.store.findNetwork(e);if(!n)throw httpError(404,`network ${e} not found`);return{...n,dns:!0,prefixLen:n.prefixLen||24}}list(){return[...this.builtins(),...this.store.listNetworks().map(e=>({...e,dns:!0,prefixLen:e.prefixLen||24}))]}create({Name:e,Driver:n,Labels:t,Internal:i=!1,Options:r,EnableIPv6:s=!1,IPAM:a}){if(n=n||"bridge",t=t||{},r=r||{},!e)throw httpError(400,"network name is required");if(!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(e))throw httpError(400,`invalid network name "${e}", only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed`);if(["bridge","host","none","default"].includes(e))throw httpError(403,`${e} is a pre-defined network and cannot be created`);if(this.store.findNetwork(e)?.name===e)throw httpError(409,`network with name ${e} already exists`);if("macvlan"===n||"ipvlan"===n)return this._createLan({Name:e,Driver:n,Labels:t,Internal:i,Options:r,IPAM:a});if("overlay"===n)return this._createOverlay({Name:e,Labels:t,Internal:i,Options:r,IPAM:a});if("bridge"!==n)throw httpError(400,`network driver "${n}" is not supported; use bridge, macvlan, ipvlan or overlay`);const o=new Set(this.store.listNetworks().map(e=>e.subnet));let l=null;for(let e=USER_POOL.first;e<=USER_POOL.last;e++){const n=`${USER_POOL.base}.${e}.0/24`;if(!o.has(n)){l=n;break}}if(!l)throw httpError(500,"no free subnets left in 172.29.0.0/16");const p=crypto.randomBytes(32).toString("hex"),d=l.split("/")[0].split(".").slice(0,3).join("."),h=s||(a?.Config||[]).some(e=>e.Subnet?.includes(":"))?ipv6Subnet(p,a):null;return this.store.insertNetwork({id:p,name:e,driver:"bridge",subnet:l,gateway:`${d}.1`,poolStart:`${d}.2`,poolEnd:`${d}.254`,...h?{subnet6:`${h}::/64`,gateway6:`${h}::1`,prefix6:h}:{},bridgeIf:`tncb-${p.slice(0,8)}`,chain:`TNC-ISO-${p.slice(0,8)}`,labels:t,internal:!!i,options:r})}_needLinuxRoot(e,n){if("local"!==this.host.kind)throw httpError(400,`${e} networks need dock on Linux as root: here containers run behind ${{vm:"the Linux VM's NAT",wsl:"WSL's NAT",rootless:"rootless user-mode networking"}[this.host.kind]||"NAT"}, so ${n}`)}_createLan({Name:e,Driver:n,Labels:t,Internal:i,Options:r,IPAM:s}){this._needLinuxRoot(n,"there is no LAN interface to put them on");const a=(s?.Config||[]).find(e=>e.Subnet&&!e.Subnet.includes(":"));if(!a)throw httpError(400,`a ${n} network needs the LAN's subnet and gateway, e.g. --subnet 192.168.1.0/24 --gateway 192.168.1.1`);const{netInt:o,plen:l,lo:p,hi:d}=ipv4Range(a),h=a.Gateway||intToIp(o+1),$="macvlan"===n?r.macvlan_mode||"bridge":r.ipvlan_mode||"l2";if(!("macvlan"===n?["bridge","private","vepa","passthru"]:["l2","l3","l3s"]).includes($))throw httpError(400,`invalid ${n} mode ${$}`);if(r.parent&&!/^[A-Za-z0-9_.@-]{1,15}$/.test(r.parent))throw httpError(400,`invalid parent interface ${r.parent}`);const u=crypto.randomBytes(32).toString("hex");return this.store.insertNetwork({id:u,name:e,driver:n,subnet:`${intToIp(o)}/${l}`,gateway:h,prefixLen:l,poolStart:intToIp(p),poolEnd:intToIp(d),parent:r.parent||"",mode:$,labels:t,internal:!!i,options:r})}_createOverlay({Name:e,Labels:n,Internal:t,Options:i,IPAM:r}){this._needLinuxRoot("overlay","other machines cannot reach its tunnel");const s=(r?.Config||[]).find(e=>e.Subnet&&!e.Subnet.includes(":"));if(!s)throw httpError(400,"an overlay network needs its subnet (the same on every machine), e.g. --subnet 10.20.0.0/16 --ip-range 10.20.1.0/24");const a=String(i.peers||"").split(",").map(e=>e.trim()).filter(Boolean);for(const e of a)if(!require("net").isIPv4(e))throw httpError(400,`peer ${e} is not an IPv4 address`);if(a.length&&!s.IPRange)throw httpError(400,"an overlay network across machines needs --ip-range: a different part of the subnet on each machine, so their containers never get the same address");const{netInt:o,plen:l,hi:p}=ipv4Range(s);let{lo:d}=ipv4Range(s);if(255&d||d++,d>=p)throw httpError(400,`ip range ${s.IPRange||s.Subnet} is too small`);const h=s.Gateway||intToIp(d),$=2**(32-l);for(const e of[DEFAULT_BRIDGE,...this.store.listNetworks()]){const[n,t]=String(e.subnet||"").split("/");if(!n||n.includes(":"))continue;const i=ipToInt(n);if(o<i+2**(32-Number(t))&&i<o+$)throw httpError(400,`subnet ${s.Subnet} overlaps network ${e.name} (${e.subnet})`)}const u=void 0!==i.vni?Number(i.vni):parseInt(crypto.createHash("sha256").update(e).digest("hex").slice(0,6),16)||1;if(!Number.isInteger(u)||u<1||u>16777215)throw httpError(400,"vni must be a number from 1 to 16777215");const c=Number(i.port||4789),v=Number(i.mtu||1450);if(!Number.isInteger(c)||c<1||c>65535)throw httpError(400,"port must be a UDP port number");if(!Number.isInteger(v)||v<1280||v>9e3)throw httpError(400,"mtu must be from 1280 to 9000");if(i.local&&!require("net").isIPv4(i.local))throw httpError(400,`local ${i.local} is not an IPv4 address`);const f=crypto.randomBytes(32).toString("hex");return this.store.insertNetwork({id:f,name:e,driver:"overlay",subnet:`${intToIp(o)}/${l}`,gateway:h,prefixLen:l,poolStart:intToIp(s.Gateway?d:d+1),poolEnd:intToIp(p),bridgeIf:`tnco-${f.slice(0,8)}`,vxlanIf:`tncx-${f.slice(0,8)}`,chain:`TNC-ISO-${f.slice(0,8)}`,vni:u,port:c,mtu:v,peers:a,local:i.local||"",labels:n,internal:!!t,options:i})}_allocate6(e,n){if(!e.prefix6)return null;const t=new Set(this.reserved);for(const e of this.store.listContainers())if(e.id!==n)for(const n of Object.values(e.runtime.networks||{}))n.ip6&&t.add(n.ip6);for(let n=2;n<65535;n++){const i=`${e.prefix6}::${n.toString(16)}`;if(!t.has(i))return this.reserved.add(i),i}throw httpError(500,`no free IPv6 addresses left in network ${e.name}`)}async remove(e){if(e.builtin)throw httpError(403,`${e.name} is a pre-defined network and cannot be removed`);"bridge"!==e.driver&&"overlay"!==e.driver||!await this.host.isUp().catch(()=>!1)||(e.vxlanIf&&await this.host.sh('ip link del "$1" 2>/dev/null; true',[e.vxlanIf]),await this.host.sh(BRIDGE_REMOVE,[e.bridgeIf,e.subnet,e.chain,e.subnet6||""])),this.store.deleteNetwork(e.id)}_allocate(e,n){const t=new Set(this.reserved);e.gateway&&t.add(e.gateway);for(const e of this.store.listContainers())if(e.id!==n){for(const n of Object.values(e.runtime.networks||{}))n.ip&&t.add(n.ip);e.runtime.ip&&t.add(e.runtime.ip)}const i=ipToInt(e.poolStart),r=ipToInt(e.poolEnd)-i+1,s=this.lastIp.get(e.id),a=s?(ipToInt(s)-i+1)%r:0;for(let n=0;n<r;n++){const s=intToIp(i+(a+n)%r);if(!t.has(s))return this.reserved.add(s),this.lastIp.set(e.id,s),s}throw httpError(500,`no free addresses left in network ${e.name}`)}names(e,n){const t=/^[0-9a-f]{12}/.test(e)?e:crypto.createHash("sha256").update(e).digest("hex");return{ns:`tnc-${t.slice(0,12)}`,vh:`vt${t.slice(0,8)}${n}`,vp:`vp${t.slice(0,8)}${n}`}}async _ensureBridge(e){const n=e.builtin?"TNC-ISO-default":e.chain;await this.host.sh(BRIDGE_SETUP,[e.bridgeIf,e.gateway,e.prefixLen,e.subnet,n,e.internal?1:0,e.subnet6||"",e.gateway6||""]),"overlay"===e.driver&&await this.host.sh(VXLAN_SETUP,[e.bridgeIf,e.vxlanIf,String(e.vni),String(e.port),e.local||"",String(e.mtu),...e.peers||[]]).catch(n=>{throw httpError(500,`overlay network ${e.name}: ${/vxlan|Unknown device type|not supported/i.test(n.message)?"this kernel has no VXLAN support (modprobe vxlan)":n.message}`)})}async attachOne(e,n,t,i=[]){const{ns:r,vh:s,vp:a}=this.names(e,t);if("null"===n.driver)return await this.host.sh(NETNS_ONLY,[r]),{networkId:"none",ifname:null,aliases:i};if("macvlan"===n.driver||"ipvlan"===n.driver){const s=this._allocate(n,e),o="02:42:"+crypto.createHash("sha1").update(`${e}:${n.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(LAN_ATTACH,[r,n.parent||"",n.driver,n.mode,a,`eth${t}`,s,n.prefixLen,n.gateway,0===t?1:0,o])}catch(e){throw this.reserved.delete(s),e}return{networkId:n.id,ifname:`eth${t}`,idx:t,ip:s,mac:"macvlan"===n.driver?o:"",gateway:n.gateway,prefixLen:n.prefixLen,aliases:i}}await this._ensureBridge(n);const o=this._allocate(n,e),l=this._allocate6(n,e),p="02:42:"+crypto.createHash("sha1").update(`${e}:${n.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(ATTACH,[r,n.bridgeIf,s,a,`eth${t}`,o,n.prefixLen,p,n.gateway,0===t?1:0,l||"",n.gateway6||"","overlay"===n.driver?String(n.mtu):""])}catch(e){throw this.reserved.delete(o),l&&this.reserved.delete(l),e}return{networkId:n.id,ifname:`eth${t}`,idx:t,ip:o,mac:p,gateway:n.gateway,prefixLen:n.prefixLen,aliases:i,...l?{ip6:l,gateway6:n.gateway6,prefixLen6:64}:{}}}async attachAll(e,n){const t=n.map(e=>({net:this.resolve(e.name),aliases:e.aliases||[]}));if("host"===t[0].net.driver)return{nsPath:null,endpoints:{host:{networkId:"host"}}};const i={};try{for(let n=0;n<t.length;n++)i[t[n].net.name]=await this.attachOne(e,t[n].net,n,t[n].aliases)}catch(n){throw await this.detachAll(e,i),n}return{nsPath:`/var/run/netns/${this.names(e,0).ns}`,endpoints:i}}async detachOne(e,n){if(!n||void 0===n.idx)return;const{ns:t,vh:i}=this.names(e,n.idx);await this.host.sh(DETACH_ONE,[t,n.ifname,i]).catch(()=>{}),n.ip&&this.reserved.delete(n.ip),n.ip6&&this.reserved.delete(n.ip6)}async detachAll(e,n={}){if(!n.host){await this.host.sh(DETACH_ALL,[this.names(e,0).ns]).catch(()=>{});for(const e of Object.values(n))e.ip&&this.reserved.delete(e.ip),e.ip6&&this.reserved.delete(e.ip6)}}}module.exports={Network,DEFAULT_BRIDGE,ipToInt};
7
+ "use strict";const crypto=require("crypto"),DEFAULT_BRIDGE={id:"bridge",name:"bridge",driver:"bridge",builtin:!0,dns:!1,bridgeIf:"tnc0",subnet:"172.28.0.0/16",gateway:"172.28.0.1",prefixLen:16,poolStart:"172.28.128.1",poolEnd:"172.28.255.254"},HOST={id:"host",name:"host",driver:"host",builtin:!0},NONE={id:"none",name:"none",driver:"null",builtin:!0},USER_POOL={base:"172.29",first:0,last:255},ipToInt=e=>e.split(".").reduce((e,t)=>(e<<8|Number(t))>>>0,0),intToIp=e=>[e>>>24,e>>>16&255,e>>>8&255,255&e].join(".");function httpError(e,t){const n=new Error(t);return n.statusCode=e,n}function ipv4Range(e){const t=/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(e.Subnet);if(!t||Number(t[2])<8||Number(t[2])>30)throw httpError(400,`invalid subnet ${e.Subnet}`);const n=Number(t[2]),i=-1<<32-n>>>0,r=(ipToInt(t[1])&i)>>>0,s=(r|~i)>>>0;let[a,o]=[r+1,s-1];if(e.IPRange){const t=/^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(e.IPRange);if(!t)throw httpError(400,`invalid ip range ${e.IPRange}`);const n=-1<<32-Number(t[2])>>>0,s=(ipToInt(t[1])&n)>>>0;if((s&i)>>>0!==r)throw httpError(400,`ip range ${e.IPRange} is not inside ${e.Subnet}`);[a,o]=[Math.max(a,s),Math.min(o,(s|~n)>>>0)]}return{netInt:r,plen:n,lo:a,hi:o}}const NETNS_DEFAULTS='ip netns exec "$NS" sh -c \'echo 0 > /proc/sys/net/ipv4/ip_unprivileged_port_start; echo "0 2147483647" > /proc/sys/net/ipv4/ping_group_range\' 2>/dev/null || true',BASE_SETUP="\nmkdir -p /var/run/netns\necho 1 > /proc/sys/net/ipv4/ip_forward\niptables -N TNC-ISOLATION 2>/dev/null || true\niptables -C FORWARD -j TNC-ISOLATION 2>/dev/null || iptables -I FORWARD 1 -j TNC-ISOLATION\niptables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -i tnc+ -j ACCEPT\niptables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || iptables -A FORWARD -o tnc+ -j ACCEPT\n",BRIDGE_SETUP=`\n${BASE_SETUP}\nbr="$1"; gw="$2"; plen="$3"; subnet="$4"; chain="$5"; internal="$6"\nip link show "$br" >/dev/null 2>&1 || ip link add "$br" type bridge\nip -4 addr show dev "$br" | grep -q "$gw/$plen" || ip addr add "$gw/$plen" dev "$br"\nip link set "$br" up\nif [ "$internal" = 1 ]; then\n iptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || true\nelse\n iptables -t nat -C POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE\nfi\nif ! iptables -L "$chain" -n >/dev/null 2>&1; then\n iptables -N "$chain"\n iptables -A "$chain" -o "$br" -j RETURN\n iptables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then iptables -A "$chain" -j DROP; fi\n iptables -A "$chain" -j RETURN\nfi\niptables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || iptables -A TNC-ISOLATION -i "$br" -j "$chain"\n# IPv6 (network create --ipv6): $7 subnet6, $8 gateway6\nsubnet6="$7"; gw6="$8"\nif [ -n "$gw6" ]; then\n echo 0 > "/proc/sys/net/ipv6/conf/$br/disable_ipv6"\n echo 1 > /proc/sys/net/ipv6/conf/all/forwarding\n ip -6 addr show dev "$br" | grep -q "$gw6/64" || ip -6 addr add "$gw6/64" dev "$br" nodad\n if command -v ip6tables >/dev/null 2>&1; then\n ip6tables -N TNC-ISOLATION 2>/dev/null || true\n ip6tables -C FORWARD -j TNC-ISOLATION 2>/dev/null || ip6tables -I FORWARD 1 -j TNC-ISOLATION\n ip6tables -C FORWARD -i tnc+ -j ACCEPT 2>/dev/null || ip6tables -A FORWARD -i tnc+ -j ACCEPT\n ip6tables -C FORWARD -o tnc+ -j ACCEPT 2>/dev/null || ip6tables -A FORWARD -o tnc+ -j ACCEPT\n if ! ip6tables -L "$chain" -n >/dev/null 2>&1; then\n ip6tables -N "$chain"\n ip6tables -A "$chain" -o "$br" -j RETURN\n ip6tables -A "$chain" -o tnc+ -j DROP\n if [ "$internal" = 1 ]; then ip6tables -A "$chain" -j DROP; fi\n ip6tables -A "$chain" -j RETURN\n fi\n ip6tables -C TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null || ip6tables -A TNC-ISOLATION -i "$br" -j "$chain"\n # outbound IPv6 through this machine's address; best effort (a kernel without IPv6 NAT keeps it inside)\n if [ "$internal" != 1 ]; then\n ip6tables -t nat -C POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null || ip6tables -t nat -A POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null || true\n fi\n fi\nfi\n`,BRIDGE_REMOVE='\nbr="$1"; subnet="$2"; chain="$3"; subnet6="$4"\niptables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\niptables -F "$chain" 2>/dev/null; iptables -X "$chain" 2>/dev/null\niptables -t nat -D POSTROUTING -s "$subnet" ! -o "$br" -j MASQUERADE 2>/dev/null\nif [ -n "$subnet6" ] && command -v ip6tables >/dev/null 2>&1; then\n ip6tables -D TNC-ISOLATION -i "$br" -j "$chain" 2>/dev/null\n ip6tables -F "$chain" 2>/dev/null; ip6tables -X "$chain" 2>/dev/null\n ip6tables -t nat -D POSTROUTING -s "$subnet6" ! -o "$br" -j MASQUERADE 2>/dev/null\nfi\nip link del "$br" 2>/dev/null\ntrue\n';function ipv6Subnet(e,t={}){const n=(t.Config||[]).map(e=>e.Subnet).find(e=>e&&e.includes(":"));if(n){const[e,t]=n.split("/");if(64!==Number(t))throw httpError(400,`IPv6 subnet ${n}: only /64 subnets are supported`);const i=expand6(e);if(!i)throw httpError(400,`invalid IPv6 subnet ${n}`);return i.slice(0,4).map(e=>e.replace(/^0+(?=.)/,"")).join(":")}return`fd${e.slice(0,2)}:${e.slice(2,6)}:${e.slice(6,10)}:0`}function expand6(e){if(!/^[0-9a-fA-F:]+$/.test(e)||(e.match(/::/g)||[]).length>1)return null;const[t,n]=e.split("::"),i=t?t.split(":"):[],r=void 0!==n&&n?n.split(":"):[],s=e.includes("::")?8-i.length-r.length:0,a=[...i,...Array(Math.max(0,s)).fill("0"),...r];return 8!==a.length||a.some(e=>!/^[0-9a-fA-F]{1,4}$/.test(e))?null:a.map(e=>e.toLowerCase().padStart(4,"0"))}const ATTACH=`\nset -e\nns="$1"; br="$2"; vh="$3"; vp="$4"; ifn="$5"; ip="$6"; plen="$7"; mac="$8"; gw="$9"; route="\${10}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$ns")}; }\nip link del "$vh" 2>/dev/null || true\nip link add "$vh" type veth peer name "$vp"\nip link set "$vh" master "$br"\nip link set "$vh" up\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nip -n "$ns" link set "$ifn" address "$mac"\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\n# an address may have belonged to a container that just left: drop the bridge's\n# stale neighbour entry and announce the new MAC (gratuitous ARP on link up)\nip neigh flush to "$ip" dev "$br" 2>/dev/null || true\nip netns exec "$ns" sh -c "echo 1 > /proc/sys/net/ipv4/conf/$ifn/arp_notify" 2>/dev/null || true\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ]; then ip -n "$ns" route replace default via "$gw"; fi\n# \${13}: MTU of an overlay network (the tunnel takes 50 bytes of every packet)\nif [ -n "\${13}" ]; then ip link set "$vh" mtu "\${13}"; ip -n "$ns" link set "$ifn" mtu "\${13}"; fi\n# IPv6: \${11} address, \${12} gateway (empty: an IPv4-only network)\nip6="\${11}"; gw6="\${12}"\nif [ -n "$ip6" ]; then\n ip netns exec "$ns" sh -c "echo 0 > /proc/sys/net/ipv6/conf/$ifn/disable_ipv6"\n ip -n "$ns" -6 addr add "$ip6/64" dev "$ifn" nodad\n if [ "$route" = 1 ]; then ip -n "$ns" -6 route replace default via "$gw6" dev "$ifn"; fi\nfi\n`,LAN_ATTACH=`\nset -e\nns="$1"; parent="$2"; kind="$3"; mode="$4"; vp="$5"; ifn="$6"; ip="$7"; plen="$8"; gw="$9"; route="\${10}"; mac="\${11}"\nmkdir -p /var/run/netns\n[ -e "/var/run/netns/$ns" ] || { ip netns add "$ns"; ip -n "$ns" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$ns")}; }\n[ -n "$parent" ] || parent=$(ip -4 route show default | awk '{print $5; exit}')\nip link show "$parent" >/dev/null 2>&1 || { echo "parent interface \\"$parent\\" does not exist on this machine" >&2; exit 1; }\nip link del "$vp" 2>/dev/null || true\nif [ "$kind" = macvlan ]; then ip link add "$vp" link "$parent" type macvlan mode "$mode"; else ip link add "$vp" link "$parent" type ipvlan mode "$mode"; fi\nip link set "$vp" netns "$ns"\nip -n "$ns" link set "$vp" name "$ifn"\nif [ "$kind" = macvlan ]; then ip -n "$ns" link set "$ifn" address "$mac"; fi\nip -n "$ns" addr add "$ip/$plen" dev "$ifn"\nip -n "$ns" link set "$ifn" up\nif [ "$route" = 1 ] && [ -n "$gw" ]; then ip -n "$ns" route replace default via "$gw"; fi\n`,VXLAN_SETUP='\nset -e\nbr="$1"; vx="$2"; vni="$3"; port="$4"; local="$5"; mtu="$6"; shift 6\nif ! ip link show "$vx" >/dev/null 2>&1; then\n if [ -n "$local" ]; then ip link add "$vx" type vxlan id "$vni" dstport "$port" local "$local"; else ip link add "$vx" type vxlan id "$vni" dstport "$port"; fi\nfi\nip link set "$vx" mtu "$mtu"\nip link set "$br" mtu "$mtu"\nip link set "$vx" master "$br"\nip link set "$vx" up\nfor peer; do\n bridge fdb show dev "$vx" | grep -q "dst $peer " || bridge fdb append 00:00:00:00:00:00 dev "$vx" dst "$peer"\ndone\niptables -C INPUT -p udp --dport "$port" -j ACCEPT 2>/dev/null || iptables -I INPUT -p udp --dport "$port" -j ACCEPT\n# the engines\' name announcements on the overlay itself (runtime/overlaydns.js)\niptables -C INPUT -i "$br" -p udp --dport 7946 -j ACCEPT 2>/dev/null || iptables -I INPUT -i "$br" -p udp --dport 7946 -j ACCEPT\n',ROUTE_SOURCES='\nfor peer; do\n src=$(ip -4 route get "$peer" 2>/dev/null | sed -n \'s/.* src \\([0-9.]*\\).*/\\1/p\' | head -n1)\n [ -n "$src" ] || { echo "no route to overlay peer $peer" >&2; exit 1; }\n echo "$peer $src"\ndone\n';function ipsecBatch(e,t,{remove:n=!1,existing:i=new Map}={}){const r=Buffer.from(e.ipsecKey,"hex"),s=(t,n)=>{const i=Buffer.from(crypto.hkdfSync("sha256",r,Buffer.from("tnc-overlay-ipsec"),Buffer.from(`${e.vni}|${t}>${n}`),40));return{spi:`0x${(2147483647&i.readUInt32BE(36)|4096).toString(16)}`,aead:`0x${i.subarray(0,36).toString("hex")}`}},a=e.vni,o=[];for(const[r,p]of t){const t=s(p,r),l=s(r,p),d=(t,n)=>`src ${t}/32 dst ${n}/32 proto udp dport ${e.port}`;if(n)o.push(`xfrm policy delete ${d(p,r)} dir out`,`xfrm policy delete ${d(r,p)} dir in`,`xfrm state delete src ${p} dst ${r} proto esp spi ${t.spi}`,`xfrm state delete src ${r} dst ${p} proto esp spi ${l.spi}`);else{for(const[e,n,s]of[[p,r,t],[r,p,l]]){const t=i.get(parseInt(s.spi,16));t!==s.aead&&(void 0!==t&&o.push(`xfrm state delete src ${e} dst ${n} proto esp spi ${s.spi}`),o.push(`xfrm state add src ${e} dst ${n} proto esp spi ${s.spi} reqid ${a} mode transport aead 'rfc4106(gcm(aes))' ${s.aead} 128`))}o.push(`xfrm policy update ${d(p,r)} dir out tmpl src ${p} dst ${r} proto esp reqid ${a} mode transport`,`xfrm policy update ${d(r,p)} dir in tmpl src ${r} dst ${p} proto esp reqid ${a} mode transport`)}}return o.join("\n")+"\n"}function parseXfrmStates(e){const t=new Map;for(const n of String(e).split(/^(?=src )/m)){const e=/proto esp spi 0x([0-9a-f]+)/.exec(n),i=/aead \S+ (0x[0-9a-f]+) \d+/.exec(n);e&&i&&t.set(parseInt(e[1],16),i[1])}return t}function overlayKey(e,t=e=>require("fs").readFileSync(e,"utf8")){let n=e.key;if(!n&&e["key-file"])try{n=t(e["key-file"]).trim()}catch(t){throw httpError(400,`key-file ${e["key-file"]}: ${t.message}`)}if(!n)throw httpError(400,"an encrypted overlay needs the same key on every machine: -o key=$(openssl rand -hex 32) or -o key-file=PATH");const i=String(n).trim(),r=/^[0-9a-f]{64}$/i.test(i)?Buffer.from(i,"hex"):Buffer.from(i,"base64");if(32!==r.length||!/^[0-9a-f]{64}$/i.test(i)&&r.toString("base64").replace(/=+$/,"")!==i.replace(/=+$/,""))throw httpError(400,"the overlay key must be 32 bytes: 64 hex characters or base64");return r.toString("hex")}const isOn=e=>void 0!==e&&!["0","false","no","off"].includes(String(e).toLowerCase()),NETNS_ONLY=`set -e; mkdir -p /var/run/netns; [ -e "/var/run/netns/$1" ] || { ip netns add "$1"; ip -n "$1" link set lo up; ${NETNS_DEFAULTS.replace(/\$NS/g,"$1")}; }`,DETACH_ONE='ip -n "$1" link del "$2" 2>/dev/null; ip link del "$3" 2>/dev/null; true',DETACH_ALL='ip netns del "$1" 2>/dev/null; true',ISOLATE_HOST='\nset -e\nns="$1"; shift\ngw=$(ip route show default 2>/dev/null | awk \'/^default/ {print $3; exit}\')\nipt() { ip netns exec "$ns" iptables "$@"; }\nfor ip in "$@" $gw; do\n [ -n "$ip" ] || continue\n ipt -C OUTPUT -d "$ip" -j DROP 2>/dev/null && continue\n ipt -A OUTPUT -d "$ip" -p udp -j ACCEPT\n ipt -A OUTPUT -d "$ip" -j DROP\ndone\n';class Network{constructor({host:e,store:t}){this.host=e,this.store=t,this.reserved=new Set,this.lastIp=new Map}builtins(){return[DEFAULT_BRIDGE,HOST,NONE]}resolve(e){if(!e||"default"===e||"bridge"===e)return DEFAULT_BRIDGE;if("host"===e)return HOST;if("none"===e)return NONE;const t=this.store.findNetwork(e);if(!t)throw httpError(404,`network ${e} not found`);return{...t,dns:!0,prefixLen:t.prefixLen||24}}list(){return[...this.builtins(),...this.store.listNetworks().map(e=>({...e,dns:!0,prefixLen:e.prefixLen||24}))]}create({Name:e,Driver:t,Labels:n,Internal:i=!1,Options:r,EnableIPv6:s=!1,IPAM:a}){if(t=t||"bridge",n=n||{},r=r||{},!e)throw httpError(400,"network name is required");if(!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(e))throw httpError(400,`invalid network name "${e}", only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed`);if(["bridge","host","none","default"].includes(e))throw httpError(403,`${e} is a pre-defined network and cannot be created`);if(this.store.findNetwork(e)?.name===e)throw httpError(409,`network with name ${e} already exists`);if("macvlan"===t||"ipvlan"===t)return this._createLan({Name:e,Driver:t,Labels:n,Internal:i,Options:r,IPAM:a});if("overlay"===t)return this._createOverlay({Name:e,Labels:n,Internal:i,Options:r,IPAM:a});if("bridge"!==t)throw httpError(400,`network driver "${t}" is not supported; use bridge, macvlan, ipvlan or overlay`);const o=new Set(this.store.listNetworks().map(e=>e.subnet));let p=null;for(let e=USER_POOL.first;e<=USER_POOL.last;e++){const t=`${USER_POOL.base}.${e}.0/24`;if(!o.has(t)){p=t;break}}if(!p)throw httpError(500,"no free subnets left in 172.29.0.0/16");const l=crypto.randomBytes(32).toString("hex"),d=p.split("/")[0].split(".").slice(0,3).join("."),h=s||(a?.Config||[]).some(e=>e.Subnet?.includes(":"))?ipv6Subnet(l,a):null;return this.store.insertNetwork({id:l,name:e,driver:"bridge",subnet:p,gateway:`${d}.1`,poolStart:`${d}.2`,poolEnd:`${d}.254`,...h?{subnet6:`${h}::/64`,gateway6:`${h}::1`,prefix6:h}:{},bridgeIf:`tncb-${l.slice(0,8)}`,chain:`TNC-ISO-${l.slice(0,8)}`,labels:n,internal:!!i,options:r})}_needLinuxRoot(e,t){if("local"!==this.host.kind)throw httpError(400,`${e} networks need dock on Linux as root: here containers run behind ${{vm:"the Linux VM's NAT",wsl:"WSL's NAT",rootless:"rootless user-mode networking"}[this.host.kind]||"NAT"}, so ${t}`)}_createLan({Name:e,Driver:t,Labels:n,Internal:i,Options:r,IPAM:s}){this._needLinuxRoot(t,"there is no LAN interface to put them on");const a=(s?.Config||[]).find(e=>e.Subnet&&!e.Subnet.includes(":"));if(!a)throw httpError(400,`a ${t} network needs the LAN's subnet and gateway, e.g. --subnet 192.168.1.0/24 --gateway 192.168.1.1`);const{netInt:o,plen:p,lo:l,hi:d}=ipv4Range(a),h=a.Gateway||intToIp(o+1),$="macvlan"===t?r.macvlan_mode||"bridge":r.ipvlan_mode||"l2";if(!("macvlan"===t?["bridge","private","vepa","passthru"]:["l2","l3","l3s"]).includes($))throw httpError(400,`invalid ${t} mode ${$}`);if(r.parent&&!/^[A-Za-z0-9_.@-]{1,15}$/.test(r.parent))throw httpError(400,`invalid parent interface ${r.parent}`);const c=crypto.randomBytes(32).toString("hex");return this.store.insertNetwork({id:c,name:e,driver:t,subnet:`${intToIp(o)}/${p}`,gateway:h,prefixLen:p,poolStart:intToIp(l),poolEnd:intToIp(d),parent:r.parent||"",mode:$,labels:n,internal:!!i,options:r})}_createOverlay({Name:e,Labels:t,Internal:n,Options:i,IPAM:r}){this._needLinuxRoot("overlay","other machines cannot reach its tunnel");const s=(r?.Config||[]).find(e=>e.Subnet&&!e.Subnet.includes(":"));if(!s)throw httpError(400,"an overlay network needs its subnet (the same on every machine), e.g. --subnet 10.20.0.0/16 --ip-range 10.20.1.0/24");const a=String(i.peers||"").split(",").map(e=>e.trim()).filter(Boolean);for(const e of a)if(!require("net").isIPv4(e))throw httpError(400,`peer ${e} is not an IPv4 address`);if(a.length&&!s.IPRange)throw httpError(400,"an overlay network across machines needs --ip-range: a different part of the subnet on each machine, so their containers never get the same address");const{netInt:o,plen:p,hi:l}=ipv4Range(s);let{lo:d}=ipv4Range(s);if(255&d||d++,d>=l)throw httpError(400,`ip range ${s.IPRange||s.Subnet} is too small`);const h=s.Gateway||intToIp(d),$=2**(32-p);for(const e of[DEFAULT_BRIDGE,...this.store.listNetworks()]){const[t,n]=String(e.subnet||"").split("/");if(!t||t.includes(":"))continue;const i=ipToInt(t);if(o<i+2**(32-Number(n))&&i<o+$)throw httpError(400,`subnet ${s.Subnet} overlaps network ${e.name} (${e.subnet})`)}const c=void 0!==i.vni?Number(i.vni):parseInt(crypto.createHash("sha256").update(e).digest("hex").slice(0,6),16)||1;if(!Number.isInteger(c)||c<1||c>16777215)throw httpError(400,"vni must be a number from 1 to 16777215");const u=Number(i.port||4789),f=isOn(i.encrypted),v=f?overlayKey(i):null,b=Number(i.mtu||(f?1400:1450));if(!Number.isInteger(u)||u<1||u>65535)throw httpError(400,"port must be a UDP port number");if(!Number.isInteger(b)||b<1280||b>9e3)throw httpError(400,"mtu must be from 1280 to 9000");if(i.local&&!require("net").isIPv4(i.local))throw httpError(400,`local ${i.local} is not an IPv4 address`);const m=crypto.randomBytes(32).toString("hex");return this.store.insertNetwork({id:m,name:e,driver:"overlay",subnet:`${intToIp(o)}/${p}`,gateway:h,prefixLen:p,poolStart:intToIp(s.Gateway?d:d+1),poolEnd:intToIp(l),bridgeIf:`tnco-${m.slice(0,8)}`,vxlanIf:`tncx-${m.slice(0,8)}`,chain:`TNC-ISO-${m.slice(0,8)}`,vni:c,port:u,mtu:b,peers:a,local:i.local||"",...f?{encrypted:!0,ipsecKey:v}:{},labels:t,internal:!!n,options:Object.fromEntries(Object.entries(i).filter(([e])=>"key"!==e))})}_allocate6(e,t){if(!e.prefix6)return null;const n=new Set(this.reserved);for(const e of this.store.listContainers())if(e.id!==t)for(const t of Object.values(e.runtime.networks||{}))t.ip6&&n.add(t.ip6);for(let t=2;t<65535;t++){const i=`${e.prefix6}::${t.toString(16)}`;if(!n.has(i))return this.reserved.add(i),i}throw httpError(500,`no free IPv6 addresses left in network ${e.name}`)}async remove(e){if(e.builtin)throw httpError(403,`${e.name} is a pre-defined network and cannot be removed`);if(("bridge"===e.driver||"overlay"===e.driver)&&await this.host.isUp().catch(()=>!1)){if(e.vxlanIf&&await this.host.sh('ip link del "$1" 2>/dev/null; true',[e.vxlanIf]),e.encrypted&&e.peers?.length){const t=await this._routeSources(e).catch(()=>[]);t.length&&await this.host.sh("ip -force -batch - 2>/dev/null; true",[],{input:ipsecBatch(e,t,{remove:!0})})}await this.host.sh(BRIDGE_REMOVE,[e.bridgeIf,e.subnet,e.chain,e.subnet6||""])}this.store.deleteNetwork(e.id)}_allocate(e,t){const n=new Set(this.reserved);e.gateway&&n.add(e.gateway);for(const e of this.store.listContainers())if(e.id!==t){for(const t of Object.values(e.runtime.networks||{}))t.ip&&n.add(t.ip);e.runtime.ip&&n.add(e.runtime.ip)}const i=ipToInt(e.poolStart),r=ipToInt(e.poolEnd)-i+1,s=this.lastIp.get(e.id),a=s?(ipToInt(s)-i+1)%r:0;for(let t=0;t<r;t++){const s=intToIp(i+(a+t)%r);if(!n.has(s))return this.reserved.add(s),this.lastIp.set(e.id,s),s}throw httpError(500,`no free addresses left in network ${e.name}`)}names(e,t){const n=/^[0-9a-f]{12}/.test(e)?e:crypto.createHash("sha256").update(e).digest("hex");return{ns:`tnc-${n.slice(0,12)}`,vh:`vt${n.slice(0,8)}${t}`,vp:`vp${n.slice(0,8)}${t}`}}async _ensureBridge(e){const t=e.builtin?"TNC-ISO-default":e.chain;if(await this.host.sh(BRIDGE_SETUP,[e.bridgeIf,e.gateway,e.prefixLen,e.subnet,t,e.internal?1:0,e.subnet6||"",e.gateway6||""]),"overlay"===e.driver&&(await this.host.sh(VXLAN_SETUP,[e.bridgeIf,e.vxlanIf,String(e.vni),String(e.port),e.local||"",String(e.mtu),...e.peers||[]]).catch(t=>{throw httpError(500,`overlay network ${e.name}: ${/vxlan|Unknown device type|not supported/i.test(t.message)?"this kernel has no VXLAN support (modprobe vxlan)":t.message}`)}),e.encrypted&&e.peers?.length)){const t=await this._routeSources(e),{stdout:n}=await this.host.sh("ip xfrm state list proto esp 2>/dev/null; true"),i=ipsecBatch(e,t,{existing:parseXfrmStates(n)});await this.host.sh("ip -batch -",[],{input:i}).catch(t=>{throw httpError(500,`overlay network ${e.name}: cannot set up its encryption: ${/rfc4106|aead|not supported|Protocol not supported/i.test(t.message)?"this kernel has no IPsec ESP with AES-GCM (modprobe esp4)":t.message}`)})}}async _routeSources(e){if(e.local)return e.peers.map(t=>[t,e.local]);const{stdout:t}=await this.host.sh(ROUTE_SOURCES,e.peers);return t.trim().split("\n").filter(Boolean).map(e=>e.split(" "))}async attachOne(e,t,n,i=[]){const{ns:r,vh:s,vp:a}=this.names(e,n);if("null"===t.driver)return await this.host.sh(NETNS_ONLY,[r]),{networkId:"none",ifname:null,aliases:i};if("macvlan"===t.driver||"ipvlan"===t.driver){const s=this._allocate(t,e),o="02:42:"+crypto.createHash("sha1").update(`${e}:${t.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(LAN_ATTACH,[r,t.parent||"",t.driver,t.mode,a,`eth${n}`,s,t.prefixLen,t.gateway,0===n?1:0,o])}catch(e){throw this.reserved.delete(s),e}return{networkId:t.id,ifname:`eth${n}`,idx:n,ip:s,mac:"macvlan"===t.driver?o:"",gateway:t.gateway,prefixLen:t.prefixLen,aliases:i}}await this._ensureBridge(t);const o=this._allocate(t,e),p=this._allocate6(t,e),l="02:42:"+crypto.createHash("sha1").update(`${e}:${t.id}`).digest("hex").slice(0,8).match(/../g).join(":");try{await this.host.sh(ATTACH,[r,t.bridgeIf,s,a,`eth${n}`,o,t.prefixLen,l,t.gateway,0===n?1:0,p||"",t.gateway6||"","overlay"===t.driver?String(t.mtu):""])}catch(e){throw this.reserved.delete(o),p&&this.reserved.delete(p),e}return{networkId:t.id,ifname:`eth${n}`,idx:n,ip:o,mac:l,gateway:t.gateway,prefixLen:t.prefixLen,aliases:i,...p?{ip6:p,gateway6:t.gateway6,prefixLen6:64}:{}}}async attachAll(e,t){const n=t.map(e=>({net:this.resolve(e.name),aliases:e.aliases||[]}));if("host"===n[0].net.driver)return{nsPath:null,endpoints:{host:{networkId:"host"}}};const i={};try{for(let t=0;t<n.length;t++)i[n[t].net.name]=await this.attachOne(e,n[t].net,t,n[t].aliases)}catch(t){throw await this.detachAll(e,i),t}return{nsPath:`/var/run/netns/${this.names(e,0).ns}`,endpoints:i}}async isolateHost(e,t){const n=[...new Set(t.filter(e=>e&&/^\d+\.\d+\.\d+\.\d+$/.test(e)))];await this.host.sh(ISOLATE_HOST,[this.names(e,0).ns,...n])}async detachOne(e,t){if(!t||void 0===t.idx)return;const{ns:n,vh:i}=this.names(e,t.idx);await this.host.sh(DETACH_ONE,[n,t.ifname,i]).catch(()=>{}),t.ip&&this.reserved.delete(t.ip),t.ip6&&this.reserved.delete(t.ip6)}async detachAll(e,t={}){if(!t.host){await this.host.sh(DETACH_ALL,[this.names(e,0).ns]).catch(()=>{});for(const e of Object.values(t))e.ip&&this.reserved.delete(e.ip),e.ip6&&this.reserved.delete(e.ip6)}}}module.exports={Network,DEFAULT_BRIDGE,ipToInt,ipsecBatch,overlayKey,parseXfrmStates};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),LABEL="tnc.sandbox",NAME_PREFIX="tnc-sb-",VALID_ID=/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/;function reinstallWhenChanged(t,e,n){const s=t.split(" ")[0],a=`$(cat ${t} 2>/dev/null | md5sum | cut -d' ' -f1)`;return`if [ -f ${s} ] && [ "$(cat ${e} 2>/dev/null)" != "${a}" ]; then ${n} && echo "${a}" > ${e}; fi`}function detectRuntime(t,{nodeVersion:e}={}){const n=e=>fs.existsSync(path.join(t,e));let s=null;try{const e=fs.readFileSync(path.join(t,"thinkncollab.yml"),"utf8");s=(/^\s*primary:\s*["']?([a-z+]+)/m.exec(e)||[])[1]||null}catch(t){}switch(s||(s=n("package.json")?"node":n("requirements.txt")||n("pyproject.toml")||n("Pipfile")?"python":n("go.mod")?"go":n("Cargo.toml")?"rust":n("CMakeLists.txt")||n("Makefile")||n("main.cpp")||n("main.c")?"cpp":"node"),s){case"python":return{runtime:"python",image:"python:3.12-slim",deps:{target:"/opt/tnc-venv",env:["VIRTUAL_ENV=/opt/tnc-venv","PATH=/opt/tnc-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},install:"[ -x /opt/tnc-venv/bin/python ] || python -m venv /opt/tnc-venv; "+reinstallWhenChanged("requirements.txt","/opt/tnc-venv/.tnc-installed","pip install -q -r requirements.txt")};case"go":return{runtime:"go",image:"golang:1.23-alpine",deps:{target:"/go/pkg/mod",env:[]},install:""};case"cpp":case"c++":case"c":return{runtime:"cpp",image:"gcc:14",deps:{target:"/work/build",env:[]},install:n("CMakeLists.txt")?"command -v cmake >/dev/null || (apt-get update -qq && apt-get install -y -qq cmake >/dev/null)":""};case"rust":return{runtime:"rust",image:"rust:1-slim",deps:{target:"/usr/local/cargo/registry",env:[]},install:""};default:{const t=String(e||"22").replace(/^v/,"").split(".")[0];return{runtime:"node",image:`node:${/^\d+$/.test(t)?t:"22"}-alpine`,deps:{target:"/work/node_modules",env:[]},install:reinstallWhenChanged("package.json package-lock.json","node_modules/.tnc-installed","(if [ -f package-lock.json ]; then npm ci; else npm install; fi)")}}}}function httpError(t,e){const n=new Error(e);return n.statusCode=t,n}class DockSandbox{constructor({api:t=null}={}){this._api=t,this.baseDir=path.join(os.homedir(),".tnc","sandboxes")}async api(){if(!this._api){const{DockApi:t}=require("./cli/api");this._api=new t}return await this._api.ensureRunning({quiet:!0}),this._api}async running(){const{dockPaths:t}=require("./paths");return require("./client").ping(this._api?.socket||t().socket)}name(t){return`tnc-sb-${t}`}async _inspect(t){const e=await this.api();try{return await e.json("GET",`/containers/${encodeURIComponent(this.name(t))}/json`)}catch(t){if(404===t.status)return null;throw t}}_meta(t){const e=t.Config.Labels||{},n=Object.entries(t.HostConfig.PortBindings||{})[0],s=t.State.Running&&!t.State.Paused,a=!s&&"no"!==e["tnc.sandbox.frozen"]&&t.State.StartedAt&&!t.State.StartedAt.startsWith("0001"),i=t.HostConfig.Memory||0;return{id:e[LABEL],name:e[LABEL],container:t.Name.replace(/^\//,""),containerId:t.Id,image:t.Config.Image,runtime:e["tnc.sandbox.runtime"]||null,state:s?"RUNNING":a?"FROZEN":"CREATED",pid:t.State.Pid||null,cmd:e["tnc.sandbox.cmd"]||"",cwd:e["tnc.sandbox.cwd"]||"",hostPort:n&&Number(n[1]?.[0]?.HostPort)||null,containerPort:n?Number(n[0].split("/")[0]):null,isolationLevel:"dock-container",memoryLimitMB:i?Math.round(i/1048576):null,roomId:e["tnc.room"]||null,roomName:e["tnc.room.name"]||null,userId:e["tnc.user"]||null,userName:e["tnc.user.name"]||null,health:s?"HEALTHY":"STOPPED",createdAt:t.Created,startedAt:s?t.State.StartedAt:null,frozenAt:s||!t.State.FinishedAt||t.State.FinishedAt.startsWith("0001")?null:t.State.FinishedAt,exitCode:s?null:t.State.ExitCode}}async start(t,e={}){try{return await this._start(t,e)}catch(t){return{success:!1,message:t.message}}}async _start(t,e){if(!VALID_ID.test(String(t)))return{success:!1,message:`invalid sandbox name "${t}" (letters, digits, _ . -)`};const n=await this.api(),s=await this._inspect(t),a=e.cwd?path.resolve(e.cwd):null;if(s&&a&&s.Config.Labels?.["tnc.sandbox.cwd"]&&s.Config.Labels["tnc.sandbox.cwd"]!==a)return{success:!1,message:`a sandbox "${t}" already runs another folder (${s.Config.Labels["tnc.sandbox.cwd"]}); pick another name with --name, or remove it: thinknsh sandbox destroy ${t}`};if(s)return s.State.Running?{success:!1,message:`Sandbox "${t}" is already running.`,meta:this._meta(s)}:(await n.json("POST",`/containers/${s.Id}/start`),{success:!0,message:`Sandbox "${t}" resumed.`,meta:this._meta(await this._inspect(t))});const i=path.resolve(e.cwd||process.cwd());if(i===path.parse(i).root||i===os.homedir())return{success:!1,message:`refusing to run ${i} as a sandbox (the whole folder is shared with the container); cd into a project folder`};const o=detectRuntime(i,e),r=e.image||o.image,c=e.cmd||"npm start",u=Number(e.containerPort||e.hostPort)||null,d=Number(e.hostPort||u)||null,m=`${this.name(t)}-deps`;await this._ensureImage(r);const l=[...Object.entries(e.env||{}).map(([t,e])=>`${t}=${e}`),...o.deps.env,"HOSTNAME=0.0.0.0",...u?[`PORT=${u}`]:[],...e.production?["NODE_ENV=production"]:[]],p={Image:r,Cmd:["sh","-c",[o.install,c].filter(Boolean).join(" && ")],WorkingDir:"/work",Env:l,Labels:{[LABEL]:String(t),"tnc.sandbox.cmd":c,"tnc.sandbox.cwd":i,"tnc.sandbox.runtime":o.runtime,...e.roomId?{"tnc.room":String(e.roomId)}:{},...e.roomName?{"tnc.room.name":String(e.roomName)}:{},...e.userId?{"tnc.user":String(e.userId)}:{},...e.userName?{"tnc.user.name":String(e.userName)}:{}},...u?{ExposedPorts:{[`${u}/tcp`]:{}}}:{},HostConfig:{Binds:[`${i}:/work`],Mounts:[{Type:"volume",Source:m,Target:o.deps.target}],...u?{PortBindings:{[`${u}/tcp`]:[{HostIp:"127.0.0.1",HostPort:String(d)}]}}:{},...e.memoryLimit?{Memory:parseMemory(e.memoryLimit)}:{},...e.restart?{RestartPolicy:{Name:e.restart}}:{},Init:!0}},h=await n.json("POST",`/containers/create?name=${encodeURIComponent(this.name(t))}`,p);try{await n.json("POST",`/containers/${h.Id}/start`)}catch(t){throw await n.json("DELETE",`/containers/${h.Id}?force=1`).catch(()=>{}),t}return{success:!0,message:`Sandbox "${t}" started in dock (${r}).`,meta:this._meta(await this._inspect(t))}}async _ensureImage(t){const e=await this.api();try{return void await e.json("GET",`/images/${encodeURIComponent(t)}/json`)}catch(t){}const[n,s]=t.split(/:(?=[^/:]+$)/);await e.progress("POST",`/images/create?fromImage=${encodeURIComponent(n)}&tag=${encodeURIComponent(s||"latest")}`,null,()=>{})}async freeze(t){const e=await this._inspect(t);if(!e)return{success:!1,message:`Sandbox "${t}" not found.`};if(!e.State.Running)return{success:!0,message:`Sandbox "${t}" is already frozen.`,meta:this._meta(e)};const n=await this.api();return await n.json("POST",`/containers/${e.Id}/stop?t=10`),{success:!0,message:`Sandbox "${t}" frozen (0 RAM; files and dependencies kept).`,meta:this._meta(await this._inspect(t))}}async stop(t){return this.freeze(t)}async resume(t){const e=await this._inspect(t);if(!e)return{success:!1,message:`Sandbox "${t}" not found.`};if(e.State.Running)return{success:!0,message:`Sandbox "${t}" is already running.`,meta:this._meta(e)};const n=await this.api();return await n.json("POST",`/containers/${e.Id}/start`),{success:!0,message:`Sandbox "${t}" resumed.`,meta:this._meta(await this._inspect(t))}}async destroy(t){const e=await this._inspect(t),n=await this.api();return e&&await n.json("DELETE",`/containers/${e.Id}?force=1`),await n.json("DELETE",`/volumes/${encodeURIComponent(`${this.name(t)}-deps`)}?force=1`).catch(()=>{}),{success:!!e,message:e?`Sandbox "${t}" destroyed.`:`Sandbox "${t}" not found.`}}async kill(t,e="SIGKILL"){const n=await this._inspect(t);if(!n)return{success:!1,message:`Sandbox "${t}" not found.`};const s=await this.api();return await s.json("POST",`/containers/${n.Id}/kill?signal=${encodeURIComponent(e)}`),{success:!0,message:`Sandbox "${t}" sent ${e}.`}}async list({start:t=!0}={}){if(!t&&!await this.running())return[];const e=await this.api(),n=await e.json("GET",`/containers/json?all=1&filters=${encodeURIComponent(JSON.stringify({label:[LABEL]}))}`),s=[];for(const t of n){const n=await e.json("GET",`/containers/${t.Id}/json`).catch(()=>null);n&&s.push(this._meta(n))}return s.sort((t,e)=>String(t.id).localeCompare(String(e.id)))}async loadMeta(t){const e=await this._inspect(t);return e?this._meta(e):null}async inspect(t){return this.loadMeta(t)}async logs(t,{tail:e=200}={}){const n=await this._inspect(t);if(!n)throw httpError(404,`Sandbox "${t}" not found.`);const s=await this.api(),a=await s.stream("GET",`/containers/${n.Id}/logs?stdout=1&stderr=1&tail=${e}`),{demuxer:i}=require("./cli/api");let o="";const r=n.Config.Tty?t=>{o+=t}:i(t=>{o+=t},t=>{o+=t});for await(const t of a)r(t);return o}async exec(t,e=["/bin/sh"],{tty:n=process.stdin.isTTY}={}){const{runCommand:s}=require("./cli/commands"),a=await s("exec",[...n?["-it"]:["-i"],this.name(t),...e]);return{success:0===a,code:a}}async prune(){const t=[];for(const e of await this.list())"RUNNING"!==e.state&&(await this.destroy(e.id),t.push(e.id));return{success:!0,removed:t}}startTunnelDaemon(t,e,n=null,s=null){return require("../core/tunnel").startDaemon(t,e,n,s)}stopTunnelDaemon(t){return require("../core/tunnel").stopDaemon(t)}}function parseMemory(t){const e=/^(\d+(?:\.\d+)?)\s*([kmg]?)b?$/i.exec(String(t).trim());if(!e)return 0;const n={"":1,k:1024,m:1048576,g:1024**3}[e[2].toLowerCase()];return Math.round(Number(e[1])*n)}module.exports={DockSandbox,detectRuntime,parseMemory,LABEL,NAME_PREFIX:"tnc-sb-"};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),path=require("path"),http=require("http"),{spawnSync}=require("child_process"),{request}=require("./client"),{dockPaths}=require("./paths"),{DOCK_VERSION}=require("./version"),{BUILD_FILE}=require("./api/wire"),sleep=t=>new Promise(e=>setTimeout(e,t)),PREFIX="dock-selftest";function demux(t){const e={stdout:"",stderr:""};for(;t.length>=8;){const a=t.readUInt32BE(4);e[2===t[0]?"stderr":"stdout"]+=t.subarray(8,8+a).toString("utf8"),t=t.subarray(8+a)}return e}async function runSelftest({log:t=()=>{},keepGoing:e=!0}={}){const a=dockPaths().socket,n=[],s=async(t,e,n,s=3e5)=>{const o=await request(a,t,e,{body:n,timeout:s});if(o.status>=400)throw new Error(`${t} ${e} → ${o.status} ${"object"==typeof o.body?o.body.message:o.body}`);return o.body},o=async(t,e)=>{await request(a,"DELETE",`/containers/${t}?force=1&v=1`).catch(()=>{});const{Id:n}=await s("POST",`/containers/create?name=${t}`,e);await s("POST",`/containers/${n}/start`);const{StatusCode:o}=await s("POST",`/containers/${n}/wait`),r=await(i=n,new Promise((t,e)=>{http.get({socketPath:a,path:`/containers/${i}/logs?stdout=1&stderr=1`},e=>{const a=[];e.on("data",t=>a.push(t)),e.on("end",()=>t(demux(Buffer.concat(a))))}).on("error",e)}));var i;return await s("DELETE",`/containers/${n}?v=1`),{code:o,...r}},r=async(a,s)=>{const o=Date.now();try{const e=await s();n.push({name:a,ok:!0,ms:Date.now()-o,detail:e||""}),t(`✓ ${a} (${Date.now()-o} ms)`)}catch(s){if(n.push({name:a,ok:!1,ms:Date.now()-o,error:s.message}),t(`✗ ${a}: ${s.message}`),!e)throw s}},i=(t,e)=>{if(!t)throw new Error(e)};await r("daemon reachable",async()=>{const t=await s("GET","/version");return`dock ${t.Version}, API ${t.ApiVersion}`}),await r("pull alpine:3.20 and nginx:alpine",async()=>{await s("POST","/images/create?fromImage=alpine&tag=3.20"),await s("POST","/images/create?fromImage=nginx&tag=alpine")}),await r("run a container (Linux runtime boots on demand)",async()=>{const t=await o(`${PREFIX}-run`,{Image:"alpine:3.20",Cmd:["sh","-c","uname -srm; echo err >&2; exit 3"]});return i(3===t.code&&"err\n"===t.stderr&&/Linux/.test(t.stdout),JSON.stringify(t)),t.stdout.trim()}),await r("container network: own IP, DNS and internet",async()=>{const t=await o(`${PREFIX}-net`,{Image:"alpine:3.20",Cmd:["sh","-c","wget -q -O /dev/null http://dl-cdn.alpinelinux.org/alpine/ && echo NET_OK"]});i(t.stdout.includes("NET_OK"),`${t.stdout} ${t.stderr}`)});let c=null;await r("published port: nginx on localhost:18765",async()=>{await request(a,"DELETE",`/containers/${PREFIX}-web?force=1`).catch(()=>{}),({Id:c}=await s("POST",`/containers/create?name=${PREFIX}-web`,{Image:"nginx:alpine",HostConfig:{PortBindings:{"80/tcp":[{HostPort:"18765"}]}}})),await s("POST",`/containers/${c}/start`);let t="";for(let e=0;e<60&&!t.includes("nginx");e++)t=await new Promise(t=>{http.get("http://127.0.0.1:18765/",e=>{let a="";e.on("data",t=>{a+=t}),e.on("end",()=>t(a))}).on("error",()=>t(""))}),t.includes("nginx")||await sleep(250);i(t.includes("Welcome to nginx"),"nginx not reachable on 127.0.0.1:18765")}),await r("exec into a running container",async()=>{i(c,"no web container");const{Id:t}=await s("POST",`/containers/${c}/exec`,{Cmd:["nginx","-v"],AttachStdout:!0,AttachStderr:!0}),e=demux(await new Promise((e,n)=>{const s=http.request({socketPath:a,method:"POST",path:`/exec/${t}/start`,headers:{"Content-Type":"application/json"}},t=>{const a=[];t.on("data",t=>a.push(t)),t.on("end",()=>e(Buffer.concat(a)))});s.on("error",n),s.end(JSON.stringify({Detach:!1,Tty:!1}))}));i(/nginx version/.test(e.stdout+e.stderr),JSON.stringify(e))}),c&&await request(a,"DELETE",`/containers/${c}?force=1`).catch(()=>{}),await r("named volume persists across containers",async()=>{await request(a,"DELETE",`/volumes/${PREFIX}-vol?force=1`).catch(()=>{}),await o(`${PREFIX}-vol-a`,{Image:"alpine:3.20",Cmd:["sh","-c","echo kept > /data/f"],HostConfig:{Binds:[`${PREFIX}-vol:/data`]}});const t=await o(`${PREFIX}-vol-b`,{Image:"alpine:3.20",Cmd:["cat","/data/f"],HostConfig:{Binds:[`${PREFIX}-vol:/data:ro`]}});await request(a,"DELETE",`/volumes/${PREFIX}-vol`).catch(()=>{}),i("kept\n"===t.stdout,JSON.stringify(t))}),await r("bind mount from your home folder",async()=>{const t=fs.mkdtempSync(path.join(os.homedir(),".tnc","selftest-bind-"));try{await o(`${PREFIX}-bind`,{Image:"alpine:3.20",Cmd:["sh","-c","echo from-container > /mnt/out.txt"],HostConfig:{Binds:[`${t}:/mnt`]}});const e=fs.readFileSync(path.join(t,"out.txt"),"utf8");i("from-container\n"===e,JSON.stringify(e))}finally{fs.rmSync(t,{recursive:!0,force:!0})}}),await r("build an image (multi-stage)",async()=>{const t=fs.mkdtempSync(path.join(os.tmpdir(),"dock-selftest-build-"));try{fs.writeFileSync(path.join(t,BUILD_FILE),'FROM alpine:3.20 AS b\nRUN echo built > /x\nFROM alpine:3.20\nCOPY --from=b /x /x\nCMD ["cat", "/x"]\n');const e=path.join(t,"ctx.tar"),n=spawnSync("tar",["-cf",e,"-C",t,BUILD_FILE]);i(0===n.status,`tar failed: ${n.stderr}`);const s=await new Promise((t,n)=>{const s=http.request({socketPath:a,method:"POST",path:`/build?t=${PREFIX}-img:1&q=1`,headers:{"Content-Type":"application/x-tar"}},e=>{let a="";e.on("data",t=>{a+=t}),e.on("end",()=>t(a))});s.on("error",n),s.end(fs.readFileSync(e))});i(!/"error"/.test(s),s.slice(0,300));const r=await o(`${PREFIX}-built`,{Image:`${PREFIX}-img:1`});i("built\n"===r.stdout,JSON.stringify(r)),await request(a,"DELETE",`/images/${PREFIX}-img:1?force=1`).catch(()=>{})}finally{fs.rmSync(t,{recursive:!0,force:!0})}}),await r("custom network: containers find each other by name",async()=>{await request(a,"DELETE",`/containers/${PREFIX}-srv?force=1`).catch(()=>{}),await request(a,"DELETE",`/networks/${PREFIX}-net`).catch(()=>{}),await s("POST","/networks/create",{Name:`${PREFIX}-net`});const{Id:t}=await s("POST",`/containers/create?name=${PREFIX}-srv`,{Image:"alpine:3.20",Cmd:["sleep","60"],HostConfig:{NetworkMode:`${PREFIX}-net`}});await s("POST",`/containers/${t}/start`);try{const t=await o(`${PREFIX}-cli`,{Image:"alpine:3.20",Cmd:["sh","-c",`ping -c1 -W2 ${PREFIX}-srv >/dev/null && echo FOUND`],HostConfig:{NetworkMode:`${PREFIX}-net`}});i(t.stdout.includes("FOUND"),JSON.stringify(t))}finally{await request(a,"DELETE",`/containers/${t}?force=1`).catch(()=>{}),await request(a,"DELETE",`/networks/${PREFIX}-net`).catch(()=>{})}}),await r("stop sends SIGTERM, then SIGKILL after the timeout",async()=>{await request(a,"DELETE",`/containers/${PREFIX}-stop?force=1`).catch(()=>{});const{Id:t}=await s("POST",`/containers/create?name=${PREFIX}-stop`,{Image:"alpine:3.20",Cmd:["sleep","1000"]});await s("POST",`/containers/${t}/start`),await s("POST",`/containers/${t}/stop?t=1`);const e=await s("GET",`/containers/${t}/json`);await s("DELETE",`/containers/${t}`),i("exited"===e.State.Status,e.State.Status)}),await r("nothing left behind",async()=>{const t=(await s("GET","/containers/json?all=1")).filter(t=>t.Names.some(t=>t.includes(PREFIX)));i(0===t.length,t.map(t=>t.Names[0]).join(", "))});let d=null;try{d=JSON.parse(fs.readFileSync(path.join(os.homedir(),".tnc","dock","runtime.json"),"utf8"))}catch(t){}const l={tool:"thinknsh dock selftest",when:(new Date).toISOString(),dock:DOCK_VERSION,runtime:d,machine:{platform:process.platform,release:os.release(),arch:process.arch,cpus:os.cpus().length,cpuModel:os.cpus()[0]?.model,memoryMB:Math.round(os.totalmem()/1048576),node:process.version},passed:n.filter(t=>t.ok).length,total:n.length,results:n},u=path.join(os.homedir(),".tnc","dock",`selftest-${process.platform}-${process.arch}-${l.when.replace(/[:.]/g,"-")}.json`);return fs.mkdirSync(path.dirname(u),{recursive:!0}),fs.writeFileSync(u,JSON.stringify(l,null,2)),{report:l,file:u}}module.exports={runSelftest};
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),http=require("http"),{spawnSync}=require("child_process"),{request}=require("./client"),{dockPaths}=require("./paths"),{DOCK_VERSION}=require("./version"),{BUILD_FILE}=require("./api/wire"),sleep=t=>new Promise(e=>setTimeout(e,t)),PREFIX="dock-selftest";function demux(t){const e={stdout:"",stderr:""};for(;t.length>=8;){const n=t.readUInt32BE(4);e[2===t[0]?"stderr":"stdout"]+=t.subarray(8,8+n).toString("utf8"),t=t.subarray(8+n)}return e}async function runSelftest({log:t=()=>{},keepGoing:e=!0}={}){const n=dockPaths().socket,a=[],s=async(t,e,a,s=3e5)=>{const o=await request(n,t,e,{body:a,timeout:s});if(o.status>=400)throw new Error(`${t} ${e} → ${o.status} ${"object"==typeof o.body?o.body.message:o.body}`);return o.body},o=async(t,e)=>{await request(n,"DELETE",`/containers/${t}?force=1&v=1`).catch(()=>{});const{Id:a}=await s("POST",`/containers/create?name=${t}`,e);await s("POST",`/containers/${a}/start`);const{StatusCode:o}=await s("POST",`/containers/${a}/wait`),r=await(i=a,new Promise((t,e)=>{http.get({socketPath:n,path:`/containers/${i}/logs?stdout=1&stderr=1`},e=>{const n=[];e.on("data",t=>n.push(t)),e.on("end",()=>t(demux(Buffer.concat(n))))}).on("error",e)}));var i;return await s("DELETE",`/containers/${a}?v=1`),{code:o,...r}},r=async(n,s)=>{const o=Date.now();try{const e=await s();a.push({name:n,ok:!0,ms:Date.now()-o,detail:e||""}),t(`✓ ${n} (${Date.now()-o} ms)`)}catch(s){if(a.push({name:n,ok:!1,ms:Date.now()-o,error:s.message}),t(`✗ ${n}: ${s.message}`),!e)throw s}},i=(t,e)=>{if(!t)throw new Error(e)};await r("daemon reachable",async()=>{const t=await s("GET","/version");return`dock ${t.Version}, API ${t.ApiVersion}`}),await r("pull alpine:3.20 and nginx:alpine",async()=>{await s("POST","/images/create?fromImage=alpine&tag=3.20"),await s("POST","/images/create?fromImage=nginx&tag=alpine")}),await r("run a container (Linux runtime boots on demand)",async()=>{const t=await o(`${PREFIX}-run`,{Image:"alpine:3.20",Cmd:["sh","-c","uname -srm; echo err >&2; exit 3"]});return i(3===t.code&&"err\n"===t.stderr&&/Linux/.test(t.stdout),JSON.stringify(t)),t.stdout.trim()}),await r("container network: own IP, DNS and internet",async()=>{let t;for(let e=0;e<2;e++)if(e&&await new Promise(t=>setTimeout(t,3e3)),t=await o(`${PREFIX}-net`,{Image:"alpine:3.20",Cmd:["sh","-c","nslookup dl-cdn.alpinelinux.org >/dev/null 2>&1 || { echo DNS_FAIL; exit 0; }; wget -q -O /dev/null http://dl-cdn.alpinelinux.org/alpine/ && echo NET_OK || echo NET_FAIL"]}),t.stdout.includes("NET_OK"))return;const e=t.stdout.includes("DNS_FAIL")?"containers could not resolve names (twice): check this machine's internet, VPN or DNS":t.stdout.includes("NET_FAIL")?"names resolve, but the internet is not reachable from containers (twice)":`${t.stdout} ${t.stderr}`.trim();i(!1,e)});let c=null;await r("published port: nginx on localhost:18765",async()=>{await request(n,"DELETE",`/containers/${PREFIX}-web?force=1`).catch(()=>{}),({Id:c}=await s("POST",`/containers/create?name=${PREFIX}-web`,{Image:"nginx:alpine",HostConfig:{PortBindings:{"80/tcp":[{HostPort:"18765"}]}}})),await s("POST",`/containers/${c}/start`);let t="";for(let e=0;e<60&&!t.includes("nginx");e++)t=await new Promise(t=>{http.get("http://127.0.0.1:18765/",e=>{let n="";e.on("data",t=>{n+=t}),e.on("end",()=>t(n))}).on("error",()=>t(""))}),t.includes("nginx")||await sleep(250);i(t.includes("Welcome to nginx"),"nginx not reachable on 127.0.0.1:18765")}),await r("exec into a running container",async()=>{i(c,"no web container");const{Id:t}=await s("POST",`/containers/${c}/exec`,{Cmd:["nginx","-v"],AttachStdout:!0,AttachStderr:!0}),e=demux(await new Promise((e,a)=>{const s=http.request({socketPath:n,method:"POST",path:`/exec/${t}/start`,headers:{"Content-Type":"application/json"}},t=>{const n=[];t.on("data",t=>n.push(t)),t.on("end",()=>e(Buffer.concat(n)))});s.on("error",a),s.end(JSON.stringify({Detach:!1,Tty:!1}))}));i(/nginx version/.test(e.stdout+e.stderr),JSON.stringify(e))}),c&&await request(n,"DELETE",`/containers/${c}?force=1`).catch(()=>{}),await r("named volume persists across containers",async()=>{await request(n,"DELETE",`/volumes/${PREFIX}-vol?force=1`).catch(()=>{}),await o(`${PREFIX}-vol-a`,{Image:"alpine:3.20",Cmd:["sh","-c","echo kept > /data/f"],HostConfig:{Binds:[`${PREFIX}-vol:/data`]}});const t=await o(`${PREFIX}-vol-b`,{Image:"alpine:3.20",Cmd:["cat","/data/f"],HostConfig:{Binds:[`${PREFIX}-vol:/data:ro`]}});await request(n,"DELETE",`/volumes/${PREFIX}-vol`).catch(()=>{}),i("kept\n"===t.stdout,JSON.stringify(t))}),await r("bind mount from your home folder",async()=>{const t=fs.mkdtempSync(path.join(os.homedir(),".tnc","selftest-bind-"));try{await o(`${PREFIX}-bind`,{Image:"alpine:3.20",Cmd:["sh","-c","echo from-container > /mnt/out.txt"],HostConfig:{Binds:[`${t}:/mnt`]}});const e=fs.readFileSync(path.join(t,"out.txt"),"utf8");i("from-container\n"===e,JSON.stringify(e))}finally{fs.rmSync(t,{recursive:!0,force:!0})}}),await r("build an image (multi-stage)",async()=>{const t=fs.mkdtempSync(path.join(os.tmpdir(),"dock-selftest-build-"));try{fs.writeFileSync(path.join(t,BUILD_FILE),'FROM alpine:3.20 AS b\nRUN echo built > /x\nFROM alpine:3.20\nCOPY --from=b /x /x\nCMD ["cat", "/x"]\n');const e=path.join(t,"ctx.tar"),a=spawnSync("tar",["-cf",e,"-C",t,BUILD_FILE]);i(0===a.status,`tar failed: ${a.stderr}`);const s=await new Promise((t,a)=>{const s=http.request({socketPath:n,method:"POST",path:`/build?t=${PREFIX}-img:1&q=1`,headers:{"Content-Type":"application/x-tar"}},e=>{let n="";e.on("data",t=>{n+=t}),e.on("end",()=>t(n))});s.on("error",a),s.end(fs.readFileSync(e))});i(!/"error"/.test(s),s.slice(0,300));const r=await o(`${PREFIX}-built`,{Image:`${PREFIX}-img:1`});i("built\n"===r.stdout,JSON.stringify(r)),await request(n,"DELETE",`/images/${PREFIX}-img:1?force=1`).catch(()=>{})}finally{fs.rmSync(t,{recursive:!0,force:!0})}}),await r("custom network: containers find each other by name",async()=>{await request(n,"DELETE",`/containers/${PREFIX}-srv?force=1`).catch(()=>{}),await request(n,"DELETE",`/networks/${PREFIX}-net`).catch(()=>{}),await s("POST","/networks/create",{Name:`${PREFIX}-net`});const{Id:t}=await s("POST",`/containers/create?name=${PREFIX}-srv`,{Image:"alpine:3.20",Cmd:["sleep","60"],HostConfig:{NetworkMode:`${PREFIX}-net`}});await s("POST",`/containers/${t}/start`);try{const t=await o(`${PREFIX}-cli`,{Image:"alpine:3.20",Cmd:["sh","-c",`ping -c1 -W2 ${PREFIX}-srv >/dev/null && echo FOUND`],HostConfig:{NetworkMode:`${PREFIX}-net`}});i(t.stdout.includes("FOUND"),JSON.stringify(t))}finally{await request(n,"DELETE",`/containers/${t}?force=1`).catch(()=>{}),await request(n,"DELETE",`/networks/${PREFIX}-net`).catch(()=>{})}}),await r("stop sends SIGTERM, then SIGKILL after the timeout",async()=>{await request(n,"DELETE",`/containers/${PREFIX}-stop?force=1`).catch(()=>{});const{Id:t}=await s("POST",`/containers/create?name=${PREFIX}-stop`,{Image:"alpine:3.20",Cmd:["sleep","1000"]});await s("POST",`/containers/${t}/start`),await s("POST",`/containers/${t}/stop?t=1`);const e=await s("GET",`/containers/${t}/json`);await s("DELETE",`/containers/${t}`),i("exited"===e.State.Status,e.State.Status)}),await r("nothing left behind",async()=>{const t=(await s("GET","/containers/json?all=1")).filter(t=>t.Names.some(t=>t.includes(PREFIX)));i(0===t.length,t.map(t=>t.Names[0]).join(", "))});let l=null;try{l=JSON.parse(fs.readFileSync(path.join(os.homedir(),".tnc","dock","runtime.json"),"utf8"))}catch(t){}const d={tool:"thinknsh dock selftest",when:(new Date).toISOString(),dock:DOCK_VERSION,runtime:l,machine:{platform:process.platform,release:os.release(),arch:process.arch,cpus:os.cpus().length,cpuModel:os.cpus()[0]?.model,memoryMB:Math.round(os.totalmem()/1048576),node:process.version},passed:a.filter(t=>t.ok).length,total:a.length,results:a},u=path.join(os.homedir(),".tnc","dock",`selftest-${process.platform}-${process.arch}-${d.when.replace(/[:.]/g,"-")}.json`);return fs.mkdirSync(path.dirname(u),{recursive:!0}),fs.writeFileSync(u,JSON.stringify(d,null,2)),{report:d,file:u}}module.exports={runSelftest};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),path=require("path"),{spawnSync}=require("child_process"),{dockPaths}=require("../paths"),LABEL="com.thinkncollab.dock",APP_LABEL="com.thinkncollab.dock.app",UNIT="tnc-dock.service",DAEMON_MAIN=path.join(__dirname,"..","daemon-main.js"),launchAgentsDir=()=>process.env.TNC_LAUNCH_AGENTS_DIR||path.join(os.homedir(),"Library","LaunchAgents"),userService=()=>"linux"===process.platform&&require("../paths").rootlessMode(),systemdDir=()=>process.env.TNC_SYSTEMD_DIR||(userService()?path.join(process.env.XDG_CONFIG_HOME||path.join(os.homedir(),".config"),"systemd","user"):"/etc/systemd/system"),systemctl=n=>userService()?["--user",...n]:n,startupDir=()=>process.env.TNC_STARTUP_DIR||path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Startup"),dryRun=()=>"1"===process.env.TNC_AUTOSTART_DRY_RUN,xml=n=>String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");function daemonEnvironment(n=process.env){const e={PATH:n.PATH||"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"};for(const t of["TNC_DOCK_HOME","TNC_DOCK_BUILDKIT","TNC_DOCK_INSECURE_REGISTRIES","TNC_SHIM","TNC_BUSYBOX","TNC_DOCK_SOCKET"])n[t]&&(e[t]=n[t]);return e}function plist({label:n,program:e,logFile:t,env:s,keepAlive:r=!0}){const o=e.map(n=>` <string>${xml(n)}</string>`).join("\n"),a=Object.entries(s||{}).map(([n,e])=>` <key>${xml(n)}</key><string>${xml(e)}</string>`).join("\n");return`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(n)}</string>\n <key>ProgramArguments</key>\n <array>\n${o}\n </array>\n <key>RunAtLoad</key><true/>\n${r?" <key>KeepAlive</key>\n <dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>10</integer>\n":""} <key>ProcessType</key><string>${r?"Background":"Interactive"}</string>\n${t?` <key>StandardOutPath</key><string>${xml(t)}</string>\n <key>StandardErrorPath</key><string>${xml(t)}</string>\n`:""}${a?` <key>EnvironmentVariables</key>\n <dict>\n${a}\n </dict>\n`:""}</dict>\n</plist>\n`}function systemdUnit({node:n,main:e,env:t,user:s=!1}){return`[Unit]\nDescription=ThinkNCollab dock container engine\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart="${n}" "${e}"\nRestart=on-failure\nRestartSec=5\nKillMode=process\nTimeoutStopSec=30\n${s?"Delegate=yes\n":""}${Object.entries(t).map(([n,e])=>`Environment="${n}=${String(e).replace(/"/g,'\\"')}"`).join("\n")}\n\n[Install]\nWantedBy=${s?"default.target":"multi-user.target"}\n`}function windowsLauncher({node:n,main:e,logFile:t}){const s=n=>n.replace(/"/g,'""');return`' ThinkNCollab dock: starts the engine at login (written by thinknsh dock autostart on)\r\nSet sh = CreateObject("WScript.Shell")\r\nsh.Run "cmd /c """"${s(n)}"" ""${s(e)}"" >> ""${s(t)}"" 2>&1""", 0, False\r\n`}function run(n,e){return dryRun()?{status:0,stdout:"",stderr:""}:spawnSync(n,e,{encoding:"utf8"})}function appBundle(){for(const n of[path.join(os.homedir(),"Applications"),"/Applications"]){const e=path.join(n,"Thinknsh.app");if(fs.existsSync(path.join(e,"Contents","MacOS")))return e}return null}function files(n=process.platform){return"darwin"===n?{daemon:path.join(launchAgentsDir(),`${LABEL}.plist`),app:path.join(launchAgentsDir(),`${APP_LABEL}.plist`)}:"linux"===n?{daemon:path.join(systemdDir(),UNIT)}:"win32"===n?{daemon:path.join(startupDir(),"tnc-dock.vbs"),app:path.join(startupDir(),"Thinknsh-tray.vbs")}:{}}function enable({platform:n=process.platform,node:e=process.execPath,app:t=!0}={}){const s=dockPaths(),r=daemonEnvironment(),o=files(n),a=[];if("darwin"===n){fs.mkdirSync(path.dirname(o.daemon),{recursive:!0}),fs.writeFileSync(o.daemon,plist({label:LABEL,program:[e,DAEMON_MAIN],logFile:s.logFile,env:r}));const n=process.getuid();run("launchctl",["bootout",`gui/${n}/${LABEL}`]);const i=run("launchctl",["bootstrap",`gui/${n}`,o.daemon]);if(0!==i.status&&!/already/i.test(i.stderr||""))throw new Error(`launchctl bootstrap: ${(i.stderr||"").trim()}`);a.push(`daemon: starts at login (${o.daemon})`);const c=appBundle();t&&c?(fs.writeFileSync(o.app,plist({label:APP_LABEL,program:["/usr/bin/open","-a",c],keepAlive:!1})),a.push(`menu-bar app: opens at login (${c})`)):fs.existsSync(o.app)&&fs.rmSync(o.app)}else if("linux"===n){const n=userService();if(process.getuid&&0!==process.getuid()&&!n&&!process.env.TNC_SYSTEMD_DIR)throw new Error("the system engine runs as root: sudo thinknsh dock autostart on");fs.mkdirSync(path.dirname(o.daemon),{recursive:!0}),fs.writeFileSync(o.daemon,systemdUnit({node:e,main:DAEMON_MAIN,env:r,user:n}));const t=run("systemctl",systemctl(["daemon-reload"]));if(0!==t.status)throw new Error(`systemctl daemon-reload: ${(t.stderr||"").trim()}`);const s=run("systemctl",systemctl(["enable",UNIT]));if(0!==s.status)throw new Error(`systemctl enable: ${(s.stderr||"").trim()}`);a.push(n?`daemon: systemd user service ${UNIT}, starts when you log in (${o.daemon}); to start at boot without a login: loginctl enable-linger ${os.userInfo().username}`:`daemon: systemd unit ${UNIT}, starts at boot (${o.daemon})`)}else{if("win32"!==n)throw new Error(`auto-start is not available on ${n}`);{fs.mkdirSync(path.dirname(o.daemon),{recursive:!0}),fs.writeFileSync(o.daemon,windowsLauncher({node:e,main:DAEMON_MAIN,logFile:s.logFile})),a.push(`daemon: starts at login (${o.daemon})`);const n=path.join(process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),"Thinknsh.vbs");t&&fs.existsSync(n)?(fs.copyFileSync(n,o.app),a.push(`tray app: opens at login (${o.app})`)):fs.existsSync(o.app)&&fs.rmSync(o.app)}}return a}function disable({platform:n=process.platform}={}){const e=files(n),t=[];if("darwin"===n)for(const n of[e.daemon,e.app])fs.existsSync(n)&&(fs.rmSync(n),t.push(`removed ${n}`));else if("linux"===n)fs.existsSync(e.daemon)&&(run("systemctl",systemctl(["disable",UNIT])),fs.rmSync(e.daemon),run("systemctl",systemctl(["daemon-reload"])),t.push(`removed ${e.daemon}`));else if("win32"===n)for(const n of[e.daemon,e.app])fs.existsSync(n)&&(fs.rmSync(n),t.push(`removed ${n}`));return t.length||t.push("auto-start was not on"),t}function status({platform:n=process.platform}={}){const e=files(n);return{enabled:!(!e.daemon||!fs.existsSync(e.daemon)),app:!(!e.app||!fs.existsSync(e.app)),file:e.daemon||null}}function kickstart({platform:n=process.platform}={}){const e=status({platform:n});if(!e.enabled||dryRun())return!1;try{if(!fs.readFileSync(e.file,"utf8").includes("daemon-main.js"))return!1}catch(n){return!1}return process.env.XPC_SERVICE_NAME!==LABEL&&!process.env.INVOCATION_ID&&("darwin"===n?0===run("launchctl",["kickstart",`gui/${process.getuid()}/${LABEL}`]).status:"linux"===n&&0===run("systemctl",systemctl(["start",UNIT])).status)}module.exports={enable,disable,status,kickstart,plist,systemdUnit,windowsLauncher,daemonEnvironment,appBundle,LABEL,APP_LABEL,UNIT};
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),{spawnSync}=require("child_process"),{dockPaths}=require("../paths"),LABEL="com.thinkncollab.dock",APP_LABEL="com.thinkncollab.dock.app",UNIT="tnc-dock.service",DAEMON_MAIN=path.join(__dirname,"..","daemon-main.js"),launchAgentsDir=()=>process.env.TNC_LAUNCH_AGENTS_DIR||path.join(os.homedir(),"Library","LaunchAgents"),userService=()=>"linux"===process.platform&&require("../paths").rootlessMode(),systemdDir=()=>process.env.TNC_SYSTEMD_DIR||(userService()?path.join(process.env.XDG_CONFIG_HOME||path.join(os.homedir(),".config"),"systemd","user"):"/etc/systemd/system"),systemctl=n=>userService()?["--user",...n]:n,startupDir=()=>process.env.TNC_STARTUP_DIR||path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Startup"),dryRun=()=>"1"===process.env.TNC_AUTOSTART_DRY_RUN,xml=n=>String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");function daemonEnvironment(n=process.env){const e={PATH:n.PATH||"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"};for(const t of["TNC_DOCK_HOME","TNC_DOCK_BUILDKIT","TNC_DOCK_INSECURE_REGISTRIES","TNC_SHIM","TNC_BUSYBOX","TNC_DOCK_SOCKET"])n[t]&&(e[t]=n[t]);return e}function plist({label:n,program:e,logFile:t,env:s,keepAlive:r=!0}){const a=e.map(n=>` <string>${xml(n)}</string>`).join("\n"),o=Object.entries(s||{}).map(([n,e])=>` <key>${xml(n)}</key><string>${xml(e)}</string>`).join("\n");return`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(n)}</string>\n <key>ProgramArguments</key>\n <array>\n${a}\n </array>\n <key>RunAtLoad</key><true/>\n${r?" <key>KeepAlive</key>\n <dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>10</integer>\n":""} <key>ProcessType</key><string>${r?"Background":"Interactive"}</string>\n${t?` <key>StandardOutPath</key><string>${xml(t)}</string>\n <key>StandardErrorPath</key><string>${xml(t)}</string>\n`:""}${o?` <key>EnvironmentVariables</key>\n <dict>\n${o}\n </dict>\n`:""}</dict>\n</plist>\n`}function systemdUnit({node:n,main:e,env:t,user:s=!1}){return`[Unit]\nDescription=ThinkNCollab dock container engine\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart="${n}" "${e}"\nRestart=on-failure\nRestartSec=5\nKillMode=process\nTimeoutStopSec=30\n${s?"Delegate=yes\n":""}${Object.entries(t).map(([n,e])=>`Environment="${n}=${String(e).replace(/"/g,'\\"')}"`).join("\n")}\n\n[Install]\nWantedBy=${s?"default.target":"multi-user.target"}\n`}function windowsLauncher({node:n,main:e,logFile:t}){const s=n=>n.replace(/"/g,'""');return`' ThinkNCollab dock: starts the engine at login (written by thinknsh dock autostart on)\r\nSet sh = CreateObject("WScript.Shell")\r\nsh.Run "cmd /c """"${s(n)}"" ""${s(e)}"" >> ""${s(t)}"" 2>&1""", 0, False\r\n`}function run(n,e){return dryRun()?{status:0,stdout:"",stderr:""}:spawnSync(n,e,{encoding:"utf8"})}function appBundle(){for(const n of[path.join(os.homedir(),"Applications"),"/Applications"]){const e=path.join(n,"Thinknsh.app");if(fs.existsSync(path.join(e,"Contents","MacOS")))return e}return null}function files(n=process.platform){return"darwin"===n?{daemon:path.join(launchAgentsDir(),`${LABEL}.plist`),app:path.join(launchAgentsDir(),`${APP_LABEL}.plist`)}:"linux"===n?{daemon:path.join(systemdDir(),UNIT)}:"win32"===n?{daemon:path.join(startupDir(),"tnc-dock.vbs"),app:path.join(startupDir(),"Thinknsh-tray.vbs")}:{}}function reloadLaunchAgent(n,e){const t=`gui/${n}/${LABEL}`;run("launchctl",["bootout",t]);for(let n=0;n<50&&0===run("launchctl",["print",t]).status;n++)sleepMs(100);let s;for(let t=0;t<5;t++){if(s=run("launchctl",["bootstrap",`gui/${n}`,e]),0===s.status||/already/i.test(s.stderr||""))return;sleepMs(500)}throw new Error(`launchctl bootstrap: ${(s.stderr||"").trim()}`)}function sleepMs(n){Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,n)}function enableApp({platform:n=process.platform}={}){const e=files(n);if("darwin"===n){const n=appBundle();return n?(fs.mkdirSync(path.dirname(e.app),{recursive:!0}),fs.writeFileSync(e.app,plist({label:APP_LABEL,program:["/usr/bin/open","-a",n],keepAlive:!1})),[`menu-bar app: opens at login (${n})`]):[]}if("win32"===n){const n=path.join(process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),"Thinknsh.vbs");return fs.existsSync(n)?(fs.mkdirSync(path.dirname(e.app),{recursive:!0}),fs.copyFileSync(n,e.app),[`tray app: opens at login (${e.app})`]):[]}return[]}function enable({platform:n=process.platform,node:e=process.execPath,app:t=!0}={}){const s=dockPaths(),r=daemonEnvironment(),a=files(n),o=[];if("darwin"===n){fs.mkdirSync(path.dirname(a.daemon),{recursive:!0}),fs.writeFileSync(a.daemon,plist({label:LABEL,program:[e,DAEMON_MAIN],logFile:s.logFile,env:r})),reloadLaunchAgent(process.getuid(),a.daemon),o.push(`daemon: starts at login (${a.daemon})`);const n=appBundle();t&&n?(fs.writeFileSync(a.app,plist({label:APP_LABEL,program:["/usr/bin/open","-a",n],keepAlive:!1})),o.push(`menu-bar app: opens at login (${n})`)):fs.existsSync(a.app)&&fs.rmSync(a.app)}else if("linux"===n){const n=userService();if(process.getuid&&0!==process.getuid()&&!n&&!process.env.TNC_SYSTEMD_DIR)throw new Error("the system engine runs as root: sudo thinknsh dock autostart on");fs.mkdirSync(path.dirname(a.daemon),{recursive:!0}),fs.writeFileSync(a.daemon,systemdUnit({node:e,main:DAEMON_MAIN,env:r,user:n}));const t=run("systemctl",systemctl(["daemon-reload"]));if(0!==t.status)throw new Error(`systemctl daemon-reload: ${(t.stderr||"").trim()}`);const s=run("systemctl",systemctl(["enable",UNIT]));if(0!==s.status)throw new Error(`systemctl enable: ${(s.stderr||"").trim()}`);o.push(n?`daemon: systemd user service ${UNIT}, starts when you log in (${a.daemon}); to start at boot without a login: loginctl enable-linger ${os.userInfo().username}`:`daemon: systemd unit ${UNIT}, starts at boot (${a.daemon})`)}else{if("win32"!==n)throw new Error(`auto-start is not available on ${n}`);{fs.mkdirSync(path.dirname(a.daemon),{recursive:!0}),fs.writeFileSync(a.daemon,windowsLauncher({node:e,main:DAEMON_MAIN,logFile:s.logFile})),o.push(`daemon: starts at login (${a.daemon})`);const n=path.join(process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),"Thinknsh.vbs");t&&fs.existsSync(n)?(fs.copyFileSync(n,a.app),o.push(`tray app: opens at login (${a.app})`)):fs.existsSync(a.app)&&fs.rmSync(a.app)}}return o}function disable({platform:n=process.platform}={}){const e=files(n),t=[];if("darwin"===n)for(const n of[e.daemon,e.app])fs.existsSync(n)&&(fs.rmSync(n),t.push(`removed ${n}`));else if("linux"===n)fs.existsSync(e.daemon)&&(run("systemctl",systemctl(["disable",UNIT])),fs.rmSync(e.daemon),run("systemctl",systemctl(["daemon-reload"])),t.push(`removed ${e.daemon}`));else if("win32"===n)for(const n of[e.daemon,e.app])fs.existsSync(n)&&(fs.rmSync(n),t.push(`removed ${n}`));return t.length||t.push("auto-start was not on"),t}function status({platform:n=process.platform}={}){const e=files(n);return{enabled:!(!e.daemon||!fs.existsSync(e.daemon)),app:!(!e.app||!fs.existsSync(e.app)),file:e.daemon||null}}function kickstart({platform:n=process.platform}={}){const e=status({platform:n});if(!e.enabled||dryRun())return!1;try{if(!fs.readFileSync(e.file,"utf8").includes("daemon-main.js"))return!1}catch(n){return!1}return process.env.XPC_SERVICE_NAME!==LABEL&&!process.env.INVOCATION_ID&&("darwin"===n?0===run("launchctl",["kickstart",`gui/${process.getuid()}/${LABEL}`]).status:"linux"===n&&0===run("systemctl",systemctl(["start",UNIT])).status)}module.exports={enable,enableApp,disable,status,kickstart,plist,systemdUnit,windowsLauncher,daemonEnvironment,appBundle,LABEL,APP_LABEL,UNIT};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),path=require("path"),crypto=require("crypto"),{spawnSync}=require("child_process"),TNC_DIR=path.join(os.homedir(),".tnc"),BUNDLE_VERSION="0.2.3",DEFAULT_BASE_URL=process.env.TNC_DOCK_BUNDLE_URL||"https://thinkncollab.com/downloads/dock/0.2.3",WSL_DISTRO="tnc-dock",linuxArch=(n=process.arch)=>({arm64:"aarch64",x64:"x86_64"}[n]);function setupError(n){const t=new Error(n);return t.setup=!0,t}function sha256File(n){const t=crypto.createHash("sha256"),r=fs.openSync(n,"r"),e=Buffer.alloc(1<<20);let s;for(;(s=fs.readSync(r,e,0,e.length,null))>0;)t.update(e.subarray(0,s));return fs.closeSync(r),t.digest("hex")}const OPTIONAL_FILES=/^tnc-busybox-linux-|^tnc-tray-/;function wantedFiles(n=process.platform,t=process.arch){const r=linuxArch(t);if(!r)throw setupError(`unsupported CPU architecture ${t}`);if("darwin"===n)return[`tnc-dock-vm-${r}.tar.gz`,"tnc-vz-darwin-universal","tnc-tray-darwin-universal.tar.gz"];if("win32"===n)return[`tnc-dock-wsl-${r}.tar.gz`];if("linux"===n)return[`tnc-shim-linux-${r}`,`crun-linux-${r}`,`tnc-busybox-linux-${r}`];throw setupError(`unsupported platform ${n}`)}async function download(n,t){const r=await fetch(n,{redirect:"follow"});if(!r.ok)throw setupError(`download failed: ${n} (HTTP ${r.status})`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.writeFileSync(`${t}.part`,Buffer.from(await r.arrayBuffer())),fs.renameSync(`${t}.part`,t)}async function fetchBundle({from:n,baseUrl:t=DEFAULT_BASE_URL,log:r}){let e,s;if(n)s=path.resolve(n),e=JSON.parse(fs.readFileSync(path.join(s,"manifest.json"),"utf8"));else{let n=await fetch(`${t}/manifest.json`,{redirect:"follow"});if(!n.ok){const r="https://thinkncollab.com/downloads/dock",s=await fetch(`${r}/manifest.json`,{redirect:"follow"});s.ok&&(e=await s.json(),t=`${r}/${e.version}`,n=s)}if(!n.ok)throw setupError(`cannot fetch bundle manifest from ${t} (HTTP ${n.status})`);e||(e=await n.json()),s=path.join(TNC_DIR,"cache","bundles",e.version)}const i={};for(const o of wantedFiles()){const c=e.files[o];if(!c&&OPTIONAL_FILES.test(o)){r(`bundle ${e.version} has no ${o}; ${/tray/.test(o)?"the menu-bar app":"cp"} needs a newer bundle`);continue}if(!c)throw setupError(`bundle ${e.version} has no ${o}`);const a=path.join(s,o);if(n||fs.existsSync(a)&&sha256File(a)===c.sha256||(r(`downloading ${o} (${(c.size/1024/1024).toFixed(1)} MB)`),await download(`${t}/${o}`,a)),!fs.existsSync(a))throw setupError(`missing ${a}`);if(sha256File(a)!==c.sha256)throw setupError(`sha256 mismatch for ${o}; refusing to install`);i[o]=a}return{manifest:e,files:i}}function run(n,t,r={}){const e=spawnSync(n,t,{encoding:"utf8",...r});return{code:e.status,out:`${e.stdout||""}${e.stderr||""}`.replace(/\0/g,"")}}function installDarwin(n,t){const r=linuxArch(),e=path.join(TNC_DIR,"kernel"),s=path.join(TNC_DIR,"bin");fs.mkdirSync(e,{recursive:!0}),fs.mkdirSync(s,{recursive:!0}),run(path.join(s,"tnc-vz"),["stop"]);for(const n of["vmlinuz","initramfs.img"]){const t=path.join(e,n);fs.existsSync(t)&&!fs.existsSync(`${t}.pre-bundle`)&&fs.copyFileSync(t,`${t}.pre-bundle`)}const i=run("tar",["-xzf",n[`tnc-dock-vm-${r}.tar.gz`],"-C",e]);if(0!==i.code)throw setupError(`cannot unpack VM image: ${i.out}`);const o=path.join(s,"tnc-vz");fs.existsSync(o)&&!fs.existsSync(`${o}.pre-bundle`)&&fs.copyFileSync(o,`${o}.pre-bundle`),fs.copyFileSync(n["tnc-vz-darwin-universal"],`${o}.new`),fs.chmodSync(`${o}.new`,493),fs.renameSync(`${o}.new`,o),t(`MicroVM image (${r}) and tnc-vz installed`);const c=n["tnc-tray-darwin-universal.tar.gz"];if(c){fs.rmSync(path.join(s,"Thinknsh.app"),{recursive:!0,force:!0});const n=run("tar",["-xzf",c,"-C",s]);if(0!==n.code)throw setupError(`cannot unpack the menu-bar app: ${n.out}`);t("menu-bar app unpacked (install it: thinknsh dock app install)")}}function wslDistros(){const n=run("wsl.exe",["--list","--quiet"]);return 0===n.code?n.out.split(/\r?\n/).map(n=>n.trim()).filter(Boolean):null}function sanitizeWslTar(n){const t=require("zlib");let r;try{r=t.gunzipSync(fs.readFileSync(n))}catch(t){return{path:n,cleanup:()=>{}}}let e=0;const s=[];let i=!1;for(;e<r.length;){const n=r.subarray(e,e+512);if(n.length<512||n.every(n=>0===n)){s.push(r.subarray(e));break}const t=n.subarray(124,136).toString("latin1").replace(/\0.*$/,"").trim(),o=parseInt(t,8)||0,c=String.fromCharCode(n[156]),a=512+512*Math.ceil(o/512);"x"===c||"g"===c?i=!0:s.push(r.subarray(e,e+a)),e+=a}if(!i)return{path:n,cleanup:()=>{}};const o=path.join(os.tmpdir(),`tnc-dock-wsl-clean-${Date.now()}.tar.gz`);return fs.writeFileSync(o,t.gzipSync(Buffer.concat(s))),{path:o,cleanup:()=>{try{fs.unlinkSync(o)}catch(n){}}}}function installWindows(n,t,{force:r=!1}={}){const e=linuxArch();if(0!==run("wsl.exe",["--status"]).code)throw setupError("WSL is not installed. Run in an admin PowerShell: wsl --install --no-distribution, reboot, then run thinknsh dock setup again");const s=wslDistros()||[],i=n[`tnc-dock-wsl-${e}.tar.gz`],{path:o,cleanup:c}=sanitizeWslTar(i);try{if(s.includes("tnc-dock")&&!r){const n=run("wsl.exe",["-d","tnc-dock","-u","root","--","tar","-xzf","-","-C","/","./usr/bin/tnc-shim","./usr/bin/crun","./usr/local/bin/tnc-wsl-init","./etc/wsl.conf","./bin/busybox.static"],{input:fs.readFileSync(o)});if(0!==n.code)throw setupError(`cannot upgrade WSL distro: ${n.out}`);return run("wsl.exe",["--terminate","tnc-dock"]),void t("WSL distro tnc-dock upgraded in place (layers and volumes kept)")}s.includes("tnc-dock")&&run("wsl.exe",["--unregister","tnc-dock"]);const n=path.join(TNC_DIR,"wsl","tnc-dock");fs.mkdirSync(n,{recursive:!0});const e=run("wsl.exe",["--import","tnc-dock",n,o,"--version","2"]);if(0!==e.code)throw setupError(`wsl --import failed: ${e.out.trim()}`);t(`WSL2 distro tnc-dock created in ${n}`)}finally{c()}}function installLinux(n,t){const r=linuxArch(),e=path.join(TNC_DIR,"bin");fs.mkdirSync(e,{recursive:!0});for(const t of[`tnc-shim-linux-${r}`,`crun-linux-${r}`,`tnc-busybox-linux-${r}`]){const r=t;n[t]&&(fs.copyFileSync(n[t],path.join(e,`${r}.new`)),fs.chmodSync(path.join(e,`${r}.new`),493),fs.renameSync(path.join(e,`${r}.new`),path.join(e,r)))}if(t(`tnc-shim, crun and busybox installed in ${e}`),0!==process.getuid?.()){const n=require("../linux/host").rootlessProblems();t(n.length?`rootless mode needs:\n - ${n.join("\n - ")}`:"rootless mode is ready (dock runs as you, no sudo)")}}async function prefetchBuildKit(n){if("0"===process.env.TNC_DOCK_BUILDKIT)return;const{installBuildKit:t,buildKitDir:r,buildKitInstalled:e,VERSION:s}=require("../build/buildkit"),i={arm64:"arm64",x64:"amd64"}[process.arch];if(!i)return;const o=r(process.env.TNC_DOCK_HOME||path.join(TNC_DIR,"dock"),i);if(e(o))return n(`BuildKit ${s} already installed`);try{n(`downloading BuildKit ${s} (linux/${i})`),await t(o,i),n(`BuildKit ${s} installed in ${o}`)}catch(t){n(`warning: ${t.message}; it will be downloaded on the first BuildKit build`)}}async function setup({from:n,baseUrl:t,force:r=!1,log:e=()=>{}}={}){const{manifest:s,files:i}=await fetchBundle({from:n,baseUrl:t,log:e});return"darwin"===process.platform?installDarwin(i,e):"win32"===process.platform?installWindows(i,e,{force:r}):installLinux(i,e),await prefetchBuildKit(e),fs.mkdirSync(path.join(TNC_DIR,"dock"),{recursive:!0}),fs.writeFileSync(path.join(TNC_DIR,"dock","runtime.json"),JSON.stringify({version:s.version,platform:process.platform,arch:process.arch,installedAt:(new Date).toISOString()},null,2)),s.version}module.exports={setup,prefetchBuildKit,wantedFiles,fetchBundle,linuxArch,WSL_DISTRO:"tnc-dock",sha256File,BUNDLE_VERSION:"0.2.3",sanitizeWslTar};
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),crypto=require("crypto"),{spawnSync}=require("child_process"),TNC_DIR=path.join(os.homedir(),".tnc"),BUNDLE_VERSION="0.2.6",DEFAULT_BASE_URL=process.env.TNC_DOCK_BUNDLE_URL||"https://thinkncollab.com/downloads/dock/0.2.6",WSL_DISTRO="tnc-dock",linuxArch=(n=process.arch)=>({arm64:"aarch64",x64:"x86_64"}[n]);function setupError(n){const t=new Error(n);return t.setup=!0,t}function sha256File(n){const t=crypto.createHash("sha256"),e=fs.openSync(n,"r"),s=Buffer.alloc(1<<20);let r;for(;(r=fs.readSync(e,s,0,s.length,null))>0;)t.update(s.subarray(0,r));return fs.closeSync(e),t.digest("hex")}const OPTIONAL_FILES=/^tnc-busybox-linux-|^tnc-tray-/;function wantedFiles(n=process.platform,t=process.arch){const e=linuxArch(t);if(!e)throw setupError(`unsupported CPU architecture ${t}`);if("darwin"===n)return[`tnc-dock-vm-${e}.tar.gz`,"tnc-vz-darwin-universal","tnc-tray-darwin-universal.tar.gz"];if("win32"===n)return[`tnc-dock-wsl-${e}.tar.gz`];if("linux"===n)return[`tnc-shim-linux-${e}`,`crun-linux-${e}`,`tnc-busybox-linux-${e}`];throw setupError(`unsupported platform ${n}`)}async function download(n,t){const e=await fetch(n,{redirect:"follow"});if(!e.ok)throw setupError(`download failed: ${n} (HTTP ${e.status})`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.writeFileSync(`${t}.part`,Buffer.from(await e.arrayBuffer())),fs.renameSync(`${t}.part`,t)}async function fetchBundle({from:n,baseUrl:t=DEFAULT_BASE_URL,log:e}){let s,r;if(n)r=path.resolve(n),s=JSON.parse(fs.readFileSync(path.join(r,"manifest.json"),"utf8"));else{let n=await fetch(`${t}/manifest.json`,{redirect:"follow"});if(!n.ok){const e="https://thinkncollab.com/downloads/dock",r=await fetch(`${e}/manifest.json`,{redirect:"follow"});r.ok&&(s=await r.json(),t=`${e}/${s.version}`,n=r)}if(!n.ok)throw setupError(`cannot fetch bundle manifest from ${t} (HTTP ${n.status})`);s||(s=await n.json()),r=path.join(TNC_DIR,"cache","bundles",s.version)}const i={};for(const o of wantedFiles()){const a=s.files[o];if(!a&&OPTIONAL_FILES.test(o)){e(`bundle ${s.version} has no ${o}; ${/tray/.test(o)?"the menu-bar app":"cp"} needs a newer bundle`);continue}if(!a)throw setupError(`bundle ${s.version} has no ${o}`);const c=path.join(r,o);if(n||fs.existsSync(c)&&sha256File(c)===a.sha256||(e(`downloading ${o} (${(a.size/1024/1024).toFixed(1)} MB)`),await download(`${t}/${o}`,c)),!fs.existsSync(c))throw setupError(`missing ${c}`);if(sha256File(c)!==a.sha256)throw setupError(`sha256 mismatch for ${o}; refusing to install`);i[o]=c}return{manifest:s,files:i}}function run(n,t,e={}){const s=spawnSync(n,t,{encoding:"utf8",...e});return{code:s.status,out:`${s.stdout||""}${s.stderr||""}`.replace(/\0/g,"")}}function installDarwin(n,t){const e=linuxArch(),s=path.join(TNC_DIR,"kernel"),r=path.join(TNC_DIR,"bin");fs.mkdirSync(s,{recursive:!0}),fs.mkdirSync(r,{recursive:!0}),run(path.join(r,"tnc-vz"),["stop"]);for(const n of["vmlinuz","initramfs.img"]){const t=path.join(s,n);fs.existsSync(t)&&!fs.existsSync(`${t}.pre-bundle`)&&fs.copyFileSync(t,`${t}.pre-bundle`)}const i=run("tar",["-xzf",n[`tnc-dock-vm-${e}.tar.gz`],"-C",s]);if(0!==i.code)throw setupError(`cannot unpack VM image: ${i.out}`);const o=path.join(r,"tnc-vz");fs.existsSync(o)&&!fs.existsSync(`${o}.pre-bundle`)&&fs.copyFileSync(o,`${o}.pre-bundle`),fs.copyFileSync(n["tnc-vz-darwin-universal"],`${o}.new`),fs.chmodSync(`${o}.new`,493),fs.renameSync(`${o}.new`,o),t(`MicroVM image (${e}) and tnc-vz installed`);const a=n["tnc-tray-darwin-universal.tar.gz"];if(a){fs.rmSync(path.join(r,"Thinknsh.app"),{recursive:!0,force:!0});const n=run("tar",["-xzf",a,"-C",r]);if(0!==n.code)throw setupError(`cannot unpack the menu-bar app: ${n.out}`);t("menu-bar app unpacked (install it: thinknsh dock app install)")}}function wslDistros(){const n=run("wsl.exe",["--list","--quiet"]);return 0===n.code?n.out.split(/\r?\n/).map(n=>n.trim()).filter(Boolean):null}function sanitizeWslTar(n){const t=require("zlib");let e;try{e=t.gunzipSync(fs.readFileSync(n))}catch(t){return{path:n,cleanup:()=>{}}}let s=0;const r=[];let i=!1;for(;s<e.length;){const n=e.subarray(s,s+512);if(n.length<512||n.every(n=>0===n)){r.push(e.subarray(s));break}const t=n.subarray(124,136).toString("latin1").replace(/\0.*$/,"").trim(),o=parseInt(t,8)||0,a=String.fromCharCode(n[156]),c=512+512*Math.ceil(o/512);"x"===a||"g"===a?i=!0:r.push(e.subarray(s,s+c)),s+=c}if(!i)return{path:n,cleanup:()=>{}};const o=path.join(os.tmpdir(),`tnc-dock-wsl-clean-${Date.now()}.tar.gz`);return fs.writeFileSync(o,t.gzipSync(Buffer.concat(r))),{path:o,cleanup:()=>{try{fs.unlinkSync(o)}catch(n){}}}}function wslMissingMessage(n=""){return["Linux containers on Windows run in WSL2, and WSL2 is not ready on this PC yet.","","Set it up once (about 5 minutes):",' 1. Open PowerShell as administrator: Start, type "PowerShell", right-click it, "Run as administrator"'," 2. Run: wsl --install --no-distribution"," 3. Restart Windows"," 4. Run: thinknsh dock setup",.../BIOS|UEFI|virtuali[sz]ation (is )?(not enabled|disabled)|enable virtuali[sz]ation/i.test(n)?["","This PC also has virtualization switched off. Turn it on in the BIOS/UEFI settings",'(often called "Intel VT-x", "AMD-V" or "SVM"), then do the steps above.']:[],"","Needs Windows 10 version 2004 or newer, or Windows 11."].join("\n")}function installWindows(n,t,{force:e=!1}={}){const s=linuxArch(),r=run("wsl.exe",["--status"]);if(0!==r.code)throw setupError(wslMissingMessage(r.out));const i=wslDistros()||[],o=n[`tnc-dock-wsl-${s}.tar.gz`],{path:a,cleanup:c}=sanitizeWslTar(o);try{if(i.includes("tnc-dock")&&!e){const n=run("wsl.exe",["-d","tnc-dock","-u","root","--","tar","-xzf","-","-C","/","./usr/bin/tnc-shim","./usr/bin/crun","./usr/local/bin/tnc-wsl-init","./etc/wsl.conf","./bin/busybox.static"],{input:fs.readFileSync(a)});if(0!==n.code)throw setupError(`cannot upgrade WSL distro: ${n.out}`);return run("wsl.exe",["--terminate","tnc-dock"]),void t("WSL distro tnc-dock upgraded in place (layers and volumes kept)")}i.includes("tnc-dock")&&run("wsl.exe",["--unregister","tnc-dock"]);const n=path.join(TNC_DIR,"wsl","tnc-dock");fs.mkdirSync(n,{recursive:!0});const s=run("wsl.exe",["--import","tnc-dock",n,a,"--version","2"]);if(0!==s.code)throw setupError(`wsl --import failed: ${s.out.trim()}`);t(`WSL2 distro tnc-dock created in ${n}`)}finally{c()}}function installLinux(n,t){const e=linuxArch(),s=path.join(TNC_DIR,"bin");fs.mkdirSync(s,{recursive:!0});for(const t of[`tnc-shim-linux-${e}`,`crun-linux-${e}`,`tnc-busybox-linux-${e}`]){const e=t;n[t]&&(fs.copyFileSync(n[t],path.join(s,`${e}.new`)),fs.chmodSync(path.join(s,`${e}.new`),493),fs.renameSync(path.join(s,`${e}.new`),path.join(s,e)))}if(t(`tnc-shim, crun and busybox installed in ${s}`),0!==process.getuid?.()){const n=require("../linux/host").rootlessProblems();t(n.length?`rootless mode needs:\n - ${n.join("\n - ")}`:"rootless mode is ready (dock runs as you, no sudo)")}}async function prefetchBuildKit(n){if("0"===process.env.TNC_DOCK_BUILDKIT)return;const{installBuildKit:t,buildKitDir:e,buildKitInstalled:s,VERSION:r}=require("../build/buildkit"),i={arm64:"arm64",x64:"amd64"}[process.arch];if(!i)return;const o=e(process.env.TNC_DOCK_HOME||path.join(TNC_DIR,"dock"),i);if(s(o))return n(`BuildKit ${r} already installed`);try{n(`downloading BuildKit ${r} (linux/${i})`),await t(o,i),n(`BuildKit ${r} installed in ${o}`)}catch(t){n(`warning: ${t.message}; it will be downloaded on the first BuildKit build`)}}async function setup({from:n,baseUrl:t,force:e=!1,log:s=()=>{}}={}){const{manifest:r,files:i}=await fetchBundle({from:n,baseUrl:t,log:s});return"darwin"===process.platform?installDarwin(i,s):"win32"===process.platform?installWindows(i,s,{force:e}):installLinux(i,s),await prefetchBuildKit(s),fs.mkdirSync(path.join(TNC_DIR,"dock"),{recursive:!0}),fs.writeFileSync(path.join(TNC_DIR,"dock","runtime.json"),JSON.stringify({version:r.version,platform:process.platform,arch:process.arch,installedAt:(new Date).toISOString()},null,2)),r.version}module.exports={wslMissingMessage,setup,prefetchBuildKit,wantedFiles,fetchBundle,linuxArch,WSL_DISTRO:"tnc-dock",sha256File,BUNDLE_VERSION:"0.2.6",sanitizeWslTar};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),path=require("path"),{spawnSync,spawn}=require("child_process"),ROOT=path.join(__dirname,"..","..",".."),CLI=path.join(ROOT,"bin","thinknsh.js"),SOURCE=path.join(ROOT,"native","tnc-tray"),macTarget=()=>process.env.TNC_APP_DIR||path.join(os.homedir(),"Applications","Thinknsh.app"),winDir=()=>process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),winMenuEntry=()=>path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Thinknsh.vbs");function agentConfig(){return JSON.stringify({node:process.execPath,cli:CLI},null,2)+"\n"}function findMacApp(n){const t=path.join(os.homedir(),".tnc","bin","Thinknsh.app");if(fs.existsSync(path.join(t,"Contents","MacOS","thinknsh-tray")))return t;const s=path.join(SOURCE,"build-macos.sh");if(fs.existsSync(s)&&0===spawnSync("xcrun",["--find","swiftc"],{stdio:"ignore"}).status){const t=fs.mkdtempSync(path.join(os.tmpdir(),"tnc-tray-"));n?.("building the menu-bar app from source (swiftc)");const e=spawnSync("bash",[s,t],{encoding:"utf8"});if(0!==e.status)throw new Error(`building the app failed: ${(e.stderr||e.stdout).trim().split("\n").slice(-3).join(" ")}`);return path.join(t,"Thinknsh.app")}throw new Error("the menu-bar app is not in this runtime: run thinknsh dock setup again, or install Xcode command line tools to build it")}function winLauncher(n){const t=n=>n.replace(/"/g,'""');return`' thinknsh dock tray (written by thinknsh dock app install)\r\nSet sh = CreateObject("WScript.Shell")\r\nsh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""${t(path.join(n,"tray.ps1"))}"" -Node ""${t(process.execPath)}"" -Cli ""${t(CLI)}""", 0, False\r\n`}function removeEarlierApp(n){const t=path.join(os.homedir(),"Applications","TNC Dock.app"),s=path.join(os.homedir(),"Library","LaunchAgents","com.thinkncollab.dock.menubar.plist");fs.existsSync(s)&&(spawnSync("launchctl",["bootout",`gui/${process.getuid()}/com.thinkncollab.dock.menubar`],{stdio:"ignore"}),fs.rmSync(s,{force:!0})),fs.existsSync(t)&&(spawnSync("pkill",["-f",`${t}/Contents/MacOS/`],{stdio:"ignore"}),fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed the earlier menu bar app (${t})`))}function install({log:n}={}){const t=[];if("darwin"===process.platform){removeEarlierApp(t);const s=findMacApp(n),e=macTarget();fs.mkdirSync(path.dirname(e),{recursive:!0}),spawnSync("pkill",["-f",`${e}/Contents/MacOS/thinknsh-tray`],{stdio:"ignore"}),fs.rmSync(e,{recursive:!0,force:!0});const i=spawnSync("ditto",[s,e],{encoding:"utf8"});if(0!==i.status)throw new Error(`copying the app: ${i.stderr.trim()}`);fs.writeFileSync(path.join(e,"Contents","Resources","agent.json"),agentConfig()),t.push(`menu-bar app: ${e}`)}else{if("win32"!==process.platform)throw new Error("there is no tray app on Linux; the dashboard is thinknsh dock ui");{const n=winDir();fs.mkdirSync(n,{recursive:!0}),fs.copyFileSync(path.join(SOURCE,"tray.ps1"),path.join(n,"tray.ps1"));const s=path.join(ROOT,"native","brand","tnc.ico");fs.existsSync(s)&&fs.copyFileSync(s,path.join(n,"tnc.ico")),fs.writeFileSync(path.join(n,"Thinknsh.vbs"),winLauncher(n)),fs.mkdirSync(path.dirname(winMenuEntry()),{recursive:!0}),fs.copyFileSync(path.join(n,"Thinknsh.vbs"),winMenuEntry()),t.push(`tray app: ${n} (Start menu: Thinknsh)`)}}const s=require("../setup/autostart");return s.status().enabled&&t.push(...s.enable().filter(n=>/app/.test(n))),open(),t}function installedPath(){if("darwin"===process.platform)return fs.existsSync(macTarget())?macTarget():require("../setup/autostart").appBundle();if("win32"===process.platform){const n=path.join(winDir(),"Thinknsh.vbs");return fs.existsSync(n)?n:null}return null}function open(){const n=installedPath();if(!n)throw new Error("the tray app is not installed: thinknsh dock app install");"darwin"===process.platform?spawn("open",[n],{stdio:"ignore",detached:!0}).unref():spawn("wscript.exe",[n],{stdio:"ignore",detached:!0}).unref()}function uninstall(){const n=[];if("darwin"===process.platform){const t=macTarget();spawnSync("pkill",["-f",`${t}/Contents/MacOS/thinknsh-tray`],{stdio:"ignore"}),fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed ${t}`))}else if("win32"===process.platform)for(const t of[winDir(),winMenuEntry()])fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed ${t}`));const t=require("../setup/autostart");return t.status().app&&(fs.rmSync(path.join(path.dirname(t.status().file),`${t.APP_LABEL}.plist`),{force:!0}),n.push("removed the login item of the app")),n.length||n.push("the tray app was not installed"),n}module.exports={install,uninstall,open,installedPath,winLauncher};
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),{spawnSync,spawn}=require("child_process"),ROOT=path.join(__dirname,"..","..",".."),CLI=path.join(ROOT,"bin","thinknsh.js"),SOURCE=path.join(ROOT,"native","tnc-tray"),macTarget=()=>process.env.TNC_APP_DIR||path.join(os.homedir(),"Applications","Thinknsh.app"),winDir=()=>process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),linuxData=()=>process.env.XDG_DATA_HOME||path.join(os.homedir(),".local","share"),linuxDesktop=()=>path.join(linuxData(),"applications","thinknsh.desktop"),linuxIcon=()=>path.join(linuxData(),"icons","hicolor","256x256","apps","thinknsh.png"),winMenuEntry=()=>path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Thinknsh.vbs");function linuxLauncher(n=process.execPath,t=CLI){const e=n=>`"${String(n).replace(/(["\\`$])/g,"\\$1")}"`;return["[Desktop Entry]","Type=Application","Name=Thinknsh","GenericName=Container dashboard","Comment=ThinkNCollab dock: containers, images, logs and settings",`Exec=${e(n)} ${e(t)} dock ui`,"Icon=thinknsh","Terminal=false","Categories=Development;","Keywords=ThinkNCollab;dock;containers;",""].join("\n")}function agentConfig(){return JSON.stringify({node:process.execPath,cli:CLI},null,2)+"\n"}function findMacApp(n){const t=path.join(os.homedir(),".tnc","bin","Thinknsh.app");if(fs.existsSync(path.join(t,"Contents","MacOS","thinknsh-tray")))return t;const e=path.join(SOURCE,"build-macos.sh");if(fs.existsSync(e)&&0===spawnSync("xcrun",["--find","swiftc"],{stdio:"ignore"}).status){const t=fs.mkdtempSync(path.join(os.tmpdir(),"tnc-tray-"));n?.("building the menu-bar app from source (swiftc)");const s=spawnSync("bash",[e,t],{encoding:"utf8"});if(0!==s.status)throw new Error(`building the app failed: ${(s.stderr||s.stdout).trim().split("\n").slice(-3).join(" ")}`);return path.join(t,"Thinknsh.app")}throw new Error("the menu-bar app is not in this runtime: run thinknsh dock setup again, or install Xcode command line tools to build it")}function winLauncher(n){const t=n=>n.replace(/"/g,'""');return`' thinknsh dock tray (written by thinknsh dock app install)\r\nSet sh = CreateObject("WScript.Shell")\r\nsh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""${t(path.join(n,"tray.ps1"))}"" -Node ""${t(process.execPath)}"" -Cli ""${t(CLI)}""", 0, False\r\n`}function removeEarlierApp(n){const t=path.join(os.homedir(),"Applications","TNC Dock.app"),e=path.join(os.homedir(),"Library","LaunchAgents","com.thinkncollab.dock.menubar.plist");fs.existsSync(e)&&(spawnSync("launchctl",["bootout",`gui/${process.getuid()}/com.thinkncollab.dock.menubar`],{stdio:"ignore"}),fs.rmSync(e,{force:!0})),fs.existsSync(t)&&(spawnSync("pkill",["-f",`${t}/Contents/MacOS/`],{stdio:"ignore"}),fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed the earlier menu bar app (${t})`))}function install({log:n}={}){const t=[];if("darwin"===process.platform){removeEarlierApp(t);const e=findMacApp(n),s=macTarget();fs.mkdirSync(path.dirname(s),{recursive:!0}),spawnSync("pkill",["-f",`${s}/Contents/MacOS/thinknsh-tray`],{stdio:"ignore"}),fs.rmSync(s,{recursive:!0,force:!0});const i=spawnSync("ditto",[e,s],{encoding:"utf8"});if(0!==i.status)throw new Error(`copying the app: ${i.stderr.trim()}`);fs.writeFileSync(path.join(s,"Contents","Resources","agent.json"),agentConfig()),t.push(`menu-bar app: ${s}`)}else{if("win32"!==process.platform){if("linux"===process.platform)return fs.mkdirSync(path.dirname(linuxIcon()),{recursive:!0}),fs.copyFileSync(path.join(ROOT,"native","brand","tnc-256.png"),linuxIcon()),fs.mkdirSync(path.dirname(linuxDesktop()),{recursive:!0}),fs.writeFileSync(linuxDesktop(),linuxLauncher(),{mode:493}),t.push(`applications menu: Thinknsh opens the dashboard (${linuxDesktop()})`),t;throw new Error(`there is no desktop app for ${process.platform}; the dashboard is thinknsh dock ui`)}{const n=winDir();fs.mkdirSync(n,{recursive:!0}),fs.copyFileSync(path.join(SOURCE,"tray.ps1"),path.join(n,"tray.ps1"));const e=path.join(ROOT,"native","brand","tnc.ico");fs.existsSync(e)&&fs.copyFileSync(e,path.join(n,"tnc.ico")),fs.writeFileSync(path.join(n,"Thinknsh.vbs"),winLauncher(n)),fs.mkdirSync(path.dirname(winMenuEntry()),{recursive:!0}),fs.copyFileSync(path.join(n,"Thinknsh.vbs"),winMenuEntry()),t.push(`tray app: ${n} (Start menu: Thinknsh)`)}}const e=require("../setup/autostart");return e.status().enabled&&t.push(...e.enableApp()),open(),t}function installedPath(){if("darwin"===process.platform)return fs.existsSync(macTarget())?macTarget():require("../setup/autostart").appBundle();if("win32"===process.platform){const n=path.join(winDir(),"Thinknsh.vbs");return fs.existsSync(n)?n:null}return"linux"===process.platform&&fs.existsSync(linuxDesktop())?linuxDesktop():null}function open(){const n=installedPath();if(!n)throw new Error("the tray app is not installed: thinknsh dock app install");"darwin"===process.platform?spawn("open",[n],{stdio:"ignore",detached:!0}).unref():"linux"===process.platform?spawn(process.execPath,[CLI,"dock","ui"],{stdio:"ignore",detached:!0}).unref():spawn("wscript.exe",[n],{stdio:"ignore",detached:!0}).unref()}function uninstall(){const n=[];if("darwin"===process.platform){const t=macTarget();spawnSync("pkill",["-f",`${t}/Contents/MacOS/thinknsh-tray`],{stdio:"ignore"}),fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed ${t}`))}else if("linux"===process.platform)for(const t of[linuxDesktop(),linuxIcon()])fs.existsSync(t)&&(fs.rmSync(t,{force:!0}),n.push(`removed ${t}`));else if("win32"===process.platform)for(const t of[winDir(),winMenuEntry()])fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed ${t}`));const t=require("../setup/autostart");return t.status().app&&(fs.rmSync(path.join(path.dirname(t.status().file),`${t.APP_LABEL}.plist`),{force:!0}),n.push("removed the login item of the app")),n.length||n.push("the tray app was not installed"),n}module.exports={install,uninstall,open,installedPath,winLauncher,linuxLauncher};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const fs=require("fs"),os=require("os"),net=require("net"),path=require("path"),crypto=require("crypto"),{spawnSync}=require("child_process"),ws=require("./workspace"),{connectPlatform,request}=require("../../core/platform-socket"),MAX_FRAME=262144,SHARE_ID=/^[a-z0-9]{10}$/,PUBKEY=/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}( [^\n]{0,100})?$/,HOSTKEY=/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/,MAX_BAD_PROOFS=5,MAX_STREAMS=32,clean=(e,t=64)=>String(e??"").replace(/[\u0000-\u001f\u007f-\u009f]/g,"").slice(0,t),safeFolder=e=>!/^\/work(\/[\w.-]+)*$/.test(e)&&!/^\/run\/tnc-workspace\/[\w.-]+\.code-workspace$/.test(e)||e.includes("..")?"/work":e,isBytes=e=>Buffer.isBuffer(e)||e instanceof ArrayBuffer||ArrayBuffer.isView(e),joinHost=e=>`tnc-join-${e}`,guestContainer=e=>`${ws.containerName(e)}-guest`,guestHome=e=>`${ws.containerName(e)}-guest-home`,guestNet=e=>`${ws.containerName(e)}-guest-net`,GUEST_LABEL=`${ws.LABEL}.guest`;function newCode(){return{shareId:Array.from(crypto.randomBytes(10),e=>"abcdefghijkmnpqrstuvwxyz23456789"[e%32]).join(""),secret:crypto.randomBytes(12).toString("base64url")}}function fingerprint(e){const t=Buffer.from(String(e).split(" ")[1]||"","base64");return crypto.createHash("sha256").update(t).digest("base64url").slice(0,16)}const keyOnly=e=>String(e).trim().split(" ").slice(0,2).join(" "),proofFor=(e,t)=>crypto.createHmac("sha256",e).update(keyOnly(t)).digest("hex");function parseCode(e){const t=/^([a-z0-9]{10})-([A-Za-z0-9_-]{16})-([A-Za-z0-9_-]{16})$/.exec(String(e||"").trim());if(!t)throw ws.workspaceError("that is not a join code (it looks like abcd2345ef-XXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXX)");return{shareId:t[1],secret:t[2],fp:t[3]}}function isPrivate(e){const t=String(e).replace(/^::ffff:/,""),[r,o]=t.split(".").map(Number);return 10===r||172===r&&o>=16&&o<=31||192===r&&168===o||100===r&&o>=64&&o<=127}function lanAddresses(){return Object.values(os.networkInterfaces()).flat().filter(e=>e&&"IPv4"===e.family&&!e.internal&&isPrivate(e.address)).map(e=>e.address)}function guestDir(e,t){return path.join(ws.stateDir(e,t),"guest")}function guestKeys(e,t){const r=guestDir(e,t);fs.mkdirSync(r,{recursive:!0,mode:448});const o=path.join(r,"ssh_host_ed25519_key");fs.existsSync(o)||(ws.keygen(o,`tnc-workspace-guest-${t}`),fs.rmSync(`${o}.pub`,{force:!0})),fs.writeFileSync(path.join(r,"authorized_keys"),"",{mode:384});const s=spawnSync("ssh-keygen",["-y","-f",o],{encoding:"utf8"});if(0!==s.status)throw ws.workspaceError(`cannot read the guest host key (${(s.stderr||"").trim()})`);return{dir:r,hostKey:keyOnly(s.stdout)}}function guestKeyLine(e,t,r){const o=String(r||"guest").replace(/[^\w.@-]/g,"_").slice(0,40);return`no-agent-forwarding,no-X11-forwarding ${keyOnly(t)} tnc-share:${e}:${o}`}function addGuestKey(e,t,r,o){fs.appendFileSync(path.join(e,"authorized_keys"),guestKeyLine(t,r,o)+"\n",{mode:384})}async function execRoot(e,t,r){const{execIn:o}=require("../room-data");return o(e,t,r,{user:"root"})}const sleep=e=>new Promise(t=>setTimeout(t,e));async function waitPort(e,t=3e4){const r=Date.now()+t;for(;Date.now()<r;){if(await new Promise(t=>{const r=net.connect({host:"127.0.0.1",port:e},()=>{r.once("data",e=>{r.destroy(),t(e.toString().startsWith("SSH-"))})});r.on("error",()=>t(!1)),r.setTimeout(1500,()=>{r.destroy(),t(!1)})}))return;await sleep(500)}throw ws.workspaceError("the guest container did not start its SSH server")}async function startGuestContainer(e,t,r,o,s){const n=guestContainer(r.name);await removeGuestContainer(e,r.name),await e.json("POST","/networks/create",{Name:guestNet(r.name),Labels:{[GUEST_LABEL]:r.name}});const a=path.join(ws.stateDir(t,r.name),"server");for(const e of fs.readdirSync(a).filter(e=>e.endsWith(".code-workspace")))fs.copyFileSync(path.join(a,e),path.join(s,e));const i=(o.HostConfig.Binds||[]).filter(e=>!/:\/run\/tnc-workspace(:|$)/.test(e)&&!e.startsWith(`${ws.homeVolume(r.name)}:`)),c=ws.listMeta(t).map(e=>e.port),d=await ws.pickPort(c),u=ws.containerBody({name:r.name,image:o.Config.Image,mounts:[],serverDir:s,port:d,memory:o.HostConfig.Memory,nanoCpus:o.HostConfig.NanoCpus,pidsLimit:o.HostConfig.PidsLimit||4096});u.Hostname=`tnc-${r.name}-guest`.slice(0,63),u.Labels={[GUEST_LABEL]:r.name,"tnc.host-access":"none"},u.HostConfig.NetworkMode=guestNet(r.name),u.HostConfig.Binds=[...i,`${s}:/run/tnc-workspace:ro`,`${guestHome(r.name)}:/home/${ws.USER}`],u.HostConfig.RestartPolicy={Name:"no"},await e.json("POST",`/containers/create?name=${encodeURIComponent(n)}`,u);for(const t of Object.keys(o.NetworkSettings?.Networks||{}))["bridge","default","host","none"].includes(t)||await e.json("POST",`/networks/${encodeURIComponent(t)}/connect`,{Container:n}).catch(()=>{});await e.json("POST",`/containers/${encodeURIComponent(n)}/start`);try{await waitPort(d)}catch(e){throw ws.workspaceError(`${e.message}; see: thinknsh logs ${n}`)}try{await verifyHostIsolation(e,n)}catch(t){throw await removeGuestContainer(e,r.name),t}return{port:d,container:n}}async function verifyHostIsolation(e,t){const{execIn:r}=require("../room-data");let o=!1;const s=net.createServer(e=>{o=!0,e.destroy()});await new Promise((e,t)=>{s.once("error",t),s.listen(0,"0.0.0.0",e)});try{const o=s.address().port;await r(e,t,["sh","-c",`timeout 4 bash -c 'exec 3<>/dev/tcp/host.tnc.internal/${o}' 2>/dev/null; true`])}finally{s.close()}if(o)throw ws.workspaceError("the guest container can still reach this machine: this dock does not isolate it yet. Restart dock with this version (thinknsh dock stop && thinknsh dock start), then share again")}async function removeGuestContainer(e,t){await e.json("DELETE",`/containers/${encodeURIComponent(guestContainer(t))}?force=1`).catch(()=>{}),await e.json("DELETE",`/volumes/${encodeURIComponent(guestHome(t))}?force=1`).catch(()=>{}),await e.json("DELETE",`/networks/${encodeURIComponent(guestNet(t))}`).catch(()=>{})}function bridge(e,t,r,o=()=>{}){let s=!0;const n=e=>{e?.streamId===t&&isBytes(e.data)&&r.write(Buffer.from(e.data.buffer?new Uint8Array(e.data.buffer,e.data.byteOffset,e.data.byteLength):e.data))},a=e=>{e?.streamId===t&&i(!1)},i=i=>{if(s){s=!1,e.off("workspace:data",n),e.off("workspace:close",a),i&&e.emit("workspace:close",{streamId:t});try{r.end()}catch(e){}o()}};return e.on("workspace:data",n),e.on("workspace:close",a),r.on("data",r=>{for(let o=0;o<r.length;o+=262144)e.emit("workspace:data",{streamId:t,data:r.subarray(o,o+262144)})}),r.on("end",()=>i(!0)),r.on("error",()=>i(!0)),e.on("disconnect",()=>i(!1)),i}async function listenLan(e){const t=lanAddresses(),r=[];let o=0;for(const s of t){const t=net.createServer(t=>{if(!isPrivate(t.remoteAddress))return t.destroy();const r=net.connect(e,()=>{t.pipe(r),r.pipe(t)});r.on("error",()=>t.destroy()),t.on("error",()=>r.destroy())});await new Promise(e=>{t.once("error",()=>e(!1)),t.listen(o,s,()=>e(!0))})&&(o=t.address().port,r.push(t))}return{port:r.length?o:null,addrs:r.map(e=>e.address().address),close:()=>r.forEach(e=>e.close())}}async function share(e,t,{api:r,owner:o,roomId:s=null,lan:n=!0,approve:a,log:i,onStop:c=()=>{},guest:d=null}){const{shareId:u,secret:p}=newCode(),f=d?{dir:d.dir,hostKey:d.hostKey}:guestKeys(t,e.name);i("starting the guest container (your folders, its own home)…");const w=d||await startGuestContainer(r,t,e,o,f.dir),l={host:"127.0.0.1",port:w.port},h=n?await listenLan(l):{port:null,addrs:[],close(){}};let y,m;const g=new Map;let k=!1;const S=async()=>{if(!k){k=!0;for(const e of g.values())e(!0);h.close();try{y?.emit("workspace:share-end",{shareId:u}),y?.close()}catch(e){}d||await removeGuestContainer(r,e.name);try{fs.writeFileSync(path.join(f.dir,"authorized_keys"),"",{mode:384})}catch(e){}}};try{({socket:y,user:m}=await connectPlatform());const t=()=>request(y,"workspace:share-register",{shareId:u,roomId:s,workspace:e.name});await t(),y.io.on("reconnect",()=>{t().catch(e=>i(`re-register failed: ${e.message}`))})}catch(e){throw await S(),e}let $=0;return y.on("workspace:join-request",async(t={})=>{const o=e=>y.emit("workspace:join-reply",{requestId:t.requestId,...e}),s=clean(t.fromName)||"someone",n=clean(t.fromUserId,40),l=`${s}${n?` (${n})`:""}`;if(k||$>=5)return o({ok:!1,error:"this share has stopped"});if(!PUBKEY.test(String(t.publicKey||"")))return o({ok:!1,error:"the join did not send an ed25519 key"});const g=Buffer.from(proofFor(p,t.publicKey)),j=Buffer.from(String(t.proof||""));if(j.length!==g.length||!crypto.timingSafeEqual(j,g))return i(`refused a join with a wrong code from ${l}`),++$>=5?(i("5 wrong codes: sharing stopped (start again for a new code)"),o({ok:!1,error:"wrong join code"}),await sleep(300),S().then(c,e=>{i(`stopping failed: ${e.message}`),c()})):o({ok:!1,error:"wrong join code"});if(!await a({name:s,userId:n}))return i(`declined ${l}`),o({ok:!1,error:"the workspace owner declined"});if(addGuestKey(f.dir,u,t.publicKey,s),!d){const e=guestKeyLine(u,t.publicKey,s),n=await execRoot(r,w.container,["sh","-c",'printf "%s\\n" "$1" >> /etc/tnc-workspace/authorized_keys',"sh",e]);if(0!==n.code)return i(`could not add ${l}'s key: ${n.stderr}`),o({ok:!1,error:"the workspace could not add your key"})}i(`let in ${l}`),o({ok:!0,user:ws.USER,hostKey:f.hostKey,workspace:e.name,folder:(e.paths||[]).length>1?`/run/tnc-workspace/${e.name}.code-workspace`:"/work",lan:h.port?h.addrs.map(e=>({address:e,port:h.port})):[],owner:m.name})}),y.on("workspace:open",(e={})=>{const{streamId:t}=e;if(!/^[a-f0-9]{24}$/.test(String(t||""))||g.has(t))return;if(g.size>=32)return y.emit("workspace:close",{streamId:t});const r=net.connect(l);g.set(t,bridge(y,t,r,()=>g.delete(t)))}),{code:`${u}-${p}-${fingerprint(f.hostKey)}`,shareId:u,lanPort:h.port,lanAddrs:h.addrs,cleanup:S}}async function join(e,t,{cli:r=process.argv[1]}={}){const{shareId:o,secret:s,fp:n}=parseCode(e),a=path.join(ws.joinedDir(t),o);fs.mkdirSync(a,{recursive:!0,mode:448});const i=path.join(a,"id_ed25519");fs.existsSync(i)||ws.keygen(i,`thinknsh-join-${o}`);const c=keyOnly(fs.readFileSync(`${i}.pub`,"utf8")),{socket:d}=await connectPlatform();try{const e=await request(d,"workspace:join",{shareId:o,proof:proofFor(s,c),publicKey:c},18e4);if(!HOSTKEY.test(e.hostKey||""))throw ws.workspaceError("the workspace sent no usable host key");if(fingerprint(e.hostKey)!==n)throw ws.workspaceError("the workspace key does not match the join code: someone between you and the owner may be posing as it. Not connecting.");const i=joinHost(o);fs.writeFileSync(path.join(a,"known_hosts"),`${i} ${e.hostKey}\n`,{mode:384});const u=(Array.isArray(e.lan)?e.lan:[]).filter(e=>e&&net.isIPv4(String(e.address))&&isPrivate(e.address)&&Number.isInteger(e.port)&&e.port>0&&e.port<65536).slice(0,8).map(e=>({address:e.address,port:e.port})),p={shareId:o,host:i,workspace:clean(e.workspace),owner:clean(e.owner),folder:safeFolder(String(e.folder||"/work")),lan:u,cli:r,joinedAt:(new Date).toISOString()};return fs.writeFileSync(path.join(a,"join.json"),JSON.stringify(p,null,2)+"\n",{mode:384}),ws.writeSshConfig(t),ws.syncUserSshConfig(t),p}catch(e){throw fs.rmSync(a,{recursive:!0,force:!0}),e}finally{d.close()}}function leave(e,t){fs.rmSync(path.join(ws.joinedDir(t),e),{recursive:!0,force:!0}),ws.writeSshConfig(t),ws.syncUserSshConfig(t)}function tryLan(e,t=800){return new Promise(r=>{let o=e.length,s=!1;if(!o)return r(null);for(const n of e){const e=net.connect({host:n.address,port:n.port}),a=setTimeout(()=>e.destroy(),t);e.once("connect",()=>{clearTimeout(a),s?e.destroy():(s=!0,r(e))}),e.once("close",()=>{clearTimeout(a),0!==--o||s||r(null)}),e.on("error",()=>{})}})}async function proxy(e,t,{stdin:r=process.stdin,stdout:o=process.stdout,stderr:s=process.stderr}={}){if(!SHARE_ID.test(e))throw ws.workspaceError(`bad share id "${e}"`);let n;try{n=JSON.parse(fs.readFileSync(path.join(ws.joinedDir(t),e,"join.json"),"utf8"))}catch(t){throw ws.workspaceError(`not joined to share ${e}; run: thinknsh workspace join CODE`)}const a=process.env.TNC_SHARE_RELAY_ONLY?null:await tryLan(n.lan.filter(e=>isPrivate(e.address)));if(a)return r.pipe(a),a.pipe(o),await new Promise(e=>a.on("close",e)),0;const{socket:i}=await connectPlatform(),c=crypto.randomBytes(12).toString("hex");await request(i,"workspace:open",{shareId:e,streamId:c}).catch(e=>{throw s.write(`thinknsh: ${e.message} (is the owner still sharing? ask them to run thinknsh workspace share again)\n`),e});const{Duplex:d}=require("stream");return await new Promise(e=>{const t=new d({read(){},write(e,t,r){o.write(e,r)}});r.on("data",e=>t.push(e)),r.on("end",()=>t.push(null)),bridge(i,c,t,e)}),i.close(),0}module.exports={share,join,leave,proxy,parseCode,newCode,fingerprint,proofFor,isPrivate,lanAddresses,clean,safeFolder,addGuestKey,guestKeyLine,guestKeys,guestContainer,guestHome,joinHost,bridge,removeGuestContainer};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),net=require("net"),path=require("path"),crypto=require("crypto"),{spawnSync}=require("child_process"),{BUILD_FILE}=require("../api/wire"),USER="tnc",SSH_PORT_IN=2222,FIRST_HOST_PORT=22220,LABEL="tnc.workspace",DEFAULT_AGENTS=["@anthropic-ai/claude-code","@openai/codex"],AGENT_ALIASES={claude:"@anthropic-ai/claude-code",codex:"@openai/codex",gemini:"@google/gemini-cli"},IMAGE_RECIPE=`FROM node:22-bookworm-slim\nARG AGENTS=""\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends openssh-server git ca-certificates curl wget less procps ripgrep python3 python3-venv openssh-client \\\n && rm -rf /var/lib/apt/lists/* \\\n && groupmod -n ${USER} node && usermod -l ${USER} -d /home/${USER} -m -s /bin/bash node \\\n && mkdir -p /run/sshd /etc/tnc-workspace\nRUN find / -xdev -perm /6000 -type f -exec chmod a-s {} + ; true\nRUN if [ -n "$AGENTS" ]; then npm install -g $AGENTS && npm cache clean --force; fi\nCOPY profile.sh /etc/profile.d/tnc-workspace.sh\nCOPY sshd_config /etc/tnc-workspace/sshd_config\nCOPY entrypoint.sh /usr/local/bin/tnc-workspace-entry\nRUN chmod 755 /usr/local/bin/tnc-workspace-entry && chmod 644 /etc/tnc-workspace/sshd_config\nENV TNC_WORKSPACE=1 NPM_CONFIG_PREFIX=/home/${USER}/.local PATH=/home/${USER}/.local/bin:$PATH\nWORKDIR /work\nEXPOSE 2222\nCMD ["/usr/local/bin/tnc-workspace-entry"]\n`,PROFILE='export NPM_CONFIG_PREFIX="$HOME/.local"\nexport PATH="$HOME/.local/bin:$PATH"\nexport TNC_WORKSPACE=1\ncd /work 2>/dev/null || true\n',SSHD_CONFIG=`Port 2222\nHostKey /etc/tnc-workspace/ssh_host_ed25519_key\nPidFile /tmp/tnc-sshd.pid\nAuthorizedKeysFile /etc/tnc-workspace/authorized_keys\nAllowUsers ${USER}\nPubkeyAuthentication yes\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nPermitEmptyPasswords no\nUsePAM no\nStrictModes yes\nPermitUserEnvironment no\nAllowAgentForwarding no\nAllowTcpForwarding local\nGatewayPorts no\nPermitTunnel no\nX11Forwarding no\nAcceptEnv LANG LC_*\nSetEnv TNC_WORKSPACE=1 NPM_CONFIG_PREFIX=/home/${USER}/.local PATH=/home/${USER}/.local/bin:/usr/local/bin:/usr/bin:/bin\nSubsystem sftp internal-sftp\nPrintMotd no\n# loopback only: editors and probes open many short connections; don't drop new ones early\nMaxStartups 100:30:200\nLoginGraceTime 30\n`,DROP=`setpriv --reuid=${USER} --regid=${USER} --init-groups --inh-caps=-all --bounding-set=-all env HOME=/home/${USER} USER=${USER} LOGNAME=${USER}`,ENTRYPOINT=`#!/bin/sh\nset -eu\nsrc=/run/tnc-workspace\n[ -f "$src/ssh_host_ed25519_key" ] && [ -f "$src/authorized_keys" ] || { echo "tnc workspace: keys missing in $src" >&2; exit 1; }\ninstall -o ${USER} -g ${USER} -m 600 "$src/ssh_host_ed25519_key" /etc/tnc-workspace/ssh_host_ed25519_key\ninstall -o root -g root -m 644 "$src/authorized_keys" /etc/tnc-workspace/authorized_keys\n[ "$(stat -c %U /home/${USER})" = "${USER}" ] || chown ${USER}:${USER} /home/${USER}\nchmod 700 /home/${USER}\nexec ${DROP} /usr/sbin/sshd -D -e -f /etc/tnc-workspace/sshd_config\n`;function workspaceError(e){const n=new Error(e);return n.workspace=!0,n}function workspaceName(e){const n=String(e||"").toLowerCase().replace(/[^a-z0-9_.-]+/g,"-").replace(/^[-_.]+|[-_.]+$/g,"").slice(0,40);if(!n)throw workspaceError(`cannot make a workspace name from "${e}"; pass --name`);return n}const containerName=e=>`tnc-ws-${e}`,homeVolume=e=>`tnc-ws-${e}-home`,sshHost=e=>`tnc-${e}`;function agentPackages(e){return null==e||""===e?DEFAULT_AGENTS:"none"===e?[]:[...new Set(String(e).split(/[\s,]+/).filter(Boolean).map(e=>AGENT_ALIASES[e]||e))].map(e=>{if(!/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.^~<>=-]+)?$/i.test(e))throw workspaceError(`invalid agent package "${e}"`);return e})}function imageTag(e){return`tnc-workspace:${crypto.createHash("sha256").update(IMAGE_RECIPE).update(SSHD_CONFIG).update(ENTRYPOINT).update(PROFILE).update(e.join(" ")).digest("hex").slice(0,12)}`}function writeImageContext(e){fs.mkdirSync(e,{recursive:!0}),fs.writeFileSync(path.join(e,BUILD_FILE),IMAGE_RECIPE),fs.writeFileSync(path.join(e,"sshd_config"),SSHD_CONFIG),fs.writeFileSync(path.join(e,"profile.sh"),PROFILE),fs.writeFileSync(path.join(e,"entrypoint.sh"),ENTRYPOINT,{mode:493})}function workspacesDir(e){return path.join(e,"workspaces")}function stateDir(e,n){return path.join(workspacesDir(e),n)}function readMeta(e,n){try{return JSON.parse(fs.readFileSync(path.join(stateDir(e,n),"workspace.json"),"utf8"))}catch(e){return null}}function listMeta(e){let n=[];try{n=fs.readdirSync(workspacesDir(e))}catch(e){}return n.map(n=>readMeta(e,n)).filter(Boolean).sort((e,n)=>e.name.localeCompare(n.name))}function keygen(e,n){const t=spawnSync("ssh-keygen",["-q","-t","ed25519","-N","","-C",n,"-f",e],{encoding:"utf8"});if(t.error)throw workspaceError("ssh-keygen was not found; install OpenSSH (it ships with macOS, Linux and Windows 10+)");if(0!==t.status)throw workspaceError(`ssh-keygen failed: ${(t.stderr||"").trim()}`)}function ensureKeys(e,n,t){const o=stateDir(e,n),r=path.join(o,"client"),s=path.join(o,"server");for(const n of[workspacesDir(e),o,r,s])fs.mkdirSync(n,{recursive:!0,mode:448});for(const e of[o,r,s])fs.chmodSync(e,448);const i=path.join(r,"id_ed25519"),a=path.join(s,"ssh_host_ed25519_key");fs.existsSync(i)||keygen(i,`thinknsh-workspace-${n}`),fs.existsSync(a)||keygen(a,`tnc-workspace-host-${n}`);const c=fs.readFileSync(`${i}.pub`,"utf8").trim();fs.writeFileSync(path.join(s,"authorized_keys"),`no-agent-forwarding,no-X11-forwarding ${c}\n`,{mode:384}),fs.rmSync(`${a}.pub`,{force:!0});const p=spawnSync("ssh-keygen",["-y","-f",a],{encoding:"utf8"}).stdout.trim(),h=path.join(o,"known_hosts");return fs.writeFileSync(h,`[127.0.0.1]:${t} ${p}\n`,{mode:384}),{dir:o,id:i,server:s,knownHosts:h}}function saveMeta(e,n){const t=path.join(stateDir(e,n.name),"workspace.json");fs.writeFileSync(t,JSON.stringify(n,null,2)+"\n",{mode:384})}function removeState(e,n){fs.rmSync(stateDir(e,n),{recursive:!0,force:!0})}const quote=e=>/[\s"]/.test(e)?`"${e.replace(/"/g,'\\"')}"`:e;function hostEntry(e,n){const t=stateDir(n,e.name);return[`Host ${sshHost(e.name)}`," HostName 127.0.0.1",` Port ${e.port}`,` User ${USER}`,` IdentityFile ${quote(path.join(t,"client","id_ed25519"))}`," IdentitiesOnly yes",` UserKnownHostsFile ${quote(path.join(t,"known_hosts"))}`," StrictHostKeyChecking yes"," ForwardAgent no"," ForwardX11 no",""].join("\n")}function writeSshConfig(e){const n=path.join(workspacesDir(e),"ssh_config");fs.mkdirSync(path.dirname(n),{recursive:!0,mode:448});const t=["# thinknsh workspaces; generated, edits are overwritten","",...listMeta(e).map(n=>hostEntry(n,e))].join("\n");return fs.writeFileSync(n,t,{mode:384}),n}function userSshConfig(){return process.env.TNC_SSH_CONFIG||path.join(os.homedir(),".ssh","config")}const INCLUDE_MARK="# thinknsh workspaces (thinknsh workspace up)",BLOCK_START="# >>> thinknsh workspaces (managed by thinknsh workspace; edits here are overwritten) >>>",BLOCK_END="# <<< thinknsh workspaces <<<";function syncUserSshConfig(e,n=userSshConfig()){let t="";try{t=fs.readFileSync(n,"utf8")}catch(e){if("ENOENT"!==e.code)throw e}const o=`Include ${quote(path.join(workspacesDir(e),"ssh_config"))}`,r=t.split(/\r?\n/),s=[];let i=!1;for(const e of r)e.trim()!==BLOCK_START?e.trim()!==BLOCK_END?i||e.trim()===INCLUDE_MARK||e.trim()===o||s.push(e):i=!1:i=!0;for(;s.length&&""===s[s.length-1].trim();)s.pop();for(;s.length&&""===s[0].trim();)s.shift();const a=listMeta(e).map(n=>hostEntry(n,e).trimEnd()),c=a.length?[BLOCK_START,...a.flatMap(e=>[e,""]),BLOCK_END]:[],p=[...s,...s.length&&c.length?[""]:[],...c].join("\n")+(s.length||c.length?"\n":"");if(p===t)return!1;fs.mkdirSync(path.dirname(n),{recursive:!0,mode:448}),fs.writeFileSync(n,p,{mode:384});try{fs.chmodSync(n,384)}catch(e){}return!0}function folderMounts(e){if(!e.length)throw workspaceError("no project folder given");if(1===e.length)return[{host:e[0],target:"/work"}];const n=new Map;return e.map(e=>{const t=path.basename(e);if(!/^[\w.@+-]+$/.test(t))throw workspaceError(`folder name "${t}" has characters that cannot be a path in the workspace; rename or pick another folder`);if(n.has(t))throw workspaceError(`two folders are both named "${t}" (${n.get(t)} and ${e}); use separate workspaces`);return n.set(t,e),{host:e,target:`/work/${t}`}})}function writeCodeWorkspace(e,n,t){const o=path.join(e,`${n}.code-workspace`),r={folders:t.map(e=>({name:path.basename(e.host),path:e.target})),settings:{}};return fs.writeFileSync(o,JSON.stringify(r,null,2)+"\n",{mode:420}),`/run/tnc-workspace/${n}.code-workspace`}function networkChoice(e,{network:n=[],compose:t,noCompose:o=!1}={}){let r=e?.networks||[];if(n.length){const e=n.flatMap(e=>String(e).split(",")).map(e=>e.trim()).filter(Boolean);r=e.includes("none")?[]:[...new Set(e)];for(const e of r){if(!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(e))throw workspaceError(`invalid network name "${e}"`);if(["host","none","null"].includes(e))throw workspaceError(`a workspace cannot join the "${e}" network`)}}let s=e&&"compose"in e?e.compose:"auto";if(o&&void 0!==t)throw workspaceError("--compose and --no-compose together");return o?s=!1:void 0!==t&&(s=""===t?"auto":t),{networks:r,compose:s}}function wantedNetworks(e,n=[]){return[...new Set([...e,...n.map(e=>e.name)])]}function portFree(e){return new Promise(n=>{const t=net.createServer();t.once("error",()=>n(!1)),t.listen(e,"127.0.0.1",()=>t.close(()=>n(!0)))})}async function pickPort(e=[]){for(let n=22220;n<22720;n++)if(!e.includes(n)&&await portFree(n))return n;throw workspaceError("no free port in 22220-22719; pass --port")}function containerBody({name:e,image:n,mounts:t,serverDir:o,port:r,memory:s,nanoCpus:i,pidsLimit:a=4096}){return{Image:n,Hostname:`tnc-${e}`.slice(0,63),Labels:{[LABEL]:e,[`${LABEL}.path`]:t.map(e=>e.host).join("\n"),[`${LABEL}.port`]:String(r)},ExposedPorts:{"2222/tcp":{}},HostConfig:{Binds:[...t.map(e=>`${e.host}:${e.target}`),`${o}:/run/tnc-workspace:ro`,`${homeVolume(e)}:/home/${USER}`],PortBindings:{"2222/tcp":[{HostIp:"127.0.0.1",HostPort:String(r)}]},Memory:s,NanoCpus:i,PidsLimit:a,CapDrop:["ALL"],CapAdd:["CHOWN","DAC_OVERRIDE","FOWNER","SETUID","SETGID","SETPCAP"],SecurityOpt:["no-new-privileges"],RestartPolicy:{Name:"unless-stopped"}}}}module.exports={USER,SSH_PORT_IN:2222,FIRST_HOST_PORT:22220,LABEL,DEFAULT_AGENTS,IMAGE_RECIPE,SSHD_CONFIG,ENTRYPOINT,PROFILE,DROP,workspaceName,containerName,homeVolume,sshHost,agentPackages,imageTag,writeImageContext,stateDir,readMeta,listMeta,saveMeta,removeState,ensureKeys,hostEntry,writeSshConfig,syncUserSshConfig,userSshConfig,INCLUDE_MARK,BLOCK_START,BLOCK_END,folderMounts,writeCodeWorkspace,networkChoice,wantedNetworks,pickPort,portFree,containerBody,workspaceError};
7
+ "use strict";const fs=require("fs"),os=require("os"),net=require("net"),path=require("path"),crypto=require("crypto"),{spawnSync}=require("child_process"),{BUILD_FILE}=require("../api/wire"),USER="tnc",SSH_PORT_IN=2222,FIRST_HOST_PORT=22220,LABEL="tnc.workspace",DEFAULT_AGENTS=["@anthropic-ai/claude-code","@openai/codex"],AGENT_ALIASES={claude:"@anthropic-ai/claude-code",codex:"@openai/codex",gemini:"@google/gemini-cli"},IMAGE_RECIPE=`FROM node:22-bookworm-slim\nARG AGENTS=""\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends openssh-server git ca-certificates curl wget less procps ripgrep python3 python3-venv openssh-client \\\n && rm -rf /var/lib/apt/lists/* \\\n && groupmod -n ${USER} node && usermod -l ${USER} -d /home/${USER} -m -s /bin/bash node \\\n && mkdir -p /run/sshd /etc/tnc-workspace\nRUN find / -xdev -perm /6000 -type f -exec chmod a-s {} + ; true\nRUN if [ -n "$AGENTS" ]; then npm install -g $AGENTS && npm cache clean --force; fi\nCOPY profile.sh /etc/profile.d/tnc-workspace.sh\nCOPY sshd_config /etc/tnc-workspace/sshd_config\nCOPY entrypoint.sh /usr/local/bin/tnc-workspace-entry\nRUN chmod 755 /usr/local/bin/tnc-workspace-entry && chmod 644 /etc/tnc-workspace/sshd_config\nENV TNC_WORKSPACE=1 NPM_CONFIG_PREFIX=/home/${USER}/.local PATH=/home/${USER}/.local/bin:$PATH\nWORKDIR /work\nEXPOSE 2222\nCMD ["/usr/local/bin/tnc-workspace-entry"]\n`,PROFILE='export NPM_CONFIG_PREFIX="$HOME/.local"\nexport PATH="$HOME/.local/bin:$PATH"\nexport TNC_WORKSPACE=1\ncd /work 2>/dev/null || true\n',SSHD_CONFIG=`Port 2222\nHostKey /etc/tnc-workspace/ssh_host_ed25519_key\nPidFile /tmp/tnc-sshd.pid\nAuthorizedKeysFile /etc/tnc-workspace/authorized_keys\nAllowUsers ${USER}\nPubkeyAuthentication yes\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nPermitEmptyPasswords no\nUsePAM no\nStrictModes yes\nPermitUserEnvironment no\nAllowAgentForwarding no\nAllowTcpForwarding local\nGatewayPorts no\nPermitTunnel no\nX11Forwarding no\nAcceptEnv LANG LC_*\nSetEnv TNC_WORKSPACE=1 NPM_CONFIG_PREFIX=/home/${USER}/.local PATH=/home/${USER}/.local/bin:/usr/local/bin:/usr/bin:/bin\nSubsystem sftp internal-sftp\nPrintMotd no\n# loopback only: editors and probes open many short connections; don't drop new ones early\nMaxStartups 100:30:200\nLoginGraceTime 30\n`,DROP=`setpriv --reuid=${USER} --regid=${USER} --init-groups --inh-caps=-all --bounding-set=-all env HOME=/home/${USER} USER=${USER} LOGNAME=${USER}`,ENTRYPOINT=`#!/bin/sh\nset -eu\nsrc=/run/tnc-workspace\n[ -f "$src/ssh_host_ed25519_key" ] && [ -f "$src/authorized_keys" ] || { echo "tnc workspace: keys missing in $src" >&2; exit 1; }\ninstall -o ${USER} -g ${USER} -m 600 "$src/ssh_host_ed25519_key" /etc/tnc-workspace/ssh_host_ed25519_key\ninstall -o root -g root -m 644 "$src/authorized_keys" /etc/tnc-workspace/authorized_keys\n[ "$(stat -c %U /home/${USER})" = "${USER}" ] || chown ${USER}:${USER} /home/${USER}\nchmod 700 /home/${USER}\nexec ${DROP} /usr/sbin/sshd -D -e -f /etc/tnc-workspace/sshd_config\n`;function workspaceError(e){const n=new Error(e);return n.workspace=!0,n}function workspaceName(e){const n=String(e||"").toLowerCase().replace(/[^a-z0-9_.-]+/g,"-").replace(/^[-_.]+|[-_.]+$/g,"").slice(0,40);if(!n)throw workspaceError(`cannot make a workspace name from "${e}"; pass --name`);return n}const containerName=e=>`tnc-ws-${e}`,homeVolume=e=>`tnc-ws-${e}-home`,sshHost=e=>`tnc-${e}`;function agentPackages(e){return null==e||""===e?DEFAULT_AGENTS:"none"===e?[]:[...new Set(String(e).split(/[\s,]+/).filter(Boolean).map(e=>AGENT_ALIASES[e]||e))].map(e=>{if(!/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.^~<>=-]+)?$/i.test(e))throw workspaceError(`invalid agent package "${e}"`);return e})}function imageTag(e){return`tnc-workspace:${crypto.createHash("sha256").update(IMAGE_RECIPE).update(SSHD_CONFIG).update(ENTRYPOINT).update(PROFILE).update(e.join(" ")).digest("hex").slice(0,12)}`}function writeImageContext(e){fs.mkdirSync(e,{recursive:!0}),fs.writeFileSync(path.join(e,BUILD_FILE),IMAGE_RECIPE),fs.writeFileSync(path.join(e,"sshd_config"),SSHD_CONFIG),fs.writeFileSync(path.join(e,"profile.sh"),PROFILE),fs.writeFileSync(path.join(e,"entrypoint.sh"),ENTRYPOINT,{mode:493})}function workspacesDir(e){return path.join(e,"workspaces")}function stateDir(e,n){return path.join(workspacesDir(e),n)}function readMeta(e,n){try{return JSON.parse(fs.readFileSync(path.join(stateDir(e,n),"workspace.json"),"utf8"))}catch(e){return null}}function listMeta(e){let n=[];try{n=fs.readdirSync(workspacesDir(e))}catch(e){}return n.map(n=>readMeta(e,n)).filter(Boolean).sort((e,n)=>e.name.localeCompare(n.name))}function keygen(e,n){const t=spawnSync("ssh-keygen",["-q","-t","ed25519","-N","","-C",n,"-f",e],{encoding:"utf8"});if(t.error)throw workspaceError("ssh-keygen was not found; install OpenSSH (it ships with macOS, Linux and Windows 10+)");if(0!==t.status)throw workspaceError(`ssh-keygen failed: ${(t.stderr||"").trim()}`)}function ensureKeys(e,n,t){const o=stateDir(e,n),r=path.join(o,"client"),s=path.join(o,"server");for(const n of[workspacesDir(e),o,r,s])fs.mkdirSync(n,{recursive:!0,mode:448});for(const e of[o,r,s])fs.chmodSync(e,448);const i=path.join(r,"id_ed25519"),a=path.join(s,"ssh_host_ed25519_key");fs.existsSync(i)||keygen(i,`thinknsh-workspace-${n}`),fs.existsSync(a)||keygen(a,`tnc-workspace-host-${n}`);const c=fs.readFileSync(`${i}.pub`,"utf8").trim();fs.writeFileSync(path.join(s,"authorized_keys"),`no-agent-forwarding,no-X11-forwarding ${c}\n`,{mode:384}),fs.rmSync(`${a}.pub`,{force:!0});const p=spawnSync("ssh-keygen",["-y","-f",a],{encoding:"utf8"}).stdout.trim(),h=path.join(o,"known_hosts");return fs.writeFileSync(h,`[127.0.0.1]:${t} ${p}\n`,{mode:384}),{dir:o,id:i,server:s,knownHosts:h}}function saveMeta(e,n){const t=path.join(stateDir(e,n.name),"workspace.json");fs.writeFileSync(t,JSON.stringify(n,null,2)+"\n",{mode:384})}function removeState(e,n){fs.rmSync(stateDir(e,n),{recursive:!0,force:!0})}const quote=e=>/[\s"]/.test(e)?`"${e.replace(/"/g,'\\"')}"`:e;function hostEntry(e,n){const t=stateDir(n,e.name);return[`Host ${sshHost(e.name)}`," HostName 127.0.0.1",` Port ${e.port}`,` User ${USER}`,` IdentityFile ${quote(path.join(t,"client","id_ed25519"))}`," IdentitiesOnly yes",` UserKnownHostsFile ${quote(path.join(t,"known_hosts"))}`," StrictHostKeyChecking yes"," ForwardAgent no"," ForwardX11 no",""].join("\n")}function joinedDir(e){return path.join(workspacesDir(e),"joined")}function listJoined(e){let n=[];try{n=fs.readdirSync(joinedDir(e))}catch(e){}return n.map(n=>{try{return JSON.parse(fs.readFileSync(path.join(joinedDir(e),n,"join.json"),"utf8"))}catch(e){return null}}).filter(Boolean).sort((e,n)=>e.host.localeCompare(n.host))}function joinedEntry(e,n){const t=path.join(joinedDir(n),e.shareId);return[`Host ${e.host}`,` HostName ${e.host}`,` User ${USER}`,` ProxyCommand ${quote(process.execPath)} ${quote(e.cli)} workspace proxy ${e.shareId}`,` IdentityFile ${quote(path.join(t,"id_ed25519"))}`," IdentitiesOnly yes",` UserKnownHostsFile ${quote(path.join(t,"known_hosts"))}`,` HostKeyAlias ${e.host}`," StrictHostKeyChecking yes"," ForwardAgent no"," ForwardX11 no",""].join("\n")}const allEntries=e=>[...listMeta(e).map(n=>hostEntry(n,e)),...listJoined(e).map(n=>joinedEntry(n,e))];function writeSshConfig(e){const n=path.join(workspacesDir(e),"ssh_config");fs.mkdirSync(path.dirname(n),{recursive:!0,mode:448});const t=["# thinknsh workspaces; generated, edits are overwritten","",...allEntries(e)].join("\n");return fs.writeFileSync(n,t,{mode:384}),n}function userSshConfig(){return process.env.TNC_SSH_CONFIG||path.join(os.homedir(),".ssh","config")}const INCLUDE_MARK="# thinknsh workspaces (thinknsh workspace up)",BLOCK_START="# >>> thinknsh workspaces (managed by thinknsh workspace; edits here are overwritten) >>>",BLOCK_END="# <<< thinknsh workspaces <<<";function syncUserSshConfig(e,n=userSshConfig()){let t="";try{t=fs.readFileSync(n,"utf8")}catch(e){if("ENOENT"!==e.code)throw e}const o=`Include ${quote(path.join(workspacesDir(e),"ssh_config"))}`,r=t.split(/\r?\n/),s=[];let i=!1;for(const e of r)e.trim()!==BLOCK_START?e.trim()!==BLOCK_END?i||e.trim()===INCLUDE_MARK||e.trim()===o||s.push(e):i=!1:i=!0;for(;s.length&&""===s[s.length-1].trim();)s.pop();for(;s.length&&""===s[0].trim();)s.shift();const a=allEntries(e).map(e=>e.trimEnd()),c=a.length?[BLOCK_START,...a.flatMap(e=>[e,""]),BLOCK_END]:[],p=[...s,...s.length&&c.length?[""]:[],...c].join("\n")+(s.length||c.length?"\n":"");if(p===t)return!1;fs.mkdirSync(path.dirname(n),{recursive:!0,mode:448}),fs.writeFileSync(n,p,{mode:384});try{fs.chmodSync(n,384)}catch(e){}return!0}function folderMounts(e){if(!e.length)throw workspaceError("no project folder given");if(1===e.length)return[{host:e[0],target:"/work"}];const n=new Map;return e.map(e=>{const t=path.basename(e);if(!/^[\w.@+-]+$/.test(t))throw workspaceError(`folder name "${t}" has characters that cannot be a path in the workspace; rename or pick another folder`);if(n.has(t))throw workspaceError(`two folders are both named "${t}" (${n.get(t)} and ${e}); use separate workspaces`);return n.set(t,e),{host:e,target:`/work/${t}`}})}function writeCodeWorkspace(e,n,t){const o=path.join(e,`${n}.code-workspace`),r={folders:t.map(e=>({name:path.basename(e.host),path:e.target})),settings:{}};return fs.writeFileSync(o,JSON.stringify(r,null,2)+"\n",{mode:420}),`/run/tnc-workspace/${n}.code-workspace`}function networkChoice(e,{network:n=[],compose:t,noCompose:o=!1}={}){let r=e?.networks||[];if(n.length){const e=n.flatMap(e=>String(e).split(",")).map(e=>e.trim()).filter(Boolean);r=e.includes("none")?[]:[...new Set(e)];for(const e of r){if(!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(e))throw workspaceError(`invalid network name "${e}"`);if(["host","none","null"].includes(e))throw workspaceError(`a workspace cannot join the "${e}" network`)}}let s=e&&"compose"in e?e.compose:"auto";if(o&&void 0!==t)throw workspaceError("--compose and --no-compose together");return o?s=!1:void 0!==t&&(s=""===t?"auto":t),{networks:r,compose:s}}function wantedNetworks(e,n=[]){return[...new Set([...e,...n.map(e=>e.name)])]}function portFree(e){return new Promise(n=>{const t=net.createServer();t.once("error",()=>n(!1)),t.listen(e,"127.0.0.1",()=>t.close(()=>n(!0)))})}async function pickPort(e=[]){for(let n=22220;n<22720;n++)if(!e.includes(n)&&await portFree(n))return n;throw workspaceError("no free port in 22220-22719; pass --port")}function containerBody({name:e,image:n,mounts:t,serverDir:o,port:r,memory:s,nanoCpus:i,pidsLimit:a=4096}){return{Image:n,Hostname:`tnc-${e}`.slice(0,63),Labels:{[LABEL]:e,[`${LABEL}.path`]:t.map(e=>e.host).join("\n"),[`${LABEL}.port`]:String(r)},ExposedPorts:{"2222/tcp":{}},HostConfig:{Binds:[...t.map(e=>`${e.host}:${e.target}`),`${o}:/run/tnc-workspace:ro`,`${homeVolume(e)}:/home/${USER}`],PortBindings:{"2222/tcp":[{HostIp:"127.0.0.1",HostPort:String(r)}]},Memory:s,NanoCpus:i,PidsLimit:a,CapDrop:["ALL"],CapAdd:["CHOWN","DAC_OVERRIDE","FOWNER","SETUID","SETGID","SETPCAP"],SecurityOpt:["no-new-privileges"],RestartPolicy:{Name:"unless-stopped"}}}}module.exports={USER,SSH_PORT_IN:2222,FIRST_HOST_PORT:22220,LABEL,DEFAULT_AGENTS,IMAGE_RECIPE,SSHD_CONFIG,ENTRYPOINT,PROFILE,DROP,workspaceName,containerName,homeVolume,sshHost,agentPackages,imageTag,writeImageContext,stateDir,readMeta,listMeta,saveMeta,removeState,ensureKeys,keygen,quote,joinedDir,listJoined,joinedEntry,hostEntry,writeSshConfig,syncUserSshConfig,userSshConfig,INCLUDE_MARK,BLOCK_START,BLOCK_END,folderMounts,writeCodeWorkspace,networkChoice,wantedNetworks,pickPort,portFree,containerBody,workspaceError};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkncollab-shell",
3
- "version": "2.0.0-beta.4",
3
+ "version": "2.0.0-beta.6",
4
4
  "description": "Lightweight MicroVM Sandbox & Zero-Daemon Dev Runner: Fast MicroVM Containers, Native Microservices, Live Database Snapshots, and End-to-End Encrypted State Sync",
5
5
  "main": "dist/src/core/shell.js",
6
6
  "bin": {
@@ -1,7 +0,0 @@
1
- /**
2
- * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
- * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
- * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
- * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
- */
7
- const{execSync,spawnSync}=require("child_process"),fs=require("fs-extra"),path=require("path"),os=require("os"),chalk=require("chalk"),HOST_ARCH=(()=>{const s=process.arch;return"arm64"===s||"aarch64"===s?"arm64":"amd64"})(),HOST_PLATFORM=process.platform,QEMU_BINARIES={amd64:"qemu-x86_64-static",arm64:"qemu-aarch64-static",arm:"qemu-arm-static",386:"qemu-i386-static",ppc64le:"qemu-ppc64le-static",s390x:"qemu-s390x-static"},BINFMT_MAGIC={amd64:{magic:"\\x7fELF\\x02\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x3e\\x00",mask:"\\xff\\xff\\xff\\xff\\xff\\xfe\\xfe\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff",offset:0},arm64:{magic:"\\x7fELF\\x02\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\xb7\\x00",mask:"\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff",offset:0},arm:{magic:"\\x7fELF\\x01\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x28\\x00",mask:"\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff",offset:0}};class ArchShim{constructor(){this.hostArch=HOST_ARCH,this.platform=HOST_PLATFORM}detectImageArch(s){try{const t=path.join(s,"config.json");if(fs.existsSync(t)){const s=fs.readJsonSync(t),r=s.architecture||s.config?.architecture;if(r)return"amd64"===r||"x86_64"===r?"amd64":"arm64"===r||"aarch64"===r?"arm64":r}}catch(s){}return"amd64"}needsTranslation(s){return s!==this.hostArch}hasRosetta2(){if("darwin"!==this.platform)return!1;try{return execSync("/usr/bin/arch -x86_64 /usr/bin/true 2>/dev/null",{stdio:"pipe"}),!0}catch(s){return!1}}async ensureRosetta2(){if(this.hasRosetta2())return!0;console.log(chalk.yellow(" [Arch] Rosetta 2 not installed. Installing..."));try{return execSync("softwareupdate --install-rosetta --agree-to-license",{stdio:"inherit"}),!0}catch(s){return console.log(chalk.red(" [Arch] Rosetta 2 install failed. Run: softwareupdate --install-rosetta --agree-to-license")),!1}}hasBinfmt(s){const t=`/proc/sys/fs/binfmt_misc/qemu-${s}`;return fs.existsSync(t)}findQEMU(s){const t=QEMU_BINARIES[s];if(!t)return null;const r=[`/usr/bin/${t}`,`/usr/local/bin/${t}`,`/usr/static/${t}`];for(const s of r)if(fs.existsSync(s))return s;return null}registerBinfmt(s){if("darwin"===this.platform||"win32"===this.platform)return{success:!1,reason:"binfmt_misc only for Linux"};const t=this.findQEMU(s);if(!t)return{success:!1,reason:"QEMU binary not found. Install: sudo apt-get install qemu-user-static"};if(this.hasBinfmt(s))return{success:!0,cached:!0};const r=BINFMT_MAGIC[s];if(!r)return{success:!1,reason:`No binfmt magic for arch: ${s}`};try{const e=`:qemu-${s}:M::${r.magic}:${r.mask}:${t}:F`;return execSync(`echo '${e}' > /proc/sys/fs/binfmt_misc/register`,{stdio:"pipe"}),{success:!0}}catch(s){return{success:!1,reason:`binfmt_misc register failed (need root): ${s.message}`}}}async getExecWrapper(s){if(!this.needsTranslation(s))return{wrapper:[],archInfo:`native (${this.hostArch})`,needsTranslation:!1};if(console.log(chalk.yellow(` [Arch] Image: ${s} | Host: ${this.hostArch} → Cross-arch translation needed`)),"darwin"===this.platform&&"arm64"===this.hostArch&&"amd64"===s&&await this.ensureRosetta2())return console.log(chalk.green(" [Arch] Using Rosetta 2 translation ✓")),{wrapper:["/usr/bin/arch","-x86_64"],archInfo:"Rosetta 2 (arm64→x86_64)",needsTranslation:!0};if("darwin"===this.platform&&"amd64"===this.hostArch&&"arm64"===s){const s=this.findQEMU("arm64")||"/opt/homebrew/bin/qemu-aarch64";if(fs.existsSync(s))return console.log(chalk.green(" [Arch] Using QEMU for arm64 on Intel Mac ✓")),{wrapper:[s],archInfo:"QEMU (x86_64→arm64)",needsTranslation:!0};console.log(chalk.yellow(" [Arch] QEMU not found. Install: brew install qemu"))}if("linux"===this.platform){const t=this.registerBinfmt(s);if(t.success){const t=this.findQEMU(s);return console.log(chalk.green(` [Arch] binfmt_misc registered for ${s} ✓`)),{wrapper:t?[t]:[],archInfo:`binfmt_misc + QEMU (${this.hostArch}→${s})`,needsTranslation:!0,requiresBinfmt:!0}}console.log(chalk.yellow(` [Arch] binfmt_misc failed: ${t.reason}`))}return"win32"===this.platform?(console.log(chalk.dim(" [Arch] Windows: cross-arch via WSL2 binfmt_misc")),{wrapper:["wsl","--"],archInfo:`WSL2 binfmt_misc (${s})`,needsTranslation:!0}):(console.log(chalk.yellow(` [Arch] WARNING: No translator found for ${this.hostArch}→${s}. Binary may fail.`)),{wrapper:[],archInfo:`unknown (${s})`,needsTranslation:!0,warned:!0})}injectQEMU(s,t){const r=this.findQEMU(t);if(!r||!fs.existsSync(r))return!1;const e=path.join(s,"usr","bin"),n=path.join(e,path.basename(r));try{return fs.ensureDirSync(e),fs.copyFileSync(r,n),fs.chmodSync(n,493),console.log(chalk.dim(` [Arch] Injected ${path.basename(r)} into rootfs`)),!0}catch(s){return!1}}status(){const s={hostArch:this.hostArch,platform:this.platform,rosetta2:"darwin"===this.platform?this.hasRosetta2():"N/A",binfmt:{}};if("linux"===this.platform)for(const t of["amd64","arm64","arm"])t!==this.hostArch&&(s.binfmt[t]=this.hasBinfmt(t));const t={};for(const[s,r]of Object.entries(QEMU_BINARIES)){const r=this.findQEMU(s);r&&(t[s]=r)}return s.qemu=t,s}}module.exports=ArchShim;
@@ -1,7 +0,0 @@
1
- /**
2
- * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
- * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
- * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
- * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
- */
7
- const fs=require("fs-extra"),path=require("path"),crypto=require("crypto"),chalk=require("chalk"),{execSync}=require("child_process"),ImageManager=require("./image-manager");class CapsuleBuilder{constructor(e=process.cwd()){this.contextDir=path.resolve(e),this.imageManager=new ImageManager,this.layersDir=path.join(this.imageManager.baseDir,"layers"),fs.ensureDirSync(this.layersDir),this.dockerignore=this._loadDockerignore()}_loadDockerignore(){const e=path.join(this.contextDir,".dockerignore");if(!fs.existsSync(e))return[];try{return fs.readFileSync(e,"utf8").split("\n").map(e=>e.trim()).filter(e=>e&&!e.startsWith("#"))}catch(e){return[]}}_isIgnored(e){if(!this.dockerignore||0===this.dockerignore.length)return!1;const t=path.basename(e);for(const s of this.dockerignore){if(s===t||s===e)return!0;if(s.endsWith("/*")&&e.startsWith(s.slice(0,-2)))return!0;if(s.startsWith("*")&&e.endsWith(s.slice(1)))return!0;if(e.includes(`/${s}/`)||e.startsWith(`${s}/`))return!0}return!1}parseSpecFile(e){if(!fs.existsSync(e))throw new Error(`Dockerfile not found at: ${e}`);const t=fs.readFileSync(e,"utf8").split("\n"),s=[];let i="";for(let e of t)if(e=e.trim(),e&&!e.startsWith("#"))if(e.endsWith("\\"))i+=e.slice(0,-1).trim()+" ";else{i+=e;const t=i.indexOf(" ");if(-1!==t){const e=i.slice(0,t).toUpperCase(),n=i.slice(t+1).trim();s.push({instruction:e,args:n,raw:i})}i=""}return s}computeStepHash(e,t,s){return crypto.createHash("sha256").update(`${e}:${t}:${s}`).digest("hex").slice(0,16)}interpolateVars(e,t={}){return e.replace(/\$\{?([a-zA-Z0-9_]+)\}?/g,(e,s)=>void 0!==t[s]?t[s]:e)}async build(e="latest",t="Capsulefile",s={}){let i=path.isAbsolute(t)?t:path.join(this.contextDir,t);if(!fs.existsSync(i)){const e=path.join(this.contextDir,"Dockerfile");fs.existsSync(e)&&(i=e)}const n=this.parseSpecFile(i);console.log(chalk.cyan(`\n[+] Building TNC Capsule from ${path.basename(i)}`)),console.log(chalk.dim(` Context: ${this.contextDir}`)),console.log(chalk.dim(` Total Steps: ${n.length}\n`));const r="img-"+crypto.randomBytes(4).toString("hex"),o={};let a=0,c=null,l=null,h="/",p={};const u=s.buildArgs||{};let g=[],f="node server.js",d="node",m="genesis",y=1;for(const e of n){const t={...u,...p},s=this.interpolateVars(e.args,t),i=this.computeStepHash(m,e.instruction,s),$=`[${y}/${n.length}]`;switch(e.instruction){case"ARG":{let[e,...t]=s.split("=");e=e.trim();const i=t.length>0?t.join("=").trim():"";void 0===u[e]&&(u[e]=i),console.log(chalk.dim(` => ${$} ARG ${e}=${u[e]}`));break}case"FROM":{let e=s;e.startsWith("--platform=")&&(e=e.split(/\s+/).slice(1).join(" "));const t=e.split(/\s+/),i=t[0];let n=null;if(t.length>=3&&"AS"===t[1].toUpperCase()&&(n=t[2]),l){const e=c||String(a);o[e]={workDir:l,env:{...p},workdir:h},c&&(o[String(a)]=o[e]),a++}c=n;const u=n?`stage-${n}`:`stage-${a}`;l=path.join(this.imageManager.baseDir,r,u),fs.ensureDirSync(l),h="/",p={},g=[],d=i.includes("python")?"python":i.includes("rust")?"rust":i.includes("golang")||i.includes("go")?"go":i.includes("cpp")||i.includes("gcc")||i.includes("clang")?"cpp":i.includes("nginx")?"nginx":"node";const f=n?` AS ${n}`:"";if(console.log(chalk.blue(` => ${$} FROM ${chalk.bold(i)}${f}`)),o[i])console.log(chalk.dim(` Using previous stage "${i}" as base...`)),fs.copySync(o[i].workDir,l),p={...o[i].env},h=o[i].workdir;else if("scratch"!==i&&"base"!==i)try{const e=new(require("./oci-puller"));let t=e.inspect(i);if(t||(console.log(chalk.dim(` Pulling base OCI image: ${i}...`)),t=(await e.pull(i)).meta),t&&t.rootfsDir&&fs.existsSync(t.rootfsDir)&&(fs.copySync(t.rootfsDir,l),t.env&&Array.isArray(t.env)))for(const e of t.env){const[t,...s]=e.split("=");p[t]=s.join("=")}}catch(e){console.log(chalk.dim(` Notice: Base OCI pull skipped (${e.message}). Using clean rootfs.`))}break}case"WORKDIR":{h=s.trim();const e=path.join(l,h.replace(/^\//,""));fs.ensureDirSync(e),console.log(chalk.blue(` => ${$} WORKDIR ${h}`));break}case"ENV":{let e,t;if(s.includes("=")){const i=s.indexOf("=");e=s.slice(0,i).trim(),t=s.slice(i+1).trim()}else{const i=s.split(/\s+/);e=i[0],t=i.slice(1).join(" ")}p[e]=t,console.log(chalk.blue(` => ${$} ENV ${e}=${t}`));break}case"COPY":case"ADD":{let e=null,t=s;if(t.startsWith("--from=")){const s=t.indexOf(" ");-1!==s&&(e=t.slice(7,s).trim(),t=t.slice(s+1).trim())}const i=t.split(/\s+/),n=i[0],r=i[1]||".";let a=this.contextDir;if(e){const t=o[e];if(!t)throw new Error(`Stage "${e}" not found in multi-stage build`);a=t.workDir,console.log(chalk.cyan(` => ${$} COPY --from=${e} ${n} -> ${r}`))}else console.log(chalk.blue(` => ${$} COPY ${n} -> ${r}`));const c=path.isAbsolute(n)&&e?path.join(a,n.replace(/^\//,"")):path.join(a,n.replace(/\*/g,"")),p=r.replace(/^\.\//,"").replace(/^\//,"");let u=path.isAbsolute(r)?path.join(l,r.replace(/^\//,"")):path.join(l,h.replace(/^\//,""),p);fs.existsSync(c)&&fs.statSync(c).isFile()&&("."===r||r.endsWith("/")||fs.existsSync(u)&&fs.statSync(u).isDirectory())&&(u=path.join(u,path.basename(c))),fs.existsSync(c)?(fs.ensureDirSync(path.dirname(u)),fs.copySync(c,u,{filter:e=>{if(path.basename(e).startsWith(".git"))return!1;const t=path.relative(a,e);return!this._isIgnored(t)}}),console.log(chalk.green(` [Copied] ${c} -> ${u}`))):console.log(chalk.red(` [Notice] Source path NOT FOUND: ${c}`));break}case"RUN":{let e=s;const t=[];for(;e.startsWith("--mount=");){const s=e.indexOf(" ");if(-1===s)break;const i=e.slice(0,s);t.push(i),e=e.slice(s+1).trim()}for(const e of t)if(e.includes("type=cache")&&e.includes("target=")){const t=e.match(/target=([^,]+)/);if(t&&t[1]){const e=path.join(l,t[1].replace(/^\//,""));fs.ensureDirSync(e)}}const i=path.join(l,h.replace(/^\//,""));fs.ensureDirSync(i),console.log(chalk.yellow(` => ${$} RUN ${chalk.white(e)}`));let n=!1;const{getMicroVMManager:r,sendCommand:o}=require("./microvm-manager"),a=r();if(!a.isTncVzAvailable()||!a.isVMRunning())throw new Error(`[Security Policy] Linux MicroVM is not running. Executing Dockerfile instruction "RUN ${e}" directly on macOS host is blocked for system safety.`);const c=`build-${Date.now().toString(36)}`;try{const t=await a.start(c,{cwd:l,cmd:`cd "${h}" && ${e}`,env:p});if(!t||!t.success)throw new Error(`Failed to initiate build step in MicroVM: ${s}`);const i=12e4,r=Date.now();let u=!0;for(;u&&Date.now()-r<i;){await new Promise(e=>setTimeout(e,600));try{const e=await o({cmd:"ps"},3e3);if(e&&e.ok&&Array.isArray(e.containers)){const t=e.containers.find(e=>e.id===c);u=Boolean(t&&t.running)}else u=!1}catch(e){u=!1}}if(u)throw await a.stop(c).catch(()=>{}),new Error(`Build step timed out after 120s: ${s}`);try{await a.commit(c)}catch(e){console.warn(chalk.yellow(` [Build Commit notice] ${e.message}`))}await a.stop(c).catch(()=>{}),n=!0}catch(e){throw await a.stop(c).catch(()=>{}),console.error(chalk.red(`[FAIL] Build step failed: ${e.message}`)),e}break}case"USER":console.log(chalk.dim(` => ${$} USER ${s}`));break;case"EXPOSE":{const e=parseInt(s.trim(),10);isNaN(e)||g.push(e),console.log(chalk.blue(` => ${$} EXPOSE ${s}`));break}case"CMD":case"ENTRYPOINT":{let t=s.trim();if(t.startsWith("[")&&t.endsWith("]"))try{t=JSON.parse(t).join(" ")}catch(e){}f=t,console.log(chalk.green(` => ${$} ${e.instruction} ${f}`));break}default:console.log(chalk.dim(` => ${$} [Ignored ${e.instruction}] ${s}`))}m=i,y++}const $=path.join(this.imageManager.baseDir,r,"rootfs");l!==$&&(fs.existsSync($)&&fs.removeSync($),fs.moveSync(l,$,{overwrite:!0}));for(let e=0;e<=a;e++){const t=path.join(this.imageManager.baseDir,r,`stage-${e}`);try{fs.removeSync(t)}catch(e){}}for(const e of Object.keys(o))if(o[e]?.workDir&&o[e].workDir!==$)try{fs.removeSync(o[e].workDir)}catch(e){}const k=e.includes(":")?e:`${path.basename(this.contextDir)}:${e}`,S={id:r,tag:k,project:path.basename(this.contextDir),runtimes:[d],entrypoint:f,ports:g,environment:p,workdir:h,builtFrom:"Dockerfile (Multi-Stage)",createdAt:(new Date).toISOString(),sizeMB:this.imageManager.calculateDirSize($)},D=path.join(this.imageManager.baseDir,r);fs.ensureDirSync(D),fs.writeJsonSync(path.join(D,"meta.json"),S,{spaces:2}),fs.writeJsonSync(path.join(D,"blueprint.json"),{project:S.project,runtime:{primary:d,runtimes:[d]},scripts:{start:f},environment:p},{spaces:2});const w=this.imageManager.loadManifest();w[k]=S,w[r]=S,this.imageManager.saveManifest(w);try{const e=path.join(this.imageManager.baseDir,"manifest.json"),t=fs.existsSync(e)?fs.readJsonSync(e):{},s={...S,imageDir:D,rootfsDir:$,layers:n.length};t[k]=s,t[r]=s,fs.writeJsonSync(e,t,{spaces:2})}catch(e){}return console.log(chalk.green(`\n[OK] Successfully built Multi-Stage Capsule: ${chalk.bold(k)} (ID: ${r})`)),console.log(chalk.dim(` Entrypoint: ${f}`)),console.log(chalk.dim(` Exposed: ${g.join(", ")||"none"}\n`)),{success:!0,imageId:r,tag:k,meta:S}}}module.exports=CapsuleBuilder;
@@ -1,7 +0,0 @@
1
- /**
2
- * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
- * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
- * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
- * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
- */
7
- const fs=require("fs-extra"),path=require("path"),{execSync}=require("child_process"),TNC_CGROUP_ROOT="/sys/fs/cgroup/tnc";class CgroupsV2Manager{constructor(){this.isLinux="linux"===process.platform,this._v2Available=null}isAvailable(){if(!this.isLinux)return!1;if(null!==this._v2Available)return this._v2Available;try{const t=fs.readFileSync("/sys/fs/cgroup/cgroup.controllers","utf8");this._v2Available=t.includes("memory")&&t.includes("cpu")}catch{this._v2Available=!1}return this._v2Available}cgroupPath(t){return path.join(TNC_CGROUP_ROOT,t)}ensureRootCgroup(){if(!this.isAvailable())return!1;try{fs.ensureDirSync(TNC_CGROUP_ROOT);const t="/sys/fs/cgroup/cgroup.subtree_control",r=fs.readFileSync(t,"utf8"),e=[];r.includes("memory")||e.push("+memory"),r.includes("cpu")||e.push("+cpu"),r.includes("pids")||e.push("+pids"),e.length>0&&fs.writeFileSync(t,e.join(" "));const s=path.join(TNC_CGROUP_ROOT,"cgroup.subtree_control");if(fs.existsSync(s))try{fs.writeFileSync(s,"+memory +cpu +pids")}catch(t){}return!0}catch(t){return!1}}create(t,r={}){if(!this.isAvailable())return{success:!1,reason:"cgroups-v2-unavailable"};const{memoryLimit:e="2g",cpuPercent:s=80,pidsMax:i=500}=r,c=this.cgroupPath(t);try{this.ensureRootCgroup(),fs.ensureDirSync(c);const t=this._parseMemory(e);if(t>0){fs.writeFileSync(path.join(c,"memory.max"),String(t));try{fs.writeFileSync(path.join(c,"memory.swap.max"),"0")}catch(t){}}const r=1e5,n=Math.floor(s/100*r);return fs.writeFileSync(path.join(c,"cpu.max"),`${n} ${r}`),fs.writeFileSync(path.join(c,"pids.max"),String(i)),{success:!0,cgPath:c,memBytes:t,cpuQuota:`${n}/${r} (${s}%)`,pidsMax:i}}catch(t){return{success:!1,reason:t.message}}}assignPid(t,r){if(!this.isAvailable())return!1;try{const e=path.join(this.cgroupPath(t),"cgroup.procs");return fs.writeFileSync(e,String(r)),!0}catch{return!1}}getStats(t){if(!this.isAvailable())return null;const r=this.cgroupPath(t);if(!fs.existsSync(r))return null;const e=(t,e="0")=>{try{return fs.readFileSync(path.join(r,t),"utf8").trim()}catch{return e}},s=parseInt(e("memory.current","0")),i=e("memory.max","max"),c=parseInt(e("pids.current","0")),n=e("pids.max","max");let a=0;try{const t=e("memory.events","").match(/oom_kill\s+(\d+)/);t&&(a=parseInt(t[1]))}catch(t){}let o=0;try{const t=e("cpu.stat","").match(/usage_usec\s+(\d+)/);t&&(o=parseInt(t[1]))}catch(t){}return{memoryMB:Math.round(s/1024/1024),memoryBytes:s,memoryLimitMB:"max"===i?null:Math.round(parseInt(i)/1024/1024),pids:c,pidsLimit:"max"===n?null:parseInt(n),oomKills:a,cpuUsageMs:Math.round(o/1e3)}}cgroupFreeze(t){if(!this.isAvailable())return!1;try{const r=path.join(this.cgroupPath(t),"cgroup.freeze");if(fs.existsSync(r))return fs.writeFileSync(r,"1"),!0}catch(t){}return!1}cgroupThaw(t){if(!this.isAvailable())return!1;try{const r=path.join(this.cgroupPath(t),"cgroup.freeze");if(fs.existsSync(r))return fs.writeFileSync(r,"0"),!0}catch(t){}return!1}killAll(t){if(!this.isAvailable())return!1;const r=this.cgroupPath(t);try{const t=path.join(r,"cgroup.kill");if(fs.existsSync(t))return fs.writeFileSync(t,"1"),!0}catch(t){}try{const t=path.join(r,"cgroup.procs");if(fs.existsSync(t)){const r=fs.readFileSync(t,"utf8").split("\n").map(t=>parseInt(t.trim())).filter(t=>!isNaN(t)&&t>0);for(const t of r)try{process.kill(t,"SIGKILL")}catch(t){}return!0}}catch(t){}return!1}destroy(t){if(!this.isAvailable())return!1;const r=this.cgroupPath(t);try{return this.killAll(t),fs.existsSync(r)&&execSync(`rmdir "${r}" 2>/dev/null || true`,{stdio:"ignore"}),!0}catch{return!1}}_parseMemory(t){if(!t)return 0;const r=String(t).toLowerCase().trim().match(/^(\d+(?:\.\d+)?)([kmg]?)b?$/);if(!r)return 0;const e=parseFloat(r[1]);switch(r[2]){case"k":return Math.floor(1024*e);case"m":return Math.floor(1024*e*1024);case"g":return Math.floor(1024*e*1024*1024);default:return Math.floor(e)}}}module.exports=CgroupsV2Manager;