@oar-sh/oar 0.9.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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,66 @@
1
+ import path from 'node:path';
2
+
3
+ import { normalizeDriveLetterOnlyPath } from './workspace-root-path-policy.mjs';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Windows drive path helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export function normalizeDriveAbsolutePath(rawPath) {
10
+ const value = String(rawPath || '').trim();
11
+ if (!value) return '';
12
+ let decoded = value;
13
+ try {
14
+ decoded = decodeURIComponent(value);
15
+ } catch {
16
+ return '';
17
+ }
18
+ const withoutNulls = decoded.replace(/\0/g, '');
19
+ let normalized = withoutNulls
20
+ .replace(/\//g, '\\')
21
+ .replace(/\\+/g, '\\')
22
+ .trim();
23
+ if (!normalized) return '';
24
+ normalized = normalizeDriveLetterOnlyPath(normalized);
25
+ if (!/^[A-Za-z]:\\/.test(normalized)) return '';
26
+ normalized = path.win32.normalize(normalized);
27
+ if (!/^[A-Za-z]:\\/.test(normalized)) return '';
28
+ const drive = `${normalized.slice(0, 1).toUpperCase()}:`;
29
+ const rest = normalized.slice(3).replace(/^\\+/, '');
30
+ return rest ? `${drive}\\${rest}` : `${drive}\\`;
31
+ }
32
+
33
+ export function driveRootFromAbsolutePath(absolutePath) {
34
+ const normalized = normalizeDriveAbsolutePath(absolutePath);
35
+ if (!normalized) return '';
36
+ return `${normalized.slice(0, 1).toUpperCase()}:\\`;
37
+ }
38
+
39
+ export function toDriveWebPath(absolutePath) {
40
+ const normalized = normalizeDriveAbsolutePath(absolutePath);
41
+ if (!normalized) return '';
42
+ const drive = `${normalized.slice(0, 1).toUpperCase()}:`;
43
+ const rest = normalized.slice(3).replace(/\\/g, '/');
44
+ return rest ? `${drive}/${rest}` : drive;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Linux absolute path helper
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export function normalizeLinuxAbsolutePath(rawPath) {
52
+ const value = String(rawPath || '').trim();
53
+ if (!value) return '';
54
+ let decoded = value;
55
+ try {
56
+ decoded = decodeURIComponent(value);
57
+ } catch {
58
+ return '';
59
+ }
60
+ const withoutNulls = decoded.replace(/\0/g, '');
61
+ // path.posix.normalize resolves any '..' segments; we only need to ensure
62
+ // the result is absolute (i.e. still under '/').
63
+ const normalized = path.posix.normalize(withoutNulls.replace(/\\/g, '/'));
64
+ if (!normalized.startsWith('/')) return '';
65
+ return normalized;
66
+ }
@@ -0,0 +1,62 @@
1
+ #!/bin/sh
2
+ # Stand-in for the `claude` CLI auth subcommands, injected through
3
+ # COPILOT_WEB_RELAY_CLAUDE_AUTH_BIN so the auth service can be exercised without
4
+ # touching the host's real Claude login.
5
+ #
6
+ # Mirrors the shapes observed on CLI 2.1.247:
7
+ # auth status --json -> one line of JSON
8
+ # auth login -> browser notice, an OSC-8 + SGR wrapped authorize URL,
9
+ # then `Paste code here if prompted > ` and a blocking read
10
+ # auth logout -> removes the credentials file
11
+ #
12
+ # Fake credentials file path comes from CLAUDE_AUTH_STUB_CRED_FILE; "logged in"
13
+ # means that file exists.
14
+
15
+ CRED_FILE="${CLAUDE_AUTH_STUB_CRED_FILE:-}"
16
+ AUTH_URL="${CLAUDE_AUTH_STUB_URL:-https://claude.com/oauth/authorize?code=true&client_id=stub-client&code_challenge=Xk3nQ7pLm2vB8sT1wY6zR0aC5dF9gH4jK7lN2oP3qS8&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback}"
17
+
18
+ command="$1 $2"
19
+
20
+ case "$command" in
21
+ "auth status")
22
+ if [ -n "$CRED_FILE" ] && [ -f "$CRED_FILE" ]; then
23
+ printf '{"loggedIn":true,"authMethod":"claudeai","apiProvider":null,"email":"stub@example.com","orgId":"org_stub","orgName":"Stub Org","subscriptionType":"max"}\n'
24
+ else
25
+ printf '{"loggedIn":false,"authMethod":null,"apiProvider":null,"email":null,"orgId":null,"orgName":null,"subscriptionType":null}\n'
26
+ fi
27
+ exit 0
28
+ ;;
29
+ "auth login")
30
+ printf 'Opening browser to sign in…\n'
31
+ # OSC-8 hyperlink (target + visible label) wrapped in SGR colour codes, the
32
+ # exact shape the real CLI emits under a PTY.
33
+ printf 'If the browser did not open, visit: \033]8;;%s\033\\\033[4;34m%s\033[0m\033]8;;\033\\\n' "$AUTH_URL" "$AUTH_URL"
34
+ printf '\033[2mPaste code here if prompted > \033[0m'
35
+ if ! read -r submitted_code; then
36
+ printf '\nAborted.\n'
37
+ exit 1
38
+ fi
39
+ if [ "$submitted_code" = "goodcode" ]; then
40
+ if [ -n "$CRED_FILE" ]; then
41
+ # Deliberately short fake tokens: anything with 20+ characters after
42
+ # `sk-ant-` trips the secret scanner in server/test-hygiene.test.mjs.
43
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-stub","refreshToken":"sk-ant-ort01-stub"}}\n' > "$CRED_FILE"
44
+ fi
45
+ printf '\nLogin successful. Logged in as stub@example.com\n'
46
+ exit 0
47
+ fi
48
+ printf '\nOAuth error: invalid_grant - the authorization code is invalid or has expired.\n' >&2
49
+ exit 1
50
+ ;;
51
+ "auth logout")
52
+ if [ -n "$CRED_FILE" ]; then
53
+ rm -f "$CRED_FILE"
54
+ fi
55
+ printf 'Logged out.\n'
56
+ exit 0
57
+ ;;
58
+ *)
59
+ printf 'claude-auth-stub: unsupported command: %s\n' "$*" >&2
60
+ exit 2
61
+ ;;
62
+ esac
@@ -0,0 +1,75 @@
1
+ #!/bin/sh
2
+ # Stand-in for a vendor CLI installer, injected through
3
+ # COPILOT_WEB_RELAY_CLI_INSTALL_COMMAND so the install flow can be exercised end
4
+ # to end without ever fetching from x.ai / claude.ai or writing to the host's
5
+ # real bin directories.
6
+ #
7
+ # The install service calls it as: cli-install-stub.sh <provider> <action>
8
+ # (both values come from the frozen descriptor table, never from a request
9
+ # body). It prints installer-shaped progress, then writes a fake binary into
10
+ # CLI_INSTALL_STUB_BIN_DIR — which the relay is pointed at through
11
+ # COPILOT_WEB_RELAY_CLI_BIN_DIR — so the resolve -> bind -> broadcast chain runs
12
+ # against something real.
13
+ #
14
+ # CLI_INSTALL_STUB_BIN_DIR where the fake binary lands (required)
15
+ # CLI_INSTALL_STUB_VERSION version the fake binary reports (default 9.9.9)
16
+ # CLI_INSTALL_STUB_FAIL non-empty -> print an error and exit 1
17
+ # CLI_INSTALL_STUB_SLEEP seconds to stall mid-install (cancel/timeout tests)
18
+
19
+ provider="$1"
20
+ action="$2"
21
+ bin_dir="${CLI_INSTALL_STUB_BIN_DIR:-}"
22
+ version="${CLI_INSTALL_STUB_VERSION:-9.9.9}"
23
+
24
+ printf 'Installing %s (%s)…\n' "$provider" "$action"
25
+ printf 'Resolving latest release for linux-x86_64\n'
26
+
27
+ if [ -n "${CLI_INSTALL_STUB_SLEEP:-}" ]; then
28
+ sleep "$CLI_INSTALL_STUB_SLEEP"
29
+ fi
30
+
31
+ if [ -n "${CLI_INSTALL_STUB_FAIL:-}" ]; then
32
+ printf 'error: download failed (stub)\n' >&2
33
+ exit 1
34
+ fi
35
+
36
+ if [ -z "$bin_dir" ]; then
37
+ printf 'cli-install-stub: CLI_INSTALL_STUB_BIN_DIR is not set\n' >&2
38
+ exit 2
39
+ fi
40
+
41
+ mkdir -p "$bin_dir"
42
+ target="$bin_dir/$provider"
43
+
44
+ # The fake binary answers exactly the probes cli-install-service runs: the
45
+ # version banner in each descriptor's shape, `update --check --json` for Grok,
46
+ # and `doctor` for Claude.
47
+ cat > "$target" <<STUB
48
+ #!/bin/sh
49
+ case "\$1 \$2" in
50
+ "--version ")
51
+ case "$provider" in
52
+ grok) printf 'grok %s (stubcommit) [stable]\n' "$version" ;;
53
+ claude) printf '%s (Claude Code)\n' "$version" ;;
54
+ *) printf '%s\n' "$version" ;;
55
+ esac
56
+ exit 0
57
+ ;;
58
+ "update --check")
59
+ printf '{"currentVersion":"%s","latestVersion":"%s","updateAvailable":false,"installer":"internal","channel":"stable","autoUpdate":null,"error":null}\n' "$version" "$version"
60
+ exit 0
61
+ ;;
62
+ "doctor ")
63
+ printf 'Running: native (%s)\n' "$version"
64
+ printf 'Path: %s\n' "$target"
65
+ printf 'Auto-updates: enabled\n'
66
+ exit 0
67
+ ;;
68
+ esac
69
+ printf 'cli-stub: unsupported command: %s\n' "\$*" >&2
70
+ exit 2
71
+ STUB
72
+ chmod +x "$target"
73
+
74
+ printf 'Installed %s %s to %s\n' "$provider" "$version" "$target"
75
+ exit 0
@@ -0,0 +1,86 @@
1
+ #!/bin/sh
2
+ # Stand-in for the `grok` CLI, injected through GROK_CLI_COMMAND so the auth
3
+ # service (and the CLI install/probe path) can be exercised without touching the
4
+ # host's real Grok login.
5
+ #
6
+ # Mirrors the shapes probed live on Grok Build 1.0.13 (2026-08-31):
7
+ # --version -> `grok 1.0.13 (5e9a58528b76)`
8
+ # update --check --json-> the machine-readable update payload
9
+ # login --device-auth -> a device URL carrying the code, the code again on its
10
+ # own line, one grey (SGR 90) warning line, then
11
+ # `Waiting for authorization...` and a poll loop. No
12
+ # TTY, no stdin read: the real CLI ignores stdin and
13
+ # exits by itself once the browser authorises.
14
+ # logout -> removes the fake auth.json
15
+ #
16
+ # The poll loop stands in for the browser: it exits 0 when GROK_STUB_SUCCESS_SENTINEL
17
+ # appears (writing the fake auth store) and non-zero when GROK_STUB_FAILURE_SENTINEL
18
+ # does. "Logged in" means GROK_STUB_AUTH_FILE exists, matching what
19
+ # readGrokCliAuthKey() actually looks at.
20
+
21
+ set -u
22
+
23
+ VERSION="${GROK_STUB_VERSION:-1.0.13}"
24
+ AUTH_FILE="${GROK_STUB_AUTH_FILE:-}"
25
+ DEVICE_CODE="${GROK_STUB_DEVICE_CODE:-D7SV-M4TR}"
26
+ DEVICE_URL="${GROK_STUB_DEVICE_URL:-https://accounts.x.ai/oauth2/device?user_code=${DEVICE_CODE}}"
27
+ SUCCESS_SENTINEL="${GROK_STUB_SUCCESS_SENTINEL:-}"
28
+ FAILURE_SENTINEL="${GROK_STUB_FAILURE_SENTINEL:-}"
29
+ TIMEOUT_SECONDS="${GROK_STUB_TIMEOUT_SECONDS:-60}"
30
+ POLL_SECONDS="${GROK_STUB_POLL_SECONDS:-0.2}"
31
+ UPDATE_AVAILABLE="${GROK_STUB_UPDATE_AVAILABLE:-false}"
32
+ LATEST_VERSION="${GROK_STUB_LATEST_VERSION:-$VERSION}"
33
+
34
+ command="${1:-} ${2:-}"
35
+
36
+ case "$command" in
37
+ "--version "*|"--version")
38
+ printf 'grok %s (5e9a58528b76)\n' "$VERSION"
39
+ exit 0
40
+ ;;
41
+ "update --check")
42
+ printf '{"currentVersion":"%s","latestVersion":"%s","updateAvailable":%s,"installer":"internal","channel":"stable","autoUpdate":null,"error":null}\n' \
43
+ "$VERSION" "$LATEST_VERSION" "$UPDATE_AVAILABLE"
44
+ exit 0
45
+ ;;
46
+ "login --device-auth")
47
+ # Byte-for-byte the banner the real CLI printed under piped stdio, including
48
+ # the blank lines and the one grey warning line — the escape-stripping in the
49
+ # URL/code scrape is tested against exactly this.
50
+ printf '\nTo sign in, open this URL in your browser:\n\n %s\n\n' "$DEVICE_URL"
51
+ printf ' (Could not open browser automatically — open the URL above manually.)\n\n'
52
+ printf 'Confirm this code in your browser:\n\n %s\n\n' "$DEVICE_CODE"
53
+ printf '\033[90mOnly continue with a code you requested. Do not share it with anyone.\033[0m\n\n'
54
+ printf 'Waiting for authorization...\n'
55
+ deadline=$(( $(date +%s) + TIMEOUT_SECONDS ))
56
+ while [ "$(date +%s)" -le "$deadline" ]; do
57
+ if [ -n "$FAILURE_SENTINEL" ] && [ -f "$FAILURE_SENTINEL" ]; then
58
+ printf '\nAuthorization failed: the device code expired.\n' >&2
59
+ exit 1
60
+ fi
61
+ if [ -n "$SUCCESS_SENTINEL" ] && [ -f "$SUCCESS_SENTINEL" ]; then
62
+ if [ -n "$AUTH_FILE" ]; then
63
+ # Deliberately short fake key: anything 32+ characters long trips the
64
+ # secret scanner in server/test-hygiene.test.mjs.
65
+ printf '{"https://auth.x.ai::stub":{"key":"grok-stub-key","create_time":"2026-08-31T00:00:00Z","expires_at":"2099-01-01T00:00:00Z"}}\n' > "$AUTH_FILE"
66
+ fi
67
+ printf '\nSigned in.\n'
68
+ exit 0
69
+ fi
70
+ sleep "$POLL_SECONDS"
71
+ done
72
+ printf '\nTimed out waiting for authorization.\n' >&2
73
+ exit 1
74
+ ;;
75
+ "logout "*|"logout")
76
+ if [ -n "$AUTH_FILE" ]; then
77
+ rm -f "$AUTH_FILE"
78
+ fi
79
+ printf 'Signed out.\n'
80
+ exit 0
81
+ ;;
82
+ *)
83
+ printf 'grok-stub: unsupported command: %s\n' "$*" >&2
84
+ exit 2
85
+ ;;
86
+ esac
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ function normalizeStorageSegment(value, fallback = 'item') {
7
+ const text = String(value || '').trim();
8
+ const compact = text.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
9
+ return compact.slice(0, 96) || fallback;
10
+ }
11
+
12
+ function parseMessageAttachments(rawAttachments, { parseAttachments, hydrateAttachment } = {}) {
13
+ let attachments = [];
14
+ if (typeof parseAttachments === 'function') {
15
+ attachments = parseAttachments(rawAttachments);
16
+ } else {
17
+ try {
18
+ attachments = JSON.parse(rawAttachments || '[]');
19
+ } catch {
20
+ attachments = [];
21
+ }
22
+ }
23
+ if (!Array.isArray(attachments)) return [];
24
+ if (typeof hydrateAttachment !== 'function') return attachments.filter(Boolean);
25
+ return attachments.map((entry) => hydrateAttachment(entry)).filter(Boolean);
26
+ }
27
+
28
+ function resolveGeneratedImagePath({
29
+ root,
30
+ sessionId,
31
+ relativePath,
32
+ } = {}) {
33
+ const normalizedRoot = String(root || '').trim();
34
+ const safeSession = normalizeStorageSegment(sessionId || '', '');
35
+ const rawRelative = String(relativePath || '').replace(/\\/g, '/').trim();
36
+ if (!normalizedRoot || !safeSession || !rawRelative) return null;
37
+ const normalizedRelative = path.posix.normalize(rawRelative).replace(/^\/+/, '');
38
+ if (!normalizedRelative || normalizedRelative.startsWith('..') || normalizedRelative.includes('/../')) return null;
39
+ const baseDir = path.resolve(path.join(normalizedRoot, safeSession, 'generated-images'));
40
+ const candidate = path.resolve(path.join(baseDir, normalizedRelative));
41
+ if (!(candidate === baseDir || candidate.startsWith(`${baseDir}${path.sep}`))) return null;
42
+ return candidate;
43
+ }
44
+
45
+ function resolveGeneratedImageConversationDir({
46
+ root,
47
+ sessionId,
48
+ conversationId,
49
+ } = {}) {
50
+ const normalizedRoot = String(root || '').trim();
51
+ const safeSession = normalizeStorageSegment(sessionId || '', '');
52
+ const safeConversation = normalizeStorageSegment(conversationId || '', '');
53
+ if (!normalizedRoot || !safeSession || !safeConversation) return null;
54
+ const baseDir = path.resolve(path.join(normalizedRoot, safeSession, 'generated-images'));
55
+ const candidate = path.resolve(path.join(baseDir, safeConversation));
56
+ if (!(candidate === baseDir || candidate.startsWith(`${baseDir}${path.sep}`))) return null;
57
+ return candidate;
58
+ }
59
+
60
+ export function cleanupGeneratedImagesForConversation({
61
+ conversationId,
62
+ sdkSessionId = null,
63
+ messageRows = [],
64
+ parseAttachments = null,
65
+ hydrateAttachment = null,
66
+ resolveSessionStateRoot = null,
67
+ } = {}) {
68
+ if (typeof resolveSessionStateRoot !== 'function') return;
69
+ const root = String(resolveSessionStateRoot() || '').trim();
70
+ const normalizedConversationId = String(conversationId || '').trim();
71
+ if (!root || !normalizedConversationId) return;
72
+
73
+ const candidateSessionIds = new Set();
74
+ const filesToDelete = new Set();
75
+ const normalizedSdkSessionId = String(sdkSessionId || '').trim();
76
+ if (normalizedSdkSessionId) candidateSessionIds.add(normalizedSdkSessionId);
77
+ candidateSessionIds.add(normalizedConversationId);
78
+
79
+ for (const row of Array.isArray(messageRows) ? messageRows : []) {
80
+ const attachments = parseMessageAttachments(row?.attachments, { parseAttachments, hydrateAttachment });
81
+ for (const attachment of attachments) {
82
+ const generatedImage = attachment?.generatedImage;
83
+ if (!generatedImage || typeof generatedImage !== 'object') continue;
84
+ const sessionId = String(generatedImage.sessionId || '').trim();
85
+ const relativePath = String(generatedImage.relativePath || '').trim();
86
+ if (sessionId) candidateSessionIds.add(sessionId);
87
+ const filePath = resolveGeneratedImagePath({
88
+ root,
89
+ sessionId: sessionId || normalizedSdkSessionId || normalizedConversationId,
90
+ relativePath,
91
+ });
92
+ if (!filePath) continue;
93
+ filesToDelete.add(filePath);
94
+ }
95
+ }
96
+
97
+ for (const filePath of filesToDelete) {
98
+ try {
99
+ fs.rmSync(filePath, { force: true });
100
+ } catch {}
101
+ }
102
+
103
+ for (const sessionId of candidateSessionIds) {
104
+ const conversationDir = resolveGeneratedImageConversationDir({
105
+ root,
106
+ sessionId,
107
+ conversationId: normalizedConversationId,
108
+ });
109
+ if (!conversationDir) continue;
110
+ try {
111
+ fs.rmSync(conversationDir, { recursive: true, force: true });
112
+ } catch {}
113
+ }
114
+ }
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ import { execFile as nodeExecFile } from 'child_process';
4
+
5
+ // Read-only-ish git integration for the "Git changes" modal: status (branch,
6
+ // ahead/behind, changed files), a full-context per-file diff, and pull.
7
+ // The service never receives paths from the client directly — routes resolve
8
+ // the workspace root server-side and validate file paths before calling in.
9
+
10
+ const GIT_COMMAND_TIMEOUT_MS = 15_000;
11
+ const GIT_PULL_TIMEOUT_MS = 120_000;
12
+ const GIT_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
13
+ // Large enough that hunks always include the whole file, so the client can
14
+ // render both "full file" and "changes only" views from one patch.
15
+ const FULL_CONTEXT_LINES = 999_999;
16
+
17
+ function normalizeGitPath(value) {
18
+ const text = String(value || '');
19
+ if (!text) return '';
20
+ // Porcelain quotes paths with special characters ("path with\ttab").
21
+ if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
22
+ try {
23
+ return JSON.parse(text);
24
+ } catch {
25
+ return text.slice(1, -1);
26
+ }
27
+ }
28
+ return text;
29
+ }
30
+
31
+ export function parseGitStatusBranchHeader(headerLine) {
32
+ const result = { branch: '', upstream: '', ahead: 0, behind: 0, detached: false, initial: false };
33
+ const text = String(headerLine || '').trim();
34
+ if (!text.startsWith('##')) return result;
35
+ let body = text.slice(2).trim();
36
+ if (!body) return result;
37
+ if (body.startsWith('No commits yet on ')) {
38
+ result.branch = body.slice('No commits yet on '.length).trim();
39
+ result.initial = true;
40
+ return result;
41
+ }
42
+ if (body === 'HEAD (no branch)') {
43
+ result.branch = 'HEAD';
44
+ result.detached = true;
45
+ return result;
46
+ }
47
+ const bracketStart = body.lastIndexOf(' [');
48
+ if (bracketStart !== -1 && body.endsWith(']')) {
49
+ const tracking = body.slice(bracketStart + 2, -1);
50
+ body = body.slice(0, bracketStart);
51
+ const aheadMatch = tracking.match(/ahead (\d+)/);
52
+ const behindMatch = tracking.match(/behind (\d+)/);
53
+ if (aheadMatch) result.ahead = Number(aheadMatch[1]);
54
+ if (behindMatch) result.behind = Number(behindMatch[1]);
55
+ }
56
+ const ellipsis = body.indexOf('...');
57
+ if (ellipsis !== -1) {
58
+ result.branch = body.slice(0, ellipsis);
59
+ result.upstream = body.slice(ellipsis + 3);
60
+ } else {
61
+ result.branch = body;
62
+ }
63
+ return result;
64
+ }
65
+
66
+ // Parses `git status --porcelain=v1 -z --branch` output. NUL-separated
67
+ // entries; renames/copies carry a second NUL-separated original path.
68
+ export function parseGitStatusPorcelain(stdout) {
69
+ const entries = String(stdout || '').split('\0').filter((entry) => entry.length > 0);
70
+ const summary = { branch: '', upstream: '', ahead: 0, behind: 0, detached: false, initial: false };
71
+ const files = [];
72
+ let index = 0;
73
+ while (index < entries.length) {
74
+ const entry = entries[index];
75
+ index += 1;
76
+ if (entry.startsWith('## ') || entry === '##') {
77
+ Object.assign(summary, parseGitStatusBranchHeader(entry));
78
+ continue;
79
+ }
80
+ if (entry.length < 4) continue;
81
+ const indexStatus = entry[0];
82
+ const worktreeStatus = entry[1];
83
+ const rawPath = entry.slice(3);
84
+ let origPath = '';
85
+ if (indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C') {
86
+ origPath = normalizeGitPath(entries[index] || '');
87
+ index += 1;
88
+ }
89
+ const untracked = indexStatus === '?' && worktreeStatus === '?';
90
+ const deleted = indexStatus === 'D' || worktreeStatus === 'D';
91
+ const statusCode = untracked
92
+ ? 'U'
93
+ : (worktreeStatus !== ' ' && worktreeStatus !== '?' ? worktreeStatus : indexStatus);
94
+ files.push({
95
+ path: normalizeGitPath(rawPath).replace(/\\/g, '/'),
96
+ origPath: origPath ? origPath.replace(/\\/g, '/') : '',
97
+ indexStatus,
98
+ worktreeStatus,
99
+ status: statusCode,
100
+ untracked,
101
+ deleted,
102
+ renamed: indexStatus === 'R' || worktreeStatus === 'R',
103
+ });
104
+ }
105
+ files.sort((a, b) => a.path.localeCompare(b.path));
106
+ return { ...summary, files };
107
+ }
108
+
109
+ function isNotARepositoryError(error) {
110
+ const text = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
111
+ return text.includes('not a git repository');
112
+ }
113
+
114
+ export function createGitChangesService({ execFileImpl = nodeExecFile } = {}) {
115
+ function runGit(rootPath, args, { timeoutMs = GIT_COMMAND_TIMEOUT_MS, okExitCodes = [0] } = {}) {
116
+ return new Promise((resolve, reject) => {
117
+ execFileImpl('git', args, {
118
+ cwd: rootPath,
119
+ timeout: timeoutMs,
120
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
121
+ windowsHide: true,
122
+ }, (error, stdout, stderr) => {
123
+ if (error && !okExitCodes.includes(Number(error.code))) {
124
+ const failure = new Error(String(stderr || error.message || 'git command failed').trim());
125
+ failure.stderr = String(stderr || '');
126
+ failure.code = error.code;
127
+ return reject(failure);
128
+ }
129
+ resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
130
+ });
131
+ });
132
+ }
133
+
134
+ async function getStatus(rootPath) {
135
+ try {
136
+ const { stdout } = await runGit(rootPath, ['status', '--porcelain=v1', '-z', '--branch']);
137
+ return { ok: true, isRepo: true, ...parseGitStatusPorcelain(stdout) };
138
+ } catch (error) {
139
+ if (isNotARepositoryError(error)) {
140
+ return { ok: true, isRepo: false, branch: '', upstream: '', ahead: 0, behind: 0, files: [] };
141
+ }
142
+ return { ok: false, error: error?.message || 'Failed to read git status' };
143
+ }
144
+ }
145
+
146
+ async function getDiff(rootPath, relativePath, { untracked = false } = {}) {
147
+ const gitPath = String(relativePath || '').replace(/\\/g, '/');
148
+ if (!gitPath) return { ok: false, error: 'Missing file path' };
149
+ try {
150
+ let patch = '';
151
+ if (untracked) {
152
+ // --no-index exits 1 when the files differ; that is the success case.
153
+ const { stdout } = await runGit(
154
+ rootPath,
155
+ ['diff', '--no-index', `-U${FULL_CONTEXT_LINES}`, '--', '/dev/null', gitPath],
156
+ { okExitCodes: [0, 1] },
157
+ );
158
+ patch = stdout;
159
+ } else {
160
+ const { stdout } = await runGit(
161
+ rootPath,
162
+ ['diff', 'HEAD', `-U${FULL_CONTEXT_LINES}`, '--', gitPath],
163
+ );
164
+ patch = stdout;
165
+ if (!patch.trim()) {
166
+ // Fall back for files that only differ in the index vs worktree edge
167
+ // cases (e.g. intent-to-add) where `diff HEAD` came back empty.
168
+ const fallback = await runGit(
169
+ rootPath,
170
+ ['diff', '--no-index', `-U${FULL_CONTEXT_LINES}`, '--', '/dev/null', gitPath],
171
+ { okExitCodes: [0, 1] },
172
+ );
173
+ patch = fallback.stdout;
174
+ }
175
+ }
176
+ return { ok: true, path: gitPath, patch };
177
+ } catch (error) {
178
+ if (isNotARepositoryError(error)) {
179
+ return { ok: false, error: 'Not a git repository' };
180
+ }
181
+ return { ok: false, error: error?.message || 'Failed to read git diff' };
182
+ }
183
+ }
184
+
185
+ async function pull(rootPath) {
186
+ try {
187
+ const { stdout, stderr } = await runGit(rootPath, ['pull'], { timeoutMs: GIT_PULL_TIMEOUT_MS });
188
+ return { ok: true, output: `${stdout}${stderr}`.trim() };
189
+ } catch (error) {
190
+ return { ok: false, error: error?.message || 'git pull failed' };
191
+ }
192
+ }
193
+
194
+ return { getStatus, getDiff, pull };
195
+ }