aicodeman 1.40.0 → 1.41.1
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/README.md +81 -13
- package/dist/attachment-registry.d.ts +2 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +14 -3
- package/dist/attachment-registry.js.map +1 -1
- package/dist/cli-agent.d.ts +227 -0
- package/dist/cli-agent.d.ts.map +1 -0
- package/dist/cli-agent.js +771 -0
- package/dist/cli-agent.js.map +1 -0
- package/dist/cli.d.ts +25 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +103 -41
- package/dist/cli.js.map +1 -1
- package/dist/codeman-credentials.d.ts +27 -0
- package/dist/codeman-credentials.d.ts.map +1 -0
- package/dist/codeman-credentials.js +66 -0
- package/dist/codeman-credentials.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +9 -0
- package/dist/config/buffer-limits.d.ts.map +1 -1
- package/dist/config/buffer-limits.js +9 -0
- package/dist/config/buffer-limits.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +10 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +15 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +47 -0
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +47 -1
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/folder-listing.d.ts +94 -0
- package/dist/folder-listing.d.ts.map +1 -0
- package/dist/folder-listing.js +241 -0
- package/dist/folder-listing.js.map +1 -0
- package/dist/git-clone.d.ts.map +1 -1
- package/dist/git-clone.js +4 -0
- package/dist/git-clone.js.map +1 -1
- package/dist/hooks-config.d.ts +36 -16
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +45 -20
- package/dist/hooks-config.js.map +1 -1
- package/dist/html-view-capabilities.d.ts +54 -0
- package/dist/html-view-capabilities.d.ts.map +1 -0
- package/dist/html-view-capabilities.js +103 -0
- package/dist/html-view-capabilities.js.map +1 -0
- package/dist/image-watcher.d.ts.map +1 -1
- package/dist/image-watcher.js +6 -2
- package/dist/image-watcher.js.map +1 -1
- package/dist/mcp-server.d.ts +114 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +917 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp-sync-targets.d.ts +34 -0
- package/dist/mcp-sync-targets.d.ts.map +1 -0
- package/dist/mcp-sync-targets.js +55 -0
- package/dist/mcp-sync-targets.js.map +1 -0
- package/dist/mcp-sync.d.ts +7 -1
- package/dist/mcp-sync.d.ts.map +1 -1
- package/dist/mcp-sync.js +113 -6
- package/dist/mcp-sync.js.map +1 -1
- package/dist/mux-interface.d.ts +36 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-files.d.ts +37 -0
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +103 -0
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-pane-history.d.ts +103 -0
- package/dist/remote-pane-history.d.ts.map +1 -0
- package/dist/remote-pane-history.js +258 -0
- package/dist/remote-pane-history.js.map +1 -0
- package/dist/remote-ssh-limiter.d.ts +16 -3
- package/dist/remote-ssh-limiter.d.ts.map +1 -1
- package/dist/remote-ssh-limiter.js +37 -6
- package/dist/remote-ssh-limiter.js.map +1 -1
- package/dist/session.d.ts +29 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -4
- package/dist/session.js.map +1 -1
- package/dist/subagent-watcher.d.ts.map +1 -1
- package/dist/subagent-watcher.js +29 -6
- package/dist/subagent-watcher.js.map +1 -1
- package/dist/tab-layout-legacy-order.d.ts.map +1 -1
- package/dist/tab-layout-legacy-order.js +72 -13
- package/dist/tab-layout-legacy-order.js.map +1 -1
- package/dist/tab-layout-service.d.ts +33 -2
- package/dist/tab-layout-service.d.ts.map +1 -1
- package/dist/tab-layout-service.js +102 -7
- package/dist/tab-layout-service.js.map +1 -1
- package/dist/tab-layout.d.ts +9 -0
- package/dist/tab-layout.d.ts.map +1 -1
- package/dist/tab-layout.js +15 -2
- package/dist/tab-layout.js.map +1 -1
- package/dist/templates/case-template.md +2 -0
- package/dist/tmux-manager.d.ts +76 -2
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +216 -40
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tui/tui-client.d.ts +3 -17
- package/dist/tui/tui-client.d.ts.map +1 -1
- package/dist/tui/tui-client.js +3 -44
- package/dist/tui/tui-client.js.map +1 -1
- package/dist/types/session.d.ts +53 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/usage-telemetry.d.ts +44 -7
- package/dist/usage-telemetry.d.ts.map +1 -1
- package/dist/usage-telemetry.js +72 -7
- package/dist/usage-telemetry.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/process-exit-wait.d.ts +40 -0
- package/dist/utils/process-exit-wait.d.ts.map +1 -0
- package/dist/utils/process-exit-wait.js +68 -0
- package/dist/utils/process-exit-wait.js.map +1 -0
- package/dist/web/approval-inbox.d.ts +12 -0
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +1 -0
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +33 -0
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/paste-image-gc.d.ts +46 -6
- package/dist/web/paste-image-gc.d.ts.map +1 -1
- package/dist/web/paste-image-gc.js +106 -32
- package/dist/web/paste-image-gc.js.map +1 -1
- package/dist/web/ports/session-port.d.ts +8 -1
- package/dist/web/ports/session-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.24689381.js +43 -0
- package/dist/web/public/app.24689381.js.br +0 -0
- package/dist/web/public/app.24689381.js.gz +0 -0
- package/dist/web/public/approvals-ui.js +4 -0
- package/dist/web/public/approvals-ui.js.br +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.c0f8f9de.js → constants.17d77b65.js} +690 -35
- package/dist/web/public/constants.17d77b65.js.br +0 -0
- package/dist/web/public/constants.17d77b65.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js +490 -43
- package/dist/web/public/entrance-animations.js.br +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/gesture/gesture-codeman.js +112 -6
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js +2 -1
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.9afd8e4d.js +2 -0
- package/dist/web/public/i18n.9afd8e4d.js.br +0 -0
- package/dist/web/public/i18n.9afd8e4d.js.gz +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js +453 -0
- package/dist/web/public/image-input.9ed1d0d2.js.br +0 -0
- package/dist/web/public/image-input.9ed1d0d2.js.gz +0 -0
- package/dist/web/public/index.html +223 -46
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.c1e7c7b7.js → keyboard-accessory.c458eea8.js} +115 -35
- package/dist/web/public/keyboard-accessory.c458eea8.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c458eea8.js.gz +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +11 -2
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.2dff1580.css +1 -0
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.css.gz +0 -0
- package/dist/web/public/{notification-manager.50bb87d8.js → notification-manager.feb82c04.js} +19 -2
- package/dist/web/public/notification-manager.feb82c04.js.br +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.4735dfc2.js → panels-ui.73ec0137.js} +87 -87
- package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.f731602c.js → session-ui.dbc6819b.js} +33 -33
- package/dist/web/public/session-ui.dbc6819b.js.br +0 -0
- package/dist/web/public/session-ui.dbc6819b.js.gz +0 -0
- package/dist/web/public/settings-ui.81ac264e.js +97 -0
- package/dist/web/public/settings-ui.81ac264e.js.br +0 -0
- package/dist/web/public/settings-ui.81ac264e.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js +11 -0
- package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/{spreadsheet-preview.af82fa2f.js → spreadsheet-preview.75c751de.js} +1 -1
- package/dist/web/public/spreadsheet-preview.75c751de.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js +110 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.900a28e4.css +1 -0
- package/dist/web/public/styles.900a28e4.css.br +0 -0
- package/dist/web/public/styles.900a28e4.css.gz +0 -0
- package/dist/web/public/{subagent-windows.e6ca799f.js → subagent-windows.07277a58.js} +7 -5
- package/dist/web/public/subagent-windows.07277a58.js.br +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +6 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js +1 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.br +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.a5867793.js → tab-rail-resize.7157e0bd.js} +1 -1
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +135 -38
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js +2 -0
- package/dist/web/public/terminal-ui.858f5da5.js.br +0 -0
- package/dist/web/public/terminal-ui.858f5da5.js.gz +0 -0
- package/dist/web/public/tile-grid.js +769 -137
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js +9 -6
- package/dist/web/public/ultracode-windows.js.br +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +4 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +12 -0
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +8 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +8 -0
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts.map +1 -1
- package/dist/web/routes/admin-routes.js +4 -1
- package/dist/web/routes/admin-routes.js.map +1 -1
- package/dist/web/routes/approval-routes.d.ts.map +1 -1
- package/dist/web/routes/approval-routes.js +6 -8
- package/dist/web/routes/approval-routes.js.map +1 -1
- package/dist/web/routes/case-routes.js +1 -1
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/cli-registry-routes.d.ts +23 -4
- package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
- package/dist/web/routes/cli-registry-routes.js +92 -7
- package/dist/web/routes/cli-registry-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts +46 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +214 -27
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +8 -1
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/html-view-routes.d.ts +60 -0
- package/dist/web/routes/html-view-routes.d.ts.map +1 -0
- package/dist/web/routes/html-view-routes.js +288 -0
- package/dist/web/routes/html-view-routes.js.map +1 -0
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/mcp-sync-routes.d.ts +7 -2
- package/dist/web/routes/mcp-sync-routes.d.ts.map +1 -1
- package/dist/web/routes/mcp-sync-routes.js +10 -3
- package/dist/web/routes/mcp-sync-routes.js.map +1 -1
- package/dist/web/routes/reboot-restore-routes.js +3 -3
- package/dist/web/routes/reboot-restore-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +1 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +345 -90
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +16 -2
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +32 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +74 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +7 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +77 -24
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +5 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +5 -2
- package/dist/web/sse-events.js.map +1 -1
- package/package.json +5 -4
- package/skills/codeman/SKILL.md +30 -7
- package/README.zh-CN.md +0 -1147
- package/dist/web/public/app.abadc970.js +0 -43
- package/dist/web/public/app.abadc970.js.br +0 -0
- package/dist/web/public/app.abadc970.js.gz +0 -0
- package/dist/web/public/constants.c0f8f9de.js.br +0 -0
- package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
- package/dist/web/public/i18n.a31747a9.js +0 -2
- package/dist/web/public/i18n.a31747a9.js.br +0 -0
- package/dist/web/public/i18n.a31747a9.js.gz +0 -0
- package/dist/web/public/image-input.898a538b.js +0 -281
- package/dist/web/public/image-input.898a538b.js.br +0 -0
- package/dist/web/public/image-input.898a538b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.br +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/mobile.111b187d.css +0 -1
- package/dist/web/public/mobile.111b187d.css.br +0 -0
- package/dist/web/public/mobile.111b187d.css.gz +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
- package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
- package/dist/web/public/panels-ui.4735dfc2.js.gz +0 -0
- package/dist/web/public/session-ui.f731602c.js.br +0 -0
- package/dist/web/public/session-ui.f731602c.js.gz +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js +0 -97
- package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
- package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
- package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
- package/dist/web/public/styles.8ee12666.css +0 -1
- package/dist/web/public/styles.8ee12666.css.br +0 -0
- package/dist/web/public/styles.8ee12666.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.br +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js +0 -1
- package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
- package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
- package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js +0 -2
- package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
- package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js +0 -4
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
|
@@ -8,12 +8,14 @@ import { homedir } from 'node:os';
|
|
|
8
8
|
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
|
+
import { finished } from 'node:stream/promises';
|
|
11
12
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
12
13
|
import { performance } from 'node:perf_hooks';
|
|
13
14
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
14
15
|
import { Session, cliTakesSessionModel, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode, } from '../../session.js';
|
|
15
16
|
import { SseEvent } from '../sse-events.js';
|
|
16
17
|
import { webviewCapabilities } from '../../webview-capabilities.js';
|
|
18
|
+
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
|
|
17
19
|
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, SessionOrderUpdateSchema, SessionWaitQuerySchema, SessionWaitOutputQuerySchema, CustomModelSelectionSchema, } from '../schemas.js';
|
|
18
20
|
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
|
19
21
|
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
|
@@ -34,7 +36,7 @@ import { applyLaunchDefaults } from '../launch-defaults.js';
|
|
|
34
36
|
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
|
35
37
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
|
36
38
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
|
37
|
-
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
|
|
39
|
+
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, agentSkillDir, agentSkillDirFor, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js';
|
|
38
40
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
|
39
41
|
import { imageWatcher } from '../../image-watcher.js';
|
|
40
42
|
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
|
@@ -43,9 +45,11 @@ import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified
|
|
|
43
45
|
import { buildHistorySessionIndexItems, setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js';
|
|
44
46
|
import { RunSummaryTracker } from '../../run-summary.js';
|
|
45
47
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
|
46
|
-
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
|
48
|
+
import { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
|
|
49
|
+
import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
|
|
47
50
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
|
48
51
|
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, rehydrateRemoteHostFields, toAttachedSessionRemote, toSessionRemote, } from '../../remote-hosts.js';
|
|
52
|
+
import { fetchRemoteScrollback } from '../../remote-pane-history.js';
|
|
49
53
|
import { checkDockerAvailable, checkDockerConfigDrift, probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
|
|
50
54
|
import { LRUMap } from '../../utils/lru-map.js';
|
|
51
55
|
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
|
|
@@ -55,6 +59,7 @@ import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-tr
|
|
|
55
59
|
import { getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, selectLastAnsweredTurn, } from '../response-viewer-transcript.js';
|
|
56
60
|
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
|
57
61
|
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
|
|
62
|
+
import { UPLOADS_DIR } from '../paste-image-gc.js';
|
|
58
63
|
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
|
59
64
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
|
60
65
|
const CODEMAN_CONFIG_DIR = getDataDir();
|
|
@@ -218,41 +223,130 @@ export function imageMagicMatchesExt(data, ext) {
|
|
|
218
223
|
const brand = data.subarray(8, 12).toString('ascii');
|
|
219
224
|
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
|
220
225
|
}
|
|
226
|
+
case '.mp4':
|
|
227
|
+
case '.m4v':
|
|
228
|
+
case '.mov':
|
|
229
|
+
// ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
|
|
230
|
+
// vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
|
|
231
|
+
// QuickTime file can open on an atom other than ftyp.
|
|
232
|
+
return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
|
|
233
|
+
case '.webm':
|
|
234
|
+
// EBML header (Matroska's WebM subset)
|
|
235
|
+
return u32be(0) === 0x1a45dfa3;
|
|
236
|
+
case '.ogv':
|
|
237
|
+
// Ogg container ("OggS")
|
|
238
|
+
return u32be(0) === 0x4f676753;
|
|
221
239
|
default:
|
|
222
240
|
return false;
|
|
223
241
|
}
|
|
224
242
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Create or re-verify `{workingDir}/.codeman-uploads` for a prompt upload, or
|
|
245
|
+
* null when something other than a regular directory sits there. An agent or
|
|
246
|
+
* postinstall script could plant `.codeman-uploads -> ~/.ssh/` and redirect
|
|
247
|
+
* future writes outside workingDir: lstat (not stat) sees the symlink itself,
|
|
248
|
+
* and mkdir without `recursive` does not follow one for the leaf either;
|
|
249
|
+
* O_EXCL|O_NOFOLLOW on the file open makes the write itself symlink-safe.
|
|
250
|
+
* The folder ignores itself: a `.gitignore` of `*`, written once with O_EXCL,
|
|
251
|
+
* so the user's repository never sees uploads, their own ignore file is never
|
|
252
|
+
* touched, and a file already there is theirs and stays as it is.
|
|
253
|
+
*/
|
|
254
|
+
async function ensureUploadDir(workingDir) {
|
|
255
|
+
// workingDir exists (live session) and the caller bounded-probed its mount, so
|
|
256
|
+
// the lstat/mkdir below normally start on a reachable path; a mount that dies
|
|
257
|
+
// after that probe is the same residual race POST /api/sessions accepts.
|
|
258
|
+
const uploadDir = join(workingDir, UPLOADS_DIR);
|
|
259
|
+
try {
|
|
260
|
+
const dirStat = await fs.lstat(uploadDir);
|
|
261
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory())
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (err.code !== 'ENOENT')
|
|
266
|
+
throw err;
|
|
267
|
+
try {
|
|
268
|
+
await fs.mkdir(uploadDir);
|
|
269
|
+
}
|
|
270
|
+
catch (mkErr) {
|
|
271
|
+
// Concurrent uploads (a batch of photos) race to create the dir — the
|
|
272
|
+
// losers get EEXIST. Treat an already-present REAL directory as success,
|
|
273
|
+
// but re-verify it isn't a symlink a racing actor planted.
|
|
274
|
+
if (mkErr.code !== 'EEXIST')
|
|
275
|
+
throw mkErr;
|
|
276
|
+
const raceStat = await fs.lstat(uploadDir);
|
|
277
|
+
if (raceStat.isSymbolicLink() || !raceStat.isDirectory())
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const ignoreFile = join(uploadDir, '.gitignore');
|
|
282
|
+
try {
|
|
283
|
+
// 'wx' = O_CREAT|O_EXCL, which also fails on a symlink at the path.
|
|
284
|
+
await fs.writeFile(ignoreFile, '*\n', { flag: 'wx' });
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
if (err.code === 'EEXIST')
|
|
288
|
+
return uploadDir;
|
|
289
|
+
// The create can succeed before the write fails (ENOSPC): an empty ignore
|
|
290
|
+
// file would read as the user's on the next upload, so take it back; its own
|
|
291
|
+
// failure must not replace the cause.
|
|
292
|
+
await fs.rm(ignoreFile, { force: true }).catch(() => { });
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
return uploadDir;
|
|
296
|
+
}
|
|
297
|
+
/** Open a fresh, uniquely named paste file for writing. */
|
|
298
|
+
async function openPasteFile(imageDir, ext) {
|
|
299
|
+
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
|
300
|
+
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
301
|
+
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
|
302
|
+
const filepath = join(imageDir, filename);
|
|
303
|
+
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
304
|
+
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
305
|
+
const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
|
|
306
|
+
return { filename, filepath, fh };
|
|
307
|
+
}
|
|
308
|
+
// Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
|
|
309
|
+
// a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
|
|
310
|
+
// 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
|
|
311
|
+
// bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
|
|
312
|
+
// memory against a flood of unique IP keys.
|
|
228
313
|
const PASTE_RATE_TOKENS = 30;
|
|
229
|
-
const
|
|
314
|
+
const VIDEO_RATE_TOKENS = 3;
|
|
230
315
|
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
|
231
316
|
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
|
232
317
|
const pasteRateBuckets = new Map();
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
318
|
+
const videoRateBuckets = new Map();
|
|
319
|
+
function takeToken(buckets, key, capacity, now) {
|
|
320
|
+
if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
|
321
|
+
for (const [k, b] of buckets) {
|
|
236
322
|
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
|
|
237
|
-
|
|
323
|
+
buckets.delete(k);
|
|
238
324
|
}
|
|
239
325
|
}
|
|
240
|
-
let b =
|
|
326
|
+
let b = buckets.get(key);
|
|
241
327
|
if (!b) {
|
|
242
|
-
b = { tokens:
|
|
243
|
-
|
|
328
|
+
b = { tokens: capacity, lastRefill: now };
|
|
329
|
+
buckets.set(key, b);
|
|
244
330
|
}
|
|
245
|
-
|
|
246
|
-
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
|
331
|
+
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
|
|
247
332
|
b.lastRefill = now;
|
|
248
333
|
if (b.tokens < 1)
|
|
249
334
|
return false;
|
|
250
335
|
b.tokens -= 1;
|
|
251
336
|
return true;
|
|
252
337
|
}
|
|
338
|
+
export function consumePasteToken(key, now = Date.now()) {
|
|
339
|
+
return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
|
|
340
|
+
}
|
|
341
|
+
// Videos stream to disk under a far larger per-file cap, so they draw on their own,
|
|
342
|
+
// much lower bucket (see the comment above the constants).
|
|
343
|
+
export function consumeVideoToken(key, now = Date.now()) {
|
|
344
|
+
return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
|
|
345
|
+
}
|
|
253
346
|
// Test hook: reset between runs.
|
|
254
347
|
export function _resetPasteRateBuckets() {
|
|
255
348
|
pasteRateBuckets.clear();
|
|
349
|
+
videoRateBuckets.clear();
|
|
256
350
|
}
|
|
257
351
|
/**
|
|
258
352
|
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
|
@@ -573,8 +667,8 @@ function abortOnClientHangUp(reply) {
|
|
|
573
667
|
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
|
|
574
668
|
* may fail the create.
|
|
575
669
|
*/
|
|
576
|
-
async function injectAgentSkill(casePath) {
|
|
577
|
-
const skillDir =
|
|
670
|
+
async function injectAgentSkill(casePath, dir) {
|
|
671
|
+
const skillDir = agentSkillDirFor(casePath, dir);
|
|
578
672
|
try {
|
|
579
673
|
// Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`,
|
|
580
674
|
// written once by `codeman skill install`) over the case copy injected below, so a
|
|
@@ -583,7 +677,7 @@ async function injectAgentSkill(casePath) {
|
|
|
583
677
|
// current on the same trigger. Refresh-only + marker-guarded; quiet on refusal,
|
|
584
678
|
// since a foreign user copy is the user's own authored skill, not a config error.
|
|
585
679
|
await refreshUserAgentSkill();
|
|
586
|
-
const result = await applyAgentSkill(casePath, true);
|
|
680
|
+
const result = await applyAgentSkill(casePath, true, dir);
|
|
587
681
|
if (result === 'foreign') {
|
|
588
682
|
console.warn(`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`);
|
|
589
683
|
}
|
|
@@ -706,6 +800,7 @@ options = {}) {
|
|
|
706
800
|
// logout has to retire them too or every dashboard URL opened during this
|
|
707
801
|
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
|
|
708
802
|
webviewCapabilities.revokeOwner(ownerFor(req));
|
|
803
|
+
htmlViewCapabilities.revokeOwner(ownerFor(req));
|
|
709
804
|
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
|
710
805
|
return {};
|
|
711
806
|
});
|
|
@@ -867,13 +962,6 @@ options = {}) {
|
|
|
867
962
|
// materializing in ~/.claude/settings.local.json was never asked for.
|
|
868
963
|
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
|
869
964
|
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
|
|
870
|
-
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
|
871
|
-
// .claude rationale as the statusLine above: a create must never remove the
|
|
872
|
-
// skill from under other live sessions in the repo. Marker-guarded, so a
|
|
873
|
-
// user's own skills/codeman is never touched.
|
|
874
|
-
if (await ctx.getAgentSkillEnabled()) {
|
|
875
|
-
await injectAgentSkill(workingDir);
|
|
876
|
-
}
|
|
877
965
|
}
|
|
878
966
|
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
|
|
879
967
|
// dies on `command not found`. The message comes from the resolver, so it names where
|
|
@@ -892,6 +980,17 @@ options = {}) {
|
|
|
892
980
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
|
|
893
981
|
}
|
|
894
982
|
}
|
|
983
|
+
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
|
984
|
+
// workspace rationale as the statusLine above: a create must never remove the
|
|
985
|
+
// skill from under other live sessions in the repo. Marker-guarded, so a user's
|
|
986
|
+
// own copy is never touched. Every CLI that takes the skill gets it in the directory
|
|
987
|
+
// that CLI scans (`capabilities.agentSkillDir`); the same remote and named-workingDir
|
|
988
|
+
// conditions as the hooks above, and only once the launch pre-flight passed, so a
|
|
989
|
+
// CLI that cannot start leaves nothing in the workspace.
|
|
990
|
+
const createSkillDir = agentSkillDir(getCli(body.mode ?? 'claude')?.capabilities);
|
|
991
|
+
if (!remote && body.workingDir && createSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
992
|
+
await injectAgentSkill(workingDir, createSkillDir);
|
|
993
|
+
}
|
|
895
994
|
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
|
896
995
|
// in Claude's projects directory. If not, skip resume to avoid confusing
|
|
897
996
|
// "No conversation found" errors from Claude CLI.
|
|
@@ -980,13 +1079,16 @@ options = {}) {
|
|
|
980
1079
|
owner,
|
|
981
1080
|
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
|
|
982
1081
|
});
|
|
983
|
-
|
|
1082
|
+
const tabLayout = body.tabGroupId
|
|
1083
|
+
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
|
|
1084
|
+
: await ctx.addSession(session);
|
|
984
1085
|
ctx.store.incrementSessionsCreated();
|
|
985
1086
|
ctx.persistSessionState(session);
|
|
986
1087
|
await ctx.setupSessionListeners(session);
|
|
987
1088
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
|
988
|
-
// loader (see seedAgentSessionPreamble). Local
|
|
989
|
-
|
|
1089
|
+
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
|
1090
|
+
// skill; best-effort.
|
|
1091
|
+
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && (await ctx.getAgentSkillEnabled())) {
|
|
990
1092
|
await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
|
|
991
1093
|
}
|
|
992
1094
|
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
|
@@ -994,7 +1096,9 @@ options = {}) {
|
|
|
994
1096
|
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
|
995
1097
|
const lightState = ctx.getSessionStateWithRespawn(session);
|
|
996
1098
|
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
|
997
|
-
|
|
1099
|
+
// A grouped creation also returns the layout that placed it, so the browser
|
|
1100
|
+
// draws the tab inside its group at once instead of after a re-read.
|
|
1101
|
+
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
|
|
998
1102
|
});
|
|
999
1103
|
// ========== Rename Session ==========
|
|
1000
1104
|
app.put('/api/sessions/:id/name', async (req) => {
|
|
@@ -2619,6 +2723,8 @@ options = {}) {
|
|
|
2619
2723
|
// Query params:
|
|
2620
2724
|
// tail=<bytes> - Only return last N bytes (faster initial load)
|
|
2621
2725
|
// full=1 - Explicitly request the entire tmux scrollback (COD-47)
|
|
2726
|
+
// alt=recording - With full=1: if the pane is in the alternate screen, load
|
|
2727
|
+
// the visible frame over the byte recording instead
|
|
2622
2728
|
app.get('/api/sessions/:id/terminal', async (req, reply) => {
|
|
2623
2729
|
const routeStartedAt = performance.now();
|
|
2624
2730
|
const { id } = req.params;
|
|
@@ -2629,7 +2735,7 @@ options = {}) {
|
|
|
2629
2735
|
// Requests WITHOUT it — shell selection/tab switches (`tail=`) and legacy
|
|
2630
2736
|
// no-param callers — keep the fast visible-frame capture.
|
|
2631
2737
|
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
|
2632
|
-
|
|
2738
|
+
let isFullReload = query.full === '1' || query.full === 'true';
|
|
2633
2739
|
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
|
2634
2740
|
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
|
2635
2741
|
// on-screen frame, not just the accumulated byte history. This matters for
|
|
@@ -2646,16 +2752,48 @@ options = {}) {
|
|
|
2646
2752
|
// for. See PaneCaptureOptions.capturedGeometry.
|
|
2647
2753
|
// `lines` bounds only the history a FULL capture reads; the visible-frame
|
|
2648
2754
|
// path reads no history and is untouched by it.
|
|
2649
|
-
|
|
2755
|
+
let captureOpts = isFullReload
|
|
2650
2756
|
? {
|
|
2651
2757
|
fullHistory: true,
|
|
2652
2758
|
historyLimitLines: captureHistoryLines(query.lines, tmuxHistoryLimit),
|
|
2653
2759
|
maxCaptureBytes: terminalBufferMaxBytes,
|
|
2654
2760
|
}
|
|
2655
2761
|
: {};
|
|
2656
|
-
|
|
2657
|
-
|
|
2762
|
+
// A remote session's local pane only sees the remote tmux client's repaints,
|
|
2763
|
+
// so its own scrollback is a fraction of the real one: read that from the
|
|
2764
|
+
// remote tmux and splice it above the local frame. Null (host unreachable,
|
|
2765
|
+
// no remote session, back-off) keeps the local capture as before.
|
|
2766
|
+
if (isFullReload && session.remote && muxName && captureOpts.historyLimitLines !== undefined) {
|
|
2767
|
+
const remoteScrollback = await fetchRemoteScrollback(session.remote, session.id, captureOpts.historyLimitLines, terminalBufferMaxBytes);
|
|
2768
|
+
if (remoteScrollback !== null) {
|
|
2769
|
+
captureOpts.scrollbackOverride = remoteScrollback.scrollback;
|
|
2770
|
+
captureOpts.scrollbackOverrideScreen = remoteScrollback.screen;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
const capture = (opts) => muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
|
2774
|
+
? ctx.mux.captureActivePaneBuffer(muxName, opts)
|
|
2658
2775
|
: null;
|
|
2776
|
+
let liveMuxBuffer = capture(captureOpts);
|
|
2777
|
+
// tmux adds no history while a pane is in the alternate screen, and a
|
|
2778
|
+
// nested tmux client (a remote SSH or Docker case, or `tmux` run by hand
|
|
2779
|
+
// in a shell) holds its pane there for good, so the full capture above is
|
|
2780
|
+
// one screen plus whatever came before. The byte recording still carries
|
|
2781
|
+
// the scrollback, so a caller that opts in (a shell's automatic load,
|
|
2782
|
+
// `alt=recording`) gets the old visible-frame load over it, tailed, rather
|
|
2783
|
+
// than losing everything above the screen. `alternate_on` cannot tell a
|
|
2784
|
+
// nested tmux from vim or less, so those take this path too while open:
|
|
2785
|
+
// the load the shell had before tmux-rendered loads existed. Not when the
|
|
2786
|
+
// history came from the remote tmux (`scrollbackOverride`): a remote pane is
|
|
2787
|
+
// in the alternate screen only because it hosts that tmux's client, and
|
|
2788
|
+
// the fallback would throw the remote scrollback away.
|
|
2789
|
+
if (isFullReload &&
|
|
2790
|
+
query.alt === 'recording' &&
|
|
2791
|
+
captureOpts.scrollbackOverride === undefined &&
|
|
2792
|
+
captureOpts.capturedAlternateOn === true) {
|
|
2793
|
+
isFullReload = false;
|
|
2794
|
+
captureOpts = {};
|
|
2795
|
+
liveMuxBuffer = capture(captureOpts);
|
|
2796
|
+
}
|
|
2659
2797
|
const captureFinishedAt = performance.now();
|
|
2660
2798
|
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
|
2661
2799
|
const source = hasLiveMuxBuffer
|
|
@@ -2696,6 +2834,29 @@ options = {}) {
|
|
|
2696
2834
|
// why the UI could only ever say "truncated for performance".
|
|
2697
2835
|
let truncationReason = null;
|
|
2698
2836
|
let cleanBuffer;
|
|
2837
|
+
// A `lines=` bound shallower than tmux's history left the older lines in
|
|
2838
|
+
// tmux: an intentional partial read, so 'tail', and a later pull with a
|
|
2839
|
+
// deeper (or no) bound recovers them. Without this a bounded window looks
|
|
2840
|
+
// like the whole history to the client. The byte cap below still wins.
|
|
2841
|
+
// Only a bound the CLIENT set and below the configured ceiling counts:
|
|
2842
|
+
// without `lines=` the bound is the ceiling itself, and on tmux before 3.7
|
|
2843
|
+
// a live pane can hold more than a lowered ceiling, which no request can
|
|
2844
|
+
// read, so calling that a recoverable cut would offer a button that can
|
|
2845
|
+
// never return more.
|
|
2846
|
+
// `historyLinesCut` tells the client this happened, apart from any byte
|
|
2847
|
+
// cut below: `fullSize` counts only what tmux returned, so the bytes a
|
|
2848
|
+
// later `tail` cut are not all that is left, and the client must not name
|
|
2849
|
+
// them as the remainder.
|
|
2850
|
+
const capturedHistoryLines = captureOpts.capturedHistoryLines;
|
|
2851
|
+
const historyLinesCut = isFullCapture &&
|
|
2852
|
+
captureOpts.historyLimitLines !== undefined &&
|
|
2853
|
+
captureOpts.historyLimitLines < tmuxHistoryLimit &&
|
|
2854
|
+
capturedHistoryLines !== undefined &&
|
|
2855
|
+
capturedHistoryLines > captureOpts.historyLimitLines;
|
|
2856
|
+
if (historyLinesCut) {
|
|
2857
|
+
truncated = true;
|
|
2858
|
+
truncationReason = 'tail';
|
|
2859
|
+
}
|
|
2699
2860
|
// Cap the payload EARLY — before the regex normalization passes below run
|
|
2700
2861
|
// over it. A full-history tmux capture can be tens of MB of scrollback;
|
|
2701
2862
|
// normalizing all of it would stall the event loop only to discard most
|
|
@@ -2775,6 +2936,9 @@ options = {}) {
|
|
|
2775
2936
|
fullSize,
|
|
2776
2937
|
truncated,
|
|
2777
2938
|
truncationReason,
|
|
2939
|
+
// The `lines=` bound left older lines in tmux (see above): the remainder
|
|
2940
|
+
// is more than `fullSize - retainedBytes`, by an amount unknown here.
|
|
2941
|
+
historyLinesCut,
|
|
2778
2942
|
// `retainedBytes` is what this response actually carries; `fullSize` is
|
|
2779
2943
|
// what existed before the cut. The gap is what the indicator reports.
|
|
2780
2944
|
retainedBytes: cleanBuffer.length,
|
|
@@ -2798,6 +2962,15 @@ options = {}) {
|
|
|
2798
2962
|
// damage that does not exist.
|
|
2799
2963
|
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
|
|
2800
2964
|
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
|
|
2965
|
+
// Rows tmux holds above the visible frame, which is the most a `full=1`
|
|
2966
|
+
// pull can add. `truncated` measures the BYTE stream, and for a pane that
|
|
2967
|
+
// keeps no scrollback (a fullscreen CLI in the alternate screen) the bytes
|
|
2968
|
+
// a tail cut drops are old repaints that no request can bring back, so a
|
|
2969
|
+
// client must not offer to load them. Absent when the pane was not read,
|
|
2970
|
+
// and for a remote session: its local pane hosts the remote tmux client in
|
|
2971
|
+
// the alternate screen, so it always reports 0, while a `full=1` load reads
|
|
2972
|
+
// the real scrollback from the remote tmux (`fetchRemoteScrollback`).
|
|
2973
|
+
paneHistoryLines: hasLiveMuxBuffer && !session.remote ? captureOpts.capturedHistoryLines : undefined,
|
|
2801
2974
|
};
|
|
2802
2975
|
});
|
|
2803
2976
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -3000,7 +3173,7 @@ options = {}) {
|
|
|
3000
3173
|
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
3001
3174
|
if (capMsg)
|
|
3002
3175
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
3003
|
-
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, } = parseBody(QuickStartSchema, req.body);
|
|
3176
|
+
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig, ompConfig, envOverrides, effort, advisorModel, parentSessionId, agentOrigin, customModel, tabGroupId, } = parseBody(QuickStartSchema, req.body);
|
|
3004
3177
|
// Resolved ONCE here: the same value labels a case directory this request creates
|
|
3005
3178
|
// (agent-case-marker.ts) and draws the tab lineage line on the session below.
|
|
3006
3179
|
const qsParentSessionId = resolveParentSessionId(ctx, req, parentSessionId, owner);
|
|
@@ -3288,12 +3461,14 @@ options = {}) {
|
|
|
3288
3461
|
}
|
|
3289
3462
|
}
|
|
3290
3463
|
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
|
3291
|
-
// marker-guarded (a user's own
|
|
3292
|
-
//
|
|
3293
|
-
//
|
|
3294
|
-
//
|
|
3295
|
-
|
|
3296
|
-
|
|
3464
|
+
// marker-guarded (a user's own copy is never touched). Each CLI that takes the
|
|
3465
|
+
// skill gets it in the directory it scans (`capabilities.agentSkillDir`:
|
|
3466
|
+
// `.claude/skills` for claude, `.agents/skills` for most others); skipped for
|
|
3467
|
+
// remote cases, whose casePath lives on another host. Docker cases qualify:
|
|
3468
|
+
// hostWorkspacePath is a real host dir and the skill crosses the bind mount.
|
|
3469
|
+
const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities);
|
|
3470
|
+
if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) {
|
|
3471
|
+
await injectAgentSkill(resolvedCasePath, quickStartSkillDir);
|
|
3297
3472
|
}
|
|
3298
3473
|
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
|
3299
3474
|
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
|
@@ -3547,13 +3722,14 @@ options = {}) {
|
|
|
3547
3722
|
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
|
3548
3723
|
}
|
|
3549
3724
|
}
|
|
3550
|
-
await ctx.addSession(session);
|
|
3725
|
+
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
|
|
3551
3726
|
ctx.store.incrementSessionsCreated();
|
|
3552
3727
|
ctx.persistSessionState(session);
|
|
3553
3728
|
await ctx.setupSessionListeners(session);
|
|
3554
3729
|
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
|
|
3555
|
-
// loader (see seedAgentSessionPreamble). Local
|
|
3556
|
-
|
|
3730
|
+
// loader (see seedAgentSessionPreamble). Local sessions of a CLI that takes the
|
|
3731
|
+
// skill; best-effort.
|
|
3732
|
+
if (agentSkillDir(getCli(mode)?.capabilities) && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
|
|
3557
3733
|
await seedAgentSessionPreamble(session.id).catch((err) => console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`));
|
|
3558
3734
|
}
|
|
3559
3735
|
getLifecycleLog().log({
|
|
@@ -3626,6 +3802,7 @@ options = {}) {
|
|
|
3626
3802
|
casePath: resolvedCasePath,
|
|
3627
3803
|
caseName,
|
|
3628
3804
|
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
|
3805
|
+
...(tabGroupId ? { tabLayout } : {}),
|
|
3629
3806
|
};
|
|
3630
3807
|
}
|
|
3631
3808
|
catch (err) {
|
|
@@ -4512,6 +4689,17 @@ options = {}) {
|
|
|
4512
4689
|
// Paste Image (clipboard / drag-drop upload)
|
|
4513
4690
|
// ═══════════════════════════════════════════════════════════════
|
|
4514
4691
|
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
|
4692
|
+
// Single-sourced from the attachment registry (what the file viewer can also play),
|
|
4693
|
+
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
|
|
4694
|
+
// would upload but not preview, and a phone's camera roll produces neither.
|
|
4695
|
+
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
|
|
4696
|
+
const VIDEO_MIME_EXT = {
|
|
4697
|
+
mp4: '.mp4',
|
|
4698
|
+
quicktime: '.mov',
|
|
4699
|
+
'x-m4v': '.m4v',
|
|
4700
|
+
webm: '.webm',
|
|
4701
|
+
ogg: '.ogv',
|
|
4702
|
+
};
|
|
4515
4703
|
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
|
4516
4704
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
|
4517
4705
|
// CSRF defense: state-changing routes must come from same origin.
|
|
@@ -4549,14 +4737,41 @@ options = {}) {
|
|
|
4549
4737
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
|
|
4550
4738
|
}
|
|
4551
4739
|
const { id } = req.params;
|
|
4552
|
-
//
|
|
4553
|
-
//
|
|
4554
|
-
|
|
4740
|
+
// `?kind=video` selects the disk-streamed path (see below). It is read here so the
|
|
4741
|
+
// rate check draws on the right bucket before any work: videos get 3/min, images 30.
|
|
4742
|
+
const isVideo = req.query.kind === 'video';
|
|
4743
|
+
// Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
|
|
4744
|
+
// authenticated client can otherwise loop large uploads. Videos get their own,
|
|
4745
|
+
// far lower bucket because the per-file cap is 1GB (see the bucket constants).
|
|
4746
|
+
const rateKey = `${req.ip}:${id}`;
|
|
4747
|
+
if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
|
|
4555
4748
|
reply.code(429);
|
|
4556
4749
|
reply.header('Retry-After', '60');
|
|
4557
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT,
|
|
4750
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, isVideo
|
|
4751
|
+
? 'Rate limit exceeded (3 video uploads/min per session)'
|
|
4752
|
+
: 'Rate limit exceeded (30 uploads/min per session)');
|
|
4558
4753
|
}
|
|
4559
4754
|
const session = findSessionOrFail(ctx, id, req);
|
|
4755
|
+
// The file lands on THIS host under the session's working directory, which
|
|
4756
|
+
// for a remote (SSH) session is the remote path: the agent there could never
|
|
4757
|
+
// read it, and the write would land in a same-named local directory or fail.
|
|
4758
|
+
// An owned Docker case is fine, its workspace is bind-mounted at the same
|
|
4759
|
+
// absolute path; an adopted container (owned: false) mounts nothing, so its
|
|
4760
|
+
// agent reads the file only if the container exposes that host path.
|
|
4761
|
+
if (session.remote) {
|
|
4762
|
+
reply.code(400);
|
|
4763
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for remote (SSH) sessions');
|
|
4764
|
+
}
|
|
4765
|
+
// The upload is written under session.workingDir (ensureUploadDir below), and
|
|
4766
|
+
// a live session's workspace can sit on a network mount that stopped answering.
|
|
4767
|
+
// Probe it bounded before the first disk touch, as #516 requires for a path the
|
|
4768
|
+
// user named, so one dead mount cannot freeze a libuv threadpool worker per
|
|
4769
|
+
// upload. Only "unknown" (the mount is not answering) is refused here, the way
|
|
4770
|
+
// the create route does; absent / not-a-directory keep ensureUploadDir's own
|
|
4771
|
+
// errors, since a live session's workspace should be neither.
|
|
4772
|
+
if ((await probePathKind(session.workingDir, { pastCap: true })) === 'unknown') {
|
|
4773
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Workspace', session.workingDir, { pastCap: true }));
|
|
4774
|
+
}
|
|
4560
4775
|
if (!req.isMultipart()) {
|
|
4561
4776
|
reply.code(400);
|
|
4562
4777
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
|
@@ -4567,9 +4782,15 @@ options = {}) {
|
|
|
4567
4782
|
// boundary scanner with several bugs: literal boundary matches anywhere in
|
|
4568
4783
|
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
|
4569
4784
|
// offsets), no part-count cap.
|
|
4785
|
+
// `?kind=video` (read above for the rate check) is the ONLY way onto the video
|
|
4786
|
+
// path: it selects the larger, disk-streamed budget before the part is seen
|
|
4787
|
+
// (busboy takes its limits up front), and it admits video types alone, so an
|
|
4788
|
+
// image can never ride the video cap through memory. Without it the route is
|
|
4789
|
+
// byte-identical to the image-only version. Truncation is checked on the stream
|
|
4790
|
+
// below rather than thrown, since the file is being written as it arrives.
|
|
4570
4791
|
let part;
|
|
4571
4792
|
try {
|
|
4572
|
-
part = await req.file();
|
|
4793
|
+
part = await req.file(isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined);
|
|
4573
4794
|
}
|
|
4574
4795
|
catch (err) {
|
|
4575
4796
|
reply.code(413);
|
|
@@ -4583,6 +4804,76 @@ options = {}) {
|
|
|
4583
4804
|
reply.code(400);
|
|
4584
4805
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
|
4585
4806
|
}
|
|
4807
|
+
if (isVideo) {
|
|
4808
|
+
let ext = '';
|
|
4809
|
+
if (part.filename) {
|
|
4810
|
+
const origExt = extname(part.filename).toLowerCase();
|
|
4811
|
+
if (ALLOWED_VIDEO_EXTS.has(origExt))
|
|
4812
|
+
ext = origExt;
|
|
4813
|
+
}
|
|
4814
|
+
const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
|
|
4815
|
+
if (videoMime)
|
|
4816
|
+
ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
|
|
4817
|
+
if (!ext) {
|
|
4818
|
+
await finished(part.file.resume()).catch(() => { });
|
|
4819
|
+
reply.code(400);
|
|
4820
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`);
|
|
4821
|
+
}
|
|
4822
|
+
const imageDir = await ensureUploadDir(session.workingDir);
|
|
4823
|
+
if (!imageDir) {
|
|
4824
|
+
await finished(part.file.resume()).catch(() => { });
|
|
4825
|
+
reply.code(403);
|
|
4826
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4827
|
+
}
|
|
4828
|
+
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
|
4829
|
+
// Written as it arrives: a phone video is far too large to buffer the way an
|
|
4830
|
+
// image is. The container is judged on the first 12 bytes as soon as they are
|
|
4831
|
+
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
|
|
4832
|
+
// of the body is drained unwritten (as busboy does past its own size limit),
|
|
4833
|
+
// which is what lets the answer reach the client. A transfer that dies midway
|
|
4834
|
+
// (the phone left the network) leaves no partial file behind.
|
|
4835
|
+
const head = Buffer.alloc(12);
|
|
4836
|
+
let headLen = 0;
|
|
4837
|
+
let mislabeled = false;
|
|
4838
|
+
try {
|
|
4839
|
+
for await (const chunk of part.file) {
|
|
4840
|
+
if (mislabeled)
|
|
4841
|
+
continue;
|
|
4842
|
+
if (headLen < head.length) {
|
|
4843
|
+
headLen += chunk.copy(head, headLen, 0, head.length - headLen);
|
|
4844
|
+
mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
|
|
4845
|
+
if (mislabeled)
|
|
4846
|
+
continue;
|
|
4847
|
+
}
|
|
4848
|
+
// writeFile loops until the whole chunk is on disk; a bare write() may be short.
|
|
4849
|
+
await fh.writeFile(chunk);
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
catch (err) {
|
|
4853
|
+
await fs.unlink(filepath).catch(() => { });
|
|
4854
|
+
throw err;
|
|
4855
|
+
}
|
|
4856
|
+
finally {
|
|
4857
|
+
await fh.close();
|
|
4858
|
+
}
|
|
4859
|
+
let failure = null;
|
|
4860
|
+
if (headLen === 0)
|
|
4861
|
+
failure = { status: 400, message: 'Empty file' };
|
|
4862
|
+
else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
|
|
4863
|
+
console.warn(`[paste-image] video magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${head.subarray(0, headLen).toString('hex')}`);
|
|
4864
|
+
failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
|
|
4865
|
+
}
|
|
4866
|
+
else if (part.file.truncated) {
|
|
4867
|
+
const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
|
|
4868
|
+
failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
|
|
4869
|
+
}
|
|
4870
|
+
if (failure) {
|
|
4871
|
+
await fs.unlink(filepath).catch(() => { });
|
|
4872
|
+
reply.code(failure.status);
|
|
4873
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
|
|
4874
|
+
}
|
|
4875
|
+
return { path: filepath, filename };
|
|
4876
|
+
}
|
|
4586
4877
|
let imageBytes;
|
|
4587
4878
|
try {
|
|
4588
4879
|
imageBytes = await part.toBuffer();
|
|
@@ -4646,49 +4937,13 @@ options = {}) {
|
|
|
4646
4937
|
reply.code(415);
|
|
4647
4938
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
|
4648
4939
|
}
|
|
4649
|
-
//
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
|
|
4655
|
-
const imageDir = join(session.workingDir, '.claude-images');
|
|
4656
|
-
try {
|
|
4657
|
-
const dirStat = await fs.lstat(imageDir);
|
|
4658
|
-
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
4659
|
-
reply.code(403);
|
|
4660
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
|
4661
|
-
}
|
|
4662
|
-
}
|
|
4663
|
-
catch (err) {
|
|
4664
|
-
if (err.code !== 'ENOENT')
|
|
4665
|
-
throw err;
|
|
4666
|
-
// Non-recursive mkdir: does not follow symlinks for the leaf.
|
|
4667
|
-
// session.workingDir is guaranteed to exist (live session).
|
|
4668
|
-
try {
|
|
4669
|
-
await fs.mkdir(imageDir);
|
|
4670
|
-
}
|
|
4671
|
-
catch (mkErr) {
|
|
4672
|
-
// Concurrent uploads (a batch of photos) race to create .claude-images —
|
|
4673
|
-
// the losers get EEXIST. Treat an already-present REAL directory as
|
|
4674
|
-
// success, but re-verify it isn't a symlink a racing actor planted
|
|
4675
|
-
// (preserve the symlink-safety guarantee above).
|
|
4676
|
-
if (mkErr.code !== 'EEXIST')
|
|
4677
|
-
throw mkErr;
|
|
4678
|
-
const raceStat = await fs.lstat(imageDir);
|
|
4679
|
-
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
|
|
4680
|
-
reply.code(403);
|
|
4681
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
|
4682
|
-
}
|
|
4683
|
-
}
|
|
4940
|
+
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
|
|
4941
|
+
const imageDir = await ensureUploadDir(session.workingDir);
|
|
4942
|
+
if (!imageDir) {
|
|
4943
|
+
reply.code(403);
|
|
4944
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
|
4684
4945
|
}
|
|
4685
|
-
|
|
4686
|
-
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
4687
|
-
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
|
4688
|
-
const filepath = join(imageDir, filename);
|
|
4689
|
-
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
4690
|
-
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
4691
|
-
const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
|
|
4946
|
+
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
|
4692
4947
|
try {
|
|
4693
4948
|
await fh.writeFile(imageBytes);
|
|
4694
4949
|
}
|