@north-light/cli 0.3.482 → 0.3.484
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/api/client.d.ts +6 -0
- package/dist/api/client.js +3 -3
- package/dist/api/contract.d.ts +1 -1
- package/dist/api/contract.js +1 -1
- package/dist/api/dto/custom-objects.d.ts +5 -1
- package/dist/api/dto/docs.d.ts +26 -0
- package/dist/api/dto/grants.d.ts +6 -4
- package/dist/api/dto/objects.d.ts +21 -1
- package/dist/api/dto/plugins.d.ts +22 -0
- package/dist/api/dto/plugins.js +0 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -1
- package/dist/api/routes.d.ts +2 -0
- package/dist/api/routes.js +1 -1
- package/dist/builtin-memory/crouter-plugin/deploying.md +107 -0
- package/dist/builtin-memory/crouter-sdk/release-notes.md +79 -0
- package/dist/builtin-memory/northlight-apps/concepts/providers.md +4 -3
- package/dist/builtin-memory/northlight-apps/guides/grant-changes.md +11 -9
- package/dist/clients/attach/viewer.js +579 -578
- package/dist/commands/pkg/plugin-manage.d.ts +4 -0
- package/dist/commands/pkg/plugin-manage.js +18 -16
- package/dist/core/canvas/migrations.js +27 -18
- package/dist/core/command-plugins/bundle.d.ts +4 -1
- package/dist/core/command-plugins/bundle.js +1 -1
- package/dist/core/command-plugins/caller-header.d.ts +10 -0
- package/dist/core/command-plugins/caller-header.js +1 -0
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/endpoint.d.ts +4 -0
- package/dist/core/command-plugins/endpoint.js +1 -1
- package/dist/core/command-plugins/transport/exec-invoke-async.d.ts +11 -0
- package/dist/core/command-plugins/transport/exec-invoke-async.js +1 -0
- package/dist/core/command-plugins/transport/exec-invoke.d.ts +14 -0
- package/dist/core/command-plugins/transport/exec-invoke.js +1 -1
- package/dist/core/command-plugins/transport/http-invoke.d.ts +9 -2
- package/dist/core/command-plugins/transport/http-invoke.js +3 -3
- package/dist/core/command-plugins/transport/http-plan.js +1 -1
- package/dist/core/command-plugins/transport/sign.d.ts +55 -0
- package/dist/core/command-plugins/transport/sign.js +3 -0
- package/dist/core/grants/mirror.d.ts +1 -1
- package/dist/core/grants/mirror.js +2 -2
- package/dist/core/graph/access.d.ts +19 -0
- package/dist/core/graph/access.js +3 -3
- package/dist/core/graph/custom.d.ts +29 -9
- package/dist/core/graph/custom.js +9 -8
- package/dist/core/graph/events.js +8 -8
- package/dist/core/graph/package-kinds.d.ts +25 -0
- package/dist/core/graph/package-kinds.js +1 -0
- package/dist/core/graph/package-reconcile.d.ts +20 -0
- package/dist/core/graph/package-reconcile.js +1 -0
- package/dist/core/graph/reader.d.ts +5 -0
- package/dist/core/graph/reader.js +3 -3
- package/dist/core/graph/receivers.d.ts +2 -1
- package/dist/core/graph/types.d.ts +2 -0
- package/dist/core/graph/watches.d.ts +4 -0
- package/dist/core/graph/watches.js +9 -8
- package/dist/core/peer/directory.d.ts +2 -1
- package/dist/core/peer/directory.js +1 -1
- package/dist/core/peer/ingest.d.ts +6 -1
- package/dist/core/peer/ingest.js +1 -1
- package/dist/core/peer/names.d.ts +2 -0
- package/dist/core/peer/names.js +1 -1
- package/dist/core/peer/outbox-store.d.ts +2 -0
- package/dist/core/peer/receiver.js +1 -1
- package/dist/core/peer/stubs.d.ts +26 -1
- package/dist/core/peer/stubs.js +1 -1
- package/dist/core/plugin-app.d.ts +31 -0
- package/dist/core/plugin-app.js +1 -0
- package/dist/core/plugin-objects-manifest.d.ts +35 -0
- package/dist/core/plugin-objects-manifest.js +1 -0
- package/dist/core/providers/bundles.js +2 -2
- package/dist/core/providers/call.d.ts +3 -0
- package/dist/core/providers/call.js +1 -1
- package/dist/core/secrets.d.ts +11 -0
- package/dist/core/secrets.js +1 -1
- package/dist/daemon/api/app-listener.js +13 -13
- package/dist/daemon/api/handlers/custom-objects.js +3 -3
- package/dist/daemon/api/handlers/objects.js +3 -2
- package/dist/daemon/api/handlers/package-docs.js +1 -1
- package/dist/daemon/api/handlers/plugin-app-token.d.ts +6 -0
- package/dist/daemon/api/handlers/plugin-app-token.js +1 -0
- package/dist/daemon/api/handlers/providers.d.ts +7 -0
- package/dist/daemon/api/handlers/providers.js +1 -1
- package/dist/daemon/api/operations.js +1 -1
- package/dist/daemon/api/server.js +1 -1
- package/dist/daemon/crtrd.js +7 -5
- package/dist/daemon/peer/forward.d.ts +4 -1
- package/dist/daemon/peer/forward.js +1 -1
- package/dist/daemon/plugin-objects/read-leaf.d.ts +20 -0
- package/dist/daemon/plugin-objects/read-leaf.js +2 -0
- package/dist/types.d.ts +16 -0
- package/docs/apps/concepts/providers.md +4 -3
- package/docs/apps/guides/grant-changes.md +11 -9
- package/docs/plugin/deploying.md +107 -0
- package/docs/sdk/release-notes.md +79 -0
- package/package.json +2 -2
- package/packages/crouter-identity/dist/index.d.ts +1 -1
- package/packages/crouter-identity/dist/index.js +6 -2
- package/packages/crouter-identity/package.json +1 -1
- package/runtime.lock.json +12 -12
package/dist/daemon/crtrd.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
var Ee=Object.defineProperty;var a=(t,n)=>Ee(t,"name",{value:n,configurable:!0});import{startPeerOutbox as Re}from"./peer/outbox.js";import{reportMachineStatus as xe}from"../core/peer/directory.js";import{createPeerRelay as Pe}from"./peer/relay-service.js";import{writeFileSync as Ie,readFileSync as De,rmSync as Te,mkdirSync as Z,existsSync as k,chmodSync as Ce}from"node:fs";import{createServer as ee}from"node:http";import{dirname as Le}from"node:path";import{envCrtrdTcp as $e}from"../shared/env.js";import{serveCanvasConversationStore as Ae}from"../core/canvas/sessions.js";import{pollRunObservers as Ne}from"./runs/observer.js";import{randomBytes as Me}from"node:crypto";import{apiSocketPath as Oe,crtrHome as Ge}from"../core/canvas/paths.js";import{activeAppUsers as Fe}from"../core/spaces/users.js";import{ensureAppSpace as He,ensureUserFiles as Be}from"../core/spaces/space.js";import{LAUNCHER_SOCKET as x}from"../launcher/spec.js";import{launcherRequest as I}from"../launcher/client.js";import{installLauncherEventSink as je}from"./launcher-events.js";import{initializeEmptyLayout as qe,layoutNeedsMigration as ze,socketDir as re}from"../core/layout.js";import{migrateLooseKeys as We}from"../core/layout-migrate/loose-keys.js";import{relocateProfileFolders as Ue}from"../core/profiles/relocate.js";import{getNode as Ke,listNodesForSupervision as te,setFrozen as ne}from"../core/canvas/index.js";import{createApiServer as Je}from"./api/server.js";import{createAppListener as Ve}from"./api/app-listener.js";import{installLocalTerminalGrant as Qe}from"../core/grants/local.js";import{onRunEventAppended as Ye}from"../core/runs/events.js";import{runNotices as Xe}from"./run-notices.js";import{createGrantSync as Ze,GRANT_SYNC_INTERVAL_S as er}from"../core/grants/sync.js";import{runRemovalCascades as rr}from"../core/grants/remove.js";import{sweepSandboxes as tr}from"../core/grants/on-sync.js";import{rebuildCronExecutions as nr}from"./cron/run-socket.js";import{hasSyncedGrants as or}from"../core/grants/mirror.js";import{readConfig as ir}from"../core/config.js";import{reconcileUndeliveredTickets as sr}from"./human/sweep.js";import{resetHandedOffDeliveries as ar}from"../core/canvas/deliveries.js";import{resetHandedOffDaemonActions as cr}from"../core/canvas/daemon-actions.js";import{sweepReviews as dr}from"./review/sweep.js";import{pidfilePath as F}from"./pidfile.js";import{rendererWarning as lr}from"../core/runtime/package-health.js";import{DEFAULT_INTERVAL_MS as pr}from"./supervise-cadence.js";import{createResumeDetector as mr}from"./resume-detector.js";import{BrokerSupervisionReconciler as fr}from"./reconcilers/broker-supervision.js";import{ControllerDeathReconciler as ur}from"./reconcilers/controller-death.js";import{NodeLifecycleReconciler as hr}from"./reconcilers/node-lifecycle/tick.js";import{NodeDeadlineReconciler as vr}from"./reconcilers/node-deadline.js";import{CronLaneReconciler as wr}from"./reconcilers/cron-lane.js";import{DeliveryLaneReconciler as gr}from"./reconcilers/delivery-lane.js";import{ActionLaneReconciler as yr}from"./reconcilers/action-lane.js";import{endLiveExecution as Sr,migrateLegacyExecutionIdentities as _r}from"../core/canvas/executions.js";import{revokePriorEpochNodeTokens as br}from"../core/canvas/node-tokens.js";import{PendingReviewSubmitReconciler as kr}from"./reconcilers/pending-review-submit.js";import{StorageMaintenanceReconciler as Er}from"./reconcilers/storage-maintenance.js";import{StorageDueWorkReconciler as Rr}from"./reconcilers/storage-due-work.js";import{BashDeadlineReconciler as xr}from"./reconcilers/bash-deadline.js";import{FaultRetryBackstopReconciler as Pr}from"./reconcilers/fault-retry-backstop.js";import{StructuredRequestReconciler as Ir}from"./reconcilers/structured-request-lane.js";import{reconcileStructuredRequestSidecars as Dr}from"./structured-requests/boot.js";import{captureLivenessSnapshot as oe,capturePidCommand as Tr,captureTeardownSnapshot as ie,isPidAlive as Cr,isAnyPidAlive as se,killProcessTreePids as ae}from"../core/canvas/pid.js";import{bindFleet as Lr,boundFleet as $r}from"../core/runtime/fleet.js";import{bindDaemonControl as Ar,clearDaemonControl as Nr}from"./control.js";import{execDaemon as Mr,spawnDaemon as Or}from"./manage.js";import{reconcileAndMigrateBoot as Gr}from"../core/canvas/boot.js";import{resolveInstallId as Fr}from"../core/canvas/install-id.js";import{setInstallId as Hr}from"../core/runtime/nodes.js";import{claimDaemonOwnership as Br,releaseDaemonOwnership as ce}from"../core/canvas/daemon-owner.js";import{migrate as jr,openDb as de,withCanvasWrite as qr,bindDaemonCanvasOpenHook as zr,clearDaemonCanvasOpenHook as le}from"../core/canvas/db.js";import{brokerSignalBus as H}from"./broker-signals/bus.js";import{brokerSignalStreams as Wr}from"./api/handlers/broker-signals.js";import{reconcilePackageDocuments as Ur}from"../core/graph/package-docs.js";import{startRepoSync as Kr}from"../core/graph/repo-sync/index.js";import{prewarmRecentRecipes as Jr,reapStaleSpares as Vr}from"../core/runtime/warm-pool.js";import{DaemonFleet as Qr}from"./fleet.js";import{BOOT_MIGRATIONS as Yr}from"./boot-migrations.js";import{applyJoinedOwner as Xr}from"./joined-owner.js";import{emitEvent as u}from"../core/events/emit.js";import{operationIdContext as S}from"../core/events/operation-id.js";import{bindDaemonEventSource as Zr}from"../core/events/source.js";import{activateCanvasImport as et,MigrationBlockedError as rt,runForcedCanvasImport as tt}from"../migrations/activation.js";import{convertLegacyProviderStore as nt,convertLegacyScopedStore as ot}from"../core/scoped-state/migrate.js";import{userScopeRoot as it}from"../core/scope.js";import{secretsDir as st}from"../core/layout.js";function B(t,n){return t.includes(` --canvas-home ${n} `)}a(B,"brokerCommandDeclaresCanvasHome");function j(t){return/ --epoch (\S+) /.exec(t)?.[1]??null}a(j,"brokerCommandEpoch");const at=5e3,pe=150;async function ct(t){const n=oe();if(n===null)throw u({level:"error",event:"daemon.census.snapshot_failed",fields:{invariant:"prior-epoch brokers are reaped before readiness"}}),new Error("prior-epoch broker census could not capture the process table");const c=Ge(),m=[];for(const[s,p]of n.processes){const y=p.command;if(p.zombie||!y.includes("broker-cli.js")||!B(y,c)||j(y)===t)continue;const _=y.trim().split(/\s+/).at(-1)??"";_===""||_.endsWith("broker-cli.js")||m.push({pid:s,nodeId:_})}let f=0;const o=[];for(const s of m){const p=Tr(s.pid);if(p===null||!p.includes("broker-cli.js")||!B(p,c)||j(p)===t||p.trim().split(/\s+/).at(-1)!==s.nodeId)continue;const y=ie(s.pid,null);if(!y.reused){try{process.platform==="linux"&&k(x)?await I({operation:"stop",pid:s.pid}):process.kill(s.pid,"SIGTERM")}catch{continue}f++,o.push({pid:s.pid,snap:y})}}const g=Date.now()+at;for(;Date.now()<g&&o.some(s=>se(s.snap.tree.length>0?s.snap.tree:[s.pid]));)await new Promise(s=>setTimeout(s,pe));let v=0;for(const s of o){const p=s.snap.tree.length>0?s.snap.tree:[s.pid];se(p)&&(process.platform==="linux"&&k(x)?await I({operation:"stop",pid:s.pid}):ae(p,"SIGKILL",s.snap.identities),v++)}v>0&&await new Promise(s=>setTimeout(s,pe));const d=oe();if(d===null)throw u({level:"error",event:"daemon.census.snapshot_failed",fields:{invariant:"prior-epoch brokers are reaped before readiness",stage:"final"}}),new Error("prior-epoch broker census could not capture the final process table");const l=[];for(const[s,p]of d.processes){const y=p.command;p.zombie||!y.includes("broker-cli.js")||B(y,c)&&j(y)!==t&&l.push(s)}if(l.length>0)throw new Error(`prior-epoch broker census could not prove termination of pid(s): ${l.join(", ")}`);u({level:"info",event:"daemon.census.completed",fields:{prior_epoch_brokers:m.length,sigtermed:f,sigkilled:v}})}a(ct,"reapPriorEpochBrokers");const dt=5e3;async function lt(t){const n=Array.from(t.entries());if(n.length===0)return;for(const[o]of n){const g=Ke(o)?.status;(g==="active"||g==="idle")&&ne(o,new Date().toISOString())}const c=n.map(([,o])=>({pid:o.pid,snap:ie(o.pid,null),exited:o.handle.exited}));for(const o of c)if(!o.snap.reused)try{process.platform==="linux"&&k(x)?await I({operation:"stop",pid:o.pid}):process.kill(o.pid,"SIGTERM")}catch{}const m=new Promise(o=>{setTimeout(o,dt).unref()});await Promise.allSettled(c.map(o=>Promise.race([o.exited.then(()=>{}),m])));const f=[];for(const o of c)Cr(o.pid)&&(process.platform==="linux"&&k(x)?await I({operation:"stop",pid:o.pid}):ae(o.snap.tree.length>0?o.snap.tree:[o.pid],"SIGKILL",o.snap.identities),f.push(o));await Promise.all(f.map(o=>o.exited));for(const[o]of n)Sr(o,{reason:"daemon-teardown"});u({level:"info",event:"daemon.fleet_teardown.completed",fields:{brokers:c.length,sigkilled:f.length}})}a(lt,"teardownFleet");function me(){Z(Le(F()),{recursive:!0}),Ie(F(),String(process.pid),"utf8")}a(me,"writePidfile");function fe(t){return typeof t=="object"&&t!==null&&t.code==="ENOENT"}a(fe,"isMissingPathError");function ue(t){const n=F();let c;try{c=De(n,"utf8")}catch(m){if(fe(m))return;throw m}if(c.trim()!==String(t))throw new Error(`daemon readiness mirror does not name this invocation (expected pid ${t})`);try{Te(n)}catch(m){if(fe(m))return;throw m}}a(ue,"removeOwnedPidfileMirror");const pt={acceptsDetachedWork:a(()=>!0,"acceptsDetachedWork"),registerDetached:a(t=>{t.catch(()=>{})},"registerDetached")},q=new fr,mt=new ur,ft=new hr,ut=new vr,ht=new wr,vt=new gr,wt=new yr,gt=new kr,yt=new Er,St=new Rr,_t=new xr,bt=new Pr,kt=new Ir;async function Et(t=Date.now(),n=pt){const c=$r(),m=await q.runFleetPreamble(c);let f,o;try{const v=te();f=v.filter(d=>d.status==="active"||d.status==="idle"),o=v.filter(d=>d.status==="done"||d.status==="canceled"||d.status==="dead")}catch(v){u({level:"error",event:"canvas.node_list.failed",error:v});return}await q.run(t,{rows:f,fleet:c,lifecycle:n}),mt.run(t,{rows:f});const g=await ft.run(t,{rows:[...f,...o],fleet:c});ut.run(t),kt.run(t),ht.run(t,{lifecycle:n}),vt.run(t,{lifecycle:n}),wt.run(t,{lifecycle:n}),gt.run(t,{lifecycle:n}),yt.run(t,{lifecycle:n,fleet:c}),St.run(t,{lifecycle:n}),Ne(),bt.run(t,{fleet:c,lifecycle:n}),await _t.run(t,{rows:f}),q.warnIfSaturated({fleet:c,lifecycle:n,thresholds:m,frozen:g})}a(Et,"superviseTick");async function Rt(t){Z(re(),{recursive:!0,mode:493}),process.platform==="linux"&&Ce(re(),493);const n=Oe();if(k(n))throw new Error(`layout refusal cannot bind occupied socket: ${n}`);const c=ee((d,l)=>{if(d.method==="GET"&&d.url==="/healthz"){l.writeHead(200,{"content-type":"application/json; charset=utf-8"}),l.end(JSON.stringify({ok:!1,daemon_up:!0,store_accessible:!1,brokers_reconciled:!1,startup_phase:"blocked",startup_blocked:"layout_migration_required"}));return}l.writeHead(503,{"content-type":"application/json; charset=utf-8"}),l.end(JSON.stringify({error:{code:"layout_migration_required",message:"run crtr sys migrate while the runtime is offline",type:"server_error",origin:"runtime",retryable:!1}}))});await new Promise((d,l)=>{c.once("error",l),c.listen(n,()=>{c.off("error",l),d()})});let m;const f=t.tcp??$e();if(f){const d=/^(.*):(\d+)$/.exec(f);if(!d||Number(d[2])>65535)throw new Error(`invalid refusal TCP address: ${f}`);const l=ee(c.listeners("request")[0]);await new Promise((s,p)=>{l.once("error",p),l.listen(Number(d[2]),d[1],()=>{l.off("error",p),s()})}),m=l}me();let o;const g=a(()=>(o??=(async()=>{c.closeAllConnections(),m?.closeAllConnections(),await Promise.all([c,m].filter(d=>d!==void 0).map(d=>new Promise((l,s)=>d.close(p=>p?s(p):l())))),ue(process.pid),process.off("SIGINT",v),process.off("SIGTERM",v)})(),o),"cleanup"),v=a(()=>{g().then(()=>process.exit(0),d=>{process.stderr.write(`[crtrd] layout refusal cleanup failed: ${String(d)}
|
|
2
|
-
`),process.exit(1)})},"onSignal");return process.on("SIGINT",v),process.on("SIGTERM",v),g}a(Rt,"serveLayoutRefusal");async function xt(t={}){if(ze())return Rt(t);qe();const n=
|
|
1
|
+
var Ee=Object.defineProperty;var a=(t,n)=>Ee(t,"name",{value:n,configurable:!0});import{startPeerOutbox as Re}from"./peer/outbox.js";import{reportMachineStatus as xe}from"../core/peer/directory.js";import{createPeerRelay as Pe}from"./peer/relay-service.js";import{writeFileSync as Ie,readFileSync as Te,rmSync as De,mkdirSync as Z,existsSync as k,chmodSync as Ce}from"node:fs";import{createServer as ee}from"node:http";import{dirname as $e}from"node:path";import{envCrtrdTcp as Le}from"../shared/env.js";import{serveCanvasConversationStore as Ae}from"../core/canvas/sessions.js";import{pollRunObservers as Ne}from"./runs/observer.js";import{randomBytes as Me}from"node:crypto";import{apiSocketPath as Oe,crtrHome as Ge}from"../core/canvas/paths.js";import{activeAppUsers as Fe}from"../core/spaces/users.js";import{ensureAppSpace as He,ensureUserFiles as je}from"../core/spaces/space.js";import{LAUNCHER_SOCKET as x}from"../launcher/spec.js";import{launcherRequest as I}from"../launcher/client.js";import{installLauncherEventSink as Be}from"./launcher-events.js";import{initializeEmptyLayout as qe,layoutNeedsMigration as ze,socketDir as re}from"../core/layout.js";import{migrateLooseKeys as We}from"../core/layout-migrate/loose-keys.js";import{relocateProfileFolders as Ue}from"../core/profiles/relocate.js";import{getNode as Ke,listNodesForSupervision as te,setFrozen as ne}from"../core/canvas/index.js";import{createApiServer as Je}from"./api/server.js";import{createAppListener as Ve}from"./api/app-listener.js";import{installLocalTerminalGrant as Qe}from"../core/grants/local.js";import{onRunEventAppended as Ye}from"../core/runs/events.js";import{runNotices as Xe}from"./run-notices.js";import{createGrantSync as Ze,GRANT_SYNC_INTERVAL_S as er}from"../core/grants/sync.js";import{runRemovalCascades as rr}from"../core/grants/remove.js";import{sweepSandboxes as tr}from"../core/grants/on-sync.js";import{rebuildCronExecutions as nr}from"./cron/run-socket.js";import{hasSyncedGrants as or}from"../core/grants/mirror.js";import{readConfig as ir}from"../core/config.js";import{reconcileUndeliveredTickets as sr}from"./human/sweep.js";import{resetHandedOffDeliveries as ar}from"../core/canvas/deliveries.js";import{resetHandedOffDaemonActions as cr}from"../core/canvas/daemon-actions.js";import{sweepReviews as dr}from"./review/sweep.js";import{pidfilePath as F}from"./pidfile.js";import{rendererWarning as lr}from"../core/runtime/package-health.js";import{DEFAULT_INTERVAL_MS as pr}from"./supervise-cadence.js";import{createResumeDetector as mr}from"./resume-detector.js";import{BrokerSupervisionReconciler as fr}from"./reconcilers/broker-supervision.js";import{ControllerDeathReconciler as ur}from"./reconcilers/controller-death.js";import{NodeLifecycleReconciler as hr}from"./reconcilers/node-lifecycle/tick.js";import{NodeDeadlineReconciler as vr}from"./reconcilers/node-deadline.js";import{CronLaneReconciler as wr}from"./reconcilers/cron-lane.js";import{DeliveryLaneReconciler as gr}from"./reconcilers/delivery-lane.js";import{ActionLaneReconciler as yr}from"./reconcilers/action-lane.js";import{endLiveExecution as Sr,migrateLegacyExecutionIdentities as _r}from"../core/canvas/executions.js";import{revokePriorEpochNodeTokens as br}from"../core/canvas/node-tokens.js";import{PendingReviewSubmitReconciler as kr}from"./reconcilers/pending-review-submit.js";import{StorageMaintenanceReconciler as Er}from"./reconcilers/storage-maintenance.js";import{StorageDueWorkReconciler as Rr}from"./reconcilers/storage-due-work.js";import{BashDeadlineReconciler as xr}from"./reconcilers/bash-deadline.js";import{FaultRetryBackstopReconciler as Pr}from"./reconcilers/fault-retry-backstop.js";import{StructuredRequestReconciler as Ir}from"./reconcilers/structured-request-lane.js";import{reconcileStructuredRequestSidecars as Tr}from"./structured-requests/boot.js";import{captureLivenessSnapshot as oe,capturePidCommand as Dr,captureTeardownSnapshot as ie,isPidAlive as Cr,isAnyPidAlive as se,killProcessTreePids as ae}from"../core/canvas/pid.js";import{bindFleet as $r,boundFleet as Lr}from"../core/runtime/fleet.js";import{bindDaemonControl as Ar,clearDaemonControl as Nr}from"./control.js";import{execDaemon as Mr,spawnDaemon as Or}from"./manage.js";import{reconcileAndMigrateBoot as Gr}from"../core/canvas/boot.js";import{resolveInstallId as Fr}from"../core/canvas/install-id.js";import{setInstallId as Hr}from"../core/runtime/nodes.js";import{claimDaemonOwnership as jr,releaseDaemonOwnership as ce}from"../core/canvas/daemon-owner.js";import{migrate as Br,openDb as de,withCanvasWrite as qr,bindDaemonCanvasOpenHook as zr,clearDaemonCanvasOpenHook as le}from"../core/canvas/db.js";import{brokerSignalBus as H}from"./broker-signals/bus.js";import{brokerSignalStreams as Wr}from"./api/handlers/broker-signals.js";import{reconcilePackages as Ur}from"../core/graph/package-reconcile.js";import{startRepoSync as Kr}from"../core/graph/repo-sync/index.js";import{prewarmRecentRecipes as Jr,reapStaleSpares as Vr}from"../core/runtime/warm-pool.js";import{DaemonFleet as Qr}from"./fleet.js";import{BOOT_MIGRATIONS as Yr}from"./boot-migrations.js";import{applyJoinedOwner as Xr}from"./joined-owner.js";import{emitEvent as u}from"../core/events/emit.js";import{operationIdContext as S}from"../core/events/operation-id.js";import{bindDaemonEventSource as Zr}from"../core/events/source.js";import{activateCanvasImport as et,MigrationBlockedError as rt,runForcedCanvasImport as tt}from"../migrations/activation.js";import{convertLegacyProviderStore as nt,convertLegacyScopedStore as ot}from"../core/scoped-state/migrate.js";import{userScopeRoot as it}from"../core/scope.js";import{secretsDir as st}from"../core/layout.js";function j(t,n){return t.includes(` --canvas-home ${n} `)}a(j,"brokerCommandDeclaresCanvasHome");function B(t){return/ --epoch (\S+) /.exec(t)?.[1]??null}a(B,"brokerCommandEpoch");const at=5e3,pe=150;async function ct(t){const n=oe();if(n===null)throw u({level:"error",event:"daemon.census.snapshot_failed",fields:{invariant:"prior-epoch brokers are reaped before readiness"}}),new Error("prior-epoch broker census could not capture the process table");const c=Ge(),m=[];for(const[s,p]of n.processes){const y=p.command;if(p.zombie||!y.includes("broker-cli.js")||!j(y,c)||B(y)===t)continue;const _=y.trim().split(/\s+/).at(-1)??"";_===""||_.endsWith("broker-cli.js")||m.push({pid:s,nodeId:_})}let f=0;const o=[];for(const s of m){const p=Dr(s.pid);if(p===null||!p.includes("broker-cli.js")||!j(p,c)||B(p)===t||p.trim().split(/\s+/).at(-1)!==s.nodeId)continue;const y=ie(s.pid,null);if(!y.reused){try{process.platform==="linux"&&k(x)?await I({operation:"stop",pid:s.pid}):process.kill(s.pid,"SIGTERM")}catch{continue}f++,o.push({pid:s.pid,snap:y})}}const g=Date.now()+at;for(;Date.now()<g&&o.some(s=>se(s.snap.tree.length>0?s.snap.tree:[s.pid]));)await new Promise(s=>setTimeout(s,pe));let v=0;for(const s of o){const p=s.snap.tree.length>0?s.snap.tree:[s.pid];se(p)&&(process.platform==="linux"&&k(x)?await I({operation:"stop",pid:s.pid}):ae(p,"SIGKILL",s.snap.identities),v++)}v>0&&await new Promise(s=>setTimeout(s,pe));const d=oe();if(d===null)throw u({level:"error",event:"daemon.census.snapshot_failed",fields:{invariant:"prior-epoch brokers are reaped before readiness",stage:"final"}}),new Error("prior-epoch broker census could not capture the final process table");const l=[];for(const[s,p]of d.processes){const y=p.command;p.zombie||!y.includes("broker-cli.js")||j(y,c)&&B(y)!==t&&l.push(s)}if(l.length>0)throw new Error(`prior-epoch broker census could not prove termination of pid(s): ${l.join(", ")}`);u({level:"info",event:"daemon.census.completed",fields:{prior_epoch_brokers:m.length,sigtermed:f,sigkilled:v}})}a(ct,"reapPriorEpochBrokers");const dt=5e3;async function lt(t){const n=Array.from(t.entries());if(n.length===0)return;for(const[o]of n){const g=Ke(o)?.status;(g==="active"||g==="idle")&&ne(o,new Date().toISOString())}const c=n.map(([,o])=>({pid:o.pid,snap:ie(o.pid,null),exited:o.handle.exited}));for(const o of c)if(!o.snap.reused)try{process.platform==="linux"&&k(x)?await I({operation:"stop",pid:o.pid}):process.kill(o.pid,"SIGTERM")}catch{}const m=new Promise(o=>{setTimeout(o,dt).unref()});await Promise.allSettled(c.map(o=>Promise.race([o.exited.then(()=>{}),m])));const f=[];for(const o of c)Cr(o.pid)&&(process.platform==="linux"&&k(x)?await I({operation:"stop",pid:o.pid}):ae(o.snap.tree.length>0?o.snap.tree:[o.pid],"SIGKILL",o.snap.identities),f.push(o));await Promise.all(f.map(o=>o.exited));for(const[o]of n)Sr(o,{reason:"daemon-teardown"});u({level:"info",event:"daemon.fleet_teardown.completed",fields:{brokers:c.length,sigkilled:f.length}})}a(lt,"teardownFleet");function me(){Z($e(F()),{recursive:!0}),Ie(F(),String(process.pid),"utf8")}a(me,"writePidfile");function fe(t){return typeof t=="object"&&t!==null&&t.code==="ENOENT"}a(fe,"isMissingPathError");function ue(t){const n=F();let c;try{c=Te(n,"utf8")}catch(m){if(fe(m))return;throw m}if(c.trim()!==String(t))throw new Error(`daemon readiness mirror does not name this invocation (expected pid ${t})`);try{De(n)}catch(m){if(fe(m))return;throw m}}a(ue,"removeOwnedPidfileMirror");const pt={acceptsDetachedWork:a(()=>!0,"acceptsDetachedWork"),registerDetached:a(t=>{t.catch(()=>{})},"registerDetached")},q=new fr,mt=new ur,ft=new hr,ut=new vr,ht=new wr,vt=new gr,wt=new yr,gt=new kr,yt=new Er,St=new Rr,_t=new xr,bt=new Pr,kt=new Ir;async function Et(t=Date.now(),n=pt){const c=Lr(),m=await q.runFleetPreamble(c);let f,o;try{const v=te();f=v.filter(d=>d.status==="active"||d.status==="idle"),o=v.filter(d=>d.status==="done"||d.status==="canceled"||d.status==="dead")}catch(v){u({level:"error",event:"canvas.node_list.failed",error:v});return}await q.run(t,{rows:f,fleet:c,lifecycle:n}),mt.run(t,{rows:f});const g=await ft.run(t,{rows:[...f,...o],fleet:c});ut.run(t),kt.run(t),ht.run(t,{lifecycle:n}),vt.run(t,{lifecycle:n}),wt.run(t,{lifecycle:n}),gt.run(t,{lifecycle:n}),yt.run(t,{lifecycle:n,fleet:c}),St.run(t,{lifecycle:n}),Ne(),bt.run(t,{fleet:c,lifecycle:n}),await _t.run(t,{rows:f}),q.warnIfSaturated({fleet:c,lifecycle:n,thresholds:m,frozen:g})}a(Et,"superviseTick");async function Rt(t){Z(re(),{recursive:!0,mode:493}),process.platform==="linux"&&Ce(re(),493);const n=Oe();if(k(n))throw new Error(`layout refusal cannot bind occupied socket: ${n}`);const c=ee((d,l)=>{if(d.method==="GET"&&d.url==="/healthz"){l.writeHead(200,{"content-type":"application/json; charset=utf-8"}),l.end(JSON.stringify({ok:!1,daemon_up:!0,store_accessible:!1,brokers_reconciled:!1,startup_phase:"blocked",startup_blocked:"layout_migration_required"}));return}l.writeHead(503,{"content-type":"application/json; charset=utf-8"}),l.end(JSON.stringify({error:{code:"layout_migration_required",message:"run crtr sys migrate while the runtime is offline",type:"server_error",origin:"runtime",retryable:!1}}))});await new Promise((d,l)=>{c.once("error",l),c.listen(n,()=>{c.off("error",l),d()})});let m;const f=t.tcp??Le();if(f){const d=/^(.*):(\d+)$/.exec(f);if(!d||Number(d[2])>65535)throw new Error(`invalid refusal TCP address: ${f}`);const l=ee(c.listeners("request")[0]);await new Promise((s,p)=>{l.once("error",p),l.listen(Number(d[2]),d[1],()=>{l.off("error",p),s()})}),m=l}me();let o;const g=a(()=>(o??=(async()=>{c.closeAllConnections(),m?.closeAllConnections(),await Promise.all([c,m].filter(d=>d!==void 0).map(d=>new Promise((l,s)=>d.close(p=>p?s(p):l())))),ue(process.pid),process.off("SIGINT",v),process.off("SIGTERM",v)})(),o),"cleanup"),v=a(()=>{g().then(()=>process.exit(0),d=>{process.stderr.write(`[crtrd] layout refusal cleanup failed: ${String(d)}
|
|
2
|
+
`),process.exit(1)})},"onSignal");return process.on("SIGINT",v),process.on("SIGTERM",v),g}a(Rt,"serveLayoutRefusal");async function xt(t={}){if(ze())return Rt(t);qe();const n=jr(Me(16).toString("hex"));if(!n.claimed){if(n.reason==="incumbent_alive")process.stderr.write(`[crtrd] already running (pid ${n.incumbentPid??"?"})
|
|
3
3
|
`);else{const r=[n.incumbentPid!==void 0?`pid ${n.incumbentPid}`:void 0,n.verdict!==void 0?`verdict ${n.verdict}`:void 0].filter(i=>i!==void 0),e=r.length>0?` (${r.join(", ")})`:"";process.stderr.write(`[crtrd] claim lost: ${n.reason}${e}
|
|
4
4
|
`),n.reason==="own_identity_indeterminate"&&process.stderr.write("[crtrd] cannot read its own process identity. crouter needs /proc on Linux, or the `ps` command elsewhere (Debian/Ubuntu: apt-get install procps).\n"),(n.reason==="own_identity_indeterminate"||n.reason==="incumbent_malformed")&&(process.exitCode=1)}return async()=>{}}const c=n.claim;let m=null;try{await ct(c.token),ot({kind:"user",root:it()});for(const e of Ue())process.stderr.write(`[crtrd] ${e}
|
|
5
5
|
`);if(process.platform==="darwin"){const{readConfig:e,updateConfig:i}=await import("../core/config.js");if(e("user").paths?.user_files===void 0){const{homedir:w}=await import("node:os");i("user",X=>{X.paths??={},X.paths.user_files??=w()})}}await nt(st());for(const e of We())process.stderr.write(`[crtrd] ${e}
|
|
6
|
-
`);
|
|
6
|
+
`);Br(de()),zr(e=>H.install(e));const r=qr(e=>Ur(e));for(const e of r.conflicts)process.stderr.write(`[crtrd] plugin documents not reconciled: ${e.message}
|
|
7
7
|
`);for(const e of r.invalid)process.stderr.write(`[crtrd] package file is not a document: ${e.path}: ${e.error}
|
|
8
8
|
`);for(const e of r.quarantined)process.stderr.write(`[crtrd] plugin "${e.name}" has a damaged ${e.file} (${e.reason}); ${e.movedTo!==void 0?`moved to ${e.movedTo}`:"left in place and skipped"}
|
|
9
|
-
`);
|
|
9
|
+
`);for(const e of r.kinds.refused)process.stderr.write(`[crtrd] plugin "${e.plugin}" kind ${e.kind} not registered: ${e.message}
|
|
10
|
+
`);for(const e of r.kinds.invalid)process.stderr.write(`[crtrd] plugin "${e.plugin}" declares invalid object kinds or scopes; no kinds registered and no app token: ${e.error}
|
|
11
|
+
`);m=Xr(),process.platform==="linux"&&k(x)&&(Be(),await I({operation:"users.sync",users:Fe()})),br(c.token)}catch(r){le(),H.close();try{const{released:e}=ce(c);if(!e)throw new Error("daemon ownership claim was not released")}catch(e){throw new AggregateError([r,e],"daemon startup failed and cleanup also failed")}throw r}let f=!0,o,g=Promise.resolve(),v=!1,d,l;const s=new Set;let p=!1,y=!1,_,L,z=!1,A,T,D,C,M;const he=mr();let E,h={phase:"initializing",message:"daemon startup is running the canvas import and normal services are unavailable",blockerPaths:[]},P="initial startup activation",ve=null;function we(){return h.phase==="ready"?null:{phase:h.phase,message:h.message,blockerPaths:h.blockerPaths}}a(we,"startupBlock");function b(r){s.add(r),r.finally(()=>{s.delete(r)}).catch(()=>{})}a(b,"registerDetached");const W={acceptsDetachedWork:a(()=>f,"acceptsDetachedWork"),registerDetached:b};Kr(W,de);async function ge(){try{f=!1,Wr.close(),le(),H.close(),d!==void 0&&(clearInterval(d),d=void 0),D!==void 0&&(clearInterval(D),D=void 0),M?.(),M=void 0;let r;do r=g,await r;while(r!==g);for(;s.size>0;)await Promise.allSettled([...s]);if(l!==void 0&&(await lt(l),l.dispose()),L!==void 0&&!z&&(z=!0,await L.close()),C!==void 0){const e=C;C=void 0,await e.stop()}if(T!==void 0){const e=T;T=void 0,await e.close()}if(A!==void 0){const e=A;A=void 0,await e.close()}if(p||(ue(process.pid),p=!0),p&&!y){const{released:e}=ce(c);if(!e)throw new Error("daemon ownership claim was not released");y=!0}}finally{Nr(),process.off("SIGINT",J),process.off("SIGTERM",V),process.off("exit",Q)}}a(ge,"cleanupAttempt");const N=1e3;let U=!1;function ye(){return U?{graceMs:N}:(U=!0,S.fresh(()=>{u({level:"info",event:"daemon.handover.armed",fields:{pid:process.pid,grace_ms:N}})}),setTimeout(()=>{Se()},N),{graceMs:N})}a(ye,"requestHandover");async function Se(){try{await R()}catch(r){const e=r instanceof Error?r.stack??r.message:String(r);process.stderr.write(`[crtrd] handover cleanup failed:
|
|
10
12
|
${e}
|
|
11
13
|
`),process.exit(1)}try{process.platform==="linux"&&k(x)&&(await I({operation:"daemon.handover"}),process.exit(0)),process.pid===1&&await Mr();const r=await Or({waitForJoinLock:!0});S.fresh(()=>{u({level:"info",event:"daemon.handover.completed",fields:{predecessor_pid:process.pid,successor_pid:r.pid??r.existing_pid??null,spawned:r.started}})})}catch(r){S.fresh(()=>{u({level:"error",event:"daemon.handover.successor_failed",error:r})});const e=r instanceof Error?r.message:String(r);process.stderr.write(`[crtrd] handover successor failed to start: ${e}
|
|
12
14
|
`),process.exit(1)}process.exit(0)}a(Se,"performHandover");function R(){if(_!==void 0)return _;const r=ge();return _=r,r.then(()=>{_===r&&(_=void 0)},()=>{_===r&&(_=void 0)}),r}a(R,"requestCleanup");async function K(r){try{await R(),process.exit(0)}catch(e){process.exitCode=1;const i=e instanceof Error?e.stack??`${e.name}: ${e.message}`:String(e);process.stderr.write(`[crtrd] ${r} cleanup failed:
|
|
13
15
|
${i}
|
|
14
|
-
`),process.exit(1)}}a(K,"handleSignal");function J(){K("SIGINT")}a(J,"onSigint");function V(){K("SIGTERM")}a(V,"onSigterm");function Q(){R().catch(()=>{})}a(Q,"onExit");function O(r){f&&(g=g.then(r).then(()=>{},e=>{S.fresh(()=>{u({level:"error",event:"daemon.lane_job.failed",error:e})})}))}a(O,"enqueue");function Y(){!f||v||(he.check()&&E&&(u({level:"info",event:"daemon.resume.detected"}),b(E().catch(()=>{}))),v=!0,O(async()=>{if(v=!1,!!f)try{await Et(Date.now(),W)}catch(r){S.fresh(()=>{u({level:"error",event:"daemon.tick.failed",error:r})})}}))}a(Y,"enqueueTick");let
|
|
16
|
+
`),process.exit(1)}}a(K,"handleSignal");function J(){K("SIGINT")}a(J,"onSigint");function V(){K("SIGTERM")}a(V,"onSigterm");function Q(){R().catch(()=>{})}a(Q,"onExit");function O(r){f&&(g=g.then(r).then(()=>{},e=>{S.fresh(()=>{u({level:"error",event:"daemon.lane_job.failed",error:e})})}))}a(O,"enqueue");function Y(){!f||v||(he.check()&&E&&(u({level:"info",event:"daemon.resume.detected"}),b(E().catch(()=>{}))),v=!0,O(async()=>{if(v=!1,!!f)try{await Et(Date.now(),W)}catch(r){S.fresh(()=>{u({level:"error",event:"daemon.tick.failed",error:r})})}}))}a(Y,"enqueueTick");let $=null;async function _e(){if($!==null)throw{status:500,code:"admission_failed",message:`daemon admission previously failed: ${$ instanceof Error?$.message:String($)}`};if(h.phase==="ready")return{admitted:!1,already_admitted:!0,startup_phase:"ready"};if(h.phase!=="prepared"&&h.phase!=="recovering")throw{status:409,code:"not_prepared",message:h.message};const r=h.phase==="prepared";try{return await G(),h={phase:"ready",message:"",blockerPaths:[]},r?{admitted:!0,already_admitted:!1,startup_phase:"ready"}:{admitted:!1,already_admitted:!0,startup_phase:"ready"}}catch(e){throw $=e,{status:500,code:"admission_failed",message:`daemon admission failed: ${e instanceof Error?e.message:String(e)}`}}}a(_e,"admitStartup");async function be(r){if(P!==null)throw{status:409,code:"migration_in_progress",message:`the canvas import is already running (${P})`};P="repair";try{const e=await tt({dryRun:r.dryRun});if(r.dryRun||e.pending||h.phase!=="blocked")return{result:e,resumed:!1};if(t.prepare)return h={phase:"prepared",message:"daemon storage is prepared and normal services are unavailable until admission",blockerPaths:[]},{result:e,resumed:!1};try{return await G(),h={phase:"ready",message:"",blockerPaths:[]},{result:e,resumed:!0}}catch(i){const w=i instanceof Error?i.message:String(i);return setTimeout(()=>{R().catch(()=>{})},1e3),{result:e,resumed:!1,resumeError:w}}}finally{P=null}}a(be,"repairMigrations");async function ke(){o=t.intervalMs??pr;const r=c.token;l=new Qr({epoch:r,enqueue:O}),$r(l),h={phase:"recovering",message:"daemon startup recovery is running and normal services are unavailable",blockerPaths:[]},S.fresh(()=>{u({level:"info",event:"daemon.started",fields:{pid:process.pid,interval_ms:o}})});const e=Fr();if(Hr(e),S.fresh(()=>{u({level:"info",event:"daemon.install_id.resolved",fields:{install_id:e}})}),S.fresh(()=>{let i;try{i=lr()}catch{return}i!==null&&u({level:"warn",event:"daemon.startup.warning",fields:{stage:"termrender",warning:i.slice(0,2048)}})}),await S.fresh(async()=>{await dr(),await sr(),ar(Date.now()),cr(Date.now())}),S.fresh(()=>{const{reconcile:i,migrated:w}=Gr(_r);i.changed&&u({level:"info",event:"daemon.boot_change.reconciled",fields:{cleared_count:i.cleared.length,same_boot_proven:i.sameBootProven}}),w.length>0&&u({level:"info",event:"daemon.pid_identity.migrated",fields:{migrated_count:w.length}})}),S.fresh(()=>{for(const i of te())i.status!=="active"||i.pi_pid!=null||i.frozen_at!=null||i.respawn_not_before!=null||(ne(i.node_id,new Date().toISOString()),u({level:"warn",event:"daemon.startup.launch_debt_recorded",node_id:i.node_id}))}),S.fresh(()=>{Tr();for(const i of Yr){const w=i.apply();w.changed&&u({level:"info",event:"daemon.boot_migration.applied",fields:{migration:i.id,...w.summary}})}}),b(nr()),E){const i=a(()=>{b(E().catch(()=>{})),b(xe().then(w=>C?.observe(w)).then(()=>{},w=>{u({level:"warn",event:"daemon.peer.status_failed",error:w})}))},"refresh");b(rr().catch(()=>{})),b(tr().then(({completion:w})=>w).catch(w=>{u({level:"error",event:"daemon.grants.sweep_failed",error:w})})),i(),D=setInterval(i,er*1e3),D.unref(),M=Re({joined:!0,register:b})}O(()=>{Vr(),Jr()}),d=setInterval(Y,o),Y()}a(ke,"completeStartup");function G(){return ve??=ke().catch(r=>{throw h={phase:"initializing",message:"daemon startup recovery failed and normal services are unavailable",blockerPaths:[]},r})}a(G,"resumeStartup");try{process.on("SIGINT",J),process.on("SIGTERM",V),process.on("exit",Q),Zr(),m!==null&&u({level:"info",event:"daemon.joined_owner.applied",fields:{owner:m.owner,rows:m.rows}}),A=await Ae();const r=c.token;Ar({epoch:r,requestHandover:ye,registerDetached:b,startupBlock:we,admitStartup:_e,repairMigrations:be}),me(),L=Je({tcp:t.tcp}),await L.ready;const e=ir("user").runtime;e?.issuer?(E=Ze(e.issuer,b),e.unsandboxed===!0&&(C=Pe({syncGrants:E})),Ye(i=>Xe().appended(i))):Qe(),e?.unsandboxed===!0&&e.listener!==void 0&&u({level:"warn",event:"daemon.app_listener.refused_unsandboxed",fields:{reason:"runtime.unsandboxed is set; runtime.listener is ignored"}}),e!==void 0&&e.listener!==void 0&&e.unsandboxed!==!0&&(T=Ve(e,()=>or(e.issuer.replace(/\/$/,"")),E),await T.ready);try{await et()}catch(i){if(P=null,i instanceof rt)return h={phase:"blocked",message:i.message,blockerPaths:i.blockerPaths},R;throw i}process.platform==="linux"&&k(x)&&(je(),await He("app:terminal"));try{t.prepare?h={phase:"prepared",message:"daemon storage is prepared and normal services are unavailable until admission",blockerPaths:[]}:(await G(),h={phase:"ready",message:"",blockerPaths:[]})}finally{P=null}return R}catch(r){try{await R()}catch(e){throw new AggregateError([r,e],"daemon startup failed and cleanup also failed")}throw r}}a(xt,"runDaemon");var eo=xt;export{j as brokerCommandDeclaresCanvasHome,B as brokerCommandEpoch,eo as default,xt as runDaemon,Et as superviseTick,lt as teardownFleet};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ApiError } from '../../api/errors.js';
|
|
2
2
|
import type { DatabaseSync } from 'node:sqlite';
|
|
3
|
-
import type { ObjectRow } from '../../core/graph/types.js';
|
|
3
|
+
import type { ObjectRow, Privacy } from '../../core/graph/types.js';
|
|
4
4
|
import { type Envelope, type EnvelopeCaller } from '../../core/peer/envelope.js';
|
|
5
5
|
import { type PeerRequest } from '../../core/peer/client.js';
|
|
6
6
|
import { type HomeDeps } from '../../core/peer/home.js';
|
|
7
|
+
import { type KindDeclaration } from '../../core/peer/stubs.js';
|
|
7
8
|
import { peerCall } from '../../core/peer/client.js';
|
|
8
9
|
import type { RequestContext, RequestPrincipal } from '../api/router.js';
|
|
9
10
|
export interface MachineLabel {
|
|
@@ -66,4 +67,6 @@ export declare function remoteStub(db: DatabaseSync, id: string, type: string, m
|
|
|
66
67
|
handle: string;
|
|
67
68
|
external_id?: string | null;
|
|
68
69
|
} | null;
|
|
70
|
+
kind_decl?: KindDeclaration;
|
|
71
|
+
privacy?: Privacy;
|
|
69
72
|
}): ObjectRow;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var _=Object.defineProperty;var t=(e,n)=>_(e,"name",{value:n,configurable:!0});import{ApiError as p}from"../../api/errors.js";import{openDb as v}from"../../core/canvas/db.js";import{getById as x,nodeObject as $}from"../../core/graph/objects.js";import{envelopeFor as g}from"../../core/peer/envelope.js";import{isUnreachable as j}from"../../core/peer/client.js";import{machineReach as D}from"../../core/peer/directory.js";import{defaultHomeDeps as O,homeOf as S,relocate as q}from"../../core/peer/home.js";import{ensureStub as k,rehomeStub as A,stubOf as E}from"../../core/peer/stubs.js";import{peerCall as F}from"../../core/peer/client.js";const C={...O,call:t((e,n,o,a)=>F(e,n,{...o===void 0?{}:{envelope:o},...a===void 0?{}:{signal:a}}),"call"),envelope:t(e=>g(e),"envelope"),label:t(async e=>{try{const n=await D(e);return{id:e,name:n?.name??e}}catch{return{id:e,name:e}}},"label")},M={current:C},N=t(e=>e.class==="person"||e.class==="broker"||e.class==="cron","originates"),m=t(e=>{const n=e?.error;return typeof n=="object"&&n!==null&&typeof n.code=="string"?n.code:null},"errorCode");function L(e,n){const o=n?.error;return new p(e,o?.code??"error",o?.message??`the home machine answered ${e}`,o?.details)}t(L,"relayError");function P(e,n){return new p(503,"runtime_unreachable",`No machine of yours answers for ${e} (unreachable: ${n.join(", ")}).`,{id:e,unreachable:n})}t(P,"unknownError");const Q=t((e,n)=>e!==null&&typeof e=="object"&&!Array.isArray(e)?{...e,machine:n}:e,"withMachine");async function T(e,n,o){const a=M.current,r=v(),u=x(r,n),i=u===null?null:E(r,u),w=e.principal;if(!N(w)){if(i!==null)throw new p(404,"not_here",`${n} is not homed on this machine (it is a stub of ${i.home_machine})`,{id:n,home_machine:i.home_machine});return{kind:"local"}}if(u!==null&&i===null)return{kind:"local"};const b={...a,call:t((s,d)=>a.call(s,d),"call")};let l=await S(r,n,{hint:e.query.get("machine"),deps:b});for(let s=0;s<2;s++){if(l.where==="nowhere"||l.where==="here"){if(i!==null)throw new p(404,"not_found",`${n} is no longer on any machine of yours`,{id:n});return{kind:"local"}}if(l.where==="unknown")return{kind:"unknown",unreachable:l.unreachable};const d=l.machine,c=await a.call(d,o,a.envelope(w)),f=await a.label(d);if(j(c))return{kind:"unknown",unreachable:[`${f.name} (${c.unreachable.reason})`]};const y=l.source!=="locate";if(c.status===404&&s===0&&(m(c.body)==="not_here"||y&&m(c.body)==="not_found")){const h=await q(r,n,d,b);if(h.where==="machine"){l=h;continue}if(h.where==="unknown")return{kind:"unknown",unreachable:h.unreachable};if(m(c.body)==="not_found")return{kind:"remote",status:c.status,body:c.body,machine:f};if(i!==null)throw new p(404,"not_found",`${n} is no longer on any machine of yours`,{id:n});return{kind:"local"}}return{kind:"remote",status:c.status,body:c.body,machine:f}}return{kind:"unknown",unreachable:[]}}t(T,"forwardVerb");function W(e){if(e.actor.kind==="person")return{sub:e.sub,act:{sub:e.via}};const{app:n,node:o,node_scopes:a,place:r}=e.actor;return{sub:e.sub,act:{sub:e.via,act:{sub:n,node:o,node_scopes:a,place:r}}}}t(W,"envelopeOfForwarded");function X(e){const n={};for(const[o,a]of e.query)o!=="machine"&&(n[o]=a);return n}t(X,"queryWithoutMachine");function Y(e,n){const o=n.principal;return o.class!=="forwarded"||o.actor.kind!=="app"||o.actor.node===null?null:k(e,o.actor.node,"node",o.via,W(o))}t(Y,"callerStub");function Z(e,n){return n.principal.class==="broker"?$(e,n.principal.node):null}t(Z,"callerNode");function ee(e,n,o,a,r){A(e,n,a);const u=r?.owner,i=u&&u.type==="app"?{id:"",type:"app",scheme:"app",external_id:u.external_id??(u.handle.startsWith("app:")?u.handle:`app:${u.handle}`),handle:u.handle}:null;return k(e,n,o,a,void 0,r===void 0?void 0:{id:n,type:o,kind:r.kind??null,name:r.name??n,...r.preview===void 0?{}:{preview:r.preview},owner:i,...r.kind_decl===void 0?{}:{kind_decl:r.kind_decl},...r.privacy===void 0?{}:{privacy:r.privacy}})}t(ee,"remoteStub");export{Z as callerNode,Y as callerStub,C as defaultForwardDeps,W as envelopeOfForwarded,T as forwardVerb,M as forwarding,N as originates,X as queryWithoutMachine,L as relayError,ee as remoteStub,P as unknownError,Q as withMachine};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { ObjectRow } from '../../core/graph/types.js';
|
|
3
|
+
import type { RequestPrincipal } from '../api/router.js';
|
|
4
|
+
export declare const READ_LEAF_DEADLINE_MS = 15000;
|
|
5
|
+
export interface ReadDelegation {
|
|
6
|
+
plugin: string;
|
|
7
|
+
command: string[];
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export type ReadLeafResult = {
|
|
12
|
+
markdown: string;
|
|
13
|
+
} | {
|
|
14
|
+
unavailable: string;
|
|
15
|
+
};
|
|
16
|
+
/** The delegation for a custom object, or null when its kind has no read leaf or no installed plugin declares it any more (`declared=0`: an inert
|
|
17
|
+
* kind shows the last announcement). Sync: runs inside the first read transaction. */
|
|
18
|
+
export declare function readDelegationFor(db: DatabaseSync, object: ObjectRow): ReadDelegation | null;
|
|
19
|
+
/** Run the declared read leaf as `principal`. Never throws. */
|
|
20
|
+
export declare function runReadLeaf(d: ReadDelegation, principal: RequestPrincipal, deadlineMs?: number): Promise<ReadLeafResult>;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var f=Object.defineProperty;var l=(n,o)=>f(n,"name",{value:o,configurable:!0});import{randomUUID as g}from"node:crypto";import{prepared as h}from"../../core/sqlite-statements.js";import{listInstalledPlugins as v}from"../../core/resolver.js";import{validatePluginCommands as b}from"../../core/command-plugins/discovery.js";import{executeExternalLeafAsync as E}from"../../core/command-plugins/transport/exec-invoke-async.js";import{invokeHttpLeaf as k}from"../../core/command-plugins/transport/http-invoke.js";import{sha256Hex as x,signedHeaderDigest as _}from"../../core/command-plugins/transport/sign.js";import{pluginAppEnv as w}from"../../core/plugin-app.js";import{callProvider as y}from"../../core/providers/call.js";import{callerFromRequest as L}from"../api/handlers/providers.js";import{signCallHeaders as P}from"../api/handlers/plugin-app-token.js";const j=15e3;function C(n,o){const a=h(n,`SELECT k.read_json, k.declared, i.scheme, i.external_id FROM object_kinds k
|
|
2
|
+
LEFT JOIN identities i ON i.object = k.owner WHERE k.name = ?`).get(o.type);if(a===void 0||a.read_json===null||a.declared===0||a.scheme!=="app"||a.external_id===null||!a.external_id.startsWith("app:"))return null;const e=JSON.parse(a.read_json).command;return{plugin:a.external_id.slice(4),command:e,id:o.id,name:o.name}}l(C,"readDelegationFor");async function J(n,o,a=j){try{const e=v("user").find(r=>r.name===n.plugin);if(e===void 0||!e.enabled)return{unavailable:`plugin ${n.plugin} is not installed`};const u={id:n.id,name:n.name};let d;if(e.manifest.provider?.id===e.name){const r=AbortSignal.timeout(a),t=y(L(o),{provider:e.name,tool:n.command.join(" "),arguments:u},{requestId:g(),signal:r});t.catch(()=>{}),d=(await Promise.race([t,new Promise((p,i)=>{r.addEventListener("abort",()=>i(new Error(`provider read leaf gave no answer within ${a} ms`)),{once:!0})})])).body}else{const r=b(e),t=r.transport;let i=r.manifest?.roots.find(m=>m.name===e.name);for(const m of n.command)i=i?.kind==="branch"?i.children.find(s=>s?.name===m):void 0;if(i?.kind!=="leaf"||t?.kind!=="exec"&&t?.kind!=="http")return{unavailable:`plugin ${n.plugin} has no read leaf ${n.command.join(" ")}`};if(t.kind==="http"){const m=i;d=await k({registration:{name:e.name,endpoint:t.endpoint,...t.authEnv!==void 0?{authEnv:t.authEnv}:{}},...r.manifest?.baseUrl!==void 0?{baseUrl:r.manifest.baseUrl}:{},timeouts:{...r.manifest?.timeouts,requestMs:a},params:m.params,rest:m.rest,output:m.output,commandPath:[e.name,...n.command]},u,void 0,{callHeaders:l(async s=>P(e.name,o,{method:s.method,url:s.url.href,body_sha256:x(s.body),..._(s.headers)}).headers,"callHeaders")})}else{if(t.executable===void 0)return{unavailable:`plugin ${n.plugin} has no read leaf ${n.command.join(" ")}`};d=await E({plugin:e,executable:t.executable,commandPath:[e.name,...n.command],output:i.output},u,{deadlineMs:a,cwd:e.root,env:{...process.env,...w(e.name)??{}}})}}const c=d?.markdown;return typeof c=="string"?{markdown:c}:{unavailable:"read leaf returned no markdown"}}catch(e){return{unavailable:e instanceof Error?e.message:String(e)}}}l(J,"runReadLeaf");export{j as READ_LEAF_DEADLINE_MS,C as readDelegationFor,J as runReadLeaf};
|
package/dist/types.d.ts
CHANGED
|
@@ -60,6 +60,18 @@ export type MemoryExtensionDeclaration = BooleanMemoryExtensionDeclaration | Str
|
|
|
60
60
|
/** Field declarations owned by one plugin manifest. The plugin's `name` is
|
|
61
61
|
* the namespace; keys here are its lowercase kebab-case local field names. */
|
|
62
62
|
export type MemoryExtensionDeclarations = Record<string, MemoryExtensionDeclaration>;
|
|
63
|
+
/** One canvas object kind a plugin declares in `object_kinds`. */
|
|
64
|
+
export interface ObjectKindDeclaration {
|
|
65
|
+
description: string;
|
|
66
|
+
events: Record<string, {
|
|
67
|
+
level: number;
|
|
68
|
+
supersedes?: boolean;
|
|
69
|
+
}>;
|
|
70
|
+
/** A leaf path of the plugin's own commands, run to render an instance as markdown. */
|
|
71
|
+
read?: {
|
|
72
|
+
command: string[];
|
|
73
|
+
};
|
|
74
|
+
}
|
|
63
75
|
export interface PluginManifest {
|
|
64
76
|
name: string;
|
|
65
77
|
version?: string;
|
|
@@ -124,6 +136,10 @@ export interface PluginManifest {
|
|
|
124
136
|
/** App-scoped env variables required by this plugin, collected during install. */
|
|
125
137
|
env?: Record<string, import('./core/plugin-env.js').PluginEnvDeclaration>;
|
|
126
138
|
transport?: PluginTransport;
|
|
139
|
+
/** Canvas object kinds this plugin declares, keyed by kind name (`<plugin>.<name>`). */
|
|
140
|
+
object_kinds?: Record<string, ObjectKindDeclaration>;
|
|
141
|
+
/** Canvas scopes (`crtr:objects:<verb>:<target>`) a local plugin's app credential holds. */
|
|
142
|
+
scopes?: string[];
|
|
127
143
|
/** Archive source for a crouter-materialized HTTP plugin. Distinct from the invocation transport. */
|
|
128
144
|
bundle?: PluginBundle;
|
|
129
145
|
/** A capability provider's `bundle.json` `provider` block, copied by the
|
|
@@ -35,11 +35,11 @@ A call is refused when the scope is missing from the grant. The refusal is an `A
|
|
|
35
35
|
|
|
36
36
|
Some apps do not work until a provider is set up for the person. Name it under **What your app requires** on the **What it asks for** tab. The person is then taken through that provider's setup the first time they sign in. Context, for example, asks for a profile URL, and its state stays `pending` for a few minutes afterwards.
|
|
37
37
|
|
|
38
|
-
Read the state on each request that depends on it. `runtime.grant.get()` returns `dependencies`, one entry per
|
|
38
|
+
Read the state on each request that depends on it. `runtime.grant.get()` returns `dependencies`, one entry per setup id the app requires or lists as optional, plus any the directory implies (such as `device` for `context.deep`), keyed by setup id (or `{}` when it has none):
|
|
39
39
|
|
|
40
40
|
```ts
|
|
41
41
|
const {dependencies} = await runtime.grant.get();
|
|
42
|
-
const context = dependencies['context']; // {state, message?, next} or undefined
|
|
42
|
+
const context = dependencies['context']; // {state, message?, next, step?} or undefined
|
|
43
43
|
if (context?.state !== 'ready') {
|
|
44
44
|
// Show context.message and link context.next; never hard-code either.
|
|
45
45
|
}
|
|
@@ -51,8 +51,9 @@ if (context?.state !== 'ready') {
|
|
|
51
51
|
| `pending` | The provider is working. |
|
|
52
52
|
| `ready` | Calls that depend on it work. |
|
|
53
53
|
| `failed` | Setup failed. `message` says why. |
|
|
54
|
+
| `unavailable` | The provider does not offer this to the person's account. `message` says so; there is nothing for them to do. |
|
|
54
55
|
|
|
55
|
-
`message` is the provider's own sentence, present for `pending` and `
|
|
56
|
+
`message` is the provider's own sentence, present for `pending`, `failed` and `unavailable`. `step` is present when the provider reports where things stand (for example `researching`); its values may grow, so ignore one you do not know. `next` is the absolute URL of the setup page for this provider and your app; `setupLink(dependency, {returnTo})` from `@north-light/sdk` adds a `return_to` with your app's registered origin so the person returns to you, and keeps the query `next` already carries. A key can contain a dot (`context.deep`). An entry is missing when the runtime has not synced the grant since the app began requiring the provider: treat that as `not_started`. The runtime does not block your calls for you, so refuse the dependent operation yourself. To act the moment a state turns `ready`, follow it with `oauth.watchGrants`; see [Grant changes](/docs/guides/grant-changes).
|
|
56
57
|
|
|
57
58
|
## Checked and not checked
|
|
58
59
|
|
|
@@ -39,31 +39,33 @@ await watcher.stop();
|
|
|
39
39
|
|
|
40
40
|
## Wait on a provider your app requires
|
|
41
41
|
|
|
42
|
-
An app whose registration `requires` a provider (the console's **What your app requires**) needs that provider set up for the person before the operations that depend on it work. The runtime does not check this for you. `grant.get()` returns `dependencies`: one entry per
|
|
42
|
+
An app whose registration `requires` a provider (the console's **What your app requires**) needs that provider set up for the person before the operations that depend on it work. The runtime does not check this for you. `grant.get()` returns `dependencies`: one entry per setup id the app requires or lists as optional (plus any the directory implies, such as `device` for `context.deep`), keyed by setup id, or `{}` when it has none. Read it on each request that needs the provider; do not store it.
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
|
-
import type
|
|
45
|
+
import {setupLink, type ProviderDependency} from '@north-light/sdk';
|
|
46
46
|
|
|
47
|
-
// ProviderDependency is { state: 'not_started' | 'pending' | 'ready' | 'failed'; message?: string; next: string }.
|
|
47
|
+
// ProviderDependency is { state: 'not_started' | 'pending' | 'ready' | 'failed' | 'unavailable'; message?: string; next: string; step?: string }.
|
|
48
48
|
const {dependencies} = await runtime.grant.get();
|
|
49
49
|
const context: ProviderDependency | undefined = dependencies['context'];
|
|
50
50
|
if (context?.state !== 'ready') {
|
|
51
51
|
// Show the provider's own sentence and link its setup page; never hard-code either.
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Nothing to link when the provider does not offer this to the person (`unavailable`).
|
|
53
|
+
const next = context && context.state !== 'unavailable' ? setupLink(context, {returnTo: 'https://app.example.com'}) : undefined; // your registered redirect origin
|
|
54
54
|
throw new AppError('provider_setup_pending', context?.message ?? 'Finish setting up Context, then try again.', {
|
|
55
55
|
status: 403,
|
|
56
56
|
retryable: false,
|
|
57
|
-
details: {provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next
|
|
57
|
+
details: {provider: 'context', state: context?.state ?? 'not_started', message: context?.message, next},
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
`AppError` stands for your app's own typed error ([Errors](/docs/errors)).
|
|
63
63
|
|
|
64
|
-
- `state` is `not_started` (the person has not done their part), `pending` (the provider is working), `ready` or `
|
|
65
|
-
- `message` is the provider's own sentence for `pending` and `
|
|
66
|
-
- `
|
|
64
|
+
- `state` is `not_started` (the person has not done their part), `pending` (the provider is working), `ready`, `failed`, or `unavailable` (the provider does not offer this to the person's account; there is nothing for them to do, so do not link `next`).
|
|
65
|
+
- `message` is the provider's own sentence for `pending`, `failed` and `unavailable`. It is absent for `not_started` and `ready`, so keep a short fallback.
|
|
66
|
+
- `step` is present when the provider reports where the person or the work is, for example `researching`. Its values belong to the provider and may grow: show a known one in your own words and ignore an unknown one. A change to `step` alone changes the grant's `version`, so `watchGrants` delivers it.
|
|
67
|
+
- `next` is the absolute URL of the directory's setup page for this provider and your app. Use `setupLink(dependency, {returnTo})` to add `return_to` (your app's registered redirect origin) so the person comes back to you. It keeps the query `next` already has, such as `client_id`, and encodes `returnTo`; do not build the URL yourself.
|
|
68
|
+
- A key can contain a dot. `context.deep` is a level of the `context` provider; read it as `dependencies['context.deep']`.
|
|
67
69
|
- An entry is missing when the runtime has not synced the grant since the app began requiring the provider. Treat it as `not_started`.
|
|
68
70
|
|
|
69
71
|
Refuse the dependent operation with `provider_setup_pending` (403), carrying `state`, `message` and `next` in `details`. To act when the state turns `ready`, use `watchGrants` above instead of polling.
|
package/docs/plugin/deploying.md
CHANGED
|
@@ -65,6 +65,113 @@ A definition with `version: N`, or a list of definitions with one per supported
|
|
|
65
65
|
|
|
66
66
|
A new optional flag, a new leaf, a new optional output field and changed prose are additive.
|
|
67
67
|
|
|
68
|
+
## Local plugins
|
|
69
|
+
|
|
70
|
+
A plugin whose endpoint is `http://localhost`, `http://*.localhost` or another loopback host is a local plugin. crtr does not start it: run the service however you like (a terminal, launchd, a container). What crtr gives a local plugin is caller context on every call, a signature that proves the call came from crtr, and a credential for acting on the canvas.
|
|
71
|
+
|
|
72
|
+
### Declaring object kinds and scopes
|
|
73
|
+
|
|
74
|
+
`plugin.json` (and `bundle.json`) take two optional top-level blocks:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"object_kinds": {
|
|
79
|
+
"search.page": {
|
|
80
|
+
"description": "A tracked web page",
|
|
81
|
+
"events": { "changed": { "level": 5, "supersedes": true } },
|
|
82
|
+
"read": { "command": ["page", "read"] }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"scopes": ["crtr:objects:link:node"]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- A kind name's first segment is the plugin name. `events` holds at most 64 entries, each with a `level` from 1 to 10 and an optional boolean `supersedes`.
|
|
90
|
+
- `read.command` is a leaf path below the plugin's root command. crtr calls it with `{ "id", "name" }` to read an instance and expects a string field `markdown`.
|
|
91
|
+
- Each `scopes` entry has the form `crtr:objects:<read|watch|link|write>:<user|profile:<p>|app:<x>|node>`. A scope may not name the plugin's own space (`app:<plugin>`). A provider bundle may not declare `scopes`: a cloud app gets its scopes from directory consent.
|
|
92
|
+
- Install and update refuse an invalid block and leave the installed version in place. A kind another plugin already owns is refused with `kind_owned`; the plugin's other kinds still apply.
|
|
93
|
+
|
|
94
|
+
### The app token
|
|
95
|
+
|
|
96
|
+
crtr gives each enabled local plugin that declares `object_kinds` or `scopes`, or uses an HTTP endpoint, one credential, `crtr_app_…`, carrying the manifest's `scopes`. The plugin uses it as `Authorization: Bearer <token>` against crtrd's `/v1` API, to register instances, link a caller, publish events and read.
|
|
97
|
+
|
|
98
|
+
A service fetches the token once at startup over crtrd's unix socket, with no bearer: it asks as the person who runs it, so it must run as that user.
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
GET /v1/plugins/<name>/app-token (over the daemon socket)
|
|
102
|
+
→ { "token": "crtr_app_…", "socket": "/path/to/the/daemon.sock" }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The answer is `404 not_found` when the plugin is not installed, not enabled, invalid, or holds no token. Fetch it again after an update, which can change the scopes, and after a `401` (uninstall or disable revokes it).
|
|
106
|
+
|
|
107
|
+
crtr never sends this token to the plugin's port: not as a bearer, a header or a body field. It reaches an exec plugin as `CRTR_APP_TOKEN` and `CRTR_DAEMON_SOCKET` in the environment. Keep it in memory or in a file with mode 0600, and never log it.
|
|
108
|
+
|
|
109
|
+
### `Crtr-Caller`
|
|
110
|
+
|
|
111
|
+
Every HTTP call to a plugin, local or hosted, carries who made it:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Crtr-Caller: node=<id>; machine=<id>; profile=<id>; run=<id>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The fields appear in that order. A field with no value is left out, and a call with none omits the header. A service uses `node` to link the calling node to an object it owns. The value comes from crtrd's view of the caller; a manifest cannot set `Crtr-Caller`, `Crtr-Signature` or `Crtr-Request-Id`.
|
|
118
|
+
|
|
119
|
+
### `Crtr-Signature`
|
|
120
|
+
|
|
121
|
+
crtrd signs a call to a local plugin when the plugin holds a token and the request goes to the origin of its installed endpoint. Any other URL gets `Crtr-Caller` and no signature.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
Crtr-Signature: v1,t=<unix seconds>,h=<signed header names>,sig=<base64url HMAC-SHA256(token, canonical)>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- `h` lists, lowercase, sorted and joined with `;`, every header crtr sent except `crtr-caller` and `crtr-signature`. It always includes `crtr-request-id`, a fresh UUID per call and the signature's nonce. A retry after a connection error sends the same id and the same signature.
|
|
128
|
+
- `canonical` is these lines joined with `\n`, no trailing newline:
|
|
129
|
+
1. `v1`
|
|
130
|
+
2. `t`
|
|
131
|
+
3. the HTTP method, uppercase
|
|
132
|
+
4. the request path with its query string, as received
|
|
133
|
+
5. the lowercase hex sha256 of the exact body bytes (of the empty string when there is no body)
|
|
134
|
+
6. the `Crtr-Caller` value, or an empty line
|
|
135
|
+
7. `h`
|
|
136
|
+
8. the lowercase hex sha256 of `<name>:<value>\n` for each name in `h`, in order, with each value as received: leading and trailing spaces and tabs removed, which is what Node and every other HTTP parser hands the service. crtr trims a mapped header value the same way before it signs and sends, so a padded value in a command argument is delivered without its padding
|
|
137
|
+
|
|
138
|
+
A service verifies a call in this order, and answers `401` when any step fails:
|
|
139
|
+
|
|
140
|
+
1. Parse the header. The version must be `v1`.
|
|
141
|
+
2. `|now − t| ≤ 300` seconds.
|
|
142
|
+
3. `h` must include `crtr-request-id`, be sorted, and name only headers present on the request.
|
|
143
|
+
4. Recompute `canonical` from the request as received and compare the MAC with a constant-time comparison.
|
|
144
|
+
5. Reject a `Crtr-Request-Id` seen in the last 600 seconds, and remember this one. Do this after step 4, so a forged request cannot fill the cache.
|
|
145
|
+
6. Trust only the headers named in `h`, plus `Crtr-Caller`.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
149
|
+
|
|
150
|
+
const sha = (data: string | Buffer) => createHash('sha256').update(data).digest('hex');
|
|
151
|
+
|
|
152
|
+
export function verify(token: string, req: { method: string; target: string; body: Buffer; headers: Record<string, string> },
|
|
153
|
+
seen: Map<string, number>, now = Date.now()): boolean {
|
|
154
|
+
const [v, t, h, sig] = (req.headers['crtr-signature'] ?? '').split(',');
|
|
155
|
+
if (v !== 'v1' || !t?.startsWith('t=') || !h?.startsWith('h=') || !sig?.startsWith('sig=')) return false;
|
|
156
|
+
const seconds = Number(t.slice(2));
|
|
157
|
+
if (!Number.isInteger(seconds) || Math.abs(now / 1000 - seconds) > 300) return false;
|
|
158
|
+
const names = h.slice(2).split(';');
|
|
159
|
+
if (!names.includes('crtr-request-id') || names.some((n, i) => req.headers[n] === undefined || (i > 0 && names[i - 1]! >= n))) return false;
|
|
160
|
+
const canonical = ['v1', String(seconds), req.method.toUpperCase(), req.target, sha(req.body),
|
|
161
|
+
req.headers['crtr-caller'] ?? '', names.join(';'), sha(names.map((n) => `${n}:${req.headers[n]}\n`).join(''))].join('\n');
|
|
162
|
+
const expected = createHmac('sha256', token).update(canonical).digest();
|
|
163
|
+
const given = Buffer.from(sig.slice(4), 'base64url');
|
|
164
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return false;
|
|
165
|
+
for (const [id, at] of seen) if (now - at > 600_000) seen.delete(id);
|
|
166
|
+
const id = req.headers['crtr-request-id']!;
|
|
167
|
+
if (seen.has(id)) return false;
|
|
168
|
+
seen.set(id, now);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`req.headers` is keyed by lowercase name (Node's `IncomingMessage` already does this) and `req.target` is `req.url`.
|
|
174
|
+
|
|
68
175
|
## Archive response
|
|
69
176
|
|
|
70
177
|
The install response is an uncompressed tar with `Content-Type: application/x-tar`. Do not configure a proxy, CDN, or framework middleware to gzip or otherwise compress it. Crtr rejects compressed archive bytes. The handler sends an ETag and supports conditional `If-None-Match` requests automatically.
|
|
@@ -7,6 +7,85 @@ 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.484
|
|
11
|
+
|
|
12
|
+
Plugins can now declare canvas object kinds and act on the canvas, and an app token can be granted canvas scopes. Everything below is additive: no request or response an existing app sends or reads changes, with one runtime-side exception under [Unknown bearers](#unknown-bearers-are-refused).
|
|
13
|
+
|
|
14
|
+
### Plugin manifests declare `object_kinds` and `scopes` (additive)
|
|
15
|
+
|
|
16
|
+
`plugin.json` and `bundle.json` take two optional top-level blocks. `object_kinds` maps a kind name (`<plugin>.<name>`, so the first segment is the plugin's own name) to `{ description, events, read? }`: `events` holds up to 64 entries `{ level: 1-10, supersedes?: boolean }`, and `read.command` is a leaf path below the plugin's root command. That leaf must answer once: a leaf declared `rest.streaming: true` is refused as a `read.command`. `scopes` lists the canvas scopes the plugin asks for. A provider bundle may not declare `scopes`: a cloud app's scopes come from directory consent. Install, update and enable refuse an invalid block and keep the installed version; a kind another owner already holds is reported as `kind_owned` and the plugin's other kinds still apply. `crtr pkg plugin install|update|enable` print these as `warnings`, plus `appScopes` (the scopes the plugin's token now carries). Plugin authors: see [Local plugins](../plugin/deploying.md#local-plugins).
|
|
17
|
+
|
|
18
|
+
`ReconcilePackagesDTO` (the answer of `POST /v1/documents/reconcile-packages`) gains two optional fields: `kinds` (`applied` with `outcome` of `inserted`, `updated` or `unchanged`; `refused` with code `kind_owned`; `invalid` plugins with the reason) and `tokens` (`granted` plugins with their scopes, and `revoked` plugin names).
|
|
19
|
+
|
|
20
|
+
### Canvas object scopes `crtr:objects:<verb>:<target>` (additive)
|
|
21
|
+
|
|
22
|
+
`@north-light/identity` accepts `crtr:objects:<read|watch|link|write>:<user|node|profile:<id>|app:<x>>`. Each scope is its own grant: `covers` is exact equality, so `crtr:objects:write:user` does not imply `read`. The target is where the object lives:
|
|
23
|
+
|
|
24
|
+
- `user`: objects in the person's space.
|
|
25
|
+
- `profile:<id>`: objects below that profile.
|
|
26
|
+
- `app:<x>`: nodes and custom objects of another app `x`. An app never needs a scope for its own objects, and a plugin may not declare its own `app:<plugin>` space.
|
|
27
|
+
- `node`: any node, including the stub of a node on another of the account's machines.
|
|
28
|
+
|
|
29
|
+
`read` lets the holder read objects of the target, `write` change them, and `link` (below) make a node watch an object the holder owns. `watch` lets a custom object that watches an object of the target read it. Holding a scope never lifts run confinement inside the owning app, and a revoked grant holds nothing. A public custom object is readable by any app that may use the owning app's tools, with no scope. The app listener now serves the canvas operations (`objects.*`, `custom-objects.*`, `docs.*`) to an app grant token under its resolved scopes; those rows answered `unknown operation` there before.
|
|
30
|
+
|
|
31
|
+
### The `crtr_app_` credential and its socket fetch (additive)
|
|
32
|
+
|
|
33
|
+
Each enabled local plugin that declares `object_kinds` or `scopes`, or serves over HTTP, holds one `crtr_app_…` token carrying its manifest's `scopes`. It is issued when the plugin is installed or enabled, reissued on update (the scopes can change), and revoked on uninstall or disable. crtrd accepts it as a bearer on its unix socket and TCP listener, looks it up on every request (a revoked token stops working on the next one), and treats the holder as app `app:<plugin>`.
|
|
34
|
+
|
|
35
|
+
- `GET /v1/plugins/:name/app-token` (new operation `plugins.app-token`, person and broker callers) answers `{ token, socket }`, or `404 not_found` when the plugin is not installed, enabled, valid and holding a token. A service fetches it once at startup over the socket as the user who runs it. A node whose uid is not the daemon's gets `403 forbidden`.
|
|
36
|
+
- An exec plugin receives the token as `CRTR_APP_TOKEN` and the socket as `CRTR_DAEMON_SOCKET` in its leaf's environment. crtr never sends the token to a plugin's HTTP port.
|
|
37
|
+
- SDK: `CrtrClient.plugins.appToken(name)` and the DTO `PluginAppTokenDTO`; route helper `routes.pluginAppToken(name)`.
|
|
38
|
+
|
|
39
|
+
### `Crtr-Caller` and `Crtr-Signature` on local HTTP calls (additive)
|
|
40
|
+
|
|
41
|
+
Every HTTP call to a plugin carries `Crtr-Caller: node=<id>; machine=<id>; profile=<id>; run=<id>` (fields in that order, empty ones left out, header omitted when none). Calls to a local plugin's endpoint origin also carry `Crtr-Signature: v1,t=<unix seconds>,h=<signed header names>,sig=<base64url HMAC-SHA256 keyed by the plugin's token>` plus a fresh `Crtr-Request-Id`. A manifest cannot set these three header names. Provider calls carry `Crtr-Caller` too. Plugin authors: the canonical string and a reference verifier are in [Local plugins](../plugin/deploying.md#local-plugins).
|
|
42
|
+
|
|
43
|
+
crtrd signs the call; the CLI never holds the token. `POST /v1/plugins/:name/call-headers` (new operation `plugins.call-headers`, person and broker callers) takes only digests, `{ method, url, body_sha256, header_names, headers_sha256 }`, and answers `{ headers }` (`Crtr-Caller`, and `Crtr-Signature` when the URL is on the installed endpoint's origin and that endpoint is loopback). Any other URL gets `Crtr-Caller` only. An unknown or disabled plugin and a provider plugin answer `404 not_found`; malformed digests answer `400 invalid_request`. SDK: `CrtrClient.plugins.callHeaders(name, request)`, `PluginCallHeadersRequestDTO`, `PluginCallHeadersDTO`, `routes.pluginCallHeaders(name)`.
|
|
44
|
+
|
|
45
|
+
### Unknown bearers are refused
|
|
46
|
+
|
|
47
|
+
A bearer that crtrd does not recognize is now `401` on the TCP listener as well. A runtime with no owner token used to treat a request with a bearer it did not know as the owner; now only a request with no bearer is, and a live `crtr_app_` token is that app. `crtr_pub_` and the owner token behave as before.
|
|
48
|
+
|
|
49
|
+
### `custom-objects.register` takes `privacy`, and `events` is optional (additive)
|
|
50
|
+
|
|
51
|
+
`RegisterCustomObjectRequestDTO.privacy?` is `public` (the default), `friends` or `private`; setting it on an existing instance changes it and publishes `configured` with `privacy` in `changed`. `events` is now optional: omit it to create or return an instance of a kind an installed plugin declares, which leaves the kind's catalog alone. Omitting it for any other kind is still `400 invalid_request`. `kind_owned` now guards only the kind catalog: registering an instance of a plugin's kind never conflicts with it.
|
|
52
|
+
|
|
53
|
+
### Publishing a plugin-declared kind has new refusals
|
|
54
|
+
|
|
55
|
+
`custom-objects.publish` on an instance of a kind that an installed plugin declares (or that crossed from another machine):
|
|
56
|
+
|
|
57
|
+
- `404 not_here` when this machine holds only a stub of the object. Details are `{ id, home_machine }`; ask the home machine.
|
|
58
|
+
- `409 kind_inert` when the kind's plugin is no longer installed and enabled.
|
|
59
|
+
- `403 forbidden` unless the caller is the kind's owning app or holds the instance's publish token.
|
|
60
|
+
|
|
61
|
+
Instances of kinds an app registered itself publish as before.
|
|
62
|
+
|
|
63
|
+
### Watching with `link` and `watcher_machine` (additive)
|
|
64
|
+
|
|
65
|
+
`POST /v1/objects/:ref/watch` and `DELETE …/watch` take an optional `watcher` ref naming a node or a custom object. A non-person app caller can name a node as the `watcher` when the object is one it owns and it holds `crtr:objects:link:<target of the watcher>`: the node then watches the object. Any miss, whatever the cause, is `404 not_found`. `watcher_machine` (body field; query parameter on DELETE) names the machine of an account's other node as the watcher; crtrd keeps a local stand-in for that node, and an event for a node that never watched the stand-in locally still reaches the node. A `watcher_machine` that is not one of the account's machines is `404`; an unreachable directory is `503 runtime_unreachable`. Custom-object watchers behave as before.
|
|
66
|
+
|
|
67
|
+
A linked node receives the object's events even when it could not read the object by its own access. crtrd keeps the linking app on the watch and checks its scope and ownership again at each publish, so a revoked scope or token stops new deliveries. Across machines, both the object's home machine and the node's machine need this release.
|
|
68
|
+
|
|
69
|
+
### Reading: `kind_decl`, privacy and plugin read text (additive)
|
|
70
|
+
|
|
71
|
+
`ObjectDTO` gains an optional `kind_decl` (`{ name, description, events, owner }`). The runtime fills it on the object of an `objects.read` answer and of an `objects.watch` answer when the object is an instance of a custom kind, so another machine can hold a stub of it. It grants nothing. `ObjectListItemDTO` extends `ObjectDTO`, so its type carries the field, but list and search answers do not fill it. `privacy` is the field `ObjectDTO` already had: a read answered by another machine now carries the home object's `kind_decl` and `privacy` into the reader's stub, so a stub is never more readable than its home object.
|
|
72
|
+
|
|
73
|
+
A read of an instance of a plugin kind whose manifest sets `read.command` runs that leaf on the home machine (15 s deadline for exec and provider plugins alike), outside the canvas transaction, and returns its `markdown` as `text`. The `read` event and the read watch are recorded once. Access is checked again after the leaf returns, and a reader who lost it gets `not_found`; an object deleted while the leaf ran answers `state: "deleted"`, and one that vanished answers `not_found`. If the leaf cannot answer (plugin not installed or disabled, timeout, no string `markdown`), `text` is the last announcement followed by `\ncontent unavailable: <reason>`. Such a read adds `data.content`: `"live"` or `"unavailable"`. A kind that is no longer declared by an installed plugin, a kind without `read.command`, and every other object read as before, with no `data.content`.
|
|
74
|
+
|
|
75
|
+
### Migrations v89–v91
|
|
76
|
+
|
|
77
|
+
The canvas database gains `object_kinds.read_json`, `object_kinds.source` (`register`, `manifest` or `peer`) and `object_kinds.declared` (v89), a `peer_ingested_destinations` table recording each event destination already ingested from another machine (v90), and `watches.link_app`, the app that made a watch through `link` (v91). No app action; an older runtime cannot open a database migrated by this release.
|
|
78
|
+
|
|
79
|
+
## 0.3.483
|
|
80
|
+
|
|
81
|
+
### `unavailable`, `step` and `setupLink` on `grant.get` dependencies (additive for the SDK; a type change for exhaustive switches)
|
|
82
|
+
|
|
83
|
+
- `SetupState` gains `unavailable`: the provider does not offer this setup to the person's account. It is not `failed`. If you `switch` over `state` exhaustively, add a case; show `message` and do not link `next`.
|
|
84
|
+
- `ProviderDependency` gains an optional `step: string` (for example `researching`, or `downloaded` on `device`). Its values belong to the provider and may grow: ignore one you do not know. A change to `step` alone changes `grant.get`'s `version`, so `oauth.watchGrants` delivers it.
|
|
85
|
+
- `dependencies` keys may be dotted setup ids such as `context.deep`.
|
|
86
|
+
- New `setupLink(dependency, {returnTo})` returns `dependency.next` with `return_to` set and URL-encoded, keeping the query `next` already has (such as `client_id`). Use it instead of building the link yourself.
|
|
87
|
+
- A runtime older than this release drops a dependency whose state is `unavailable` and silently loses `step`, so an app that reads either needs a runtime on this release.
|
|
88
|
+
|
|
10
89
|
## 0.3.482
|
|
11
90
|
|
|
12
91
|
### `runs.start` accepts `deadline` (additive, no app change)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.484",
|
|
4
4
|
"description": "crtr — agent runtime with memory, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
},
|
|
138
138
|
"license": "UNLICENSED",
|
|
139
139
|
"dependencies": {
|
|
140
|
-
"@north-light/identity": "0.3.
|
|
140
|
+
"@north-light/identity": "0.3.484",
|
|
141
141
|
"@earendil-works/pi-agent-core": "1.1.0",
|
|
142
142
|
"@earendil-works/pi-ai": "1.1.0",
|
|
143
143
|
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
@@ -22,7 +22,7 @@ export interface IdTokenClaims extends JWTPayload {
|
|
|
22
22
|
family_name?: string;
|
|
23
23
|
picture?: string;
|
|
24
24
|
}
|
|
25
|
-
export type Scope = 'crtr:llm' | 'crtr:act' | 'crtr:schedule' | `crtr:memory:${'read' | 'rw'}:user${'' | `:${string}`}` | 'crtr:memory:manage:user' | `crtr:memory:${'read' | 'rw'}:app:${string}` | `crtr:files:${'read' | 'rw'}:${'user' | `app:${string}`}:${string}` | 'crtr:files:share' | `crtr:${'conversations' | 'objects'}:read:app:${string}` | `crtr:objects:${'message' | 'spawn'}:app:${string}` | `crtr:peer:${string}` | `${string}:${string}`;
|
|
25
|
+
export type Scope = 'crtr:llm' | 'crtr:act' | 'crtr:schedule' | `crtr:memory:${'read' | 'rw'}:user${'' | `:${string}`}` | 'crtr:memory:manage:user' | `crtr:memory:${'read' | 'rw'}:app:${string}` | `crtr:files:${'read' | 'rw'}:${'user' | `app:${string}`}:${string}` | 'crtr:files:share' | `crtr:${'conversations' | 'objects'}:read:app:${string}` | `crtr:objects:${'message' | 'spawn'}:app:${string}` | `crtr:objects:${'read' | 'watch' | 'link' | 'write'}:${'user' | 'node' | `profile:${string}` | `app:${string}`}` | `crtr:peer:${string}` | `${string}:${string}`;
|
|
26
26
|
/** App-memory form keys omit the app id. */
|
|
27
27
|
export declare const ENFORCED: ReadonlySet<string>;
|
|
28
28
|
export declare function parseScope(value: string, grantee?: string): Scope;
|
|
@@ -31,8 +31,12 @@ function selector(value) {
|
|
|
31
31
|
match = /^crtr:files:(read|rw):(user|app:[A-Za-z0-9_-]{1,64}):(.+)$/.exec(value);
|
|
32
32
|
if (match && validPath(match[3], true))
|
|
33
33
|
return { kind: 'files', access: match[1], holder: match[2], path: match[3] };
|
|
34
|
-
//
|
|
35
|
-
|
|
34
|
+
// Canvas object scopes `crtr:objects:<read|watch|link|write>:<user|node|profile:<p>|app:<x>>`. They, and the
|
|
35
|
+
// message and spawn scopes below, are unrelated scopes: `covers` is exact equality for every one of them.
|
|
36
|
+
match = /^crtr:objects:(?:read|watch|link|write):(?:user|node|profile:[A-Za-z0-9_-]{1,64}|app:[A-Za-z0-9_-]{1,64})$/.exec(value);
|
|
37
|
+
if (match)
|
|
38
|
+
return { kind: 'objects', value };
|
|
39
|
+
match = /^crtr:(?:(conversations):read|(objects):(?:message|spawn)):app:([A-Za-z0-9_-]{1,64})$/.exec(value);
|
|
36
40
|
if (match)
|
|
37
41
|
return { kind: (match[1] ?? match[2]), value };
|
|
38
42
|
match = /^crtr:peer:([A-Za-z0-9_-]{1,64})$/.exec(value);
|