@iinm/plain-agent 1.0.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 (79) hide show
  1. package/.config/agents.library/code-simplifier.md +5 -0
  2. package/.config/agents.library/qa-engineer.md +74 -0
  3. package/.config/agents.library/software-architect.md +278 -0
  4. package/.config/agents.predefined/worker.md +3 -0
  5. package/.config/config.predefined.json +825 -0
  6. package/.config/prompts.library/code-review.md +8 -0
  7. package/.config/prompts.library/feature-dev.md +6 -0
  8. package/.config/prompts.predefined/shortcuts/commit-by-user.md +9 -0
  9. package/.config/prompts.predefined/shortcuts/commit.md +10 -0
  10. package/.config/prompts.predefined/shortcuts/general-question.md +6 -0
  11. package/LICENSE +21 -0
  12. package/README.md +624 -0
  13. package/bin/plain +3 -0
  14. package/bin/plain-interrupt +6 -0
  15. package/bin/plain-notify-desktop +19 -0
  16. package/bin/plain-notify-terminal-bell +3 -0
  17. package/package.json +57 -0
  18. package/sandbox/bin/plain-sandbox +972 -0
  19. package/src/agent.d.ts +48 -0
  20. package/src/agent.mjs +159 -0
  21. package/src/agentLoop.mjs +369 -0
  22. package/src/agentState.mjs +41 -0
  23. package/src/cliArgs.mjs +45 -0
  24. package/src/cliFormatter.mjs +217 -0
  25. package/src/cliInteractive.mjs +739 -0
  26. package/src/config.d.ts +48 -0
  27. package/src/config.mjs +168 -0
  28. package/src/context/consumeInterruptMessage.mjs +30 -0
  29. package/src/context/loadAgentRoles.mjs +272 -0
  30. package/src/context/loadPrompts.mjs +312 -0
  31. package/src/context/loadUserMessageContext.mjs +147 -0
  32. package/src/env.mjs +46 -0
  33. package/src/main.mjs +202 -0
  34. package/src/mcp.mjs +202 -0
  35. package/src/model.d.ts +109 -0
  36. package/src/modelCaller.mjs +29 -0
  37. package/src/modelDefinition.d.ts +73 -0
  38. package/src/prompt.mjs +128 -0
  39. package/src/providers/anthropic.d.ts +248 -0
  40. package/src/providers/anthropic.mjs +596 -0
  41. package/src/providers/gemini.d.ts +208 -0
  42. package/src/providers/gemini.mjs +752 -0
  43. package/src/providers/openai.d.ts +281 -0
  44. package/src/providers/openai.mjs +551 -0
  45. package/src/providers/openaiCompatible.d.ts +147 -0
  46. package/src/providers/openaiCompatible.mjs +658 -0
  47. package/src/providers/platform/azure.mjs +42 -0
  48. package/src/providers/platform/bedrock.mjs +74 -0
  49. package/src/providers/platform/googleCloud.mjs +34 -0
  50. package/src/subagent.mjs +247 -0
  51. package/src/tmpfile.mjs +27 -0
  52. package/src/tool.d.ts +74 -0
  53. package/src/toolExecutor.mjs +236 -0
  54. package/src/toolInputValidator.mjs +183 -0
  55. package/src/toolUseApprover.mjs +98 -0
  56. package/src/tools/askGoogle.mjs +135 -0
  57. package/src/tools/delegateToSubagent.d.ts +4 -0
  58. package/src/tools/delegateToSubagent.mjs +48 -0
  59. package/src/tools/execCommand.d.ts +22 -0
  60. package/src/tools/execCommand.mjs +200 -0
  61. package/src/tools/fetchWebPage.mjs +96 -0
  62. package/src/tools/patchFile.d.ts +4 -0
  63. package/src/tools/patchFile.mjs +96 -0
  64. package/src/tools/reportAsSubagent.d.ts +3 -0
  65. package/src/tools/reportAsSubagent.mjs +44 -0
  66. package/src/tools/tavilySearch.d.ts +6 -0
  67. package/src/tools/tavilySearch.mjs +57 -0
  68. package/src/tools/tmuxCommand.d.ts +14 -0
  69. package/src/tools/tmuxCommand.mjs +194 -0
  70. package/src/tools/writeFile.d.ts +4 -0
  71. package/src/tools/writeFile.mjs +56 -0
  72. package/src/utils/evalJSONConfig.mjs +48 -0
  73. package/src/utils/matchValue.d.ts +6 -0
  74. package/src/utils/matchValue.mjs +40 -0
  75. package/src/utils/noThrow.mjs +31 -0
  76. package/src/utils/notify.mjs +28 -0
  77. package/src/utils/parseFileRange.mjs +18 -0
  78. package/src/utils/readFileRange.mjs +33 -0
  79. package/src/utils/retryOnError.mjs +41 -0
@@ -0,0 +1,972 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -eu -o pipefail
4
+
5
+ SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
6
+ SCRIPT_NAME=$(basename "${BASH_SOURCE[0]}")
7
+
8
+ MOUNTABLE_SCRIPT_PATH="$HOME/.cache/$SCRIPT_NAME/$SCRIPT_NAME"
9
+ CONTAINER_SCRIPT_PATH="/sandbox/bin/$SCRIPT_NAME"
10
+
11
+ help() {
12
+ cat << HELP
13
+ $SCRIPT_NAME - Run a command in a sandboxed Docker environment
14
+
15
+ Usage: $SCRIPT_NAME [--dockerfile FILE]
16
+ [--platform PLATFORM]
17
+ [--env-file FILE]
18
+ [--allow-write] [--allow-net [DESTINATIONS|--]]
19
+ [--volume [NAME:]PATH]
20
+ [--mount-writable HOST_DIR:CONTAINER_DIR]
21
+ [--mount-readonly HOST_DIR:CONTAINER_DIR]
22
+ [--publish [HOST_ADDRESS:]HOST_PORT:CONTAINER_PORT]
23
+ [--tty]
24
+ [--no-cache]
25
+ [--skip-build]
26
+ [--verbose] [--dry-run]
27
+ [--keep-alive SECONDS]
28
+ COMMAND
29
+
30
+
31
+ Options:
32
+
33
+ --dockerfile FILE Path to a Dockerfile. If not set, a preset Dockerfile
34
+ and network policy is used.
35
+ The container must include busybox, bash, iptables, ipset,
36
+ dnsmasq, and dig commands.
37
+ --env-file FILE Path to a .env file to load environment variables from.
38
+ --platform Specify the platform for image building and container execution
39
+ (e.g., linux/arm64 or linux/amd64).
40
+ --allow-write Allow write access to the project root inside the container.
41
+ By default, the project root (git root or current directory)
42
+ is read-only.
43
+ --allow-net DESTINATIONS Allow connections to specified domains or IP addresses.
44
+ Separate multiple destinations with commas.
45
+ If no port is given, only HTTPS (443) is allowed.
46
+ --volume [NAME:]PATH Mount a writable Docker volume at PATH inside the container.
47
+ If PATH is absolute, it is used as-is inside the container.
48
+ if PATH is relative, it is mounted relative to the project root
49
+ inside the container.
50
+ If NAME is not given, the volume name is generated.
51
+
52
+ --mount-readonly HOST_DIR:CONTAINER_DIR
53
+ Mount a host file or directory to a container path as read-only.
54
+
55
+ --mount-writable HOST_DIR:CONTAINER_DIR
56
+ Mount a host file or directory to a container path as writable.
57
+
58
+ --publish [HOST_ADDRESS:]HOST_PORT:CONTAINER_PORT
59
+ Publish container port(s) to the host.
60
+ If no host address is given, ports bind to 127.0.0.1 by default.
61
+
62
+ --tty Allocate a pseudo-TTY for the container.
63
+ --no-cache Disables cache when building the image.
64
+ --skip-build Skips building the image; assumes it already exists.
65
+ --verbose Output verbose logs to stderr.
66
+ --dry-run Does not execute the command; just prints it to stdout.
67
+ --keep-alive SECONDS Keep the container running after COMMAND finishes, so it can be reused.
68
+ Stop the container if no command is running for SECONDS.
69
+ Default: 0 (stop within ~5s after execution).
70
+
71
+
72
+ Examples:
73
+
74
+ Start shell with preset configuration:
75
+ $SCRIPT_NAME --tty --verbose zsh
76
+
77
+ Check preset configuration:
78
+ $SCRIPT_NAME --tty --verbose --dry-run zsh
79
+
80
+ Start Claude Code:
81
+ $SCRIPT_NAME --allow-write \\
82
+ --allow-net api.anthropic.com,sentry.io,statsig.anthropic.com,statsig.com \\
83
+ --mount-writable ~/.claude:/home/sandbox/.claude,~/.claude.json:/home/sandbox/.claude.json \\
84
+ --mount-readonly ~/.gitconfig:/home/sandbox/.gitconfig \\
85
+ --tty --verbose claude
86
+
87
+ Start Claude Code using Amazon Bedrock:
88
+ Configure environment variables as described at: https://code.claude.com/docs/en/amazon-bedrock
89
+
90
+ $SCRIPT_NAME --env-file .env.sandbox --allow-write --allow-net \\
91
+ --mount-writable ~/.claude:/home/sandbox/.claude,~/.claude.json:/home/sandbox/.claude.json \\
92
+ --mount-readonly ~/.gitconfig:/home/sandbox/.gitconfig \\
93
+ --mount-readonly ~/.aws:/home/sandbox/.aws \\
94
+ --allow-net bedrock-runtime.ap-northeast-1.amazonaws.com \\
95
+ --allow-net bedrock.ap-northeast-1.amazonaws.com \\
96
+ --allow-net oidc.ap-northeast-1.amazonaws.com \\
97
+ --allow-net portal.sso.ap-northeast-1.amazonaws.com \\
98
+ --tty --verbose claude
99
+
100
+ Start Codex CLI:
101
+ $SCRIPT_NAME --allow-write \\
102
+ --allow-net api.openai.com \\
103
+ --mount-writable ~/.codex:/home/sandbox/.codex \\
104
+ --mount-readonly ~/.gitconfig:/home/sandbox/.gitconfig \\
105
+ --tty --verbose codex
106
+
107
+ Start Gemini CLI:
108
+ $SCRIPT_NAME --allow-write \\
109
+ --allow-net generativelanguage.googleapis.com,oauth2.googleapis.com,cloudcode-pa.googleapis.com,play.googleapis.com,registry.npmjs.org,github.com,release-assets.githubusercontent.com \\
110
+ --mount-writable ~/.gemini:/home/sandbox/.gemini \\
111
+ --mount-readonly ~/.gitconfig:/home/sandbox/.gitconfig \\
112
+ --tty --verbose gemini
113
+
114
+ Install tools with mise:
115
+ $SCRIPT_NAME --allow-net mise-versions.jdx.dev,nodejs.org --verbose mise install node
116
+ $SCRIPT_NAME --allow-net mise-versions.jdx.dev,github.com,dl.google.com mise install go
117
+
118
+ Use volume:
119
+ $SCRIPT_NAME --volume $SCRIPT_NAME--global--home-npm:/home/node/.npm \\
120
+ --volume node_modules \\
121
+ --verbose npm install
122
+
123
+ Allow access to docker host:
124
+ $SCRIPT_NAME --allow-net host.docker.internal:3000 \\
125
+ --verbose busybox nc host.docker.internal 3000 < /dev/null
126
+
127
+ Run with Dockerfile:
128
+ $SCRIPT_NAME --dockerfile Dockerfile.minimum --tty --verbose bash
129
+
130
+
131
+ Preset Configuration:
132
+
133
+ When --dockerfile is not specified, a preset Node.js LTS image is used with:
134
+ - System packages: busybox, bash, zsh (with grml config), ripgrep, fd, dig, curl, git
135
+ - mise package manager for additional runtime installations
136
+ - AI coding assistants: Claude Code, Gemini CLI, Codex CLI
137
+ - Persistent storage for shell history, git config, and AI tool configurations
138
+ - Default editor: busybox vi
139
+
140
+
141
+ How to view DNS query log:
142
+
143
+ DNS queries are logged by dnsmasq and can only be viewed through Docker logs.
144
+
145
+ Find the container name:
146
+ docker ps | grep $SCRIPT_NAME
147
+
148
+ View DNS query logs in real-time
149
+ docker logs -f <container-name>
150
+
151
+ Custom entrypoint:
152
+ If /sandbox/bin/user-entrypoint.sh exists and is executable,
153
+ it will be executed before the main command. Use this for custom
154
+ initialization (e.g., setting environment variables, starting services).
155
+ HELP
156
+ }
157
+
158
+ log() {
159
+ echo -e "[$(date "+%Y-%m-%d %H:%M:%S%z")][$(hostname)] $*"
160
+ }
161
+
162
+ # These variables are global because they are accessed in the on_exit trap after main() returns.
163
+ VERBOSE="no"
164
+ DRY_RUN="no"
165
+ IMAGE_NAME=
166
+
167
+ main() {
168
+ # Options
169
+ local dockerfile=""
170
+ local allow_write="no"
171
+ local allow_net="no"
172
+ local allow_net_destinations=()
173
+ local volume_dirs=()
174
+ local readonly_mounts=()
175
+ local writable_mounts=()
176
+ local publish_ports=()
177
+ local env_file=""
178
+ local allocate_tty="no"
179
+ local no_cache="no"
180
+ local skip_build="no"
181
+ local platform=""
182
+ local keep_alive_seconds="0"
183
+
184
+ while test "$#" -gt 0; do
185
+ case "$1" in
186
+ --help ) help; return 0 ;;
187
+ --dockerfile ) dockerfile="$2"; shift 2 ;;
188
+ --platform ) platform="$2"; shift 2 ;;
189
+ --allow-write ) allow_write="yes"; shift ;;
190
+ --allow-net )
191
+ allow_net="yes"
192
+ if test -n "$2" && ! grep -q "^--" <<< "$2"; then
193
+ local new_allow_net_destinations
194
+ IFS=',' read -r -a new_allow_net_destinations <<< "$2"
195
+ allow_net_destinations+=("${new_allow_net_destinations[@]}")
196
+ shift 2
197
+ else
198
+ shift 1
199
+ fi
200
+ ;;
201
+ --volume )
202
+ local new_volume_dirs
203
+ IFS=',' read -r -a new_volume_dirs <<< "$2"
204
+ volume_dirs+=("${new_volume_dirs[@]}")
205
+ shift 2
206
+ ;;
207
+ --mount-readonly )
208
+ local new_readonly_mounts
209
+ IFS=',' read -r -a new_readonly_mounts <<< "$2"
210
+ readonly_mounts+=("${new_readonly_mounts[@]}")
211
+ shift 2
212
+ ;;
213
+ --mount-writable )
214
+ local new_writable_mounts
215
+ IFS=',' read -r -a new_writable_mounts <<< "$2"
216
+ writable_mounts+=("${new_writable_mounts[@]}")
217
+ shift 2
218
+ ;;
219
+ --publish )
220
+ local new_publish_ports
221
+ IFS=',' read -r -a new_publish_ports <<< "$2"
222
+ publish_ports+=("${new_publish_ports[@]}")
223
+ shift 2
224
+ ;;
225
+ --env-file ) env_file="$2"; shift 2 ;;
226
+ --tty ) allocate_tty="yes"; shift ;;
227
+ --no-cache ) no_cache="yes"; shift ;;
228
+ --skip-build ) skip_build="yes"; shift ;;
229
+ --verbose ) VERBOSE="yes"; shift ;;
230
+ --dry-run ) DRY_RUN="yes"; shift ;;
231
+ --keep-alive ) keep_alive_seconds="$2"; shift 2 ;;
232
+ -- ) shift; break ;;
233
+ --* ) echo "Error: Unknown option: $1" >&2; return 1 ;;
234
+ * ) break ;;
235
+ esac
236
+ done
237
+
238
+ # Save arguments to generate container id
239
+ local sandbox_args=(
240
+ "$dockerfile"
241
+ "$allow_write"
242
+ "$allow_net"
243
+ "${allow_net_destinations[@]:-}"
244
+ "${volume_dirs[@]:-}"
245
+ "${readonly_mounts[@]:-}"
246
+ "${writable_mounts[@]:-}"
247
+ "${publish_ports[@]:-}"
248
+ "$env_file"
249
+ "$platform"
250
+ )
251
+
252
+ if test "$#" -eq 0; then
253
+ help >&2
254
+ return 1
255
+ fi
256
+
257
+ # save stdout, stderr
258
+ exec 3>&1
259
+ exec 4>&2
260
+
261
+ if test "$VERBOSE" = "no"; then
262
+ # discard stderr
263
+ exec 2> /dev/null
264
+ fi
265
+
266
+ # stdout to stderr
267
+ exec 1>&2
268
+
269
+ run() {
270
+ if test "$DRY_RUN" = "no"; then
271
+ "$@"
272
+ else
273
+ echo "DRY_RUN: $*" >&3
274
+ fi
275
+ }
276
+
277
+ log "Copy script to mountable path: $SCRIPT_PATH -> $MOUNTABLE_SCRIPT_PATH"
278
+ run mkdir -p "$(dirname "$MOUNTABLE_SCRIPT_PATH")"
279
+ run cp -f "$SCRIPT_PATH" "$MOUNTABLE_SCRIPT_PATH"
280
+ run chmod +x "$MOUNTABLE_SCRIPT_PATH"
281
+
282
+ local host_user_id
283
+ local host_group_id
284
+ host_user_id=$(id -u)
285
+ host_group_id=$(id -g)
286
+
287
+ local host_project_root
288
+ local container_project_root
289
+ local container_workdir
290
+ host_project_root="$(git rev-parse --show-toplevel || pwd)"
291
+ container_project_root="$host_project_root"
292
+ container_workdir=$(pwd)
293
+
294
+ local project_id
295
+ if which shasum &> /dev/null; then
296
+ project_id="$(shasum -a 256 <<< "$(pwd)" | head -c 8)"
297
+ elif which sha256sum &> /dev/null; then
298
+ project_id="$(sha256sum <<< "$(pwd)" | head -c 8)"
299
+ else
300
+ echo "Error: Neither shasum nor sha256sum found. Please install one of them." >&2
301
+ return 1
302
+ fi
303
+
304
+ IMAGE_NAME="${SCRIPT_NAME}--$(basename "$(pwd)")-$project_id"
305
+
306
+ local image_tag
307
+ image_tag="latest"
308
+
309
+ local container_id
310
+ if which shasum &> /dev/null; then
311
+ container_id="$(shasum -a 256 <<< "${sandbox_args[@]}" | head -c 8)"
312
+ elif which sha256sum &> /dev/null; then
313
+ container_id="$(sha256sum <<< "${sandbox_args[@]}" | head -c 8)"
314
+ else
315
+ echo "Error: Neither shasum nor sha256sum found. Please install one of them." >&2
316
+ return 1
317
+ fi
318
+
319
+ local container_name="${IMAGE_NAME}--${container_id}"
320
+ local network_name="${container_name}"
321
+
322
+ if test -z "$dockerfile"; then
323
+ log "Dockerfile not specified, using preset configuration."
324
+ "$SCRIPT_PATH" print_preset_dockerfile
325
+
326
+ local platform_with_default="default"
327
+ if test -n "$platform"; then
328
+ platform_with_default="$platform"
329
+ fi
330
+
331
+ volume_dirs+=(
332
+ # global persistent volume
333
+ "${SCRIPT_NAME}--global--${platform_with_default}--mise-data:/persistent/mise-data"
334
+ # project local persistent volume
335
+ "${IMAGE_NAME}--home:/persistent/home"
336
+ )
337
+ fi
338
+
339
+ # docker options
340
+ local docker_build_opts=(--tag "$IMAGE_NAME:$image_tag")
341
+ local docker_run_opts=(
342
+ --detach
343
+ --rm
344
+ --name "$container_name"
345
+ --entrypoint ""
346
+ --user 0:0
347
+ --mount "type=bind,source=${MOUNTABLE_SCRIPT_PATH},target=${CONTAINER_SCRIPT_PATH},readonly"
348
+ )
349
+ local docker_exec_opts=(
350
+ --interactive
351
+ --user "$host_user_id:$host_group_id"
352
+ --workdir "$container_workdir"
353
+ )
354
+
355
+ local docker_build_context=""
356
+ if test -n "$dockerfile"; then
357
+ docker_build_opts+=(--file "$dockerfile")
358
+ docker_build_context=$(dirname "$dockerfile")
359
+ fi
360
+
361
+ if test "$no_cache" = "yes"; then
362
+ docker_build_opts+=(--no-cache)
363
+ fi
364
+
365
+ if test -n "$platform"; then
366
+ docker_build_opts+=(--platform "$platform")
367
+ docker_run_opts+=(--platform "$platform")
368
+ fi
369
+
370
+ if test -n "$env_file"; then
371
+ docker_run_opts+=(--env-file "$env_file")
372
+ fi
373
+
374
+ if test "$allow_write" = "yes"; then
375
+ docker_run_opts+=(--mount "type=bind,source=${host_project_root},target=${container_project_root},consistency=delegated")
376
+ else
377
+ docker_run_opts+=(--mount "type=bind,source=${host_project_root},target=${container_project_root},readonly,consistency=delegated")
378
+ fi
379
+
380
+ if test "$allow_net" = "yes"; then
381
+ docker_run_opts+=(
382
+ --cap-add NET_ADMIN
383
+ --cap-add NET_RAW
384
+ --net "$network_name"
385
+ --add-host host.docker.internal:host-gateway
386
+ )
387
+ else
388
+ docker_run_opts+=(--net none)
389
+ fi
390
+
391
+ # volume options
392
+ local volume_mount_paths=()
393
+ if test "${#volume_dirs[@]}" -gt 0; then
394
+ local volume_name
395
+ local mount_path
396
+
397
+ for dir in "${volume_dirs[@]}"; do
398
+ if grep -qE ':' <<< "$dir"; then
399
+ IFS=':' read -r volume_name mount_path <<< "$dir"
400
+ else
401
+ volume_name="${IMAGE_NAME}--$(echo "$dir" | sed 's,/,-,g; s,\.,-dot-,g')"
402
+ mount_path="$dir"
403
+ if ! grep -qE "^/" <<< "$mount_path"; then
404
+ if ! test -e "$dir"; then
405
+ mkdir -p "$dir"
406
+ fi
407
+ mount_path=$(readlink -f "$dir")
408
+ fi
409
+ fi
410
+
411
+ docker_run_opts+=(--mount "type=volume,source=${volume_name},target=${mount_path},consistency=delegated")
412
+ volume_mount_paths+=("$mount_path")
413
+ done
414
+ fi
415
+
416
+ # mount options
417
+ if test "${#readonly_mounts[@]}" -gt 0; then
418
+ for mount in "${readonly_mounts[@]}"; do
419
+ local host_path
420
+ local container_path
421
+ IFS=':' read -r host_path container_path <<< "$mount"
422
+ local host_abs_path="$host_path"
423
+ if ! grep -qE '^/' <<< "$host_abs_path"; then
424
+ # shellcheck disable=SC2001
425
+ host_abs_path=$(readlink -f "$(sed "s,~/,$HOME/," <<< "$host_abs_path")")
426
+ fi
427
+ docker_run_opts+=(--mount "type=bind,source=${host_abs_path},target=${container_path},readonly,consistency=delegated")
428
+ done
429
+ fi
430
+
431
+ if test "${#writable_mounts[@]}" -gt 0; then
432
+ for mount in "${writable_mounts[@]}"; do
433
+ local host_path
434
+ local container_path
435
+ IFS=':' read -r host_path container_path <<< "$mount"
436
+ local host_abs_path="$host_path"
437
+ if ! grep -qE '^/' <<< "$host_abs_path"; then
438
+ # shellcheck disable=SC2001
439
+ host_abs_path=$(readlink -f "$(sed "s,~/,$HOME/," <<< "$host_abs_path")")
440
+ fi
441
+ docker_run_opts+=(--mount "type=bind,source=${host_abs_path},target=${container_path},consistency=delegated")
442
+ done
443
+ fi
444
+
445
+ # publish options
446
+ if test "${#publish_ports[@]}" -gt 0; then
447
+ for port in "${publish_ports[@]}"; do
448
+ if grep -qE '.+:.+:.+' <<< "$port"; then
449
+ # host_address:host_port:container_port
450
+ IFS=':' read -r host_address host_port container_port <<< "$port"
451
+ docker_run_opts+=(--publish "${host_address}:${host_port}:${container_port}")
452
+ elif grep -qE '.+:.+' <<< "$port"; then
453
+ # host_port:container_port
454
+ IFS=':' read -r host_port container_port <<< "$port"
455
+ docker_run_opts+=(--publish "127.0.0.1:${host_port}:${container_port}")
456
+ else
457
+ echo "Error: Invalid port format: $port" >&2
458
+ return 1
459
+ fi
460
+ done
461
+ fi
462
+
463
+ if test "$allocate_tty" = "yes"; then
464
+ docker_exec_opts+=(--tty)
465
+ fi
466
+
467
+ local host_timezone
468
+ if test -n "${TZ:-}"; then
469
+ host_timezone="$TZ"
470
+ elif readlink /etc/localtime | grep -q "/zoneinfo/"; then
471
+ host_timezone="$(readlink /etc/localtime | awk -F '/' '{print $(NF-1)"/"$NF}')"
472
+ elif test -f /etc/localtime; then
473
+ host_timezone="$(cat /etc/timezone)"
474
+ fi
475
+
476
+ if test -n "$host_timezone"; then
477
+ docker_run_opts+=(--env "TZ=${host_timezone}")
478
+ fi
479
+
480
+ # shellcheck disable=SC2001
481
+ log "$(cat << EOF
482
+ Sandbox Configurations:
483
+ docker_build_opts=
484
+ $(echo "${docker_build_opts[*]}" | sed 's, ,\n ,g')
485
+ docker_build_context=$docker_build_context
486
+ docker_run_opts=
487
+ $(echo "${docker_run_opts[*]}" | sed 's, ,\n ,g')
488
+ docker_exec_opts=
489
+ $(echo "${docker_exec_opts[*]}" | sed 's, ,\n ,g')
490
+ command=$@
491
+ EOF
492
+ )"
493
+
494
+ on_exit() {
495
+ local exit_status="$?"
496
+
497
+ if test "$VERBOSE" = "no"; then
498
+ # discard stderr
499
+ exec 2> /dev/null
500
+ fi
501
+ exec 1>&2
502
+
503
+ run cleanup_networks "$IMAGE_NAME"
504
+
505
+ exec 3>&-
506
+ exec 4>&-
507
+
508
+ return "$exit_status"
509
+ }
510
+
511
+ trap 'on_exit' EXIT
512
+
513
+ if test "$skip_build" = "yes"; then
514
+ log "Skip building docker image: $IMAGE_NAME"
515
+ else
516
+ log "Building docker image: $IMAGE_NAME"
517
+ if test -n "$dockerfile"; then
518
+ run docker build "${docker_build_opts[@]}" "$docker_build_context"
519
+ else
520
+ run docker build "${docker_build_opts[@]}" - \
521
+ < <("$SCRIPT_PATH" print_preset_dockerfile)
522
+ fi
523
+ fi
524
+
525
+ if test "$skip_build" = "yes" -a "$(docker inspect --type container "$container_name" --format "{{ .State.Running }}" 2> /dev/null)" = "true"; then
526
+ log "Container is already running. Reusing $container_name"
527
+ else
528
+ log "Stopping any existing container: $container_name"
529
+ run docker stop "$container_name" 2> /dev/null || true
530
+
531
+ log "Remove any existing network: $network_name"
532
+ run docker network rm "$network_name" 2> /dev/null || true
533
+
534
+ if test "$allow_net" = "yes"; then
535
+ log "Creating network: $network_name"
536
+ run docker network create "$network_name" --driver bridge \
537
+ -o com.docker.network.bridge.enable_ip_masquerade=true
538
+ fi
539
+
540
+ log "Inspect container user and group id."
541
+ local container_default_user_group
542
+ local container_default_user_id
543
+ local container_default_group_id
544
+ if test "$DRY_RUN" = "no"; then
545
+ container_default_user_group=$(docker run --rm "${IMAGE_NAME}:${image_tag}" bash -c 'echo $(id -u):$(id -g)')
546
+ else
547
+ container_default_user_group="unknown:unknown"
548
+ fi
549
+ container_default_user_id=$(cut -d: -f1 <<< "$container_default_user_group")
550
+ container_default_group_id=$(cut -d: -f2 <<< "$container_default_user_group")
551
+ log "Container default user and group id: $container_default_user_group"
552
+
553
+ log "Starting container."
554
+ run docker run "${docker_run_opts[@]}" "${IMAGE_NAME}:${image_tag}" \
555
+ "$CONTAINER_SCRIPT_PATH" start_container_dnsmasq
556
+
557
+ if test "${#volume_mount_paths[@]}" -gt 0; then
558
+ log "Setting up volume ownership to match host user ($host_user_id:$host_group_id)."
559
+ run docker exec --user 0:0 "$container_name" "$CONTAINER_SCRIPT_PATH" setup_container_volume_owner \
560
+ "$host_user_id" "$host_group_id" "${volume_mount_paths[@]}"
561
+ fi
562
+
563
+ if test "$allow_net" = "yes"; then
564
+ log "Setting up firewall."
565
+ run docker exec --user 0:0 "$container_name" \
566
+ "$CONTAINER_SCRIPT_PATH" setup_container_firewall "${allow_net_destinations[@]}"
567
+ fi
568
+
569
+ log "Setting up container user to match host user ($host_user_id:$host_group_id) for file access."
570
+ run docker exec --user 0:0 "$container_name" "$CONTAINER_SCRIPT_PATH" setup_container_user \
571
+ "$container_default_user_id" "$container_default_group_id" \
572
+ "$host_user_id" "$host_group_id"
573
+ fi
574
+
575
+ run docker exec --detach --user 0:0 "$container_name" \
576
+ "$CONTAINER_SCRIPT_PATH" terminate_idle_container "$keep_alive_seconds"
577
+
578
+ # restore stdout, stderr
579
+ exec 1>&3
580
+ exec 2>&4
581
+
582
+ run docker exec "${docker_exec_opts[@]}" "$container_name" \
583
+ "$CONTAINER_SCRIPT_PATH" container_exec "$container_project_root" "$@"
584
+ }
585
+
586
+ terminate_idle_container() {
587
+ local keep_alive_seconds="$1"
588
+
589
+ local last_activity_time
590
+ last_activity_time=$(date +%s)
591
+
592
+ # stop existing watchdog process
593
+ for pid in $(busybox ps -o pid=,user=,comm=,args= | awk '$1 > 1 && $0 ~ /terminate_idle_container/ && $3 !~ /awk/ { print $1 }'); do
594
+ if test "$pid" -ne "$$"; then
595
+ kill "$pid" || true
596
+ fi
597
+ done
598
+
599
+ while sleep 5; do
600
+ if busybox ps -o pid=,user=,comm=,args= | awk '$1 > 1 && $2 !~ /root/ { print; found=1 } END { if (!found) exit 1 }'; then
601
+ last_activity_time=$(date +%s)
602
+ elif test "$(( $(date +%s) - last_activity_time ))" -ge "$keep_alive_seconds"; then
603
+ kill 1
604
+ fi
605
+ done
606
+ }
607
+
608
+ cleanup_networks() {
609
+ local image_name="$1"
610
+
611
+ log "Cleaning up networks."
612
+ local network_name
613
+ for network_name in $(docker network ls --format '{{.Name}}' --filter "name=^$image_name"); do
614
+ log "Checking if network $network_name is used."
615
+ # Note: network_name = container_name
616
+ if ! docker inspect --type container "$network_name" &> /dev/null; then
617
+ log "Removing network."
618
+ docker network rm "$network_name" || true
619
+ fi
620
+ done
621
+ }
622
+
623
+ container_exec() {
624
+ local project_dir="$1"
625
+ shift 1
626
+
627
+ # Wait for the project directory owner to match the execution user (on colima).
628
+ local max_attempts=20
629
+ for _ in $(seq "$max_attempts"); do
630
+ if stat -c "%U:%G" "$project_dir" | grep -qE "$(id -un):$(id -gn)"; then
631
+ if test -x /sandbox/bin/user-entrypoint.sh; then
632
+ exec /sandbox/bin/user-entrypoint.sh "$@"
633
+ else
634
+ exec "$@"
635
+ fi
636
+ fi
637
+ sleep 0.5
638
+ done
639
+
640
+ echo "Error: Could not match project directory owner after multiple attempts." >&2
641
+ exit 1
642
+ }
643
+
644
+ start_container_dnsmasq() {
645
+ local servers=()
646
+ IFS=$'\n' read -r -a servers < <(grep nameserver /etc/resolv.conf | awk '{print $2}')
647
+
648
+ mkdir -p /sandbox/etc
649
+ cat > /sandbox/etc/dnsmasq.conf << 'EOF'
650
+ log-queries
651
+ listen-address=127.0.0.1
652
+ EOF
653
+ for server in "${servers[@]}"; do
654
+ echo "server=$server" >> /sandbox/etc/dnsmasq.conf
655
+ done
656
+ echo "nameserver 127.0.0.1" > /etc/resolv.conf
657
+
658
+ exec dnsmasq -k -C /sandbox/etc/dnsmasq.conf --log-facility /dev/stdout
659
+ }
660
+
661
+ setup_container_volume_owner() {
662
+ local host_user_id=$1
663
+ local host_group_id=$2
664
+ shift 2
665
+
666
+ for mount_path in "$@"; do
667
+ chown "$host_user_id:$host_group_id" "$mount_path"
668
+ done
669
+ }
670
+
671
+ setup_container_firewall() {
672
+ local destinations=("$@")
673
+ local addresses=()
674
+
675
+ local address_pattern='^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(/[0-9]{1,2})?$'
676
+
677
+ for destination in "${destinations[@]}"; do
678
+ local host="$destination"
679
+ local port=''
680
+ if grep -q ':' <<< "$destination"; then
681
+ host=$(cut -d: -f1 <<< "$destination")
682
+ port=$(cut -d: -f2 <<< "$destination")
683
+ fi
684
+
685
+ # address
686
+ if grep -qE "$address_pattern" <<< "$host"; then
687
+ echo "Allow outgoing connections to $destination"
688
+ if test -n "$port"; then
689
+ addresses+=("$host:$port")
690
+ else
691
+ addresses+=("$host")
692
+ fi
693
+ continue
694
+ fi
695
+
696
+ # domain
697
+ local domain_address_count=0
698
+ for address in $(getent hosts "$host" | awk '{print $1}'; dig +short A "$host"); do
699
+ if ! grep -qE "$address_pattern" <<< "$address"; then
700
+ log "Warning: Ignoring invalid address $address of $host"
701
+ continue
702
+ fi
703
+
704
+ if test -n "$port"; then
705
+ log "Allow outgoing connections to $destination $address:$port"
706
+ addresses+=("$address:$port")
707
+ else
708
+ log "Allow outgoing connections to $destination $address"
709
+ addresses+=("$address")
710
+ fi
711
+
712
+ domain_address_count=$((domain_address_count + 1))
713
+ done
714
+
715
+ if test "$domain_address_count" = 0; then
716
+ echo "Error: Failed to resolve any address for $host" >&2
717
+ return 1
718
+ fi
719
+ done
720
+
721
+ local docker_host_address
722
+ docker_host_address=$(busybox ip route | grep default | cut -d" " -f3)
723
+ if test -z "$docker_host_address"; then
724
+ echo "Error: Failed to determine docker host address" >&2
725
+ return 1
726
+ fi
727
+
728
+ # reset
729
+ iptables -F
730
+ iptables -X
731
+ # iptables -t nat -F
732
+ # iptables -t nat -X
733
+ iptables -t mangle -F
734
+ iptables -t mangle -X
735
+
736
+ ip6tables -F
737
+ ip6tables -X
738
+ ip6tables -t mangle -F
739
+ ip6tables -t mangle -X
740
+
741
+ ipset create allow_list hash:net,port -exist
742
+ ipset flush allow_list
743
+
744
+ # allow dns
745
+ iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
746
+ iptables -A INPUT -p udp --sport 53 -j ACCEPT
747
+
748
+ # allow localhost
749
+ iptables -A INPUT -i lo -j ACCEPT
750
+ iptables -A OUTPUT -o lo -j ACCEPT
751
+
752
+ # set default policies
753
+ iptables -P INPUT DROP
754
+ iptables -P FORWARD DROP
755
+ iptables -P OUTPUT DROP
756
+
757
+ ip6tables -P INPUT DROP
758
+ ip6tables -P FORWARD DROP
759
+ ip6tables -P OUTPUT DROP
760
+
761
+ # allow access from docker host
762
+ iptables -A INPUT -s "$docker_host_address" -j ACCEPT
763
+ # Do not allow access to docker host by default
764
+ # iptables -A OUTPUT -d "$docker_host_address" -j ACCEPT
765
+
766
+ # allow established connections
767
+ iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
768
+ iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
769
+
770
+ # allow given addresses
771
+ for address in "${addresses[@]}"; do
772
+ local address_part
773
+ local port
774
+ if grep -q ':' <<< "$address"; then
775
+ IFS=':' read -r address_part port <<< "$address"
776
+ else
777
+ address_part="$address"
778
+ port=443
779
+ fi
780
+
781
+ if test "$address_part" = "0.0.0.0/0"; then
782
+ iptables -A OUTPUT -d "$address_part" -p tcp --dport "$port" -j ACCEPT
783
+ else
784
+ ipset add allow_list "${address_part},tcp:${port}" -exist
785
+ fi
786
+ done
787
+ iptables -A OUTPUT -p tcp -m set --match-set allow_list dst,dst -j ACCEPT
788
+ }
789
+
790
+ setup_container_user() {
791
+ local container_default_user_id="$1"
792
+ local container_default_group_id="$2"
793
+ local host_user_id="$3"
794
+ local host_group_id="$4"
795
+
796
+ local user
797
+ local group
798
+ user=$(getent passwd "$container_default_user_id" | cut -d: -f1 2> /dev/null || echo "")
799
+ group=$(getent group "$container_default_group_id" | cut -d: -f1 2> /dev/null || echo "")
800
+
801
+ if test "$container_default_user_id" -eq 0; then
802
+ log "Container's default user is root, creating sandbox user/group"
803
+
804
+ if ! getent group sandbox &> /dev/null; then
805
+ log "Creating group sandbox"
806
+ groupadd sandbox
807
+ fi
808
+
809
+ if ! getent passwd sandbox &> /dev/null; then
810
+ log "Creating user sandbox"
811
+ useradd -g sandbox -m sandbox
812
+ fi
813
+
814
+ user="sandbox"
815
+ group="sandbox"
816
+ fi
817
+
818
+ log "Updating group '$group' ID to $host_group_id"
819
+ local conflicting_group
820
+ conflicting_group=$(getent group "$host_group_id" | cut -d: -f1 2> /dev/null || echo "")
821
+ if test -n "$conflicting_group" && test "$conflicting_group" != "$group"; then
822
+ log "Resolving group ID conflict: moving group '$conflicting_group'"
823
+ local temp_gid
824
+ for gid in $(seq 1000 65533); do
825
+ if ! getent group "$gid" &> /dev/null; then
826
+ temp_gid=$gid
827
+ break
828
+ fi
829
+ done
830
+
831
+ if test -z "$temp_gid"; then
832
+ echo "Error: No available GID in user range" >&2
833
+ return 1
834
+ fi
835
+
836
+ groupmod -g "$temp_gid" "$conflicting_group"
837
+ fi
838
+
839
+ groupmod -g "$host_group_id" "$group"
840
+
841
+ log "Updating user '$user' ID to $host_user_id:$host_group_id"
842
+ local conflicting_user
843
+ conflicting_user=$(getent passwd "$host_user_id" | cut -d: -f1 2> /dev/null || echo "")
844
+ if test -n "$conflicting_user" && test "$conflicting_user" != "$user"; then
845
+ log "Resolving user ID conflict: moving user '$conflicting_user'"
846
+ local temp_uid
847
+ for uid in $(seq 1000 65533); do
848
+ if ! getent passwd "$uid" &> /dev/null; then
849
+ temp_uid=$uid
850
+ break
851
+ fi
852
+ done
853
+
854
+ if test -z "$temp_uid"; then
855
+ echo "Error: No available UID in user range" >&2
856
+ return 1
857
+ fi
858
+
859
+ usermod -u "$temp_uid" "$conflicting_user"
860
+ fi
861
+
862
+ usermod -u "$host_user_id" -g "$host_group_id" "$user"
863
+
864
+ # Adjust home directory permissions to match new UID/GID
865
+ local home_dir
866
+ home_dir=$(getent passwd "$user" | cut -d: -f6)
867
+ if test -n "$home_dir" && test -d "$home_dir"; then
868
+ log "Updating home directory ownership: $home_dir"
869
+ chown "$host_user_id:$host_group_id" "$home_dir" || true
870
+ fi
871
+
872
+ log "User setup completed: $user ($host_user_id:$host_group_id)"
873
+ }
874
+
875
+ print_preset_dockerfile() {
876
+ cat << 'EOF'
877
+ FROM node:lts
878
+
879
+ RUN apt update \
880
+ && apt install -y \
881
+ busybox \
882
+ bash zsh \
883
+ ripgrep fd-find \
884
+ iptables ipset dnsmasq dnsutils \
885
+ curl git gpg locales tmux \
886
+ && bash -c 'ln -s $(which fdfind) /usr/local/bin/fd' \
887
+ && echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen \
888
+ && echo 'ja_JP.UTF-8 UTF-8' >> /etc/locale.gen \
889
+ && locale-gen \
890
+ && npm install -g npm@latest
891
+
892
+ # mise: https://mise.jdx.dev/
893
+ RUN install -dm 755 /etc/apt/keyrings \
894
+ && curl -fsSL https://mise.jdx.dev/gpg-key.pub | gpg --dearmor | tee /etc/apt/keyrings/mise-archive-keyring.gpg 1> /dev/null \
895
+ && echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=$(dpkg --print-architecture)] https://mise.jdx.dev/deb stable main" | tee /etc/apt/sources.list.d/mise.list \
896
+ && apt update \
897
+ && apt install -y mise
898
+
899
+ # Create user
900
+ RUN groupadd sandbox \
901
+ && useradd -g sandbox -m sandbox \
902
+ && mkdir -p /sandbox \
903
+ && chmod 777 /sandbox
904
+
905
+ # Create user entrypoint script for persistence configuration
906
+ RUN mkdir -p /sandbox/bin
907
+ COPY <<'USER_ENTRYPOINT' /sandbox/bin/user-entrypoint.sh
908
+ #!/bin/bash
909
+ # Configure persistence for sandbox data (history, configs, etc.) by symlinking to /persistent/home/.
910
+
911
+ test -L ~/.bash_history || (touch /persistent/home/.bash_history; ln -s /persistent/home/.bash_history ~/ 2> /dev/null)
912
+ test -L ~/.zsh_history || (touch /persistent/home/.zsh_history; ln -s /persistent/home/.zsh_history ~/ 2> /dev/null)
913
+ test -L ~/.config || (mkdir -p /persistent/home/.config; ln -s /persistent/home/.config ~/ 2> /dev/null)
914
+ test -L ~/.gitconfig || (touch /persistent/home/.gitconfig; ln -s /persistent/home/.gitconfig ~/ 2> /dev/null)
915
+ test -L ~/.local || (mkdir -p /persistent/home/.local/share; ln -s /persistent/home/.local ~/ 2> /dev/null)
916
+ test -L ~/.claude || (mkdir -p /persistent/home/.claude; ln -s /persistent/home/.claude ~/ 2> /dev/null)
917
+ test -L ~/.claude.json || (touch /persistent/home/.claude.json; ln -s /persistent/home/.claude.json ~/ 2> /dev/null)
918
+ test -L ~/.gemini || (mkdir -p /persistent/home/.gemini; ln -s /persistent/home/.gemini ~/ 2> /dev/null)
919
+ test -L ~/.codex || (mkdir -p /persistent/home/.codex; ln -s /persistent/home/.codex ~/ 2> /dev/null)
920
+
921
+ eval "$(mise activate bash)"
922
+ exec "$@"
923
+ USER_ENTRYPOINT
924
+ RUN chmod +x /sandbox/bin/user-entrypoint.sh
925
+
926
+ USER sandbox
927
+ ENV NPM_CONFIG_PREFIX=/sandbox/npm-global
928
+ ENV PATH=/home/sandbox/.local/bin:/sandbox/npm-global/bin:$PATH
929
+
930
+ # Install coding agents
931
+ RUN umask 000 \
932
+ && curl -fsSL https://claude.ai/install.sh | bash \
933
+ && npm install -g @openai/codex \
934
+ && npm install -g @google/gemini-cli \
935
+ && rm -rf /home/sandbox/.npm
936
+
937
+ # Configure shell
938
+ # - grml zsh config: https://grml.org/zsh/
939
+ # - zsh-autosuggestions: https://github.com/zsh-users/zsh-autosuggestions
940
+ # - zsh-syntax-highlighting: https://github.com/zsh-users/zsh-syntax-highlighting
941
+ RUN curl -fsSL https://raw.githubusercontent.com/grml/grml-etc-core/refs/tags/v0.19.23/etc/zsh/zshrc -o /home/sandbox/.zshrc \
942
+ && echo 'fc0a6642d61193fd95293fb39a561e456e240b81e9ddb8c08fc5c2ac8c31d2f4 /home/sandbox/.zshrc' > /home/sandbox/.zshrc.sha256sum \
943
+ && sha256sum -c /home/sandbox/.zshrc.sha256sum \
944
+ && git clone --depth 1 https://github.com/zsh-users/zsh-autosuggestions /home/sandbox/.zsh/zsh-autosuggestions \
945
+ && echo 'source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh' >> /home/sandbox/.zshrc.local \
946
+ && git clone --depth 1 https://github.com/zsh-users/zsh-syntax-highlighting /home/sandbox/.zsh/zsh-syntax-highlighting \
947
+ && echo 'source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh' >> /home/sandbox/.zshrc.local \
948
+ && echo 'unsetopt HIST_SAVE_BY_COPY' >> /home/sandbox/.zshrc.local
949
+
950
+ ENV LANG=en_US.UTF-8
951
+ ENV EDITOR="busybox vi"
952
+ ENV MISE_DATA_DIR=/persistent/mise-data
953
+ EOF
954
+ }
955
+
956
+ if test "$#" -gt 0; then
957
+ case "$1" in
958
+ start_container_dnsmasq \
959
+ | setup_container_volume_owner \
960
+ | setup_container_firewall \
961
+ | setup_container_user \
962
+ | terminate_idle_container \
963
+ | container_exec \
964
+ | print_preset_dockerfile )
965
+ "$@"
966
+ ;;
967
+ * )
968
+ main "$@"
969
+ esac
970
+ else
971
+ main
972
+ fi