@qwen-code/qwen-code 0.19.3 → 0.19.4-nightly.20260702.46814e4f1
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/bundled/loop/SKILL.md +22 -3
- package/bundled/loop/SKILL.test.ts +20 -0
- package/bundled/loop/autonomous-loop.test.ts +59 -0
- package/bundled/loop/autonomous-loop.ts +102 -0
- package/bundled/loop/loop-task-file.test.ts +1064 -0
- package/bundled/loop/loop-task-file.ts +404 -0
- package/bundled/loop/loop-tick-resolver.test.ts +906 -0
- package/bundled/loop/loop-tick-resolver.ts +416 -0
- package/bundled/qc-helper/SKILL.md +12 -5
- package/bundled/qc-helper/docs/configuration/auth.md +1 -1
- package/bundled/qc-helper/docs/configuration/settings.md +62 -32
- package/bundled/qc-helper/docs/features/approval-mode.md +3 -0
- package/bundled/qc-helper/docs/features/auto-mode.md +40 -0
- package/bundled/qc-helper/docs/features/channels/overview.md +81 -18
- package/bundled/qc-helper/docs/features/channels/plugins.md +1 -1
- package/bundled/qc-helper/docs/features/code-review.md +17 -61
- package/bundled/qc-helper/docs/features/commands.md +42 -40
- package/bundled/qc-helper/docs/features/headless.md +19 -18
- package/bundled/qc-helper/docs/features/hooks.md +6 -1
- package/bundled/qc-helper/docs/features/mcp.md +3 -1
- package/bundled/qc-helper/docs/features/memory.md +1 -1
- package/bundled/qc-helper/docs/features/scheduled-tasks.md +10 -0
- package/bundled/qc-helper/docs/features/status-line.md +3 -3
- package/bundled/qc-helper/docs/ide-integration/ide-integration.md +4 -4
- package/bundled/qc-helper/docs/qwen-serve.md +53 -0
- package/bundled/qc-helper/docs/support/troubleshooting.md +11 -0
- package/bundled/review/DESIGN.md +21 -26
- package/bundled/review/SKILL.md +45 -129
- package/chunks/MaxSizedBox-GHGRQ2OS.js +69 -0
- package/chunks/{StandaloneSessionPicker-Z52JTYST.js → StandaloneSessionPicker-G7YTTM27.js} +49 -42
- package/chunks/{acpAgent-7RZUOWOM.js → acpAgent-SDOILBSU.js} +1325 -365
- package/chunks/{agent-4FG7WTHJ.js → agent-NTSXGV4L.js} +31 -25
- package/chunks/agent-headless-WYURMSAB.js +62 -0
- package/chunks/{anthropicContentGenerator-PFITVL7Y.js → anthropicContentGenerator-WL3LJOUB.js} +110 -31
- package/chunks/{artifact-tool-D2X7G3C3.js → artifact-tool-W76WC2JX.js} +3 -3
- package/chunks/{askUserQuestion-UGETICJQ.js → askUserQuestion-J3P6TH3V.js} +2 -2
- package/chunks/bridge-AMOL4W4P.js +73 -0
- package/chunks/{ca-IUFWESZL.js → ca-HCMB2I54.js} +10 -9
- package/chunks/channel-worker-supervisor-C3IB63V7.js +659 -0
- package/chunks/{chunk-STUWFKGD.js → chunk-26JIREA5.js} +466 -543
- package/chunks/{chunk-SLNKLLEO.js → chunk-2MRV62AU.js} +37 -8
- package/chunks/{chunk-ODLIHNOY.js → chunk-2U4HUMOL.js} +1 -1
- package/chunks/{chunk-CKQA6AWF.js → chunk-4D73HUOP.js} +1 -1
- package/chunks/{chunk-M2U5NM24.js → chunk-54YZOP7P.js} +309 -72
- package/chunks/{chunk-J7FA27RO.js → chunk-5LKL3PAU.js} +3 -3
- package/chunks/chunk-5P5XGNYH.js +93 -0
- package/chunks/{chunk-UFLGERMB.js → chunk-6JE32BRB.js} +178 -124
- package/chunks/{chunk-FKAVULLM.js → chunk-75AVTQJ2.js} +74 -10
- package/chunks/{chunk-I2LYBGNI.js → chunk-7IOMIYOE.js} +1 -1
- package/chunks/{chunk-4LUZAKMK.js → chunk-7KXAGQJ2.js} +1 -1
- package/chunks/{chunk-BXSHL2SF.js → chunk-7LUOQJ3D.js} +1 -1
- package/chunks/{chunk-YAINVQ3Q.js → chunk-A5OPLHFM.js} +18693 -17055
- package/chunks/chunk-AHNXYU4O.js +24 -0
- package/chunks/{chunk-UX4SGCGN.js → chunk-AVQDCKNF.js} +96 -22
- package/chunks/{chunk-HYCC3XM3.js → chunk-B236XPGF.js} +1 -1
- package/chunks/chunk-B6SZLQAZ.js +3523 -0
- package/chunks/{chunk-5DWXLGOM.js → chunk-BJBWRCSK.js} +1 -1
- package/chunks/chunk-BPFJQGY7.js +193 -0
- package/chunks/{chunk-SKQZZVAV.js → chunk-BZHLWC6T.js} +2 -2
- package/chunks/{chunk-U3KSMM7G.js → chunk-D5KISTLW.js} +339 -383
- package/chunks/{chunk-GT2FGICD.js → chunk-DE7YXUR7.js} +1 -1
- package/chunks/{chunk-BUUQ5HPX.js → chunk-DG573EOO.js} +1 -1
- package/chunks/{chunk-VVLOGDB6.js → chunk-DX3TKXNT.js} +2 -2
- package/chunks/chunk-E5A7LHNN.js +51 -0
- package/chunks/{chunk-3PJXIDKI.js → chunk-E6US47KI.js} +3 -27
- package/chunks/{chunk-QBZ22PQX.js → chunk-EWALXUAD.js} +3 -1
- package/chunks/{chunk-PDE7CSDS.js → chunk-FXKI7OW6.js} +2 -2
- package/chunks/{chunk-F3ZYSEY6.js → chunk-FY4TT4L4.js} +3 -3
- package/chunks/{chunk-QLDPRILK.js → chunk-G7OQ3RFJ.js} +3 -3
- package/chunks/{chunk-R2BXK7SX.js → chunk-GHOTR7HL.js} +6 -1
- package/chunks/{chunk-KMGMKW5Q.js → chunk-GLPV75ES.js} +9 -3
- package/chunks/{chunk-LD2XBG6Z.js → chunk-GO6LNQXT.js} +1 -39
- package/chunks/{chunk-CNLEDYE3.js → chunk-H6TNLOLM.js} +5 -5
- package/chunks/{chunk-JVHEVCAU.js → chunk-HLW4EGKR.js} +31 -6
- package/chunks/{chunk-I4H7PRWQ.js → chunk-HTWHAN44.js} +1 -1
- package/chunks/{chunk-QE6KJYLT.js → chunk-IPYE5BIS.js} +123 -25
- package/chunks/{chunk-EJYJYJZ2.js → chunk-IRJWHZWU.js} +3 -70
- package/chunks/{chunk-EDMZZFDU.js → chunk-J3YPTTNL.js} +930 -798
- package/chunks/{chunk-GGBIN5K7.js → chunk-JGAJMIJZ.js} +1 -1
- package/chunks/{chunk-N4EJFBN5.js → chunk-JVLP5US5.js} +427 -263
- package/chunks/{chunk-R5OP2ATH.js → chunk-JZG7VB6C.js} +11 -11
- package/chunks/{chunk-QI7GOGJR.js → chunk-KKQWC6WS.js} +1 -1
- package/chunks/{chunk-ADCIHOQZ.js → chunk-KX5QZRW2.js} +142 -30
- package/chunks/{chunk-SBBN4BWR.js → chunk-L7OJ3ZO3.js} +1 -1
- package/chunks/{chunk-NDOC6M5S.js → chunk-L7QPM2YD.js} +2 -2
- package/chunks/{chunk-BVSVIN45.js → chunk-LKWAVAB5.js} +3 -3
- package/chunks/{chunk-T4T2FNV4.js → chunk-LOW52WYZ.js} +2 -2
- package/chunks/{chunk-4QK3O43S.js → chunk-LU7BIVWC.js} +4 -4
- package/chunks/{chunk-ZP6UKOPB.js → chunk-LWGLBVWA.js} +1 -1
- package/chunks/{chunk-L7OKCI7T.js → chunk-M7BLWURA.js} +15 -1
- package/chunks/{chunk-PR32FRT6.js → chunk-MIM62IXS.js} +3 -3
- package/chunks/{chunk-RD6YJJJ6.js → chunk-MOUXUCXQ.js} +1 -1
- package/chunks/{chunk-VBZ452JT.js → chunk-MSNGFH6L.js} +494 -114
- package/chunks/{chunk-BX5YO7UW.js → chunk-MTNTVOJR.js} +542 -370
- package/chunks/chunk-MX2YXRER.js +44 -0
- package/chunks/chunk-NIOPRZNN.js +27 -0
- package/chunks/{chunk-SMYQZVZ3.js → chunk-NOGSPTLJ.js} +1 -1
- package/chunks/{chunk-DDMOU62Q.js → chunk-NPQWQYU6.js} +1 -1
- package/chunks/{chunk-MNF6S5PR.js → chunk-P5THQV6A.js} +4 -4
- package/chunks/{chunk-6V4NZFUS.js → chunk-PCYOT4PK.js} +5 -5
- package/chunks/{chunk-CF6BB3LT.js → chunk-PPFWEV7G.js} +10 -1
- package/chunks/{chunk-FXKVLFFY.js → chunk-PSFDB5FZ.js} +3 -3
- package/chunks/{chunk-XJYUPHRC.js → chunk-QOMAHFYI.js} +3 -3
- package/chunks/{chunk-J37FGIOA.js → chunk-QXPI7FEC.js} +38 -4
- package/chunks/{chunk-RRFZXMYP.js → chunk-QZDBHNMV.js} +691 -164
- package/chunks/{chunk-S7AKE7HL.js → chunk-R6RI2HIC.js} +63 -9
- package/chunks/{chunk-HZT4UCFB.js → chunk-RB7S52NJ.js} +4 -4
- package/chunks/{chunk-J33ZR6OJ.js → chunk-RK22ACPP.js} +16 -16
- package/chunks/chunk-RM3PGSGL.js +66 -0
- package/chunks/chunk-RNBYOUGV.js +42 -0
- package/chunks/chunk-S6RLAIUR.js +23 -0
- package/chunks/{chunk-3TV3HUWZ.js → chunk-SCYK2YLI.js} +81 -30
- package/chunks/{chunk-HPZTOP4I.js → chunk-SEKRT4SD.js} +133 -23
- package/chunks/chunk-SEZC2725.js +36 -0
- package/chunks/chunk-SIUQ3YYX.js +78 -0
- package/chunks/{chunk-O2VBQ3Y2.js → chunk-SJBIFVD2.js} +2 -2
- package/chunks/chunk-SYCJMSIJ.js +82 -0
- package/chunks/{chunk-JE5FVRPC.js → chunk-T22H4V4R.js} +48 -23
- package/chunks/{chunk-3227DZO4.js → chunk-T42ACHFI.js} +1 -1
- package/chunks/{chunk-UO5GVXQE.js → chunk-TENOUWY2.js} +3 -3
- package/chunks/{chunk-JOLIREYR.js → chunk-TOAUDIS7.js} +1 -1
- package/chunks/{chunk-QNKBEJO2.js → chunk-UA6XBZT7.js} +2776 -1289
- package/chunks/{chunk-O75BHFQW.js → chunk-UQTYK5SS.js} +2 -2
- package/chunks/{chunk-Y6YMRS3U.js → chunk-UY2TCIZS.js} +17 -3
- package/chunks/{chunk-FNJ7BDPH.js → chunk-UY7ZAYTY.js} +10 -7
- package/chunks/{chunk-UYT3MWEB.js → chunk-UYHWM24C.js} +30 -2
- package/chunks/{chunk-SMXULRAN.js → chunk-VXGPYF7U.js} +1 -1
- package/chunks/{chunk-BTLJ2MF5.js → chunk-W7CG5HPC.js} +3 -3
- package/chunks/{chunk-JJXWTVSK.js → chunk-XHZKDN7M.js} +34 -1
- package/chunks/{chunk-K5XU6E4T.js → chunk-XKSCTXPK.js} +2 -2
- package/chunks/chunk-Y4LITCRB.js +367 -0
- package/chunks/chunk-YHTNWGRR.js +239 -0
- package/chunks/{chunk-BKXBARJZ.js → chunk-YPCPZSZB.js} +1 -1
- package/chunks/{chunk-QWFNTRH4.js → chunk-YSM24SCE.js} +1 -1
- package/chunks/chunk-YTFBURQD.js +313 -0
- package/chunks/{chunk-NL4C3H6C.js → chunk-YXK4GPYO.js} +1 -1
- package/chunks/{chunk-RYLJU2DY.js → chunk-ZN5T4BHI.js} +1 -1
- package/chunks/{computer-use-JGB36FDW.js → computer-use-MTSVPLPY.js} +34 -28
- package/chunks/contextCommand-VGJ3BPTK.js +67 -0
- package/chunks/{cron-create-SKEY55RG.js → cron-create-6YTCOX6P.js} +4 -4
- package/chunks/{cron-delete-I3NAT32Z.js → cron-delete-E3NIFSUK.js} +4 -4
- package/chunks/{cron-list-6XRRL6TH.js → cron-list-Q64MNQNE.js} +4 -4
- package/chunks/daemon-EEB2RYOL.js +8453 -0
- package/chunks/daemon-status-provider-H5SNMK42.js +71 -0
- package/chunks/{de-EOW5PVHF.js → de-PLLFDERI.js} +10 -9
- package/chunks/{dist-NHEYYB2B.js → dist-ADMIQGHI.js} +2 -2
- package/chunks/{dist-J37VXPLD.js → dist-BCQ7INM7.js} +40 -15
- package/chunks/{dist-663NZ6MI.js → dist-KNZFCY5F.js} +116 -11
- package/chunks/{dist-LB6OGPVM.js → dist-LHUWC3K6.js} +26 -5
- package/chunks/{dist-PQPZ5Q4N.js → dist-WBBZ76JZ.js} +20 -9
- package/chunks/{earlyInputCapture-44RISVVJ.js → earlyInputCapture-U4ZIQSQU.js} +32 -26
- package/chunks/{edit-NN6GM5DT.js → edit-SYIQ2KBI.js} +41 -38
- package/chunks/{en-HPWEJBTR.js → en-M4EBUBNU.js} +32 -10
- package/chunks/{enter-worktree-PB3O2C7D.js → enter-worktree-HPPOO244.js} +31 -25
- package/chunks/{enterPlanMode-YQFNNSUA.js → enterPlanMode-56YYO52U.js} +41 -25
- package/chunks/{errors-LS7XU5DZ.js → errors-EO4SXCFP.js} +33 -27
- package/chunks/{exit-worktree-E2FTNSBK.js → exit-worktree-UDM7E6XH.js} +31 -25
- package/chunks/{exitPlanMode-GLYK2BUB.js → exitPlanMode-M6P3AWLJ.js} +149 -33
- package/chunks/{fast-path-WQ6M7LI7.js → fast-path-42P4QYWI.js} +33 -14
- package/chunks/{fast-path-settings-OAGU3X5H.js → fast-path-settings-UQLRFDQP.js} +2 -2
- package/chunks/{fr-BN5NKSAC.js → fr-MBMNQSQ6.js} +10 -9
- package/chunks/{gemini-2YFLVA2M.js → gemini-NRLXS5J7.js} +84 -70
- package/chunks/{geminiContentGenerator-XV6SV6CD.js → geminiContentGenerator-DLJPYYPA.js} +41 -5
- package/chunks/{glob-2H6CDDMK.js → glob-JPY3F74Y.js} +31 -25
- package/chunks/{grep-VXIUXM3C.js → grep-HW23YES5.js} +31 -25
- package/chunks/initializer-HWOZTC4A.js +67 -0
- package/chunks/{ja-7DEUB2TS.js → ja-HFV5QCHR.js} +10 -9
- package/chunks/{keychain-token-storage-YLMDLKXT.js → keychain-token-storage-GPAKRDKQ.js} +2 -2
- package/chunks/list-OZGAGRDG.js +71 -0
- package/chunks/loadedSettingsAdapter-RUTZVVIA.js +66 -0
- package/chunks/{loop-wakeup-CITGGQHA.js → loop-wakeup-G2EHBRGR.js} +5 -5
- package/chunks/{ls-R5WP4BGZ.js → ls-IDHUVSAG.js} +4 -4
- package/chunks/{lsp-GSICJMMW.js → lsp-FSRYXGFT.js} +2 -2
- package/chunks/{monitor-HKLRS2HQ.js → monitor-QVWEIXU3.js} +33 -26
- package/chunks/nonInteractiveCli-RV2WYPML.js +109 -0
- package/chunks/{notebook-edit-Y6SKSGTT.js → notebook-edit-B26W3FFH.js} +37 -34
- package/chunks/{openaiContentGenerator-J7G2FYJT.js → openaiContentGenerator-6TH7AZGQ.js} +15 -13
- package/chunks/pidfile-KW2QQJZI.js +73 -0
- package/chunks/{pt-TNZRNLQB.js → pt-2THT3YHA.js} +10 -9
- package/chunks/{qwenContentGenerator-5XIEM5GV.js → qwenContentGenerator-5IQAC7RN.js} +33 -27
- package/chunks/{qwenOAuth2-AG3PFFLZ.js → qwenOAuth2-LWLA6MQG.js} +5 -4
- package/chunks/{read-file-J7D552ZJ.js → read-file-CWFAHOI2.js} +11 -10
- package/chunks/{read-mcp-resource-DQQW7H7T.js → read-mcp-resource-PIJF24RO.js} +2 -2
- package/chunks/ripGrep-FTZGDKY4.js +60 -0
- package/chunks/{ru-NIAUBKCN.js → ru-Z67YJHBG.js} +10 -9
- package/chunks/{run-qwen-serve-SGEEVMXD.js → run-qwen-serve-FN2SW5ZI.js} +545 -97
- package/chunks/{scheduler-7CQI3S4Q.js → scheduler-BMS23O3I.js} +31 -25
- package/chunks/{send-message-EE4TWKVU.js → send-message-33PYFMCT.js} +20 -4
- package/chunks/serve-PXFYKL65.js +71 -0
- package/chunks/{server-4QMUWXPW.js → server-BRKALJ4F.js} +4132 -1175
- package/chunks/{session-NRO7UFOY.js → session-AQMISBP4.js} +205 -64
- package/chunks/{settings-SGWRDQSC.js → settings-JCPVUZMB.js} +35 -29
- package/chunks/{shell-62ANGIHL.js → shell-POHCRVN2.js} +31 -25
- package/chunks/{skill-VWAQAWW6.js → skill-QPEVHS6H.js} +13 -12
- package/chunks/spawnChannel-V3QT62TE.js +69 -0
- package/chunks/{src-KUV5J5Q2.js → src-IZSP7L6Y.js} +127 -32
- package/chunks/{startInteractiveUI-6EYJN3JV.js → startInteractiveUI-63KATISO.js} +16762 -16075
- package/chunks/{syntheticOutput-T5YHQ6S2.js → syntheticOutput-YOSWIHDJ.js} +3 -3
- package/chunks/{task-create-BLY7RHXX.js → task-create-NHS2NGB3.js} +9 -7
- package/chunks/{task-list-3QUYNVYR.js → task-list-5UIB564R.js} +7 -6
- package/chunks/{task-stop-U4RATK2V.js → task-stop-HGXZ2VZ3.js} +2 -2
- package/chunks/{task-update-U54O7KJX.js → task-update-IYYWUILG.js} +40 -7
- package/chunks/{team-create-LPOGCPM7.js → team-create-E5O2F3CA.js} +31 -25
- package/chunks/{team-delete-LKBYPASD.js → team-delete-FEKJCU4P.js} +7 -6
- package/chunks/team-plan-approval-2TU4YROB.js +202 -0
- package/chunks/theme-manager-37KOGPJL.js +64 -0
- package/chunks/{todoWrite-OYXKTVYN.js → todoWrite-TKMVZ2HT.js} +4 -4
- package/chunks/{tool-search-YPUFJ3ZQ.js → tool-search-4ZTGNSFI.js} +38 -11
- package/chunks/{trustedFolders-5B24TMEA.js → trustedFolders-RJ5WXJ6L.js} +32 -26
- package/chunks/{validateNonInterActiveAuth-BTDTYOVT.js → validateNonInterActiveAuth-ZWMHZAOK.js} +66 -55
- package/chunks/{web-fetch-EEB5LZYU.js → web-fetch-J4PIW36Q.js} +5 -4
- package/chunks/{workflow-LAWIW5LI.js → workflow-6PG2PC5M.js} +68 -39
- package/chunks/workspace-providers-status-4QIZN2W4.js +68 -0
- package/chunks/workspace-service-CYMCOEIO.js +78 -0
- package/chunks/{write-file-PCV226DJ.js → write-file-R354LGQF.js} +41 -40
- package/chunks/{zh-5EUYXASN.js → zh-SLZNSSZB.js} +32 -10
- package/chunks/{zh-TW-TR3XYGE5.js → zh-TW-P53C2SL5.js} +32 -10
- package/cli.js +6 -6
- package/locales/ca.js +15 -13
- package/locales/de.js +15 -13
- package/locales/en.js +52 -15
- package/locales/fr.js +15 -14
- package/locales/ja.js +15 -14
- package/locales/pt.js +15 -13
- package/locales/ru.js +15 -13
- package/locales/zh-TW.js +50 -14
- package/locales/zh.js +50 -14
- package/node_modules/@qwen-code/audio-capture/package.json +1 -1
- package/node_modules/@qwen-code/audio-capture/prebuilds/darwin-arm64/@qwen-code+audio-capture.node +0 -0
- package/node_modules/@qwen-code/audio-capture/prebuilds/darwin-x64/@qwen-code+audio-capture.node +0 -0
- package/node_modules/@qwen-code/audio-capture/prebuilds/win32-x64/@qwen-code+audio-capture.node +0 -0
- package/package.json +15 -6
- package/patches/chrome-devtools-mcp+1.4.0.patch +31 -0
- package/postinstall.js +50 -0
- package/web-shell/assets/{arc-DA7r8Ueo.js → arc-Bd3048nX.js} +1 -1
- package/web-shell/assets/{architectureDiagram-3BPJPVTR-ZDUxPtZ2.js → architectureDiagram-3BPJPVTR-DyzbzABx.js} +1 -1
- package/web-shell/assets/{blockDiagram-GPEHLZMM-vrtOf1kJ.js → blockDiagram-GPEHLZMM-CTjxm9Sy.js} +1 -1
- package/web-shell/assets/{c4Diagram-AAUBKEIU-BHFjzIi4.js → c4Diagram-AAUBKEIU-DSOFWNU6.js} +1 -1
- package/web-shell/assets/channel-oVoup_Ci.js +1 -0
- package/web-shell/assets/{chunk-2J33WTMH-mdeQVz59.js → chunk-2J33WTMH-D_PFdjo2.js} +1 -1
- package/web-shell/assets/{chunk-4BX2VUAB-BkKMlwBX.js → chunk-4BX2VUAB-52eG6wOT.js} +1 -1
- package/web-shell/assets/{chunk-55IACEB6-B7bnXNfu.js → chunk-55IACEB6-CGTZfKkC.js} +1 -1
- package/web-shell/assets/{chunk-727SXJPM-DGz8Mm6i.js → chunk-727SXJPM-CZ-qfRUm.js} +1 -1
- package/web-shell/assets/{chunk-AQP2D5EJ-BQAydUNk.js → chunk-AQP2D5EJ-B2dSDcqM.js} +1 -1
- package/web-shell/assets/{chunk-FMBD7UC4-GHr2LN_p.js → chunk-FMBD7UC4-Cfy3tyA0.js} +1 -1
- package/web-shell/assets/{chunk-ND2GUHAM-7R91lnUx.js → chunk-ND2GUHAM-CApNUpUq.js} +1 -1
- package/web-shell/assets/{chunk-QZHKN3VN-aTJD3lSQ.js → chunk-QZHKN3VN-CVBG8kgg.js} +1 -1
- package/web-shell/assets/classDiagram-4FO5ZUOK-B8WsaT2G.js +1 -0
- package/web-shell/assets/classDiagram-v2-Q7XG4LA2-B8WsaT2G.js +1 -0
- package/web-shell/assets/{cose-bilkent-S5V4N54A-BTf8t1RL.js → cose-bilkent-S5V4N54A-URd77-_-.js} +1 -1
- package/web-shell/assets/{dagre-BM42HDAG-_sgp3WuA.js → dagre-BM42HDAG-C07H4G9-.js} +1 -1
- package/web-shell/assets/{diagram-2AECGRRQ-CROtRy1w.js → diagram-2AECGRRQ-CYbpXrSn.js} +1 -1
- package/web-shell/assets/{diagram-5GNKFQAL-BqGRzwSc.js → diagram-5GNKFQAL-SI8s6fjX.js} +1 -1
- package/web-shell/assets/{diagram-KO2AKTUF-CMrcEtA7.js → diagram-KO2AKTUF-mBkC4RTW.js} +1 -1
- package/web-shell/assets/{diagram-LMA3HP47-BRydGNW5.js → diagram-LMA3HP47-CyBPDzWJ.js} +1 -1
- package/web-shell/assets/{diagram-OG6HWLK6-D0ilgNjV.js → diagram-OG6HWLK6-DwK7kBCu.js} +1 -1
- package/web-shell/assets/{erDiagram-TEJ5UH35-2lnQeJUM.js → erDiagram-TEJ5UH35-Ds1StEUd.js} +1 -1
- package/web-shell/assets/{flowDiagram-I6XJVG4X-C_ZMdQiC.js → flowDiagram-I6XJVG4X-BDC4-jHO.js} +1 -1
- package/web-shell/assets/{ganttDiagram-6RSMTGT7-Dw9V_Ny0.js → ganttDiagram-6RSMTGT7-CV2aHQqf.js} +1 -1
- package/web-shell/assets/{gitGraphDiagram-PVQCEYII-HDXI-F1B.js → gitGraphDiagram-PVQCEYII-B8RcuV_L.js} +1 -1
- package/web-shell/assets/index-B5WZ0qP1.js +717 -0
- package/web-shell/assets/index-CYE1IMd1.css +5 -0
- package/web-shell/assets/{infoDiagram-5YYISTIA-CuB8Ebua.js → infoDiagram-5YYISTIA-DUPoTf50.js} +1 -1
- package/web-shell/assets/{ishikawaDiagram-YF4QCWOH-D_tyhVwh.js → ishikawaDiagram-YF4QCWOH-FDYOx9rf.js} +1 -1
- package/web-shell/assets/{journeyDiagram-JHISSGLW-CgCwJQNU.js → journeyDiagram-JHISSGLW-C5_ohMRp.js} +1 -1
- package/web-shell/assets/{kanban-definition-UN3LZRKU-PZUtcbPx.js → kanban-definition-UN3LZRKU-Bn4UnOMG.js} +1 -1
- package/web-shell/assets/{linear-CZqRdJIU.js → linear-Bl0pKQtY.js} +1 -1
- package/web-shell/assets/{mermaid.core-CPW_4UEF.js → mermaid.core-sevQFoji.js} +72 -72
- package/web-shell/assets/{mindmap-definition-RKZ34NQL-D_Y75ID7.js → mindmap-definition-RKZ34NQL-DSH4JZF9.js} +1 -1
- package/web-shell/assets/{pieDiagram-4H26LBE5-Q4dTyLeF.js → pieDiagram-4H26LBE5-acWvdQTD.js} +1 -1
- package/web-shell/assets/{quadrantDiagram-W4KKPZXB-ckw329Ft.js → quadrantDiagram-W4KKPZXB-DlggX4Sm.js} +1 -1
- package/web-shell/assets/{requirementDiagram-4Y6WPE33-DbVDm4It.js → requirementDiagram-4Y6WPE33--mMqwmV8.js} +1 -1
- package/web-shell/assets/{sankeyDiagram-5OEKKPKP-CHuZ_ecH.js → sankeyDiagram-5OEKKPKP-BtV9tZ77.js} +1 -1
- package/web-shell/assets/{sequenceDiagram-3UESZ5HK-DU2YSpq-.js → sequenceDiagram-3UESZ5HK-iDQzPFlj.js} +1 -1
- package/web-shell/assets/{stateDiagram-AJRCARHV-CA9PEBUo.js → stateDiagram-AJRCARHV-CsCyQLZ3.js} +1 -1
- package/web-shell/assets/stateDiagram-v2-BHNVJYJU-CPapECG9.js +1 -0
- package/web-shell/assets/{timeline-definition-PNZ67QCA-Bhlo4WVc.js → timeline-definition-PNZ67QCA-Dl0rnKvr.js} +1 -1
- package/web-shell/assets/{vennDiagram-CIIHVFJN-6xtPLsyl.js → vennDiagram-CIIHVFJN-qF7nTZzA.js} +1 -1
- package/web-shell/assets/{wardley-L42UT6IY-3eozmXPf.js → wardley-L42UT6IY-fWoK9i7F.js} +1 -1
- package/web-shell/assets/{wardleyDiagram-YWT4CUSO-BOn6hKss.js → wardleyDiagram-YWT4CUSO-pHGfkMnc.js} +1 -1
- package/web-shell/assets/{xychartDiagram-2RQKCTM6-BsP4-I_y.js → xychartDiagram-2RQKCTM6-3Doc4Trz.js} +1 -1
- package/web-shell/index.html +18 -2
- package/chunks/MaxSizedBox-QNF3EIGU.js +0 -63
- package/chunks/agent-headless-RQ5BTX3G.js +0 -56
- package/chunks/bridge-JL2PMCIW.js +0 -66
- package/chunks/chunk-5GOVVXEV.js +0 -73
- package/chunks/chunk-BC3C4F3Q.js +0 -40
- package/chunks/chunk-BW4KCQFD.js +0 -1103
- package/chunks/chunk-RKIZXJMB.js +0 -13
- package/chunks/contextCommand-WYU6OQ2F.js +0 -61
- package/chunks/daemon-status-provider-HH2EC5FI.js +0 -65
- package/chunks/headlessSafetyWarnings-TBE35GJ4.js +0 -55
- package/chunks/initializer-2Y7SLOKV.js +0 -61
- package/chunks/list-ZAFHZ7OS.js +0 -65
- package/chunks/loadedSettingsAdapter-6KJXD6M3.js +0 -60
- package/chunks/nonInteractiveCli-5W4GIZQM.js +0 -96
- package/chunks/ripGrep-IEIFIWP4.js +0 -54
- package/chunks/serve-V6GVZR6U.js +0 -65
- package/chunks/spawnChannel-3VHW5EPY.js +0 -63
- package/chunks/theme-manager-LYLGAERF.js +0 -58
- package/chunks/workspace-providers-status-PIAFVYHA.js +0 -62
- package/chunks/workspace-service-YAIOD4HG.js +0 -71
- package/web-shell/assets/channel-iRo68q4Q.js +0 -1
- package/web-shell/assets/classDiagram-4FO5ZUOK-iHF24a8X.js +0 -1
- package/web-shell/assets/classDiagram-v2-Q7XG4LA2-iHF24a8X.js +0 -1
- package/web-shell/assets/index-CHmzAWys.css +0 -5
- package/web-shell/assets/index-w6GTOHh1.js +0 -713
- package/web-shell/assets/stateDiagram-v2-BHNVJYJU-t1LRI0oY.js +0 -1
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Qwen
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs/promises';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
13
|
+
readLoopTaskFile,
|
|
14
|
+
} from './loop-task-file.js';
|
|
15
|
+
|
|
16
|
+
// Make only open controllable; every other fs call stays real so the temp-dir
|
|
17
|
+
// fixtures keep working. The reader is bounded via fs.open + filehandle.read,
|
|
18
|
+
// so open is the injection point. The default impl calls through to actual.
|
|
19
|
+
vi.mock('node:fs/promises', async (importActual) => {
|
|
20
|
+
const actual = await importActual<typeof import('node:fs/promises')>();
|
|
21
|
+
return { ...actual, open: vi.fn(actual.open) };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Capture the module's debug calls so a test can assert WHY a candidate was
|
|
25
|
+
// skipped (the whitespace-only branch is the load-bearing case). Other tests
|
|
26
|
+
// don't read it; production debug() no-ops without an active session anyway.
|
|
27
|
+
const debugSpy = vi.hoisted(() => vi.fn());
|
|
28
|
+
vi.mock('../../../utils/debugLogger.js', () => ({
|
|
29
|
+
createDebugLogger: () => ({
|
|
30
|
+
isEnabled: () => true,
|
|
31
|
+
debug: debugSpy,
|
|
32
|
+
info: vi.fn(),
|
|
33
|
+
warn: vi.fn(),
|
|
34
|
+
error: vi.fn(),
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
describe('readLoopTaskFile', () => {
|
|
39
|
+
let tempDir: string;
|
|
40
|
+
let projectRoot: string;
|
|
41
|
+
let homeDir: string;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-task-file-'));
|
|
45
|
+
projectRoot = path.join(tempDir, 'project');
|
|
46
|
+
homeDir = path.join(tempDir, 'home');
|
|
47
|
+
await fs.mkdir(projectRoot, { recursive: true });
|
|
48
|
+
await fs.mkdir(homeDir, { recursive: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const writeProject = (content: string) =>
|
|
57
|
+
fs
|
|
58
|
+
.mkdir(path.join(projectRoot, '.qwen'), { recursive: true })
|
|
59
|
+
.then(() =>
|
|
60
|
+
fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), content),
|
|
61
|
+
);
|
|
62
|
+
const writeHome = (content: string) =>
|
|
63
|
+
fs
|
|
64
|
+
.mkdir(path.join(homeDir, '.qwen'), { recursive: true })
|
|
65
|
+
.then(() =>
|
|
66
|
+
fs.writeFile(path.join(homeDir, '.qwen', 'loop.md'), content),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Wrap the next fs.open so each handle.read() length is recorded against a real
|
|
70
|
+
// handle. Lets a test prove the reader stays bounded: a "read the whole file,
|
|
71
|
+
// then slice" regression pulls the full file through these reads and trips the
|
|
72
|
+
// per-read / cumulative cap assertions. Returns the array, filled by reference.
|
|
73
|
+
const recordHandleReadLengths = async (): Promise<number[]> => {
|
|
74
|
+
const lengths: number[] = [];
|
|
75
|
+
const actual =
|
|
76
|
+
await vi.importActual<typeof import('node:fs/promises')>(
|
|
77
|
+
'node:fs/promises',
|
|
78
|
+
);
|
|
79
|
+
vi.mocked(fs.open).mockImplementationOnce(async (p) => {
|
|
80
|
+
const handle = await actual.open(
|
|
81
|
+
p as Parameters<typeof actual.open>[0],
|
|
82
|
+
'r',
|
|
83
|
+
);
|
|
84
|
+
const realRead = handle.read.bind(handle);
|
|
85
|
+
handle.read = ((...readArgs: Parameters<typeof handle.read>) => {
|
|
86
|
+
// Impl calls read(buffer, offset, length, position); record length.
|
|
87
|
+
lengths.push((readArgs as unknown[])[2] as number);
|
|
88
|
+
return realRead(...(readArgs as Parameters<typeof handle.read>));
|
|
89
|
+
}) as typeof handle.read;
|
|
90
|
+
return handle;
|
|
91
|
+
});
|
|
92
|
+
return lengths;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
it('reads the project loop task file first', async () => {
|
|
96
|
+
await writeProject('project tasks');
|
|
97
|
+
await writeHome('user tasks');
|
|
98
|
+
|
|
99
|
+
const result = await readLoopTaskFile({
|
|
100
|
+
projectRoot,
|
|
101
|
+
homeDir,
|
|
102
|
+
allowProjectFile: true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual({
|
|
106
|
+
status: 'found',
|
|
107
|
+
path: path.join(projectRoot, '.qwen', 'loop.md'),
|
|
108
|
+
source: 'project',
|
|
109
|
+
content: 'project tasks',
|
|
110
|
+
truncated: false,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('falls back to the user loop task file', async () => {
|
|
115
|
+
await writeHome('user tasks');
|
|
116
|
+
|
|
117
|
+
const result = await readLoopTaskFile({
|
|
118
|
+
projectRoot,
|
|
119
|
+
homeDir,
|
|
120
|
+
allowProjectFile: true,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result).toEqual({
|
|
124
|
+
status: 'found',
|
|
125
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
126
|
+
source: 'home',
|
|
127
|
+
content: 'user tasks',
|
|
128
|
+
truncated: false,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not follow symlinked project loop task files', async () => {
|
|
133
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
134
|
+
const outside = path.join(tempDir, 'secret.txt');
|
|
135
|
+
await fs.writeFile(outside, 'secret tasks');
|
|
136
|
+
await fs.symlink(outside, path.join(projectRoot, '.qwen', 'loop.md'));
|
|
137
|
+
await writeHome('user tasks');
|
|
138
|
+
|
|
139
|
+
const result = await readLoopTaskFile({
|
|
140
|
+
projectRoot,
|
|
141
|
+
homeDir,
|
|
142
|
+
allowProjectFile: true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
status: 'found',
|
|
147
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
148
|
+
source: 'home',
|
|
149
|
+
content: 'user tasks',
|
|
150
|
+
truncated: false,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('refuses a project loop.md whose .qwen ancestor symlinks outside the workspace', async () => {
|
|
155
|
+
// `.qwen -> <outside>` makes a final-component lstat pass while the file
|
|
156
|
+
// resolves outside the project; realpath must catch the ancestor symlink.
|
|
157
|
+
const outside = path.join(tempDir, 'outside');
|
|
158
|
+
await fs.mkdir(outside, { recursive: true });
|
|
159
|
+
await fs.writeFile(path.join(outside, 'loop.md'), 'escaped tasks');
|
|
160
|
+
await fs.symlink(outside, path.join(projectRoot, '.qwen'));
|
|
161
|
+
await writeHome('user tasks');
|
|
162
|
+
|
|
163
|
+
const result = await readLoopTaskFile({
|
|
164
|
+
projectRoot,
|
|
165
|
+
homeDir,
|
|
166
|
+
allowProjectFile: true,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(result).toEqual({
|
|
170
|
+
status: 'found',
|
|
171
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
172
|
+
source: 'home',
|
|
173
|
+
content: 'user tasks',
|
|
174
|
+
truncated: false,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('refuses a project loop.md resolving to a SIBLING dir that shares a name prefix', async () => {
|
|
179
|
+
// isWithin appends path.sep before startsWith, so root `<ws>/foo` must NOT
|
|
180
|
+
// accept a candidate under the sibling `<ws>/foobar`. Make projectRoot
|
|
181
|
+
// `<ws>/foo` and symlink its `.qwen` to `<ws>/foobar/.qwen`; realpath then
|
|
182
|
+
// resolves loop.md into `foobar`, whose canonical path bare-startsWith
|
|
183
|
+
// `<ws>/foo` yet is NOT a descendant. A regression to a bare
|
|
184
|
+
// `real.startsWith(root)` (no separator) would wave this cross-workspace
|
|
185
|
+
// read through — this test fails the moment that separator is dropped.
|
|
186
|
+
const fooRoot = path.join(tempDir, 'foo');
|
|
187
|
+
const siblingQwen = path.join(tempDir, 'foobar', '.qwen');
|
|
188
|
+
await fs.mkdir(fooRoot, { recursive: true });
|
|
189
|
+
await fs.mkdir(siblingQwen, { recursive: true });
|
|
190
|
+
await fs.writeFile(path.join(siblingQwen, 'loop.md'), 'sibling tasks');
|
|
191
|
+
await fs.symlink(siblingQwen, path.join(fooRoot, '.qwen'));
|
|
192
|
+
await writeHome('user tasks');
|
|
193
|
+
|
|
194
|
+
const result = await readLoopTaskFile({
|
|
195
|
+
projectRoot: fooRoot,
|
|
196
|
+
homeDir,
|
|
197
|
+
allowProjectFile: true,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Refused → falls through to home; the sibling content is never returned.
|
|
201
|
+
expect(result).toEqual({
|
|
202
|
+
status: 'found',
|
|
203
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
204
|
+
source: 'home',
|
|
205
|
+
content: 'user tasks',
|
|
206
|
+
truncated: false,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not read a project loop.md symlinked to an in-workspace file (exfiltration guard)', async () => {
|
|
211
|
+
// The dangerous case confinement alone misses: a repo-committed
|
|
212
|
+
// `.qwen/loop.md -> ../.env` resolves INSIDE the workspace, so the realpath
|
|
213
|
+
// confinement passes — yet it must NOT be read. A symlinked project loop.md
|
|
214
|
+
// is refused outright; only a real regular file at the literal path is read.
|
|
215
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
216
|
+
const secret = path.join(projectRoot, '.env');
|
|
217
|
+
await fs.writeFile(secret, 'SECRET=should-not-be-read');
|
|
218
|
+
await fs.symlink(
|
|
219
|
+
path.join('..', '.env'),
|
|
220
|
+
path.join(projectRoot, '.qwen', 'loop.md'),
|
|
221
|
+
);
|
|
222
|
+
await writeHome('user tasks');
|
|
223
|
+
|
|
224
|
+
const result = await readLoopTaskFile({
|
|
225
|
+
projectRoot,
|
|
226
|
+
homeDir,
|
|
227
|
+
allowProjectFile: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(result).toEqual({
|
|
231
|
+
status: 'found',
|
|
232
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
233
|
+
source: 'home',
|
|
234
|
+
content: 'user tasks',
|
|
235
|
+
truncated: false,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('does not read a HARD-LINKED project loop.md (exfiltration guard)', async () => {
|
|
240
|
+
// The case the symlink guard misses: `ln <secret> .qwen/loop.md` makes
|
|
241
|
+
// loop.md an ordinary regular file (lstat sees no symlink, isFile() true)
|
|
242
|
+
// that SHARES the secret's inode (nlink === 2). It resolves to itself inside
|
|
243
|
+
// the workspace, so confinement passes too — only the `nlink > 1` guard
|
|
244
|
+
// refuses it. Mutation check: drop that guard and the secret is returned as
|
|
245
|
+
// the project source instead of falling through to home.
|
|
246
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
247
|
+
const secret = path.join(tempDir, 'secret-env');
|
|
248
|
+
await fs.writeFile(secret, 'SECRET=should-not-be-read');
|
|
249
|
+
const projectLoop = path.join(projectRoot, '.qwen', 'loop.md');
|
|
250
|
+
await fs.link(secret, projectLoop); // hard link → nlink 2, same inode
|
|
251
|
+
// Precondition: the link really is a hard link to the secret, not a symlink.
|
|
252
|
+
const linkStat = await fs.lstat(projectLoop);
|
|
253
|
+
expect(linkStat.isSymbolicLink()).toBe(false);
|
|
254
|
+
expect(linkStat.nlink).toBeGreaterThan(1);
|
|
255
|
+
await writeHome('user tasks');
|
|
256
|
+
|
|
257
|
+
const result = await readLoopTaskFile({
|
|
258
|
+
projectRoot,
|
|
259
|
+
homeDir,
|
|
260
|
+
allowProjectFile: true,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// The hard-linked project file is skipped → home is read; the secret content
|
|
264
|
+
// is never returned from any candidate.
|
|
265
|
+
expect(result).toEqual({
|
|
266
|
+
status: 'found',
|
|
267
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
268
|
+
source: 'home',
|
|
269
|
+
content: 'user tasks',
|
|
270
|
+
truncated: false,
|
|
271
|
+
});
|
|
272
|
+
if (result.status !== 'found') {
|
|
273
|
+
throw new Error('expected loop.md to be found');
|
|
274
|
+
}
|
|
275
|
+
expect(result.content).not.toContain('SECRET');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('does not read a HARD-LINKED home loop.md (exfiltration guard)', async () => {
|
|
279
|
+
// Same hard-link vector on the home candidate: `ln <secret> ~/.qwen/loop.md`.
|
|
280
|
+
// fs.stat follows to a regular file with nlink 2, so isFile()/confinement
|
|
281
|
+
// pass — only the `nlink > 1` guard refuses it. No project file here, so the
|
|
282
|
+
// result is `missing`; the secret content is never returned.
|
|
283
|
+
await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
|
|
284
|
+
const secret = path.join(tempDir, 'home-secret');
|
|
285
|
+
await fs.writeFile(secret, 'SECRET=should-not-be-read');
|
|
286
|
+
const homeLoop = path.join(homeDir, '.qwen', 'loop.md');
|
|
287
|
+
await fs.link(secret, homeLoop);
|
|
288
|
+
const linkStat = await fs.lstat(homeLoop);
|
|
289
|
+
expect(linkStat.nlink).toBeGreaterThan(1);
|
|
290
|
+
|
|
291
|
+
const result = await readLoopTaskFile({
|
|
292
|
+
projectRoot,
|
|
293
|
+
homeDir,
|
|
294
|
+
allowProjectFile: true,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual({
|
|
298
|
+
status: 'missing',
|
|
299
|
+
checkedPaths: [
|
|
300
|
+
path.join(projectRoot, '.qwen', 'loop.md'),
|
|
301
|
+
path.join(homeDir, '.qwen', 'loop.md'),
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('does not falsely refuse a project loop.md when the workspace root is a filesystem root', async () => {
|
|
307
|
+
// When the CLI runs from a filesystem root, realRoot is `/` (or `C:\`), so the
|
|
308
|
+
// old `realRoot + path.sep` prefix became `//` (`C:\\`) — which no descendant
|
|
309
|
+
// startsWith, wrongly refusing every project loop.md. Drive realRoot to the
|
|
310
|
+
// filesystem root via a realpath mock; the real loop.md still resolves to a
|
|
311
|
+
// normal absolute path (a descendant of the root) and must be read, not refused.
|
|
312
|
+
await writeProject('- root-level tasks');
|
|
313
|
+
const root = path.parse(projectRoot).root; // '/' on POSIX, e.g. 'C:\\' on Windows
|
|
314
|
+
const actual =
|
|
315
|
+
await vi.importActual<typeof import('node:fs/promises')>(
|
|
316
|
+
'node:fs/promises',
|
|
317
|
+
);
|
|
318
|
+
vi.spyOn(fs, 'realpath').mockImplementation((p) =>
|
|
319
|
+
String(p) === projectRoot
|
|
320
|
+
? Promise.resolve(root)
|
|
321
|
+
: actual.realpath(p as string),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const result = await readLoopTaskFile({
|
|
325
|
+
projectRoot,
|
|
326
|
+
homeDir,
|
|
327
|
+
allowProjectFile: true,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result).toMatchObject({
|
|
331
|
+
status: 'found',
|
|
332
|
+
source: 'project',
|
|
333
|
+
content: '- root-level tasks',
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('skips a FIFO/non-regular project loop.md before opening it (does not hang)', async () => {
|
|
338
|
+
// A FIFO at the project path must be rejected BEFORE the blocking fs.open:
|
|
339
|
+
// open() on a FIFO blocks until a writer appears, wedging the tick forever.
|
|
340
|
+
// Drive a FIFO-typed node via a mocked lstat (a real mkfifo is platform-
|
|
341
|
+
// fragile); the load-bearing proof is that fs.open is never called on the
|
|
342
|
+
// project path, so no blocking open() can happen.
|
|
343
|
+
await writeHome('user tasks');
|
|
344
|
+
const projectLoop = path.join(projectRoot, '.qwen', 'loop.md');
|
|
345
|
+
const actual =
|
|
346
|
+
await vi.importActual<typeof import('node:fs/promises')>(
|
|
347
|
+
'node:fs/promises',
|
|
348
|
+
);
|
|
349
|
+
const fifoStat = {
|
|
350
|
+
isSymbolicLink: () => false,
|
|
351
|
+
isFile: () => false,
|
|
352
|
+
isFIFO: () => true,
|
|
353
|
+
} as unknown as Awaited<ReturnType<typeof fs.lstat>>;
|
|
354
|
+
vi.spyOn(fs, 'lstat').mockImplementation(async (p) =>
|
|
355
|
+
String(p) === projectLoop ? fifoStat : actual.lstat(p as string),
|
|
356
|
+
);
|
|
357
|
+
const openSpy = vi.mocked(fs.open);
|
|
358
|
+
openSpy.mockClear();
|
|
359
|
+
|
|
360
|
+
const result = await readLoopTaskFile({
|
|
361
|
+
projectRoot,
|
|
362
|
+
homeDir,
|
|
363
|
+
allowProjectFile: true,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(result).toMatchObject({ source: 'home', content: 'user tasks' });
|
|
367
|
+
// The project FIFO path is never opened — proof there is no blocking open().
|
|
368
|
+
for (const call of openSpy.mock.calls) {
|
|
369
|
+
expect(String(call[0])).not.toBe(projectLoop);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('reads a home loop.md that is a symlink to a real regular file inside $HOME', async () => {
|
|
374
|
+
// The user's own dotfile may legitimately be a symlink (e.g. into a synced
|
|
375
|
+
// dotfiles repo). Follow it, as long as the target is a real regular file
|
|
376
|
+
// that resolves WITHIN $HOME (the confinement added for escapes).
|
|
377
|
+
await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
|
|
378
|
+
const target = path.join(homeDir, 'dotfiles', 'loop.md');
|
|
379
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
380
|
+
await fs.writeFile(target, 'symlinked user tasks');
|
|
381
|
+
await fs.symlink(target, path.join(homeDir, '.qwen', 'loop.md'));
|
|
382
|
+
|
|
383
|
+
const result = await readLoopTaskFile({
|
|
384
|
+
projectRoot,
|
|
385
|
+
homeDir,
|
|
386
|
+
allowProjectFile: true,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(result).toEqual({
|
|
390
|
+
status: 'found',
|
|
391
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
392
|
+
source: 'home',
|
|
393
|
+
content: 'symlinked user tasks',
|
|
394
|
+
truncated: false,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('skips a home loop.md whose symlink target escapes $HOME', async () => {
|
|
399
|
+
// Home symlinks are allowed (dotfiles repos), but only if they resolve
|
|
400
|
+
// WITHIN $HOME. A `~/.qwen/loop.md -> /etc/passwd`-style escape (here a
|
|
401
|
+
// sibling outside homeDir) must be skipped, not read and fed to the model.
|
|
402
|
+
await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
|
|
403
|
+
const outside = path.join(tempDir, 'outside-secret');
|
|
404
|
+
await fs.writeFile(outside, 'SECRET=should-not-be-read');
|
|
405
|
+
await fs.symlink(outside, path.join(homeDir, '.qwen', 'loop.md'));
|
|
406
|
+
|
|
407
|
+
const result = await readLoopTaskFile({
|
|
408
|
+
projectRoot,
|
|
409
|
+
homeDir,
|
|
410
|
+
allowProjectFile: true,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(result).toEqual({
|
|
414
|
+
status: 'missing',
|
|
415
|
+
checkedPaths: [
|
|
416
|
+
path.join(projectRoot, '.qwen', 'loop.md'),
|
|
417
|
+
path.join(homeDir, '.qwen', 'loop.md'),
|
|
418
|
+
],
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('reads the home loop.md from a relocated homeQwenDir (QWEN_HOME)', async () => {
|
|
423
|
+
// The home candidate lives in the QWEN_HOME-aware global dir, not always
|
|
424
|
+
// <homeDir>/.qwen — write loop.md into a relocated global dir and confirm it
|
|
425
|
+
// is read as the `home` source from <homeQwenDir>/loop.md.
|
|
426
|
+
const relocated = path.join(tempDir, 'relocated-qwen');
|
|
427
|
+
await fs.mkdir(relocated, { recursive: true });
|
|
428
|
+
await fs.writeFile(path.join(relocated, 'loop.md'), 'relocated user tasks');
|
|
429
|
+
|
|
430
|
+
const result = await readLoopTaskFile({
|
|
431
|
+
projectRoot,
|
|
432
|
+
// Caller passes the global dir as both candidate dir and confinement root
|
|
433
|
+
// when QWEN_HOME is set (see Session.#getLoopTickResolver).
|
|
434
|
+
homeDir: relocated,
|
|
435
|
+
homeQwenDir: relocated,
|
|
436
|
+
allowProjectFile: true,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
expect(result).toEqual({
|
|
440
|
+
status: 'found',
|
|
441
|
+
path: path.join(relocated, 'loop.md'),
|
|
442
|
+
source: 'home',
|
|
443
|
+
content: 'relocated user tasks',
|
|
444
|
+
truncated: false,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('keeps confinement for a relocated homeQwenDir (escaping symlink refused)', async () => {
|
|
449
|
+
// Relocation must not loosen the earlier confinement: a symlink whose target
|
|
450
|
+
// escapes the home confinement root is still refused, not read.
|
|
451
|
+
const relocated = path.join(tempDir, 'relocated-qwen');
|
|
452
|
+
await fs.mkdir(relocated, { recursive: true });
|
|
453
|
+
const outside = path.join(tempDir, 'outside-secret');
|
|
454
|
+
await fs.writeFile(outside, 'SECRET=should-not-be-read');
|
|
455
|
+
await fs.symlink(outside, path.join(relocated, 'loop.md'));
|
|
456
|
+
|
|
457
|
+
const result = await readLoopTaskFile({
|
|
458
|
+
projectRoot,
|
|
459
|
+
homeDir: relocated,
|
|
460
|
+
homeQwenDir: relocated,
|
|
461
|
+
allowProjectFile: true,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(result).toEqual({
|
|
465
|
+
status: 'missing',
|
|
466
|
+
checkedPaths: [
|
|
467
|
+
path.join(projectRoot, '.qwen', 'loop.md'),
|
|
468
|
+
path.join(relocated, 'loop.md'),
|
|
469
|
+
],
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('skips a home loop.md that is a self-referential symlink (ELOOP) instead of throwing', async () => {
|
|
474
|
+
// fs.stat follows the home symlink; a self-referential link raises ELOOP.
|
|
475
|
+
// That must be treated as a skippable candidate (→ missing), not crash the
|
|
476
|
+
// tick — without ELOOP in the skip whitelist this rethrows and aborts.
|
|
477
|
+
await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
|
|
478
|
+
const loop = path.join(homeDir, '.qwen', 'loop.md');
|
|
479
|
+
await fs.symlink(loop, loop); // points at itself → ELOOP on stat
|
|
480
|
+
|
|
481
|
+
const result = await readLoopTaskFile({
|
|
482
|
+
projectRoot,
|
|
483
|
+
homeDir,
|
|
484
|
+
allowProjectFile: true,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
expect(result).toEqual({
|
|
488
|
+
status: 'missing',
|
|
489
|
+
checkedPaths: [path.join(projectRoot, '.qwen', 'loop.md'), loop],
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('skips a home loop.md that resolves to a non-regular file (directory/FIFO)', async () => {
|
|
494
|
+
// The home candidate follows symlinks via fs.stat; if the (possibly
|
|
495
|
+
// symlinked) target is a directory/FIFO it must be skipped — the project
|
|
496
|
+
// path proves this via lstat, but the home path's fs.stat needs its own
|
|
497
|
+
// coverage so a blocking open / directory read never happens.
|
|
498
|
+
const homeLoop = path.join(homeDir, '.qwen', 'loop.md');
|
|
499
|
+
await fs.mkdir(path.dirname(homeLoop), { recursive: true });
|
|
500
|
+
await fs.writeFile(homeLoop, '- user tasks'); // real file so realpath resolves
|
|
501
|
+
const actual =
|
|
502
|
+
await vi.importActual<typeof import('node:fs/promises')>(
|
|
503
|
+
'node:fs/promises',
|
|
504
|
+
);
|
|
505
|
+
const dirStat = {
|
|
506
|
+
isFile: () => false,
|
|
507
|
+
isDirectory: () => true,
|
|
508
|
+
} as unknown as Awaited<ReturnType<typeof fs.stat>>;
|
|
509
|
+
vi.spyOn(fs, 'stat').mockImplementation(async (p) =>
|
|
510
|
+
String(p) === homeLoop ? dirStat : actual.stat(p as string),
|
|
511
|
+
);
|
|
512
|
+
const openSpy = vi.mocked(fs.open);
|
|
513
|
+
openSpy.mockClear();
|
|
514
|
+
|
|
515
|
+
const result = await readLoopTaskFile({
|
|
516
|
+
projectRoot,
|
|
517
|
+
homeDir,
|
|
518
|
+
allowProjectFile: true,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(result.status).toBe('missing');
|
|
522
|
+
// The non-regular guard fired before any open() on the home path.
|
|
523
|
+
for (const call of openSpy.mock.calls) {
|
|
524
|
+
expect(String(call[0])).not.toBe(homeLoop);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('defaults to fail-secure: omitting allowProjectFile skips the project file', async () => {
|
|
529
|
+
// This function is re-exported from the core barrel; an external caller that
|
|
530
|
+
// forgets the option must NOT read the repo-controlled project loop.md from
|
|
531
|
+
// an untrusted workspace. The default is false — callers opt IN to trust.
|
|
532
|
+
await writeProject('repo-controlled tasks');
|
|
533
|
+
await writeHome('user tasks');
|
|
534
|
+
|
|
535
|
+
const result = await readLoopTaskFile({ projectRoot, homeDir });
|
|
536
|
+
|
|
537
|
+
expect(result).toEqual({
|
|
538
|
+
status: 'found',
|
|
539
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
540
|
+
source: 'home',
|
|
541
|
+
content: 'user tasks',
|
|
542
|
+
truncated: false,
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('skips the project candidate entirely when allowProjectFile is false', async () => {
|
|
547
|
+
// Untrusted folder: the repo-controlled project loop.md is not read even
|
|
548
|
+
// when present; the user-owned home loop.md still is.
|
|
549
|
+
await writeProject('repo-controlled tasks');
|
|
550
|
+
await writeHome('user tasks');
|
|
551
|
+
|
|
552
|
+
const result = await readLoopTaskFile({
|
|
553
|
+
projectRoot,
|
|
554
|
+
homeDir,
|
|
555
|
+
allowProjectFile: false,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(result).toEqual({
|
|
559
|
+
status: 'found',
|
|
560
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
561
|
+
source: 'home',
|
|
562
|
+
content: 'user tasks',
|
|
563
|
+
truncated: false,
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('reports only the home path as missing when allowProjectFile is false', async () => {
|
|
568
|
+
const result = await readLoopTaskFile({
|
|
569
|
+
projectRoot,
|
|
570
|
+
homeDir,
|
|
571
|
+
allowProjectFile: false,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
expect(result).toEqual({
|
|
575
|
+
status: 'missing',
|
|
576
|
+
checkedPaths: [path.join(homeDir, '.qwen', 'loop.md')],
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('skips a non-directory component at .qwen (ENOTDIR) and falls through', async () => {
|
|
581
|
+
// A regular file where the `.qwen` dir should be → reading .qwen/loop.md
|
|
582
|
+
// raises ENOTDIR; skip to home rather than throwing.
|
|
583
|
+
await fs.writeFile(path.join(projectRoot, '.qwen'), 'not a dir');
|
|
584
|
+
await writeHome('user tasks');
|
|
585
|
+
|
|
586
|
+
const result = await readLoopTaskFile({
|
|
587
|
+
projectRoot,
|
|
588
|
+
homeDir,
|
|
589
|
+
allowProjectFile: true,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(result).toEqual({
|
|
593
|
+
status: 'found',
|
|
594
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
595
|
+
source: 'home',
|
|
596
|
+
content: 'user tasks',
|
|
597
|
+
truncated: false,
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('skips a directory at the loop.md path and falls through', async () => {
|
|
602
|
+
// A directory at the project path yields EISDIR on read — skip it, not throw.
|
|
603
|
+
await fs.mkdir(path.join(projectRoot, '.qwen', 'loop.md'), {
|
|
604
|
+
recursive: true,
|
|
605
|
+
});
|
|
606
|
+
await writeHome('user tasks');
|
|
607
|
+
|
|
608
|
+
const result = await readLoopTaskFile({
|
|
609
|
+
projectRoot,
|
|
610
|
+
homeDir,
|
|
611
|
+
allowProjectFile: true,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
expect(result).toEqual({
|
|
615
|
+
status: 'found',
|
|
616
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
617
|
+
source: 'home',
|
|
618
|
+
content: 'user tasks',
|
|
619
|
+
truncated: false,
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('rethrows non-whitelisted fs errors (e.g. EACCES)', async () => {
|
|
624
|
+
// Only ENOENT/EISDIR/ENOTDIR fall through to the next candidate; a real
|
|
625
|
+
// error such as a permission denial must surface, not be swallowed.
|
|
626
|
+
await writeProject('project tasks');
|
|
627
|
+
const eacces = Object.assign(new Error('EACCES: permission denied'), {
|
|
628
|
+
code: 'EACCES',
|
|
629
|
+
});
|
|
630
|
+
vi.mocked(fs.open).mockRejectedValueOnce(eacces);
|
|
631
|
+
|
|
632
|
+
await expect(
|
|
633
|
+
readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
|
|
634
|
+
).rejects.toThrow(/EACCES/);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('evicts the cached project-root realpath after a transient failure and retries on the next tick', async () => {
|
|
638
|
+
// The project-root realpath is cached per process. A TRANSIENT failure
|
|
639
|
+
// (EACCES/ENOENT) must NOT be pinned: the entry is evicted on rejection so
|
|
640
|
+
// the next tick re-resolves instead of replaying a permanently-cached
|
|
641
|
+
// rejection. Drop that eviction and one transient error would break loop.md
|
|
642
|
+
// resolution for this root forever. Drive it purely via the realpath mock.
|
|
643
|
+
await writeProject('project tasks');
|
|
644
|
+
|
|
645
|
+
const eacces = Object.assign(new Error('EACCES: permission denied'), {
|
|
646
|
+
code: 'EACCES',
|
|
647
|
+
});
|
|
648
|
+
const actual =
|
|
649
|
+
await vi.importActual<typeof import('node:fs/promises')>(
|
|
650
|
+
'node:fs/promises',
|
|
651
|
+
);
|
|
652
|
+
const realpathSpy = vi.spyOn(fs, 'realpath');
|
|
653
|
+
// Fail the first project-root resolution, then resolve normally.
|
|
654
|
+
realpathSpy.mockRejectedValueOnce(eacces);
|
|
655
|
+
realpathSpy.mockImplementation((p) => actual.realpath(p as string));
|
|
656
|
+
|
|
657
|
+
// First tick: the transient error surfaces (current per-tick semantics).
|
|
658
|
+
await expect(
|
|
659
|
+
readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
|
|
660
|
+
).rejects.toThrow(/EACCES/);
|
|
661
|
+
|
|
662
|
+
// Second tick: the poisoned entry was evicted, so realpath is retried and
|
|
663
|
+
// the project loop.md resolves — proving the rejection was not cached.
|
|
664
|
+
const result = await readLoopTaskFile({
|
|
665
|
+
projectRoot,
|
|
666
|
+
homeDir,
|
|
667
|
+
allowProjectFile: true,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
expect(result).toEqual({
|
|
671
|
+
status: 'found',
|
|
672
|
+
path: path.join(projectRoot, '.qwen', 'loop.md'),
|
|
673
|
+
source: 'project',
|
|
674
|
+
content: 'project tasks',
|
|
675
|
+
truncated: false,
|
|
676
|
+
});
|
|
677
|
+
// The root was re-resolved on the retry (call #2), not served from a
|
|
678
|
+
// poisoned cache entry; #3 is the loop.md realpath on the successful tick.
|
|
679
|
+
expect(realpathSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('skips an empty or whitespace-only file and falls through', async () => {
|
|
683
|
+
await writeProject(' \n\t \n');
|
|
684
|
+
await writeHome('user tasks');
|
|
685
|
+
|
|
686
|
+
const result = await readLoopTaskFile({
|
|
687
|
+
projectRoot,
|
|
688
|
+
homeDir,
|
|
689
|
+
allowProjectFile: true,
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
expect(result).toEqual({
|
|
693
|
+
status: 'found',
|
|
694
|
+
path: path.join(homeDir, '.qwen', 'loop.md'),
|
|
695
|
+
source: 'home',
|
|
696
|
+
content: 'user tasks',
|
|
697
|
+
truncated: false,
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('logs a debug line when it skips a whitespace-only loop.md', async () => {
|
|
702
|
+
// The whitespace-only skip was the ONLY skip branch with no debug log, so a
|
|
703
|
+
// present-but-empty file was indistinguishable from an absent one in logs.
|
|
704
|
+
// Assert the labelled skip line fires for the project candidate before the
|
|
705
|
+
// fall-through to home.
|
|
706
|
+
await writeProject(' \n\t \n');
|
|
707
|
+
await writeHome('user tasks');
|
|
708
|
+
debugSpy.mockClear();
|
|
709
|
+
|
|
710
|
+
const result = await readLoopTaskFile({
|
|
711
|
+
projectRoot,
|
|
712
|
+
homeDir,
|
|
713
|
+
allowProjectFile: true,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
expect(result).toMatchObject({ source: 'home', content: 'user tasks' });
|
|
717
|
+
expect(debugSpy).toHaveBeenCalledWith('skipping whitespace-only loop.md', {
|
|
718
|
+
source: 'project',
|
|
719
|
+
filePath: path.join(projectRoot, '.qwen', 'loop.md'),
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('returns missing when every candidate is empty', async () => {
|
|
724
|
+
await writeProject('');
|
|
725
|
+
await writeHome('\n \n');
|
|
726
|
+
|
|
727
|
+
const result = await readLoopTaskFile({
|
|
728
|
+
projectRoot,
|
|
729
|
+
homeDir,
|
|
730
|
+
allowProjectFile: true,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
expect(result).toEqual({
|
|
734
|
+
status: 'missing',
|
|
735
|
+
checkedPaths: [
|
|
736
|
+
path.join(projectRoot, '.qwen', 'loop.md'),
|
|
737
|
+
path.join(homeDir, '.qwen', 'loop.md'),
|
|
738
|
+
],
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('returns a missing result when no task file exists', async () => {
|
|
743
|
+
await expect(
|
|
744
|
+
readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
|
|
745
|
+
).resolves.toEqual({
|
|
746
|
+
status: 'missing',
|
|
747
|
+
checkedPaths: [
|
|
748
|
+
path.join(projectRoot, '.qwen', 'loop.md'),
|
|
749
|
+
path.join(homeDir, '.qwen', 'loop.md'),
|
|
750
|
+
],
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('byte-caps task files above the cap and flags them truncated', async () => {
|
|
755
|
+
await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES + 5));
|
|
756
|
+
|
|
757
|
+
const result = await readLoopTaskFile({
|
|
758
|
+
projectRoot,
|
|
759
|
+
homeDir,
|
|
760
|
+
allowProjectFile: true,
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
expect(result.status).toBe('found');
|
|
764
|
+
if (result.status !== 'found') {
|
|
765
|
+
throw new Error('expected loop.md to be found');
|
|
766
|
+
}
|
|
767
|
+
expect(Buffer.byteLength(result.content, 'utf8')).toBe(
|
|
768
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
769
|
+
);
|
|
770
|
+
expect(result.truncated).toBe(true);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('bounds the read for a very large file (never reads past the cap)', async () => {
|
|
774
|
+
// A multi-MB file must not be fully read/decoded every tick. Observe the
|
|
775
|
+
// actual handle.read() calls: neither any single read nor their sum may
|
|
776
|
+
// exceed the cap budget — so a "read the whole file, then slice" regression
|
|
777
|
+
// (which would pull all 2 MB through these reads) fails this test.
|
|
778
|
+
await writeProject('x'.repeat(2_000_000));
|
|
779
|
+
const cap = LOOP_TASK_FILE_MAX_BYTES + 1;
|
|
780
|
+
const openSpy = vi.mocked(fs.open);
|
|
781
|
+
openSpy.mockClear();
|
|
782
|
+
const readLengths = await recordHandleReadLengths();
|
|
783
|
+
|
|
784
|
+
const result = await readLoopTaskFile({
|
|
785
|
+
projectRoot,
|
|
786
|
+
homeDir,
|
|
787
|
+
allowProjectFile: true,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
expect(result.status).toBe('found');
|
|
791
|
+
if (result.status !== 'found') {
|
|
792
|
+
throw new Error('expected loop.md to be found');
|
|
793
|
+
}
|
|
794
|
+
expect(result.truncated).toBe(true);
|
|
795
|
+
expect(Buffer.byteLength(result.content, 'utf8')).toBe(
|
|
796
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
797
|
+
);
|
|
798
|
+
// A single bounded fs.open handle, not fs.readFile of the whole.
|
|
799
|
+
expect(openSpy).toHaveBeenCalledTimes(1);
|
|
800
|
+
// Load-bearing: every read, and the total bytes requested, stay within cap.
|
|
801
|
+
expect(readLengths.length).toBeGreaterThan(0);
|
|
802
|
+
for (const length of readLengths) {
|
|
803
|
+
expect(length).toBeLessThanOrEqual(cap);
|
|
804
|
+
}
|
|
805
|
+
expect(readLengths.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(cap);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('reads a short file fully via bounded reads that never exceed the cap', async () => {
|
|
809
|
+
// The EOF path: a sub-cap file is returned whole (not truncated), and the
|
|
810
|
+
// bounded reader still never requests past the cap on any read.
|
|
811
|
+
const body = 'short tasks\n';
|
|
812
|
+
await writeProject(body);
|
|
813
|
+
const cap = LOOP_TASK_FILE_MAX_BYTES + 1;
|
|
814
|
+
const readLengths = await recordHandleReadLengths();
|
|
815
|
+
|
|
816
|
+
const result = await readLoopTaskFile({
|
|
817
|
+
projectRoot,
|
|
818
|
+
homeDir,
|
|
819
|
+
allowProjectFile: true,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
expect(result).toMatchObject({
|
|
823
|
+
status: 'found',
|
|
824
|
+
content: body,
|
|
825
|
+
truncated: false,
|
|
826
|
+
});
|
|
827
|
+
expect(readLengths.length).toBeGreaterThan(0);
|
|
828
|
+
for (const length of readLengths) {
|
|
829
|
+
expect(length).toBeLessThanOrEqual(cap);
|
|
830
|
+
}
|
|
831
|
+
// Load-bearing: the buffer is sized to the file (+1 for truncation
|
|
832
|
+
// detection), NOT the 25 KB cap — so a tiny loop.md doesn't zero-fill 25 KB
|
|
833
|
+
// every tick. The first read requests exactly that bounded length.
|
|
834
|
+
expect(readLengths[0]).toBe(body.length + 1);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('does not truncate task files at exactly the byte cap', async () => {
|
|
838
|
+
await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES));
|
|
839
|
+
|
|
840
|
+
const result = await readLoopTaskFile({
|
|
841
|
+
projectRoot,
|
|
842
|
+
homeDir,
|
|
843
|
+
allowProjectFile: true,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
expect(result.status).toBe('found');
|
|
847
|
+
if (result.status !== 'found') {
|
|
848
|
+
throw new Error('expected loop.md to be found');
|
|
849
|
+
}
|
|
850
|
+
expect(Buffer.byteLength(result.content, 'utf8')).toBe(
|
|
851
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
852
|
+
);
|
|
853
|
+
expect(result.truncated).toBe(false);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('truncates on a UTF-8 boundary without exceeding the cap or inserting a replacement char', async () => {
|
|
857
|
+
// 3-byte chars make the raw byte cap land mid-character.
|
|
858
|
+
await writeProject('一'.repeat(LOOP_TASK_FILE_MAX_BYTES));
|
|
859
|
+
|
|
860
|
+
const result = await readLoopTaskFile({
|
|
861
|
+
projectRoot,
|
|
862
|
+
homeDir,
|
|
863
|
+
allowProjectFile: true,
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
expect(result.status).toBe('found');
|
|
867
|
+
if (result.status !== 'found') {
|
|
868
|
+
throw new Error('expected loop.md to be found');
|
|
869
|
+
}
|
|
870
|
+
expect(result.truncated).toBe(true);
|
|
871
|
+
expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(
|
|
872
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
873
|
+
);
|
|
874
|
+
expect(result.content).not.toContain('�');
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('drops an INCOMPLETE trailing multi-byte sequence at the cap (no orphan lead / U+FFFD)', async () => {
|
|
878
|
+
// A buffer cut mid-4-byte-sequence whose final byte is NOT a continuation
|
|
879
|
+
// (the sequence is incomplete) defeats the continuation-only back-off: it
|
|
880
|
+
// would keep `f0 9f a6` and decode a trailing U+FFFD. Sized so the orphan
|
|
881
|
+
// lands exactly on the cap, so the byte-length re-clamp can't mask it — only
|
|
882
|
+
// dropping the whole incomplete lead keeps the tail clean.
|
|
883
|
+
const head = Buffer.alloc(LOOP_TASK_FILE_MAX_BYTES - 3, 0x61); // 'a' * (cap-3)
|
|
884
|
+
const partial = Buffer.from([0xf0, 0x9f, 0xa6]); // 3 of a 4-byte char...
|
|
885
|
+
const tail = Buffer.from([0x62]); // ...then 'b' (non-continuation) → incomplete
|
|
886
|
+
const raw = Buffer.concat([head, partial, tail]); // cap + 1 bytes → truncated
|
|
887
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
888
|
+
await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
|
|
889
|
+
|
|
890
|
+
const result = await readLoopTaskFile({
|
|
891
|
+
projectRoot,
|
|
892
|
+
homeDir,
|
|
893
|
+
allowProjectFile: true,
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
expect(result.status).toBe('found');
|
|
897
|
+
if (result.status !== 'found') {
|
|
898
|
+
throw new Error('expected loop.md to be found');
|
|
899
|
+
}
|
|
900
|
+
expect(result.truncated).toBe(true);
|
|
901
|
+
expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(
|
|
902
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
903
|
+
);
|
|
904
|
+
// The incomplete sequence is gone entirely — no replacement char, and the
|
|
905
|
+
// body ends on the last complete ('a') char.
|
|
906
|
+
expect(result.content).not.toContain('�');
|
|
907
|
+
expect(result.content.endsWith('a')).toBe(true);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('drops an INCOMPLETE trailing 2-byte lead at the cap (covers the 2-byte width branch)', async () => {
|
|
911
|
+
// A lone 2-byte lead (0xc3, its continuation replaced by a non-continuation)
|
|
912
|
+
// must be dropped by the width branch ((b & 0xe0) === 0xc0 → width 2), not
|
|
913
|
+
// kept as an orphan decoding to U+FFFD. The two trailing continuation bytes
|
|
914
|
+
// are sized so a width-table regression (treating 0xc3 as width 1) leaves
|
|
915
|
+
// the orphan's U+FFFD at exactly the cap, where the byte-length re-clamp
|
|
916
|
+
// can't mask it — catching a regression the re-clamp alone would hide.
|
|
917
|
+
const N = LOOP_TASK_FILE_MAX_BYTES;
|
|
918
|
+
const head = Buffer.alloc(N - 3, 0x61); // 'a' * (N-3)
|
|
919
|
+
const tail = Buffer.from([0xc3, 0x41, 0x80, 0x80]); // 2-byte lead, 'A', 2 conts
|
|
920
|
+
const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated
|
|
921
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
922
|
+
await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
|
|
923
|
+
|
|
924
|
+
const result = await readLoopTaskFile({
|
|
925
|
+
projectRoot,
|
|
926
|
+
homeDir,
|
|
927
|
+
allowProjectFile: true,
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
expect(result.status).toBe('found');
|
|
931
|
+
if (result.status !== 'found') {
|
|
932
|
+
throw new Error('expected loop.md to be found');
|
|
933
|
+
}
|
|
934
|
+
expect(result.truncated).toBe(true);
|
|
935
|
+
expect(result.content).not.toContain('�');
|
|
936
|
+
expect(result.content).toBe('a'.repeat(N - 3));
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('drops an INCOMPLETE trailing 3-byte lead at the cap (covers the 3-byte width branch)', async () => {
|
|
940
|
+
// A 3-byte lead with only ONE of its two continuations (0xe4 0xb8) followed
|
|
941
|
+
// by a non-continuation must be dropped by the width branch
|
|
942
|
+
// ((b & 0xf0) === 0xe0 → width 3). Sized so a width-table regression (0xe4
|
|
943
|
+
// treated as width 1 or 2) leaves the orphan's U+FFFD below the cap, where
|
|
944
|
+
// it survives the re-clamp — so the regression is observable.
|
|
945
|
+
const N = LOOP_TASK_FILE_MAX_BYTES;
|
|
946
|
+
const head = Buffer.alloc(N - 4, 0x61); // 'a' * (N-4)
|
|
947
|
+
const tail = Buffer.from([0xe4, 0xb8, 0x41, 0x80, 0x80]); // lead+1 cont, 'A', 2 conts
|
|
948
|
+
const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated
|
|
949
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
950
|
+
await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
|
|
951
|
+
|
|
952
|
+
const result = await readLoopTaskFile({
|
|
953
|
+
projectRoot,
|
|
954
|
+
homeDir,
|
|
955
|
+
allowProjectFile: true,
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
expect(result.status).toBe('found');
|
|
959
|
+
if (result.status !== 'found') {
|
|
960
|
+
throw new Error('expected loop.md to be found');
|
|
961
|
+
}
|
|
962
|
+
expect(result.truncated).toBe(true);
|
|
963
|
+
expect(result.content).not.toContain('�');
|
|
964
|
+
expect(result.content).toBe('a'.repeat(N - 4));
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it('drops an ORPHAN lead followed by an ASCII byte and stray continuations (no U+FFFD)', async () => {
|
|
968
|
+
// The continuation back-off walks `lead` to the LAST non-continuation byte,
|
|
969
|
+
// so a `> end` width check alone stops at the ASCII `0x41` (a complete 1-byte
|
|
970
|
+
// char) and keeps the orphan `0xc3` before it plus the three stray `0x80`
|
|
971
|
+
// continuations after it — all of which decode to trailing U+FFFD. Re-checking
|
|
972
|
+
// the boundary against the EXACT char width (and re-running after each trim)
|
|
973
|
+
// is what strips the whole malformed tail. The trailing `0x61` defeats the
|
|
974
|
+
// initial continuation back-off, so the stray `0x80` bytes are not at the very
|
|
975
|
+
// end and only the boundary loop removes them.
|
|
976
|
+
const N = LOOP_TASK_FILE_MAX_BYTES;
|
|
977
|
+
const head = Buffer.alloc(N - 5, 0x61); // 'a' * (N-5)
|
|
978
|
+
const tail = Buffer.from([0xc3, 0x41, 0x80, 0x80, 0x80, 0x61]); // orphan lead, 'A', 3 conts, 'a'
|
|
979
|
+
const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated
|
|
980
|
+
await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
|
|
981
|
+
await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
|
|
982
|
+
|
|
983
|
+
const result = await readLoopTaskFile({
|
|
984
|
+
projectRoot,
|
|
985
|
+
homeDir,
|
|
986
|
+
allowProjectFile: true,
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
expect(result.status).toBe('found');
|
|
990
|
+
if (result.status !== 'found') {
|
|
991
|
+
throw new Error('expected loop.md to be found');
|
|
992
|
+
}
|
|
993
|
+
expect(result.truncated).toBe(true);
|
|
994
|
+
expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(
|
|
995
|
+
LOOP_TASK_FILE_MAX_BYTES,
|
|
996
|
+
);
|
|
997
|
+
// The whole malformed tail is gone: no replacement char, and the body ends
|
|
998
|
+
// on the last complete ('a') char — a clean UTF-8 boundary.
|
|
999
|
+
expect(result.content).not.toContain('�');
|
|
1000
|
+
expect(result.content).toBe('a'.repeat(N - 5));
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('skips a candidate that raises ENAMETOOLONG and falls through instead of throwing', async () => {
|
|
1004
|
+
// The over-long-path code is in the skip whitelist but otherwise untested; a
|
|
1005
|
+
// typo'd entry would start throwing on a real ENAMETOOLONG instead of falling
|
|
1006
|
+
// through. Drive it via a mocked lstat on the project path; home still reads.
|
|
1007
|
+
await writeHome('user tasks');
|
|
1008
|
+
const projectLoop = path.join(projectRoot, '.qwen', 'loop.md');
|
|
1009
|
+
const actual =
|
|
1010
|
+
await vi.importActual<typeof import('node:fs/promises')>(
|
|
1011
|
+
'node:fs/promises',
|
|
1012
|
+
);
|
|
1013
|
+
const enametoolong = Object.assign(new Error('ENAMETOOLONG'), {
|
|
1014
|
+
code: 'ENAMETOOLONG',
|
|
1015
|
+
});
|
|
1016
|
+
vi.spyOn(fs, 'lstat').mockImplementation(async (p) =>
|
|
1017
|
+
String(p) === projectLoop
|
|
1018
|
+
? Promise.reject(enametoolong)
|
|
1019
|
+
: actual.lstat(p as string),
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
const result = await readLoopTaskFile({
|
|
1023
|
+
projectRoot,
|
|
1024
|
+
homeDir,
|
|
1025
|
+
allowProjectFile: true,
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
expect(result).toMatchObject({
|
|
1029
|
+
status: 'found',
|
|
1030
|
+
source: 'home',
|
|
1031
|
+
content: 'user tasks',
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it('lets the original read error propagate when handle.close() also throws', async () => {
|
|
1036
|
+
// readBoundedTaskFile closes the handle in a `finally`. If read() throws
|
|
1037
|
+
// (e.g. EIO) AND close() also throws (e.g. EBADF), an unguarded `finally`
|
|
1038
|
+
// would replace the original I/O error with the close error, masking the
|
|
1039
|
+
// real cause. The close is guarded, so the ORIGINAL read error must survive.
|
|
1040
|
+
await writeProject('project tasks'); // real file so lstat/realpath/confine pass
|
|
1041
|
+
const eio = Object.assign(new Error('EIO: i/o error, read'), {
|
|
1042
|
+
code: 'EIO',
|
|
1043
|
+
});
|
|
1044
|
+
const ebadf = Object.assign(
|
|
1045
|
+
new Error('EBADF: bad file descriptor, close'),
|
|
1046
|
+
{ code: 'EBADF' },
|
|
1047
|
+
);
|
|
1048
|
+
const close = vi.fn().mockRejectedValue(ebadf);
|
|
1049
|
+
const fakeHandle = {
|
|
1050
|
+
stat: async () => ({ isFile: () => true, size: 100 }),
|
|
1051
|
+
read: vi.fn().mockRejectedValue(eio),
|
|
1052
|
+
close,
|
|
1053
|
+
} as unknown as Awaited<ReturnType<typeof fs.open>>;
|
|
1054
|
+
// The first fs.open is the project candidate (read first); hand it the
|
|
1055
|
+
// fake handle. lstat/realpath above this still run against the real file.
|
|
1056
|
+
vi.mocked(fs.open).mockImplementationOnce(async () => fakeHandle);
|
|
1057
|
+
|
|
1058
|
+
await expect(
|
|
1059
|
+
readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
|
|
1060
|
+
).rejects.toBe(eio);
|
|
1061
|
+
// The close was still attempted (we swallow its failure, not skip it).
|
|
1062
|
+
expect(close).toHaveBeenCalled();
|
|
1063
|
+
});
|
|
1064
|
+
});
|