@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.
- package/.config/agents.library/code-simplifier.md +5 -0
- package/.config/agents.library/qa-engineer.md +74 -0
- package/.config/agents.library/software-architect.md +278 -0
- package/.config/agents.predefined/worker.md +3 -0
- package/.config/config.predefined.json +825 -0
- package/.config/prompts.library/code-review.md +8 -0
- package/.config/prompts.library/feature-dev.md +6 -0
- package/.config/prompts.predefined/shortcuts/commit-by-user.md +9 -0
- package/.config/prompts.predefined/shortcuts/commit.md +10 -0
- package/.config/prompts.predefined/shortcuts/general-question.md +6 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/bin/plain +3 -0
- package/bin/plain-interrupt +6 -0
- package/bin/plain-notify-desktop +19 -0
- package/bin/plain-notify-terminal-bell +3 -0
- package/package.json +57 -0
- package/sandbox/bin/plain-sandbox +972 -0
- package/src/agent.d.ts +48 -0
- package/src/agent.mjs +159 -0
- package/src/agentLoop.mjs +369 -0
- package/src/agentState.mjs +41 -0
- package/src/cliArgs.mjs +45 -0
- package/src/cliFormatter.mjs +217 -0
- package/src/cliInteractive.mjs +739 -0
- package/src/config.d.ts +48 -0
- package/src/config.mjs +168 -0
- package/src/context/consumeInterruptMessage.mjs +30 -0
- package/src/context/loadAgentRoles.mjs +272 -0
- package/src/context/loadPrompts.mjs +312 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/env.mjs +46 -0
- package/src/main.mjs +202 -0
- package/src/mcp.mjs +202 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +29 -0
- package/src/modelDefinition.d.ts +73 -0
- package/src/prompt.mjs +128 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +596 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +752 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +551 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +658 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +74 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +247 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +98 -0
- package/src/tools/askGoogle.mjs +135 -0
- package/src/tools/delegateToSubagent.d.ts +4 -0
- package/src/tools/delegateToSubagent.mjs +48 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/fetchWebPage.mjs +96 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +96 -0
- package/src/tools/reportAsSubagent.d.ts +3 -0
- package/src/tools/reportAsSubagent.mjs +44 -0
- package/src/tools/tavilySearch.d.ts +6 -0
- package/src/tools/tavilySearch.mjs +57 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/utils/evalJSONConfig.mjs +48 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +28 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/readFileRange.mjs +33 -0
- 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
|