@north-light/cli 0.3.489 → 0.3.491

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.
@@ -7,5 +7,10 @@ import type { Socket } from 'node:net';
7
7
  * `handle` returns the single reply line and calls `requestRead` once the request is read, which
8
8
  * resumes the socket so a peer that leaves during a long operation (`wait`) is noticed and destroyed.
9
9
  * Destroying happens after the reply flushes, so an async reply is never truncated.
10
+ *
11
+ * Contract: a peer's FIN after its request means it left (the socket is not `allowHalfOpen`). The only
12
+ * client, `launcherRequest` in client.ts, never half-closes and waits for the reply; an `allowHalfOpen`
13
+ * listener could not tell a half-close from a departure and would hold the fd of every client that
14
+ * leaves during `wait`.
10
15
  */
11
16
  export declare function serveAccepted(socket: Socket, handle: (requestRead: () => void) => Promise<string>): void;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A launch pins `/etc/passwd` and `/etc/group` as descriptors and bwrap binds them a moment later. Replacing
3
+ * either file in that interval hands bwrap an unlinked inode ("Can't find source path /proc/self/fd/N"). Every
4
+ * launch runs through `launch`, and every registry write through `write`: a write holds back launches that
5
+ * arrive after it, waits for those already running, then runs with none in flight.
6
+ */
7
+ export declare class LaunchGate {
8
+ private readonly running;
9
+ private writers;
10
+ private release;
11
+ private open;
12
+ launch<T>(start: () => Promise<T>): Promise<T>;
13
+ write<T>(run: () => T): Promise<T>;
14
+ }
@@ -0,0 +1 @@
1
+ var r=Object.defineProperty;var n=(t,i)=>r(t,"name",{value:i,configurable:!0});class h{static{n(this,"LaunchGate")}running=new Set;writers=0;release;open=Promise.resolve();async launch(i){for(;this.writers>0;)await this.open;const e=i();this.running.add(e);const s=n(()=>{this.running.delete(e)},"done");return e.then(s,s),e}async write(i){this.writers++===0&&(this.open=new Promise(e=>{this.release=e}));try{for(;this.running.size>0;)await Promise.allSettled([...this.running]);return i()}finally{--this.writers===0&&this.release()}}}export{h as LaunchGate};
@@ -1,3 +1,3 @@
1
- var q=Object.defineProperty;var h=(l,d)=>q(l,"name",{value:d,configurable:!0});import{spawn as V}from"node:child_process";import{createHash as z}from"node:crypto";import{chownSync as O,chmodSync as P,closeSync as Q,existsSync as W,mkdirSync as A,readFileSync as X,rmSync as Y,statSync as Z}from"node:fs";import{createServer as ee}from"node:net";import{dirname as H,join as _}from"node:path";import{fileURLToPath as te}from"node:url";import{loadLinuxNative as re}from"../native/linux.js";import{envHomeOverride as oe}from"../shared/env.js";import{launchSandbox as ne}from"./bwrap.js";import{serveAccepted as se}from"./accepted-socket.js";import{installNetworkRule as ie}from"./netrule.js";import{LAUNCHER_SOCKET as v,LaunchRefused as f}from"./spec.js";import{systemIdentity as ae,AppUsers as ce}from"./users.js";import{SourceValidator as de}from"./validate.js";import{prepareDaemonScopeDirectories as ue}from"./scope-directories.js";import{LimitsManager as le}from"./limits/manager.js";import{CRTRD_OOM_SCORE_ADJ as pe}from"./limits/policy.js";import{writeLimitsLog as fe}from"./limits/log.js";const B=re();function U(l){const d=l._handle?.fd;if(d===void 0||d<0)throw new Error("launcher socket fd unavailable");return d}h(U,"socketFd");async function me(){if(process.platform!=="linux"||process.getuid?.()!==0||!B)throw new Error("launcher requires Linux root and its native addon");const{daemonUid:l,daemonGid:d,dataGid:T}=ae(),I=H(v);if(A(I,{recursive:!0,mode:493}),(Z(I).mode&511)!==493)throw new Error(`${I} must be mode 0755`);O(I,l,d),process.umask(7);const G=_(I,"nodes");A(G,{recursive:!0,mode:504}),O(G,0,d),P(G,504);const S=new de(oe()||"/",d);ue(_(S.root,"runtime"),l,d);const b=new ce(T);ie();const p=new le({runtimeRoot:S.runtime,appsRoot:_(S.root,"apps"),stateDir:_(S.runtime,"limits-state"),dataGid:T,users:b});p.init();const L=new Map,$=h(r=>{const u=p.takeEvents();for(const s of u)fe(s);return u.length===0?r:{...r,events:u}},"reply_"),k=new Map,x=new Map,F=h((r,u)=>{const s=u.capture,c=z("sha256"),e=h((a,n)=>{let m=0,w=!1;const R=[];return{take(M){n?.update(M);const C=Math.max(0,Math.min(M.length,a-m));C&&(R.push(M.subarray(0,C)),m+=C),C!==M.length&&(w=!0)},result:h(()=>({text:Buffer.concat(R).toString("utf8"),truncated:w}),"result")}},"chunks"),i=e(s.stdoutBytes,c),t=e(s.stderrBytes);let o=Buffer.alloc(0),y=0;return r.stdout?.on("data",a=>i.take(a)),r.stderr?.on("data",a=>{const n=a;s.stderrTail?(y+=n.length,o=n.length>=s.stderrBytes?n.subarray(n.length-s.stderrBytes):Buffer.concat([o,n]).subarray(Math.max(0,o.length+n.length-s.stderrBytes))):t.take(n)}),new Promise(a=>r.once("close",(n,m)=>{const w=i.result(),R=s.stderrTail?{text:o.toString("utf8"),truncated:y>s.stderrBytes}:t.result();a({exitCode:n,signal:m,stdout:w.text,stderr:R.text,stdoutTruncated:w.truncated,stderrTruncated:R.truncated,stdoutSha256:c.digest("hex")})}))},"captureExit"),J=h(r=>{const u=new Set;for(;r>1&&!u.has(r);){const s=k.get(r);if(s)return s;u.add(r);try{const c=X(`/proc/${r}/status`,"utf8");r=Number(/^PPid:\s*(\d+)/m.exec(c)?.[1]??0)}catch{return null}}return null},"sandboxForPid");let g,E=!1;const j=_(H(te(import.meta.url)),"../daemon/crtrd-cli.js"),D=h(()=>{E||(g=V("/bin/setpriv",["--reuid",String(l),"--regid",String(d),"--groups",String(T),"--",process.execPath,j,...process.argv.slice(2)],{stdio:"inherit",env:{...process.env,CRTR_LAUNCHER_SOCKET:v,CRTR_HOME:S.root}}),g.pid&&p.protectDaemon(g.pid,pe),g.once("exit",()=>{g=void 0,E||D()}),g.once("error",r=>{console.error("crtrd spawn failed:",r),process.exitCode=1,E=!0}))},"startDaemon");W(v)&&Y(v);const N=ee({pauseOnConnect:!0},r=>se(r,u=>{const s=[];return(async()=>{try{if(B.peerCred(U(r)).uid!==l)throw new f("invalid_spec","launcher peer is not crtrd");let c;for(;;)try{c=B.recvFds(U(r));break}catch(t){if(t.code!=="EINTR")throw t}u(),s.push(...c.fds);const e=JSON.parse(c.data);let i;switch(e.operation){case"launch":{if(s.length>1||!b.has(e.spec.uid))throw new f("invalid_uid","app uid is not registered");let t,o;const y=p.prepareRun(e.spec.uid,e.spec.nodeId);try{o=await ne(e.spec,s[0],S,T,a=>{if(!a.pid)throw new f("launch_failed","bwrap has no pid");t=a.pid;const n=a.pid;if(k.set(n,{child:a,uid:e.spec.uid,nodeId:e.spec.nodeId,mounts:e.spec.mounts,plan:y}),a.once("exit",()=>{k.delete(n);const m=p.finishRun(y);L.set(n,m.oomKilled),setTimeout(()=>L.delete(n),5*6e4).unref()}),e.spec.capture){const m=F(a,e.spec).then(w=>({...w,oomKilled:L.get(n)===!0}));x.set(n,m),m.then(()=>{setTimeout(()=>{x.get(n)===m&&x.delete(n)},3e5).unref()})}},y)}catch(a){throw t!==void 0?x.delete(t):p.finishRun(y),a}if(!o.pid)throw new f("launch_failed","bwrap has no pid");p.confirmStarted(y,o.pid),i={pid:o.pid};break}case"wait":{const t=x.get(e.pid);if(!t)throw new f("invalid_spec","unknown captured sandbox pid");const o=await t;x.delete(e.pid),i={result:o};break}case"stop":{const t=Number.isInteger(e.pid)?J(e.pid):null;if(!t||e.nodeId!==void 0&&t.nodeId!==e.nodeId)throw new f("invalid_spec","unknown sandbox pid");if(e.signal!==void 0&&e.signal!=="SIGTERM"&&e.signal!=="SIGKILL")throw new f("invalid_spec","invalid stop signal");const o=e.signal??"SIGKILL";t.child.kill(o),o==="SIGKILL"&&p.killRun(t.plan),i={ok:!0};break}case"user.create":i={uid:b.create(e.app,e.uid)};break;case"user.delete":{if([...k.values()].some(t=>t.uid===e.uid))throw new f("invalid_spec","stop this app\u2019s sandboxes before deleting its user");b.delete(e.uid),i={ok:!0};break}case"users.sync":b.sync(e.users),i={ok:!0};break;case"list":i={sandboxes:[...k].map(([t,o])=>({pid:t,uid:o.uid,nodeId:o.nodeId,mounts:o.mounts}))};break;case"limits.apply":{const t=b.entries().find(o=>o.app===e.app.replace(/^app:/,""));if(!t)throw new f("invalid_uid","app is not registered");p.applyApp(t.app,t.uid,!0),i={ok:!0};break}case"limits.show":i={limits:p.show()};break;case"cache.purge":i={purged:await p.cachePass()};break;case"exit.status":i={oomKilled:L.get(e.pid)===!0};break;case"daemon.handover":g?.kill("SIGTERM"),i={ok:!0};break;default:throw new f("invalid_spec","unknown launcher operation")}return`${JSON.stringify($(i))}
2
- `}catch(c){const e=c instanceof f?c.refusal:"launch_failed";return console.error(`launcher refusal ${e}:`,c),`${JSON.stringify({refusal:e})}
3
- `}finally{for(const c of s)Q(c)}})()}));await new Promise((r,u)=>{N.once("error",u),N.listen(v,r)}),O(v,l,d),P(v,384);const K=h(()=>{E=!0,g?.kill("SIGTERM");for(const{child:r}of k.values())r.kill("SIGKILL");N.close(),S.close()},"shutdown");process.once("SIGTERM",K),process.once("SIGINT",K),D()}h(me,"runLauncher"),await me().catch(l=>{console.error(l),process.exitCode=1});export{me as runLauncher};
1
+ var V=Object.defineProperty;var w=(f,u)=>V(f,"name",{value:u,configurable:!0});import{spawn as z}from"node:child_process";import{createHash as Q}from"node:crypto";import{chownSync as B,chmodSync as A,closeSync as W,existsSync as X,mkdirSync as H,readFileSync as Y,rmSync as Z,statSync as ee}from"node:fs";import{createServer as te}from"node:net";import{dirname as U,join as _}from"node:path";import{fileURLToPath as re}from"node:url";import{loadLinuxNative as ne}from"../native/linux.js";import{envHomeOverride as oe}from"../shared/env.js";import{launchSandbox as se}from"./bwrap.js";import{LaunchGate as ie}from"./launch-gate.js";import{serveAccepted as ae}from"./accepted-socket.js";import{installNetworkRule as ce}from"./netrule.js";import{LAUNCHER_SOCKET as b,LaunchRefused as p}from"./spec.js";import{systemIdentity as de,AppUsers as ue}from"./users.js";import{SourceValidator as le}from"./validate.js";import{prepareDaemonScopeDirectories as pe}from"./scope-directories.js";import{LimitsManager as fe}from"./limits/manager.js";import{CRTRD_OOM_SCORE_ADJ as me}from"./limits/policy.js";import{writeLimitsLog as he}from"./limits/log.js";const D=ne();function $(f){const u=f._handle?.fd;if(u===void 0||u<0)throw new Error("launcher socket fd unavailable");return u}w($,"socketFd");async function we(){if(process.platform!=="linux"||process.getuid?.()!==0||!D)throw new Error("launcher requires Linux root and its native addon");const{daemonUid:f,daemonGid:u,dataGid:L}=de(),R=U(b);if(H(R,{recursive:!0,mode:493}),(ee(R).mode&511)!==493)throw new Error(`${R} must be mode 0755`);B(R,f,u),process.umask(7);const N=_(R,"nodes");H(N,{recursive:!0,mode:504}),B(N,0,u),A(N,504);const v=new le(oe()||"/",u);pe(_(v.root,"runtime"),f,u);const k=new ue(L);ce();const m=new fe({runtimeRoot:v.runtime,appsRoot:_(v.root,"apps"),stateDir:_(v.runtime,"limits-state"),dataGid:L,users:k});m.init();const T=new Map,F=w(r=>{const l=m.takeEvents();for(const o of l)he(o);return l.length===0?r:{...r,events:l}},"reply_"),E=new ie,x=new Map,I=new Map,J=w((r,l)=>{const o=l.capture,a=Q("sha256"),e=w((c,s)=>{let d=0,h=!1;const g=[];return{take(C){s?.update(C);const G=Math.max(0,Math.min(C.length,c-d));G&&(g.push(C.subarray(0,G)),d+=G),G!==C.length&&(h=!0)},result:w(()=>({text:Buffer.concat(g).toString("utf8"),truncated:h}),"result")}},"chunks"),i=e(o.stdoutBytes,a),t=e(o.stderrBytes);let n=Buffer.alloc(0),S=0;return r.stdout?.on("data",c=>i.take(c)),r.stderr?.on("data",c=>{const s=c;o.stderrTail?(S+=s.length,n=s.length>=o.stderrBytes?s.subarray(s.length-o.stderrBytes):Buffer.concat([n,s]).subarray(Math.max(0,n.length+s.length-o.stderrBytes))):t.take(s)}),new Promise(c=>r.once("close",(s,d)=>{const h=i.result(),g=o.stderrTail?{text:n.toString("utf8"),truncated:S>o.stderrBytes}:t.result();c({exitCode:s,signal:d,stdout:h.text,stderr:g.text,stdoutTruncated:h.truncated,stderrTruncated:g.truncated,stdoutSha256:a.digest("hex")})}))},"captureExit"),j=w(r=>{const l=new Set;for(;r>1&&!l.has(r);){const o=x.get(r);if(o)return o;l.add(r);try{const a=Y(`/proc/${r}/status`,"utf8");r=Number(/^PPid:\s*(\d+)/m.exec(a)?.[1]??0)}catch{return null}}return null},"sandboxForPid");let y,M=!1;const q=_(U(re(import.meta.url)),"../daemon/crtrd-cli.js"),K=w(()=>{M||(y=z("/bin/setpriv",["--reuid",String(f),"--regid",String(u),"--groups",String(L),"--",process.execPath,q,...process.argv.slice(2)],{stdio:"inherit",env:{...process.env,CRTR_LAUNCHER_SOCKET:b,CRTR_HOME:v.root}}),y.pid&&m.protectDaemon(y.pid,me),y.once("exit",()=>{y=void 0,M||K()}),y.once("error",r=>{console.error("crtrd spawn failed:",r),process.exitCode=1,M=!0}))},"startDaemon");X(b)&&Z(b);const O=te({pauseOnConnect:!0},r=>ae(r,l=>{const o=[];return(async()=>{try{if(D.peerCred($(r)).uid!==f)throw new p("invalid_spec","launcher peer is not crtrd");let a;for(;;)try{a=D.recvFds($(r));break}catch(t){if(t.code!=="EINTR")throw t}l(),o.push(...a.fds);const e=JSON.parse(a.data);let i;switch(e.operation){case"launch":{if(o.length>1)throw new p("invalid_uid","app uid is not registered");let t,n,S;try{n=await E.launch(async()=>{if(!k.has(e.spec.uid))throw new p("invalid_uid","app uid is not registered");const c=S=m.prepareRun(e.spec.uid,e.spec.nodeId);return se(e.spec,o[0],v,L,s=>{if(!s.pid)throw new p("launch_failed","bwrap has no pid");t=s.pid;const d=s.pid;if(x.set(d,{child:s,uid:e.spec.uid,nodeId:e.spec.nodeId,mounts:e.spec.mounts,plan:c}),s.once("exit",()=>{x.delete(d);const h=m.finishRun(c);T.set(d,h.oomKilled),setTimeout(()=>T.delete(d),5*6e4).unref()}),e.spec.capture){const h=J(s,e.spec).then(g=>({...g,oomKilled:T.get(d)===!0}));I.set(d,h),h.then(()=>{setTimeout(()=>{I.get(d)===h&&I.delete(d)},3e5).unref()})}},c)})}catch(c){throw t!==void 0?I.delete(t):S&&m.finishRun(S),c}if(!n.pid)throw new p("launch_failed","bwrap has no pid");m.confirmStarted(S,n.pid),i={pid:n.pid};break}case"wait":{const t=I.get(e.pid);if(!t)throw new p("invalid_spec","unknown captured sandbox pid");const n=await t;I.delete(e.pid),i={result:n};break}case"stop":{const t=Number.isInteger(e.pid)?j(e.pid):null;if(!t||e.nodeId!==void 0&&t.nodeId!==e.nodeId)throw new p("invalid_spec","unknown sandbox pid");if(e.signal!==void 0&&e.signal!=="SIGTERM"&&e.signal!=="SIGKILL")throw new p("invalid_spec","invalid stop signal");const n=e.signal??"SIGKILL";t.child.kill(n),n==="SIGKILL"&&m.killRun(t.plan),i={ok:!0};break}case"user.create":i={uid:await E.write(()=>k.create(e.app,e.uid))};break;case"user.delete":{await E.write(()=>{if([...x.values()].some(t=>t.uid===e.uid))throw new p("invalid_spec","stop this app\u2019s sandboxes before deleting its user");k.delete(e.uid)}),i={ok:!0};break}case"users.sync":await E.write(()=>k.sync(e.users)),i={ok:!0};break;case"list":i={sandboxes:[...x].map(([t,n])=>({pid:t,uid:n.uid,nodeId:n.nodeId,mounts:n.mounts}))};break;case"limits.apply":{const t=k.entries().find(n=>n.app===e.app.replace(/^app:/,""));if(!t)throw new p("invalid_uid","app is not registered");m.applyApp(t.app,t.uid,!0),i={ok:!0};break}case"limits.show":i={limits:m.show()};break;case"cache.purge":i={purged:await m.cachePass()};break;case"exit.status":i={oomKilled:T.get(e.pid)===!0};break;case"daemon.handover":y?.kill("SIGTERM"),i={ok:!0};break;default:throw new p("invalid_spec","unknown launcher operation")}return`${JSON.stringify(F(i))}
2
+ `}catch(a){const e=a instanceof p?a.refusal:"launch_failed";return console.error(`launcher refusal ${e}:`,a),`${JSON.stringify({refusal:e})}
3
+ `}finally{for(const a of o)W(a)}})()}));await new Promise((r,l)=>{O.once("error",l),O.listen(b,r)}),B(b,f,u),A(b,384);const P=w(()=>{M=!0,y?.kill("SIGTERM");for(const{child:r}of x.values())r.kill("SIGKILL");O.close(),v.close()},"shutdown");process.once("SIGTERM",P),process.once("SIGINT",P),K()}w(we,"runLauncher"),await we().catch(f=>{console.error(f),process.exitCode=1});export{we as runLauncher};
@@ -1,7 +1,7 @@
1
- var h=Object.defineProperty;var i=(n,t)=>h(n,"name",{value:t,configurable:!0});import{readFileSync as a,writeFileSync as l,renameSync as f,openSync as w,closeSync as m,fchmodSync as g,constants as u}from"node:fs";import{APP_UID_MAX as y,APP_UID_MIN as _,LaunchRefused as p}from"./spec.js";function I(){const n=a("/etc/passwd","utf8").split(`
2
- `).map(s=>s.split(":")),t=a("/etc/group","utf8").split(`
3
- `).map(s=>s.split(":")),e=n.find(s=>s[0]==="crtrd"),r=t.find(s=>s[0]==="crtr-data");if(!e||!r)throw new Error("crtrd user and crtr-data group must exist");return{daemonUid:Number(e[2]),daemonGid:Number(e[3]),dataGid:Number(r[2])}}i(I,"systemIdentity");const $=/^[a-z0-9][a-z0-9-]{0,62}$/;function d(n,t){if(!$.test(n)||!Number.isInteger(t)||t<_||t>y)throw new p("invalid_uid","invalid app or uid")}i(d,"check");class b{static{i(this,"AppUsers")}dataGid;users=new Map;constructor(t){this.dataGid=t}sync(t){const e=new Map,r=new Set;for(const s of t){if(d(s.app,s.uid),e.has(s.uid)||r.has(s.app))throw new p("invalid_uid","duplicate app or uid");e.set(s.uid,s.app),r.add(s.app)}this.users=e,this.write()}create(t,e){if(d(t,e),this.users.has(e)&&this.users.get(e)!==t||[...this.users.values()].includes(t)&&this.users.get(e)!==t)throw new p("invalid_uid","app uid conflict");return this.users.set(e,t),this.write(),e}delete(t){if(!this.users.has(t))throw new p("invalid_uid","unknown app uid");this.users.delete(t),this.write()}has(t){return this.users.has(t)}appFor(t){return this.users.get(t)}entries(){return[...this.users].map(([t,e])=>({app:e,uid:t}))}write(){const t=a("/etc/passwd","utf8").split(`
4
- `).filter(r=>r&&!/^nl-app-[a-z0-9-]+:/.test(r)),e=a("/etc/group","utf8").split(`
5
- `).filter(r=>r&&!/^nl-app-[a-z0-9-]+:/.test(r));for(const[r,s]of this.users)t.push(`nl-app-${s}:x:${r}:${this.dataGid}:Northlight app:/apps/${s}/home:/bin/sh`),e.push(`nl-app-${s}:x:${r}:`);for(const[r,s]of[["/etc/passwd",t],["/etc/group",e]]){const c=`${r}.launcher-${process.pid}`,o=w(c,u.O_WRONLY|u.O_CREAT|u.O_EXCL,420);try{l(o,`${s.join(`
1
+ var l=Object.defineProperty;var a=(r,t)=>l(r,"name",{value:t,configurable:!0});import{readFileSync as i,writeFileSync as f,renameSync as w,openSync as m,closeSync as g,fchmodSync as y,constants as c}from"node:fs";import{APP_UID_MAX as _,APP_UID_MIN as $,LaunchRefused as o}from"./spec.js";function b(){const r=i("/etc/passwd","utf8").split(`
2
+ `).map(s=>s.split(":")),t=i("/etc/group","utf8").split(`
3
+ `).map(s=>s.split(":")),e=r.find(s=>s[0]==="crtrd"),n=t.find(s=>s[0]==="crtr-data");if(!e||!n)throw new Error("crtrd user and crtr-data group must exist");return{daemonUid:Number(e[2]),daemonGid:Number(e[3]),dataGid:Number(n[2])}}a(b,"systemIdentity");const N=/^[a-z0-9][a-z0-9-]{0,62}$/;function h(r,t){if(!N.test(r)||!Number.isInteger(t)||t<$||t>_)throw new o("invalid_uid","invalid app or uid")}a(h,"check");class A{static{a(this,"AppUsers")}dataGid;users=new Map;constructor(t){this.dataGid=t}sync(t){const e=new Map,n=new Set;for(const s of t){if(h(s.app,s.uid),e.has(s.uid)||n.has(s.app))throw new o("invalid_uid","duplicate app or uid");e.set(s.uid,s.app),n.add(s.app)}this.users=e,this.write()}create(t,e){if(h(t,e),this.users.has(e)&&this.users.get(e)!==t||[...this.users.values()].includes(t)&&this.users.get(e)!==t)throw new o("invalid_uid","app uid conflict");return this.users.set(e,t),this.write(),e}delete(t){if(!this.users.has(t))throw new o("invalid_uid","unknown app uid");this.users.delete(t),this.write()}has(t){return this.users.has(t)}appFor(t){return this.users.get(t)}entries(){return[...this.users].map(([t,e])=>({app:e,uid:t}))}write(){const t=i("/etc/passwd","utf8").split(`
4
+ `).filter(n=>n&&!/^nl-app-[a-z0-9-]+:/.test(n)),e=i("/etc/group","utf8").split(`
5
+ `).filter(n=>n&&!/^nl-app-[a-z0-9-]+:/.test(n));for(const[n,s]of this.users)t.push(`nl-app-${s}:x:${n}:${this.dataGid}:Northlight app:/apps/${s}/home:/bin/sh`),e.push(`nl-app-${s}:x:${n}:`);for(const[n,s]of[["/etc/passwd",t],["/etc/group",e]]){const u=`${s.join(`
6
6
  `)}
7
- `),g(o,420)}finally{m(o)}f(c,r)}}}export{b as AppUsers,I as systemIdentity};
7
+ `;if(i(n,"utf8")===u)continue;const d=`${n}.launcher-${process.pid}`,p=m(d,c.O_WRONLY|c.O_CREAT|c.O_EXCL,420);try{f(p,u),y(p,420)}finally{g(p)}w(d,n)}}}export{A as AppUsers,b as systemIdentity};
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use this only for a release explicitly authorized by Silas. `.github/workflows/publish.yml` is the source of truth; this is its manual path when Actions cannot run. Work on clean, current `main`, with `npm whoami` showing the publishing account. Verify the code first (`npm run build:api`, `npm run build`, `npx tsc --noEmit`, relevant `npm run test:isolated -- …`). Do not start a release while another agent owns uncommitted changes in this shared checkout.
4
4
 
5
- 1. Check the six latest registry versions with `npm view <name> version` for each of `crouter`, `@north-light/api`, `@north-light/env-docker`, `@north-light/identity`, `@north-light/sdk` and `@north-light/plugin`. Fetch `origin/main`, ensure the release commit is current, and choose a patch version greater than every published version. Do **not** use the workflow's `git reset --hard` in a shared checkout.
5
+ 1. Check the six latest registry versions with `npm view <name> version` for each of `@north-light/cli`, `@north-light/api`, `@north-light/env-docker`, `@north-light/identity`, `@north-light/sdk` and `@north-light/plugin`. Fetch `origin/main`, ensure the release commit is current, and choose a patch version greater than every published version. Do **not** use the workflow's `git reset --hard` in a shared checkout.
6
6
  2. Run `node scripts/release-version.mjs "$VERSION"`. It sets all six manifests to `$VERSION`, pins every dependency between them (root → identity, SDK → api and identity, plugin → api and identity) to exactly `$VERSION` with no `^`, writes `SDK_VERSION` in `packages/crouter-sdk/src/version.ts` (informational: compatibility is the contract level), and renames the `## Next release` section of `docs/sdk/release-notes.md` to `## $VERSION`. One release is one version set: an app pinning `@north-light/sdk@$VERSION` gets exactly this release's api and identity. Run `npm install --package-lock-only --ignore-scripts`, then `node scripts/contract.mjs release`: it fails on an unacknowledged breaking `/v1` change or an undeclared watched file (the same check CI runs), then writes `contract/surface.txt`, `CONTRACT_LEVEL` in `src/api/contract.ts` (a minor bump only when the surface changed or a declaration says `bump`), `crouterContract` in the SDK manifest, and resets `contract/next.json`. Run `node scripts/release-version.mjs --check` (prints `one version set`). Check `git diff` and commit only the six manifests, `packages/crouter-sdk/src/version.ts`, `docs/sdk/release-notes.md`, `package-lock.json`, `contract/surface.txt`, `contract/next.json` and `src/api/contract.ts` as `chore: release v$VERSION`. Create `git tag -a "v$VERSION" -m "chore: release v$VERSION"`, then `git push --atomic origin HEAD:main "refs/tags/v$VERSION"`. If rejected due to a concurrent push, remove the *local* release tag, integrate fresh main, then recheck versions and retry; never push a tag separately.
7
7
  3. From the tagged release commit run `npm ci`. Publish in dependency order, checking `npm view "$PACKAGE@$VERSION" version` before each and skipping only on a positive sighting: `npm run build:identity` then `npm publish` in `packages/crouter-identity`; `npm run build` then `CRTR_PACK_MINIFY=1 npm publish` at root (Docker must be running: the pack cross-builds the Linux x64 and arm64 native addons into `dist/native/linux/<arch>/addon.node` and fails the release if it cannot); `npm run build:api` then publish in `packages/crouter-api`; `npm run build:env-docker` then publish in `packages/crouter-env-docker`; `npm run build:api && npm run build:identity && npm run build:sdk` then `npm publish --tag next` in `packages/crouter-sdk` (its `prepublishOnly` guard refuses any other form, including `--tag latest`; every SDK release lands on `next`); `npm run build:api && npm run build:identity && npm run build:plugin` then `CRTR_PACK_MINIFY=1 npm publish` in `packages/crouter-plugin`. Use npm's interactive authentication/2FA where required (see `personal-operations/npm-2fa-cli`); a read-only token cannot publish. Provenance remains off because the source repository is private. Do not build or publish the Docker image in this no-Actions path.
8
8
  4. Confirm all six exact versions with `npm view`. In a fresh temporary directory, `npm init -y && npm install @north-light/sdk@$VERSION`, then prove both `@north-light/sdk/errors` (`errorCodes`) and `@north-light/sdk/stores/postgres` resolve via `node --input-type=module -e '…'`. Record any partial publish and finish it from the exact release tag rather than cutting another version: `gh workflow run publish.yml -f release_tag=v$VERSION` runs `recover-release`, which verifies the tag is a release commit on main, checks it out, and publishes only the packages and image the registry does not yet show (SDK to `next`; it never writes the image `:latest`, so if the recovered tag is the newest release, `:latest` follows the next normal release).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.489",
3
+ "version": "0.3.491",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -99,6 +99,7 @@
99
99
  "test:runtime-packaging": "node --test scripts/runtime-smoke.test.mjs scripts/pack-args.test.mjs scripts/ensure-native-linux.test.mjs scripts/release-version.test.mjs",
100
100
  "contract:check": "node scripts/contract.mjs check",
101
101
  "contract:surface": "node scripts/contract.mjs generate",
102
+ "docs:check-reference": "npm exec --workspace @north-light/docs -- tsx scripts/check-reference.ts",
102
103
  "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs scripts/sdk-docs-commands.test.mjs scripts/ci-image.test.mjs scripts/ci-miss-triage.test.mjs scripts/ci-test-pick.test.mjs",
103
104
  "test:build-output": "node scripts/build-concurrency-test.mjs",
104
105
  "check:cli-no-canvas": "node scripts/check-cli-no-canvas.mjs",
@@ -137,7 +138,7 @@
137
138
  },
138
139
  "license": "UNLICENSED",
139
140
  "dependencies": {
140
- "@north-light/identity": "0.3.489",
141
+ "@north-light/identity": "0.3.491",
141
142
  "@earendil-works/pi-agent-core": "1.1.0",
142
143
  "@earendil-works/pi-ai": "1.1.0",
143
144
  "@earendil-works/pi-coding-agent": "1.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.489",
3
+ "version": "0.3.491",
4
4
  "description": "Northlight access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.489",
3
+ "version": "0.3.491",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.489",
9
+ "version": "0.3.491",
10
10
  "hasInstallScript": true,
11
11
  "license": "UNLICENSED",
12
12
  "workspaces": [
@@ -18,7 +18,7 @@
18
18
  "@earendil-works/pi-ai": "1.1.0",
19
19
  "@earendil-works/pi-coding-agent": "1.1.0",
20
20
  "@earendil-works/pi-tui": "1.1.0",
21
- "@north-light/identity": "0.3.489",
21
+ "@north-light/identity": "0.3.491",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -12116,17 +12116,17 @@
12116
12116
  },
12117
12117
  "packages/crouter-api": {
12118
12118
  "name": "@north-light/api",
12119
- "version": "0.3.489",
12119
+ "version": "0.3.491",
12120
12120
  "license": "UNLICENSED"
12121
12121
  },
12122
12122
  "packages/crouter-env-docker": {
12123
12123
  "name": "@north-light/env-docker",
12124
- "version": "0.3.489",
12124
+ "version": "0.3.491",
12125
12125
  "license": "UNLICENSED"
12126
12126
  },
12127
12127
  "packages/crouter-identity": {
12128
12128
  "name": "@north-light/identity",
12129
- "version": "0.3.489",
12129
+ "version": "0.3.491",
12130
12130
  "license": "UNLICENSED",
12131
12131
  "dependencies": {
12132
12132
  "jose": "^6.2.1"
@@ -12134,20 +12134,20 @@
12134
12134
  },
12135
12135
  "packages/crouter-plugin": {
12136
12136
  "name": "@north-light/plugin",
12137
- "version": "0.3.489",
12137
+ "version": "0.3.491",
12138
12138
  "license": "UNLICENSED",
12139
12139
  "dependencies": {
12140
- "@north-light/api": "0.3.489",
12141
- "@north-light/identity": "0.3.489"
12140
+ "@north-light/api": "0.3.491",
12141
+ "@north-light/identity": "0.3.491"
12142
12142
  }
12143
12143
  },
12144
12144
  "packages/crouter-sdk": {
12145
12145
  "name": "@north-light/sdk",
12146
- "version": "0.3.489",
12146
+ "version": "0.3.491",
12147
12147
  "license": "UNLICENSED",
12148
12148
  "dependencies": {
12149
- "@north-light/api": "0.3.489",
12150
- "@north-light/identity": "0.3.489",
12149
+ "@north-light/api": "0.3.491",
12150
+ "@north-light/identity": "0.3.491",
12151
12151
  "jose": "^6.2.1"
12152
12152
  },
12153
12153
  "bin": {