@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -5,13 +5,15 @@
5
5
  * .bashrc/.zshrc, which can be sourced before each command to provide a familiar
6
6
  * shell experience.
7
7
  */
8
- import * as fs from "node:fs";
8
+ import * as fs from "node:fs/promises";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { postmortem } from "@gajae-code/utils";
12
12
 
13
- const cachedSnapshotPaths = new Map<string, string>();
14
13
  const SNAPSHOT_TIMEOUT_MS = 2_000;
14
+ const SNAPSHOT_ROOT_PREFIX = "gjc-shell-snapshots-";
15
+ const PRIVATE_DIRECTORY_MODE = 0o700;
16
+ const PRIVATE_FILE_MODE = 0o600;
15
17
 
16
18
  function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
17
19
  const sanitized = { ...env };
@@ -23,8 +25,7 @@ function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<st
23
25
  /**
24
26
  * Get the user's shell config file path.
25
27
  */
26
- function getShellConfigFile(shell: string): string {
27
- const home = os.homedir();
28
+ function getShellConfigFile(shell: string, home: string): string {
28
29
  if (shell.includes("zsh")) return path.join(home, ".zshrc");
29
30
  if (shell.includes("bash")) return path.join(home, ".bashrc");
30
31
  return path.join(home, ".profile");
@@ -35,8 +36,7 @@ function getShellConfigFile(shell: string): string {
35
36
  * This script sources the user's rc file and extracts functions, aliases, and options.
36
37
  * Matches Anthropic Code's snapshot generation logic.
37
38
  */
38
- function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): string {
39
- const hasRcFile = fs.existsSync(rcFile);
39
+ function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string, hasRcFile: boolean): string {
40
40
  const isZsh = shell.includes("zsh");
41
41
  const commonToolsRegex =
42
42
  "^(ls|dir|vdir|cat|head|tail|less|more|grep|egrep|fgrep|rg|find|fd|locate|sed|awk|perl|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|pwd|readlink|stat|cut|sort|uniq|xargs|tee|tr|basename|dirname)$";
@@ -79,6 +79,7 @@ echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
79
79
  `;
80
80
 
81
81
  return `
82
+ umask 077
82
83
  SNAPSHOT_FILE='${escapedPath}'
83
84
 
84
85
  # Source user's rc file if it exists
@@ -114,74 +115,183 @@ fi
114
115
  `.trim();
115
116
  }
116
117
 
117
- /**
118
- * Create a shell snapshot, caching the result.
119
- * Returns the path to the snapshot file, or null if creation failed.
120
- */
121
- export async function getOrCreateSnapshot(
122
- shell: string,
123
- env: Record<string, string | undefined>,
124
- ): Promise<string | null> {
125
- const cacheKey = shell;
126
- // Return cached snapshot if valid
127
- const cached = cachedSnapshotPaths.get(cacheKey);
128
- if (cached && fs.existsSync(cached)) {
129
- return cached;
130
- }
131
- if (cached) {
132
- cachedSnapshotPaths.delete(cacheKey);
133
- }
118
+ interface ShellSnapshotCacheOptions {
119
+ tempRoot: string;
120
+ home: string | (() => string);
121
+ platform: NodeJS.Platform;
122
+ }
134
123
 
135
- // Skip on Windows (no .bashrc in standard location)
136
- if (process.platform === "win32") {
137
- return null;
124
+ function ownedByCurrentUser(uid: number): boolean {
125
+ return typeof process.getuid !== "function" || uid === process.getuid();
126
+ }
127
+
128
+ async function isTrustedSnapshot(snapshotPath: string): Promise<boolean> {
129
+ try {
130
+ const stat = await fs.lstat(snapshotPath);
131
+ return stat.isFile() && !stat.isSymbolicLink() && ownedByCurrentUser(stat.uid) && (stat.mode & 0o077) === 0;
132
+ } catch {
133
+ return false;
138
134
  }
135
+ }
139
136
 
140
- const rcFile = getShellConfigFile(shell);
137
+ function createShellSnapshotCache(options: ShellSnapshotCacheOptions) {
138
+ const cachedPaths = new Map<string, string>();
139
+ const inFlight = new Map<string, Promise<string | null>>();
140
+ let rootPath: string | null = null;
141
+ let rootInitialization: Promise<string> | null = null;
142
+ let cleanupAttempt: Promise<void> | null = null;
143
+ let cleaningUp = false;
144
+ let lifecycle = 0;
141
145
 
142
- // Create snapshot directory
143
- const snapshotDir = path.join(os.tmpdir(), "gjc-shell-snapshots");
144
- fs.mkdirSync(snapshotDir, { recursive: true });
146
+ async function initializeRoot(): Promise<string> {
147
+ const root = await fs.mkdtemp(path.join(options.tempRoot, SNAPSHOT_ROOT_PREFIX));
148
+ try {
149
+ await fs.chmod(root, PRIVATE_DIRECTORY_MODE);
150
+ const stat = await fs.lstat(root);
151
+ if (
152
+ !stat.isDirectory() ||
153
+ stat.isSymbolicLink() ||
154
+ !ownedByCurrentUser(stat.uid) ||
155
+ (stat.mode & 0o777) !== 0o700
156
+ ) {
157
+ throw new Error("Shell snapshot root is not owner-private");
158
+ }
159
+ return root;
160
+ } catch (error) {
161
+ await fs.rm(root, { recursive: true, force: true }).catch(() => {});
162
+ throw error;
163
+ }
164
+ }
165
+
166
+ async function getRoot(): Promise<string> {
167
+ if (rootPath) return rootPath;
168
+ if (rootInitialization) return await rootInitialization;
169
+ const attempt = initializeRoot();
170
+ rootInitialization = attempt;
171
+ try {
172
+ rootPath = await attempt;
173
+ return rootPath;
174
+ } catch (error) {
175
+ if (rootInitialization === attempt) rootInitialization = null;
176
+ throw error;
177
+ }
178
+ }
145
179
 
146
- // Generate unique snapshot path
147
- const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
148
- const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
180
+ async function createSnapshot(shell: string, env: Record<string, string | undefined>): Promise<string | null> {
181
+ const root = await getRoot();
182
+ const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
183
+ const snapshotPath = path.join(root, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
184
+ let keep = false;
185
+ try {
186
+ const handle = await fs.open(snapshotPath, "wx", PRIVATE_FILE_MODE);
187
+ try {
188
+ await handle.chmod(PRIVATE_FILE_MODE);
189
+ } finally {
190
+ await handle.close();
191
+ }
149
192
 
150
- // Generate and execute snapshot script
151
- const script = generateSnapshotScript(shell, snapshotPath, rcFile);
193
+ const home = typeof options.home === "function" ? options.home() : options.home;
194
+ const rcFile = getShellConfigFile(shell, home);
195
+ const script = generateSnapshotScript(shell, snapshotPath, rcFile, await Bun.file(rcFile).exists());
196
+ const snapshotEnv = sanitizeSnapshotEnv(env);
197
+ const spawnEnv: Record<string, string> = {};
198
+ for (const [key, value] of Object.entries(snapshotEnv)) {
199
+ if (value !== undefined) spawnEnv[key] = value;
200
+ }
201
+ const child = Bun.spawn([shell, "-c", script], {
202
+ env: spawnEnv,
203
+ stdin: "ignore",
204
+ stdout: "ignore",
205
+ stderr: "ignore",
206
+ timeout: SNAPSHOT_TIMEOUT_MS,
207
+ killSignal: "SIGKILL",
208
+ });
152
209
 
153
- try {
154
- const snapshotEnv = sanitizeSnapshotEnv(env);
155
- const spawnEnv: Record<string, string> = {};
156
- for (const [key, value] of Object.entries(snapshotEnv)) {
157
- if (value !== undefined) {
158
- spawnEnv[key] = value;
210
+ await child.exited;
211
+ if (child.exitCode === 0 && (await isTrustedSnapshot(snapshotPath))) {
212
+ cachedPaths.set(shell, snapshotPath);
213
+ keep = true;
214
+ return snapshotPath;
159
215
  }
216
+ } catch {
217
+ // Snapshot creation failed, proceed without it.
218
+ } finally {
219
+ if (!keep) await fs.unlink(snapshotPath).catch(() => {});
160
220
  }
161
- const child = Bun.spawn([shell, "-c", script], {
162
- env: spawnEnv,
163
- stdin: "ignore",
164
- stdout: "ignore",
165
- stderr: "ignore",
166
- timeout: SNAPSHOT_TIMEOUT_MS,
167
- killSignal: "SIGKILL",
168
- });
169
-
170
- await child.exited;
171
- if (child.exitCode === 0 && fs.existsSync(snapshotPath)) {
172
- cachedSnapshotPaths.set(cacheKey, snapshotPath);
173
- return snapshotPath;
174
- }
175
- } catch {
176
- // Snapshot creation failed, proceed without it
221
+ return null;
177
222
  }
178
223
 
179
- return null;
224
+ return {
225
+ async getOrCreateSnapshot(shell: string, env: Record<string, string | undefined>) {
226
+ if (options.platform === "win32" || cleaningUp) return null;
227
+ const currentLifecycle = lifecycle;
228
+ const cached = cachedPaths.get(shell);
229
+ if (cached && (await isTrustedSnapshot(cached))) {
230
+ return cleaningUp || lifecycle !== currentLifecycle ? null : cached;
231
+ }
232
+ if (cleaningUp || lifecycle !== currentLifecycle) return null;
233
+ if (cached) cachedPaths.delete(shell);
234
+ const pending = inFlight.get(shell);
235
+ if (pending) {
236
+ const snapshot = await pending;
237
+ return cleaningUp || lifecycle !== currentLifecycle ? null : snapshot;
238
+ }
239
+ const attempt = createSnapshot(shell, env);
240
+ inFlight.set(shell, attempt);
241
+ try {
242
+ const snapshot = await attempt;
243
+ return cleaningUp || lifecycle !== currentLifecycle ? null : snapshot;
244
+ } finally {
245
+ if (inFlight.get(shell) === attempt) inFlight.delete(shell);
246
+ }
247
+ },
248
+ async cleanup() {
249
+ if (cleanupAttempt) return await cleanupAttempt;
250
+ const attempt = (async () => {
251
+ cleaningUp = true;
252
+ lifecycle += 1;
253
+ try {
254
+ const pending: Promise<unknown>[] = [...inFlight.values()];
255
+ if (rootInitialization) pending.push(rootInitialization);
256
+ await Promise.allSettled(pending);
257
+ const root = rootPath ?? (await rootInitialization?.catch(() => null)) ?? null;
258
+ cachedPaths.clear();
259
+ inFlight.clear();
260
+ rootPath = null;
261
+ rootInitialization = null;
262
+ if (root) await fs.rm(root, { recursive: true, force: true });
263
+ } finally {
264
+ cleaningUp = false;
265
+ }
266
+ })();
267
+ cleanupAttempt = attempt;
268
+ try {
269
+ await attempt;
270
+ } finally {
271
+ if (cleanupAttempt === attempt) cleanupAttempt = null;
272
+ }
273
+ },
274
+ };
180
275
  }
181
276
 
182
- postmortem.register("shell-snapshot", () => {
183
- for (const snapshotPath of cachedSnapshotPaths.values()) {
184
- fs.unlinkSync(snapshotPath);
185
- }
186
- cachedSnapshotPaths.clear();
277
+ /** @internal Test-only factory for isolated filesystem and platform assertions. */
278
+ export const createShellSnapshotCacheForTesting = createShellSnapshotCache;
279
+
280
+ const processSnapshotCache = createShellSnapshotCache({
281
+ tempRoot: os.tmpdir(),
282
+ home: () => os.homedir(),
283
+ platform: process.platform,
187
284
  });
285
+
286
+ /**
287
+ * Create a shell snapshot, caching the result.
288
+ * Returns the path to the snapshot file, or null if creation failed.
289
+ */
290
+ export async function getOrCreateSnapshot(
291
+ shell: string,
292
+ env: Record<string, string | undefined>,
293
+ ): Promise<string | null> {
294
+ return await processSnapshotCache.getOrCreateSnapshot(shell, env);
295
+ }
296
+
297
+ postmortem.register("shell-snapshot", () => processSnapshotCache.cleanup());
@@ -1,4 +1,4 @@
1
- import { $env, $flag } from "@gajae-code/utils";
1
+ import { $pickenv, $pickflag } from "@gajae-code/utils";
2
2
 
3
3
  const SIXEL_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
4
4
  const SIXEL_END_SEQUENCE = "\x1b\\";
@@ -14,8 +14,8 @@ const SIXEL_PLACEHOLDER_PREFIX = "__GJC_SIXEL_SEQUENCE_";
14
14
  * - PI_ALLOW_SIXEL_PASSTHROUGH=1
15
15
  */
16
16
  export function isSixelPassthroughEnabled(): boolean {
17
- const forcedProtocol = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
18
- return forcedProtocol === "sixel" && $flag("PI_ALLOW_SIXEL_PASSTHROUGH");
17
+ const forcedProtocol = $pickenv("GJC_FORCE_IMAGE_PROTOCOL", "PI_FORCE_IMAGE_PROTOCOL")?.trim().toLowerCase();
18
+ return forcedProtocol === "sixel" && $pickflag("GJC_ALLOW_SIXEL_PASSTHROUGH", "PI_ALLOW_SIXEL_PASSTHROUGH");
19
19
  }
20
20
  /** Returns true when the text contains a SIXEL start sequence. */
21
21
  export function containsSixelSequence(text: string): boolean {
@@ -31,6 +31,7 @@ const KILL_GRACE_MS = 2_000;
31
31
 
32
32
  /** Stable note prefixes — tests assert on these without depending on full stderr. */
33
33
  export const INSANE_NOTES = {
34
+ securityDisabled: `insane fallback disabled: remote renderer cannot preserve validated network routing`,
34
35
  guardBlocked: (reason: string) => `insane fallback blocked: target URL is not public HTTP(S): ${reason}`,
35
36
  vendorMissing: `insane fallback unavailable: vendor engine missing at packages/coding-agent/vendor/insane-search`,
36
37
  noPython: `insane fallback unavailable: python3 not found; install python3 and curl_cffi, then retry with web.insaneFallback=true`,
@@ -325,8 +326,11 @@ function mapEngineOutput(raw: EngineRawOutput, timeoutMs: number): InsaneBridgeR
325
326
  * (which MUST run before this is called). Never throws; always returns a result.
326
327
  */
327
328
  export async function tryInsaneFetch(url: string, options: TryInsaneFetchOptions = {}): Promise<InsaneBridgeResult> {
329
+ if (!options.runner) {
330
+ return { ok: false, reason: "disabled-security", notes: [INSANE_NOTES.securityDisabled] };
331
+ }
328
332
  const prober = options.prober ?? probeInsaneDependencies;
329
- const runner = options.runner ?? (inv => runEngineSubprocess(inv));
333
+ const runner = options.runner;
330
334
  const limit = options.concurrencyLimit ?? DEFAULT_CONCURRENCY;
331
335
 
332
336
  const deps = await prober();
@@ -28,12 +28,36 @@ export type PublicUrlResult = PublicUrlAccepted | PublicUrlRejected;
28
28
  /** Resolver seam so tests can inject DNS results without real lookups. */
29
29
  export type AddressResolver = (hostname: string) => Promise<string[]>;
30
30
 
31
+ type ProxyEnvironment = Record<string, string | undefined>;
32
+
33
+ export type GuardedPublicFetchResult =
34
+ | { ok: true; response: Response; logicalUrl: URL; wireUrl: URL }
35
+ | { ok: false; reason: string; logicalUrl: string };
36
+
31
37
  const defaultResolver: AddressResolver = async hostname => {
32
38
  const records = await dns.lookup(hostname, { all: true, verbatim: true });
33
39
  return records.map(record => record.address);
34
40
  };
35
41
 
36
42
  const BLOCKED_HOSTNAMES = new Set(["localhost", "localhost.localdomain", "0.0.0.0", ""]);
43
+ const PROXY_ENV_KEYS = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] as const;
44
+
45
+ export function hasConfiguredProxy(env: ProxyEnvironment): boolean {
46
+ return PROXY_ENV_KEYS.some(key => Boolean(env[key]));
47
+ }
48
+
49
+ async function resolveWithSignal(resolver: AddressResolver, hostname: string, signal?: AbortSignal): Promise<string[]> {
50
+ if (!signal) return resolver(hostname);
51
+ if (signal.aborted) throw signal.reason;
52
+ const { promise, reject } = Promise.withResolvers<never>();
53
+ const onAbort = () => reject(signal.reason);
54
+ signal.addEventListener("abort", onAbort, { once: true });
55
+ try {
56
+ return await Promise.race([resolver(hostname), promise]);
57
+ } finally {
58
+ signal.removeEventListener("abort", onAbort);
59
+ }
60
+ }
37
61
 
38
62
  function isBlockedHostname(hostname: string): boolean {
39
63
  const normalized = hostname.toLowerCase().replace(/\.$/, "");
@@ -67,37 +91,81 @@ function isPrivateIPv4(address: string): boolean {
67
91
  );
68
92
  }
69
93
 
70
- function normalizeIPv4MappedIPv6(address: string): string {
71
- return address.toLowerCase().startsWith("::ffff:") ? address.slice(7) : address;
94
+ function parseIPv6Bytes(address: string): Uint8Array | undefined {
95
+ let normalized = address.toLowerCase();
96
+ const ipv4Tail = normalized.slice(normalized.lastIndexOf(":") + 1);
97
+ if (ipv4Tail.includes(".")) {
98
+ const parts = ipv4Tail.split(".").map(part => Number.parseInt(part, 10));
99
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return;
100
+ normalized = `${normalized.slice(0, normalized.lastIndexOf(":") + 1)}${((parts[0] << 8) | parts[1]).toString(16)}:${((parts[2] << 8) | parts[3]).toString(16)}`;
101
+ }
102
+
103
+ const halves = normalized.split("::");
104
+ if (halves.length > 2) return;
105
+ const left = halves[0] ? halves[0].split(":") : [];
106
+ const right = halves[1] ? halves[1].split(":") : [];
107
+ const omitted = 8 - left.length - right.length;
108
+ if ((halves.length === 1 && omitted !== 0) || (halves.length === 2 && omitted < 1)) return;
109
+ const words = [...left, ...Array.from({ length: omitted }, () => "0"), ...right];
110
+ if (words.length !== 8 || words.some(word => !/^[0-9a-f]{1,4}$/.test(word))) return;
111
+
112
+ const bytes = new Uint8Array(16);
113
+ for (let index = 0; index < words.length; index++) {
114
+ const word = Number.parseInt(words[index], 16);
115
+ bytes[index * 2] = word >> 8;
116
+ bytes[index * 2 + 1] = word & 0xff;
117
+ }
118
+ return bytes;
119
+ }
120
+
121
+ interface IPv6Cidr {
122
+ network: Uint8Array;
123
+ prefixLength: number;
124
+ }
125
+
126
+ function ipv6Cidr(network: string, prefixLength: number): IPv6Cidr {
127
+ const bytes = parseIPv6Bytes(network);
128
+ if (!bytes) throw new Error(`Invalid static IPv6 CIDR: ${network}/${prefixLength}`);
129
+ return { network: bytes, prefixLength };
72
130
  }
73
131
 
132
+ function matchesIPv6Cidr(address: Uint8Array, cidr: IPv6Cidr): boolean {
133
+ const wholeBytes = Math.floor(cidr.prefixLength / 8);
134
+ for (let index = 0; index < wholeBytes; index++) {
135
+ if (address[index] !== cidr.network[index]) return false;
136
+ }
137
+ const remainingBits = cidr.prefixLength % 8;
138
+ if (remainingBits === 0) return true;
139
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
140
+ return (address[wholeBytes] & mask) === (cidr.network[wholeBytes] & mask);
141
+ }
142
+
143
+ const IPV6_GLOBAL_UNICAST = ipv6Cidr("2000::", 3);
144
+ const BLOCKED_GLOBAL_IPV6_RANGES = [
145
+ ipv6Cidr("2001::", 23), // IETF protocol assignments
146
+ ipv6Cidr("2001:db8::", 32), // documentation
147
+ ipv6Cidr("2002::", 16), // 6to4
148
+ ipv6Cidr("2620:4f:8000::", 48), // AS112 service
149
+ ipv6Cidr("3ffe::", 16), // returned 6bone range
150
+ ipv6Cidr("3fff::", 20), // documentation
151
+ ] as const;
152
+
74
153
  function isPrivateIPv6(address: string): boolean {
75
- const normalized = address.toLowerCase();
76
- const mapped = normalizeIPv4MappedIPv6(normalized);
77
- if (mapped !== normalized && net.isIP(mapped) === 4) return isPrivateIPv4(mapped);
154
+ const bytes = parseIPv6Bytes(address);
155
+ if (!bytes) return true;
156
+ const isIPv4Mapped = bytes.subarray(0, 10).every(byte => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
157
+ if (isIPv4Mapped) return isPrivateIPv4(`${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`);
78
158
  return (
79
- normalized === "::" || // unspecified
80
- normalized === "::1" || // loopback
81
- normalized.startsWith("fc") || // ULA fc00::/7
82
- normalized.startsWith("fd") || // ULA
83
- normalized.startsWith("fe8") || // link-local fe80::/10
84
- normalized.startsWith("fe9") ||
85
- normalized.startsWith("fea") ||
86
- normalized.startsWith("feb") ||
87
- normalized.startsWith("ff") || // multicast ff00::/8
88
- normalized.startsWith("2001:db8") || // documentation
89
- normalized.startsWith("::ffff:") // any remaining IPv4-mapped form we could not classify
159
+ !matchesIPv6Cidr(bytes, IPV6_GLOBAL_UNICAST) ||
160
+ BLOCKED_GLOBAL_IPV6_RANGES.some(cidr => matchesIPv6Cidr(bytes, cidr))
90
161
  );
91
162
  }
92
163
 
93
164
  /** True for any address that is not a routable public unicast address. */
94
165
  export function isPrivateOrSpecialAddress(address: string): boolean {
95
- const normalized = normalizeIPv4MappedIPv6(address);
96
- const family = net.isIP(normalized);
97
- if (family === 4) return isPrivateIPv4(normalized);
98
- if (family === 6) return isPrivateIPv6(normalized);
99
- // Re-check the raw value in case it was an IPv4-mapped IPv6 literal.
100
- if (net.isIP(address) === 6) return isPrivateIPv6(address);
166
+ const family = net.isIP(address);
167
+ if (family === 4) return isPrivateIPv4(address);
168
+ if (family === 6) return isPrivateIPv6(address);
101
169
  return true; // not a recognizable IP -> treat as unsafe
102
170
  }
103
171
 
@@ -108,7 +176,7 @@ export function isPrivateOrSpecialAddress(address: string): boolean {
108
176
  */
109
177
  export async function validatePublicHttpUrl(
110
178
  rawUrl: string,
111
- options: { resolver?: AddressResolver } = {},
179
+ options: { resolver?: AddressResolver; signal?: AbortSignal } = {},
112
180
  ): Promise<PublicUrlResult> {
113
181
  const resolver = options.resolver ?? defaultResolver;
114
182
 
@@ -128,18 +196,20 @@ export async function validatePublicHttpUrl(
128
196
  return { ok: false, reason: "localhost or internal host" };
129
197
  }
130
198
 
131
- const literalFamily = net.isIP(url.hostname);
199
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
200
+ const literalFamily = net.isIP(hostname);
132
201
  if (literalFamily !== 0) {
133
- if (isPrivateOrSpecialAddress(url.hostname)) {
202
+ if (isPrivateOrSpecialAddress(hostname)) {
134
203
  return { ok: false, reason: "private, loopback, link-local, or reserved IP literal" };
135
204
  }
136
- return { ok: true, url, addresses: [url.hostname] };
205
+ return { ok: true, url, addresses: [hostname] };
137
206
  }
138
207
 
139
208
  let addresses: string[];
140
209
  try {
141
- addresses = await resolver(url.hostname);
210
+ addresses = await resolveWithSignal(resolver, hostname, options.signal);
142
211
  } catch {
212
+ if (options.signal?.aborted) return { ok: false, reason: "host resolution aborted" };
143
213
  return { ok: false, reason: "host could not be resolved" };
144
214
  }
145
215
  if (addresses.length === 0) {
@@ -151,6 +221,49 @@ export async function validatePublicHttpUrl(
151
221
  return { ok: true, url, addresses };
152
222
  }
153
223
 
224
+ export async function guardedPublicFetch(
225
+ rawUrl: string,
226
+ init: BunFetchRequestInit = {},
227
+ options: { resolver?: AddressResolver } = {},
228
+ ): Promise<GuardedPublicFetchResult> {
229
+ if (Object.hasOwn(init, "proxy") || Object.hasOwn(init, "unix") || hasConfiguredProxy(process.env)) {
230
+ return { ok: false, reason: "proxy or Unix-socket routing is not allowed", logicalUrl: rawUrl };
231
+ }
232
+
233
+ const signal = init.signal ?? undefined;
234
+ const guard = await validatePublicHttpUrl(rawUrl, { resolver: options.resolver, signal });
235
+ if (signal?.aborted) throw signal.reason;
236
+ if (!guard.ok) return { ok: false, reason: guard.reason, logicalUrl: rawUrl };
237
+
238
+ const logicalUrl = guard.url;
239
+ const headers = new Headers(init.headers);
240
+ headers.delete("host");
241
+ headers.set("Host", logicalUrl.host);
242
+ const hostname = logicalUrl.hostname.replace(/^\[|\]$/g, "");
243
+ const tls =
244
+ logicalUrl.protocol === "https:"
245
+ ? { rejectUnauthorized: true, ...(net.isIP(hostname) === 0 ? { serverName: hostname } : {}) }
246
+ : undefined;
247
+ const method = (init.method ?? "GET").toUpperCase();
248
+ const addresses = method === "GET" || method === "HEAD" ? guard.addresses : guard.addresses.slice(0, 1);
249
+ let lastError: unknown;
250
+ for (const address of addresses) {
251
+ if (hasConfiguredProxy(process.env)) {
252
+ return { ok: false, reason: "proxy routing appeared during resolution", logicalUrl: rawUrl };
253
+ }
254
+ const wireUrl = new URL(logicalUrl);
255
+ wireUrl.hostname = net.isIP(address) === 6 ? `[${address}]` : address;
256
+ try {
257
+ const response = await fetch(wireUrl, { ...init, headers, redirect: "manual", keepalive: false, tls });
258
+ return { ok: true, response, logicalUrl, wireUrl };
259
+ } catch (error) {
260
+ if (signal?.aborted) throw signal.reason;
261
+ lastError = error;
262
+ }
263
+ }
264
+ throw lastError;
265
+ }
266
+
154
267
  export async function validatePublicHttpUrlForInsane(
155
268
  rawUrl: string,
156
269
  options: { resolver?: AddressResolver } = {},
@@ -299,7 +299,21 @@ async function readCachedRustdocCrate(
299
299
  ): Promise<{ crate: RustdocCrate; fetchedAt: string } | null> {
300
300
  const cachePath = getDocsRsCachePath(target);
301
301
  try {
302
- const [jsonStr, stat] = await Promise.all([Bun.file(cachePath).text(), fs.stat(cachePath)]);
302
+ const stat = await fs.stat(cachePath);
303
+ if (stat.size > MAX_BYTES) {
304
+ await fs.rm(cachePath, { force: true });
305
+ return null;
306
+ }
307
+
308
+ const cacheBytes = await Bun.file(cachePath)
309
+ .slice(0, MAX_BYTES + 1)
310
+ .arrayBuffer();
311
+ if (cacheBytes.byteLength > MAX_BYTES) {
312
+ await fs.rm(cachePath, { force: true });
313
+ return null;
314
+ }
315
+
316
+ const jsonStr = Buffer.from(cacheBytes).toString("utf-8");
303
317
  const crate = tryParseJson<RustdocCrate>(jsonStr);
304
318
  if (!crate?.index) return null;
305
319
  return { crate, fetchedAt: stat.mtime.toISOString() };
@@ -378,11 +392,22 @@ export const handleDocsRs: SpecialHandler = async (
378
392
  const requestSignal = ptree.combineSignals(signal, timeout * 1000);
379
393
  const response = await fetch(jsonUrl, {
380
394
  signal: requestSignal,
381
- headers: { "User-Agent": "gjc-web-fetch/1.0", Accept: "application/gzip" },
395
+ headers: {
396
+ "User-Agent": "gjc-web-fetch/1.0",
397
+ Accept: "application/gzip",
398
+ "Accept-Encoding": "identity",
399
+ },
382
400
  redirect: "follow",
401
+ decompress: false,
383
402
  });
384
403
  if (!response.ok) return null;
385
404
 
405
+ const contentEncoding = response.headers.get("Content-Encoding");
406
+ if (contentEncoding && contentEncoding.trim().toLowerCase() !== "identity") {
407
+ await response.body?.cancel();
408
+ return null;
409
+ }
410
+
386
411
  const reader = response.body?.getReader();
387
412
  if (!reader) return null;
388
413
 
@@ -394,13 +419,13 @@ export const handleDocsRs: SpecialHandler = async (
394
419
  chunks.push(value);
395
420
  totalSize += value.length;
396
421
  if (totalSize > MAX_BYTES) {
397
- reader.cancel();
398
- break;
422
+ await reader.cancel();
423
+ return null;
399
424
  }
400
425
  }
401
426
 
402
427
  const compressed = Buffer.concat(chunks);
403
- const jsonStr = gunzipSync(compressed).toString("utf-8");
428
+ const jsonStr = gunzipSync(compressed, { maxOutputLength: MAX_BYTES }).toString("utf-8");
404
429
  crate_ = tryParseJson<RustdocCrate>(jsonStr);
405
430
  if (crate_?.index) {
406
431
  await writeCachedRustdocCrate(target, jsonStr);