@greppleai/grip 0.0.1 → 0.1.4

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 (221) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +13 -7
  3. package/dist/bun/cli.d.ts.map +1 -1
  4. package/dist/bun/cli.js +4 -2
  5. package/dist/bun/cli.js.map +1 -1
  6. package/dist/bun/embedded-assets.d.ts +3 -0
  7. package/dist/bun/embedded-assets.d.ts.map +1 -0
  8. package/dist/bun/embedded-assets.js +52 -0
  9. package/dist/bun/embedded-assets.js.map +1 -0
  10. package/dist/cli/args.d.ts.map +1 -1
  11. package/dist/cli/args.js +1 -0
  12. package/dist/cli/args.js.map +1 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +3 -3
  15. package/dist/config.js.map +1 -1
  16. package/dist/core/agent-session.d.ts.map +1 -1
  17. package/dist/core/agent-session.js +17 -2
  18. package/dist/core/agent-session.js.map +1 -1
  19. package/dist/core/extensions/types.d.ts +5 -1
  20. package/dist/core/extensions/types.d.ts.map +1 -1
  21. package/dist/core/extensions/types.js.map +1 -1
  22. package/dist/core/remote-workspace-protocol/contract.d.ts +13 -4
  23. package/dist/core/remote-workspace-protocol/contract.d.ts.map +1 -1
  24. package/dist/core/remote-workspace-protocol/contract.js +17 -1
  25. package/dist/core/remote-workspace-protocol/contract.js.map +1 -1
  26. package/dist/core/remote-workspace-protocol/guest-shared-client.d.ts +16 -0
  27. package/dist/core/remote-workspace-protocol/guest-shared-client.d.ts.map +1 -0
  28. package/dist/core/remote-workspace-protocol/guest-shared-client.js +54 -0
  29. package/dist/core/remote-workspace-protocol/guest-shared-client.js.map +1 -0
  30. package/dist/core/remote-workspace-protocol/guest-shared-outbound-scheduler.d.ts +23 -0
  31. package/dist/core/remote-workspace-protocol/guest-shared-outbound-scheduler.d.ts.map +1 -0
  32. package/dist/core/remote-workspace-protocol/guest-shared-outbound-scheduler.js +112 -0
  33. package/dist/core/remote-workspace-protocol/guest-shared-outbound-scheduler.js.map +1 -0
  34. package/dist/core/remote-workspace-protocol/guest-shared-uplink.d.ts +30 -0
  35. package/dist/core/remote-workspace-protocol/guest-shared-uplink.d.ts.map +1 -0
  36. package/dist/core/remote-workspace-protocol/guest-shared-uplink.js +243 -0
  37. package/dist/core/remote-workspace-protocol/guest-shared-uplink.js.map +1 -0
  38. package/dist/core/remote-workspace-protocol/lima-owner-directory.d.ts +3 -0
  39. package/dist/core/remote-workspace-protocol/lima-owner-directory.d.ts.map +1 -0
  40. package/dist/core/remote-workspace-protocol/lima-owner-directory.js +31 -0
  41. package/dist/core/remote-workspace-protocol/lima-owner-directory.js.map +1 -0
  42. package/dist/core/remote-workspace-protocol/lima-owner-lock.d.ts +9 -0
  43. package/dist/core/remote-workspace-protocol/lima-owner-lock.d.ts.map +1 -0
  44. package/dist/core/remote-workspace-protocol/lima-owner-lock.js +89 -0
  45. package/dist/core/remote-workspace-protocol/lima-owner-lock.js.map +1 -0
  46. package/dist/core/remote-workspace-protocol/session.d.ts +2 -0
  47. package/dist/core/remote-workspace-protocol/session.d.ts.map +1 -1
  48. package/dist/core/remote-workspace-protocol/session.js +3 -0
  49. package/dist/core/remote-workspace-protocol/session.js.map +1 -1
  50. package/dist/core/remote-workspace-protocol/shared-transport.d.ts +71 -0
  51. package/dist/core/remote-workspace-protocol/shared-transport.d.ts.map +1 -0
  52. package/dist/core/remote-workspace-protocol/shared-transport.js +68 -0
  53. package/dist/core/remote-workspace-protocol/shared-transport.js.map +1 -0
  54. package/dist/core/sandbox/command.d.ts +2 -4
  55. package/dist/core/sandbox/command.d.ts.map +1 -1
  56. package/dist/core/sandbox/command.js +24 -46
  57. package/dist/core/sandbox/command.js.map +1 -1
  58. package/dist/core/sandbox/config.d.ts +91 -0
  59. package/dist/core/sandbox/config.d.ts.map +1 -0
  60. package/dist/core/sandbox/config.js +88 -0
  61. package/dist/core/sandbox/config.js.map +1 -0
  62. package/dist/core/sandbox/lima-guest-binary.d.ts +11 -0
  63. package/dist/core/sandbox/lima-guest-binary.d.ts.map +1 -0
  64. package/dist/core/sandbox/lima-guest-binary.js +140 -0
  65. package/dist/core/sandbox/lima-guest-binary.js.map +1 -0
  66. package/dist/core/sandbox/lima.d.ts +22 -3
  67. package/dist/core/sandbox/lima.d.ts.map +1 -1
  68. package/dist/core/sandbox/lima.js +434 -42
  69. package/dist/core/sandbox/lima.js.map +1 -1
  70. package/dist/core/sandbox/shared-lima-roots.d.ts +11 -0
  71. package/dist/core/sandbox/shared-lima-roots.d.ts.map +1 -0
  72. package/dist/core/sandbox/shared-lima-roots.js +25 -0
  73. package/dist/core/sandbox/shared-lima-roots.js.map +1 -0
  74. package/dist/core/sandbox/update-shared-guest-binary.d.ts +8 -0
  75. package/dist/core/sandbox/update-shared-guest-binary.d.ts.map +1 -0
  76. package/dist/core/sandbox/update-shared-guest-binary.js +100 -0
  77. package/dist/core/sandbox/update-shared-guest-binary.js.map +1 -0
  78. package/dist/core/settings-manager.d.ts +1 -5
  79. package/dist/core/settings-manager.d.ts.map +1 -1
  80. package/dist/core/settings-manager.js.map +1 -1
  81. package/dist/core/tools/bash.d.ts.map +1 -1
  82. package/dist/core/tools/bash.js +25 -14
  83. package/dist/core/tools/bash.js.map +1 -1
  84. package/dist/core/tools/file-mutation-queue.d.ts +2 -0
  85. package/dist/core/tools/file-mutation-queue.d.ts.map +1 -1
  86. package/dist/core/tools/file-mutation-queue.js +9 -0
  87. package/dist/core/tools/file-mutation-queue.js.map +1 -1
  88. package/dist/core/tools/find.d.ts.map +1 -1
  89. package/dist/core/tools/find.js +2 -3
  90. package/dist/core/tools/find.js.map +1 -1
  91. package/dist/core/tools/grep.d.ts.map +1 -1
  92. package/dist/core/tools/grep.js +2 -3
  93. package/dist/core/tools/grep.js.map +1 -1
  94. package/dist/core/tools/ls.d.ts.map +1 -1
  95. package/dist/core/tools/ls.js +2 -3
  96. package/dist/core/tools/ls.js.map +1 -1
  97. package/dist/core/tools/operations.d.ts +13 -2
  98. package/dist/core/tools/operations.d.ts.map +1 -1
  99. package/dist/core/tools/operations.js +132 -36
  100. package/dist/core/tools/operations.js.map +1 -1
  101. package/dist/core/tools/read.d.ts.map +1 -1
  102. package/dist/core/tools/read.js +3 -3
  103. package/dist/core/tools/read.js.map +1 -1
  104. package/dist/core/tools/render-utils.d.ts +1 -0
  105. package/dist/core/tools/render-utils.d.ts.map +1 -1
  106. package/dist/core/tools/render-utils.js +5 -0
  107. package/dist/core/tools/render-utils.js.map +1 -1
  108. package/dist/core/tools/write.d.ts.map +1 -1
  109. package/dist/core/tools/write.js +2 -3
  110. package/dist/core/tools/write.js.map +1 -1
  111. package/dist/daemon/activity-budget.d.ts +10 -0
  112. package/dist/daemon/activity-budget.d.ts.map +1 -0
  113. package/dist/daemon/activity-budget.js +34 -0
  114. package/dist/daemon/activity-budget.js.map +1 -0
  115. package/dist/daemon/config.d.ts +2 -0
  116. package/dist/daemon/config.d.ts.map +1 -1
  117. package/dist/daemon/config.js +15 -0
  118. package/dist/daemon/config.js.map +1 -1
  119. package/dist/daemon/outbound-budget.d.ts +10 -0
  120. package/dist/daemon/outbound-budget.d.ts.map +1 -0
  121. package/dist/daemon/outbound-budget.js +30 -0
  122. package/dist/daemon/outbound-budget.js.map +1 -0
  123. package/dist/daemon/security.d.ts.map +1 -1
  124. package/dist/daemon/security.js +3 -1
  125. package/dist/daemon/security.js.map +1 -1
  126. package/dist/daemon/server.d.ts.map +1 -1
  127. package/dist/daemon/server.js +104 -24
  128. package/dist/daemon/server.js.map +1 -1
  129. package/dist/daemon/shared-connection.d.ts +33 -0
  130. package/dist/daemon/shared-connection.d.ts.map +1 -0
  131. package/dist/daemon/shared-connection.js +271 -0
  132. package/dist/daemon/shared-connection.js.map +1 -0
  133. package/dist/daemon/shared-revision.d.ts +5 -0
  134. package/dist/daemon/shared-revision.d.ts.map +1 -0
  135. package/dist/daemon/shared-revision.js +61 -0
  136. package/dist/daemon/shared-revision.js.map +1 -0
  137. package/dist/daemon/shared-roots.d.ts +9 -0
  138. package/dist/daemon/shared-roots.d.ts.map +1 -0
  139. package/dist/daemon/shared-roots.js +33 -0
  140. package/dist/daemon/shared-roots.js.map +1 -0
  141. package/dist/daemon/workspace-runtime.d.ts +1 -1
  142. package/dist/daemon/workspace-runtime.d.ts.map +1 -1
  143. package/dist/daemon/workspace-runtime.js +42 -10
  144. package/dist/daemon/workspace-runtime.js.map +1 -1
  145. package/dist/main.d.ts.map +1 -1
  146. package/dist/main.js +5 -3
  147. package/dist/main.js.map +1 -1
  148. package/dist/modes/interactive/components/assistant-message.d.ts +1 -0
  149. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  150. package/dist/modes/interactive/components/assistant-message.js +13 -3
  151. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  152. package/dist/modes/interactive/components/branch-summary-message.d.ts +4 -3
  153. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
  154. package/dist/modes/interactive/components/branch-summary-message.js +5 -4
  155. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
  156. package/dist/modes/interactive/components/compaction-summary-message.d.ts +4 -3
  157. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
  158. package/dist/modes/interactive/components/compaction-summary-message.js +5 -4
  159. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
  160. package/dist/modes/interactive/components/custom-message.d.ts.map +1 -1
  161. package/dist/modes/interactive/components/custom-message.js +4 -3
  162. package/dist/modes/interactive/components/custom-message.js.map +1 -1
  163. package/dist/modes/interactive/components/hook-execution.d.ts +3 -0
  164. package/dist/modes/interactive/components/hook-execution.d.ts.map +1 -1
  165. package/dist/modes/interactive/components/hook-execution.js +20 -2
  166. package/dist/modes/interactive/components/hook-execution.js.map +1 -1
  167. package/dist/modes/interactive/components/message-card.d.ts +9 -0
  168. package/dist/modes/interactive/components/message-card.d.ts.map +1 -0
  169. package/dist/modes/interactive/components/message-card.js +24 -0
  170. package/dist/modes/interactive/components/message-card.js.map +1 -0
  171. package/dist/modes/interactive/components/skill-invocation-message.d.ts +4 -3
  172. package/dist/modes/interactive/components/skill-invocation-message.d.ts.map +1 -1
  173. package/dist/modes/interactive/components/skill-invocation-message.js +5 -4
  174. package/dist/modes/interactive/components/skill-invocation-message.js.map +1 -1
  175. package/dist/modes/interactive/components/tool-execution.d.ts +1 -1
  176. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  177. package/dist/modes/interactive/components/tool-execution.js +27 -21
  178. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  179. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  180. package/dist/modes/interactive/components/user-message.js +3 -2
  181. package/dist/modes/interactive/components/user-message.js.map +1 -1
  182. package/dist/modes/interactive/interactive-mode.d.ts +1 -3
  183. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  184. package/dist/modes/interactive/interactive-mode.js +106 -47
  185. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  186. package/dist/modes/interactive/theme/theme.d.ts +3 -0
  187. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  188. package/dist/modes/interactive/theme/theme.js +10 -0
  189. package/dist/modes/interactive/theme/theme.js.map +1 -1
  190. package/dist/utils/clipboard-native.d.ts.map +1 -1
  191. package/dist/utils/clipboard-native.js +1 -1
  192. package/dist/utils/clipboard-native.js.map +1 -1
  193. package/dist/utils/image-resize.d.ts.map +1 -1
  194. package/dist/utils/image-resize.js +5 -0
  195. package/dist/utils/image-resize.js.map +1 -1
  196. package/dist/utils/photon.d.ts.map +1 -1
  197. package/dist/utils/photon.js +1 -0
  198. package/dist/utils/photon.js.map +1 -1
  199. package/docs/daemon.md +6 -4
  200. package/docs/docs.json +1 -1
  201. package/docs/extensions.md +3 -0
  202. package/docs/index.md +8 -12
  203. package/docs/packages.md +3 -3
  204. package/docs/quickstart.md +11 -19
  205. package/docs/sandbox.md +40 -117
  206. package/docs/settings.md +12 -11
  207. package/docs/termux.md +1 -18
  208. package/docs/usage.md +2 -2
  209. package/examples/README.md +0 -2
  210. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  211. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  212. package/examples/extensions/sandbox/package.json +1 -1
  213. package/examples/extensions/with-deps/package.json +1 -1
  214. package/npm-shrinkwrap.json +12 -12
  215. package/package.json +5 -6
  216. package/dist/core/sandbox/docker.d.ts +0 -169
  217. package/dist/core/sandbox/docker.d.ts.map +0 -1
  218. package/dist/core/sandbox/docker.js +0 -633
  219. package/dist/core/sandbox/docker.js.map +0 -1
  220. package/examples/sandbox-docker/Dockerfile +0 -274
  221. package/examples/sandbox-docker/README.md +0 -88
@@ -1,10 +1,15 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
2
3
  import { createConnection } from "node:net";
3
4
  import { basename, resolve } from "node:path";
4
5
  import { VERSION } from "../../config.js";
5
6
  import { DAEMON_WEBSOCKET_PATH } from "../../daemon/config.js";
6
7
  import { spawnProcess } from "../../utils/child-process.js";
7
- import { allocateSandboxDaemonPort, redactSecrets, resolveSandboxConfig, SANDBOX_LABEL_PREFIX } from "./docker.js";
8
+ import { acquireSharedLimaOwnerLock } from "../remote-workspace-protocol/lima-owner-lock.js";
9
+ import { allocateSandboxDaemonPort, redactSecrets, resolveSandboxConfig, SANDBOX_LABEL_PREFIX } from "./config.js";
10
+ import { inspectLocalGuestBinary, installLocalGuestBinary } from "./lima-guest-binary.js";
11
+ import { resolveSharedLimaWorkspace } from "./shared-lima-roots.js";
12
+ import { stageSharedGuestBinary } from "./update-shared-guest-binary.js";
8
13
  class ProcessLimaRunner {
9
14
  run(command, args, options = {}) {
10
15
  return new Promise((resolveCommand, reject) => {
@@ -162,6 +167,258 @@ export class LimaSandboxService {
162
167
  throw new Error(redactSecrets(`Lima is unavailable or not reachable via ${config.limaBinary}: ${message}`));
163
168
  }
164
169
  }
170
+ /** Read-only validation: no VM creation, daemon restart, token rotation or ownership claim. */
171
+ async inspectSharedInstance(options) {
172
+ const config = this.resolveConfig();
173
+ const { instanceName } = options;
174
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(instanceName))
175
+ throw new Error("Invalid shared Lima instance name");
176
+ if (config.daemonHostBind !== "127.0.0.1" && config.daemonHostBind !== "::1") {
177
+ throw new Error("Shared Lima daemon forward must bind to loopback");
178
+ }
179
+ const binding = await resolveSharedLimaWorkspace(options.hostParent, config.workspaceMountPath, options.hostRoot);
180
+ const records = await this.listRecords(config, instanceName);
181
+ if (records.length !== 1 || records[0]?.name !== instanceName) {
182
+ throw new Error(`Shared Lima instance ${instanceName} does not exist by exact name`);
183
+ }
184
+ const record = records[0];
185
+ if (record.status?.toLowerCase() !== "running") {
186
+ throw new Error(`Shared Lima instance ${instanceName} is not running`);
187
+ }
188
+ const mounts = record.config?.mounts?.filter((mount) => mount.mountPoint === binding.guestParent) ?? [];
189
+ if (mounts.length !== 1 || !mounts[0]?.writable || !mounts[0].location) {
190
+ throw new Error(`Shared Lima instance ${instanceName} requires one writable fixed parent mount`);
191
+ }
192
+ if ((await realpath(mounts[0].location)) !== binding.hostParent) {
193
+ throw new Error(`Shared Lima instance ${instanceName} mounts a different host parent`);
194
+ }
195
+ const forwards = record.config?.portForwards?.filter((forward) => forward.guestPort === config.daemonPort || forward.hostPort === config.daemonPort) ?? [];
196
+ if (forwards.length !== 1 ||
197
+ forwards[0]?.guestPort !== config.daemonPort ||
198
+ forwards[0].hostPort !== config.daemonPort ||
199
+ forwards[0].hostIP !== config.daemonHostBind) {
200
+ throw new Error(`Shared Lima instance ${instanceName} requires one matching loopback daemon forward`);
201
+ }
202
+ return {
203
+ ...binding,
204
+ instanceName,
205
+ daemonUrl: `ws://${urlHost(config.daemonHostBind)}:${config.daemonPort}${DAEMON_WEBSOCKET_PATH}`,
206
+ };
207
+ }
208
+ /** Reinspect and reuse the supervised guest daemon under the exclusive named-VM owner lock. */
209
+ async ensureSharedDaemon(options, owner, updated = false) {
210
+ if (owner.instanceName !== options.instanceName)
211
+ throw new Error("Shared Lima owner lock names a different VM");
212
+ await owner.assertHeld();
213
+ await this.inspectSharedInstance(options);
214
+ const config = this.resolveConfig();
215
+ const local = this.env.GRIP_SANDBOX_GUEST_BINARY
216
+ ? await inspectLocalGuestBinary(this.env.GRIP_SANDBOX_GUEST_BINARY)
217
+ : undefined;
218
+ const versionArgs = [
219
+ "shell",
220
+ "--tty=false",
221
+ options.instanceName,
222
+ "sh",
223
+ "-lc",
224
+ local || updated ? '"$HOME/.local/bin/grip" --version' : "grip --version",
225
+ ];
226
+ const version = await this.runner.run(config.limaBinary, versionArgs);
227
+ if (version.exitCode !== 0 || version.stdout.trim() !== VERSION) {
228
+ throw new Error("Shared Lima grip version mismatch or binary missing; install the matching version manually");
229
+ }
230
+ await owner.assertHeld();
231
+ const directory = "$HOME/.cache/grip/shared-lima";
232
+ // /proc start time plus boot ID distinguishes a recycled PID or a rebooted guest.
233
+ // kill -0 also succeeds for zombies, which must never be reused as a live daemon.
234
+ const processStartTime = `awk '{ sub(/^.*\\) /, ""); if ($1 == "Z" || $1 == "X") exit 1; print $20 }'`;
235
+ const script = [
236
+ "set -eu",
237
+ "umask 077",
238
+ `directory="${directory}"`,
239
+ // Both current modes reserve the guest transition; a crashed reservation is operator-owned.
240
+ 'mkdir -p "$HOME/.cache/grip"',
241
+ 'mode_lock="$HOME/.cache/grip/daemon-mode.lock"',
242
+ 'mkdir "$mode_lock" || exit 28',
243
+ "trap 'rmdir \"$mode_lock\" 2>/dev/null || true' EXIT",
244
+ // A legacy per-session daemon may own this VM or its port. Do not start a second daemon.
245
+ 'for legacy_file in "$HOME/.cache/grip"/sandbox-daemon-*.pid "$HOME/.cache/grip"/sandbox-daemon-*.log; do',
246
+ ' if test -e "$legacy_file" || test -L "$legacy_file"; then exit 27; fi',
247
+ "done",
248
+ // A transient user service must outlive the Lima SSH session. Do not mutate
249
+ // private state unless the guest manager is running and linger was enabled by an operator.
250
+ "command -v systemd-run >/dev/null && systemctl --user show-environment >/dev/null || exit 29",
251
+ 'test "$(loginctl show-user "$(id -un)" --property=Linger --value)" = yes || exit 29',
252
+ `mkdir -p "$directory"; test ! -L "$directory"; chmod 700 "$directory"`,
253
+ 'rmdir "$mode_lock" || exit 28',
254
+ "trap - EXIT",
255
+ `token_file="$directory/daemon-${config.daemonPort}.token"`,
256
+ `pid_file="$directory/daemon-${config.daemonPort}.pid"`,
257
+ `identity_file="$directory/daemon-${config.daemonPort}.identity"`,
258
+ `log_file="$directory/daemon-${config.daemonPort}.log"`,
259
+ `start_lock="$directory/daemon-${config.daemonPort}.starting"`,
260
+ `unit="grip-shared-lima-${config.daemonPort}.service"`,
261
+ 'test ! -L "$token_file"; test ! -L "$pid_file"; test ! -L "$identity_file"; test ! -L "$log_file"',
262
+ 'if test -e "$log_file"; then test -f "$log_file" && test "$(stat -c %a "$log_file")" = 600 || exit 22; fi',
263
+ 'test ! -e "$start_lock" && test ! -L "$start_lock" || exit 26',
264
+ 'if test -e "$token_file" || test -e "$pid_file" || test -e "$identity_file"; then',
265
+ ' test -f "$token_file" && test -f "$pid_file" && test -f "$identity_file" || exit 21',
266
+ ' test "$(stat -c %a "$token_file")" = 600 || exit 22',
267
+ ' test "$(stat -c %a "$pid_file")" = 600 || exit 22',
268
+ ' test "$(stat -c %a "$identity_file")" = 600 || exit 22',
269
+ ' pid="$(cat "$pid_file")"',
270
+ " case \"$pid\" in ''|*[!0-9]*) exit 23;; esac",
271
+ ' kill -0 "$pid" || exit 24',
272
+ ` start_time="$(${processStartTime} "/proc/$pid/stat")" || exit 25`,
273
+ ' boot_id="$(cat /proc/sys/kernel/random/boot_id)" || exit 25',
274
+ ' test -n "$start_time" && test -n "$boot_id" && test "$(cat "$identity_file")" = "$boot_id:$start_time" || exit 25',
275
+ ' test "$(systemctl --user show -P MainPID "$unit")" = "$pid" && systemctl --user is-active --quiet "$unit" || exit 25',
276
+ ' token="$(cat "$token_file")"',
277
+ "else",
278
+ ' mkdir "$start_lock" || exit 26',
279
+ " trap 'rmdir \"$start_lock\" 2>/dev/null || true' EXIT",
280
+ " token=\"$(head -c 32 /dev/urandom | base64 | tr -d '\\n')\"",
281
+ ' printf \'%s\' "$token" >"$token_file"',
282
+ ' chmod 600 "$token_file"',
283
+ // The systemd user manager owns the process. It reads the private token file
284
+ // inside the unit, never from a systemd-run argument or environment property.
285
+ local
286
+ ? ' grip_bin="$HOME/.local/bin/grip"; test -x "$grip_bin"'
287
+ : ' grip_bin="$(command -v grip)"; case "$grip_bin" in /*) ;; *) exit 25;; esac',
288
+ ` systemd-run --user --unit="$unit" --property=Restart=no --property=KillMode=control-group -- sh -c 'set -eu; umask 077; GRIP_DAEMON_TOKEN="$(cat "$HOME/.cache/grip/shared-lima/daemon-${config.daemonPort}.token")"; test -n "$GRIP_DAEMON_TOKEN"; export GRIP_DAEMON_TOKEN; exec "$1" --daemon --daemon-host 127.0.0.1 --daemon-allow-insecure-transport --daemon-port ${config.daemonPort} --daemon-cwd "$2" --daemon-shared-parent "$2" --daemon-allow-root --daemon-allow-process-exec >"$HOME/.cache/grip/shared-lima/daemon-${config.daemonPort}.log" 2>&1 </dev/null' sh "$grip_bin" ${shellQuote(config.workspaceMountPath)} >/dev/null`,
289
+ ' pid=""; attempts=0',
290
+ ' while test "$attempts" -lt 50; do',
291
+ ' pid="$(systemctl --user show -P MainPID "$unit")" || exit 25',
292
+ ' case "$pid" in ""|0) sleep 0.1;; *[!0-9]*) exit 25;; *) break;; esac',
293
+ " attempts=$((attempts + 1))",
294
+ " done",
295
+ ' case "$pid" in ""|0|*[!0-9]*) exit 25;; esac',
296
+ ' systemctl --user is-active --quiet "$unit" || exit 25',
297
+ ' printf \'%s\' "$pid" >"$pid_file"',
298
+ ' chmod 600 "$pid_file"',
299
+ ` start_time="$(${processStartTime} "/proc/$pid/stat")" || exit 25`,
300
+ ' boot_id="$(cat /proc/sys/kernel/random/boot_id)" || exit 25',
301
+ ' test -n "$start_time" && test -n "$boot_id" || exit 25',
302
+ ' printf \'%s:%s\' "$boot_id" "$start_time" >"$identity_file"',
303
+ ' chmod 600 "$identity_file"',
304
+ ' rmdir "$start_lock" || exit 26',
305
+ " trap - EXIT",
306
+ "fi",
307
+ "printf 'GRIP_SHARED_TOKEN=%s\\n' \"$token\"",
308
+ ].join("\n");
309
+ const args = ["shell", "--tty=false", options.instanceName, "sh", "-lc", script];
310
+ const result = await this.runner.run(config.limaBinary, args);
311
+ // Never include stdout/stderr from a credential-bearing shell in an error.
312
+ if (result.exitCode !== 0)
313
+ throw new Error("Shared Lima daemon state is uncertain; reconcile guest PID/token files manually");
314
+ const token = result.stdout.match(/(?:^|\n)GRIP_SHARED_TOKEN=([A-Za-z0-9+/]{43}=)(?:\r?\n|$)/)?.[1];
315
+ if (!token)
316
+ throw new Error("Shared Lima daemon did not return a valid credential; reconcile manually");
317
+ await this.readinessWaiter(config.daemonHostBind, config.daemonPort);
318
+ // Readiness can outlive the owner. Never hand a guest credential to a lost owner.
319
+ await owner.assertHeld();
320
+ return token;
321
+ }
322
+ /** Update only a verified supervised daemon. Staging happens before disconnecting peers. */
323
+ async updateSharedDaemon(options, owner) {
324
+ await owner.assertHeld();
325
+ await this.inspectSharedInstance(options);
326
+ const config = this.resolveConfig();
327
+ const name = options.instanceName;
328
+ const port = config.daemonPort;
329
+ if (!this.env.GRIP_SANDBOX_GUEST_BINARY && process.argv[1]?.endsWith("/packages/coding-agent/src/cli.ts")) {
330
+ throw new Error("Development sandbox update requires GRIP_SANDBOX_GUEST_BINARY pointing to a built Linux Grip executable");
331
+ }
332
+ const local = this.env.GRIP_SANDBOX_GUEST_BINARY
333
+ ? await inspectLocalGuestBinary(this.env.GRIP_SANDBOX_GUEST_BINARY)
334
+ : undefined;
335
+ const shell = (script) => this.runner.run(config.limaBinary, ["shell", "--tty=false", name, "sh", "-lc", script]);
336
+ const directory = "$HOME/.cache/grip/shared-lima";
337
+ const unit = `grip-shared-lima-${port}.service`;
338
+ const verify = [
339
+ "set -eu",
340
+ `directory="${directory}"`,
341
+ `unit=${shellQuote(unit)}`,
342
+ 'test -d "$directory" && test ! -L "$directory"',
343
+ 'test ! -e "$HOME/.cache/grip/daemon-mode.lock" && test ! -L "$HOME/.cache/grip/daemon-mode.lock"',
344
+ `token_file="$directory/daemon-${port}.token"`,
345
+ `pid_file="$directory/daemon-${port}.pid"`,
346
+ `identity_file="$directory/daemon-${port}.identity"`,
347
+ 'for file in "$token_file" "$pid_file" "$identity_file"; do test -f "$file" && test ! -L "$file" && test "$(stat -c %a "$file")" = 600; done',
348
+ 'pid="$(cat "$pid_file")"; case "$pid" in ""|0|*[!0-9]*) exit 45;; esac',
349
+ 'kill -0 "$pid"',
350
+ `start_time="$(awk '{ sub(/^.*\\) /, ""); if ($1 == "Z" || $1 == "X") exit 1; print $20 }' "/proc/$pid/stat")"`,
351
+ 'boot_id="$(cat /proc/sys/kernel/random/boot_id)"',
352
+ 'test -n "$start_time" && test "$(cat "$identity_file")" = "$boot_id:$start_time"',
353
+ 'test "$(systemctl --user show -P MainPID "$unit")" = "$pid" && systemctl --user is-active --quiet "$unit"',
354
+ 'case "$(systemctl --user show -P ExecStart "$unit")" in *"$HOME/.local/bin/grip"*) ;; *) exit 46;; esac',
355
+ ];
356
+ const preflight = await shell(verify.join("\n"));
357
+ if (preflight.exitCode !== 0) {
358
+ throw new Error("Shared Lima daemon identity or binary path is uncertain; update refused without disconnecting clients");
359
+ }
360
+ const staged = await stageSharedGuestBinary(this.runner, config.limaBinary, name, unit, VERSION, local);
361
+ try {
362
+ await owner.assertHeld();
363
+ const stop = await shell([
364
+ ...verify,
365
+ 'mkdir "$HOME/.cache/grip/daemon-mode.lock" || exit 45',
366
+ 'trap \u0027rmdir "$HOME/.cache/grip/daemon-mode.lock" 2>/dev/null || true\u0027 EXIT',
367
+ 'systemctl --user stop "$unit"',
368
+ 'test "$(systemctl --user show -P MainPID "$unit")" = 0',
369
+ '! kill -0 "$pid" 2>/dev/null',
370
+ ].join("\n"));
371
+ if (stop.exitCode !== 0)
372
+ throw new Error("Shared daemon stop is uncertain; inspect the guest before retrying update");
373
+ await owner.assertHeld();
374
+ await staged.install();
375
+ await owner.assertHeld();
376
+ const restart = await shell([
377
+ "set -eu",
378
+ `directory="${directory}"`,
379
+ `unit=${shellQuote(unit)}`,
380
+ 'test -d "$directory" && test ! -L "$directory"',
381
+ `token_file="$directory/daemon-${port}.token"`,
382
+ `pid_file="$directory/daemon-${port}.pid"`,
383
+ `identity_file="$directory/daemon-${port}.identity"`,
384
+ 'for file in "$token_file" "$pid_file" "$identity_file"; do test -f "$file" && test ! -L "$file" && test "$(stat -c %a "$file")" = 600; done',
385
+ 'test ! -e "$HOME/.cache/grip/daemon-mode.lock" && test ! -L "$HOME/.cache/grip/daemon-mode.lock"',
386
+ 'mkdir "$HOME/.cache/grip/daemon-mode.lock" || exit 45',
387
+ 'trap \u0027rmdir "$HOME/.cache/grip/daemon-mode.lock" 2>/dev/null || true\u0027 EXIT',
388
+ 'old_pid="$(cat "$pid_file")"; case "$old_pid" in ""|0|*[!0-9]*) exit 45;; esac',
389
+ '! kill -0 "$old_pid" 2>/dev/null',
390
+ `test "$("$HOME/.local/bin/grip" --version)" = ${shellQuote(VERSION)}`,
391
+ 'load="$(systemctl --user show -P LoadState "$unit" 2>/dev/null || true)"',
392
+ 'case "$load" in',
393
+ ' loaded) test "$(systemctl --user show -P MainPID "$unit")" = 0; systemctl --user start "$unit" ;;',
394
+ ' not-found|"")',
395
+ ` systemd-run --user --unit="$unit" --property=Restart=no --property=KillMode=control-group -- sh -c 'set -eu; umask 077; GRIP_DAEMON_TOKEN="$(cat "$HOME/.cache/grip/shared-lima/daemon-${port}.token")"; test -n "$GRIP_DAEMON_TOKEN"; export GRIP_DAEMON_TOKEN; exec "$1" --daemon --daemon-host 127.0.0.1 --daemon-allow-insecure-transport --daemon-port ${port} --daemon-cwd "$2" --daemon-shared-parent "$2" --daemon-allow-root --daemon-allow-process-exec >"$HOME/.cache/grip/shared-lima/daemon-${port}.log" 2>&1 </dev/null' sh "$HOME/.local/bin/grip" ${shellQuote(config.workspaceMountPath)} >/dev/null ;;`,
396
+ " *) exit 45 ;;",
397
+ "esac",
398
+ 'pid=""; attempts=0',
399
+ 'while test "$attempts" -lt 50; do',
400
+ ' pid="$(systemctl --user show -P MainPID "$unit")" || exit 45',
401
+ ' case "$pid" in ""|0) sleep 0.1;; *[!0-9]*) exit 45;; *) break;; esac',
402
+ " attempts=$((attempts + 1))",
403
+ "done",
404
+ 'case "$pid" in ""|0|*[!0-9]*) exit 45;; esac',
405
+ 'kill -0 "$pid"; systemctl --user is-active --quiet "$unit"',
406
+ `start_time="$(awk '{ sub(/^.*\\) /, ""); if ($1 == "Z" || $1 == "X") exit 1; print $20 }' "/proc/$pid/stat")"`,
407
+ 'boot_id="$(cat /proc/sys/kernel/random/boot_id)"',
408
+ 'test -n "$start_time" && test -n "$boot_id"',
409
+ 'printf "%s" "$pid" >"$pid_file"',
410
+ 'printf "%s:%s" "$boot_id" "$start_time" >"$identity_file"',
411
+ 'chmod 600 "$pid_file" "$identity_file"',
412
+ ].join("\n"));
413
+ if (restart.exitCode !== 0)
414
+ throw new Error("Shared daemon restart is uncertain; inspect the guest before retrying update");
415
+ await this.readinessWaiter(config.daemonHostBind, port);
416
+ await owner.assertHeld();
417
+ }
418
+ finally {
419
+ await staged.cleanup();
420
+ }
421
+ }
165
422
  async startInstance(config, workspaceRoot, name, daemonPort) {
166
423
  const mountExpression = `.mounts = [{"location":${JSON.stringify(workspaceRoot)},"mountPoint":${JSON.stringify(config.workspaceMountPath)},"writable":true}]`;
167
424
  const forwardExpression = `.portForwards += [{"guestPort":${daemonPort},"hostPort":${daemonPort},"hostIP":${JSON.stringify(config.daemonHostBind)}}]`;
@@ -179,29 +436,56 @@ export class LimaSandboxService {
179
436
  ];
180
437
  ensureLimaSuccessful(config.limaBinary, await this.runner.run(config.limaBinary, args), args);
181
438
  }
182
- async ensureGrip(config, name, managed) {
183
- const checkArgs = ["shell", "--tty=false", name, "sh", "-lc", "command -v grip >/dev/null"];
439
+ async ensureGrip(config, name, managed, local) {
440
+ if (local) {
441
+ await installLocalGuestBinary(this.runner, config.limaBinary, name, local, VERSION);
442
+ return;
443
+ }
444
+ const checkArgs = [
445
+ "shell",
446
+ "--tty=false",
447
+ name,
448
+ "sh",
449
+ "-lc",
450
+ 'command -v grip >/dev/null || test -x "$HOME/.local/bin/grip"',
451
+ ];
184
452
  const check = await this.runner.run(config.limaBinary, checkArgs);
185
453
  if (check.exitCode === 0)
186
454
  return;
187
455
  if (!managed) {
188
- throw new Error(`Lima instance ${name} does not provide grip; install @greppleai/grip in the VM first`);
456
+ throw new Error(`Lima instance ${name} does not provide grip; install the matching Linux release in the VM first`);
189
457
  }
190
- const packageSpec = `@greppleai/grip@${VERSION}`;
191
- const script = `set -eu; sudo apt-get update; sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs npm; sudo npm install -g ${shellQuote(packageSpec)}`;
458
+ const script = `set -eu; tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT; curl -fsSL -o "$tmp" https://raw.githubusercontent.com/greppleai/grip/main/install.sh; GRIP_VERSION=${shellQuote(`v${VERSION}`)} sh "$tmp"; test -x "$HOME/.local/bin/grip"`;
192
459
  const args = ["shell", "--tty=false", name, "sh", "-lc", script];
193
460
  ensureLimaSuccessful(config.limaBinary, await this.runner.run(config.limaBinary, args), args);
194
461
  }
462
+ /** A legacy named attach or stop must not rotate or kill a shared guest daemon. */
463
+ async assertNoSharedGuestState(config, name, operation = "attach") {
464
+ const script = 'test ! -e "$HOME/.cache/grip/shared-lima" && test ! -L "$HOME/.cache/grip/shared-lima"';
465
+ const args = ["shell", "--tty=false", name, "sh", "-lc", script];
466
+ const result = await this.runner.run(config.limaBinary, args);
467
+ // A guest shell failure is ambiguous too; never guess that shared state is absent.
468
+ if (result.exitCode !== 0) {
469
+ throw new Error(`Lima named VM may host a shared daemon; legacy ${operation} refused; reconcile manually`);
470
+ }
471
+ }
195
472
  async startDaemon(config, name) {
196
473
  const pidFile = `.cache/grip/sandbox-daemon-${config.daemonPort}.pid`;
197
474
  const logFile = `.cache/grip/sandbox-daemon-${config.daemonPort}.log`;
198
475
  const script = [
199
476
  "set -eu",
477
+ "umask 077",
200
478
  `mkdir -p "$HOME/.cache/grip"`,
479
+ 'mode_lock="$HOME/.cache/grip/daemon-mode.lock"',
480
+ 'mkdir "$mode_lock" || exit 28',
481
+ "trap 'rmdir \"$mode_lock\" 2>/dev/null || true' EXIT",
482
+ 'test ! -e "$HOME/.cache/grip/shared-lima" && test ! -L "$HOME/.cache/grip/shared-lima" || exit 29',
201
483
  `if test -f "$HOME/${pidFile}"; then old_pid="$(cat "$HOME/${pidFile}")"; kill "$old_pid" 2>/dev/null || true; fi`,
202
484
  `token="$(head -c 32 /dev/urandom | base64 | tr -d '\\n')"`,
203
- `nohup env GRIP_DAEMON_TOKEN="$token" grip --daemon --daemon-host 127.0.0.1 --daemon-allow-insecure-transport --daemon-port ${config.daemonPort} --daemon-cwd ${shellQuote(config.workspaceMountPath)} --daemon-allow-root --daemon-allow-process-exec >"$HOME/${logFile}" 2>&1 </dev/null &`,
485
+ `nohup env GRIP_DAEMON_TOKEN="$token" "${this.env.GRIP_SANDBOX_GUEST_BINARY ? "$HOME/.local/bin/grip" : '$(command -v grip || printf %s "$HOME/.local/bin/grip")'}" --daemon --daemon-host 127.0.0.1 --daemon-allow-insecure-transport --daemon-port ${config.daemonPort} --daemon-cwd ${shellQuote(config.workspaceMountPath)} --daemon-allow-root --daemon-allow-process-exec >"$HOME/${logFile}" 2>&1 </dev/null &`,
204
486
  `echo $! >"$HOME/${pidFile}"`,
487
+ 'rmdir "$mode_lock" || exit 28',
488
+ "trap - EXIT",
205
489
  `printf 'GRIP_SANDBOX_TOKEN=%s\\n' "$token"`,
206
490
  ].join("\n");
207
491
  const args = ["shell", "--tty=false", "--workdir", config.workspaceMountPath, name, "sh", "-lc", script];
@@ -217,20 +501,103 @@ export class LimaSandboxService {
217
501
  if (!mount || resolve(mount.location ?? "") !== workspaceRoot || !mount.writable) {
218
502
  throw new Error(`Lima instance ${name} must mount ${workspaceRoot} read-write at ${config.workspaceMountPath}`);
219
503
  }
220
- if (record.status?.toLowerCase() === "running")
504
+ const status = record.status?.toLowerCase();
505
+ if (status === "running")
221
506
  return;
507
+ if (status !== "stopped") {
508
+ throw new Error(`Lima instance ${name} is in an uncertain state; refusing to resume`);
509
+ }
222
510
  const args = ["start", "--tty=false", name];
223
511
  ensureLimaSuccessful(config.limaBinary, await this.runner.run(config.limaBinary, args), args);
224
512
  }
513
+ async findExactLegacyInstance(config, name) {
514
+ if (!name)
515
+ return undefined;
516
+ const records = await this.listRecords(config, name);
517
+ if (records.length === 0)
518
+ return undefined;
519
+ if (records.length !== 1 || records[0]?.name !== name) {
520
+ throw new Error("Lima instance lookup did not return one exact name; refusing to attach or create");
521
+ }
522
+ return records[0];
523
+ }
225
524
  async start(options) {
226
- if (options.image)
227
- throw new Error("Docker images are not supported by the Lima sandbox runtime");
525
+ const requestedName = options.name?.trim();
526
+ if (!requestedName) {
527
+ if (options.update)
528
+ throw new Error("Sandbox update requires a named running shared VM");
529
+ return this.startWithLegacyInstance(options);
530
+ }
531
+ // Coordinate VM resume/create as well as guest daemon launch with a named shared owner.
532
+ // A stale lock is ambiguous, not permission to start an existing VM.
533
+ const owner = await acquireSharedLimaOwnerLock(requestedName);
534
+ try {
535
+ const config = this.resolveConfig(options);
536
+ const existing = await this.findExactLegacyInstance(config, requestedName);
537
+ if (options.update && existing?.status?.toLowerCase() !== "running") {
538
+ throw new Error("Sandbox update requires an existing running shared VM");
539
+ }
540
+ if (existing?.status?.toLowerCase() !== "running") {
541
+ return await this.startWithLegacyInstance(options, { record: existing });
542
+ }
543
+ // Select the guest mode before any legacy setup. An unreadable or ambiguous
544
+ // marker is not permission to start a per-session daemon in this VM.
545
+ const probe = await this.runner.run(config.limaBinary, [
546
+ "shell",
547
+ "--tty=false",
548
+ requestedName,
549
+ "sh",
550
+ "-lc",
551
+ 'if test -e "$HOME/.cache/grip/shared-lima" || test -L "$HOME/.cache/grip/shared-lima"; then if test -d "$HOME/.cache/grip/shared-lima" && test ! -L "$HOME/.cache/grip/shared-lima"; then printf "GRIP_SHARED_GUEST\\n"; else exit 42; fi; else printf "GRIP_LEGACY_GUEST\\n"; fi',
552
+ ]);
553
+ if (probe.exitCode !== 0)
554
+ throw new Error("Lima guest daemon mode is uncertain; refusing named attach");
555
+ if (probe.stdout.trim() === "GRIP_LEGACY_GUEST") {
556
+ if (options.update)
557
+ throw new Error("Sandbox update requires a supervised shared guest; legacy guest is unchanged");
558
+ return await this.startWithLegacyInstance(options, { record: existing });
559
+ }
560
+ if (probe.stdout.trim() !== "GRIP_SHARED_GUEST") {
561
+ throw new Error("Lima guest daemon mode is uncertain; refusing named attach");
562
+ }
563
+ await owner.assertHeld();
564
+ const mount = findWorkspaceMount(existing, config.workspaceMountPath);
565
+ if (!mount?.location)
566
+ throw new Error("Shared Lima instance mount is unavailable; refusing attach");
567
+ const sharedOptions = {
568
+ instanceName: requestedName,
569
+ hostParent: resolve(mount.location),
570
+ hostRoot: resolve(options.workspaceRoot),
571
+ };
572
+ const inspection = await this.inspectSharedInstance(sharedOptions);
573
+ if (options.update)
574
+ await this.updateSharedDaemon(sharedOptions, owner);
575
+ const token = await this.ensureSharedDaemon(sharedOptions, owner, options.update);
576
+ const daemonUrl = `grip+${inspection.daemonUrl}?root=${encodeURIComponent(inspection.guestRoot)}`;
577
+ return {
578
+ containerId: requestedName,
579
+ containerName: requestedName,
580
+ workspaceRoot: inspection.hostRoot,
581
+ workspaceMountPath: inspection.guestRoot,
582
+ daemonUrl,
583
+ daemonUrlRedacted: inspection.daemonUrl,
584
+ token,
585
+ labels: { [`${SANDBOX_LABEL_PREFIX}.runtime`]: "lima", [`${SANDBOX_LABEL_PREFIX}.version`]: VERSION },
586
+ };
587
+ }
588
+ finally {
589
+ await owner.release();
590
+ }
591
+ }
592
+ async startWithLegacyInstance(options, known) {
228
593
  const workspaceRoot = resolve(options.workspaceRoot);
594
+ const local = this.env.GRIP_SANDBOX_GUEST_BINARY
595
+ ? await inspectLocalGuestBinary(this.env.GRIP_SANDBOX_GUEST_BINARY)
596
+ : undefined;
229
597
  const config = this.resolveConfig(options);
230
598
  const requestedName = options.name?.trim();
231
- const records = requestedName ? await this.listRecords(config, requestedName) : [];
232
599
  const managed = !requestedName;
233
- const existing = records[0];
600
+ const existing = known ? known.record : await this.findExactLegacyInstance(config, requestedName);
234
601
  if (existing)
235
602
  await this.prepareExistingInstance(config, existing, requestedName, workspaceRoot);
236
603
  const configuredForward = existing ? findDaemonForward(existing, config.daemonPort) : undefined;
@@ -242,28 +609,26 @@ export class LimaSandboxService {
242
609
  const effectiveConfig = { ...config, daemonPort };
243
610
  if (!existing)
244
611
  await this.startInstance(effectiveConfig, workspaceRoot, name, daemonPort);
245
- try {
246
- await this.ensureGrip(effectiveConfig, name, !existing);
247
- const token = await this.startDaemon(effectiveConfig, name);
248
- this.managedInstances.set(name, { workspaceRoot, daemonPort, ownerId: options.sessionId, managed });
249
- await this.readinessWaiter(config.daemonHostBind, daemonPort);
250
- const daemonUrl = `ws://${urlHost(daemonConnectionHost(config.daemonHostBind))}:${daemonPort}${DAEMON_WEBSOCKET_PATH}`;
251
- return {
252
- containerId: name,
253
- containerName: name,
254
- workspaceRoot,
255
- workspaceMountPath: config.workspaceMountPath,
256
- daemonUrl,
257
- daemonUrlRedacted: daemonUrl,
258
- token,
259
- labels: { [`${SANDBOX_LABEL_PREFIX}.runtime`]: "lima", [`${SANDBOX_LABEL_PREFIX}.version`]: VERSION },
260
- };
261
- }
262
- catch (error) {
263
- if (!existing)
264
- await this.removeInstance(effectiveConfig, name).catch(() => undefined);
265
- throw error;
266
- }
612
+ // A failed guest install/start can have changed state; never delete an uncertain VM.
613
+ if (existing)
614
+ await this.assertNoSharedGuestState(effectiveConfig, name);
615
+ await this.ensureGrip(effectiveConfig, name, !existing, local);
616
+ const token = await this.startDaemon(effectiveConfig, name);
617
+ await this.readinessWaiter(config.daemonHostBind, daemonPort);
618
+ // A failed readiness check is not proof that this daemon is ours to stop.
619
+ // Leave uncertain guest state for explicit operator reconciliation.
620
+ this.managedInstances.set(name, { workspaceRoot, daemonPort, ownerId: options.sessionId, managed });
621
+ const daemonUrl = `ws://${urlHost(daemonConnectionHost(config.daemonHostBind))}:${daemonPort}${DAEMON_WEBSOCKET_PATH}`;
622
+ return {
623
+ containerId: name,
624
+ containerName: name,
625
+ workspaceRoot,
626
+ workspaceMountPath: config.workspaceMountPath,
627
+ daemonUrl,
628
+ daemonUrlRedacted: daemonUrl,
629
+ token,
630
+ labels: { [`${SANDBOX_LABEL_PREFIX}.runtime`]: "lima", [`${SANDBOX_LABEL_PREFIX}.version`]: VERSION },
631
+ };
267
632
  }
268
633
  async list(options) {
269
634
  const config = this.resolveConfig();
@@ -278,27 +643,54 @@ export class LimaSandboxService {
278
643
  const args = ["shell", "--tty=false", name, "sh", "-lc", script];
279
644
  ensureLimaSuccessful(config.limaBinary, await this.runner.run(config.limaBinary, args), args);
280
645
  }
281
- async removeInstance(config, name) {
282
- const args = ["delete", "--force", name];
283
- ensureLimaSuccessful(config.limaBinary, await this.runner.run(config.limaBinary, args), args);
284
- }
285
646
  async stop(options) {
286
647
  const config = this.resolveConfig();
287
648
  const instances = await this.list({ workspaceRoot: options.workspaceRoot });
288
649
  const selected = selectLimaSandbox(instances, options.target ?? options.currentContainerId);
289
650
  if (!selected)
290
651
  return { status: "not-found", message: "No matching Grip Lima sandbox found" };
652
+ if (instances.filter((instance) => instance.name === selected.name).length !== 1) {
653
+ throw new Error("Lima sandbox identity is ambiguous; refusing to stop a VM or daemon");
654
+ }
291
655
  const tracked = this.managedInstances.get(selected.name);
292
- const ownsInstance = tracked?.managed ?? selected.name.startsWith(`${config.limaInstanceNamePrefix}-`);
656
+ // Names and Lima's list output do not establish ownership. In particular, a
657
+ // shared named VM can have the ordinary sandbox prefix without being ours.
658
+ // Only a VM started by this session may be stopped or have its daemon touched.
659
+ if (!tracked || tracked.workspaceRoot !== selected.workspaceRoot || tracked.daemonPort !== selected.daemonPort) {
660
+ throw new Error("Lima sandbox ownership is unknown or daemon port changed; refusing to stop an untracked VM or daemon");
661
+ }
662
+ // A shared guest must never lose its daemon or VM to a legacy
663
+ // in-process record. Resolve the selected VM again under the host owner lock.
664
+ const owner = await acquireSharedLimaOwnerLock(selected.name);
665
+ try {
666
+ const matches = (await this.list({ workspaceRoot: options.workspaceRoot })).filter((instance) => instance.name === selected.name);
667
+ const current = matches[0];
668
+ if (matches.length !== 1 ||
669
+ !current ||
670
+ current.id !== selected.id ||
671
+ current.state !== selected.state ||
672
+ current.workspaceRoot !== selected.workspaceRoot ||
673
+ current.daemonPort !== selected.daemonPort) {
674
+ throw new Error("Lima sandbox identity changed during stop; refusing to touch the VM or daemon");
675
+ }
676
+ return await this.stopTrackedInstance(config, current, tracked);
677
+ }
678
+ finally {
679
+ await owner.release();
680
+ }
681
+ }
682
+ async stopTrackedInstance(config, selected, tracked) {
683
+ const ownsInstance = tracked.managed;
293
684
  if (selected.state !== "running") {
294
685
  if (ownsInstance && config.cleanup === "remove") {
295
- await this.removeInstance(config, selected.name);
296
- this.managedInstances.delete(selected.name);
297
- return { status: "removed", container: selected };
686
+ // An already stopped VM cannot be probed without resuming it. Its
687
+ // in-memory record is not proof it was never repurposed externally.
688
+ throw new Error("Lima sandbox is stopped; refusing to delete an unverifiable VM; reconcile manually");
298
689
  }
299
690
  this.managedInstances.delete(selected.name);
300
691
  return { status: "already-stopped", container: selected };
301
692
  }
693
+ await this.assertNoSharedGuestState(config, selected.name, "stop");
302
694
  await this.stopDaemon(config, selected.name, tracked?.daemonPort ?? selected.daemonPort ?? config.daemonPort);
303
695
  if (ownsInstance) {
304
696
  const args = config.cleanup === "remove" ? ["delete", "--force", selected.name] : ["stop", selected.name];