aicodeman 1.29.1 → 1.30.0

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.
Files changed (117) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/reboot-restore.d.ts +146 -0
  4. package/dist/reboot-restore.d.ts.map +1 -0
  5. package/dist/reboot-restore.js +205 -0
  6. package/dist/reboot-restore.js.map +1 -0
  7. package/dist/session-env-clamp.d.ts +80 -0
  8. package/dist/session-env-clamp.d.ts.map +1 -0
  9. package/dist/session-env-clamp.js +96 -0
  10. package/dist/session-env-clamp.js.map +1 -0
  11. package/dist/session.d.ts +9 -0
  12. package/dist/session.d.ts.map +1 -1
  13. package/dist/session.js +12 -0
  14. package/dist/session.js.map +1 -1
  15. package/dist/web/ports/session-port.d.ts +31 -0
  16. package/dist/web/ports/session-port.d.ts.map +1 -1
  17. package/dist/web/public/admin-ui.js.gz +0 -0
  18. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  19. package/dist/web/public/{app.556be563.js → app.6d2dc4e8.js} +2 -2
  20. package/dist/web/public/app.6d2dc4e8.js.br +0 -0
  21. package/dist/web/public/app.6d2dc4e8.js.gz +0 -0
  22. package/dist/web/public/approvals-ui.js.gz +0 -0
  23. package/dist/web/public/constants.258b140f.js.gz +0 -0
  24. package/dist/web/public/cron-ui.js.gz +0 -0
  25. package/dist/web/public/entrance-animations.js.gz +0 -0
  26. package/dist/web/public/home-sessions.js.gz +0 -0
  27. package/dist/web/public/i18n.5f897ed5.js.gz +0 -0
  28. package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
  29. package/dist/web/public/index.html +25 -6
  30. package/dist/web/public/index.html.br +0 -0
  31. package/dist/web/public/index.html.gz +0 -0
  32. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  33. package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
  34. package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
  35. package/dist/web/public/mobile-overview.js.gz +0 -0
  36. package/dist/web/public/{mobile.e9d0b53e.css → mobile.6caaa28a.css} +1 -1
  37. package/dist/web/public/mobile.6caaa28a.css.br +0 -0
  38. package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
  39. package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
  40. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  41. package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
  42. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  43. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  44. package/dist/web/public/readmymind-ui.js.gz +0 -0
  45. package/dist/web/public/reboot-restore-ui.js +142 -0
  46. package/dist/web/public/reboot-restore-ui.js.br +0 -0
  47. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  48. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  49. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  50. package/dist/web/public/session-lineage.js.gz +0 -0
  51. package/dist/web/public/session-ui.42b81477.js.gz +0 -0
  52. package/dist/web/public/settings-ui.58f1d756.js.gz +0 -0
  53. package/dist/web/public/styles.e6edfb0b.css +1 -0
  54. package/dist/web/public/styles.e6edfb0b.css.br +0 -0
  55. package/dist/web/public/styles.e6edfb0b.css.gz +0 -0
  56. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  57. package/dist/web/public/sw.js.gz +0 -0
  58. package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
  59. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +1 -0
  60. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
  61. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  62. package/dist/web/public/{terminal-ui.38c49244.js → terminal-ui.4f8d5820.js} +2 -2
  63. package/dist/web/public/terminal-ui.4f8d5820.js.br +0 -0
  64. package/dist/web/public/terminal-ui.4f8d5820.js.gz +0 -0
  65. package/dist/web/public/ultracode-panel.js.gz +0 -0
  66. package/dist/web/public/ultracode-windows.js.gz +0 -0
  67. package/dist/web/public/upload.html.gz +0 -0
  68. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  69. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  70. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  71. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  72. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  73. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  74. package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
  75. package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
  76. package/dist/web/public/vendor/xterm.css.gz +0 -0
  77. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  78. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
  79. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  80. package/dist/web/public/webview-tabs.js.gz +0 -0
  81. package/dist/web/reboot-restore-registry.d.ts +107 -0
  82. package/dist/web/reboot-restore-registry.d.ts.map +1 -0
  83. package/dist/web/reboot-restore-registry.js +189 -0
  84. package/dist/web/reboot-restore-registry.js.map +1 -0
  85. package/dist/web/routes/index.d.ts +1 -0
  86. package/dist/web/routes/index.d.ts.map +1 -1
  87. package/dist/web/routes/index.js +1 -0
  88. package/dist/web/routes/index.js.map +1 -1
  89. package/dist/web/routes/reboot-restore-routes.d.ts +30 -0
  90. package/dist/web/routes/reboot-restore-routes.d.ts.map +1 -0
  91. package/dist/web/routes/reboot-restore-routes.js +273 -0
  92. package/dist/web/routes/reboot-restore-routes.js.map +1 -0
  93. package/dist/web/routes/session-routes.d.ts +1 -18
  94. package/dist/web/routes/session-routes.d.ts.map +1 -1
  95. package/dist/web/routes/session-routes.js +1 -65
  96. package/dist/web/routes/session-routes.js.map +1 -1
  97. package/dist/web/schemas.d.ts +11 -0
  98. package/dist/web/schemas.d.ts.map +1 -1
  99. package/dist/web/schemas.js +13 -0
  100. package/dist/web/schemas.js.map +1 -1
  101. package/dist/web/server.d.ts +74 -0
  102. package/dist/web/server.d.ts.map +1 -1
  103. package/dist/web/server.js +238 -2
  104. package/dist/web/server.js.map +1 -1
  105. package/package.json +1 -1
  106. package/dist/web/public/app.556be563.js.br +0 -0
  107. package/dist/web/public/app.556be563.js.gz +0 -0
  108. package/dist/web/public/mobile.e9d0b53e.css.br +0 -0
  109. package/dist/web/public/mobile.e9d0b53e.css.gz +0 -0
  110. package/dist/web/public/styles.6add175d.css +0 -1
  111. package/dist/web/public/styles.6add175d.css.br +0 -0
  112. package/dist/web/public/styles.6add175d.css.gz +0 -0
  113. package/dist/web/public/terminal-keycode229-recovery.6ea8fe37.js +0 -1
  114. package/dist/web/public/terminal-keycode229-recovery.6ea8fe37.js.br +0 -0
  115. package/dist/web/public/terminal-keycode229-recovery.6ea8fe37.js.gz +0 -0
  116. package/dist/web/public/terminal-ui.38c49244.js.br +0 -0
  117. package/dist/web/public/terminal-ui.38c49244.js.gz +0 -0
package/README.md CHANGED
@@ -443,7 +443,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
443
443
  - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
444
444
  - **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
445
445
  - **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
446
- - **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add dashboard**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
446
+ - **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
447
447
  - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
448
448
  - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
449
449
  - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
package/README.zh-CN.md CHANGED
@@ -445,7 +445,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
445
445
  - **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
446
446
  - **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
447
447
  - **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
448
- - **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add dashboard**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
448
+ - **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
449
449
  - **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
450
450
  - **远程 SSH 会话** —— 把 case 指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话;文件预览与下载走同一条 ssh 连接。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
451
451
  - **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @fileoverview Decide which sessions a host reboot destroyed and may be rebuilt.
3
+ *
4
+ * A server restart and a host reboot both leave `reconcileSessions()` reporting
5
+ * dead sessions, and they need opposite handling. A server restart leaves the
6
+ * tmux panes running, so recovery ATTACHES to them. A host reboot takes the tmux
7
+ * server down with it, so there is nothing to attach to and the pane has to be
8
+ * created again. This module holds the decision half of that second case, kept
9
+ * free of tmux and disk access so it can be unit tested without either. Every
10
+ * observation it reads is gathered by the caller and passed in.
11
+ *
12
+ * "Eligible" here means a session the user did not end on purpose. The rule that
13
+ * an intentional kill or detach is never auto-revived is enforced at runtime by
14
+ * an in-memory guard in `TmuxManager`, and memory does not survive a reboot. The
15
+ * durable equivalent is the record `cleanupSession()` leaves behind. An unpinned
16
+ * kill deletes the record outright, so it is already absent here. A pinned kill
17
+ * goes through `demoteOrRemoveSession()` and lands as `status: 'stopped'`, which
18
+ * is the marker this module refuses. Pruning keeps a pinned record WITHOUT
19
+ * touching its status, so a pinned session a reboot killed still reads `idle` or
20
+ * `busy` and stays eligible.
21
+ *
22
+ * ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
23
+ * recognise today, and a reboot restores it. `/exit` ends the CLI inside the
24
+ * pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
25
+ * `tmux attach-session` process, which stays alive throughout — so no exit
26
+ * handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
27
+ * and `status: 'idle'`. Nothing durable distinguishes it from a session that was
28
+ * simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
29
+ * notice the dead pane; until a record can say the agent is gone, this pass will
30
+ * offer those sessions back, and the user dismisses or closes them.
31
+ *
32
+ * The `pid` check below is therefore NOT that rule. It refuses a record whose
33
+ * attach process was already gone, which is a session that never started or
34
+ * whose pane died outright.
35
+ *
36
+ * @dependencies types (SessionState), config/cli-registry
37
+ * @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
38
+ *
39
+ * @module reboot-restore
40
+ */
41
+ import type { SessionState } from './types.js';
42
+ /** Observations the reboot heuristic reads. Gathered by the caller, never here. */
43
+ export interface RebootEvidence {
44
+ /** Sessions that still had a live pane during reconciliation. */
45
+ livePaneCount: number;
46
+ /** Sessions reconciliation just marked dead. */
47
+ deadSessionCount: number;
48
+ /** `os.uptime()`, in seconds. */
49
+ uptimeSeconds: number;
50
+ /** Newest `lastActivityAt` across the persisted records, in ms since the epoch. */
51
+ newestPersistedActivityAt: number;
52
+ /** `Date.now()` when the evidence was gathered, in ms. */
53
+ now: number;
54
+ }
55
+ /**
56
+ * Decide whether the machine plausibly rebooted rather than the server restarting.
57
+ *
58
+ * Two signals have to agree. The socket must hold no panes at all while state
59
+ * still lists sessions, which rules out an ordinary server restart. The host
60
+ * must also have booted after the newest persisted session activity, which is
61
+ * the corroboration `os.uptime()` provides cheaply. A wiped tmux socket on a
62
+ * long-uptime host fails the second test, so a user who killed the tmux server
63
+ * by hand does not get every session offered back to them.
64
+ *
65
+ * This heuristic decides whether to ASK, never whether to act. A wrong yes costs
66
+ * the user a banner they dismiss, because the restore itself waits for a click.
67
+ *
68
+ * ⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that
69
+ * cuts BOTH ways rather than simply switching the feature off in Docker. After a
70
+ * genuine host reboot a containerized Codeman sees the host's short uptime, so the
71
+ * banner DOES appear and the feature works. What it cannot see is a container-only
72
+ * restart: the host uptime is long, the boot test fails, and no banner appears
73
+ * although every in-container pane is gone (`docker/server.Dockerfile` installs
74
+ * tmux inside the Codeman container, and the self-updater restarts the Compose
75
+ * deployment by exiting the container, so that is the case where this would help
76
+ * most). Failing quiet is the safe direction, and closing the gap needs a boot
77
+ * signal the container owns (PID 1's start time, gated on the existing
78
+ * `isRunningInContainer()`) rather than a wider heuristic.
79
+ */
80
+ export declare function looksLikeHostReboot(evidence: RebootEvidence): boolean;
81
+ /**
82
+ * Pick the conversation the rebuilt pane should resume.
83
+ *
84
+ * The chain's tail is the newest conversation the session was holding, which is
85
+ * what a compact or a clear leaves behind; `resumeSessionId` covers a session
86
+ * that was itself started as a resume, and the session id is the original
87
+ * conversation for everything else.
88
+ */
89
+ export declare function resolveResumeConversationId(state: SessionState): string;
90
+ /**
91
+ * Why one session was passed over. Reported for logging and shown to the user.
92
+ *
93
+ * The first seven are decided before anything is built. `capacity-reached` and
94
+ * `rebuild-failed` can only happen once a click is spending the plan, and they
95
+ * are the two the banner must not confuse with a missing workspace: one means
96
+ * "try again after closing something", the other means the CLI would not start.
97
+ */
98
+ export interface RebootRestoreRejection {
99
+ sessionId: string;
100
+ reason: 'no-persisted-record' | 'intentionally-ended' | 'not-running' | 'respawn-blocked' | 'remote-or-docker' | 'unsupported-mode' | 'no-working-dir' | 'workspace-missing' | 'workspace-forbidden' | 'already-live' | 'capacity-reached' | 'rebuild-failed';
101
+ }
102
+ /** One restorable session, as the banner shows it and the rebuild replays it. */
103
+ export interface RebootRestoreEntry {
104
+ sessionId: string;
105
+ name?: string;
106
+ workingDir: string;
107
+ owner?: string;
108
+ mode: string;
109
+ /** The conversation the rebuilt pane resumes. */
110
+ resumeConversationId: string;
111
+ /**
112
+ * The persisted record, kept whole so the rebuild can replay what it held.
113
+ * Read at boot, before pruning deletes it, and held in memory until the click.
114
+ */
115
+ state: SessionState;
116
+ }
117
+ export interface RebootRestorePlan {
118
+ restore: RebootRestoreEntry[];
119
+ skipped: RebootRestoreRejection[];
120
+ }
121
+ /**
122
+ * Split the sessions reconciliation just killed into the ones a reboot restore
123
+ * may offer and the ones it must leave alone.
124
+ *
125
+ * @param deadSessionIds Session ids `reconcileSessions()` reported as dead.
126
+ * @param persisted The `state.json` session records, which `cleanupStaleSessions()`
127
+ * has not pruned yet at the point this runs.
128
+ * @param workspaceExists Whether a working directory is still on disk. A tmux
129
+ * session can outlive its deleted repo, and rebuilding one there would scaffold
130
+ * an empty tree. The caller owns the disk access; the click re-checks, because
131
+ * a repo can be deleted between the boot and the click.
132
+ */
133
+ export declare function planRebootRestore(deadSessionIds: readonly string[], persisted: Readonly<Record<string, SessionState>>, workspaceExists: (workingDir: string) => boolean): RebootRestorePlan;
134
+ /**
135
+ * Drop the entries whose conversation is already on screen.
136
+ *
137
+ * Hours can pass between the boot that built the plan and the click that spends
138
+ * it, and the Resume list can reach the same conversation in the meantime. Two
139
+ * panes running `claude --resume` on one conversation is the failure this
140
+ * prevents, so a match on either the session id or the conversation id is enough
141
+ * to skip the entry.
142
+ */
143
+ export declare function rejectAlreadyLive(entries: readonly RebootRestoreEntry[], liveSessionIds: ReadonlySet<string>, liveConversationIds: ReadonlySet<string>): RebootRestorePlan;
144
+ /** Newest `lastActivityAt` across persisted records, or 0 when there are none. */
145
+ export declare function newestPersistedActivity(persisted: Readonly<Record<string, SessionState>>): number;
146
+ //# sourceMappingURL=reboot-restore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reboot-restore.d.ts","sourceRoot":"","sources":["../src/reboot-restore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAM/C,mFAAmF;AACnF,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,aAAa,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,gBAAgB,EAAE,MAAM,CAAC;IACzB,iCAAiC;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,mFAAmF;IACnF,yBAAyB,EAAE,MAAM,CAAC;IAClC,0DAA0D;IAC1D,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,cAAc,GAAG,OAAO,CAMrE;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAIvE;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EACF,qBAAqB,GACrB,qBAAqB,GACrB,aAAa,GACb,iBAAiB,GACjB,kBAAkB,GAClB,kBAAkB,GAClB,gBAAgB,GAChB,mBAAmB,GACnB,qBAAqB,GACrB,cAAc,GACd,kBAAkB,GAClB,gBAAgB,CAAC;CACtB;AAED,iFAAiF;AACjF,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,OAAO,EAAE,sBAAsB,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,cAAc,EAAE,SAAS,MAAM,EAAE,EACjC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,EACjD,eAAe,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,GAC/C,iBAAiB,CAuEnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,SAAS,kBAAkB,EAAE,EACtC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,EACnC,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAAC,GACvC,iBAAiB,CAWnB;AAED,kFAAkF;AAClF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,GAAG,MAAM,CAOjG"}
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @fileoverview Decide which sessions a host reboot destroyed and may be rebuilt.
3
+ *
4
+ * A server restart and a host reboot both leave `reconcileSessions()` reporting
5
+ * dead sessions, and they need opposite handling. A server restart leaves the
6
+ * tmux panes running, so recovery ATTACHES to them. A host reboot takes the tmux
7
+ * server down with it, so there is nothing to attach to and the pane has to be
8
+ * created again. This module holds the decision half of that second case, kept
9
+ * free of tmux and disk access so it can be unit tested without either. Every
10
+ * observation it reads is gathered by the caller and passed in.
11
+ *
12
+ * "Eligible" here means a session the user did not end on purpose. The rule that
13
+ * an intentional kill or detach is never auto-revived is enforced at runtime by
14
+ * an in-memory guard in `TmuxManager`, and memory does not survive a reboot. The
15
+ * durable equivalent is the record `cleanupSession()` leaves behind. An unpinned
16
+ * kill deletes the record outright, so it is already absent here. A pinned kill
17
+ * goes through `demoteOrRemoveSession()` and lands as `status: 'stopped'`, which
18
+ * is the marker this module refuses. Pruning keeps a pinned record WITHOUT
19
+ * touching its status, so a pinned session a reboot killed still reads `idle` or
20
+ * `busy` and stays eligible.
21
+ *
22
+ * ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
23
+ * recognise today, and a reboot restores it. `/exit` ends the CLI inside the
24
+ * pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
25
+ * `tmux attach-session` process, which stays alive throughout — so no exit
26
+ * handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
27
+ * and `status: 'idle'`. Nothing durable distinguishes it from a session that was
28
+ * simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
29
+ * notice the dead pane; until a record can say the agent is gone, this pass will
30
+ * offer those sessions back, and the user dismisses or closes them.
31
+ *
32
+ * The `pid` check below is therefore NOT that rule. It refuses a record whose
33
+ * attach process was already gone, which is a session that never started or
34
+ * whose pane died outright.
35
+ *
36
+ * @dependencies types (SessionState), config/cli-registry
37
+ * @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
38
+ *
39
+ * @module reboot-restore
40
+ */
41
+ import { getCli } from './config/cli-registry/registry.js';
42
+ /** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
43
+ const RESTORABLE_STATUSES = new Set(['idle', 'busy', 'error']);
44
+ /**
45
+ * Decide whether the machine plausibly rebooted rather than the server restarting.
46
+ *
47
+ * Two signals have to agree. The socket must hold no panes at all while state
48
+ * still lists sessions, which rules out an ordinary server restart. The host
49
+ * must also have booted after the newest persisted session activity, which is
50
+ * the corroboration `os.uptime()` provides cheaply. A wiped tmux socket on a
51
+ * long-uptime host fails the second test, so a user who killed the tmux server
52
+ * by hand does not get every session offered back to them.
53
+ *
54
+ * This heuristic decides whether to ASK, never whether to act. A wrong yes costs
55
+ * the user a banner they dismiss, because the restore itself waits for a click.
56
+ *
57
+ * ⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that
58
+ * cuts BOTH ways rather than simply switching the feature off in Docker. After a
59
+ * genuine host reboot a containerized Codeman sees the host's short uptime, so the
60
+ * banner DOES appear and the feature works. What it cannot see is a container-only
61
+ * restart: the host uptime is long, the boot test fails, and no banner appears
62
+ * although every in-container pane is gone (`docker/server.Dockerfile` installs
63
+ * tmux inside the Codeman container, and the self-updater restarts the Compose
64
+ * deployment by exiting the container, so that is the case where this would help
65
+ * most). Failing quiet is the safe direction, and closing the gap needs a boot
66
+ * signal the container owns (PID 1's start time, gated on the existing
67
+ * `isRunningInContainer()`) rather than a wider heuristic.
68
+ */
69
+ export function looksLikeHostReboot(evidence) {
70
+ if (evidence.deadSessionCount === 0)
71
+ return false;
72
+ if (evidence.livePaneCount > 0)
73
+ return false;
74
+ if (evidence.newestPersistedActivityAt <= 0)
75
+ return false;
76
+ const bootedAt = evidence.now - evidence.uptimeSeconds * 1000;
77
+ return bootedAt > evidence.newestPersistedActivityAt;
78
+ }
79
+ /**
80
+ * Pick the conversation the rebuilt pane should resume.
81
+ *
82
+ * The chain's tail is the newest conversation the session was holding, which is
83
+ * what a compact or a clear leaves behind; `resumeSessionId` covers a session
84
+ * that was itself started as a resume, and the session id is the original
85
+ * conversation for everything else.
86
+ */
87
+ export function resolveResumeConversationId(state) {
88
+ const chain = state.claudeSessionChain;
89
+ const chainTail = Array.isArray(chain) && chain.length > 0 ? chain[chain.length - 1] : undefined;
90
+ return chainTail || state.resumeSessionId || state.id;
91
+ }
92
+ /**
93
+ * Split the sessions reconciliation just killed into the ones a reboot restore
94
+ * may offer and the ones it must leave alone.
95
+ *
96
+ * @param deadSessionIds Session ids `reconcileSessions()` reported as dead.
97
+ * @param persisted The `state.json` session records, which `cleanupStaleSessions()`
98
+ * has not pruned yet at the point this runs.
99
+ * @param workspaceExists Whether a working directory is still on disk. A tmux
100
+ * session can outlive its deleted repo, and rebuilding one there would scaffold
101
+ * an empty tree. The caller owns the disk access; the click re-checks, because
102
+ * a repo can be deleted between the boot and the click.
103
+ */
104
+ export function planRebootRestore(deadSessionIds, persisted, workspaceExists) {
105
+ const restore = [];
106
+ const skipped = [];
107
+ for (const sessionId of deadSessionIds) {
108
+ const state = persisted[sessionId];
109
+ if (!state) {
110
+ // An unpinned kill already deleted the record, so absence IS the guard.
111
+ skipped.push({ sessionId, reason: 'no-persisted-record' });
112
+ continue;
113
+ }
114
+ if (!RESTORABLE_STATUSES.has(state.status)) {
115
+ // A pinned kill was demoted to `stopped`. Reviving it would undo the kill.
116
+ skipped.push({ sessionId, reason: 'intentionally-ended' });
117
+ continue;
118
+ }
119
+ if (state.pid === null || state.pid === undefined) {
120
+ // No attach process when the record was last written: the session never
121
+ // started, or its pane died outright rather than its agent exiting inside a
122
+ // surviving pane. Either way there was nothing running to bring back.
123
+ //
124
+ // ⚠️ This does NOT catch a session the user ended with `/exit`. See the
125
+ // module header: that leaves the pid in place, because the pid is the tmux
126
+ // attach process and `remain-on-exit` keeps it alive.
127
+ //
128
+ // Conservative on purpose. A session that somehow persisted no pid while
129
+ // genuinely running is not offered, and its conversation stays reachable
130
+ // from the Resume list, which is where every session would be without this
131
+ // feature.
132
+ skipped.push({ sessionId, reason: 'not-running' });
133
+ continue;
134
+ }
135
+ if (state.respawnBlocked === true) {
136
+ // The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
137
+ skipped.push({ sessionId, reason: 'respawn-blocked' });
138
+ continue;
139
+ }
140
+ if (state.remote || state.docker) {
141
+ // Both need another host or a container to be up, which a just-booted machine
142
+ // cannot promise. The remote reconnect watcher owns the remote case already.
143
+ skipped.push({ sessionId, reason: 'remote-or-docker' });
144
+ continue;
145
+ }
146
+ // Capability, not a CLI id: this pass resumes by handing the CLI a conversation
147
+ // id through the top-level `resumeSessionId`, which only a CLI whose history the
148
+ // claude-jsonl reader understands can consume that way. Others carry their thread
149
+ // id in their own `<Mode>Config`, which this pass does not thread through.
150
+ if (getCli(state.mode ?? 'claude')?.capabilities.transcript !== 'claude-jsonl') {
151
+ skipped.push({ sessionId, reason: 'unsupported-mode' });
152
+ continue;
153
+ }
154
+ if (!state.workingDir) {
155
+ skipped.push({ sessionId, reason: 'no-working-dir' });
156
+ continue;
157
+ }
158
+ if (!workspaceExists(state.workingDir)) {
159
+ skipped.push({ sessionId, reason: 'workspace-missing' });
160
+ continue;
161
+ }
162
+ restore.push({
163
+ sessionId,
164
+ name: state.name,
165
+ workingDir: state.workingDir,
166
+ owner: state.owner,
167
+ mode: state.mode ?? 'claude',
168
+ resumeConversationId: resolveResumeConversationId(state),
169
+ state,
170
+ });
171
+ }
172
+ return { restore, skipped };
173
+ }
174
+ /**
175
+ * Drop the entries whose conversation is already on screen.
176
+ *
177
+ * Hours can pass between the boot that built the plan and the click that spends
178
+ * it, and the Resume list can reach the same conversation in the meantime. Two
179
+ * panes running `claude --resume` on one conversation is the failure this
180
+ * prevents, so a match on either the session id or the conversation id is enough
181
+ * to skip the entry.
182
+ */
183
+ export function rejectAlreadyLive(entries, liveSessionIds, liveConversationIds) {
184
+ const restore = [];
185
+ const skipped = [];
186
+ for (const entry of entries) {
187
+ if (liveSessionIds.has(entry.sessionId) || liveConversationIds.has(entry.resumeConversationId)) {
188
+ skipped.push({ sessionId: entry.sessionId, reason: 'already-live' });
189
+ continue;
190
+ }
191
+ restore.push(entry);
192
+ }
193
+ return { restore, skipped };
194
+ }
195
+ /** Newest `lastActivityAt` across persisted records, or 0 when there are none. */
196
+ export function newestPersistedActivity(persisted) {
197
+ let newest = 0;
198
+ for (const state of Object.values(persisted)) {
199
+ const stamp = state.lastActivityAt ?? state.createdAt ?? 0;
200
+ if (stamp > newest)
201
+ newest = stamp;
202
+ }
203
+ return newest;
204
+ }
205
+ //# sourceMappingURL=reboot-restore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reboot-restore.js","sourceRoot":"","sources":["../src/reboot-restore.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @fileoverview The env-var half of the multi-user privilege clamp.
3
+ *
4
+ * A session's `envOverrides` can hand back privilege that the per-CLI config
5
+ * clamp removed, so a non-granted owner's overrides get the privileged keys
6
+ * stripped before the session is built. The create and resume routes are what
7
+ * this bites on: they clamp what a request asked for.
8
+ *
9
+ * The reboot-restore route calls it as defence in depth, and today it can strip
10
+ * nothing. `Session.getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*` and
11
+ * `CLAUDE_CONFIG_DIR` out of a session's overrides, claude's `privilegedEnvKeys`
12
+ * are the five `ANTHROPIC_*` names, and that pass admits claude alone — so a
13
+ * persisted record cannot carry a clamped key. The call is there for the day the
14
+ * persisted set widens. The grant re-resolution that does bite on that path is
15
+ * `resolveClaudeModeForUsername`, which recomputes the permission mode.
16
+ *
17
+ * This lives outside `web/routes` on purpose. The question it answers is about
18
+ * session privilege rather than about HTTP, and `cron/cron-service.ts` sets the
19
+ * precedent by importing `canUsernameRunPrivilegedCommands` from `user-store.ts`
20
+ * directly and re-resolving the owner's grant when a job fires. Every caller here
21
+ * re-resolves the grant at the moment it builds a session, for the same reason.
22
+ *
23
+ * @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
24
+ * @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
25
+ *
26
+ * @module session-env-clamp
27
+ */
28
+ /**
29
+ * Env-var keys a non-granted owner must not be able to set, because each one
30
+ * hands back privilege `clampExternalCliBypassForOwner()` just removed, or redirects a
31
+ * credential-resolution endpoint.
32
+ *
33
+ * The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
34
+ * allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
35
+ * that is also how a user configures the harness's non-privileged knobs.
36
+ *
37
+ * - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
38
+ * bypass is a command-line FLAG, reachable only through the per-CLI config the
39
+ * clamp already owns; this one is an env var, so the config clamp alone is
40
+ * half a gate.
41
+ * - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
42
+ * executes at BOOT, before any approval row can apply. A user who can write a
43
+ * workspace can put a profile in it, so this is the wider of the two.
44
+ * - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
45
+ * forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
46
+ * `applyEnvOverrides()` runs — so a non-granted owner who could set the base
47
+ * URL would have the operator's API key sent as a bearer credential to a host
48
+ * of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
49
+ * your OWN key removes privilege rather than granting it.)
50
+ * - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
51
+ * credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
52
+ * because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
53
+ * forward any operator-held key into an omp pane today (omp's provider
54
+ * credentials live in `~/.omp` config files, not env vars), so there is no
55
+ * known concrete exfiltration path yet — clamped defensively anyway, since a
56
+ * non-granted owner redirecting where a shared multi-tenant deployment
57
+ * resolves auth from is not something to allow silently (found in
58
+ * Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
59
+ * already allowlisted for pi and not addressed here — see resolveOmpHome()).
60
+ */
61
+ export declare function ownerClampedEnvKeys(): string[];
62
+ /**
63
+ * Env-var half of the multi-user bypass clamp.
64
+ *
65
+ * `clampExternalCliBypassForOwner()` in `web/routes/session-routes.ts` clamps the
66
+ * per-CLI CONFIG, and for every CLI
67
+ * but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
68
+ * AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
69
+ * request lands last and wins, and a non-granted owner could restore
70
+ * `danger-full-access` on the very request the config clamp downgraded.
71
+ *
72
+ * Keys are DROPPED rather than rewritten: dropping falls through to what
73
+ * `_configureCliEnv()` exports, which is the clamped config and the server's own
74
+ * `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
75
+ * granted owner, like every other clamp here
76
+ * (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
77
+ * and it returns the caller's own object untouched when there is nothing to strip.
78
+ */
79
+ export declare function clampEnvOverridesForOwner(owner: string | undefined, envOverrides: Record<string, string> | undefined): Promise<Record<string, string> | undefined>;
80
+ //# sourceMappingURL=session-env-clamp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-env-clamp.d.ts","sourceRoot":"","sources":["../src/session-env-clamp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,EAAE,CAE9C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAC/C,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAQ7C"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview The env-var half of the multi-user privilege clamp.
3
+ *
4
+ * A session's `envOverrides` can hand back privilege that the per-CLI config
5
+ * clamp removed, so a non-granted owner's overrides get the privileged keys
6
+ * stripped before the session is built. The create and resume routes are what
7
+ * this bites on: they clamp what a request asked for.
8
+ *
9
+ * The reboot-restore route calls it as defence in depth, and today it can strip
10
+ * nothing. `Session.getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*` and
11
+ * `CLAUDE_CONFIG_DIR` out of a session's overrides, claude's `privilegedEnvKeys`
12
+ * are the five `ANTHROPIC_*` names, and that pass admits claude alone — so a
13
+ * persisted record cannot carry a clamped key. The call is there for the day the
14
+ * persisted set widens. The grant re-resolution that does bite on that path is
15
+ * `resolveClaudeModeForUsername`, which recomputes the permission mode.
16
+ *
17
+ * This lives outside `web/routes` on purpose. The question it answers is about
18
+ * session privilege rather than about HTTP, and `cron/cron-service.ts` sets the
19
+ * precedent by importing `canUsernameRunPrivilegedCommands` from `user-store.ts`
20
+ * directly and re-resolving the owner's grant when a job fires. Every caller here
21
+ * re-resolves the grant at the moment it builds a session, for the same reason.
22
+ *
23
+ * @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
24
+ * @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
25
+ *
26
+ * @module session-env-clamp
27
+ */
28
+ import { canUsernameRunPrivilegedCommands } from './user-store.js';
29
+ import { enabledClis } from './config/cli-registry/registry.js';
30
+ /**
31
+ * Env-var keys a non-granted owner must not be able to set, because each one
32
+ * hands back privilege `clampExternalCliBypassForOwner()` just removed, or redirects a
33
+ * credential-resolution endpoint.
34
+ *
35
+ * The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
36
+ * allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
37
+ * that is also how a user configures the harness's non-privileged knobs.
38
+ *
39
+ * - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
40
+ * bypass is a command-line FLAG, reachable only through the per-CLI config the
41
+ * clamp already owns; this one is an env var, so the config clamp alone is
42
+ * half a gate.
43
+ * - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
44
+ * executes at BOOT, before any approval row can apply. A user who can write a
45
+ * workspace can put a profile in it, so this is the wider of the two.
46
+ * - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
47
+ * forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
48
+ * `applyEnvOverrides()` runs — so a non-granted owner who could set the base
49
+ * URL would have the operator's API key sent as a bearer credential to a host
50
+ * of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
51
+ * your OWN key removes privilege rather than granting it.)
52
+ * - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
53
+ * credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
54
+ * because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
55
+ * forward any operator-held key into an omp pane today (omp's provider
56
+ * credentials live in `~/.omp` config files, not env vars), so there is no
57
+ * known concrete exfiltration path yet — clamped defensively anyway, since a
58
+ * non-granted owner redirecting where a shared multi-tenant deployment
59
+ * resolves auth from is not something to allow silently (found in
60
+ * Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
61
+ * already allowlisted for pi and not addressed here — see resolveOmpHome()).
62
+ */
63
+ export function ownerClampedEnvKeys() {
64
+ return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
65
+ }
66
+ /**
67
+ * Env-var half of the multi-user bypass clamp.
68
+ *
69
+ * `clampExternalCliBypassForOwner()` in `web/routes/session-routes.ts` clamps the
70
+ * per-CLI CONFIG, and for every CLI
71
+ * but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
72
+ * AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
73
+ * request lands last and wins, and a non-granted owner could restore
74
+ * `danger-full-access` on the very request the config clamp downgraded.
75
+ *
76
+ * Keys are DROPPED rather than rewritten: dropping falls through to what
77
+ * `_configureCliEnv()` exports, which is the clamped config and the server's own
78
+ * `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
79
+ * granted owner, like every other clamp here
80
+ * (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
81
+ * and it returns the caller's own object untouched when there is nothing to strip.
82
+ */
83
+ export async function clampEnvOverridesForOwner(owner, envOverrides) {
84
+ if (!envOverrides)
85
+ return envOverrides;
86
+ const keys = ownerClampedEnvKeys();
87
+ if (!keys.some((key) => key in envOverrides))
88
+ return envOverrides;
89
+ if (await canUsernameRunPrivilegedCommands(owner))
90
+ return envOverrides;
91
+ const clamped = { ...envOverrides };
92
+ for (const key of keys)
93
+ delete clamped[key];
94
+ return clamped;
95
+ }
96
+ //# sourceMappingURL=session-env-clamp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-env-clamp.js","sourceRoot":"","sources":["../src/session-env-clamp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,gCAAgC,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,UAAU,mBAAmB;IACjC,OAAO,WAAW,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAChF,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,KAAyB,EACzB,YAAgD;IAEhD,IAAI,CAAC,YAAY;QAAE,OAAO,YAAY,CAAC;IACvC,MAAM,IAAI,GAAG,mBAAmB,EAAE,CAAC;IACnC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,IAAI,YAAY,CAAC;QAAE,OAAO,YAAY,CAAC;IAClE,IAAI,MAAM,gCAAgC,CAAC,KAAK,CAAC;QAAE,OAAO,YAAY,CAAC;IACvE,MAAM,OAAO,GAAG,EAAE,GAAG,YAAY,EAAE,CAAC;IACpC,KAAK,MAAM,GAAG,IAAI,IAAI;QAAE,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC;IAC5C,OAAO,OAAO,CAAC;AACjB,CAAC"}
package/dist/session.d.ts CHANGED
@@ -579,6 +579,15 @@ export declare class Session extends EventEmitter {
579
579
  * re-pinning an already-pinned session refreshes its pinnedAt.
580
580
  */
581
581
  setPinned(pinned: boolean): void;
582
+ /**
583
+ * Restore a pin from a persisted record, keeping the moment it was pinned.
584
+ *
585
+ * `setPinned()` stamps `pinnedAt` with now, which is right for a user pinning a
586
+ * session and wrong for a restore: the session-manager orders its pinned group
587
+ * by that stamp, so a restored session would jump to the front of a list it had
588
+ * been sitting further down.
589
+ */
590
+ restorePin(pinned: boolean, pinnedAt?: number): void;
582
591
  get flickerFilterEnabled(): boolean;
583
592
  set flickerFilterEnabled(enabled: boolean);
584
593
  isIdle(): boolean;