@htooayelwinict/appv23 2.3.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 (3) hide show
  1. package/README.md +37 -0
  2. package/bin/appv23.js +429 -0
  3. package/package.json +25 -0
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # appv23
2
+
3
+ Thin npm launcher for the appv23 Docker sandbox.
4
+
5
+ ## Usage
6
+
7
+ Run with `npx`:
8
+
9
+ ```bash
10
+ npx @htooayelwinict/appv23 --cwd .
11
+ ```
12
+
13
+ Or install globally:
14
+
15
+ ```bash
16
+ npm install -g @htooayelwinict/appv23
17
+ appv23 --cwd .
18
+ ```
19
+
20
+ The launcher pulls and runs:
21
+
22
+ ```text
23
+ ghcr.io/htooayelwinict/appv23:production
24
+ ```
25
+
26
+ It mounts only the selected `--cwd` as `/workspace`, stores sandbox state in `~/.appv23/sandbox-home`, and copies host `~/.agents/skills` into the sandbox.
27
+
28
+ ## Options
29
+
30
+ ```bash
31
+ appv23 --cwd /path/to/workspace
32
+ appv23 --cwd . --dry-run
33
+ appv23 --cwd . --no-pull
34
+ appv23 --cwd . --image ghcr.io/htooayelwinict/appv23:production
35
+ ```
36
+
37
+ The host `.env` file is not mounted or passed automatically. Use `/login` inside the TUI for API keys.
package/bin/appv23.js ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { spawnSync } = require("node:child_process");
8
+
9
+ const DEFAULT_IMAGE =
10
+ process.env.APPV23_IMAGE || process.env.APPV23_SANDBOX_IMAGE || "ghcr.io/htooayelwinict/appv23:production";
11
+ const PUBLIC_APPV23_IMAGE_PREFIX = "ghcr.io/htooayelwinict/appv23:";
12
+ const CONTAINER_WORKSPACE = "/workspace";
13
+ const CONTAINER_AGENT_HOME = "/agent-home";
14
+ const SKIP_IMPORT_NAMES = new Set([
15
+ ".DS_Store",
16
+ ".git",
17
+ ".hg",
18
+ ".mypy_cache",
19
+ ".pytest_cache",
20
+ ".ruff_cache",
21
+ ".svn",
22
+ ".venv",
23
+ "__pycache__",
24
+ "node_modules",
25
+ "venv",
26
+ ]);
27
+
28
+ function parseArgs(argv) {
29
+ const config = {
30
+ cwd: process.cwd(),
31
+ image: DEFAULT_IMAGE,
32
+ agentHome: process.env.APPV23_SANDBOX_HOME || path.join(os.homedir(), ".appv23", "sandbox-home"),
33
+ network: true,
34
+ dryRun: false,
35
+ pull: true,
36
+ agentsFiles: [],
37
+ skillsPaths: [],
38
+ importUserSkills: true,
39
+ appArgs: [],
40
+ };
41
+
42
+ for (let index = 0; index < argv.length; index += 1) {
43
+ const arg = argv[index];
44
+ if (arg === "--") {
45
+ config.appArgs.push(...argv.slice(index + 1));
46
+ break;
47
+ }
48
+ if (arg === "--cwd") {
49
+ config.cwd = requireValue(argv, ++index, arg);
50
+ continue;
51
+ }
52
+ if (arg.startsWith("--cwd=")) {
53
+ config.cwd = arg.slice("--cwd=".length);
54
+ continue;
55
+ }
56
+ if (arg === "--image") {
57
+ config.image = requireValue(argv, ++index, arg);
58
+ continue;
59
+ }
60
+ if (arg.startsWith("--image=")) {
61
+ config.image = arg.slice("--image=".length);
62
+ continue;
63
+ }
64
+ if (arg === "--agent-home") {
65
+ config.agentHome = requireValue(argv, ++index, arg);
66
+ continue;
67
+ }
68
+ if (arg.startsWith("--agent-home=")) {
69
+ config.agentHome = arg.slice("--agent-home=".length);
70
+ continue;
71
+ }
72
+ if (arg === "--agents-file") {
73
+ config.agentsFiles.push(requireValue(argv, ++index, arg));
74
+ continue;
75
+ }
76
+ if (arg.startsWith("--agents-file=")) {
77
+ config.agentsFiles.push(arg.slice("--agents-file=".length));
78
+ continue;
79
+ }
80
+ if (arg === "--with-skills") {
81
+ config.skillsPaths.push(requireValue(argv, ++index, arg));
82
+ continue;
83
+ }
84
+ if (arg.startsWith("--with-skills=")) {
85
+ config.skillsPaths.push(arg.slice("--with-skills=".length));
86
+ continue;
87
+ }
88
+ if (arg === "--no-user-skills") {
89
+ config.importUserSkills = false;
90
+ continue;
91
+ }
92
+ if (arg === "--no-network") {
93
+ config.network = false;
94
+ continue;
95
+ }
96
+ if (arg === "--pull") {
97
+ config.pull = true;
98
+ continue;
99
+ }
100
+ if (arg === "--no-pull") {
101
+ config.pull = false;
102
+ continue;
103
+ }
104
+ if (arg === "--dry-run") {
105
+ config.dryRun = true;
106
+ continue;
107
+ }
108
+ if (arg === "--help" || arg === "-h") {
109
+ config.help = true;
110
+ continue;
111
+ }
112
+ config.appArgs.push(arg);
113
+ }
114
+
115
+ return {
116
+ ...config,
117
+ cwd: resolvePath(config.cwd),
118
+ agentHome: resolvePath(config.agentHome),
119
+ agentsFiles: config.agentsFiles.map(resolvePath),
120
+ skillsPaths: config.skillsPaths.map(resolvePath),
121
+ appArgs: sanitizeAppArgs(config.appArgs),
122
+ };
123
+ }
124
+
125
+ function requireValue(argv, index, flag) {
126
+ const value = argv[index];
127
+ if (!value) {
128
+ throw new Error(`${flag} requires a value`);
129
+ }
130
+ return value;
131
+ }
132
+
133
+ function resolvePath(value) {
134
+ const expanded = value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
135
+ return path.resolve(expanded);
136
+ }
137
+
138
+ function sanitizeAppArgs(args) {
139
+ const stripped = [];
140
+ let skipNext = false;
141
+ for (const arg of args) {
142
+ if (skipNext) {
143
+ skipNext = false;
144
+ continue;
145
+ }
146
+ if (arg === "--cwd" || arg === "--dotenv") {
147
+ skipNext = true;
148
+ continue;
149
+ }
150
+ if (arg.startsWith("--cwd=") || arg.startsWith("--dotenv=")) {
151
+ continue;
152
+ }
153
+ stripped.push(arg);
154
+ }
155
+ return stripped;
156
+ }
157
+
158
+ function buildDockerCommand(config, runtime = {}) {
159
+ const uid = runtime.uid ?? (typeof process.getuid === "function" ? process.getuid() : 1000);
160
+ const gid = runtime.gid ?? (typeof process.getgid === "function" ? process.getgid() : 1000);
161
+ const pid = runtime.pid ?? process.pid;
162
+ const command = [
163
+ "docker",
164
+ "run",
165
+ "--rm",
166
+ "-it",
167
+ "--name",
168
+ `appv23-sandbox-${pid}`,
169
+ "--workdir",
170
+ CONTAINER_WORKSPACE,
171
+ "--cap-drop",
172
+ "ALL",
173
+ "--security-opt",
174
+ "no-new-privileges",
175
+ "--pids-limit",
176
+ "512",
177
+ "--user",
178
+ `${uid}:${gid}`,
179
+ "-v",
180
+ `${config.cwd}:${CONTAINER_WORKSPACE}:rw`,
181
+ "-v",
182
+ `${config.agentHome}:${CONTAINER_AGENT_HOME}:rw`,
183
+ "-e",
184
+ `HOME=${CONTAINER_AGENT_HOME}`,
185
+ "-e",
186
+ `PI_CODING_AGENT_DIR=${CONTAINER_AGENT_HOME}/agent`,
187
+ "-e",
188
+ "APPV23_SANDBOX=1",
189
+ "-e",
190
+ "APPV23_NO_VENV_REEXEC=1",
191
+ "-e",
192
+ "PYTHONUNBUFFERED=1",
193
+ ];
194
+ if (!config.network) {
195
+ command.push("--network=none");
196
+ }
197
+ command.push(config.image, "--cwd", CONTAINER_WORKSPACE, ...config.appArgs);
198
+ return command;
199
+ }
200
+
201
+ function buildPullCommand(config) {
202
+ return config.pull ? ["docker", "pull", config.image] : [];
203
+ }
204
+
205
+ function isPublicAppv23Image(image) {
206
+ return image.startsWith(PUBLIC_APPV23_IMAGE_PREFIX);
207
+ }
208
+
209
+ function shouldUseIsolatedDockerConfig(config, env = process.env) {
210
+ return config.pull && isPublicAppv23Image(config.image) && !env.DOCKER_CONFIG && !env.APPV23_DOCKER_CONFIG;
211
+ }
212
+
213
+ function buildPullEnv(config, dockerConfig, env = process.env) {
214
+ if (env.APPV23_DOCKER_CONFIG) {
215
+ return { ...env, DOCKER_CONFIG: env.APPV23_DOCKER_CONFIG };
216
+ }
217
+ if (dockerConfig) {
218
+ return { ...env, DOCKER_CONFIG: dockerConfig };
219
+ }
220
+ return env;
221
+ }
222
+
223
+ function prepareSandboxImports(config, runtime = {}) {
224
+ const homeDir = runtime.homeDir || os.homedir();
225
+ fs.mkdirSync(config.agentHome, { recursive: true, mode: 0o700 });
226
+ prepareAgentsFiles(config);
227
+ prepareSkills(config, homeDir);
228
+ }
229
+
230
+ function prepareAgentsFiles(config) {
231
+ if (!config.agentsFiles.length) {
232
+ return;
233
+ }
234
+ const target = path.join(config.agentHome, "agent", "AGENTS.md");
235
+ const parts = [
236
+ "<!-- appv23-sandbox-imported-agents -->",
237
+ "# Imported appv23 sandbox instructions",
238
+ "",
239
+ "These instructions were copied into the sandbox from explicit --agents-file arguments.",
240
+ "",
241
+ ];
242
+ for (const source of config.agentsFiles) {
243
+ const stat = fs.statSync(source);
244
+ if (!stat.isFile()) {
245
+ throw new Error(`agents file is not a file: ${source}`);
246
+ }
247
+ parts.push(`## Source: ${source}`, "", fs.readFileSync(source, "utf8"), "");
248
+ }
249
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
250
+ fs.writeFileSync(target, parts.join("\n"), { mode: 0o600 });
251
+ }
252
+
253
+ function prepareSkills(config, homeDir) {
254
+ const sources = [];
255
+ const userSkills = path.join(homeDir, ".agents", "skills");
256
+ if (config.importUserSkills && fs.existsSync(userSkills)) {
257
+ sources.push(userSkills);
258
+ }
259
+ sources.push(...config.skillsPaths);
260
+ if (!sources.length) {
261
+ return;
262
+ }
263
+ const targetRoot = path.join(config.agentHome, ".agents", "skills");
264
+ fs.rmSync(targetRoot, { recursive: true, force: true });
265
+ fs.mkdirSync(targetRoot, { recursive: true, mode: 0o700 });
266
+ for (const source of sources) {
267
+ copySkillSource(source, targetRoot);
268
+ }
269
+ }
270
+
271
+ function copySkillSource(source, targetRoot) {
272
+ const stat = fs.statSync(source);
273
+ if (stat.isFile()) {
274
+ if (path.extname(source) !== ".md") {
275
+ throw new Error(`skills file must be markdown: ${source}`);
276
+ }
277
+ copyFileSafe(source, path.join(targetRoot, path.basename(source)));
278
+ return;
279
+ }
280
+ if (!stat.isDirectory()) {
281
+ throw new Error(`skills path is not a file or directory: ${source}`);
282
+ }
283
+ if (fs.existsSync(path.join(source, "SKILL.md"))) {
284
+ copyTreeSafe(source, path.join(targetRoot, path.basename(source)));
285
+ return;
286
+ }
287
+ for (const child of fs.readdirSync(source).sort()) {
288
+ const childPath = path.join(source, child);
289
+ if (shouldSkipImport(childPath)) {
290
+ continue;
291
+ }
292
+ const childStat = fs.statSync(childPath);
293
+ if (childStat.isDirectory()) {
294
+ copyTreeSafe(childPath, path.join(targetRoot, child));
295
+ } else if (childStat.isFile() && path.extname(childPath) === ".md") {
296
+ copyFileSafe(childPath, path.join(targetRoot, child));
297
+ }
298
+ }
299
+ }
300
+
301
+ function copyTreeSafe(source, target) {
302
+ if (shouldSkipImport(source) || fs.lstatSync(source).isSymbolicLink()) {
303
+ return;
304
+ }
305
+ const stat = fs.statSync(source);
306
+ if (stat.isFile()) {
307
+ copyFileSafe(source, target);
308
+ return;
309
+ }
310
+ fs.mkdirSync(target, { recursive: true });
311
+ for (const child of fs.readdirSync(source).sort()) {
312
+ const childPath = path.join(source, child);
313
+ if (shouldSkipImport(childPath)) {
314
+ continue;
315
+ }
316
+ const childTarget = path.join(target, child);
317
+ const childStat = fs.statSync(childPath);
318
+ if (childStat.isDirectory()) {
319
+ copyTreeSafe(childPath, childTarget);
320
+ } else if (childStat.isFile()) {
321
+ copyFileSafe(childPath, childTarget);
322
+ }
323
+ }
324
+ }
325
+
326
+ function copyFileSafe(source, target) {
327
+ if (shouldSkipImport(source) || fs.lstatSync(source).isSymbolicLink()) {
328
+ return;
329
+ }
330
+ fs.mkdirSync(path.dirname(target), { recursive: true });
331
+ fs.copyFileSync(source, target);
332
+ }
333
+
334
+ function shouldSkipImport(filePath) {
335
+ const name = path.basename(filePath);
336
+ return SKIP_IMPORT_NAMES.has(name) || name.startsWith(".env");
337
+ }
338
+
339
+ function printHelp() {
340
+ process.stdout.write(`appv23-sandbox
341
+
342
+ Run the prebuilt appv23 Docker image from any directory.
343
+
344
+ Usage:
345
+ appv23-sandbox [options] [-- appv23 args]
346
+
347
+ Options:
348
+ --cwd <path> Host workspace to mount as /workspace. Defaults to current directory.
349
+ --image <name> Docker image. Defaults to APPV23_IMAGE, APPV23_SANDBOX_IMAGE, or ghcr.io/htooayelwinict/appv23:production.
350
+ --agent-home <path> Sandbox state directory. Defaults to ~/.appv23/sandbox-home.
351
+ --agents-file <path> Copy an explicit AGENTS.md-style file into sandbox context.
352
+ --with-skills <path> Copy an extra skill file or directory into sandbox ~/.agents/skills.
353
+ --no-user-skills Do not copy host ~/.agents/skills.
354
+ --no-network Run container with --network=none.
355
+ --pull Pull image before running. Default.
356
+ --no-pull Do not pull image before running.
357
+ --dry-run Print docker command instead of running it.
358
+ -h, --help Show help.
359
+ `);
360
+ }
361
+
362
+ function main(argv = process.argv.slice(2)) {
363
+ let config;
364
+ try {
365
+ config = parseArgs(argv);
366
+ if (config.help) {
367
+ printHelp();
368
+ return 0;
369
+ }
370
+ if (!fs.existsSync(config.cwd) || !fs.statSync(config.cwd).isDirectory()) {
371
+ throw new Error(`workspace does not exist or is not a directory: ${config.cwd}`);
372
+ }
373
+ prepareSandboxImports(config);
374
+ const pullCommand = buildPullCommand(config);
375
+ const command = buildDockerCommand(config);
376
+ if (config.dryRun) {
377
+ if (pullCommand.length) {
378
+ process.stdout.write(`${pullCommand.map(shellQuote).join(" ")}\n`);
379
+ }
380
+ process.stdout.write(`${command.map(shellQuote).join(" ")}\n`);
381
+ return 0;
382
+ }
383
+ if (pullCommand.length) {
384
+ let dockerConfig;
385
+ try {
386
+ if (shouldUseIsolatedDockerConfig(config)) {
387
+ dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "appv23-docker-config-"));
388
+ }
389
+ const pull = spawnSync(pullCommand[0], pullCommand.slice(1), {
390
+ stdio: "inherit",
391
+ env: buildPullEnv(config, dockerConfig),
392
+ });
393
+ if ((pull.status ?? 1) !== 0) {
394
+ return pull.status ?? 1;
395
+ }
396
+ } finally {
397
+ if (dockerConfig) {
398
+ fs.rmSync(dockerConfig, { recursive: true, force: true });
399
+ }
400
+ }
401
+ }
402
+ const result = spawnSync(command[0], command.slice(1), { stdio: "inherit" });
403
+ return result.status ?? 1;
404
+ } catch (error) {
405
+ process.stderr.write(`Error: ${error.message}\n`);
406
+ return 1;
407
+ }
408
+ }
409
+
410
+ function shellQuote(value) {
411
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
412
+ return value;
413
+ }
414
+ return `'${value.replaceAll("'", "'\\''")}'`;
415
+ }
416
+
417
+ module.exports = {
418
+ buildDockerCommand,
419
+ buildPullEnv,
420
+ buildPullCommand,
421
+ parseArgs,
422
+ prepareSandboxImports,
423
+ sanitizeAppArgs,
424
+ shouldUseIsolatedDockerConfig,
425
+ };
426
+
427
+ if (require.main === module) {
428
+ process.exit(main());
429
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@htooayelwinict/appv23",
3
+ "version": "2.3.0",
4
+ "description": "npx-friendly Docker launcher for the appv23 coding-agent sandbox.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "appv23": "bin/appv23.js",
8
+ "appv23-sandbox": "bin/appv23.js"
9
+ },
10
+ "files": [
11
+ "bin/appv23.js",
12
+ "README.md",
13
+ "package.json"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test test/appv23-cli.test.js",
23
+ "pack:dry-run": "npm pack --dry-run"
24
+ }
25
+ }