chainlesschain 0.162.160 → 0.162.161
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/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-CkRHjW8e.js → AIOps-DMn528q-.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-BP3MeNi2.js → ActionButton-D9ZcDCJL.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-CjbjQsOr.js → Analytics-A_K-8PX0.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-i0adH1M8.js → AppLayout-D4pFW3X6.js} +3 -3
- package/src/assets/web-panel/assets/{Artifacts-CCCG2GOU.js → Artifacts-Dz46COAd.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-DrsgvwHC.js → Audit-cbMjbyg0.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-DkXNkbZM.js → BackgroundAgents-Dtf88GAj.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-DarTWCex.js → Backup-C5KRxn52.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-DaDzhcto.js → BaseInput-Dd2UK3BN.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-C4EzGw6V.js → Chat-lptKTFdG.js} +6 -6
- package/src/assets/web-panel/assets/ChatBubbleRenderer-C2KLtwHz.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-gddNgaQi.js → Checkbox-_yspG6rG.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-BKldFWf9.js → Codegen-BGq_GAha.js} +1 -1
- package/src/assets/web-panel/assets/{Col-ChR-brDs.js → Col-CePor71g.js} +1 -1
- package/src/assets/web-panel/assets/{Community-DlHtZX86.js → Community-CLOWhIT7.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BAhfcBpC.js → Compact-CivJt2vT.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-gIrKkm6m.js → Compliance-Btmo1GGE.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-BYnzLBO3.js → Cowork-Ct35OlG4.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-ByUDIljI.js → Cron-CTc2GmhB.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-DOdNbHFp.js → Crosschain-ZtiBETk1.js} +1 -1
- package/src/assets/web-panel/assets/{DID-BJHaNWD2.js → DID-BNqMQQXw.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Drm_tClz.js → Dashboard-BmzZd-9V.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-CA1WLzGN.js → Dropdown-CMVgvP2M.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-Di1SKjNO.js → EmailListRenderer-CcqQFAfT.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-44Ve7ZFe.js → FamilyGuardDashboard-CTqB1_4V.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-CubPwv_N.js → Federation-CiKOOzlb.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-CsUFe5bU.js → FormItemContext-Wk-OT0gr.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-D7xRjPjZ.js → GenericCardRenderer-DbEQT6ue.js} +1 -1
- package/src/assets/web-panel/assets/{Git-D-Qx9dhO.js → Git-0ZvCPSD9.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-_YMYaU4n.js → Governance-CmVuKkPb.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-J7Yzh-K_.js → Inference-CTWE3Jwg.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-D37iI_Mo.js → KnowledgeGraph-CBEIF00L.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-oMTSYfGY.js → Logs-DLhxDwVG.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-DxywvhTU.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-DKUqYDaF.js → Marketplace-BgsCBrnc.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-BEv3Gx61.js → McpTools-ClBUGDS2.js} +3 -3
- package/src/assets/web-panel/assets/{Memory-CtA8XD2y.js → Memory-CnG_ZnYw.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-ug7c1mMj.js → MobileBridge-BUOAbR1Z.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-Dnt_9-WB.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-WJaMs4i2.js → Mtc-CLjt4kCD.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-DXnK_-ru.js → MtcAudit-f24U9Aes.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-C4Ev7726.js → Multisig-GrmGQbSq.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-BMFb9_zk.js → NLProgramming-DADaWn-n.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-Bl93nvBX.js → Notes-xtf5RX6C.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-BN2W5cl6.js → NotificationSettings-Cd27Rgtx.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-Dxc9oN4o.js → OrderTableRenderer-fXRxlDjl.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-CAs48637.js → Organization-joCFlNB0.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-Bhq27Zoc.js → Overflow-B1HoURq0.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-D61-2542.js → P2P-DiwsKbT7.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-C1j4iUxz.js → PdhVaultBrowser-Xe-BsW6S.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-rJ2-fScM.js → Permissions-C23qi8kl.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-DFIlTok6.js → PersonalDataHub-BS1syX2f.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-BjIgEWoZ.js → Pipeline-DRYwwDyh.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-BQoH5wcX.js → Privacy-BQKaDHPH.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-CYR_-_Ub.js → ProjectInit-BC6F9xiG.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-nkatCq-9.js → ProjectSettings-ClFPc1Nd.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-SS9uZKCl.js → Projects-Dn-f5aYe.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-BnKM9fsF.js → Providers-CLWuOUNt.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-Cfe82dbz.js → QrScannerModal-DiqULTxb.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-r1RbPufV.js → QuickAsk-9A03P-E_.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-CjCbM2WE.js → Recommend-CJZAnbq_.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-BuMGPH6V.js → RemoteSession-C9A1R77h.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-Bmj3NY1F.js → Reputation-BIZc6Rky.js} +1 -1
- package/src/assets/web-panel/assets/{Row-BIsNcE_1.js → Row-DfIPNtuG.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-BObmojD2.js → RssFeed-C5znFcQ1.js} +2 -2
- package/src/assets/web-panel/assets/{Search-BRmbkQNS.js → Search-DSs2z3OX.js} +1 -1
- package/src/assets/web-panel/assets/{Security-_rF3DRpY.js → Security-CV6LcrLY.js} +3 -3
- package/src/assets/web-panel/assets/{Services-D0If9ZV4.js → Services-cmhoX0Xj.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-o7zIksUg.js → Skeleton-tCsg2po7.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-oUR7zi9u.js → Skills-BK1KUDiy.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-fqKyGi03.js → Sla-yRgyeA82.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-B1wZ_gxx.js → SpeechSettings-c1mo1mlp.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-B3qJWYXq.js → SyncSettings-koIAfuT3.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-BH5_dUfr.js → Tasks-DkkreAaB.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-CCIVpjRP.js → Templates-fy5Ctf9M.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-2GZtTGnB.js → Tenant-Bw70Jf6S.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BlMkfs_u.js → Terminal-BKh6Rx-4.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-U6hi5OMc.js → TimelineRenderer-Dg54WP0c.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-C-_BerGJ.js → Tokens-BvvD5SxJ.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-DaKzBgN3.js → Trigger-B7Ug9x4e.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-CLvcsco3.js → Trust-BjvuMORY.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BFn_Y1zZ.js → UkeySign-CZqhkx8p.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-B0V18dLT.js → VideoEditing-ClR50lhX.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BbmP7Imu.js → Wallet-DlfppYjx.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-Dewj_0kt.js → WebAuthn-BirgVr8B.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-DOxz3Lwa.js → WorkflowEditor-B8DP_bax.js} +1 -1
- package/src/assets/web-panel/assets/{chat-B1OIpZnh.js → chat-DA8AUZzL.js} +1 -1
- package/src/assets/web-panel/assets/{colors-CSGNVdri.js → colors-C38pLGp1.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-8S0vcg0u.js → compact-item-81rtr98o.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-vZyc27TN.js → createContext-D34ixq8t.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-CHDIEOeN.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-DWw3-UhA.js → hasIn-D4bzqTrZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-dxocDVD_.js → index-9qoVpPt6.js} +1 -1
- package/src/assets/web-panel/assets/{index-xx5M5Rbj.js → index-B0QArPQt.js} +1 -1
- package/src/assets/web-panel/assets/{index-DtHWHJnj.js → index-B8mkLM4x.js} +1 -1
- package/src/assets/web-panel/assets/{index-DLgkAhEE.js → index-BIP62a6r.js} +1 -1
- package/src/assets/web-panel/assets/{index-tr1x96W9.js → index-BP-zkXK8.js} +1 -1
- package/src/assets/web-panel/assets/{index-CfEvacz7.js → index-BS3M5H9V.js} +1 -1
- package/src/assets/web-panel/assets/{index-D1i_8oop.js → index-BXd2_LmH.js} +1 -1
- package/src/assets/web-panel/assets/{index-BDHyovnK.js → index-BlxEphl7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CLPr2vC_.js → index-BqIhLAbA.js} +1 -1
- package/src/assets/web-panel/assets/{index-BQssf0EQ.js → index-BtGxuQrO.js} +1 -1
- package/src/assets/web-panel/assets/{index-BuB5P0TR.js → index-BvD7b2IK.js} +1 -1
- package/src/assets/web-panel/assets/{index-DeCS12Mh.js → index-BvL_3J_n.js} +1 -1
- package/src/assets/web-panel/assets/{index-BeBv896m.js → index-COe_cOLJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dc40pX3F.js → index-CP5alL4t.js} +1 -1
- package/src/assets/web-panel/assets/index-CY1NURWZ.js +1 -0
- package/src/assets/web-panel/assets/{index-B4pP2ycr.js → index-CbhE3j5j.js} +1 -1
- package/src/assets/web-panel/assets/{index-DKwl1AkQ.js → index-Cd3KuNK5.js} +1 -1
- package/src/assets/web-panel/assets/{index-BZnauCqN.js → index-CoVgZw_b.js} +1 -1
- package/src/assets/web-panel/assets/{index-BwKrTVV0.js → index-CuS-UPoj.js} +1 -1
- package/src/assets/web-panel/assets/{index-Q6oyLnKC.js → index-D0Ih7Hwn.js} +1 -1
- package/src/assets/web-panel/assets/{index-CB2MOS_8.js → index-D3zCCZRS.js} +3 -3
- package/src/assets/web-panel/assets/{index-BdyokEau.js → index-D6GfvEQG.js} +1 -1
- package/src/assets/web-panel/assets/{index-DPgY5GLy.js → index-DBFSo1NF.js} +1 -1
- package/src/assets/web-panel/assets/{index-CFQWZvak.js → index-DFHa1HIg.js} +1 -1
- package/src/assets/web-panel/assets/{index-BjC-TrOb.js → index-DFyWKOBD.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dg0Izipr.js → index-DMgnRwrK.js} +1 -1
- package/src/assets/web-panel/assets/{index-BKEmQQvR.js → index-DUQLXLQM.js} +1 -1
- package/src/assets/web-panel/assets/{index-DLAvUtOH.js → index-D_2VYPr1.js} +1 -1
- package/src/assets/web-panel/assets/{index-D8qXVfoV.js → index-DakfFZ-k.js} +1 -1
- package/src/assets/web-panel/assets/{index-DcfLXAW7.js → index-Ddk0e_Pi.js} +1 -1
- package/src/assets/web-panel/assets/{index-BoIPHP4R.js → index-DkHqMnn3.js} +1 -1
- package/src/assets/web-panel/assets/{index-CJgKP2cm.js → index-Dmo3dFHb.js} +1 -1
- package/src/assets/web-panel/assets/index-DokUYqpq.js +1 -0
- package/src/assets/web-panel/assets/{index-C6r27A-o.js → index-DvfCEJG-.js} +1 -1
- package/src/assets/web-panel/assets/{index-CbZqw7pg.js → index-E1OJRXs4.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkWd6YL7.js → index-F76w1NiL.js} +1 -1
- package/src/assets/web-panel/assets/{index-t-q3rkVm.js → index-ZRJRItm0.js} +1 -1
- package/src/assets/web-panel/assets/{index-CwXBblm-.js → index-fI5sXMLg.js} +1 -1
- package/src/assets/web-panel/assets/{index-CI3slBKI.js → index-tKiC8T-4.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-Di5YFId0.js → initDefaultProps-ByyvflIR.js} +1 -1
- package/src/assets/web-panel/assets/{motion-BVSN0aeL.js → motion-B3FQm2By.js} +1 -1
- package/src/assets/web-panel/assets/{move-DHNiaP2l.js → move-BSfns6Ef.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-xJPf3gQD.js → mtc-parser-CTG1Ieri.js} +1 -1
- package/src/assets/web-panel/assets/{omit-C7qzUdkN.js → omit-DObM_2j6.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-pSpjg3Le.js → pickAttrs-DOfelQ1t.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-cs_jL_UC.js → placementArrow-CHZILds_.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-DkS_Zs4r.js → responsiveObserve-REbucKSx.js} +1 -1
- package/src/assets/web-panel/assets/{slide-B5O1Kpxo.js → slide-C6EZzD7o.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BX02GnZp.js → statusUtils-BxkWS-g3.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-EmBYRftd.js → styleChecker-Chklyoq6.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-ZGzN1cU8.js → useFlexGapSupport-CRVwKrrI.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-CfP7M1m7.js → useFs-DV24ik_J.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-CbIPHgNh.js → usePersonalDataHub-C7vIo4Pg.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-7xh0If_O.js → vnode-By-v5GlF.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DmitvTR2.js → zoom-B1w9Am3r.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agenda.js +15 -1
- package/src/commands/agent.js +7 -0
- package/src/commands/background-session.js +16 -2
- package/src/data/changelog.json +7 -2
- package/src/gateways/ws/background-agent-protocol.js +88 -14
- package/src/lib/agent-authority.js +251 -0
- package/src/lib/background-agent-phase.js +100 -0
- package/src/lib/background-agent-supervisor.js +288 -8
- package/src/lib/backpressure-policy.js +105 -0
- package/src/lib/capability-negotiation.js +212 -0
- package/src/lib/credential-guard.js +11 -0
- package/src/lib/credential-proxy.js +182 -0
- package/src/lib/event-seq-replay.js +167 -0
- package/src/lib/goal-condition-engine.js +369 -0
- package/src/lib/headless-manifest.js +13 -0
- package/src/lib/ide-context.js +53 -14
- package/src/lib/project-instructions.js +98 -1
- package/src/lib/remote-path-mapping.js +229 -0
- package/src/lib/schedule-planner.js +136 -0
- package/src/repl/bg-dashboard.js +13 -12
- package/src/runtime/agent-core.js +11 -4
- package/src/runtime/headless-stream.js +195 -11
- package/src/runtime/system-prompt.js +3 -0
- package/src/workers/background-agent-worker.js +37 -2
- package/src/assets/web-panel/assets/ChatBubbleRenderer-lkavIWMS.js +0 -1
- package/src/assets/web-panel/assets/MarkdownRenderer-BqaPmC0W.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-BpX5g9It.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DYEZcY55.js +0 -1
- package/src/assets/web-panel/assets/index-DmVFfcNR.js +0 -1
- package/src/assets/web-panel/assets/index-x7YyhMUa.js +0 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-agent authority envelope + approval binding (P0 security slice —
|
|
3
|
+
* CLAUDE_CODE_CLI_INCREMENTAL_GAP_ANALYSIS_2026-07-12 §"权限来源与跨 Agent
|
|
4
|
+
* 授权边界").
|
|
5
|
+
*
|
|
6
|
+
* When Subagent, Team, Remote Control, Inbound Channel, Hook and MCP all feed
|
|
7
|
+
* text into one session, "who said this" is a security boundary: another
|
|
8
|
+
* agent's "the user approved" MUST NOT gain the user's authority, and an
|
|
9
|
+
* external channel message MUST NOT silently widen the session's permissions.
|
|
10
|
+
*
|
|
11
|
+
* This module is the single source of truth for two invariants the runtime
|
|
12
|
+
* enforces at every trust seam:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Who may approve.** Only the local user (UI-owned stdin), an explicit
|
|
15
|
+
* permission tool, or a *paired + authenticated + approve-scoped* remote
|
|
16
|
+
* device may answer a permission gate. Model / subagent / teammate /
|
|
17
|
+
* channel / hook messages top out at `steer` — the string "approved" in
|
|
18
|
+
* their payload is just text.
|
|
19
|
+
*
|
|
20
|
+
* 2. **What an approval authorizes.** An approval is bound to
|
|
21
|
+
* `tool_call_id + normalized_arguments + policy_digest`. A replayed or
|
|
22
|
+
* mis-routed approval, or one issued for arguments that have since
|
|
23
|
+
* changed, fails the binding check — so a stale "yes" can never green-light
|
|
24
|
+
* a different (or tampered) tool call.
|
|
25
|
+
*
|
|
26
|
+
* `origin` is always assigned by trusted dispatch code from HOW a message
|
|
27
|
+
* arrived (which socket / which authenticated device / which internal caller)
|
|
28
|
+
* — it is never read from untrusted message *content*, which is the whole
|
|
29
|
+
* point: a model cannot label its own text `origin:"user"`.
|
|
30
|
+
*
|
|
31
|
+
* Pure + dependency-light (only node:crypto) so every seam — remote-session
|
|
32
|
+
* protocol, channels, hooks, subagent dispatch — can share it.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
36
|
+
|
|
37
|
+
/** Where a message / approval entered the runtime. Assigned by trusted code. */
|
|
38
|
+
export const ORIGIN = Object.freeze({
|
|
39
|
+
USER: "user", // the human at the local UI (owns stdin)
|
|
40
|
+
MODEL: "model", // the assistant's own output
|
|
41
|
+
SUBAGENT: "subagent", // a spawned sub-agent
|
|
42
|
+
TEAMMATE: "teammate", // another agent in a team run
|
|
43
|
+
CHANNEL: "channel", // an inbound channel (webhook / telegram)
|
|
44
|
+
HOOK: "hook", // a settings/plugin hook result
|
|
45
|
+
REMOTE: "remote", // a paired remote-control device
|
|
46
|
+
SYSTEM: "system", // internal runtime event
|
|
47
|
+
PERMISSION_TOOL: "permission_tool", // an explicit permission-answering tool
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** What a message is allowed to do. Ordered least → most privileged. */
|
|
51
|
+
export const AUTHORITY = Object.freeze({
|
|
52
|
+
NONE: "none", // ignored for control purposes
|
|
53
|
+
STEER: "steer", // may add a user turn / guide the conversation
|
|
54
|
+
APPROVE: "approve", // may answer a permission gate (approve/deny a tool)
|
|
55
|
+
MANAGE: "manage", // may also manage session lifecycle
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const AUTHORITY_RANK = Object.freeze({
|
|
59
|
+
none: 0,
|
|
60
|
+
steer: 1,
|
|
61
|
+
approve: 2,
|
|
62
|
+
manage: 3,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Numeric rank for an authority string (unknown → 0). */
|
|
66
|
+
export function authorityRank(authority) {
|
|
67
|
+
return AUTHORITY_RANK[authority] ?? 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Remote scopes that already imply the ability to answer a permission gate. */
|
|
71
|
+
function scopeGrantsApprove(context) {
|
|
72
|
+
const scopes = normalizeScopes(context);
|
|
73
|
+
return scopes.has("approve") || scopes.has("manage");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scopeGrantsSteer(context) {
|
|
77
|
+
const scopes = normalizeScopes(context);
|
|
78
|
+
return (
|
|
79
|
+
scopes.has("prompt") ||
|
|
80
|
+
scopes.has("interrupt") ||
|
|
81
|
+
scopes.has("observe") ||
|
|
82
|
+
scopeGrantsApprove(context)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeScopes(context = {}) {
|
|
87
|
+
const raw = Array.isArray(context.scopes)
|
|
88
|
+
? context.scopes
|
|
89
|
+
: context.scope
|
|
90
|
+
? [context.scope]
|
|
91
|
+
: [];
|
|
92
|
+
return new Set(raw.map((s) => String(s || "").toLowerCase()));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The MAXIMUM authority an origin may exercise given its authentication
|
|
97
|
+
* context. This is a ceiling — a caller still passes the specific action
|
|
98
|
+
* through `canApprove` / `assertCanApprove`.
|
|
99
|
+
*
|
|
100
|
+
* The only paths to `approve` are: the local user (`manage`), an explicit
|
|
101
|
+
* permission tool, or a remote device that is BOTH authenticated AND was
|
|
102
|
+
* granted the approve scope at pairing. Everything an agent or external
|
|
103
|
+
* message can say tops out at `steer`.
|
|
104
|
+
*/
|
|
105
|
+
export function authorityForOrigin(origin, context = {}) {
|
|
106
|
+
switch (origin) {
|
|
107
|
+
case ORIGIN.USER:
|
|
108
|
+
return AUTHORITY.MANAGE;
|
|
109
|
+
case ORIGIN.PERMISSION_TOOL:
|
|
110
|
+
return AUTHORITY.APPROVE;
|
|
111
|
+
case ORIGIN.REMOTE:
|
|
112
|
+
// A paired Remote Approval Bridge acts for the user ONLY when the pairing
|
|
113
|
+
// is authenticated AND carries the approve scope. Never on the strength
|
|
114
|
+
// of the message alone.
|
|
115
|
+
if (context.authenticated === true && scopeGrantsApprove(context)) {
|
|
116
|
+
return normalizeScopes(context).has("manage")
|
|
117
|
+
? AUTHORITY.MANAGE
|
|
118
|
+
: AUTHORITY.APPROVE;
|
|
119
|
+
}
|
|
120
|
+
if (context.authenticated === true && scopeGrantsSteer(context)) {
|
|
121
|
+
return AUTHORITY.STEER;
|
|
122
|
+
}
|
|
123
|
+
return AUTHORITY.NONE;
|
|
124
|
+
case ORIGIN.MODEL:
|
|
125
|
+
case ORIGIN.SUBAGENT:
|
|
126
|
+
case ORIGIN.TEAMMATE:
|
|
127
|
+
case ORIGIN.CHANNEL:
|
|
128
|
+
case ORIGIN.HOOK:
|
|
129
|
+
case ORIGIN.SYSTEM:
|
|
130
|
+
// May guide the conversation, never speak with the user's approval
|
|
131
|
+
// authority. "The user approved" in their text is just text.
|
|
132
|
+
return AUTHORITY.STEER;
|
|
133
|
+
default:
|
|
134
|
+
return AUTHORITY.NONE;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* True when this envelope may answer a permission gate. An envelope is
|
|
140
|
+
* `{ origin, authenticated?, scope?/scopes?, principalId?, sessionId?, … }`.
|
|
141
|
+
*/
|
|
142
|
+
export function canApprove(envelope = {}) {
|
|
143
|
+
return (
|
|
144
|
+
authorityRank(authorityForOrigin(envelope.origin, envelope)) >=
|
|
145
|
+
AUTHORITY_RANK.approve
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** True when this envelope may manage session lifecycle. */
|
|
150
|
+
export function canManageSession(envelope = {}) {
|
|
151
|
+
return (
|
|
152
|
+
authorityRank(authorityForOrigin(envelope.origin, envelope)) >=
|
|
153
|
+
AUTHORITY_RANK.manage
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Throw a clear, log-safe error when an envelope is NOT allowed to approve.
|
|
159
|
+
* Callers gate every "resolve a permission" seam through this so the rule
|
|
160
|
+
* lives in exactly one place.
|
|
161
|
+
*/
|
|
162
|
+
export function assertCanApprove(envelope = {}) {
|
|
163
|
+
if (canApprove(envelope)) return;
|
|
164
|
+
const who = envelope.origin || "unknown";
|
|
165
|
+
const got = authorityForOrigin(envelope.origin, envelope);
|
|
166
|
+
throw new Error(
|
|
167
|
+
`origin "${who}" (authority "${got}") is not authorized to approve tool calls — ` +
|
|
168
|
+
"only the local user, a paired approve-scoped remote device, or an explicit permission tool may",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Approval binding ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Deterministic, key-order-independent serialization of tool arguments, so
|
|
176
|
+
* two structurally-equal argument objects hash identically and ANY real value
|
|
177
|
+
* change hashes differently. Non-objects serialize via JSON.
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeToolArgs(args) {
|
|
180
|
+
return stableStringify(args === undefined ? null : args);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stableStringify(value) {
|
|
184
|
+
if (value === null || typeof value !== "object") {
|
|
185
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(value)) {
|
|
188
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
189
|
+
}
|
|
190
|
+
const keys = Object.keys(value).sort();
|
|
191
|
+
return (
|
|
192
|
+
"{" +
|
|
193
|
+
keys
|
|
194
|
+
.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k]))
|
|
195
|
+
.join(",") +
|
|
196
|
+
"}"
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The identity an approval authorizes: the exact tool call (`toolCallId`), its
|
|
202
|
+
* normalized arguments, and the policy in force (`policyDigest`). Change any
|
|
203
|
+
* one and the digest changes, so a "yes" for one call can never be replayed
|
|
204
|
+
* onto another or onto tampered arguments.
|
|
205
|
+
*/
|
|
206
|
+
export function approvalBindingDigest({ toolCallId, args, policyDigest } = {}) {
|
|
207
|
+
const h = createHash("sha256");
|
|
208
|
+
h.update("cc-approval-binding-v1\n");
|
|
209
|
+
h.update(String(toolCallId ?? "") + "\n");
|
|
210
|
+
h.update(normalizeToolArgs(args) + "\n");
|
|
211
|
+
h.update(String(policyDigest ?? "") + "\n");
|
|
212
|
+
return "ab_" + h.digest("hex").slice(0, 32);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function timingSafeEqualStr(a, b) {
|
|
216
|
+
const ba = Buffer.from(String(a), "utf8");
|
|
217
|
+
const bb = Buffer.from(String(b), "utf8");
|
|
218
|
+
if (ba.length !== bb.length) return false;
|
|
219
|
+
return timingSafeEqual(ba, bb);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Verify an incoming approval targets the expected pending request. Each side
|
|
224
|
+
* may be a precomputed digest string or a `{toolCallId, args, policyDigest}`
|
|
225
|
+
* descriptor. Returns false on any mismatch or missing input (fail closed).
|
|
226
|
+
* Comparison is constant-time.
|
|
227
|
+
*/
|
|
228
|
+
export function verifyApprovalBinding(expected, provided) {
|
|
229
|
+
if (!expected || !provided) return false;
|
|
230
|
+
const e =
|
|
231
|
+
typeof expected === "string" ? expected : approvalBindingDigest(expected);
|
|
232
|
+
const p =
|
|
233
|
+
typeof provided === "string" ? provided : approvalBindingDigest(provided);
|
|
234
|
+
if (!e || !p) return false;
|
|
235
|
+
return timingSafeEqualStr(e, p);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A compact, log-safe provenance string for the permission/audit log so it is
|
|
240
|
+
* always clear WHICH principal, over WHICH session, with WHICH authority
|
|
241
|
+
* requested or answered a permission. Carries no secrets.
|
|
242
|
+
*/
|
|
243
|
+
export function describeAuthorityChain(envelope = {}) {
|
|
244
|
+
const parts = [`origin=${envelope.origin || "unknown"}`];
|
|
245
|
+
if (envelope.principalId) parts.push(`principal=${envelope.principalId}`);
|
|
246
|
+
if (envelope.sessionId) parts.push(`session=${envelope.sessionId}`);
|
|
247
|
+
if (envelope.parentAgentId) parts.push(`parent=${envelope.parentAgentId}`);
|
|
248
|
+
if (envelope.correlationId) parts.push(`corr=${envelope.correlationId}`);
|
|
249
|
+
parts.push(`authority=${authorityForOrigin(envelope.origin, envelope)}`);
|
|
250
|
+
return parts.join(" ");
|
|
251
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-agent phase vocabulary + dashboard grouping (P0 state-machine
|
|
3
|
+
* slice — CLAUDE_CODE_CLI_INCREMENTAL_GAP_ANALYSIS_2026-07-12 §"后台 Agent
|
|
4
|
+
* 状态机").
|
|
5
|
+
*
|
|
6
|
+
* A session has two orthogonal axes:
|
|
7
|
+
* - `status` — running | completed | failed | stopped | lost — owned by the
|
|
8
|
+
* supervisor's liveness reconciliation.
|
|
9
|
+
* - `phase` — the live sub-state WHILE running, persisted by the worker.
|
|
10
|
+
*
|
|
11
|
+
* Historically the worker only emitted "turn" (a turn is executing) and
|
|
12
|
+
* "idle" (between turns, transport open), and the dashboard mislabeled *idle*
|
|
13
|
+
* as "Needs input" — so an agent that had simply finished a turn and was
|
|
14
|
+
* parked shouted for attention it didn't need. Claude-Code's Agent View keeps
|
|
15
|
+
* these distinct (Working / Needs input / Idle / …).
|
|
16
|
+
*
|
|
17
|
+
* This module is the single source of truth that (a) names the fuller phase
|
|
18
|
+
* vocabulary the runtime is growing into and (b) classifies a session into a
|
|
19
|
+
* dashboard group, crucially separating `idle` (finished a turn, nothing
|
|
20
|
+
* blocking) from a genuine *needs-input* state (a human decision — a question
|
|
21
|
+
* or a pending permission — is blocking progress). Pure + zero-dependency so
|
|
22
|
+
* the worker, the supervisor and the REPL dashboard can all share it without a
|
|
23
|
+
* worker ever importing UI code.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonical running sub-phases. `working`/`idle` are the steady states;
|
|
28
|
+
* `needs_input`/`waiting_permission` mark a turn that cannot advance without a
|
|
29
|
+
* human. `starting` is the brief pre-first-turn window.
|
|
30
|
+
*/
|
|
31
|
+
export const BACKGROUND_AGENT_PHASES = Object.freeze({
|
|
32
|
+
STARTING: "starting",
|
|
33
|
+
WORKING: "working",
|
|
34
|
+
IDLE: "idle",
|
|
35
|
+
NEEDS_INPUT: "needs_input",
|
|
36
|
+
WAITING_PERMISSION: "waiting_permission",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Aliases accepted from producers / older state schemas → canonical phase.
|
|
41
|
+
* The legacy worker emits "turn" for a live turn; map it to `working` so old
|
|
42
|
+
* on-disk states and new ones classify identically. Kebab and *_approval
|
|
43
|
+
* spellings are tolerated because different producers naturally reach for
|
|
44
|
+
* different casing.
|
|
45
|
+
*/
|
|
46
|
+
const PHASE_ALIASES = new Map([
|
|
47
|
+
["turn", BACKGROUND_AGENT_PHASES.WORKING],
|
|
48
|
+
["working", BACKGROUND_AGENT_PHASES.WORKING],
|
|
49
|
+
["starting", BACKGROUND_AGENT_PHASES.STARTING],
|
|
50
|
+
["idle", BACKGROUND_AGENT_PHASES.IDLE],
|
|
51
|
+
["needs_input", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
|
|
52
|
+
["needs-input", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
|
|
53
|
+
["awaiting_input", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
|
|
54
|
+
["question", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
|
|
55
|
+
["waiting_permission", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
|
|
56
|
+
["waiting-permission", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
|
|
57
|
+
["waiting_approval", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
|
|
58
|
+
["awaiting_approval", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize a raw phase value to a canonical phase, or null when absent /
|
|
63
|
+
* unrecognized (callers treat null as "no declared phase"). Never throws.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeBackgroundAgentPhase(value) {
|
|
66
|
+
if (typeof value !== "string") return null;
|
|
67
|
+
return PHASE_ALIASES.get(value.trim().toLowerCase()) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Phases where a human decision is blocking progress → "Needs input". */
|
|
71
|
+
const BLOCKING_PHASES = new Set([
|
|
72
|
+
BACKGROUND_AGENT_PHASES.NEEDS_INPUT,
|
|
73
|
+
BACKGROUND_AGENT_PHASES.WAITING_PERMISSION,
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dashboard group for a session. Running sessions split by phase:
|
|
78
|
+
* - a pending approval OR a blocking phase (needs_input / waiting_permission)
|
|
79
|
+
* → "needs-input" (a human decision unblocks it)
|
|
80
|
+
* - "idle" (finished a turn, transport open, available but nothing blocking)
|
|
81
|
+
* → "idle"
|
|
82
|
+
* - everything else (working / turn / starting / unknown) → "working"
|
|
83
|
+
* Terminal statuses map to their own groups; lost/unknown → "failed".
|
|
84
|
+
*
|
|
85
|
+
* `pendingApprovals` wins over `phase` because an approval that is genuinely
|
|
86
|
+
* pending blocks progress regardless of the phase label the worker last wrote.
|
|
87
|
+
*/
|
|
88
|
+
export function phaseGroupKey(session) {
|
|
89
|
+
const status = session?.status;
|
|
90
|
+
if (status === "running") {
|
|
91
|
+
if (Number(session?.pendingApprovals) > 0) return "needs-input";
|
|
92
|
+
const phase = normalizeBackgroundAgentPhase(session?.phase);
|
|
93
|
+
if (BLOCKING_PHASES.has(phase)) return "needs-input";
|
|
94
|
+
if (phase === BACKGROUND_AGENT_PHASES.IDLE) return "idle";
|
|
95
|
+
return "working";
|
|
96
|
+
}
|
|
97
|
+
if (status === "completed") return "completed";
|
|
98
|
+
if (status === "stopped") return "stopped";
|
|
99
|
+
return "failed"; // failed | lost | unknown
|
|
100
|
+
}
|
|
@@ -16,11 +16,167 @@ import { join } from "node:path";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { getHomeDir } from "./paths.js";
|
|
18
18
|
|
|
19
|
-
export const _deps = { spawn, spawnSync };
|
|
20
|
-
|
|
21
19
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
|
22
20
|
export const DEFAULT_HEARTBEAT_STALE_MS = 120000;
|
|
23
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Pid identity tolerance (Gap 1: OS pid reuse). A pid alone does not identify
|
|
24
|
+
* a process — after the worker dies the OS can hand the same pid to an
|
|
25
|
+
* unrelated process, making `kill(pid, 0)` lie: the session shows "running"
|
|
26
|
+
* until the heartbeat goes stale and, far worse, `stopBackgroundAgent` would
|
|
27
|
+
* `taskkill /T /F` an innocent process tree. The state file records
|
|
28
|
+
* `startedAt` (launcher clock, written just before the spawn), so we compare
|
|
29
|
+
* it against the pid's REAL creation time. Reuse is one-sided: a process
|
|
30
|
+
* that took over the pid can only have been created AFTER the original
|
|
31
|
+
* worker died — i.e. noticeably later than startedAt. Creation at/before
|
|
32
|
+
* startedAt (± tolerance for clock skew and spawn latency) is our process.
|
|
33
|
+
*/
|
|
34
|
+
export const PID_IDENTITY_TOLERANCE_MS = 60000;
|
|
35
|
+
|
|
36
|
+
const START_TIME_CACHE_TTL_MS = 10000;
|
|
37
|
+
const _startTimeCache = new Map();
|
|
38
|
+
|
|
39
|
+
/** CIM_DATETIME (`20260711120000.500000+480`) → epoch ms, null when unparseable. */
|
|
40
|
+
export function parseCimDateToMs(raw) {
|
|
41
|
+
const m =
|
|
42
|
+
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/.exec(
|
|
43
|
+
String(raw || "").trim(),
|
|
44
|
+
);
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
const localAsUtc = Date.UTC(
|
|
47
|
+
Number(m[1]),
|
|
48
|
+
Number(m[2]) - 1,
|
|
49
|
+
Number(m[3]),
|
|
50
|
+
Number(m[4]),
|
|
51
|
+
Number(m[5]),
|
|
52
|
+
Number(m[6]),
|
|
53
|
+
Math.floor(Number(m[7]) / 1000),
|
|
54
|
+
);
|
|
55
|
+
const offsetMinutes = (m[8] === "-" ? -1 : 1) * Number(m[9]);
|
|
56
|
+
return localAsUtc - offsetMinutes * 60000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read a process's creation time (epoch ms) with ONE synchronous probe, or
|
|
61
|
+
* null when it cannot be determined (missing tools, permissions, platform
|
|
62
|
+
* quirks). Callers must treat null as "unknown" and FAIL OPEN to the legacy
|
|
63
|
+
* kill(pid, 0) answer — a broken probe must never declare a live worker dead
|
|
64
|
+
* (nor green-light killing a process we could not identify as ours; see the
|
|
65
|
+
* per-call-site policy).
|
|
66
|
+
*/
|
|
67
|
+
function defaultReadProcessStartTimeMs(pid) {
|
|
68
|
+
const target = Number(pid);
|
|
69
|
+
if (!Number.isInteger(target) || target <= 0) return null;
|
|
70
|
+
if (process.platform === "win32") {
|
|
71
|
+
// wmic first (fast, ~100-300ms); PowerShell CIM as fallback (wmic is
|
|
72
|
+
// removed from recent Windows 11 builds).
|
|
73
|
+
try {
|
|
74
|
+
const r = spawnSync(
|
|
75
|
+
"wmic",
|
|
76
|
+
[
|
|
77
|
+
"process",
|
|
78
|
+
"where",
|
|
79
|
+
`ProcessId=${target}`,
|
|
80
|
+
"get",
|
|
81
|
+
"CreationDate",
|
|
82
|
+
"/value",
|
|
83
|
+
],
|
|
84
|
+
{ windowsHide: true, encoding: "utf8", timeout: 5000 },
|
|
85
|
+
);
|
|
86
|
+
if (!r.error && r.status === 0) {
|
|
87
|
+
const m = /CreationDate=([^\r\n]+)/.exec(r.stdout || "");
|
|
88
|
+
const ms = m ? parseCimDateToMs(m[1]) : null;
|
|
89
|
+
if (ms !== null) return ms;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
/* fall through to PowerShell */
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const script =
|
|
96
|
+
`$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${target}' -ErrorAction SilentlyContinue; ` +
|
|
97
|
+
"if ($p -and $p.CreationDate) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds() }";
|
|
98
|
+
const r = spawnSync(
|
|
99
|
+
"powershell",
|
|
100
|
+
["-NoProfile", "-NonInteractive", "-Command", script],
|
|
101
|
+
{ windowsHide: true, encoding: "utf8", timeout: 10000 },
|
|
102
|
+
);
|
|
103
|
+
if (!r.error && r.status === 0) {
|
|
104
|
+
const n = Number(String(r.stdout || "").trim());
|
|
105
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* fail open below */
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
// POSIX: `ps -o lstart=` gives an absolute start timestamp on Linux + macOS
|
|
113
|
+
// (procps and BSD ps both support it; busybox ps does not → null → open).
|
|
114
|
+
try {
|
|
115
|
+
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(target)], {
|
|
116
|
+
encoding: "utf8",
|
|
117
|
+
timeout: 5000,
|
|
118
|
+
});
|
|
119
|
+
if (!r.error && r.status === 0) {
|
|
120
|
+
const t = Date.parse(String(r.stdout || "").trim());
|
|
121
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
/* fail open below */
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Kill a whole process tree (Gap 2 orphan reclaim). Windows: `taskkill /T`.
|
|
131
|
+
* POSIX: negative-pid group signal (the worker spawns the agent child
|
|
132
|
+
* detached → its own group), falling back to a direct kill.
|
|
133
|
+
*/
|
|
134
|
+
function defaultKillProcessTree(pid, signal = "SIGKILL") {
|
|
135
|
+
const target = Number(pid);
|
|
136
|
+
if (!Number.isInteger(target) || target <= 0) return false;
|
|
137
|
+
try {
|
|
138
|
+
if (process.platform === "win32") {
|
|
139
|
+
const r = spawnSync("taskkill", ["/PID", String(target), "/T", "/F"], {
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
});
|
|
143
|
+
return !r.error && r.status === 0;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-target, signal);
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
process.kill(target, signal);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const _deps = {
|
|
158
|
+
spawn,
|
|
159
|
+
spawnSync,
|
|
160
|
+
readProcessStartTimeMs: defaultReadProcessStartTimeMs,
|
|
161
|
+
killProcessTree: defaultKillProcessTree,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Cached probe (default impl only — injected probes run uncached for tests). */
|
|
165
|
+
function processStartTimeMs(pid) {
|
|
166
|
+
if (_deps.readProcessStartTimeMs !== defaultReadProcessStartTimeMs) {
|
|
167
|
+
const raw = _deps.readProcessStartTimeMs(pid);
|
|
168
|
+
const n = Number(raw);
|
|
169
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
170
|
+
}
|
|
171
|
+
const key = Number(pid);
|
|
172
|
+
const at = Date.now();
|
|
173
|
+
const hit = _startTimeCache.get(key);
|
|
174
|
+
if (hit && at - hit.at < START_TIME_CACHE_TTL_MS) return hit.value;
|
|
175
|
+
const value = defaultReadProcessStartTimeMs(key);
|
|
176
|
+
_startTimeCache.set(key, { at, value });
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
24
180
|
function nowMs(options = {}) {
|
|
25
181
|
return typeof options.now === "number" ? options.now : Date.now();
|
|
26
182
|
}
|
|
@@ -136,6 +292,55 @@ export function isProcessAlive(pid) {
|
|
|
136
292
|
}
|
|
137
293
|
}
|
|
138
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Is the live process at `pid` the SAME process the state file recorded at
|
|
297
|
+
* `expectedStartedAtMs` — or a pid-reusing stranger? (Gap 1.)
|
|
298
|
+
*
|
|
299
|
+
* - dead pid → false (not the same process; it is no process at all)
|
|
300
|
+
* - no anchor (legacy state without startedAt) → true (legacy semantics)
|
|
301
|
+
* - probe failed → true (FAIL OPEN: never declare a live worker dead — and
|
|
302
|
+
* never kill — on the strength of a broken probe; callers fall back to the
|
|
303
|
+
* plain kill(pid,0) behavior this system always had)
|
|
304
|
+
* - creation time > startedAt + tolerance → false (a reuser can only be
|
|
305
|
+
* born after the original died)
|
|
306
|
+
*/
|
|
307
|
+
export function isSameProcess(pid, expectedStartedAtMs, options = {}) {
|
|
308
|
+
if (!isProcessAlive(pid)) return false;
|
|
309
|
+
const expected = Number(expectedStartedAtMs);
|
|
310
|
+
if (!Number.isFinite(expected) || expected <= 0) return true;
|
|
311
|
+
const actual = processStartTimeMs(pid);
|
|
312
|
+
if (actual === null) return true;
|
|
313
|
+
const tolerance = Number.isFinite(Number(options.toleranceMs))
|
|
314
|
+
? Number(options.toleranceMs)
|
|
315
|
+
: PID_IDENTITY_TOLERANCE_MS;
|
|
316
|
+
return actual <= expected + tolerance;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Reap a lost worker's recorded agent child (Gap 2). The worker persists
|
|
321
|
+
* `agentPid` + `agentStartedAt` per turn; when the WORKER dies (crash /
|
|
322
|
+
* pid-reuse detection) nothing used to kill that grandchild — it kept
|
|
323
|
+
* running unsupervised. Kill it, tree-wide, IF we can prove identity:
|
|
324
|
+
*
|
|
325
|
+
* - `agentStartedAt` anchor is REQUIRED — unlike the read-only liveness
|
|
326
|
+
* probe, this path kills, so a legacy state without the anchor fails
|
|
327
|
+
* CLOSED (no kill) rather than risking a pid-reused stranger;
|
|
328
|
+
* - with the anchor present, a failed creation-time probe fails open to the
|
|
329
|
+
* kill (the orphan leak is the common case, reuse the rare one — and the
|
|
330
|
+
* anchor already bounds the window to this session's lifetime).
|
|
331
|
+
*
|
|
332
|
+
* @returns {boolean} true when a kill was actually issued
|
|
333
|
+
*/
|
|
334
|
+
export function reclaimOrphanAgentProcess(state) {
|
|
335
|
+
const agentPid = Number(state?.agentPid);
|
|
336
|
+
if (!Number.isInteger(agentPid) || agentPid <= 0) return false;
|
|
337
|
+
if (agentPid === Number(state?.pid)) return false; // never the worker itself
|
|
338
|
+
const anchor = Number(state?.agentStartedAt);
|
|
339
|
+
if (!Number.isFinite(anchor) || anchor <= 0) return false; // fail closed
|
|
340
|
+
if (!isSameProcess(agentPid, anchor)) return false; // reused / already gone
|
|
341
|
+
return _deps.killProcessTree(agentPid, "SIGKILL") === true;
|
|
342
|
+
}
|
|
343
|
+
|
|
139
344
|
export function normalizeBackgroundAgentTitle(title) {
|
|
140
345
|
const value = String(title || "").trim();
|
|
141
346
|
if (!value) throw new Error("Background agent title cannot be empty");
|
|
@@ -164,12 +369,34 @@ export function effectiveBackgroundAgentState(state, options = {}) {
|
|
|
164
369
|
endedAt: state.endedAt || t,
|
|
165
370
|
lostReason: "process-exited",
|
|
166
371
|
};
|
|
372
|
+
} else if (!isSameProcess(state.pid, state.startedAt)) {
|
|
373
|
+
// Gap 1: the pid is alive but belongs to a process created well after
|
|
374
|
+
// this session started — the OS reused the worker's pid.
|
|
375
|
+
next = {
|
|
376
|
+
...state,
|
|
377
|
+
status: "lost",
|
|
378
|
+
endedAt: state.endedAt || t,
|
|
379
|
+
lostReason: "pid-reused",
|
|
380
|
+
};
|
|
167
381
|
}
|
|
168
|
-
if (next !== state
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
382
|
+
if (next !== state) {
|
|
383
|
+
// Gap 2: the worker is gone (dead pid or pid-reused stranger) — reap the
|
|
384
|
+
// recorded agent grandchild so a crashed worker never leaves it running
|
|
385
|
+
// unsupervised. A merely-stale heartbeat with a live, verified worker
|
|
386
|
+
// still owns its child, so isSameProcess gates the reclaim.
|
|
387
|
+
if (!isSameProcess(state.pid, state.startedAt)) {
|
|
388
|
+
try {
|
|
389
|
+
reclaimOrphanAgentProcess(state);
|
|
390
|
+
} catch {
|
|
391
|
+
/* best-effort */
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (options.persist !== false) {
|
|
395
|
+
try {
|
|
396
|
+
writeBackgroundAgentState(next);
|
|
397
|
+
} catch {
|
|
398
|
+
/* best-effort; callers still get the corrected state */
|
|
399
|
+
}
|
|
173
400
|
}
|
|
174
401
|
}
|
|
175
402
|
return next;
|
|
@@ -430,6 +657,7 @@ export function launchBackgroundAgent({
|
|
|
430
657
|
pid: null,
|
|
431
658
|
workerPid: null,
|
|
432
659
|
agentPid: null,
|
|
660
|
+
agentStartedAt: null,
|
|
433
661
|
status: "running",
|
|
434
662
|
startedAt: Date.now(),
|
|
435
663
|
heartbeatAt: Date.now(),
|
|
@@ -496,7 +724,35 @@ export function readBackgroundAgentLog(id, options = {}) {
|
|
|
496
724
|
export function stopBackgroundAgent(id) {
|
|
497
725
|
const state = effectiveBackgroundAgentState(readBackgroundAgentState(id));
|
|
498
726
|
if (!state) throw new Error(`Background agent not found: ${id}`);
|
|
499
|
-
if (state.status !== "running")
|
|
727
|
+
if (state.status !== "running") {
|
|
728
|
+
// Gap 2: stopping an already-lost session still reaps a leaked agent
|
|
729
|
+
// child recorded before the worker died (identity-guarded inside).
|
|
730
|
+
if (state.status === "lost") {
|
|
731
|
+
try {
|
|
732
|
+
reclaimOrphanAgentProcess(state);
|
|
733
|
+
} catch {
|
|
734
|
+
/* best-effort */
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return { ...state, stopped: false };
|
|
738
|
+
}
|
|
739
|
+
// Gap 1: last-instant identity re-check before the kill — never
|
|
740
|
+
// taskkill/SIGTERM a pid the OS has re-assigned to an unrelated process.
|
|
741
|
+
if (!isSameProcess(state.pid, state.startedAt)) {
|
|
742
|
+
try {
|
|
743
|
+
reclaimOrphanAgentProcess(state);
|
|
744
|
+
} catch {
|
|
745
|
+
/* best-effort */
|
|
746
|
+
}
|
|
747
|
+
const lost = {
|
|
748
|
+
...state,
|
|
749
|
+
status: "lost",
|
|
750
|
+
endedAt: Date.now(),
|
|
751
|
+
lostReason: "pid-reused",
|
|
752
|
+
};
|
|
753
|
+
writeBackgroundAgentState(lost);
|
|
754
|
+
return { ...lost, stopped: false };
|
|
755
|
+
}
|
|
500
756
|
if (process.platform === "win32") {
|
|
501
757
|
const killed = _deps.spawnSync(
|
|
502
758
|
"taskkill",
|
|
@@ -517,6 +773,30 @@ export function stopBackgroundAgent(id) {
|
|
|
517
773
|
} catch {
|
|
518
774
|
process.kill(Number(state.pid), "SIGTERM");
|
|
519
775
|
}
|
|
776
|
+
// The worker detaches the agent child into its OWN process group (so a
|
|
777
|
+
// crashed worker's child can be group-killed) — which also means the
|
|
778
|
+
// worker-group SIGTERM above no longer reaches it. Kill the agent group
|
|
779
|
+
// explicitly, identity-guarded via its agentStartedAt anchor.
|
|
780
|
+
const agentPid = Number(state.agentPid);
|
|
781
|
+
const anchor = Number(state.agentStartedAt);
|
|
782
|
+
if (
|
|
783
|
+
Number.isInteger(agentPid) &&
|
|
784
|
+
agentPid > 0 &&
|
|
785
|
+
agentPid !== Number(state.pid) &&
|
|
786
|
+
Number.isFinite(anchor) &&
|
|
787
|
+
anchor > 0 &&
|
|
788
|
+
isSameProcess(agentPid, anchor)
|
|
789
|
+
) {
|
|
790
|
+
try {
|
|
791
|
+
process.kill(-agentPid, "SIGTERM");
|
|
792
|
+
} catch {
|
|
793
|
+
try {
|
|
794
|
+
process.kill(agentPid, "SIGTERM");
|
|
795
|
+
} catch {
|
|
796
|
+
/* already gone */
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
520
800
|
}
|
|
521
801
|
const next = {
|
|
522
802
|
...state,
|