@north-light/cli 0.3.495 → 0.3.497
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-memory/crouter-sdk/release-notes.md +2 -1
- package/dist/commands/cron.js +5 -5
- package/dist/core/peer/client.d.ts +1 -1
- package/dist/core/peer/directory.d.ts +1 -1
- package/dist/core/peer/token.d.ts +0 -2
- package/dist/core/peer/token.js +2 -2
- package/dist/daemon/api/handlers/crons.js +1 -1
- package/dist/daemon/api/principal.d.ts +2 -2
- package/dist/daemon/api/router.d.ts +1 -1
- package/dist/daemon/cron/ended.d.ts +14 -0
- package/dist/daemon/cron/ended.js +2 -0
- package/dist/daemon/cron/passes.js +1 -1
- package/dist/daemon/peer/ingress.d.ts +2 -2
- package/dist/daemon/peer/relay-client.d.ts +1 -1
- package/dist/launcher/limits/manager.js +1 -1
- package/dist/launcher/spec.d.ts +2 -2
- package/docs/sdk/release-notes.md +2 -1
- package/package.json +2 -2
- package/packages/crouter-identity/dist/index.d.ts +8 -92
- package/packages/crouter-identity/dist/index.js +9 -94
- package/packages/crouter-identity/package.json +1 -1
- package/runtime.lock.json +12 -12
|
@@ -9,7 +9,7 @@ when-and-why-to-read: When you need Release notes, this knowledge should be read
|
|
|
9
9
|
|
|
10
10
|
Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
|
|
11
11
|
|
|
12
|
-
## 0.3.
|
|
12
|
+
## 0.3.495
|
|
13
13
|
|
|
14
14
|
`GET /v1/limits` reports how much of the data disk each app uses on a host without disk quota, and the disk as a whole. Additive: no request or response an existing app sends or reads changes.
|
|
15
15
|
|
|
@@ -18,6 +18,7 @@ Changes to `@north-light/sdk` that can change what an existing app sees. Newest
|
|
|
18
18
|
- `AppLimitsDTO.disk_used_bytes` was `null` whenever the host could not enforce project quota (every hosted runtime on Blaxel). It now holds the bytes under the app's folder, measured by a directory walk at most a minute old. It stays `null` when the host has no launcher, the first walk has not finished (a read waits at most 5 s for it), or the walk failed.
|
|
19
19
|
- `LimitsDTO.data_disk` is new and optional: `{total_bytes, used_bytes, user_bytes, runtime_bytes}` for the data volume. `crtr sys limits show` prints it.
|
|
20
20
|
|
|
21
|
+
## 0.3.493
|
|
21
22
|
|
|
22
23
|
An app can act on a person's hosted runtime with no sign-in of theirs, and `@north-light/plugin` can declare canvas object kinds. Everything below is additive: no request or response an existing app sends or reads changes.
|
|
23
24
|
|
package/dist/commands/cron.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
var W=Object.defineProperty;var o=(n,e)=>W(n,"name",{value:e,configurable:!0});import{envNodeId as P,envProfileId as U}from"../shared/env.js";import{existsSync as M,statSync as H}from"node:fs";import{resolve as Y}from"node:path";import{defineBranch as B,defineLeaf as h}from"../core/command.js";import{InputError as c,readStdinRaw as V}from"../core/io.js";import{parseWhen as z,parseCadence as Z,cadenceDisplay as k}from"../core/wake.js";import{profilesStateBlock as j,resolveProfileViaDaemon as J}from"./profile/daemon-profiles.js";import{cliClient as l,rethrowAsCliError as u}from"./api-client.js";async function L(n){return(await J(n.trim())).id}o(L,"canonicalProfileId");function b(n,e){return e!==void 0&&e.trim()!==""?{tz:e,now:n}:{now:n}}o(b,"parseOpts");function $(n){const e={wake_in_past:'Pick a future instant \u2014 a positive duration ("5m","2h") or an ISO time later than now.',bad_when:'Use a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00").',bad_cadence:'Use a duration ("6h"), a 5-field cron ("0 9 * * *"), or an @alias ("@daily").',unknown_zone:'Pass --tz with an IANA zone name (e.g. "America/New_York").',cadence_too_fast:'Use a cadence of at least 60s (e.g. "1m","5m","1h").'};throw new c({error:n.code,message:n.message,received:n.received,next:e[n.code]??"Fix the time value and retry."})}o($,"throwTimeError");function G(n){const e=/^(\d+)(s|m|h)?$/.exec(n.trim());if(e===null)throw new c({error:"bad_run_timeout",message:`"${n}" is not a valid run timeout`,field:"run-timeout",next:'Use a duration like "90s", "5m", "1h", or bare seconds ("300").'});const r=Number(e[1]),t=e[2]==="h"?3600:e[2]==="m"?60:1,i=r*t;if(i<=0)throw new c({error:"bad_run_timeout",message:"run timeout must be positive",field:"run-timeout",next:'Use a positive duration, e.g. "5m".'});return i}o(G,"parseRunTimeout");function f(n){const e=n??U();return e!==void 0&&e.trim()!==""?{profile:e}:void 0}o(f,"scopeQuery");function Q(n){if(n===void 0)return process.cwd();const e=Y(n);if(!M(e)||!H(e).isDirectory())throw new c({error:"bad_cwd",message:`${e} is not a directory`,field:"cwd",next:"Pass --cwd with an existing directory, or omit it to inherit this one."});return e}o(Q,"resolveCwd");function X(n){const e=Array.isArray(n)?n.map(String):typeof n=="string"&&n!==""?[n]:[];if(e.length===0)return null;const r={};for(const t of e){const i=t.indexOf("="),s=i===-1?"":t.slice(0,i);if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s))throw new c({error:"bad_env",message:`"${t}" is not KEY=VALUE`,field:"env",next:"Pass --env KEY=VALUE (repeat the flag per variable); KEY must be a shell-legal name."});r[s]=t.slice(i+1)}return JSON.stringify(r)}o(X,"resolveEnvJson");function p(n){const e=typeof n.cronId=="string"?n.cronId.trim():"";if(e==="")throw new c({error:"missing_cron_id",message:"a cron id is required",field:"cron-id",next:"Pass the cron id from `crtr cron list`."});return e}o(p,"requireCronId");function ee(n){if(n===null)return"never run";const e=n.finished??"",r=n.exit_code===0?"ok":n.exit_code===-1?"TIMEOUT":n.exit_code===null?"no exit":`exit ${n.exit_code}`;return e===""?r:`${r} ${e}`}o(ee,"healthDisplay");function ne(n){const e=n.exit_code===null?"no exit":n.exit_code===-1?"TIMEOUT":`exit ${n.exit_code}`,r=n.duration_ms!==null?`${n.duration_ms}ms`:"\u2014";return`- ${n.started} \u2014 ${e}, ${r} \u2014 ${n.delivered??"(no disposition recorded)"}`}o(ne,"renderRun");const te=h({name:"add",description:"arm a scheduled command (bash on stdin; one of --at / --every)",whenToUse:"you need to arm a new bash schedule, rather than inspect or control an existing cron.",help:{name:"cron add",summary:"arm a scheduled command",params:[{kind:"stdin",name:"command",required:!0,constraint:"Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR and the anchor node's $CRTR_CONTEXT_DIR too when anchored; an unanchored cron has no context dir). Use absolute paths: `~` is the app's HOME, not the arming node's. The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output."},{kind:"flag",name:"name",type:"string",required:!0,constraint:"Agent-legible label \u2014 how the cron shows in `cron list`."},{kind:"flag",name:"at",type:"string",required:!1,constraint:`One-shot: run the stored bash once at <when> \u2014 a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run's disposition pauses it.`},{kind:"flag",name:"every",type:"string",required:!1,constraint:'Recurring: run the stored bash at each cadence \u2014 a fixed interval ("6h" \u2014 first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.'},{kind:"flag",name:"tz",type:"string",required:!1,constraint:'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.'},{kind:"flag",name:"on-output",type:"enum",choices:["silent","on-failure","always","on-change"],required:!1,constraint:"What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody \u2014 never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run's \u2014 the shape polling wants."},{kind:"flag",name:"sink",type:"string",required:!1,constraint:`Where always/on-change stdout deliveries go: "node:<id>" (an existing node's inbox \u2014 must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff \u2014 a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.`},{kind:"flag",name:"tier",type:"enum",choices:["normal","urgent","critical"],required:!1,default:"urgent",constraint:"Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant \u2014 a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright."},{kind:"flag",name:"expires",type:"string",required:!1,constraint:"Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern. For a held one-shot (last run exited 75), expiry bounds the wait for eligibility \u2014 the row is deleted UNFIRED, never fired blind at expiry."},{kind:"flag",name:"anchor",type:"string",required:!1,constraint:"Couple the cron's lifetime to a node: the row is deleted when that node is deleted. Without an anchor it outlives every node; it ends through its one-shot run, --expires, explicit cancellation, or self-cancellation from its bash."},{kind:"flag",name:"anchor-self",type:"bool",required:!1,constraint:"Anchor to the calling node ($CRTR_NODE_ID). Errors outside a node."},{kind:"flag",name:"cancel-on-wake",type:"bool",required:!1,constraint:"Also delete the row when the anchor node revives: use only when the cron belongs to that current dormancy, not for standing work. Requires an anchor; at most one cancel-on-wake cron per anchor \u2014 arming a second replaces the anchor\u2019s pending one."},{kind:"flag",name:"run-timeout",type:"string",required:!1,default:"5m",constraint:'Kill a run that exceeds this ("90s","5m","1h", or bare seconds). A timeout is a failure.'},{kind:"flag",name:"overlap",type:"enum",choices:["skip","queue","replace"],required:!1,default:"skip",constraint:"A fire landing while the previous run is still going. skip (default): log the skip, do not start a second run. queue: run once more after the current finishes (depth 1; further fires collapse). replace: kill the running instance and start fresh."},{kind:"flag",name:"cwd",type:"path",required:!1,constraint:"Directory every run executes in. Defaults to this one, must exist now, and is snapshotted onto the row \u2014 a cron never re-resolves its context. In a hosted runtime, runs are sandboxed and see only the app's own folders: an omitted --cwd that is outside them becomes the node's directory or the app files directory, and an explicit --cwd outside them is refused now (`cron show` prints the directory the row got)."},{kind:"flag",name:"env",type:"string",required:!1,repeatable:!0,constraint:"KEY=VALUE added to every run's env (repeat per variable). Snapshotted at arm time; overlays the operational env, alongside $CRTR_CRON_ID/$CRTR_CRON_NAME/$CRTR_RUN_ID."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile the runs resolve config and credentials under, and \u2014 under --scope profile \u2014 the profile this cron lists and cancels within. A profile id or manifest name from the <profiles> list below; a close-enough name resolves to the nearest profile. Defaults to the calling node's ($CRTR_PROFILE_ID)."},{kind:"flag",name:"scope",type:"enum",choices:["profile","global"],required:!1,constraint:"Who lists and cancels this cron: profile (default when a profile is resolved) confines it to that profile; global makes it canvas-home-wide, visible from every profile."}],output:[{name:"cron_id",type:"string",required:!0,constraint:"The server-minted armed cron id \u2014 inspect with `cron show <cron_id>`, cancel with `cron cancel <cron_id>`."},{name:"fire_at",type:"string",required:!0,constraint:"Next fire (UTC ISO)."},{name:"recur",type:"string",required:!0,constraint:'Cadence display; "none" for a one-shot.'}],outputKind:"object",effects:["Arms one cron row (daemon-run; survives this process and every node).","Each fire runs the command via bash in this cwd and records a run-log entry.","A failing run (nonzero exit, timeout, undeliverable sink) pauses the cron and spawns a node to deal with it \u2014 unless --on-output silent."]},helpState:{leaf:j},run:o(async n=>{let e=typeof n.command=="string"?n.command.trim():"";if(e===""&&(e=(await V()).trim()),e==="")throw new c({error:"missing_command",message:"no command on stdin",field:"command",next:"Pipe the bash command on stdin (e.g. from a single-quoted heredoc)."});const r=typeof n.name=="string"?n.name.trim():"";if(r==="")throw new c({error:"missing_name",message:"--name is required",field:"name",next:"Pass --name <label>."});const t=typeof n.at=="string"&&n.at!==""?n.at:void 0,i=typeof n.every=="string"&&n.every!==""?n.every:void 0;if(t===void 0==(i===void 0))throw new c({error:"bad_schedule",message:t===void 0?"one of --at / --every is required":"--at and --every are mutually exclusive",next:"Pass exactly one: --at <when> for a one-shot, or --every <cadence> for a recurrence."});const s=typeof n.tz=="string"&&n.tz!==""?n.tz:void 0,m=new Date;let d,T=null;if(i!==void 0){const a=Z(i,b(m,s));"error"in a&&$(a.error),d=a.firstFireAt,T=a.recur}else{const a=z(t,b(m,s));"error"in a&&$(a.error),d=a.fireAt}let R=null;const C=typeof n.expires=="string"&&n.expires!==""?n.expires:void 0;if(C!==void 0){const a=z(C,b(m,s));"error"in a&&$(a.error),R=a.fireAt}const q=typeof n.anchor=="string"&&n.anchor!==""?n.anchor:void 0,A=n.anchorSelf===!0;if(q!==void 0&&A)throw new c({error:"bad_anchor",message:"--anchor and --anchor-self are mutually exclusive",next:"Pass one anchor or the other."});let w=q??null;if(A){const a=P();if(a===void 0||a==="")throw new c({error:"no_self_node",message:"--anchor-self requires a calling node ($CRTR_NODE_ID is unset)",next:"Run from inside a node, or pass --anchor <node-id> explicitly."});w=a}const I=n.cancelOnWake===!0;if(I&&w===null)throw new c({error:"cancel_on_wake_needs_anchor",message:"--cancel-on-wake requires an anchor",next:"Add --anchor <node-id> or --anchor-self."});const O=typeof n.runTimeout=="string"&&n.runTimeout!==""?G(n.runTimeout):void 0,E=typeof n.cwd=="string"&&n.cwd!==""?n.cwd:void 0,g=P(),S=g?await l().getNode(g).catch(u):null,K=Q(E??(S?.isolated?S.cwd:void 0)),F=X(n.env),D=typeof n.profile=="string"&&n.profile!==""?n.profile:void 0,y=U(),_=D!==void 0?await L(D):y!==void 0&&y.trim()!==""?y:void 0,N=n.scope??(_!==void 0?"profile":"global");if(N==="profile"&&_===void 0)throw new c({error:"scope_needs_profile",message:"--scope profile names the profile this cron lists and cancels within, but no profile is resolved",field:"profile",next:"Pass --profile <id>, or use --scope global for a canvas-home-wide cron."});const v=g,x=await l().armCron({name:r,command:e,fire_at:d,recur:T,tz:s??null,created_by:v!==void 0&&v!==""?v:null,cwd:K,...E===void 0?{cwd_is_default:!0}:{},env_json:F,profile:_??null,scope:N,on_output:n.onOutput,sink:typeof n.sink=="string"&&n.sink!==""?n.sink:null,tier:n.tier??"urgent",expires_at:R,anchor_node:w,cancel_on_wake:I,...O!==void 0?{run_timeout_s:O}:{},overlap:n.overlap??"skip"}).catch(u);return{cron_id:x.cron_id,fire_at:x.fire_at,recur:k(x.recur)}},"run"),render:o(n=>{const e=String(n.recur),r=e==="none"?"one-shot (row deleted after the run)":e;return`Cron armed \u2014 ${String(n.cron_id)}.
|
|
1
|
+
var W=Object.defineProperty;var o=(n,e)=>W(n,"name",{value:e,configurable:!0});import{envNodeId as P,envProfileId as U}from"../shared/env.js";import{existsSync as M,statSync as H}from"node:fs";import{resolve as Y}from"node:path";import{defineBranch as B,defineLeaf as h}from"../core/command.js";import{InputError as c,readStdinRaw as V}from"../core/io.js";import{parseWhen as z,parseCadence as Z,cadenceDisplay as k}from"../core/wake.js";import{profilesStateBlock as j,resolveProfileViaDaemon as J}from"./profile/daemon-profiles.js";import{cliClient as l,rethrowAsCliError as u}from"./api-client.js";async function L(n){return(await J(n.trim())).id}o(L,"canonicalProfileId");function b(n,e){return e!==void 0&&e.trim()!==""?{tz:e,now:n}:{now:n}}o(b,"parseOpts");function $(n){const e={wake_in_past:'Pick a future instant \u2014 a positive duration ("5m","2h") or an ISO time later than now.',bad_when:'Use a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00").',bad_cadence:'Use a duration ("6h"), a 5-field cron ("0 9 * * *"), or an @alias ("@daily").',unknown_zone:'Pass --tz with an IANA zone name (e.g. "America/New_York").',cadence_too_fast:'Use a cadence of at least 60s (e.g. "1m","5m","1h").'};throw new c({error:n.code,message:n.message,received:n.received,next:e[n.code]??"Fix the time value and retry."})}o($,"throwTimeError");function G(n){const e=/^(\d+)(s|m|h)?$/.exec(n.trim());if(e===null)throw new c({error:"bad_run_timeout",message:`"${n}" is not a valid run timeout`,field:"run-timeout",next:'Use a duration like "90s", "5m", "1h", or bare seconds ("300").'});const r=Number(e[1]),t=e[2]==="h"?3600:e[2]==="m"?60:1,i=r*t;if(i<=0)throw new c({error:"bad_run_timeout",message:"run timeout must be positive",field:"run-timeout",next:'Use a positive duration, e.g. "5m".'});return i}o(G,"parseRunTimeout");function f(n){const e=n??U();return e!==void 0&&e.trim()!==""?{profile:e}:void 0}o(f,"scopeQuery");function Q(n){if(n===void 0)return process.cwd();const e=Y(n);if(!M(e)||!H(e).isDirectory())throw new c({error:"bad_cwd",message:`${e} is not a directory`,field:"cwd",next:"Pass --cwd with an existing directory, or omit it to inherit this one."});return e}o(Q,"resolveCwd");function X(n){const e=Array.isArray(n)?n.map(String):typeof n=="string"&&n!==""?[n]:[];if(e.length===0)return null;const r={};for(const t of e){const i=t.indexOf("="),a=i===-1?"":t.slice(0,i);if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(a))throw new c({error:"bad_env",message:`"${t}" is not KEY=VALUE`,field:"env",next:"Pass --env KEY=VALUE (repeat the flag per variable); KEY must be a shell-legal name."});r[a]=t.slice(i+1)}return JSON.stringify(r)}o(X,"resolveEnvJson");function p(n){const e=typeof n.cronId=="string"?n.cronId.trim():"";if(e==="")throw new c({error:"missing_cron_id",message:"a cron id is required",field:"cron-id",next:"Pass the cron id from `crtr cron list`."});return e}o(p,"requireCronId");function ee(n){if(n===null)return"never run";const e=n.finished??"",r=n.exit_code===0?"ok":n.exit_code===-1?"TIMEOUT":n.exit_code===null?"no exit":`exit ${n.exit_code}`;return e===""?r:`${r} ${e}`}o(ee,"healthDisplay");function ne(n){const e=n.exit_code===null?"no exit":n.exit_code===-1?"TIMEOUT":`exit ${n.exit_code}`,r=n.duration_ms!==null?`${n.duration_ms}ms`:"\u2014";return`- ${n.started} \u2014 ${e}, ${r} \u2014 ${n.delivered??"(no disposition recorded)"}`}o(ne,"renderRun");const te=h({name:"add",description:"arm a scheduled command (bash on stdin; one of --at / --every)",whenToUse:"you need to arm a new bash schedule, rather than inspect or control an existing cron.",help:{name:"cron add",summary:"arm a scheduled command",params:[{kind:"stdin",name:"command",required:!0,constraint:"Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR and the anchor node's $CRTR_CONTEXT_DIR too when anchored; an unanchored cron has no context dir). Use absolute paths: `~` is the app's HOME, not the arming node's. The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A pipeline exits with its last stage, so a failing probe piped into grep or jq exits 0 with empty stdout unless the command starts with `set -o pipefail`; a poll that must notice its probe failing needs it (a run that fails escalates, one that exits 0 quietly does not). A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output."},{kind:"flag",name:"name",type:"string",required:!0,constraint:"Agent-legible label \u2014 how the cron shows in `cron list`."},{kind:"flag",name:"at",type:"string",required:!1,constraint:`One-shot: run the stored bash once at <when> \u2014 a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run's disposition pauses it.`},{kind:"flag",name:"every",type:"string",required:!1,constraint:'Recurring: run the stored bash at each cadence \u2014 a fixed interval ("6h" \u2014 first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.'},{kind:"flag",name:"tz",type:"string",required:!1,constraint:'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.'},{kind:"flag",name:"on-output",type:"enum",choices:["silent","on-failure","always","on-change"],required:!1,constraint:"What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody \u2014 never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run's \u2014 the shape polling wants."},{kind:"flag",name:"sink",type:"string",required:!1,constraint:`Where always/on-change stdout deliveries go: "node:<id>" (an existing node's inbox \u2014 must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff \u2014 a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.`},{kind:"flag",name:"tier",type:"enum",choices:["normal","urgent","critical"],required:!1,default:"urgent",constraint:"Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant \u2014 a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright."},{kind:"flag",name:"expires",type:"string",required:!1,constraint:"Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern. A node:<id> sink is mailed (waking) that the cron expired, with its run count and last delivery. For a held one-shot (last run exited 75), expiry bounds the wait for eligibility \u2014 the row is deleted UNFIRED, never fired blind at expiry."},{kind:"flag",name:"anchor",type:"string",required:!1,constraint:"Couple the cron's lifetime to a node: the row is deleted when that node is deleted. Without an anchor it outlives every node; it ends through its one-shot run, --expires, explicit cancellation, or self-cancellation from its bash."},{kind:"flag",name:"anchor-self",type:"bool",required:!1,constraint:"Anchor to the calling node ($CRTR_NODE_ID). Errors outside a node."},{kind:"flag",name:"cancel-on-wake",type:"bool",required:!1,constraint:"Also delete the row when the anchor node revives: use only when the cron belongs to that current dormancy, not for standing work. Requires an anchor; at most one cancel-on-wake cron per anchor \u2014 arming a second replaces the anchor\u2019s pending one."},{kind:"flag",name:"run-timeout",type:"string",required:!1,default:"5m",constraint:'Kill a run that exceeds this ("90s","5m","1h", or bare seconds). A timeout is a failure.'},{kind:"flag",name:"overlap",type:"enum",choices:["skip","queue","replace"],required:!1,default:"skip",constraint:"A fire landing while the previous run is still going. skip (default): log the skip, do not start a second run. queue: run once more after the current finishes (depth 1; further fires collapse). replace: kill the running instance and start fresh."},{kind:"flag",name:"cwd",type:"path",required:!1,constraint:"Directory every run executes in. Defaults to this one, must exist now, and is snapshotted onto the row \u2014 a cron never re-resolves its context. In a hosted runtime, runs are sandboxed and see only the app's own folders: an omitted --cwd that is outside them becomes the node's directory or the app files directory, and an explicit --cwd outside them is refused now (`cron show` prints the directory the row got)."},{kind:"flag",name:"env",type:"string",required:!1,repeatable:!0,constraint:"KEY=VALUE added to every run's env (repeat per variable). Snapshotted at arm time; overlays the operational env, alongside $CRTR_CRON_ID/$CRTR_CRON_NAME/$CRTR_RUN_ID."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile the runs resolve config and credentials under, and \u2014 under --scope profile \u2014 the profile this cron lists and cancels within. A profile id or manifest name from the <profiles> list below; a close-enough name resolves to the nearest profile. Defaults to the calling node's ($CRTR_PROFILE_ID)."},{kind:"flag",name:"scope",type:"enum",choices:["profile","global"],required:!1,constraint:"Who lists and cancels this cron: profile (default when a profile is resolved) confines it to that profile; global makes it canvas-home-wide, visible from every profile."}],output:[{name:"cron_id",type:"string",required:!0,constraint:"The server-minted armed cron id \u2014 inspect with `cron show <cron_id>`, cancel with `cron cancel <cron_id>`."},{name:"fire_at",type:"string",required:!0,constraint:"Next fire (UTC ISO)."},{name:"recur",type:"string",required:!0,constraint:'Cadence display; "none" for a one-shot.'}],outputKind:"object",effects:["Arms one cron row (daemon-run; survives this process and every node).","Each fire runs the command via bash in this cwd and records a run-log entry.","A failing run (nonzero exit, timeout, undeliverable sink) pauses the cron and spawns a node to deal with it \u2014 unless --on-output silent."]},helpState:{leaf:j},run:o(async n=>{let e=typeof n.command=="string"?n.command.trim():"";if(e===""&&(e=(await V()).trim()),e==="")throw new c({error:"missing_command",message:"no command on stdin",field:"command",next:"Pipe the bash command on stdin (e.g. from a single-quoted heredoc)."});const r=typeof n.name=="string"?n.name.trim():"";if(r==="")throw new c({error:"missing_name",message:"--name is required",field:"name",next:"Pass --name <label>."});const t=typeof n.at=="string"&&n.at!==""?n.at:void 0,i=typeof n.every=="string"&&n.every!==""?n.every:void 0;if(t===void 0==(i===void 0))throw new c({error:"bad_schedule",message:t===void 0?"one of --at / --every is required":"--at and --every are mutually exclusive",next:"Pass exactly one: --at <when> for a one-shot, or --every <cadence> for a recurrence."});const a=typeof n.tz=="string"&&n.tz!==""?n.tz:void 0,m=new Date;let d,T=null;if(i!==void 0){const s=Z(i,b(m,a));"error"in s&&$(s.error),d=s.firstFireAt,T=s.recur}else{const s=z(t,b(m,a));"error"in s&&$(s.error),d=s.fireAt}let R=null;const C=typeof n.expires=="string"&&n.expires!==""?n.expires:void 0;if(C!==void 0){const s=z(C,b(m,a));"error"in s&&$(s.error),R=s.fireAt}const q=typeof n.anchor=="string"&&n.anchor!==""?n.anchor:void 0,A=n.anchorSelf===!0;if(q!==void 0&&A)throw new c({error:"bad_anchor",message:"--anchor and --anchor-self are mutually exclusive",next:"Pass one anchor or the other."});let w=q??null;if(A){const s=P();if(s===void 0||s==="")throw new c({error:"no_self_node",message:"--anchor-self requires a calling node ($CRTR_NODE_ID is unset)",next:"Run from inside a node, or pass --anchor <node-id> explicitly."});w=s}const I=n.cancelOnWake===!0;if(I&&w===null)throw new c({error:"cancel_on_wake_needs_anchor",message:"--cancel-on-wake requires an anchor",next:"Add --anchor <node-id> or --anchor-self."});const O=typeof n.runTimeout=="string"&&n.runTimeout!==""?G(n.runTimeout):void 0,E=typeof n.cwd=="string"&&n.cwd!==""?n.cwd:void 0,g=P(),S=g?await l().getNode(g).catch(u):null,K=Q(E??(S?.isolated?S.cwd:void 0)),F=X(n.env),D=typeof n.profile=="string"&&n.profile!==""?n.profile:void 0,y=U(),_=D!==void 0?await L(D):y!==void 0&&y.trim()!==""?y:void 0,N=n.scope??(_!==void 0?"profile":"global");if(N==="profile"&&_===void 0)throw new c({error:"scope_needs_profile",message:"--scope profile names the profile this cron lists and cancels within, but no profile is resolved",field:"profile",next:"Pass --profile <id>, or use --scope global for a canvas-home-wide cron."});const v=g,x=await l().armCron({name:r,command:e,fire_at:d,recur:T,tz:a??null,created_by:v!==void 0&&v!==""?v:null,cwd:K,...E===void 0?{cwd_is_default:!0}:{},env_json:F,profile:_??null,scope:N,on_output:n.onOutput,sink:typeof n.sink=="string"&&n.sink!==""?n.sink:null,tier:n.tier??"urgent",expires_at:R,anchor_node:w,cancel_on_wake:I,...O!==void 0?{run_timeout_s:O}:{},overlap:n.overlap??"skip"}).catch(u);return{cron_id:x.cron_id,fire_at:x.fire_at,recur:k(x.recur)}},"run"),render:o(n=>{const e=String(n.recur),r=e==="none"?"one-shot (row deleted after the run)":e;return`Cron armed \u2014 ${String(n.cron_id)}.
|
|
2
2
|
|
|
3
3
|
- next fire: ${String(n.fire_at)}
|
|
4
4
|
- cadence: ${r}
|
|
5
5
|
|
|
6
|
-
It runs daemon-side; nothing more to do here. Inspect it with \`crtr cron show ${String(n.cron_id)}\`; cancel with \`crtr cron cancel ${String(n.cron_id)}\` from its profile, or pass \`--profile <profile>\` from another profile.`},"render")}),re=h({name:"list",description:"what is scheduled, with next fire and recent health",whenToUse:"you want to see every cron in scope \u2014 to find an id to show/cancel, to check what fires next and what is paused, or to spot one whose last run failed.",help:{name:"cron list",summary:"every cron in scope, next-fire order",params:[],output:[{name:"crons",type:"object[]",required:!0,constraint:"One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered \u2014 null if it never ran)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async()=>({crons:(await l().listCrons(f()).catch(u)).map(e=>({cron_id:e.cron_id,name:e.name,fire_at:e.fire_at,recur:k(e.recur),state:e.state,held:e.held,on_output:e.on_output,sink:e.sink,expires_at:e.expires_at,last_run:e.last_run}))}),"run"),render:o(n=>{const e=n.crons;if(e.length===0)return"No crons armed.";const r=e.map(t=>{const i=[`next ${t.fire_at}`,t.recur==="none"?"one-shot":t.recur,t.sink!==null?`${t.on_output} \u2192 ${t.sink}`:t.on_output,`last: ${ee(t.last_run)}`];t.expires_at!==null&&i.push(`expires ${t.expires_at}`);const
|
|
6
|
+
It runs daemon-side; nothing more to do here. Inspect it with \`crtr cron show ${String(n.cron_id)}\`; cancel with \`crtr cron cancel ${String(n.cron_id)}\` from its profile, or pass \`--profile <profile>\` from another profile.`},"render")}),re=h({name:"list",description:"what is scheduled, with next fire and recent health",whenToUse:"you want to see every cron in scope \u2014 to find an id to show/cancel, to check what fires next and what is paused, or to spot one whose last run failed.",help:{name:"cron list",summary:"every cron in scope, next-fire order",params:[],output:[{name:"crons",type:"object[]",required:!0,constraint:"One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered \u2014 null if it never ran)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async()=>({crons:(await l().listCrons(f()).catch(u)).map(e=>({cron_id:e.cron_id,name:e.name,fire_at:e.fire_at,recur:k(e.recur),state:e.state,held:e.held,on_output:e.on_output,sink:e.sink,expires_at:e.expires_at,last_run:e.last_run}))}),"run"),render:o(n=>{const e=n.crons;if(e.length===0)return"No crons armed.";const r=e.map(t=>{const i=[`next ${t.fire_at}`,t.recur==="none"?"one-shot":t.recur,t.sink!==null?`${t.on_output} \u2192 ${t.sink}`:t.on_output,`last: ${ee(t.last_run)}`];t.expires_at!==null&&i.push(`expires ${t.expires_at}`);const a=t.state==="paused"?t.held?" [PAUSED (held) \u2014 resume or cancel]":" [PAUSED \u2014 resume or cancel]":t.held?t.recur==="none"?` [HELD \u2014 last run exited 75; run manually with \`crtr cron run ${t.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`]`:" [HELD \u2014 last run exited 75; re-checks at the next cadence or when a host platform calls `POST /v1/crons/poke`]":"";return`- ${t.name} (${t.cron_id}) \u2014 ${i.join(", ")}${a}`});return`${e.length} cron(s):
|
|
7
7
|
|
|
8
8
|
${r.join(`
|
|
9
|
-
`)}`},"render")}),oe=h({name:"show",description:"one cron: full config plus its run history",whenToUse:'you want to inspect one cron \u2014 its exact command and settings, and what its recent runs actually did (exit codes, output heads, where deliveries went). The run log is how you answer "did it silently stop working".',help:{name:"cron show",summary:"one cron with its run-log ring",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to inspect (from `cron list`)."}],output:[{name:"cron",type:"object",required:!0,constraint:"The full cron config: command, schedule, snapshotted context (cwd, profile, scope, env var names \u2014 values stay server-side), disposition, sink, coupling, state."},{name:"runs",type:"object[]",required:!0,constraint:"The bounded run log, most recent first: started, exit_code (-1 = timeout kill), duration_ms, stdout/stderr heads, delivered (what the sink did, or why it did not)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."));return{cron:r.cron,runs:r.runs}},"run"),render:o(n=>{const e=n.cron,r=n.runs,t=e.recur!==null?k(e.recur):e.held?"one-shot":`one-shot at ${e.fire_at}`,i=e.tz!==null?` (tz ${e.tz})`:"",
|
|
10
|
-
`)},"render")}),ie=h({name:"run",description:"execute now, out of band \u2014 does not advance the schedule",whenToUse:"you need the stored command to execute now because its own behavior has not been exercised, or you fixed the command behind a paused cron. This is not a scheduled-delivery check: once `cron add` has armed a valid row, trust the clock and node delivery rather than firing it again for reassurance.",help:{name:"cron run",summary:"fire one cron now, out of band, and report the settled run",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to run (from `cron list`)."}],output:[{name:"exit_code",type:"number",required:!1,constraint:"The run's exit code (-1 = timeout kill; null when the process failed to start)."},{name:"duration_ms",type:"number",required:!0,constraint:"Wall-clock run time."},{name:"stdout_head",type:"string",required:!1,constraint:"Captured stdout (head)."},{name:"stderr_head",type:"string",required:!1,constraint:"Captured stderr (head)."},{name:"delivered",type:"string",required:!0,constraint:"What the sink did with the output, or why it did not."}],outputKind:"object",effects:["Runs the command NOW (blocking this call until it settles) and records the run in the cron's run log.","Delivers per the cron's disposition; never advances the schedule, consumes a one-shot, or escalates a failure."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(
|
|
11
|
-
`)},"render")}),
|
|
9
|
+
`)}`},"render")}),oe=h({name:"show",description:"one cron: full config plus its run history",whenToUse:'you want to inspect one cron \u2014 its exact command and settings, and what its recent runs actually did (exit codes, output heads, where deliveries went). The run log is how you answer "did it silently stop working".',help:{name:"cron show",summary:"one cron with its run-log ring",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to inspect (from `cron list`)."}],output:[{name:"cron",type:"object",required:!0,constraint:"The full cron config: command, schedule, snapshotted context (cwd, profile, scope, env var names \u2014 values stay server-side), disposition, sink, coupling, state."},{name:"runs",type:"object[]",required:!0,constraint:"The bounded run log, most recent first: started, exit_code (-1 = timeout kill), duration_ms, stdout/stderr heads, delivered (what the sink did, or why it did not)."}],outputKind:"object",effects:["None. Read-only."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."));return{cron:r.cron,runs:r.runs}},"run"),render:o(n=>{const e=n.cron,r=n.runs,t=e.recur!==null?k(e.recur):e.held?"one-shot":`one-shot at ${e.fire_at}`,i=e.tz!==null?` (tz ${e.tz})`:"",a=e.held?e.recur!==null?`- schedule: ${t}${i} \u2014 next re-check ${e.fire_at} (held \u2014 also re-checks when a host platform calls \`POST /v1/crons/poke\`; last run exited 75)`:e.expires_at!==null?`- schedule: ${t}${i} \u2014 no scheduled re-check \u2014 expires ${e.expires_at} (held \u2014 run manually with \`crtr cron run ${e.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`; deleted unfired at expiry)`:`- schedule: ${t}${i} \u2014 no scheduled re-check (held \u2014 run manually with \`crtr cron run ${e.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`)`:`- schedule: ${t}${i} \u2014 next fire ${e.fire_at}`,m=e.state==="paused"?e.held?" \u2014 PAUSED (held)":" \u2014 PAUSED":e.held?" \u2014 HELD":"",d=[`# ${e.name} (${e.cron_id})${m}`,"",a,`- disposition: ${e.on_output}${e.sink!==null?` \u2192 ${e.sink} (tier ${e.tier})`:""}`,`- context: cwd ${e.cwd}${e.profile!==null?`, profile ${e.profile}`:""}, scope ${e.scope}${e.env_keys.length>0?`, env ${e.env_keys.join(", ")}`:""}`,`- run: timeout ${e.run_timeout_s}s, overlap ${e.overlap}${e.run_state==="running"?" \u2014 a run is IN FLIGHT now":""}`];return e.expires_at!==null&&d.push(`- expires: ${e.expires_at}`),e.anchor_node!==null&&d.push(`- anchored to node ${e.anchor_node}${e.cancel_on_wake?" (canceled when it wakes)":""}`),e.created_by!==null&&d.push(`- created by node ${e.created_by}`),d.push("","## Command","","```bash",e.command,"```","",`## Runs (most recent first, last ${r.length})`,""),d.push(...r.length>0?r.map(ne):["(no runs yet)"]),d.join(`
|
|
10
|
+
`)},"render")}),ie=h({name:"run",description:"execute now, out of band \u2014 does not advance the schedule",whenToUse:"you need the stored command to execute now because its own behavior has not been exercised, or you fixed the command behind a paused cron. This is not a scheduled-delivery check: once `cron add` has armed a valid row, trust the clock and node delivery rather than firing it again for reassurance.",help:{name:"cron run",summary:"fire one cron now, out of band, and report the settled run",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to run (from `cron list`)."}],output:[{name:"exit_code",type:"number",required:!1,constraint:"The run's exit code (-1 = timeout kill; null when the process failed to start)."},{name:"duration_ms",type:"number",required:!0,constraint:"Wall-clock run time."},{name:"stdout_head",type:"string",required:!1,constraint:"Captured stdout (head)."},{name:"stderr_head",type:"string",required:!1,constraint:"Captured stderr (head)."},{name:"delivered",type:"string",required:!0,constraint:"What the sink did with the output, or why it did not."}],outputKind:"object",effects:["Runs the command NOW (blocking this call until it settles) and records the run in the cron's run log.","Delivers per the cron's disposition; never advances the schedule, consumes a one-shot, or escalates a failure."]},run:o(async n=>{const e=p(n),r=await l().showCron(e,f()).catch(a=>u(a,"Check the id against `crtr cron list`."));return await l({timeoutMs:r.cron.run_timeout_s*1e3+15e3}).runCron(e,f()).catch(a=>u(a))},"run"),render:o(n=>{const e=n,t=[`Run settled \u2014 exit ${e.exit_code===null?"no exit (process error)":e.exit_code===-1?"-1 (TIMEOUT)":String(e.exit_code)}, ${e.duration_ms??"?"}ms.`,"",`- delivered: ${e.delivered??"(nothing recorded)"}`];return e.stdout_head!==null&&t.push("","## stdout (head)","",e.stdout_head),e.stderr_head!==null&&t.push("","## stderr (head)","",e.stderr_head),t.join(`
|
|
11
|
+
`)},"render")}),se=h({name:"pause",description:"stop firing without losing config or history",whenToUse:"a cron should stop firing for now but its config and run history are worth keeping \u2014 a job under investigation, or a poll to silence temporarily. (A failing cron pauses itself.)",help:{name:"cron pause",summary:"pause one cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to pause (from `cron list`)."}],output:[],outputKind:"object",effects:["Stops the cron firing until `cron resume`. An in-flight run settles normally; the row just stops being due."]},run:o(async n=>{const e=p(n);return await l().pauseCron(e,f()).catch(r=>u(r,"Check the id against `crtr cron list`.")),{}},"run"),render:o(()=>"Cron paused.","render")}),ae=h({name:"resume",description:"re-arm a paused cron",whenToUse:"a paused cron should fire again \u2014 you fixed what made it fail (verify first with `cron run`), or its silence is over. A fire that came due while paused fires immediately on resume.",help:{name:"cron resume",summary:"resume one paused cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to resume (from `cron list`)."}],output:[{name:"fire_at",type:"string",required:!0,constraint:"Next fire \u2014 if already past, it fires on the next daemon tick."}],outputKind:"object",effects:["Re-arms the cron. A past-due fire_at fires immediately (instant verification after un-wedging); a recurrence then advances naturally."]},run:o(async n=>{const e=p(n);return{fire_at:(await l().resumeCron(e,f()).catch(t=>u(t,"Check the id against `crtr cron list`."))).fire_at}},"run"),render:o(n=>`Cron resumed \u2014 next fire ${String(n.fire_at)} (if that is already past, it fires on the next tick).`,"render")}),ce=h({name:"cancel",description:"remove one cron (idempotent)",whenToUse:"a scheduled command should stop firing \u2014 it is obsolete, wrong, or its purpose is served. Idempotent, so a script can safely self-cancel with `crtr cron cancel $CRTR_CRON_ID` even if a racing cancel got there first.",help:{name:"cron cancel",summary:"remove one cron",params:[{kind:"positional",name:"cron-id",required:!0,constraint:"The cron to remove (from `cron list`, or $CRTR_CRON_ID inside a run)."},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile scope to cancel within. Defaults to the calling node's profile; pass the profile id or manifest name for a cron armed under another profile."}],output:[],outputKind:"object",effects:["Deletes the cron row and its run history; kills any in-flight run. A no-op if already gone."]},helpState:{leaf:j},run:o(async n=>{const e=p(n),r=process.env.CRTR_RUN_ID;return await l().cancelCron(e,{...f(typeof n.profile=="string"&&n.profile.trim()!==""?await L(n.profile):void 0),...r===void 0||r===""?{}:{run_id:r}}).catch(t=>u(t,"Check the id against `crtr cron list`.")),{}},"run"),render:o(()=>"Cron canceled.","render")});function _e(){return B({name:"cron",rootEntry:{concept:"one scheduling primitive \u2014 run this bash command, then",desc:"arm, inspect, and control scheduled bash commands the daemon runs on the clock",useWhen:"bash must run later or on a cadence, rather than waiting for a runtime-delivered wake."},help:{name:"cron",summary:"scheduled bash commands, daemon-run",model:"A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node's lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent \u2014 right when the recurrence is your polling cadence. Exit 75 holds the row. A held recurring row re-checks at its next natural slot, or when a host platform calls the daemon's `POST /v1/crons/poke` endpoint. A held one-shot has no scheduled re-check: it stays held until that endpoint is called, or you execute its bash explicitly with `crtr cron run <cron-id>` (which does not unhold or reschedule the row). A local filesystem condition does not call the daemon, so use --every for that condition or run the cron explicitly after it changes. Any other nonzero exit is a real failure and escalates per --on-output. Keep gate errors nonzero so a broken probe is loud instead of silently held. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run's disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it."},children:[te,re,oe,ie,se,ae,ce]})}o(_e,"registerCron");export{_e as registerCron};
|
|
@@ -56,6 +56,6 @@ export interface PeerDeps {
|
|
|
56
56
|
startingBudgetMs: number;
|
|
57
57
|
}
|
|
58
58
|
export declare function createPeerCall(deps: PeerDeps): (machineId: string, request: PeerRequest, options?: PeerOptions) => Promise<PeerResult>;
|
|
59
|
-
/** Call a machine of this account. Resolves reach through the directory (cached 5 min), authenticates with a
|
|
59
|
+
/** Call a machine of this account. Resolves reach through the directory (cached 5 min), authenticates with a peer call token for the callee's URL,
|
|
60
60
|
* and retries `503 runtime_starting`. See the module header for the two kinds of result. */
|
|
61
61
|
export declare const peerCall: (machineId: string, request: PeerRequest, options?: PeerOptions) => Promise<PeerResult>;
|
|
@@ -13,7 +13,7 @@ export type Reach = {
|
|
|
13
13
|
export interface Machine {
|
|
14
14
|
machine_id: string;
|
|
15
15
|
name: string;
|
|
16
|
-
/** The machine's callable address, also the audience of its
|
|
16
|
+
/** The machine's callable address, also the audience of its peer call tokens. */
|
|
17
17
|
url: string;
|
|
18
18
|
reach: Reach;
|
|
19
19
|
crtr_version: string | null;
|
|
@@ -11,5 +11,3 @@ export interface TokenCache {
|
|
|
11
11
|
export declare function createTokenCache(mint?: Mint, now?: () => number): TokenCache;
|
|
12
12
|
/** The daemon's process-wide cache. */
|
|
13
13
|
export declare const peerTokens: TokenCache;
|
|
14
|
-
/** A machine token whose `aud` is `audience` (a peer machine's URL, or `https://relay.northlight.ai`). */
|
|
15
|
-
export declare function machineToken(issuer: string, audience: string): Promise<string>;
|
package/dist/core/peer/token.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var
|
|
2
|
-
${t??""}`,"keyOf");return{async get(e,t){const
|
|
1
|
+
var _=Object.defineProperty;var p=(a,s)=>_(a,"name",{value:s,configurable:!0});import{mintDirectoryToken as k}from"../grants/sync.js";const g=3e4;function y(a=k,s=Date.now){const r=new Map,o=new Map,i=p((e,t)=>`${e.replace(/\/$/,"")}
|
|
2
|
+
${t??""}`,"keyOf");return{async get(e,t){const n=i(e,t),l=r.get(n);if(l&&l.expires_at-g>s())return l.access_token;let c=o.get(n);return c||(c=a(e,t).then(f=>(r.set(n,f),f)).finally(()=>{o.delete(n)}),o.set(n,c)),(await c).access_token},drop(e,t){r.delete(i(e,t))},clear(){r.clear(),o.clear()}}}p(y,"createTokenCache");const h=y();export{g as REFRESH_BEFORE_EXPIRY_MS,y as createTokenCache,h as peerTokens};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var X=Object.defineProperty;var t=(e,n)=>X(e,"name",{value:n,configurable:!0});import{randomUUID as nn}from"node:crypto";import{relative as en,resolve as z,sep as B}from"node:path";import{ApiError as a}from"../../../api/errors.js";import{armCron as rn,cancelCron as on,getCron as y,listCrons as T,listCronRuns as G,pokeHeldCrons as tn,setCronState as F}from"../../../core/canvas/crons.js";import{getNode as h}from"../../../core/canvas/canvas.js";import{appSpace as sn,cronFolder as an}from"../../../core/layout.js";import{appProcessesSandboxed as cn,cwdIsMounted as ln}from"../../../core/spaces/process.js";import{activeGrant as dn,activeGrantForApp as un,grantFor as pn,terminalGrant as fn}from"../../../core/grants/mirror.js";import{readConfig as _n}from"../../../core/config.js";import{assertScope as L}from"../../../core/scopes.js";import{InputError as i}from"../../../core/io.js";import{nextSlotAfter as hn}from"../../../core/wake.js";import{executeCron as mn,markSelfCanceledCronRun as gn}from"../../cron-run.js";import{toCronDTO as k,toCronRunDTO as Z}from"../map.js";import{appOf as H,assertCeiling as wn,canReadNode as bn}from"../router.js";import{checkCronBirth as vn,checkCronFire as yn}from"../../cron/authority.js";import{requireUsableProfile as kn}from"./profiles.js";function A(e,n){const r=e[n];if(typeof r!="string"||r.trim()==="")throw new i({error:"missing_field",message:`cron arm request requires a non-empty "${n}"`,field:n,next:`Send a non-empty string "${n}".`});return r}t(A,"requireString");function O(e,n,r,o){if(e==null)return o;if(typeof e=="string"&&r.includes(e))return e;throw new i({error:`bad_${n}`,message:`"${String(e)}" is not a valid ${n}`,field:n,next:`Use one of: ${r.join(", ")}.`})}t(O,"requireEnum");function Sn(e){if(e==="human")return JSON.stringify({human:!0});if(e.startsWith("node:")){const n=e.slice(5).trim();if(n==="")throw new i({error:"bad_sink",message:"node sink requires a node id",field:"sink",next:'Use sink "node:<node-id>".'});if(h(n)===null)throw new i({error:"sink_node_not_found",message:`sink node ${n} does not exist on the canvas`,field:"sink",next:"Name a live node id, or use spawn:<kind> for a delivery that outlives any one node."});return JSON.stringify({node:n})}if(e.startsWith("spawn:")){const n=e.slice(6).trim();if(n==="")throw new i({error:"bad_sink",message:"spawn sink requires a node kind",field:"sink",next:'Use sink "spawn:<kind>" (e.g. spawn:general).'});return JSON.stringify({spawn:n})}throw new i({error:"bad_sink",message:`"${e}" is not a valid sink`,field:"sink",next:"Use node:<node-id>, spawn:<kind>, or human."})}t(Sn,"parseSinkSpec");const $n=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function M(e,n){if(!$n.test(e)||Number.isNaN(Date.parse(e)))throw new i({error:`bad_${n}`,message:`"${e}" is not a UTC ISO instant`,field:n,next:'Send a resolved UTC instant like "2026-06-07T09:00:00.000Z" (the client resolves the time grammar).'});return e}t(M,"requireUtcIso");function On(e){try{hn(e,new Date)}catch(n){throw new i({error:"bad_recur",message:`recur is not advanceable: ${n instanceof Error?n.message:String(n)}`,field:"recur",next:'Send recur JSON the daemon can advance: {"every":"6h"} or {"cron":"0 9 * * *","tz":"America/New_York"}.'})}return e}t(On,"requireRecur");function Cn(e){let n;try{n=JSON.parse(e)}catch{throw new i({error:"bad_env_json",message:"env_json is not valid JSON",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'})}if(typeof n!="object"||n===null||Array.isArray(n))throw new i({error:"bad_env_json",message:"env_json must be a JSON object",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'});for(const[r,o]of Object.entries(n))if(typeof o!="string")throw new i({error:"bad_env_json",message:`env_json value for "${r}" is not a string`,field:"env_json",next:"Every env value must be a string."});return e}t(Cn,"requireEnvJson");function E(e){const n=e.query.get("profile");return n!==null&&n.trim()!==""?n.trim():null}t(E,"callerProfile");function Y(e,n){return n===null||e.scope==="global"||e.profile===n}t(Y,"inScope");function K(e){return e.principal.class==="person"?null:H(e.principal)}t(K,"callerApp");function U(e,n){return bn(e.principal,n.grantee,n.created_by??void 0)}t(U,"visibleTo");function In(e){return typeof e.anchor_node=="string"&&e.anchor_node!==""?e.anchor_node:null}t(In,"anchorNodeOf");function Nn(e,n,r,o,c,u){const l=typeof e.cwd=="string"&&e.cwd.trim()!==""?z(e.cwd):null;if(!cn()){if(l===null)throw new i({error:"missing_field",message:'cron arm request requires a non-empty "cwd"',field:"cwd",next:'Send a non-empty string "cwd".'});return l}const p=o!==null&&h(o)!==null?o:void 0,d=t(N=>ln(n,N,p,c,void 0,u),"reaches");if(l!==null&&d(l))return l;const s=sn(n).files;if(l!==null&&e.cwd_is_default!==!0)throw new a(400,"invalid_request",`cwd ${l} is not inside a directory this app's cron runs can see, so every run would fail to start; use a directory under ${s}`,{cwd:l,default:s},void 0,{param:"cwd"});const m=r===null?null:h(r)?.cwd??null;return m!==null&&d(m)?m:s}t(Nn,"resolveArmedCwd");function qn(e,n,r){const o=e.principal;if(o.class==="person"){const s=fn();return{sub:s?.sub??"user:local",grant_id:s?.id??null,scopes:null,origin_run_id:null}}if(o.class==="app"){const s=un(n);if(s===null)throw new a(401,"grant_revoked",`no active grant for ${n}`);const m=e.ceiling??o.scopes;return{sub:s.sub,grant_id:s.id,scopes:[...m??s.resolved],origin_run_id:null}}if(o.class==="cron"){const s=yn({grantee:o.app,sub:o.sub,grant_id:o.grant,scopes:o.scopes});if(!s.allowed)throw new a(s.reason==="scope_missing"?403:401,s.reason==="scope_missing"?"scope_missing":"grant_revoked",s.message);return{sub:o.sub,grant_id:o.grant,scopes:o.scopes===null?[...s.effectiveScopes]:[...o.scopes],origin_run_id:o.origin_run}}if(o.class!=="broker")throw new a(401,"unauthorized","this caller cannot arm a cron");const c=r??h(o.node),u=c?.grant_id??o.grant,l=c?.sub??o.sub,p=u!=null?dn(u):pn(n,l);if(p===null){if(n==="app:terminal"&&!_n("user").runtime?.issuer)return{sub:l,grant_id:u??null,scopes:c?.scopes??o.scopes,origin_run_id:c?.run_id??o.run};throw new a(401,"grant_revoked","the node grant is not active")}const d=c?.scopes??o.scopes;return{sub:p.sub,grant_id:p.id,scopes:d===null?[...p.resolved]:[...d],origin_run_id:c?.run_id??o.run}}t(qn,"armPrincipal");function jn(e){const n=e.body??{},r=A(n,"name"),o=A(n,"command"),c=M(A(n,"fire_at"),"fire_at"),u=typeof n.recur=="string"&&n.recur!==""?On(n.recur):null,l=typeof n.expires_at=="string"&&n.expires_at!==""?M(n.expires_at,"expires_at"):null,p=typeof n.env_json=="string"&&n.env_json!==""?Cn(n.env_json):null,d=typeof n.sink=="string"&&n.sink.trim()!==""?n.sink.trim():null,s=O(n.on_output,"on_output",["silent","on-failure","always","on-change"],d?.startsWith("node:")?"on-change":"on-failure"),m=O(n.overlap,"overlap",["skip","queue","replace"],"skip"),N=O(n.tier,"tier",["normal","urgent","critical"],"normal"),R=s==="always"||s==="on-change";if(R&&d===null)throw new i({error:"sink_required",message:`on_output "${s}" delivers stdout every qualifying run \u2014 it needs a sink`,field:"sink",next:"Pass sink node:<node-id> (a living node's inbox), spawn:<kind> (birth a fresh node per delivery), or human (the humanloop inbox)."});if(!R&&d!==null)throw new i({error:"sink_unused",message:`on_output "${s}" never delivers stdout \u2014 a sink would be dead config`,field:"sink",next:'Drop the sink, or use on_output "always"/"on-change" if delivery is the point.'});const Q=d!==null?Sn(d):"{}";if(d?.startsWith("spawn:")&&(wn(e.ceiling,"crtr:act"),e.principal.class==="cron")){const _=vn({grantee:e.principal.app,sub:e.principal.sub,grant_id:e.principal.grant,scopes:e.principal.scopes});if(!_.allowed)throw new a(_.reason==="scope_missing"?403:401,_.reason==="scope_missing"?"scope_missing":"grant_revoked",_.message)}const b=H(e.principal)??"app:terminal",v=e.principal.class==="broker"?e.principal.node:e.principal.class==="cron"?null:typeof n.created_by=="string"&&n.created_by!==""?n.created_by:null;if(e.principal.class==="broker"&&n.created_by!=null&&n.created_by!==v)throw new a(403,"permission_error","a broker may only arm a cron as its own node");const g=v===null?null:h(v);if(g!==null){if(g.grantee!==b)throw new i({error:"creator_app_mismatch",message:"creator node belongs to another app",field:"created_by",next:"Use a node of the calling app."});L(g,"crtr:schedule"),d?.startsWith("spawn:")&&L(g,"crtr:act")}if(n.isolated!==void 0&&typeof n.isolated!="boolean")throw new a(400,"invalid_request","isolated must be a boolean");if(g?.isolated&&n.isolated===!1)throw new a(403,"isolated_run","a cron created by an isolated node cannot escape isolation");const D=g?.isolated===!0||e.principal.class==="cron"&&e.principal.isolated;if(D&&n.isolated===!1)throw new a(403,"isolated_run","a cron created by an isolated caller cannot escape isolation");const q=D||n.isolated===!0,w=qn(e,b,g),j=nn(),S=q?an(b,j):null;if(S!==null&&n.cwd_is_default!==!0){const _=en(S,typeof n.cwd=="string"&&n.cwd.trim()!==""?z(n.cwd):S);if(_===".."||_.startsWith(`..${B}`)||_.startsWith(B))throw new a(400,"invalid_request","an isolated cron must run in its own cron folder",{param:"cwd"})}const V=S??Nn(n,b,v,In(n),q,{sub:w.sub,grantId:w.grant_id,scopes:w.scopes}),f=typeof n.anchor_node=="string"&&n.anchor_node!==""?n.anchor_node:null;if(f!==null&&h(f)!==null&&h(f).grantee!==b)throw new i({error:"anchor_app_mismatch",message:"anchor node belongs to another app",field:"anchor_node",next:"Use an anchor of the calling app."});if(f!==null&&h(f)===null)throw new i({error:"anchor_not_found",message:`anchor node ${f} does not exist on the canvas`,field:"anchor_node",next:"Name a live node id, or drop the anchor \u2014 an unanchored cron outlives every node."});const W=n.cancel_on_wake===!0;if(W&&f===null)throw new i({error:"cancel_on_wake_needs_anchor",message:"cancel_on_wake couples the cron to a node's dormancy \u2014 it requires an anchor node",field:"anchor_node",next:"Pass anchor_node with the node whose wake should cancel this cron."});const $=n.run_timeout_s??300;if(typeof $!="number"||!Number.isInteger($)||$<=0)throw new i({error:"bad_run_timeout",message:`run_timeout_s must be a positive integer (got ${String(n.run_timeout_s)})`,field:"run_timeout_s",next:"Send the timeout in whole seconds, e.g. 300."});const P=typeof n.profile=="string"&&n.profile!==""?kn(e,n.profile).profileId:null,x=O(n.scope,"scope",["profile","global"],P!==null?"profile":"global");if(x==="profile"&&P===null)throw new i({error:"scope_needs_profile",message:'scope "profile" names the profile the cron lists and cancels within \u2014 it requires a profile',field:"profile",next:'Send a profile, or use scope "global" for a canvas-home-wide cron.'});return rn({cron_id:j,name:r,created_by:v,grantee:b,sub:w.sub,grant_id:w.grant_id,scopes:w.scopes,origin_run_id:w.origin_run_id,isolated:q,command:o,fire_at:c,recur:u,tz:typeof n.tz=="string"?n.tz:null,expires_at:l,anchor_node:f,cancel_on_wake:W,cwd:V,env_json:p,profile:P,scope:x,run_timeout_s:$,overlap:m,on_output:s,sink:Q,tier:N}),{status:201,body:k(y(j),null)}}t(jn,"handleArm");function Pn(e){const n=E(e);return{status:200,body:(n===null?T({all:!0}):[...T({profile:n}),...T({global:!0})].sort((o,c)=>o.fire_at<c.fire_at?-1:o.fire_at>c.fire_at?1:0)).filter(o=>U(e,o)).map(o=>k(o,J(o)))}}t(Pn,"handleList");function J(e){return G(e.cron_id)[0]??null}t(J,"lastRunOf");function C(e){const n=e.params.cronId,r=y(n);if(r===null||!U(e,r))throw new a(404,"cron_not_found",`no cron ${n}`);if(!Y(r,E(e)))throw new a(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);return r}t(C,"requireCron");function I(e,n){const r=K(e);if(r!==null&&n.grantee!==r)throw new a(403,"other_app_run",`cron ${n.cron_id} belongs to another app`);return n}t(I,"requireOwnCron");function Tn(e){const n=C(e),r=G(n.cron_id);return{status:200,body:{cron:k(n,r[0]??null),runs:r.map(Z)}}}t(Tn,"handleShow");function An(e){const n=I(e,C(e));F(n.cron_id,"paused");const r=y(n.cron_id);return{status:200,body:k(r,J(r))}}t(An,"handlePause");function En(e){const n=I(e,C(e));F(n.cron_id,"active");const r=y(n.cron_id);return{status:200,body:k(r,J(r))}}t(En,"handleResume");async function Un(e){const n=I(e,C(e));if(n.run_state==="running")throw new a(409,"cron_running",`cron ${n.cron_id} has a run in flight \u2014 wait for it to settle`);const r=await mn(n,{outOfBand:!0});if(r===null)throw new a(409,"cron_running",`cron ${n.cron_id} was claimed by a concurrent run \u2014 check \`cron show\` for its result`);return{status:200,body:Z(r)}}t(Un,"handleRun");function Jn(e){const n=new Date().toISOString();return{status:200,body:{unparked:tn(n,K(e)),at:n}}}t(Jn,"handlePoke");function Rn(e){const n=e.params.cronId,r=y(n);if(r!==null&&!U(e,r))throw new a(404,"cron_not_found",`no cron ${n}`);if(r!==null&&!Y(r,E(e)))throw new a(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);r!==null&&I(e,r);const o=e.query.get("run_id"),c=e.principal.class==="cron"?o===e.principal.execution&&n===e.principal.cron:!0,u=o!==null&&c&&gn(n,o);return on(n,{preserveRunningProcess:u}),{status:204}}t(Rn,"handleCancel");const re=[{method:"POST",pattern:"/v1/crons",scope:"crtr:schedule",handler:jn},{method:"GET",pattern:"/v1/crons",handler:Pn},{method:"GET",pattern:"/v1/crons/:cronId",handler:Tn},{method:"POST",pattern:"/v1/crons/:cronId/pause",scope:"crtr:schedule",handler:An},{method:"POST",pattern:"/v1/crons/:cronId/resume",scope:"crtr:schedule",handler:En},{method:"POST",pattern:"/v1/crons/poke",scope:"crtr:schedule",handler:Jn},{method:"POST",pattern:"/v1/crons/:cronId/run",scope:"crtr:schedule",handler:Un},{method:"DELETE",pattern:"/v1/crons/:cronId",scope:"crtr:schedule",handler:Rn}];export{re as cronRoutes};
|
|
1
|
+
var X=Object.defineProperty;var t=(e,n)=>X(e,"name",{value:n,configurable:!0});import{randomUUID as nn}from"node:crypto";import{relative as en,resolve as B,sep as G}from"node:path";import{ApiError as c}from"../../../api/errors.js";import{armCron as rn,parseCronSink as on,getCron as y,listCrons as T,listCronRuns as F,pokeHeldCrons as tn,setCronState as L}from"../../../core/canvas/crons.js";import{getNode as h}from"../../../core/canvas/canvas.js";import{appSpace as sn,cronFolder as an}from"../../../core/layout.js";import{appProcessesSandboxed as cn,cwdIsMounted as ln}from"../../../core/spaces/process.js";import{activeGrant as dn,activeGrantForApp as un,grantFor as pn,terminalGrant as fn}from"../../../core/grants/mirror.js";import{readConfig as _n}from"../../../core/config.js";import{assertScope as Z}from"../../../core/scopes.js";import{InputError as i}from"../../../core/io.js";import{nextSlotAfter as hn}from"../../../core/wake.js";import{executeCron as mn,markSelfCanceledCronRun as gn}from"../../cron-run.js";import{toCronDTO as k,toCronRunDTO as H}from"../map.js";import{appOf as A,assertCeiling as wn,canReadNode as bn}from"../router.js";import{checkCronBirth as vn,checkCronFire as yn}from"../../cron/authority.js";import{endCron as kn}from"../../cron/ended.js";import{requireUsableProfile as Sn}from"./profiles.js";function E(e,n){const r=e[n];if(typeof r!="string"||r.trim()==="")throw new i({error:"missing_field",message:`cron arm request requires a non-empty "${n}"`,field:n,next:`Send a non-empty string "${n}".`});return r}t(E,"requireString");function C(e,n,r,o){if(e==null)return o;if(typeof e=="string"&&r.includes(e))return e;throw new i({error:`bad_${n}`,message:`"${String(e)}" is not a valid ${n}`,field:n,next:`Use one of: ${r.join(", ")}.`})}t(C,"requireEnum");function $n(e){if(e==="human")return JSON.stringify({human:!0});if(e.startsWith("node:")){const n=e.slice(5).trim();if(n==="")throw new i({error:"bad_sink",message:"node sink requires a node id",field:"sink",next:'Use sink "node:<node-id>".'});if(h(n)===null)throw new i({error:"sink_node_not_found",message:`sink node ${n} does not exist on the canvas`,field:"sink",next:"Name a live node id, or use spawn:<kind> for a delivery that outlives any one node."});return JSON.stringify({node:n})}if(e.startsWith("spawn:")){const n=e.slice(6).trim();if(n==="")throw new i({error:"bad_sink",message:"spawn sink requires a node kind",field:"sink",next:'Use sink "spawn:<kind>" (e.g. spawn:general).'});return JSON.stringify({spawn:n})}throw new i({error:"bad_sink",message:`"${e}" is not a valid sink`,field:"sink",next:"Use node:<node-id>, spawn:<kind>, or human."})}t($n,"parseSinkSpec");const Cn=/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;function M(e,n){if(!Cn.test(e)||Number.isNaN(Date.parse(e)))throw new i({error:`bad_${n}`,message:`"${e}" is not a UTC ISO instant`,field:n,next:'Send a resolved UTC instant like "2026-06-07T09:00:00.000Z" (the client resolves the time grammar).'});return e}t(M,"requireUtcIso");function On(e){try{hn(e,new Date)}catch(n){throw new i({error:"bad_recur",message:`recur is not advanceable: ${n instanceof Error?n.message:String(n)}`,field:"recur",next:'Send recur JSON the daemon can advance: {"every":"6h"} or {"cron":"0 9 * * *","tz":"America/New_York"}.'})}return e}t(On,"requireRecur");function In(e){let n;try{n=JSON.parse(e)}catch{throw new i({error:"bad_env_json",message:"env_json is not valid JSON",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'})}if(typeof n!="object"||n===null||Array.isArray(n))throw new i({error:"bad_env_json",message:"env_json must be a JSON object",field:"env_json",next:'Send a JSON object of string values, e.g. {"API_BASE":"https://x"}.'});for(const[r,o]of Object.entries(n))if(typeof o!="string")throw new i({error:"bad_env_json",message:`env_json value for "${r}" is not a string`,field:"env_json",next:"Every env value must be a string."});return e}t(In,"requireEnvJson");function U(e){const n=e.query.get("profile");return n!==null&&n.trim()!==""?n.trim():null}t(U,"callerProfile");function Y(e,n){return n===null||e.scope==="global"||e.profile===n}t(Y,"inScope");function K(e){return e.principal.class==="person"?null:A(e.principal)}t(K,"callerApp");function J(e,n){return bn(e.principal,n.grantee,n.created_by??void 0)}t(J,"visibleTo");function Nn(e){return typeof e.anchor_node=="string"&&e.anchor_node!==""?e.anchor_node:null}t(Nn,"anchorNodeOf");function qn(e,n,r,o,a,u){const l=typeof e.cwd=="string"&&e.cwd.trim()!==""?B(e.cwd):null;if(!cn()){if(l===null)throw new i({error:"missing_field",message:'cron arm request requires a non-empty "cwd"',field:"cwd",next:'Send a non-empty string "cwd".'});return l}const p=o!==null&&h(o)!==null?o:void 0,d=t(N=>ln(n,N,p,a,void 0,u),"reaches");if(l!==null&&d(l))return l;const s=sn(n).files;if(l!==null&&e.cwd_is_default!==!0)throw new c(400,"invalid_request",`cwd ${l} is not inside a directory this app's cron runs can see, so every run would fail to start; use a directory under ${s}`,{cwd:l,default:s},void 0,{param:"cwd"});const m=r===null?null:h(r)?.cwd??null;return m!==null&&d(m)?m:s}t(qn,"resolveArmedCwd");function jn(e,n,r){const o=e.principal;if(o.class==="person"){const s=fn();return{sub:s?.sub??"user:local",grant_id:s?.id??null,scopes:null,origin_run_id:null}}if(o.class==="app"){const s=un(n);if(s===null)throw new c(401,"grant_revoked",`no active grant for ${n}`);const m=e.ceiling??o.scopes;return{sub:s.sub,grant_id:s.id,scopes:[...m??s.resolved],origin_run_id:null}}if(o.class==="cron"){const s=yn({grantee:o.app,sub:o.sub,grant_id:o.grant,scopes:o.scopes});if(!s.allowed)throw new c(s.reason==="scope_missing"?403:401,s.reason==="scope_missing"?"scope_missing":"grant_revoked",s.message);return{sub:o.sub,grant_id:o.grant,scopes:o.scopes===null?[...s.effectiveScopes]:[...o.scopes],origin_run_id:o.origin_run}}if(o.class!=="broker")throw new c(401,"unauthorized","this caller cannot arm a cron");const a=r??h(o.node),u=a?.grant_id??o.grant,l=a?.sub??o.sub,p=u!=null?dn(u):pn(n,l);if(p===null){if(n==="app:terminal"&&!_n("user").runtime?.issuer)return{sub:l,grant_id:u??null,scopes:a?.scopes??o.scopes,origin_run_id:a?.run_id??o.run};throw new c(401,"grant_revoked","the node grant is not active")}const d=a?.scopes??o.scopes;return{sub:p.sub,grant_id:p.id,scopes:d===null?[...p.resolved]:[...d],origin_run_id:a?.run_id??o.run}}t(jn,"armPrincipal");function Pn(e){const n=e.body??{},r=E(n,"name"),o=E(n,"command"),a=M(E(n,"fire_at"),"fire_at"),u=typeof n.recur=="string"&&n.recur!==""?On(n.recur):null,l=typeof n.expires_at=="string"&&n.expires_at!==""?M(n.expires_at,"expires_at"):null,p=typeof n.env_json=="string"&&n.env_json!==""?In(n.env_json):null,d=typeof n.sink=="string"&&n.sink.trim()!==""?n.sink.trim():null,s=C(n.on_output,"on_output",["silent","on-failure","always","on-change"],d?.startsWith("node:")?"on-change":"on-failure"),m=C(n.overlap,"overlap",["skip","queue","replace"],"skip"),N=C(n.tier,"tier",["normal","urgent","critical"],"normal"),D=s==="always"||s==="on-change";if(D&&d===null)throw new i({error:"sink_required",message:`on_output "${s}" delivers stdout every qualifying run \u2014 it needs a sink`,field:"sink",next:"Pass sink node:<node-id> (a living node's inbox), spawn:<kind> (birth a fresh node per delivery), or human (the humanloop inbox)."});if(!D&&d!==null)throw new i({error:"sink_unused",message:`on_output "${s}" never delivers stdout \u2014 a sink would be dead config`,field:"sink",next:'Drop the sink, or use on_output "always"/"on-change" if delivery is the point.'});const Q=d!==null?$n(d):"{}";if(d?.startsWith("spawn:")&&(wn(e.ceiling,"crtr:act"),e.principal.class==="cron")){const _=vn({grantee:e.principal.app,sub:e.principal.sub,grant_id:e.principal.grant,scopes:e.principal.scopes});if(!_.allowed)throw new c(_.reason==="scope_missing"?403:401,_.reason==="scope_missing"?"scope_missing":"grant_revoked",_.message)}const b=A(e.principal)??"app:terminal",v=e.principal.class==="broker"?e.principal.node:e.principal.class==="cron"?null:typeof n.created_by=="string"&&n.created_by!==""?n.created_by:null;if(e.principal.class==="broker"&&n.created_by!=null&&n.created_by!==v)throw new c(403,"permission_error","a broker may only arm a cron as its own node");const g=v===null?null:h(v);if(g!==null){if(g.grantee!==b)throw new i({error:"creator_app_mismatch",message:"creator node belongs to another app",field:"created_by",next:"Use a node of the calling app."});Z(g,"crtr:schedule"),d?.startsWith("spawn:")&&Z(g,"crtr:act")}if(n.isolated!==void 0&&typeof n.isolated!="boolean")throw new c(400,"invalid_request","isolated must be a boolean");if(g?.isolated&&n.isolated===!1)throw new c(403,"isolated_run","a cron created by an isolated node cannot escape isolation");const W=g?.isolated===!0||e.principal.class==="cron"&&e.principal.isolated;if(W&&n.isolated===!1)throw new c(403,"isolated_run","a cron created by an isolated caller cannot escape isolation");const q=W||n.isolated===!0,w=jn(e,b,g),j=nn(),S=q?an(b,j):null;if(S!==null&&n.cwd_is_default!==!0){const _=en(S,typeof n.cwd=="string"&&n.cwd.trim()!==""?B(n.cwd):S);if(_===".."||_.startsWith(`..${G}`)||_.startsWith(G))throw new c(400,"invalid_request","an isolated cron must run in its own cron folder",{param:"cwd"})}const V=S??qn(n,b,v,Nn(n),q,{sub:w.sub,grantId:w.grant_id,scopes:w.scopes}),f=typeof n.anchor_node=="string"&&n.anchor_node!==""?n.anchor_node:null;if(f!==null&&h(f)!==null&&h(f).grantee!==b)throw new i({error:"anchor_app_mismatch",message:"anchor node belongs to another app",field:"anchor_node",next:"Use an anchor of the calling app."});if(f!==null&&h(f)===null)throw new i({error:"anchor_not_found",message:`anchor node ${f} does not exist on the canvas`,field:"anchor_node",next:"Name a live node id, or drop the anchor \u2014 an unanchored cron outlives every node."});const x=n.cancel_on_wake===!0;if(x&&f===null)throw new i({error:"cancel_on_wake_needs_anchor",message:"cancel_on_wake couples the cron to a node's dormancy \u2014 it requires an anchor node",field:"anchor_node",next:"Pass anchor_node with the node whose wake should cancel this cron."});const $=n.run_timeout_s??300;if(typeof $!="number"||!Number.isInteger($)||$<=0)throw new i({error:"bad_run_timeout",message:`run_timeout_s must be a positive integer (got ${String(n.run_timeout_s)})`,field:"run_timeout_s",next:"Send the timeout in whole seconds, e.g. 300."});const P=typeof n.profile=="string"&&n.profile!==""?Sn(e,n.profile).profileId:null,z=C(n.scope,"scope",["profile","global"],P!==null?"profile":"global");if(z==="profile"&&P===null)throw new i({error:"scope_needs_profile",message:'scope "profile" names the profile the cron lists and cancels within \u2014 it requires a profile',field:"profile",next:'Send a profile, or use scope "global" for a canvas-home-wide cron.'});return rn({cron_id:j,name:r,created_by:v,grantee:b,sub:w.sub,grant_id:w.grant_id,scopes:w.scopes,origin_run_id:w.origin_run_id,isolated:q,command:o,fire_at:a,recur:u,tz:typeof n.tz=="string"?n.tz:null,expires_at:l,anchor_node:f,cancel_on_wake:x,cwd:V,env_json:p,profile:P,scope:z,run_timeout_s:$,overlap:m,on_output:s,sink:Q,tier:N}),{status:201,body:k(y(j),null)}}t(Pn,"handleArm");function Tn(e){const n=U(e);return{status:200,body:(n===null?T({all:!0}):[...T({profile:n}),...T({global:!0})].sort((o,a)=>o.fire_at<a.fire_at?-1:o.fire_at>a.fire_at?1:0)).filter(o=>J(e,o)).map(o=>k(o,R(o)))}}t(Tn,"handleList");function R(e){return F(e.cron_id)[0]??null}t(R,"lastRunOf");function O(e){const n=e.params.cronId,r=y(n);if(r===null||!J(e,r))throw new c(404,"cron_not_found",`no cron ${n}`);if(!Y(r,U(e)))throw new c(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);return r}t(O,"requireCron");function I(e,n){const r=K(e);if(r!==null&&n.grantee!==r)throw new c(403,"other_app_run",`cron ${n.cron_id} belongs to another app`);return n}t(I,"requireOwnCron");function An(e){const n=O(e),r=F(n.cron_id);return{status:200,body:{cron:k(n,r[0]??null),runs:r.map(H)}}}t(An,"handleShow");function En(e){const n=I(e,O(e));L(n.cron_id,"paused");const r=y(n.cron_id);return{status:200,body:k(r,R(r))}}t(En,"handlePause");function Un(e){const n=I(e,O(e));L(n.cron_id,"active");const r=y(n.cron_id);return{status:200,body:k(r,R(r))}}t(Un,"handleResume");async function Jn(e){const n=I(e,O(e));if(n.run_state==="running")throw new c(409,"cron_running",`cron ${n.cron_id} has a run in flight \u2014 wait for it to settle`);const r=await mn(n,{outOfBand:!0});if(r===null)throw new c(409,"cron_running",`cron ${n.cron_id} was claimed by a concurrent run \u2014 check \`cron show\` for its result`);return{status:200,body:H(r)}}t(Jn,"handleRun");function Rn(e){const n=new Date().toISOString();return{status:200,body:{unparked:tn(n,K(e)),at:n}}}t(Rn,"handlePoke");function Dn(e,n){const r=e.principal;if(r.class==="cron")return r.cron===n.cron_id?null:{kind:"canceled",by:`cron ${r.cron}`};if(r.class==="broker"){const a=on(n.sink);return a?.kind==="node"&&a.node===r.node?null:{kind:"canceled",by:`node ${r.node}`}}const o=A(r);return{kind:"canceled",by:o===null||o==="app:terminal"?"the person":o}}t(Dn,"cancelNotice");function Wn(e){const n=e.params.cronId,r=y(n);if(r!==null&&!J(e,r))throw new c(404,"cron_not_found",`no cron ${n}`);if(r!==null&&!Y(r,U(e)))throw new c(404,"cron_not_found",`no cron ${n} in this profile \u2014 it is scoped to another profile`);r!==null&&I(e,r);const o=e.query.get("run_id"),a=e.principal.class==="cron"?o===e.principal.execution&&n===e.principal.cron:!0,u=o!==null&&a&&gn(n,o);return r!==null&&kn(r,Dn(e,r),{preserveRunningProcess:u}),{status:204}}t(Wn,"handleCancel");const se=[{method:"POST",pattern:"/v1/crons",scope:"crtr:schedule",handler:Pn},{method:"GET",pattern:"/v1/crons",handler:Tn},{method:"GET",pattern:"/v1/crons/:cronId",handler:An},{method:"POST",pattern:"/v1/crons/:cronId/pause",scope:"crtr:schedule",handler:En},{method:"POST",pattern:"/v1/crons/:cronId/resume",scope:"crtr:schedule",handler:Un},{method:"POST",pattern:"/v1/crons/poke",scope:"crtr:schedule",handler:Rn},{method:"POST",pattern:"/v1/crons/:cronId/run",scope:"crtr:schedule",handler:Jn},{method:"DELETE",pattern:"/v1/crons/:cronId",scope:"crtr:schedule",handler:Wn}];export{se as cronRoutes};
|
|
@@ -31,7 +31,7 @@ export declare class AppPrincipalResolver {
|
|
|
31
31
|
}) => Promise<void>, jwksUrl?: string);
|
|
32
32
|
private load;
|
|
33
33
|
isReady(): boolean;
|
|
34
|
-
/** The directory key set, for the peer ingress's
|
|
34
|
+
/** The directory key set, for the peer ingress's call-token check. */
|
|
35
35
|
get jwks(): JwksCache;
|
|
36
36
|
close(): void;
|
|
37
37
|
private readonly ledger;
|
|
@@ -49,7 +49,7 @@ export declare class AppPrincipalResolver {
|
|
|
49
49
|
* there is no `sub` or `jti` requirement. Every failure is 401. */
|
|
50
50
|
verifyDirectoryRead(authorization: string | undefined, purpose: string): Promise<void>;
|
|
51
51
|
/** Verify a bearer once as a call token: ES256 signature against the JWKS mirror and every check of
|
|
52
|
-
* `verifyCallToken` (
|
|
52
|
+
* `verifyCallToken` (a token with no `act` is refused). The caller routes on `chain[0]`. Authorizes nothing.
|
|
53
53
|
* 503 before the mirror loads; every other failure is 401. */
|
|
54
54
|
verifyCall(authorization: string | undefined): Promise<CallToken>;
|
|
55
55
|
resolve(authorization: string | undefined, scope?: string): Promise<AppPrincipal>;
|
|
@@ -50,7 +50,7 @@ export type ForwardedPrincipal = {
|
|
|
50
50
|
place: RunPlace;
|
|
51
51
|
};
|
|
52
52
|
};
|
|
53
|
-
/** A
|
|
53
|
+
/** A peer call token with no envelope: the calling machine itself (`peer.*` rows only), never a person or an app. */
|
|
54
54
|
export type MachinePrincipal = {
|
|
55
55
|
class: 'machine';
|
|
56
56
|
via: string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Cron } from '../../core/canvas/crons.js';
|
|
2
|
+
export type CronEnd = {
|
|
3
|
+
kind: 'expired';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'canceled';
|
|
6
|
+
by: string;
|
|
7
|
+
};
|
|
8
|
+
/** Delete `c` and, in the same write, mail its node sink the reason. Returns
|
|
9
|
+
* whether this call deleted the row. `end: null` deletes silently (the sink
|
|
10
|
+
* node canceled it itself). The notice is skipped when the sink is not a node
|
|
11
|
+
* that can still take mail: gone, deleting, or finalized with no wake ahead. */
|
|
12
|
+
export declare function endCron(c: Cron, end: CronEnd | null, options?: {
|
|
13
|
+
preserveRunningProcess?: boolean;
|
|
14
|
+
}): boolean;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var p=Object.defineProperty;var r=(e,n)=>p(e,"name",{value:n,configurable:!0});import{getNode as c}from"../../core/canvas/canvas.js";import{withCanvasWrite as f}from"../../core/canvas/db.js";import{CRON_RUN_RING as h,cancelCron as m,listCronRuns as y,parseCronSink as g}from"../../core/canvas/crons.js";import{hasNoNaturalCycle as $}from"../../core/runtime/revive-all.js";import{isParked as _}from"../../core/runtime/lifecycle.js";import{enqueueNodeMail as x}from"../../core/feed/messages.js";import{cadenceDisplay as v}from"../../core/wake.js";const k=/^delivered: /,b=r(e=>e.exit_code===0&&(e.stdout_head??"")==="","EMPTY_OK");function w(e){return e.filter(n=>n.exit_code!==null||!(n.delivered??"").startsWith("skipped"))}r(w,"realRuns");function N(e,n,s){const t=w(s),i=s.length>=h,d=t.find(u=>k.test(u.delivered??"")),a=n.kind==="expired"?`expired at ${e.expires_at} and was deleted${e.held?" while held by exit 75; the pending occurrence never fired":""}`:`was canceled by ${n.by} and deleted`,l=[`Cron "${e.name}" (${e.cron_id}, ${e.recur!=null?v(e.recur):"one-shot"}) ${a}. It was delivering to you, so nothing will wake you from it any more.`,"",`- runs made: ${i?`at least ${t.length} (the run log keeps the last ${h} entries)`:t.length}`,d!==void 0?`- last delivery to you: ${d.finished}`:`- last delivery to you: ${i?`none in its last ${t.length} runs`:"never delivered"}`],o=t[0];if(o!==void 0){const u=o.exit_code===null?"no exit observed":o.exit_code===-1?"timed out":`exit ${o.exit_code}`;l.push(`- last run: ${o.finished} \u2014 ${u} \u2014 ${o.delivered??""}`)}return t.length>0&&t.every(b)&&l.push("","Every recorded run exited 0 with empty stdout, so the check may have been failing without saying so. A pipeline exits with its last stage, so `probe 2>&1 | grep \u2026 | jq \u2026` hides a failed probe unless the command starts with `set -o pipefail`."),{label:`\u23F0 cron ${e.name} ${n.kind==="expired"?"expired":"canceled"}`,body:l.join(`
|
|
2
|
+
`)}}r(N,"endedMessage");function C(e){return e.deleting_at!=null||e.run_id!=null&&c(e.run_id)?.deleting_at!=null}r(C,"runIsDeleting");function K(e,n,s){const t=g(e.sink),i=t?.kind==="node"?c(t.node):null,d=n!==null&&i!==null&&!C(i)&&(!$(i)||_(i));return f(()=>{const a=d?y(e.cron_id):[];if(!m(e.cron_id,s))return!1;if(d){const{label:l,body:o}=N(e,n,a);x(i.node_id,{kind:"message",label:l,senderNodeId:null,bodyJson:{body:o},urgency:"normal",wake:1,idempotencyKey:`cron-ended:${e.cron_id}`})}return!0})}r(K,"endCron");export{K as endCron};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var c=Object.defineProperty;var o=(n,e)=>c(n,"name",{value:e,configurable:!0});import{randomUUID as l}from"node:crypto";import{cronDaemonInstanceId as s,liveRunFor as
|
|
1
|
+
var c=Object.defineProperty;var o=(n,e)=>c(n,"name",{value:e,configurable:!0});import{randomUUID as l}from"node:crypto";import{cronDaemonInstanceId as s,liveRunFor as u}from"./live-runs.js";import{advanceCron as a,dueOverlapCrons as p,expiredIdleCrons as f,listCronRuns as m,recordCronRun as _}from"../../core/canvas/crons.js";import{emitEvent as v}from"../../core/events/emit.js";import{nextSlotAfter as x}from"../../core/wake.js";import{endCron as h}from"./ended.js";const d="skipped (overlap=skip)";function A(n){for(const e of f(n))h(e,{kind:"expired"})&&v({level:"info",event:"cron.expired",fields:{cron_id:e.cron_id,name:e.name,expires_at:e.expires_at}})}o(A,"expireDueCrons");function k(n){return m(n)[0]?.delivered?.startsWith(d)===!0}o(k,"latestRunIsSkip");function E(n,e){for(const r of p(e)){if(r.overlap==="queue")continue;if(r.overlap==="skip"){let t;try{t=x(r.recur,new Date(n))}catch{continue}a(r.cron_id,t),k(r.cron_id)||_({run_id:l(),cron_id:r.cron_id,started:e,finished:e,duration_ms:0,exit_code:null,stdout_head:null,stderr_head:null,delivered:`${d}: fire landed while the previous run was in flight (consecutive skips are not re-recorded until a run settles)`});continue}const i=u(r.cron_id);r.run_lease_owner===s&&i!==void 0&&!i.replaced&&(i.replaced=!0,i.kill())}}o(E,"applyOverlapPolicies");export{E as applyOverlapPolicies,A as expireDueCrons};
|
|
@@ -3,11 +3,11 @@ import { type CallToken, type JwksCache } from '@north-light/identity';
|
|
|
3
3
|
import { Router } from '../api/router.js';
|
|
4
4
|
import type { GrantSync } from '../../core/grants/cover.js';
|
|
5
5
|
export interface PeerIngressContext {
|
|
6
|
-
/** The directory the
|
|
6
|
+
/** The directory the peer call tokens come from. */
|
|
7
7
|
issuer: string;
|
|
8
8
|
/** Its key set (the app listener's cache, or the laptop's own). */
|
|
9
9
|
jwks: JwksCache;
|
|
10
|
-
/** The audience
|
|
10
|
+
/** The audience peer call tokens must carry: `machineUrl()` on the laptop, `runtime.audience` on the hosted runtime. Null before it is known: every request is refused 503. */
|
|
11
11
|
audience(): string | null;
|
|
12
12
|
/** The account owner (`user:<id>`) this runtime belongs to. Null when it has none: no relation reaches the machine and every request is refused 403. */
|
|
13
13
|
owner(): string | null;
|
|
@@ -8,7 +8,7 @@ export declare const TOKEN_REFRESH_LEAD_MS = 60000;
|
|
|
8
8
|
export declare const SILENCE_LIMIT_MS = 60000;
|
|
9
9
|
export declare const HELLO_TIMEOUT_MS = 10000;
|
|
10
10
|
export interface RelayClientOptions {
|
|
11
|
-
/** The directory (`iss` of the
|
|
11
|
+
/** The directory (`iss` of the peer call tokens). */
|
|
12
12
|
issuer: string;
|
|
13
13
|
/** The relay origin from the machine's reach (`https://relay.northlight.ai`); also the token audience. */
|
|
14
14
|
relayOrigin: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var D=Object.defineProperty;var d=(c,t)=>D(c,"name",{value:t,configurable:!0});import{spawn as w}from"node:child_process";import{randomBytes as A}from"node:crypto";import{existsSync as S,statfsSync as _}from"node:fs";import{dirname as B,join as b}from"node:path";import{fileURLToPath as N}from"node:url";import{Cgroups as O,setOomScoreAdj as P}from"./cgroup.js";import{ADMISSION_TICKET_MS as q,APP_QUOTA_CLEANUP_FRACTION as U,BOOT_RESERVATION_BYTES as M,BOOT_WINDOW_MS as T,CONVERSATIONS_DISK_BYTES as I,appCgroupPath as C,appProjectId as v,bareApp as g,bootPendingBytes as E,budgetFor as F,conversationsProjectId as x,memoryAdmission as L,passesFraction as K,runGroupName as j,selectCachePurges as Q,tmpfsSizes as G}from"./policy.js";import{ProjectQuota as $}from"./quota.js";import{readSettings as z}from"./settings.js";function J(c,t,s,i,e){const r=b(B(N(import.meta.url)),"purge-worker.js");return new Promise((o,n)=>{const a=w("/bin/setpriv",["--reuid",String(s),"--regid",String(i),"--groups",String(i),"--no-new-privs","--",process.execPath,r,c,t,String(e)],{stdio:["ignore","pipe","pipe"]});let h="",l="";a.stdout.on("data",u=>{h+=String(u)}),a.stderr.on("data",u=>{l+=String(u)}),a.once("error",n),a.once("close",u=>{if(u!==0)return n(new Error(`purge worker exited ${u}: ${l.trim().slice(0,200)}`));try{o(JSON.parse(h))}catch(y){n(y)}})})}d(J,"spawnPurge");function V(c){return new Promise(t=>{if(!S(c))return t(null);const s=w("du",["-x","-B1","-s",c],{stdio:["ignore","pipe","ignore"],timeout:6e4});let i="";s.stdout.on("data",e=>{i+=String(e)}),s.once("error",()=>t(null)),s.once("close",(e,r)=>{const o=Number(i.split(/\s/)[0]);t(r===null&&(e===0||e===1)&&i.length>0&&Number.isFinite(o)?o:null)})})}d(V,"walkBytes");const W=6e4;function Y(c){const t=_(c),s=Number(t.blocks);return s>0?1-Number(t.bavail)/s:0}d(Y,"appsDiskUsedFraction");class at{static{d(this,"LimitsManager")}options;cgroups;quota;events=[];appliedMemory=new Map;appliedDisk=new Map;purging=new Set;purge;diskUsedFraction;setOom;now;walk;diskUsage=null;diskUsageRun=null;tickets=new Map;brokerRuns=new Map;constructor(t){this.options=t,this.cgroups=t.cgroups??new O,this.quota=t.quota??new $(t.appsRoot,t.stateDir),this.purge=t.purge??J,this.diskUsedFraction=t.diskUsedFraction??(()=>Y(t.appsRoot)),this.setOom=t.setOom??P,this.now=t.now??Date.now,this.walk=t.walk??V}note(t){this.events.push(t),this.events.length>200&&this.events.splice(0,this.events.length-200)}takeEvents(){return this.events.splice(0,this.events.length)}init(){this.cgroups.init()?this.cgroups.sweep():this.note({level:"warn",event:"limits.memory.unavailable",fields:{reason:this.cgroups.unavailable}}),this.quota.init()||this.note({level:"warn",event:"limits.disk.unavailable",fields:{reason:this.quota.unavailable}})}budget(t){return F(t,z(this.options.runtimeRoot))}applyApp(t,s,i=!1){const e=this.budget(t),r=g(t);if(this.cgroups.unavailable===null){const o=JSON.stringify([e.memoryBytes,e.pids]);if(i||this.appliedMemory.get(r)!==o)try{this.cgroups.applyApp(r,e),this.appliedMemory.set(r,o),this.note({level:"info",event:"limits.memory.applied",fields:{app:r,memory_bytes:e.memoryBytes,pids:e.pids}})}catch(n){this.note({level:"warn",event:"limits.memory.failed",fields:{app:r,error:String(n)}})}}if(this.quota.unavailable===null)try{if(this.quota.tag(r,s,{uid:0,gid:0})){const o=String(e.diskBytes);(i||this.appliedDisk.get(s)!==o)&&(this.quota.setLimit(v(s),e.diskBytes),this.quota.setLimit(x(s),I),this.appliedDisk.set(s,o),this.note({level:"info",event:"limits.disk.applied",fields:{app:r,disk_bytes:e.diskBytes}}))}}catch(o){this.note({level:"warn",event:"limits.disk.failed",fields:{app:r,error:String(o)}})}return e}admitBroker(t,s){const i={verdict:"admitted"};if(this.cgroups.unavailable!==null)return i;const e=this.now();for(const[m,f]of this.tickets)e-f.issuedAt>=q&&this.tickets.delete(m);if(this.tickets.has(s))return i;const r=g(t),o=this.cgroups.root,n=`${o}/apps`,a=C(o,r),h=[...this.tickets.values()],l=[...this.brokerRuns].map(([m,f])=>({dir:m,...f})),u=d((m,f,k)=>({max:f,current:this.cgroups.memoryCurrent(m)??0,pending:h.filter(p=>k(p.app)).length*M+l.filter(p=>k(p.app)&&e-p.startedAt<T).reduce((p,R)=>p+E(this.cgroups.memoryCurrent(R.dir)??0),0),hasBroker:h.some(p=>k(p.app))||l.some(p=>k(p.app))}),"level"),y=L({app:u(a,this.cgroups.exists(a)?this.cgroups.memoryLimit(a):this.budget(r).memoryBytes,m=>m===r),apps:u(n,this.cgroups.memoryLimit(n),()=>!0)});return y.verdict==="admitted"&&this.tickets.set(s,{app:r,issuedAt:e}),y}prepareRun(t,s,i=!1){const e=this.options.users.appFor(t)??String(t),r=this.applyApp(e,t);let o=null;if(this.cgroups.unavailable===null)try{o=this.cgroups.createRun(e,j(s,A(4).toString("hex")))}catch(n){this.note({level:"warn",event:"limits.memory.failed",nodeId:s,fields:{app:e,error:String(n)}})}return i&&(this.tickets.delete(s),o!==null&&this.brokerRuns.set(o,{app:g(e),nodeId:s,startedAt:this.now()})),this.purgeIfNearQuota(e,t,r),{cgroupDir:o,app:g(e),nodeId:s,budget:r,tmpfs:G(r)}}purgeIfNearQuota(t,s,i){if(this.quota.unavailable!==null||i.diskBytes===null)return;const e=this.quota.usage().get(v(s))?.usedBytes??0;K(e,i.diskBytes,U)&&this.purgeApp(t,s,"all").catch(()=>{})}confirmStarted(t,s){t.cgroupDir!==null&&!this.cgroups.contains(t.cgroupDir,s)&&this.note({level:"warn",event:"limits.memory.not_joined",nodeId:t.nodeId,fields:{app:t.app,pid:s}})}finishRun(t){if(t.cgroupDir===null)return{oomKilled:!1};const s=t.cgroupDir;this.brokerRuns.delete(s);const i=this.cgroups.oomKilled(s);this.cgroups.kill(s),i&&this.note({level:"warn",event:"run.oom_killed",nodeId:t.nodeId,fields:{app:t.app,memory_bytes:t.budget.memoryBytes}});let e=0;const r=d(()=>{this.cgroups.remove(s)||++e>=20||setTimeout(r,100).unref()},"retry");return r(),{oomKilled:i}}killRun(t){t?.cgroupDir&&this.cgroups.kill(t.cgroupDir)}protectDaemon(t,s){try{this.setOom(t,s)}catch(i){this.note({level:"warn",event:"limits.oom_score.failed",fields:{pid:t,error:String(i)}})}}show(){const t=this.quota.unavailable===null?this.quota.usage():new Map;return{memory:{available:this.cgroups.unavailable===null,reason:this.cgroups.unavailable},disk:{available:this.quota.unavailable===null,reason:this.quota.unavailable},apps:this.options.users.entries().map(({app:s,uid:i})=>{const e=this.budget(s);return{app:s,uid:i,memory_bytes:e.memoryBytes,disk_bytes:e.diskBytes,memory_source:e.memorySource,disk_source:e.diskSource,disk_used_bytes:t.get(v(i))?.usedBytes??null}})}}async diskUsageReport(){const t=this.diskUsage;return t&&this.now()-t.at<W?t.report:(this.diskUsageRun??=this.measureDisk().finally(()=>{this.diskUsageRun=null}),this.diskUsageRun)}async measureDisk(){const t=_(this.options.appsRoot),s=Number(t.bsize),i=Number(t.blocks)*s,e=i-Number(t.bfree)*s,r=[...new Set(this.options.users.entries().map(l=>l.app))],[o,n,a]=await Promise.all([Promise.all(r.map(async l=>({app:l,bytes:await this.walk(b(this.options.appsRoot,g(l)))
|
|
1
|
+
var D=Object.defineProperty;var d=(c,t)=>D(c,"name",{value:t,configurable:!0});import{spawn as w}from"node:child_process";import{randomBytes as A}from"node:crypto";import{existsSync as S,statfsSync as _}from"node:fs";import{dirname as B,join as b}from"node:path";import{fileURLToPath as N}from"node:url";import{Cgroups as O,setOomScoreAdj as P}from"./cgroup.js";import{ADMISSION_TICKET_MS as q,APP_QUOTA_CLEANUP_FRACTION as U,BOOT_RESERVATION_BYTES as M,BOOT_WINDOW_MS as T,CONVERSATIONS_DISK_BYTES as I,appCgroupPath as C,appProjectId as v,bareApp as g,bootPendingBytes as E,budgetFor as F,conversationsProjectId as x,memoryAdmission as L,passesFraction as K,runGroupName as j,selectCachePurges as Q,tmpfsSizes as G}from"./policy.js";import{ProjectQuota as $}from"./quota.js";import{readSettings as z}from"./settings.js";function J(c,t,s,i,e){const r=b(B(N(import.meta.url)),"purge-worker.js");return new Promise((o,n)=>{const a=w("/bin/setpriv",["--reuid",String(s),"--regid",String(i),"--groups",String(i),"--no-new-privs","--",process.execPath,r,c,t,String(e)],{stdio:["ignore","pipe","pipe"]});let h="",l="";a.stdout.on("data",u=>{h+=String(u)}),a.stderr.on("data",u=>{l+=String(u)}),a.once("error",n),a.once("close",u=>{if(u!==0)return n(new Error(`purge worker exited ${u}: ${l.trim().slice(0,200)}`));try{o(JSON.parse(h))}catch(y){n(y)}})})}d(J,"spawnPurge");function V(c){return new Promise(t=>{if(!S(c))return t(null);const s=w("du",["-x","-B1","-s",c],{stdio:["ignore","pipe","ignore"],timeout:6e4});let i="";s.stdout.on("data",e=>{i+=String(e)}),s.once("error",()=>t(null)),s.once("close",(e,r)=>{const o=Number(i.split(/\s/)[0]);t(r===null&&(e===0||e===1)&&i.length>0&&Number.isFinite(o)?o:null)})})}d(V,"walkBytes");const W=6e4;function Y(c){const t=_(c),s=Number(t.blocks);return s>0?1-Number(t.bavail)/s:0}d(Y,"appsDiskUsedFraction");class at{static{d(this,"LimitsManager")}options;cgroups;quota;events=[];appliedMemory=new Map;appliedDisk=new Map;purging=new Set;purge;diskUsedFraction;setOom;now;walk;diskUsage=null;diskUsageRun=null;tickets=new Map;brokerRuns=new Map;constructor(t){this.options=t,this.cgroups=t.cgroups??new O,this.quota=t.quota??new $(t.appsRoot,t.stateDir),this.purge=t.purge??J,this.diskUsedFraction=t.diskUsedFraction??(()=>Y(t.appsRoot)),this.setOom=t.setOom??P,this.now=t.now??Date.now,this.walk=t.walk??V}note(t){this.events.push(t),this.events.length>200&&this.events.splice(0,this.events.length-200)}takeEvents(){return this.events.splice(0,this.events.length)}init(){this.cgroups.init()?this.cgroups.sweep():this.note({level:"warn",event:"limits.memory.unavailable",fields:{reason:this.cgroups.unavailable}}),this.quota.init()||this.note({level:"warn",event:"limits.disk.unavailable",fields:{reason:this.quota.unavailable}})}budget(t){return F(t,z(this.options.runtimeRoot))}applyApp(t,s,i=!1){const e=this.budget(t),r=g(t);if(this.cgroups.unavailable===null){const o=JSON.stringify([e.memoryBytes,e.pids]);if(i||this.appliedMemory.get(r)!==o)try{this.cgroups.applyApp(r,e),this.appliedMemory.set(r,o),this.note({level:"info",event:"limits.memory.applied",fields:{app:r,memory_bytes:e.memoryBytes,pids:e.pids}})}catch(n){this.note({level:"warn",event:"limits.memory.failed",fields:{app:r,error:String(n)}})}}if(this.quota.unavailable===null)try{if(this.quota.tag(r,s,{uid:0,gid:0})){const o=String(e.diskBytes);(i||this.appliedDisk.get(s)!==o)&&(this.quota.setLimit(v(s),e.diskBytes),this.quota.setLimit(x(s),I),this.appliedDisk.set(s,o),this.note({level:"info",event:"limits.disk.applied",fields:{app:r,disk_bytes:e.diskBytes}}))}}catch(o){this.note({level:"warn",event:"limits.disk.failed",fields:{app:r,error:String(o)}})}return e}admitBroker(t,s){const i={verdict:"admitted"};if(this.cgroups.unavailable!==null)return i;const e=this.now();for(const[m,f]of this.tickets)e-f.issuedAt>=q&&this.tickets.delete(m);if(this.tickets.has(s))return i;const r=g(t),o=this.cgroups.root,n=`${o}/apps`,a=C(o,r),h=[...this.tickets.values()],l=[...this.brokerRuns].map(([m,f])=>({dir:m,...f})),u=d((m,f,k)=>({max:f,current:this.cgroups.memoryCurrent(m)??0,pending:h.filter(p=>k(p.app)).length*M+l.filter(p=>k(p.app)&&e-p.startedAt<T).reduce((p,R)=>p+E(this.cgroups.memoryCurrent(R.dir)??0),0),hasBroker:h.some(p=>k(p.app))||l.some(p=>k(p.app))}),"level"),y=L({app:u(a,this.cgroups.exists(a)?this.cgroups.memoryLimit(a):this.budget(r).memoryBytes,m=>m===r),apps:u(n,this.cgroups.memoryLimit(n),()=>!0)});return y.verdict==="admitted"&&this.tickets.set(s,{app:r,issuedAt:e}),y}prepareRun(t,s,i=!1){const e=this.options.users.appFor(t)??String(t),r=this.applyApp(e,t);let o=null;if(this.cgroups.unavailable===null)try{o=this.cgroups.createRun(e,j(s,A(4).toString("hex")))}catch(n){this.note({level:"warn",event:"limits.memory.failed",nodeId:s,fields:{app:e,error:String(n)}})}return i&&(this.tickets.delete(s),o!==null&&this.brokerRuns.set(o,{app:g(e),nodeId:s,startedAt:this.now()})),this.purgeIfNearQuota(e,t,r),{cgroupDir:o,app:g(e),nodeId:s,budget:r,tmpfs:G(r)}}purgeIfNearQuota(t,s,i){if(this.quota.unavailable!==null||i.diskBytes===null)return;const e=this.quota.usage().get(v(s))?.usedBytes??0;K(e,i.diskBytes,U)&&this.purgeApp(t,s,"all").catch(()=>{})}confirmStarted(t,s){t.cgroupDir!==null&&!this.cgroups.contains(t.cgroupDir,s)&&this.note({level:"warn",event:"limits.memory.not_joined",nodeId:t.nodeId,fields:{app:t.app,pid:s}})}finishRun(t){if(t.cgroupDir===null)return{oomKilled:!1};const s=t.cgroupDir;this.brokerRuns.delete(s);const i=this.cgroups.oomKilled(s);this.cgroups.kill(s),i&&this.note({level:"warn",event:"run.oom_killed",nodeId:t.nodeId,fields:{app:t.app,memory_bytes:t.budget.memoryBytes}});let e=0;const r=d(()=>{this.cgroups.remove(s)||++e>=20||setTimeout(r,100).unref()},"retry");return r(),{oomKilled:i}}killRun(t){t?.cgroupDir&&this.cgroups.kill(t.cgroupDir)}protectDaemon(t,s){try{this.setOom(t,s)}catch(i){this.note({level:"warn",event:"limits.oom_score.failed",fields:{pid:t,error:String(i)}})}}show(){const t=this.quota.unavailable===null?this.quota.usage():new Map;return{memory:{available:this.cgroups.unavailable===null,reason:this.cgroups.unavailable},disk:{available:this.quota.unavailable===null,reason:this.quota.unavailable},apps:this.options.users.entries().map(({app:s,uid:i})=>{const e=this.budget(s);return{app:s,uid:i,memory_bytes:e.memoryBytes,disk_bytes:e.diskBytes,memory_source:e.memorySource,disk_source:e.diskSource,disk_used_bytes:t.get(v(i))?.usedBytes??null}})}}async diskUsageReport(){const t=this.diskUsage;return t&&this.now()-t.at<W?t.report:(this.diskUsageRun??=this.measureDisk().finally(()=>{this.diskUsageRun=null}),this.diskUsageRun)}async measureDisk(){const t=_(this.options.appsRoot),s=Number(t.bsize),i=Number(t.blocks)*s,e=i-Number(t.bfree)*s,r=[...new Set(this.options.users.entries().map(l=>l.app))],[o,n,a]=await Promise.all([Promise.all(r.map(async l=>({app:l,bytes:await this.walk(b(this.options.appsRoot,g(l)))}))),this.walk(b(B(this.options.appsRoot),"user")),this.walk(this.options.runtimeRoot)]),h={total_bytes:i,used_bytes:e,apps:o.sort((l,u)=>(u.bytes??-1)-(l.bytes??-1)||l.app.localeCompare(u.app)),user_bytes:n,runtime_bytes:a,measured_at:this.now()};return this.diskUsage={at:this.now(),report:h},h}async purgeApp(t,s,i){const e=g(t),r=b(this.options.appsRoot,e,"cache");if(this.purging.has(e)||!S(r))return null;this.purging.add(e);try{const o=await this.purge(r,i,s,this.options.dataGid,this.now());return this.note({level:"info",event:"app.cache.purged",fields:{app:e,mode:i,files:o.files,bytes:o.bytes,dirs:o.dirs}}),{app:e,mode:i,files:o.files,bytes:o.bytes}}catch(o){return this.note({level:"warn",event:"app.cache.purge_failed",fields:{app:e,mode:i,error:String(o)}}),null}finally{this.purging.delete(e)}}async cachePass(){const t=this.quota.unavailable===null?this.quota.usage():new Map;let s=0;try{s=this.diskUsedFraction()}catch{}const i=this.options.users.entries(),e=Q(i.map(({app:n,uid:a})=>({app:n,usedBytes:t.get(v(a))?.usedBytes??0,limitBytes:this.budget(n).diskBytes})),s),r=new Map(i.map(n=>[n.app,n.uid]));return(await Promise.all(e.map(({app:n,mode:a})=>this.purgeApp(n,r.get(n),a)))).filter(n=>n!==null)}}export{at as LimitsManager,J as spawnPurge,V as walkBytes};
|
package/dist/launcher/spec.d.ts
CHANGED
|
@@ -116,10 +116,10 @@ export interface DiskUsageReport {
|
|
|
116
116
|
/** The data volume the apps live on, from statfs. */
|
|
117
117
|
total_bytes: number;
|
|
118
118
|
used_bytes: number;
|
|
119
|
-
/** Bytes under each registered app's folder, largest first. */
|
|
119
|
+
/** Bytes under each registered app's folder, largest first; null where the walk failed. */
|
|
120
120
|
apps: Array<{
|
|
121
121
|
app: string;
|
|
122
|
-
bytes: number;
|
|
122
|
+
bytes: number | null;
|
|
123
123
|
}>;
|
|
124
124
|
user_bytes: number | null;
|
|
125
125
|
runtime_bytes: number | null;
|
|
@@ -7,7 +7,7 @@ description: Behavior changes in @north-light/sdk that an app must act on, newes
|
|
|
7
7
|
|
|
8
8
|
Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
|
|
9
9
|
|
|
10
|
-
## 0.3.
|
|
10
|
+
## 0.3.495
|
|
11
11
|
|
|
12
12
|
`GET /v1/limits` reports how much of the data disk each app uses on a host without disk quota, and the disk as a whole. Additive: no request or response an existing app sends or reads changes.
|
|
13
13
|
|
|
@@ -16,6 +16,7 @@ Changes to `@north-light/sdk` that can change what an existing app sees. Newest
|
|
|
16
16
|
- `AppLimitsDTO.disk_used_bytes` was `null` whenever the host could not enforce project quota (every hosted runtime on Blaxel). It now holds the bytes under the app's folder, measured by a directory walk at most a minute old. It stays `null` when the host has no launcher, the first walk has not finished (a read waits at most 5 s for it), or the walk failed.
|
|
17
17
|
- `LimitsDTO.data_disk` is new and optional: `{total_bytes, used_bytes, user_bytes, runtime_bytes}` for the data volume. `crtr sys limits show` prints it.
|
|
18
18
|
|
|
19
|
+
## 0.3.493
|
|
19
20
|
|
|
20
21
|
An app can act on a person's hosted runtime with no sign-in of theirs, and `@north-light/plugin` can declare canvas object kinds. Everything below is additive: no request or response an existing app sends or reads changes.
|
|
21
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.497",
|
|
4
4
|
"description": "crtr — agent runtime with memory, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"license": "UNLICENSED",
|
|
140
140
|
"dependencies": {
|
|
141
|
-
"@north-light/identity": "0.3.
|
|
141
|
+
"@north-light/identity": "0.3.497",
|
|
142
142
|
"@earendil-works/pi-agent-core": "1.1.0",
|
|
143
143
|
"@earendil-works/pi-ai": "1.1.0",
|
|
144
144
|
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { type JSONWebKeySet, type JWTPayload } from 'jose';
|
|
2
|
-
|
|
3
|
-
sub: string;
|
|
4
|
-
grantee: string;
|
|
5
|
-
client_id: string;
|
|
6
|
-
grant: string;
|
|
7
|
-
/** The grant revision the directory minted against: a freshness floor, never authorization (contract 1). */
|
|
8
|
-
rev: number;
|
|
9
|
-
exp: number;
|
|
10
|
-
}
|
|
11
|
-
/** Thrown by `verifyAccessToken` for a signed token with no `rev` claim: the directory predates the claim. */
|
|
2
|
+
/** Thrown by `verifyCallToken` for an `app:` link with a `grant` and no `rev`: the directory predates the claim. */
|
|
12
3
|
export declare class MissingRevClaimError extends Error {
|
|
13
4
|
constructor();
|
|
14
5
|
}
|
|
@@ -67,81 +58,6 @@ export declare class JwksCache {
|
|
|
67
58
|
private fetchKeys;
|
|
68
59
|
forKid(kid: string | undefined): Promise<JSONWebKeySet>;
|
|
69
60
|
}
|
|
70
|
-
/** @deprecated Use `verifyCallToken`; removed after the peer-token rollout window (plan I2). */
|
|
71
|
-
export declare function verifyAccessToken(options: {
|
|
72
|
-
token: string;
|
|
73
|
-
jwks: JSONWebKeySet | JwksCache;
|
|
74
|
-
issuer: string;
|
|
75
|
-
audience: string;
|
|
76
|
-
}): Promise<AccessTokenClaims>;
|
|
77
|
-
/** The claims of a machine token: one machine of an account calling another (or the account relay). It names the
|
|
78
|
-
* person and the calling machine and carries no grant, so it authorizes nothing on its own. */
|
|
79
|
-
export interface MachineTokenClaims extends JWTPayload {
|
|
80
|
-
iss: string;
|
|
81
|
-
/** The single audience: a peer machine URL or the relay origin. */
|
|
82
|
-
aud: string;
|
|
83
|
-
/** The account's person, `user:<id>`. */
|
|
84
|
-
sub: string;
|
|
85
|
-
/** Always equal to `sub`. */
|
|
86
|
-
grantee: string;
|
|
87
|
-
client_id: string;
|
|
88
|
-
/** The calling machine, `runtime:<id>`. */
|
|
89
|
-
machine_id: string;
|
|
90
|
-
exp: number;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Verifies a machine token: ES256 with typ `at+jwt`, signature against `jwks`, `iss` = `issuer`, `aud` a string equal
|
|
94
|
-
* to `audience` (the sole audience), and `exp`. `sub` must be `user:<id>`, `grantee` must equal `sub`, `machine_id`
|
|
95
|
-
* must be `runtime:<id>` and `client_id` a non-empty string. A token carrying a `grant`, `rev` or `scope` claim is
|
|
96
|
-
* refused, so an app grant token never verifies here; `verifyAccessToken` requires an `app:` grantee, so a machine
|
|
97
|
-
* token never verifies there.
|
|
98
|
-
*/
|
|
99
|
-
/** @deprecated Use `verifyCallToken`; removed after the peer-token rollout window (plan I2). */
|
|
100
|
-
export declare function verifyMachineToken(options: {
|
|
101
|
-
token: string;
|
|
102
|
-
jwks: JSONWebKeySet | JwksCache;
|
|
103
|
-
issuer: string;
|
|
104
|
-
audience: string;
|
|
105
|
-
}): Promise<MachineTokenClaims>;
|
|
106
|
-
/** One level of the outbound token's RFC 8693 `act` nesting: the actor, and the actor it acted for. */
|
|
107
|
-
export interface OutboundActor {
|
|
108
|
-
sub: string;
|
|
109
|
-
act?: OutboundActor;
|
|
110
|
-
}
|
|
111
|
-
/** The claims of the outbound token the directory mints for one provider call (contract 3). */
|
|
112
|
-
export interface OutboundTokenClaims extends JWTPayload {
|
|
113
|
-
iss: string;
|
|
114
|
-
aud: string;
|
|
115
|
-
/** The user the call is made for, `user:<id>`. */
|
|
116
|
-
sub: string;
|
|
117
|
-
/** The principal the grant was issued to, `app:<id>` or the user. */
|
|
118
|
-
grantee: string;
|
|
119
|
-
/** The runtime's registered OAuth client id. */
|
|
120
|
-
client_id: string;
|
|
121
|
-
/** The provider's `<provider>:<group>` strings the caller holds, space-separated; may be empty. */
|
|
122
|
-
scope: string;
|
|
123
|
-
initiating_app: string;
|
|
124
|
-
/** Audit only; nothing authorizes on it. */
|
|
125
|
-
act: OutboundActor;
|
|
126
|
-
iat: number;
|
|
127
|
-
exp: number;
|
|
128
|
-
jti: string;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Verifies the outbound token a provider receives (contract 3 D3-TOK-12): ES256 or RS256 with
|
|
132
|
-
* a `kid`, typ `at+jwt`, signature against `jwks` (a `JwksCache` refetches once on an unknown
|
|
133
|
-
* `kid`), `iss` = `issuer`, `aud` exactly `audience` (a trailing-slash difference is refused),
|
|
134
|
-
* and `exp`. `sub` must be a `user:` principal and `grantee` and `initiating_app`
|
|
135
|
-
* an `app:` or `user:` principal; the remaining claims are checked for presence and type only; which of them gates a
|
|
136
|
-
* call is the caller's rule (D3-TOK-13 gates on `scope` alone, and nothing gates on `act`).
|
|
137
|
-
*/
|
|
138
|
-
/** @deprecated Use `verifyCallToken`; removed after the peer-token rollout window (plan I2). */
|
|
139
|
-
export declare function verifyOutboundToken(options: {
|
|
140
|
-
token: string;
|
|
141
|
-
jwks: JSONWebKeySet | JwksCache;
|
|
142
|
-
issuer: string;
|
|
143
|
-
audience: string;
|
|
144
|
-
}): Promise<OutboundTokenClaims>;
|
|
145
61
|
/** One link of a call token's actor chain. `grant` and `rev` appear only on an `app:` link. */
|
|
146
62
|
export interface CallLink {
|
|
147
63
|
sub: string;
|
|
@@ -157,10 +73,10 @@ export interface CallTokenClaims extends JWTPayload {
|
|
|
157
73
|
client_id: string;
|
|
158
74
|
exp: number;
|
|
159
75
|
iat: number;
|
|
160
|
-
/** The RFC 8693 actor chain as signed
|
|
76
|
+
/** The RFC 8693 actor chain as signed. */
|
|
161
77
|
act?: unknown;
|
|
162
78
|
scope?: string;
|
|
163
|
-
/**
|
|
79
|
+
/** Only on a peer token minted while the directory still wrote it (until its flag flip); when present it names the presenter. */
|
|
164
80
|
machine_id?: string;
|
|
165
81
|
grantee?: string;
|
|
166
82
|
initiating_app?: string;
|
|
@@ -172,7 +88,7 @@ export interface CallToken {
|
|
|
172
88
|
/** `user:<id>`. */
|
|
173
89
|
sub: string;
|
|
174
90
|
aud: string;
|
|
175
|
-
/** The actor chain, index 0 = the presenter.
|
|
91
|
+
/** The actor chain, index 0 = the presenter. */
|
|
176
92
|
chain: CallLink[];
|
|
177
93
|
/** Present only on provider tokens. */
|
|
178
94
|
scope?: string;
|
|
@@ -182,10 +98,10 @@ export interface CallToken {
|
|
|
182
98
|
* Verifies a call token of any use (peer, app access, outbound provider, run notice) and returns its actor chain,
|
|
183
99
|
* presenter first. Checks in order: header `typ` `at+jwt`, `alg` ES256 or RS256, non-empty `kid`; signature; `iss`;
|
|
184
100
|
* `aud` a string equal to `audience`; `exp` and `iat` numbers; `sub` = `user:<id>`; `client_id` non-empty; `scope`
|
|
185
|
-
* a string when present; then the chain.
|
|
186
|
-
*
|
|
187
|
-
* `
|
|
188
|
-
*
|
|
101
|
+
* a string when present; then the chain. A token with no `act` is refused: the legacy machine and app shapes are not
|
|
102
|
+
* accepted. Any `machine_id` must equal the first link, any top-level `grant` an app link's, and, on a token without
|
|
103
|
+
* `scope`, any `grantee` and `rev` the app link's (or, with no app link, `grantee` = `sub` and no `rev`). It authorizes
|
|
104
|
+
* nothing.
|
|
189
105
|
* Throws `MissingRevClaimError` for an `app:` link carrying `grant` and no `rev`.
|
|
190
106
|
*/
|
|
191
107
|
export declare function verifyCallToken(options: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
|
|
2
|
-
/** Thrown by `
|
|
2
|
+
/** Thrown by `verifyCallToken` for an `app:` link with a `grant` and no `rev`: the directory predates the claim. */
|
|
3
3
|
export class MissingRevClaimError extends Error {
|
|
4
4
|
constructor() { super('access token has no rev claim: the directory is older than this runtime'); this.name = 'MissingRevClaimError'; }
|
|
5
5
|
}
|
|
@@ -215,85 +215,9 @@ function validatePrincipal(value, kind) {
|
|
|
215
215
|
function keySet(jwks, kid) {
|
|
216
216
|
return jwks instanceof JwksCache ? jwks.forKid(kid) : Promise.resolve(jwks);
|
|
217
217
|
}
|
|
218
|
-
/** @deprecated Use `verifyCallToken`; removed after the peer-token rollout window (plan I2). */
|
|
219
|
-
export async function verifyAccessToken(options) {
|
|
220
|
-
const header = decodeProtectedHeader(options.token);
|
|
221
|
-
if (header.typ !== 'at+jwt' || header.alg !== 'ES256')
|
|
222
|
-
throw new Error('Invalid access token header');
|
|
223
|
-
const keys = await keySet(options.jwks, header.kid);
|
|
224
|
-
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
225
|
-
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'], typ: 'at+jwt',
|
|
226
|
-
});
|
|
227
|
-
if (payload.aud !== options.audience || !validatePrincipal(payload.sub, 'user') || !validatePrincipal(payload.grantee, 'app') ||
|
|
228
|
-
typeof payload.client_id !== 'string' || !id.test(payload.client_id) || typeof payload.grant !== 'string' ||
|
|
229
|
-
typeof payload.exp !== 'number') {
|
|
230
|
-
throw new Error('Invalid access token claims');
|
|
231
|
-
}
|
|
232
|
-
if (payload.rev === undefined)
|
|
233
|
-
throw new MissingRevClaimError();
|
|
234
|
-
if (typeof payload.rev !== 'number' || !Number.isInteger(payload.rev) || payload.rev < 1)
|
|
235
|
-
throw new Error('Invalid access token claims');
|
|
236
|
-
return payload;
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* Verifies a machine token: ES256 with typ `at+jwt`, signature against `jwks`, `iss` = `issuer`, `aud` a string equal
|
|
240
|
-
* to `audience` (the sole audience), and `exp`. `sub` must be `user:<id>`, `grantee` must equal `sub`, `machine_id`
|
|
241
|
-
* must be `runtime:<id>` and `client_id` a non-empty string. A token carrying a `grant`, `rev` or `scope` claim is
|
|
242
|
-
* refused, so an app grant token never verifies here; `verifyAccessToken` requires an `app:` grantee, so a machine
|
|
243
|
-
* token never verifies there.
|
|
244
|
-
*/
|
|
245
|
-
/** @deprecated Use `verifyCallToken`; removed after the peer-token rollout window (plan I2). */
|
|
246
|
-
export async function verifyMachineToken(options) {
|
|
247
|
-
const header = decodeProtectedHeader(options.token);
|
|
248
|
-
if (header.typ !== 'at+jwt' || header.alg !== 'ES256')
|
|
249
|
-
throw new Error('Invalid machine token header');
|
|
250
|
-
const keys = await keySet(options.jwks, header.kid);
|
|
251
|
-
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
252
|
-
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'], typ: 'at+jwt', requiredClaims: ['exp'],
|
|
253
|
-
});
|
|
254
|
-
if (payload.aud !== options.audience || !validatePrincipal(payload.sub, 'user') || payload.grantee !== payload.sub ||
|
|
255
|
-
!validatePrincipal(payload.machine_id, 'runtime') || !nonEmptyString(payload.client_id) || typeof payload.exp !== 'number' ||
|
|
256
|
-
payload.grant !== undefined || payload.rev !== undefined || payload.scope !== undefined) {
|
|
257
|
-
throw new Error('Invalid machine token claims');
|
|
258
|
-
}
|
|
259
|
-
return payload;
|
|
260
|
-
}
|
|
261
218
|
function nonEmptyString(value) {
|
|
262
219
|
return typeof value === 'string' && value !== '';
|
|
263
220
|
}
|
|
264
|
-
function appOrUser(value) {
|
|
265
|
-
return validatePrincipal(value, 'app') || validatePrincipal(value, 'user');
|
|
266
|
-
}
|
|
267
|
-
function isActor(value) {
|
|
268
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value) && nonEmptyString(value.sub);
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* Verifies the outbound token a provider receives (contract 3 D3-TOK-12): ES256 or RS256 with
|
|
272
|
-
* a `kid`, typ `at+jwt`, signature against `jwks` (a `JwksCache` refetches once on an unknown
|
|
273
|
-
* `kid`), `iss` = `issuer`, `aud` exactly `audience` (a trailing-slash difference is refused),
|
|
274
|
-
* and `exp`. `sub` must be a `user:` principal and `grantee` and `initiating_app`
|
|
275
|
-
* an `app:` or `user:` principal; the remaining claims are checked for presence and type only; which of them gates a
|
|
276
|
-
* call is the caller's rule (D3-TOK-13 gates on `scope` alone, and nothing gates on `act`).
|
|
277
|
-
*/
|
|
278
|
-
/** @deprecated Use `verifyCallToken`; removed after the peer-token rollout window (plan I2). */
|
|
279
|
-
export async function verifyOutboundToken(options) {
|
|
280
|
-
const header = decodeProtectedHeader(options.token);
|
|
281
|
-
if (header.typ !== 'at+jwt' || (header.alg !== 'ES256' && header.alg !== 'RS256'))
|
|
282
|
-
throw new Error('Invalid outbound token header');
|
|
283
|
-
if (!nonEmptyString(header.kid))
|
|
284
|
-
throw new Error('Outbound token has no key id');
|
|
285
|
-
const keys = await keySet(options.jwks, header.kid);
|
|
286
|
-
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
287
|
-
issuer: options.issuer, audience: options.audience, algorithms: ['ES256', 'RS256'], typ: 'at+jwt', requiredClaims: ['exp'],
|
|
288
|
-
});
|
|
289
|
-
if (payload.aud !== options.audience || typeof payload.exp !== 'number' || typeof payload.iat !== 'number' ||
|
|
290
|
-
!validatePrincipal(payload.sub, 'user') || !appOrUser(payload.grantee) || !nonEmptyString(payload.client_id) ||
|
|
291
|
-
typeof payload.scope !== 'string' || !appOrUser(payload.initiating_app) || !isActor(payload.act) ||
|
|
292
|
-
!nonEmptyString(payload.jti)) {
|
|
293
|
-
throw new Error('Invalid outbound token claims');
|
|
294
|
-
}
|
|
295
|
-
return payload;
|
|
296
|
-
}
|
|
297
221
|
const MAX_CHAIN_DEPTH = 8;
|
|
298
222
|
const DEVELOPER_CREDENTIAL_LINK = /^developer-credential:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
299
223
|
function isDeveloperCredential(value) {
|
|
@@ -319,17 +243,8 @@ function chainOf(payload) {
|
|
|
319
243
|
return bad();
|
|
320
244
|
return { sub, grant, rev };
|
|
321
245
|
};
|
|
322
|
-
if (p.act === undefined)
|
|
323
|
-
if (p.machine_id !== undefined) {
|
|
324
|
-
if (!validatePrincipal(p.machine_id, 'runtime') || p.grantee !== p.sub ||
|
|
325
|
-
p.grant !== undefined || p.rev !== undefined || p.scope !== undefined)
|
|
326
|
-
return bad();
|
|
327
|
-
return [{ sub: p.machine_id }];
|
|
328
|
-
}
|
|
329
|
-
if (validatePrincipal(p.grantee, 'app') && typeof p.grant === 'string')
|
|
330
|
-
return [appLink(p.grantee, p.grant, p.rev)];
|
|
246
|
+
if (p.act === undefined)
|
|
331
247
|
return bad();
|
|
332
|
-
}
|
|
333
248
|
const chain = [];
|
|
334
249
|
let node = p.act;
|
|
335
250
|
while (node !== undefined) {
|
|
@@ -359,9 +274,9 @@ function chainOf(payload) {
|
|
|
359
274
|
return bad();
|
|
360
275
|
if (p.grant !== undefined && !chain.some((l) => l.sub.startsWith('app:') && l.grant === p.grant))
|
|
361
276
|
return bad();
|
|
362
|
-
// A
|
|
363
|
-
//
|
|
364
|
-
// runs for, not a link of the chain.
|
|
277
|
+
// A token's top-level claims (kept for providers pinned to an older `@north-light/plugin`, which read them instead of
|
|
278
|
+
// `act`) must say what its chain says, or the old and new verifiers authorize different identities from one signed token.
|
|
279
|
+
// Provider tokens (`scope`) are exempt: their `grantee` is the person or app the call runs for, not a link of the chain.
|
|
365
280
|
if (p.scope === undefined) {
|
|
366
281
|
const appLinks = chain.filter((l) => l.sub.startsWith('app:'));
|
|
367
282
|
const appLink = appLinks.find((l) => l.grant !== undefined && l.grant === p.grant) ?? appLinks[0];
|
|
@@ -384,10 +299,10 @@ function chainOf(payload) {
|
|
|
384
299
|
* Verifies a call token of any use (peer, app access, outbound provider, run notice) and returns its actor chain,
|
|
385
300
|
* presenter first. Checks in order: header `typ` `at+jwt`, `alg` ES256 or RS256, non-empty `kid`; signature; `iss`;
|
|
386
301
|
* `aud` a string equal to `audience`; `exp` and `iat` numbers; `sub` = `user:<id>`; `client_id` non-empty; `scope`
|
|
387
|
-
* a string when present; then the chain.
|
|
388
|
-
*
|
|
389
|
-
* `
|
|
390
|
-
*
|
|
302
|
+
* a string when present; then the chain. A token with no `act` is refused: the legacy machine and app shapes are not
|
|
303
|
+
* accepted. Any `machine_id` must equal the first link, any top-level `grant` an app link's, and, on a token without
|
|
304
|
+
* `scope`, any `grantee` and `rev` the app link's (or, with no app link, `grantee` = `sub` and no `rev`). It authorizes
|
|
305
|
+
* nothing.
|
|
391
306
|
* Throws `MissingRevClaimError` for an `app:` link carrying `grant` and no `rev`.
|
|
392
307
|
*/
|
|
393
308
|
export async function verifyCallToken(options) {
|
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.497",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/cli",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.497",
|
|
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.
|
|
21
|
+
"@north-light/identity": "0.3.497",
|
|
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.
|
|
12119
|
+
"version": "0.3.497",
|
|
12120
12120
|
"license": "UNLICENSED"
|
|
12121
12121
|
},
|
|
12122
12122
|
"packages/crouter-env-docker": {
|
|
12123
12123
|
"name": "@north-light/env-docker",
|
|
12124
|
-
"version": "0.3.
|
|
12124
|
+
"version": "0.3.497",
|
|
12125
12125
|
"license": "UNLICENSED"
|
|
12126
12126
|
},
|
|
12127
12127
|
"packages/crouter-identity": {
|
|
12128
12128
|
"name": "@north-light/identity",
|
|
12129
|
-
"version": "0.3.
|
|
12129
|
+
"version": "0.3.497",
|
|
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.
|
|
12137
|
+
"version": "0.3.497",
|
|
12138
12138
|
"license": "UNLICENSED",
|
|
12139
12139
|
"dependencies": {
|
|
12140
|
-
"@north-light/api": "0.3.
|
|
12141
|
-
"@north-light/identity": "0.3.
|
|
12140
|
+
"@north-light/api": "0.3.497",
|
|
12141
|
+
"@north-light/identity": "0.3.497"
|
|
12142
12142
|
}
|
|
12143
12143
|
},
|
|
12144
12144
|
"packages/crouter-sdk": {
|
|
12145
12145
|
"name": "@north-light/sdk",
|
|
12146
|
-
"version": "0.3.
|
|
12146
|
+
"version": "0.3.497",
|
|
12147
12147
|
"license": "UNLICENSED",
|
|
12148
12148
|
"dependencies": {
|
|
12149
|
-
"@north-light/api": "0.3.
|
|
12150
|
-
"@north-light/identity": "0.3.
|
|
12149
|
+
"@north-light/api": "0.3.497",
|
|
12150
|
+
"@north-light/identity": "0.3.497",
|
|
12151
12151
|
"jose": "^6.2.1"
|
|
12152
12152
|
},
|
|
12153
12153
|
"bin": {
|