@exagone313/dsh-podman 0.2.0-rc.2

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 (165) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/README.zh.md +41 -0
  4. package/cordis.patch.yml +17 -0
  5. package/dist/approval-reasons.d.ts +129 -0
  6. package/dist/approval-reasons.js +253 -0
  7. package/dist/approval-reasons.test.d.ts +1 -0
  8. package/dist/approval-reasons.test.js +115 -0
  9. package/dist/approval.d.ts +29 -0
  10. package/dist/approval.js +412 -0
  11. package/dist/approval.test.d.ts +1 -0
  12. package/dist/approval.test.js +462 -0
  13. package/dist/card-route.d.ts +15 -0
  14. package/dist/card-route.js +478 -0
  15. package/dist/card-route.test.d.ts +1 -0
  16. package/dist/card-route.test.js +181 -0
  17. package/dist/card-test-support.d.ts +15 -0
  18. package/dist/card-test-support.js +160 -0
  19. package/dist/client/ContainerCard.d.ts +6 -0
  20. package/dist/client/ContainerCard.js +79 -0
  21. package/dist/client/card-client.d.ts +6 -0
  22. package/dist/client/card-client.js +47 -0
  23. package/dist/client/card-protocol.d.ts +101 -0
  24. package/dist/client/card-protocol.js +10 -0
  25. package/dist/client/container-card-caches.d.ts +10 -0
  26. package/dist/client/container-card-caches.js +31 -0
  27. package/dist/client/container-card-controller.d.ts +107 -0
  28. package/dist/client/container-card-controller.js +207 -0
  29. package/dist/client/container-card-create-modal.d.ts +24 -0
  30. package/dist/client/container-card-create-modal.js +109 -0
  31. package/dist/client/container-card-directory-styles.d.ts +5 -0
  32. package/dist/client/container-card-directory-styles.js +246 -0
  33. package/dist/client/container-card-directory.d.ts +12 -0
  34. package/dist/client/container-card-directory.js +144 -0
  35. package/dist/client/container-card-editors.d.ts +30 -0
  36. package/dist/client/container-card-editors.js +116 -0
  37. package/dist/client/container-card-images.d.ts +37 -0
  38. package/dist/client/container-card-images.js +35 -0
  39. package/dist/client/container-card-paths.d.ts +20 -0
  40. package/dist/client/container-card-paths.js +88 -0
  41. package/dist/client/container-card-row.d.ts +30 -0
  42. package/dist/client/container-card-row.js +54 -0
  43. package/dist/client/container-card-secrets.d.ts +21 -0
  44. package/dist/client/container-card-secrets.js +71 -0
  45. package/dist/client/container-card-shared.d.ts +55 -0
  46. package/dist/client/container-card-shared.js +152 -0
  47. package/dist/client/container-card-styles.d.ts +21 -0
  48. package/dist/client/container-card-styles.js +143 -0
  49. package/dist/client/container-card-volumes.d.ts +12 -0
  50. package/dist/client/container-card-volumes.js +37 -0
  51. package/dist/client/container-card-workspace.d.ts +31 -0
  52. package/dist/client/container-card-workspace.js +24 -0
  53. package/dist/client/directory-picker.d.ts +12 -0
  54. package/dist/client/directory-picker.js +30 -0
  55. package/dist/client/index.d.ts +5 -0
  56. package/dist/client/index.js +4419 -0
  57. package/dist/client/locales.d.ts +9 -0
  58. package/dist/client/locales.js +370 -0
  59. package/dist/client/read-only-approval.d.ts +8 -0
  60. package/dist/client/read-only-approval.js +63 -0
  61. package/dist/client/slot-contract.d.ts +12 -0
  62. package/dist/client/slot-contract.js +4 -0
  63. package/dist/client/terminal-styles.d.ts +5 -0
  64. package/dist/client/terminal-styles.js +31 -0
  65. package/dist/client/tool-views.d.ts +8 -0
  66. package/dist/client/tool-views.js +241 -0
  67. package/dist/containers.test.d.ts +1 -0
  68. package/dist/containers.test.js +443 -0
  69. package/dist/daemons.test.d.ts +1 -0
  70. package/dist/daemons.test.js +153 -0
  71. package/dist/fs-provider.d.ts +23 -0
  72. package/dist/fs-provider.js +184 -0
  73. package/dist/fs-provider.test.d.ts +1 -0
  74. package/dist/fs-provider.test.js +260 -0
  75. package/dist/generated/version.d.ts +2 -0
  76. package/dist/generated/version.js +6 -0
  77. package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
  78. package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
  79. package/dist/grpc/runtime-client.d.ts +5 -0
  80. package/dist/grpc/runtime-client.js +43 -0
  81. package/dist/guest-rpc.d.ts +62 -0
  82. package/dist/guest-rpc.js +391 -0
  83. package/dist/images.test.d.ts +1 -0
  84. package/dist/images.test.js +79 -0
  85. package/dist/index.d.ts +22 -0
  86. package/dist/index.js +140 -0
  87. package/dist/misc.test.d.ts +1 -0
  88. package/dist/misc.test.js +141 -0
  89. package/dist/mount-enums.d.ts +22 -0
  90. package/dist/mount-enums.js +81 -0
  91. package/dist/mount-input.d.ts +14 -0
  92. package/dist/mount-input.js +64 -0
  93. package/dist/mounts.test.d.ts +1 -0
  94. package/dist/mounts.test.js +408 -0
  95. package/dist/output-reader.d.ts +34 -0
  96. package/dist/output-reader.js +87 -0
  97. package/dist/paths.test.d.ts +1 -0
  98. package/dist/paths.test.js +74 -0
  99. package/dist/preferences.d.ts +6 -0
  100. package/dist/preferences.js +27 -0
  101. package/dist/project-path.d.ts +6 -0
  102. package/dist/project-path.js +73 -0
  103. package/dist/project-path.test.d.ts +1 -0
  104. package/dist/project-path.test.js +52 -0
  105. package/dist/prompts.d.ts +13 -0
  106. package/dist/prompts.js +149 -0
  107. package/dist/public.d.ts +4 -0
  108. package/dist/public.js +63 -0
  109. package/dist/read-only-shell.d.ts +14 -0
  110. package/dist/read-only-shell.js +150 -0
  111. package/dist/read-only-shell.test.d.ts +1 -0
  112. package/dist/read-only-shell.test.js +127 -0
  113. package/dist/secrets.test.d.ts +1 -0
  114. package/dist/secrets.test.js +181 -0
  115. package/dist/settings-commands.test.d.ts +1 -0
  116. package/dist/settings-commands.test.js +549 -0
  117. package/dist/settings-create.test.d.ts +1 -0
  118. package/dist/settings-create.test.js +394 -0
  119. package/dist/settings-mounts.test.d.ts +1 -0
  120. package/dist/settings-mounts.test.js +467 -0
  121. package/dist/settings-paths.test.d.ts +1 -0
  122. package/dist/settings-paths.test.js +65 -0
  123. package/dist/settings-schema.d.ts +14 -0
  124. package/dist/settings-schema.js +14 -0
  125. package/dist/settings-workspaces.test.d.ts +1 -0
  126. package/dist/settings-workspaces.test.js +265 -0
  127. package/dist/spill-store.d.ts +17 -0
  128. package/dist/spill-store.js +54 -0
  129. package/dist/spill-store.test.d.ts +1 -0
  130. package/dist/spill-store.test.js +59 -0
  131. package/dist/subprocess.d.ts +9 -0
  132. package/dist/subprocess.js +369 -0
  133. package/dist/subprocess.test.d.ts +1 -0
  134. package/dist/subprocess.test.js +234 -0
  135. package/dist/test-support.d.ts +328 -0
  136. package/dist/test-support.js +437 -0
  137. package/dist/tool-defs.d.ts +26 -0
  138. package/dist/tool-defs.js +188 -0
  139. package/dist/tool-handlers.d.ts +2 -0
  140. package/dist/tool-handlers.js +546 -0
  141. package/dist/tool-params.d.ts +803 -0
  142. package/dist/tool-params.js +494 -0
  143. package/dist/tool-schemas.d.ts +2 -0
  144. package/dist/tool-schemas.js +5 -0
  145. package/dist/tool-views.d.ts +2 -0
  146. package/dist/tool-views.js +56 -0
  147. package/dist/views.test.d.ts +1 -0
  148. package/dist/views.test.js +23 -0
  149. package/dist/volumes.test.d.ts +1 -0
  150. package/dist/volumes.test.js +40 -0
  151. package/dist/workspace-binding.d.ts +40 -0
  152. package/dist/workspace-binding.js +275 -0
  153. package/dist/workspace-binding.test.d.ts +1 -0
  154. package/dist/workspace-binding.test.js +337 -0
  155. package/docs/architecture.md +173 -0
  156. package/docs/architecture.zh.md +129 -0
  157. package/docs/configuration.md +160 -0
  158. package/docs/configuration.zh.md +147 -0
  159. package/docs/development.md +142 -0
  160. package/docs/development.zh.md +135 -0
  161. package/docs/install-dsh-and-dsh-podman.md +165 -0
  162. package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
  163. package/docs/usage.md +393 -0
  164. package/docs/usage.zh.md +315 -0
  165. package/package.json +119 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elouan Martinet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
3
+
4
+ SPDX-License-Identifier: MIT
5
+ -->
6
+
7
+ # dsh-podman
8
+
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
10
+ [![npm version](https://img.shields.io/npm/v/@exagone313/dsh-podman)](https://www.npmjs.com/package/@exagone313/dsh-podman)
11
+ [![GitHub release](https://img.shields.io/github/v/release/Exagone313/dsh-podman)](https://github.com/Exagone313/dsh-podman/releases)
12
+ [![orchestrator](https://img.shields.io/badge/orchestrator-GHCR-blue)](https://github.com/Exagone313/dsh-podman/pkgs/container/dsh-podman%2Forchestrator)
13
+ [![guest-agent](https://img.shields.io/badge/guest--agent-GHCR-blue)](https://github.com/Exagone313/dsh-podman/pkgs/container/dsh-podman%2Fguest-agent)
14
+
15
+ English | [中文](README.zh.md)
16
+
17
+ [Podman](https://podman.io/)-backed execution for
18
+ [DeepSeek Harness](https://deepseek.com/harness) (`dsh`).
19
+
20
+ dsh-podman routes all shell execution and file access through disposable and
21
+ per-project Podman containers.
22
+
23
+ ## Documentation
24
+
25
+ - **[Install DeepSeek Harness & dsh-podman with rootless Podman](docs/install-dsh-and-dsh-podman.md)**
26
+ - [Architecture](docs/architecture.md)
27
+ - [Configuration](docs/configuration.md)
28
+ - [Usage](docs/usage.md)
29
+ - [Development](docs/development.md)
30
+
31
+ ## AI-usage disclosure
32
+
33
+ This repository was written in major parts using AI models:
34
+
35
+ - early commits using OpenAI GPT-5.6 Luna
36
+ - DeepSeek V4 Flash
37
+ - source code weakness fixes by Anthropic Claude Opus 5
38
+
39
+ ## License
40
+
41
+ [MIT](./LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,41 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
3
+
4
+ SPDX-License-Identifier: MIT
5
+ -->
6
+
7
+ # dsh-podman
8
+
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
10
+ [![npm version](https://img.shields.io/npm/v/@exagone313/dsh-podman)](https://www.npmjs.com/package/@exagone313/dsh-podman)
11
+ [![GitHub release](https://img.shields.io/github/v/release/Exagone313/dsh-podman)](https://github.com/Exagone313/dsh-podman/releases)
12
+ [![orchestrator](https://img.shields.io/badge/orchestrator-GHCR-blue)](https://github.com/Exagone313/dsh-podman/pkgs/container/dsh-podman%2Forchestrator)
13
+ [![guest-agent](https://img.shields.io/badge/guest--agent-GHCR-blue)](https://github.com/Exagone313/dsh-podman/pkgs/container/dsh-podman%2Fguest-agent)
14
+
15
+ [English](README.md) | 中文
16
+
17
+ 基于 [Podman](https://podman.io/) 的
18
+ [DeepSeek Harness](https://deepseek.com/harness)(`dsh`)执行后端。
19
+
20
+ dsh-podman 将所有的 shell 执行与文件访问都路由到一次性、按项目隔离的 Podman
21
+ 容器中。
22
+
23
+ ## 文档
24
+
25
+ - **[使用 rootless Podman 安装 DeepSeek Harness 与 dsh-podman](docs/install-dsh-and-dsh-podman.zh.md)**
26
+ - [架构](docs/architecture.zh.md)
27
+ - [配置](docs/configuration.zh.md)
28
+ - [使用](docs/usage.zh.md)
29
+ - [开发](docs/development.zh.md)
30
+
31
+ ## AI 使用声明
32
+
33
+ 本仓库的大部分代码由 AI 模型编写:
34
+
35
+ - 早期提交使用 OpenAI GPT-5.6 Luna
36
+ - DeepSeek V4 Flash
37
+ - 源代码弱点修复由 Anthropic Claude Opus 5 完成
38
+
39
+ ## 许可证
40
+
41
+ [MIT](./LICENSE)
@@ -0,0 +1,17 @@
1
+ # SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ # dsh bundle patch: mounts the dsh-podman plugin into a profile's layer
6
+ # stack. The subprocess/fs-sandbox/spill-local rows are disabled because this
7
+ # plugin provides the ctx.subprocess, ctx.fs and ctx.spillStore providers backed
8
+ # by workspace Podman containers instead.
9
+ - id: subprocess
10
+ disabled: true
11
+ - id: fs-sandbox
12
+ disabled: true
13
+ - id: spill-local
14
+ disabled: true
15
+ - insert:
16
+ - id: podman
17
+ name: '@exagone313/dsh-podman'
@@ -0,0 +1,129 @@
1
+ export type ReasonLocale = "en" | "zh";
2
+ export interface MountFact {
3
+ kind: "project" | "volume" | "tmpfs" | "secret";
4
+ source: string;
5
+ destination?: string;
6
+ readOnly?: boolean;
7
+ }
8
+ export type ReasonFact = {
9
+ kind: "image_build";
10
+ image: string;
11
+ parent?: string;
12
+ packages?: readonly string[];
13
+ } | {
14
+ kind: "image_rebuild";
15
+ image: string;
16
+ } | {
17
+ kind: "image_rebuild_all";
18
+ } | {
19
+ kind: "image_remove";
20
+ image: string;
21
+ } | {
22
+ kind: "container_start" | "container_recreate";
23
+ container: string;
24
+ image?: string;
25
+ mounts?: readonly MountFact[];
26
+ env?: readonly string[];
27
+ paths?: readonly string[];
28
+ } | {
29
+ kind: "container_remove";
30
+ container: string;
31
+ } | {
32
+ kind: "container_mount_add";
33
+ container: string;
34
+ mount: MountFact;
35
+ } | {
36
+ kind: "container_mount_remove";
37
+ container: string;
38
+ mount: MountFact;
39
+ } | {
40
+ kind: "container_mount_update";
41
+ container: string;
42
+ mount: MountFact;
43
+ } | {
44
+ kind: "container_path_set";
45
+ container: string;
46
+ paths: readonly string[];
47
+ } | {
48
+ kind: "container_path_add";
49
+ container: string;
50
+ path: string;
51
+ } | {
52
+ kind: "container_path_remove";
53
+ container: string;
54
+ path: string;
55
+ } | {
56
+ kind: "read_only_remount";
57
+ tool: string;
58
+ remount: readonly MountFact[];
59
+ keep: readonly MountFact[];
60
+ } | {
61
+ kind: "volume_remove";
62
+ name: string;
63
+ } | {
64
+ kind: "secret_remove";
65
+ name: string;
66
+ } | {
67
+ kind: "container_secret_add";
68
+ container: string;
69
+ secret: string;
70
+ env: string;
71
+ } | {
72
+ kind: "container_secret_remove";
73
+ container: string;
74
+ env: string;
75
+ } | {
76
+ kind: "container_bash";
77
+ container: string;
78
+ command: string;
79
+ cwd?: string;
80
+ uid?: number;
81
+ gid?: number;
82
+ groups?: readonly number[];
83
+ } | {
84
+ kind: "container_exec";
85
+ container: string;
86
+ argv: readonly string[];
87
+ cwd?: string;
88
+ uid?: number;
89
+ gid?: number;
90
+ groups?: readonly number[];
91
+ } | {
92
+ kind: "container_write";
93
+ container: string;
94
+ path: string;
95
+ } | {
96
+ kind: "container_edit";
97
+ container: string;
98
+ path: string;
99
+ } | {
100
+ kind: "daemon_start";
101
+ container: string;
102
+ argv: readonly string[];
103
+ name?: string;
104
+ uid?: number;
105
+ gid?: number;
106
+ groups?: readonly number[];
107
+ };
108
+ export type DenialFact = {
109
+ kind: "read_only";
110
+ tool: string;
111
+ } | {
112
+ kind: "read_only_builtin";
113
+ tool: string;
114
+ } | {
115
+ kind: "read_only_remount_declined";
116
+ tool: string;
117
+ } | {
118
+ kind: "project_destination";
119
+ source?: string;
120
+ mirror?: string;
121
+ };
122
+ export interface SettingsReader {
123
+ get(ns: string): unknown;
124
+ }
125
+ export declare function resolveReasonLocale(settings: SettingsReader | undefined): ReasonLocale;
126
+ export declare function reasonLocale(value: unknown): ReasonLocale;
127
+ export declare function renderReason(locale: ReasonLocale, fact: ReasonFact): string;
128
+ export declare function renderCacheCleanNotice(locale: ReasonLocale, files: number): string;
129
+ export declare function renderDenial(locale: ReasonLocale, fact: DenialFact): string;
@@ -0,0 +1,253 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import { CONTAINER_NS } from "./settings-schema.js";
5
+ // Settings namespace owned by the browser locale plugin; only read here.
6
+ const LOCALE_NS = "locale";
7
+ const LOCALE_PREFERENCE_FIELD = "preference";
8
+ // The locale to render approval text in: the client-observed active locale
9
+ // first (it also captures the browser default), then the durable user
10
+ // preference, then English.
11
+ export function resolveReasonLocale(settings) {
12
+ if (settings === undefined)
13
+ return "en";
14
+ const observed = field(settings.get(CONTAINER_NS), "uiLocale");
15
+ if (typeof observed === "string" && observed !== "")
16
+ return reasonLocale(observed);
17
+ return reasonLocale(field(settings.get(LOCALE_NS), LOCALE_PREFERENCE_FIELD));
18
+ }
19
+ // Normalize a BCP 47-style locale id to a shipped language; `zh-Hans`, `zh_CN`
20
+ // and friends all map to `zh`, everything else to English.
21
+ export function reasonLocale(value) {
22
+ if (typeof value !== "string")
23
+ return "en";
24
+ const primary = value.toLowerCase().split(/[-_]/, 1)[0];
25
+ return primary === "zh" ? "zh" : "en";
26
+ }
27
+ function field(value, key) {
28
+ return typeof value === "object" && value !== null
29
+ ? value[key]
30
+ : undefined;
31
+ }
32
+ // Pick the locale's variant of one string.
33
+ function pick(locale, en, zh) {
34
+ return locale === "zh" ? zh : en;
35
+ }
36
+ // Quote one identifier with the locale's quote marks.
37
+ function quoted(locale, value) {
38
+ return pick(locale, `"${value}"`, `“${value}”`);
39
+ }
40
+ // Join list elements, capping at 8 with a localized "more" tail.
41
+ const LIST_CAP = 8;
42
+ function joinList(locale, items) {
43
+ if (items.length === 0)
44
+ return "";
45
+ const separator = pick(locale, ", ", "、");
46
+ const shown = items.slice(0, LIST_CAP).join(separator);
47
+ const extra = items.length - LIST_CAP;
48
+ return extra > 0
49
+ ? pick(locale, `${shown}, +${extra} more`, `${shown},另有 ${extra} 项`)
50
+ : shown;
51
+ }
52
+ // Render a mount's source name: `volume "data"`, `project "team/src"`,
53
+ // `secret "valkey-tls"`, or `tmpfs`.
54
+ function mountLabel(locale, mount) {
55
+ const name = quoted(locale, mount.source);
56
+ switch (mount.kind) {
57
+ case "tmpfs":
58
+ return "tmpfs";
59
+ case "volume":
60
+ return pick(locale, `volume ${name}`, `卷 ${name}`);
61
+ case "secret":
62
+ return pick(locale, `secret ${name}`, `机密 ${name}`);
63
+ default:
64
+ return pick(locale, `project ${name}`, `项目 ${name}`);
65
+ }
66
+ }
67
+ // Render a mount's destination clause (`at "/data"` / `挂载到 “/data”`), or
68
+ // undefined when it has none. The caller supplies the surrounding punctuation,
69
+ // since the locales wrap it differently.
70
+ function mountDestinationText(locale, mount) {
71
+ return mount.destination === undefined
72
+ ? undefined
73
+ : pick(locale, `at ${quoted(locale, mount.destination)}`, `挂载到 ${quoted(locale, mount.destination)}`);
74
+ }
75
+ // Render a mount's source and destination: `volume "data" at "/data"`.
76
+ function mountSourceText(locale, mount) {
77
+ const destination = mountDestinationText(locale, mount);
78
+ const label = mountLabel(locale, mount);
79
+ return destination === undefined ? label : `${label} ${destination}`;
80
+ }
81
+ // A mount's mode as a bare word (`read-only` / `read-write`), or undefined when
82
+ // the call carries no mode or the kind has none (tmpfs is always read-write and
83
+ // secrets take none).
84
+ function mountModeName(locale, mount) {
85
+ if (mount.readOnly === undefined ||
86
+ mount.kind === "tmpfs" ||
87
+ mount.kind === "secret") {
88
+ return undefined;
89
+ }
90
+ return mount.readOnly
91
+ ? pick(locale, "read-only", "只读")
92
+ : pick(locale, "read-write", "读写");
93
+ }
94
+ // Render one mount as `volume "data" at "/data" (read-only)`.
95
+ function mountText(locale, mount) {
96
+ const mode = mountModeName(locale, mount);
97
+ const suffix = mode === undefined ? "" : pick(locale, ` (${mode})`, `(${mode})`);
98
+ return `${mountSourceText(locale, mount)}${suffix}`;
99
+ }
100
+ // Render a command word list, capped at 8 words with an ellipsis tail.
101
+ function argvText(argv) {
102
+ const shown = argv.slice(0, 8).join(" ");
103
+ return argv.length > 8 ? `${shown} …` : shown;
104
+ }
105
+ // identityText renders the process identity a call requests as a parenthesized
106
+ // suffix, or "" when it requests none. Shared by every tool that can run a
107
+ // process as another user.
108
+ function identityText(locale, fact) {
109
+ const ids = [];
110
+ if (fact.uid !== undefined)
111
+ ids.push(`uid ${fact.uid}`);
112
+ if (fact.gid !== undefined)
113
+ ids.push(`gid ${fact.gid}`);
114
+ if (fact.groups !== undefined && fact.groups.length > 0) {
115
+ ids.push(`groups ${joinList(locale, fact.groups.map((group) => String(group)))}`);
116
+ }
117
+ return ids.length === 0 ? "" : pick(locale, ` (${ids.join(", ")})`, `(${ids.join("、")})`);
118
+ }
119
+ // Start/recreate share one shape; only the verb differs.
120
+ function containerRun(locale, fact) {
121
+ const image = fact.image === undefined
122
+ ? ""
123
+ : pick(locale, ` from image ${quoted(locale, fact.image)}`, `(镜像 ${quoted(locale, fact.image)})`);
124
+ const mounts = fact.mounts === undefined || fact.mounts.length === 0
125
+ ? ""
126
+ : pick(locale, ` with mounts: ${joinList(locale, fact.mounts.map((mount) => mountText(locale, mount)))}`, `,挂载:${joinList(locale, fact.mounts.map((mount) => mountText(locale, mount)))}`);
127
+ const env = fact.env === undefined || fact.env.length === 0
128
+ ? ""
129
+ : pick(locale, ` with env: ${joinList(locale, fact.env)}`, `,环境变量:${joinList(locale, fact.env)}`);
130
+ const paths = fact.paths === undefined || fact.paths.length === 0
131
+ ? ""
132
+ : pick(locale, ` with PATH additions: ${joinList(locale, fact.paths.map((path) => quoted(locale, path)))}`, `,PATH 附加项:${joinList(locale, fact.paths.map((path) => quoted(locale, path)))}`);
133
+ const verb = fact.kind === "container_start"
134
+ ? pick(locale, "Start", "启动")
135
+ : pick(locale, "Recreate", "重建");
136
+ return pick(locale, `${verb} container ${quoted(locale, fact.container)}${image}${mounts}${env}${paths}.`, `${verb}容器 ${quoted(locale, fact.container)}${image}${mounts}${env}${paths}。`);
137
+ }
138
+ // Render one reason as the single-line headline shown in the approval prompt.
139
+ // Reasons that end in a raw command keep no trailing period, so the command
140
+ // reads as typed.
141
+ export function renderReason(locale, fact) {
142
+ switch (fact.kind) {
143
+ case "image_build": {
144
+ const parent = fact.parent === undefined
145
+ ? ""
146
+ : pick(locale, ` from ${quoted(locale, fact.parent)}`, `(基于 ${quoted(locale, fact.parent)})`);
147
+ const packages = fact.packages === undefined
148
+ ? ""
149
+ : pick(locale, ` with packages: ${joinList(locale, fact.packages)}`, `,软件包:${joinList(locale, fact.packages)}`);
150
+ return pick(locale, `Build image ${quoted(locale, fact.image)}${parent}${packages}.`, `构建镜像 ${quoted(locale, fact.image)}${parent}${packages}。`);
151
+ }
152
+ case "image_rebuild":
153
+ return pick(locale, `Rebuild image ${quoted(locale, fact.image)}.`, `重建镜像 ${quoted(locale, fact.image)}。`);
154
+ case "image_rebuild_all":
155
+ return pick(locale, "Rebuild all images.", "重建全部镜像。");
156
+ case "image_remove":
157
+ return pick(locale, `Remove image ${quoted(locale, fact.image)}.`, `移除镜像 ${quoted(locale, fact.image)}。`);
158
+ case "container_start":
159
+ case "container_recreate":
160
+ return containerRun(locale, fact);
161
+ case "container_remove":
162
+ return pick(locale, `Remove container ${quoted(locale, fact.container)}.`, `移除容器 ${quoted(locale, fact.container)}。`);
163
+ case "container_mount_add":
164
+ return pick(locale, `Add mount to container ${quoted(locale, fact.container)}: ${mountText(locale, fact.mount)}.`, `在容器 ${quoted(locale, fact.container)} 中添加挂载:${mountText(locale, fact.mount)}。`);
165
+ case "container_mount_remove":
166
+ return pick(locale, `Remove mount from container ${quoted(locale, fact.container)}: ${mountText(locale, fact.mount)}.`, `从容器 ${quoted(locale, fact.container)} 中移除挂载:${mountText(locale, fact.mount)}。`);
167
+ case "container_mount_update": {
168
+ const destination = mountDestinationText(locale, fact.mount);
169
+ const mode = mountModeName(locale, fact.mount) ?? "";
170
+ // The zh sentence separates the mount from the verb with a space after a
171
+ // quoted name, but a full-width parenthetical needs none.
172
+ const target = destination === undefined
173
+ ? `${mountLabel(locale, fact.mount)} `
174
+ : `${mountLabel(locale, fact.mount)}(${destination})`;
175
+ return pick(locale, `Update mount in container ${quoted(locale, fact.container)}: remount ${mountSourceText(locale, fact.mount)} to ${mode}.`, `更新容器 ${quoted(locale, fact.container)} 中的挂载:将${target}重新挂载为${mode}。`);
176
+ }
177
+ case "container_path_set": {
178
+ const list = fact.paths.length === 0
179
+ ? pick(locale, "(none)", "(无)")
180
+ : joinList(locale, fact.paths.map((path) => quoted(locale, path)));
181
+ return pick(locale, `Set the PATH additions of container ${quoted(locale, fact.container)}: ${list}.`, `设置容器 ${quoted(locale, fact.container)} 的 PATH 附加项:${list}。`);
182
+ }
183
+ case "container_path_add":
184
+ return pick(locale, `Add ${quoted(locale, fact.path)} to the PATH of container ${quoted(locale, fact.container)}.`, `将 ${quoted(locale, fact.path)} 添加到容器 ${quoted(locale, fact.container)} 的 PATH 中。`);
185
+ case "container_path_remove":
186
+ return pick(locale, `Remove ${quoted(locale, fact.path)} from the PATH of container ${quoted(locale, fact.container)}.`, `从容器 ${quoted(locale, fact.container)} 的 PATH 中移除 ${quoted(locale, fact.path)}。`);
187
+ case "read_only_remount": {
188
+ const remount = fact.remount
189
+ .map((mount) => `- ${mountText(locale, mount)}`)
190
+ .join("\n");
191
+ const keep = fact.keep.length === 0
192
+ ? ""
193
+ : pick(locale, `\n\nKept as-is: ${joinList(locale, fact.keep.map((mount) => mountText(locale, mount)))}.`, `\n\n保持不变:${joinList(locale, fact.keep.map((mount) => mountText(locale, mount)))}。`);
194
+ return pick(locale, `Read-only mode blocks ${quoted(locale, fact.tool)} while a mount is read-write. Remount these mounts read-only to run it:\n\n${remount}${keep}`, `只读模式在存在读写挂载时会阻止 ${quoted(locale, fact.tool)}。将这些挂载重新挂载为只读即可运行:\n\n${remount}${keep}`);
195
+ }
196
+ case "volume_remove":
197
+ return pick(locale, `Remove volume ${quoted(locale, fact.name)}.`, `移除卷 ${quoted(locale, fact.name)}。`);
198
+ case "secret_remove":
199
+ return pick(locale, `Remove secret ${quoted(locale, fact.name)}.`, `移除机密 ${quoted(locale, fact.name)}。`);
200
+ case "container_secret_add":
201
+ return pick(locale, `Attach secret ${quoted(locale, fact.secret)} to container ${quoted(locale, fact.container)} as ${quoted(locale, fact.env)}.`, `将机密 ${quoted(locale, fact.secret)} 作为 ${quoted(locale, fact.env)} 注入容器 ${quoted(locale, fact.container)}。`);
202
+ case "container_secret_remove":
203
+ return pick(locale, `Detach secret env var ${quoted(locale, fact.env)} from container ${quoted(locale, fact.container)}.`, `从容器 ${quoted(locale, fact.container)} 中移除机密环境变量 ${quoted(locale, fact.env)}。`);
204
+ case "container_bash": {
205
+ const cwd = fact.cwd === undefined
206
+ ? ""
207
+ : pick(locale, ` (cwd ${quoted(locale, fact.cwd)})`, `(工作目录 ${quoted(locale, fact.cwd)})`);
208
+ const identity = identityText(locale, fact);
209
+ return pick(locale, `Run a shell command in container ${quoted(locale, fact.container)}${cwd}: ${fact.command}${identity}`, `在容器 ${quoted(locale, fact.container)}${cwd} 中运行 shell 命令:${fact.command}${identity}`);
210
+ }
211
+ case "container_exec": {
212
+ const cwd = fact.cwd === undefined
213
+ ? ""
214
+ : pick(locale, ` (cwd ${quoted(locale, fact.cwd)})`, `(工作目录 ${quoted(locale, fact.cwd)})`);
215
+ const identity = identityText(locale, fact);
216
+ return pick(locale, `Run a command in container ${quoted(locale, fact.container)}${cwd}: ${argvText(fact.argv)}${identity}`, `在容器 ${quoted(locale, fact.container)}${cwd} 中运行命令:${argvText(fact.argv)}${identity}`);
217
+ }
218
+ case "container_write":
219
+ return pick(locale, `Write ${quoted(locale, fact.path)} in container ${quoted(locale, fact.container)}.`, `在容器 ${quoted(locale, fact.container)} 中写入 ${quoted(locale, fact.path)}。`);
220
+ case "container_edit":
221
+ return pick(locale, `Edit ${quoted(locale, fact.path)} in container ${quoted(locale, fact.container)}.`, `在容器 ${quoted(locale, fact.container)} 中编辑 ${quoted(locale, fact.path)}。`);
222
+ case "daemon_start": {
223
+ const named = fact.name === undefined ? "" : ` ${quoted(locale, fact.name)}`;
224
+ const suffix = identityText(locale, fact);
225
+ return pick(locale, `Start daemon${named} in container ${quoted(locale, fact.container)}: ${argvText(fact.argv)}${suffix}`, `在容器 ${quoted(locale, fact.container)} 中启动守护进程${named}:${argvText(fact.argv)}${suffix}`);
226
+ }
227
+ }
228
+ }
229
+ // Render the settings notice shown after a cache cleanup. The card already
230
+ // shows the resulting sizes, so the notice only names how many files went.
231
+ export function renderCacheCleanNotice(locale, files) {
232
+ return pick(locale, `removed ${files} cached ${files === 1 ? "file" : "files"}`, `已移除 ${files} 个缓存文件`);
233
+ }
234
+ // Render one policy denial.
235
+ export function renderDenial(locale, fact) {
236
+ switch (fact.kind) {
237
+ case "read_only":
238
+ return pick(locale, `Denied: the session is read-only, but ${quoted(locale, fact.tool)} needs write access.`, `已拒绝:当前会话为只读,而 ${quoted(locale, fact.tool)} 需要写入权限。`);
239
+ case "read_only_builtin":
240
+ return pick(locale, `Denied: the session is read-only, but ${quoted(locale, fact.tool)} can modify files.`, `已拒绝:当前会话为只读,而 ${quoted(locale, fact.tool)} 可能修改文件。`);
241
+ case "read_only_remount_declined":
242
+ return pick(locale, `Denied: read-only mode blocks ${quoted(locale, fact.tool)} and the mounts were not remounted.`, `已拒绝:只读模式阻止 ${quoted(locale, fact.tool)},且挂载未被重新挂载。`);
243
+ case "project_destination": {
244
+ if (fact.mirror === undefined || fact.mirror === "") {
245
+ return pick(locale, "Project mounts cannot set a destination.", "项目挂载不能指定目标路径。");
246
+ }
247
+ const source = fact.source === undefined || fact.source === ""
248
+ ? pick(locale, "the project", "该项目")
249
+ : quoted(locale, fact.source);
250
+ return pick(locale, `Project mounts cannot set a destination; ${source} always mounts at ${quoted(locale, fact.mirror)}.`, `项目挂载不能指定目标路径;${source} 始终挂载到 ${quoted(locale, fact.mirror)}。`);
251
+ }
252
+ }
253
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { reasonLocale, renderCacheCleanNotice, renderDenial, renderReason, resolveReasonLocale, } from "./approval-reasons.js";
7
+ import { preExecutePolicy, summarizeArgs } from "./index.js";
8
+ import { testReadSession } from "./test-support.js";
9
+ test("reasonLocale normalizes a locale id to a shipped language", () => {
10
+ assert.equal(reasonLocale("zh"), "zh");
11
+ assert.equal(reasonLocale("zh-Hans"), "zh");
12
+ assert.equal(reasonLocale("zh_CN"), "zh");
13
+ assert.equal(reasonLocale("ZH"), "zh");
14
+ assert.equal(reasonLocale("en-US"), "en");
15
+ assert.equal(reasonLocale("fr"), "en");
16
+ assert.equal(reasonLocale(undefined), "en");
17
+ assert.equal(reasonLocale(42), "en");
18
+ });
19
+ test("resolveReasonLocale prefers the client-observed locale over the preference", () => {
20
+ const settings = (container, locale) => ({
21
+ get: (ns) => (ns === "podman" ? container : ns === "locale" ? locale : undefined),
22
+ });
23
+ assert.equal(resolveReasonLocale(undefined), "en");
24
+ assert.equal(resolveReasonLocale(settings({ uiLocale: "zh" }, { preference: "en" })), "zh");
25
+ assert.equal(resolveReasonLocale(settings({ uiLocale: "" }, { preference: "zh" })), "zh");
26
+ assert.equal(resolveReasonLocale(settings({}, { preference: "zh" })), "zh");
27
+ assert.equal(resolveReasonLocale(settings({ uiLocale: "en" }, { preference: "zh" })), "en");
28
+ assert.equal(resolveReasonLocale(settings({}, {})), "en");
29
+ });
30
+ test("summarizeArgs renders reasons in Chinese when asked", () => {
31
+ assert.equal(summarizeArgs("container_mount_add", {
32
+ container: "web",
33
+ kind: "volume",
34
+ volume: "valkey-data",
35
+ destination: "/data",
36
+ mode: "read_only",
37
+ }, undefined, "zh"), "在容器 “web” 中添加挂载:卷 “valkey-data” 挂载到 “/data”(只读)。");
38
+ assert.equal(summarizeArgs("container_start", {
39
+ container: "web",
40
+ image: "img",
41
+ mounts: [{ project: "team", mode: "read_only" }],
42
+ env: { A: "1" },
43
+ }, undefined, "zh"), "启动容器 “web”(镜像 “img”),挂载:项目 “team”(只读),环境变量:A。");
44
+ });
45
+ test("renderDenial renders both languages", () => {
46
+ assert.equal(renderDenial("en", { kind: "read_only", tool: "container_start" }), 'Denied: the session is read-only, but "container_start" needs write access.');
47
+ assert.equal(renderDenial("zh", { kind: "read_only", tool: "container_start" }), "已拒绝:当前会话为只读,而 “container_start” 需要写入权限。");
48
+ assert.equal(renderDenial("zh", {
49
+ kind: "project_destination",
50
+ source: "team/src",
51
+ mirror: "/projects/team/src",
52
+ }), "项目挂载不能指定目标路径;“team/src” 始终挂载到 “/projects/team/src”。");
53
+ assert.equal(renderDenial("zh", { kind: "project_destination" }), "项目挂载不能指定目标路径。");
54
+ assert.equal(renderDenial("en", { kind: "project_destination", mirror: "/projects" }), 'Project mounts cannot set a destination; the project always mounts at "/projects".');
55
+ assert.equal(renderDenial("zh", { kind: "project_destination", mirror: "/projects" }), "项目挂载不能指定目标路径;该项目 始终挂载到 “/projects”。");
56
+ });
57
+ test("renderReason covers the remaining reason kinds in both languages", () => {
58
+ assert.equal(renderReason("en", { kind: "image_rebuild_all" }), "Rebuild all images.");
59
+ assert.equal(renderReason("zh", { kind: "image_rebuild_all" }), "重建全部镜像。");
60
+ assert.equal(renderReason("zh", { kind: "daemon_start", container: "c", argv: ["x"], uid: 1 }), "在容器 “c” 中启动守护进程:x(uid 1)");
61
+ assert.equal(renderReason("zh", {
62
+ kind: "container_bash",
63
+ container: "c",
64
+ command: "id",
65
+ uid: 1,
66
+ groups: [3, 4],
67
+ }), "在容器 “c” 中运行 shell 命令:id(uid 1、groups 3、4)");
68
+ assert.equal(renderReason("zh", { kind: "container_secret_add", container: "c", secret: "s", env: "E" }), "将机密 “s” 作为 “E” 注入容器 “c”。");
69
+ assert.equal(renderReason("zh", {
70
+ kind: "container_mount_update",
71
+ container: "valkey-ctr",
72
+ mount: { kind: "volume", source: "valkey-data", destination: "/data", readOnly: false },
73
+ }), "更新容器 “valkey-ctr” 中的挂载:将卷 “valkey-data”(挂载到 “/data”)重新挂载为读写。");
74
+ assert.equal(renderReason("zh", {
75
+ kind: "container_mount_update",
76
+ container: "web",
77
+ mount: { kind: "project", source: "team", readOnly: true },
78
+ }), "更新容器 “web” 中的挂载:将项目 “team” 重新挂载为只读。");
79
+ });
80
+ test("renderCacheCleanNotice reports the removed file count", () => {
81
+ assert.equal(renderCacheCleanNotice("en", 1), "removed 1 cached file");
82
+ assert.equal(renderCacheCleanNotice("en", 3), "removed 3 cached files");
83
+ assert.equal(renderCacheCleanNotice("zh", 3), "已移除 3 个缓存文件");
84
+ });
85
+ test("renderReason renders the read-only remount plan", () => {
86
+ assert.equal(renderReason("en", {
87
+ kind: "read_only_remount",
88
+ tool: "bash",
89
+ remount: [
90
+ { kind: "project", source: "team", readOnly: false },
91
+ { kind: "volume", source: "data", destination: "/data", readOnly: false },
92
+ ],
93
+ keep: [{ kind: "tmpfs", source: "", destination: "/scratch" }],
94
+ }), 'Read-only mode blocks "bash" while a mount is read-write. Remount these mounts read-only to run it:\n\n- project "team" (read-write)\n- volume "data" at "/data" (read-write)\n\nKept as-is: tmpfs at "/scratch".');
95
+ assert.equal(renderReason("zh", {
96
+ kind: "read_only_remount",
97
+ tool: "bash",
98
+ remount: [{ kind: "project", source: "team", readOnly: false }],
99
+ keep: [],
100
+ }), "只读模式在存在读写挂载时会阻止 “bash”。将这些挂载重新挂载为只读即可运行:\n\n- 项目 “team”(读写)");
101
+ });
102
+ test("renderDenial renders the declined remount", () => {
103
+ assert.equal(renderDenial("en", { kind: "read_only_remount_declined", tool: "bash" }), 'Denied: read-only mode blocks "bash" and the mounts were not remounted.');
104
+ assert.equal(renderDenial("zh", { kind: "read_only_remount_declined", tool: "bash" }), "已拒绝:只读模式阻止 “bash”,且挂载未被重新挂载。");
105
+ });
106
+ test("preExecutePolicy renders a localized deny reason", async () => {
107
+ const denied = (await preExecutePolicy({ name: "container_start", agent: { session: { facts: { mode: "read-only" } } } }, () => Promise.resolve({ kind: "allow" }), undefined, () => "zh", testReadSession));
108
+ assert.equal(denied.kind, "deny");
109
+ assert.equal(denied.reason, "已拒绝:当前会话为只读,而 “container_start” 需要写入权限。");
110
+ });
111
+ test("preExecutePolicy renders a localized deny reason for a built-in tool", async () => {
112
+ const denied = (await preExecutePolicy({ name: "bash", agent: { session: { facts: { mode: "read-only" } } } }, () => Promise.resolve({ kind: "allow" }), undefined, () => "zh", testReadSession));
113
+ assert.equal(denied.kind, "deny");
114
+ assert.equal(denied.reason, "已拒绝:当前会话为只读,而 “bash” 可能修改文件。");
115
+ });
@@ -0,0 +1,29 @@
1
+ import { type ReadOnlyShellDecision } from "./read-only-shell.js";
2
+ import { type ReasonFact, type ReasonLocale } from "./approval-reasons.js";
3
+ export declare function reasonFact(name: string, args: Record<string, unknown>, sessionCwd?: unknown): ReasonFact | undefined;
4
+ export declare function summarizeArgs(name: string, args: Record<string, unknown>, sessionCwd?: unknown, locale?: ReasonLocale): string;
5
+ export declare function approvalDecision(name: string, args?: Record<string, unknown>, sessionCwd?: unknown, locale?: ReasonLocale): {
6
+ kind: "ask";
7
+ reason?: string;
8
+ } | undefined;
9
+ export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
10
+ export declare const PODMAN_OPS_PRESET = "podman-ops";
11
+ export declare const PODMAN_OPS_APPROVAL_TOOLS: ReadonlySet<string>;
12
+ export interface SessionFacts {
13
+ /** Effective sandbox mode ("read-only", "workspace-write", …). */
14
+ mode?: string;
15
+ /** Effective approval policy ("ask", "never", …). */
16
+ policy?: string;
17
+ /** The preset the session currently runs. */
18
+ preset?: string;
19
+ }
20
+ export declare function preExecutePolicy(exec: {
21
+ name: string;
22
+ arguments?: unknown;
23
+ agent?: {
24
+ session?: unknown;
25
+ };
26
+ }, next: () => Promise<unknown>, getProjectsRoot?: () => string, getLocale?: () => ReasonLocale, readSession?: (session: unknown) => SessionFacts, readOnlyShell?: (exec: unknown, locale: ReasonLocale) => Promise<ReadOnlyShellDecision>): Promise<unknown>;
27
+ export declare function mountDestinationsReason(name: string, args: Record<string, unknown> | undefined, projectsRoot: string | undefined, locale?: ReasonLocale): string | undefined;
28
+ export declare const SANDBOX_ESCALATION_REASON_PREFIX = "escalate sandbox to ";
29
+ export declare function isSandboxEscalation(request: unknown): boolean;