@fitlab-ai/agent-infra 0.5.9 → 0.6.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 (100) hide show
  1. package/README.md +200 -8
  2. package/README.zh-CN.md +176 -8
  3. package/bin/{cli.js → cli.ts} +23 -19
  4. package/dist/bin/cli.js +116 -0
  5. package/dist/lib/defaults.json +61 -0
  6. package/dist/lib/init.js +238 -0
  7. package/dist/lib/log.js +18 -0
  8. package/dist/lib/merge.js +747 -0
  9. package/dist/lib/paths.js +18 -0
  10. package/dist/lib/prompt.js +85 -0
  11. package/dist/lib/render.js +139 -0
  12. package/dist/lib/sandbox/commands/create.js +1173 -0
  13. package/dist/lib/sandbox/commands/enter.js +98 -0
  14. package/dist/lib/sandbox/commands/ls.js +93 -0
  15. package/dist/lib/sandbox/commands/rebuild.js +101 -0
  16. package/dist/lib/sandbox/commands/refresh.js +85 -0
  17. package/dist/lib/sandbox/commands/rm.js +226 -0
  18. package/dist/lib/sandbox/commands/vm.js +144 -0
  19. package/dist/lib/sandbox/config.js +85 -0
  20. package/dist/lib/sandbox/constants.js +104 -0
  21. package/dist/lib/sandbox/credentials.js +437 -0
  22. package/dist/lib/sandbox/dockerfile.js +76 -0
  23. package/dist/lib/sandbox/dotfiles.js +170 -0
  24. package/dist/lib/sandbox/engine.js +155 -0
  25. package/dist/lib/sandbox/engines/colima.js +64 -0
  26. package/dist/lib/sandbox/engines/docker-desktop.js +27 -0
  27. package/dist/lib/sandbox/engines/index.js +25 -0
  28. package/dist/lib/sandbox/engines/native.js +96 -0
  29. package/dist/lib/sandbox/engines/orbstack.js +63 -0
  30. package/dist/lib/sandbox/engines/selinux.js +48 -0
  31. package/dist/lib/sandbox/engines/wsl2-paths.js +47 -0
  32. package/dist/lib/sandbox/engines/wsl2.js +57 -0
  33. package/dist/lib/sandbox/index.js +70 -0
  34. package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +39 -0
  35. package/dist/lib/sandbox/runtimes/base.dockerfile +178 -0
  36. package/dist/lib/sandbox/runtimes/java17.dockerfile +3 -0
  37. package/dist/lib/sandbox/runtimes/java21.dockerfile +3 -0
  38. package/dist/lib/sandbox/runtimes/node20.dockerfile +3 -0
  39. package/dist/lib/sandbox/runtimes/node22.dockerfile +3 -0
  40. package/dist/lib/sandbox/runtimes/python3.dockerfile +3 -0
  41. package/dist/lib/sandbox/shell.js +148 -0
  42. package/dist/lib/sandbox/task-resolver.js +35 -0
  43. package/dist/lib/sandbox/tools.js +115 -0
  44. package/dist/lib/update.js +186 -0
  45. package/dist/lib/version.js +5 -0
  46. package/dist/package.json +5 -0
  47. package/lib/{init.js → init.ts} +64 -20
  48. package/lib/{log.js → log.ts} +4 -4
  49. package/lib/{merge.js → merge.ts} +129 -63
  50. package/lib/paths.ts +18 -0
  51. package/lib/{prompt.js → prompt.ts} +12 -12
  52. package/lib/{render.js → render.ts} +30 -17
  53. package/lib/sandbox/commands/create.ts +1507 -0
  54. package/lib/sandbox/commands/enter.ts +115 -0
  55. package/lib/sandbox/commands/{ls.js → ls.ts} +41 -10
  56. package/lib/sandbox/commands/rebuild.ts +135 -0
  57. package/lib/sandbox/commands/refresh.ts +128 -0
  58. package/lib/sandbox/commands/{rm.js → rm.ts} +71 -21
  59. package/lib/sandbox/commands/{vm.js → vm.ts} +62 -15
  60. package/lib/sandbox/config.ts +133 -0
  61. package/lib/sandbox/{constants.js → constants.ts} +41 -17
  62. package/lib/sandbox/credentials.ts +634 -0
  63. package/lib/sandbox/{dockerfile.js → dockerfile.ts} +13 -6
  64. package/lib/sandbox/dotfiles.ts +236 -0
  65. package/lib/sandbox/engine.ts +231 -0
  66. package/lib/sandbox/engines/colima.ts +81 -0
  67. package/lib/sandbox/engines/docker-desktop.ts +36 -0
  68. package/lib/sandbox/engines/index.ts +74 -0
  69. package/lib/sandbox/engines/native.ts +131 -0
  70. package/lib/sandbox/engines/orbstack.ts +78 -0
  71. package/lib/sandbox/engines/selinux.ts +66 -0
  72. package/lib/sandbox/engines/wsl2-paths.ts +65 -0
  73. package/lib/sandbox/engines/wsl2.ts +74 -0
  74. package/lib/sandbox/{index.js → index.ts} +17 -8
  75. package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
  76. package/lib/sandbox/runtimes/base.dockerfile +116 -1
  77. package/lib/sandbox/shell.ts +186 -0
  78. package/lib/sandbox/{task-resolver.js → task-resolver.ts} +6 -6
  79. package/lib/sandbox/{tools.js → tools.ts} +33 -29
  80. package/lib/{update.js → update.ts} +33 -10
  81. package/package.json +22 -12
  82. package/templates/.agents/rules/create-issue.github.en.md +2 -4
  83. package/templates/.agents/rules/create-issue.github.zh-CN.md +2 -4
  84. package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
  85. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
  86. package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
  87. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +26 -41
  88. package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
  89. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  90. package/templates/.agents/skills/import-issue/SKILL.en.md +6 -8
  91. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +6 -8
  92. package/lib/paths.js +0 -9
  93. package/lib/sandbox/commands/create.js +0 -1174
  94. package/lib/sandbox/commands/enter.js +0 -79
  95. package/lib/sandbox/commands/rebuild.js +0 -102
  96. package/lib/sandbox/config.js +0 -84
  97. package/lib/sandbox/engine.js +0 -256
  98. package/lib/sandbox/shell.js +0 -122
  99. package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
  100. /package/lib/{version.js → version.ts} +0 -0
@@ -0,0 +1,131 @@
1
+ import type { OnMessage, SandboxAdapter } from './index.ts';
2
+
3
+ type NativeRunSafe = (cmd: string, args: string[]) => string;
4
+ type EngineRun = (engine: string, cmd: string, args: string[]) => string;
5
+ type EngineRunSafe = (engine: string, cmd: string, args: string[]) => string;
6
+
7
+ export function isRootlessDocker({
8
+ env = process.env,
9
+ runSafe
10
+ }: { env?: NodeJS.ProcessEnv; runSafe?: NativeRunSafe } = {}): boolean {
11
+ const dockerHost = env.DOCKER_HOST ?? '';
12
+ if (dockerHost.startsWith('unix:///run/user/')) {
13
+ return true;
14
+ }
15
+
16
+ if (!runSafe) {
17
+ return false;
18
+ }
19
+
20
+ try {
21
+ const securityOptions = runSafe('docker', ['info', '--format', '{{.SecurityOptions}}']);
22
+ return securityOptions.includes('rootless');
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export function resolveBuildUid({
29
+ engine,
30
+ runFn,
31
+ runSafeFn,
32
+ env = process.env
33
+ }: {
34
+ engine: string;
35
+ runFn: EngineRun;
36
+ runSafeFn?: EngineRunSafe;
37
+ env?: NodeJS.ProcessEnv;
38
+ }) {
39
+ const runSafe = runSafeFn
40
+ ? (cmd: string, args: string[]) => runSafeFn(engine, cmd, args)
41
+ : undefined;
42
+
43
+ if (engine === 'native' && isRootlessDocker({ env, runSafe })) {
44
+ return { uid: '0', gid: '0' };
45
+ }
46
+
47
+ return {
48
+ uid: runFn(engine, 'id', ['-u']),
49
+ gid: runFn(engine, 'id', ['-g'])
50
+ };
51
+ }
52
+
53
+ export const nativeAdapter: SandboxAdapter = {
54
+ id: 'native',
55
+ displayName: 'native Docker',
56
+ supportedPlatforms: ['linux', 'win32'],
57
+ dockerContext: null,
58
+ managed: false,
59
+ canApplyResources: 'never',
60
+
61
+ defaultResources() {
62
+ return null;
63
+ },
64
+
65
+ async ensure(_config, _onMessage: OnMessage, { runOk, runSafe }) {
66
+ if (!runOk('which', ['docker'])) {
67
+ throw new Error([
68
+ 'Docker is not installed.',
69
+ 'Install Docker Engine for your distribution: https://docs.docker.com/engine/install/',
70
+ 'Then start the daemon with: sudo systemctl enable --now docker',
71
+ 'If you want to run Docker without sudo, add your user to the docker group: sudo usermod -aG docker $USER'
72
+ ].join('\n'));
73
+ }
74
+
75
+ if (runOk('docker', ['info'])) {
76
+ return false;
77
+ }
78
+
79
+ const serverVersion = runSafe('docker', ['version', '--format', '{{.Server.Version}}']);
80
+ const rootless = isRootlessDocker({ runSafe });
81
+ if (!serverVersion) {
82
+ if (rootless) {
83
+ throw new Error([
84
+ 'Docker rootless daemon is not running or is unreachable.',
85
+ 'Start it with: systemctl --user start docker',
86
+ 'Enable it on login with: systemctl --user enable docker',
87
+ 'Verify DOCKER_HOST points at $XDG_RUNTIME_DIR/docker.sock.',
88
+ 'Then retry: ai sandbox create <branch>'
89
+ ].join('\n'));
90
+ }
91
+
92
+ throw new Error([
93
+ 'Docker daemon is not running or is unreachable.',
94
+ 'Start it with: sudo systemctl start docker',
95
+ 'Enable it on boot with: sudo systemctl enable docker',
96
+ 'If you use rootless or remote Docker, verify DOCKER_HOST points at a reachable socket.',
97
+ 'For rootless Docker, export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock and run: systemctl --user start docker',
98
+ 'Then retry: ai sandbox create <branch>'
99
+ ].join('\n'));
100
+ }
101
+
102
+ if (rootless) {
103
+ throw new Error([
104
+ 'docker info failed even though the rootless daemon responded to version.',
105
+ 'This usually means DOCKER_HOST or XDG_RUNTIME_DIR is misconfigured.',
106
+ 'Verify DOCKER_HOST matches $XDG_RUNTIME_DIR/docker.sock.',
107
+ 'Check the daemon with: systemctl --user status docker',
108
+ 'Then retry: ai sandbox create <branch>'
109
+ ].join('\n'));
110
+ }
111
+
112
+ throw new Error([
113
+ 'Docker is installed, but the current user may lack permission to use the daemon.',
114
+ 'Add your user to the docker group: sudo usermod -aG docker $USER',
115
+ 'Open a new login shell or run: newgrp docker'
116
+ ].join('\n'));
117
+ },
118
+
119
+ syncResources(config, onMessage: OnMessage) {
120
+ if (!config.hasUserVmConfig?.(config.userVm)) {
121
+ return;
122
+ }
123
+
124
+ onMessage?.(
125
+ 'Warning: Linux native Docker has no managed VM; sandbox.vm.* is not applicable. '
126
+ + 'Use docker run --cpus / --memory per container or host cgroups.'
127
+ );
128
+ }
129
+ };
130
+
131
+ export default nativeAdapter;
@@ -0,0 +1,78 @@
1
+ import type { OnMessage, SandboxAdapter } from './index.ts';
2
+
3
+ export const orbstackAdapter: SandboxAdapter = {
4
+ id: 'orbstack',
5
+ displayName: 'OrbStack',
6
+ supportedPlatforms: ['darwin'],
7
+ dockerContext: 'orbstack',
8
+ managed: true,
9
+ canApplyResources: 'hot',
10
+
11
+ defaultResources() {
12
+ return null;
13
+ },
14
+
15
+ async ensure(_config, onMessage: OnMessage, { runOk, runVerbose }) {
16
+ let started = false;
17
+
18
+ if (!runOk('which', ['orb'])) {
19
+ onMessage?.('Installing OrbStack via Homebrew...');
20
+ runVerbose('brew', ['install', '--cask', 'orbstack']);
21
+ }
22
+
23
+ if (!runOk('docker', ['info'])) {
24
+ onMessage?.('Starting OrbStack...');
25
+ runVerbose('orb', ['start']);
26
+ started = true;
27
+ }
28
+
29
+ if (!runOk('docker', ['info'])) {
30
+ throw new Error('Docker daemon is not available after starting OrbStack');
31
+ }
32
+
33
+ return started;
34
+ },
35
+
36
+ startVm(_config, _onMessage: OnMessage, { runOk, runVerbose }) {
37
+ if (runOk('orb', ['status'])) {
38
+ return 'already-running';
39
+ }
40
+
41
+ runVerbose('orb', ['start']);
42
+ return 'started';
43
+ },
44
+
45
+ stopVm(_config, _onMessage: OnMessage, { run }) {
46
+ run('orb', ['stop']);
47
+ return 'stopped';
48
+ },
49
+
50
+ syncResources(config, onMessage: OnMessage, { runVerbose }) {
51
+ const vm = config?.vm ?? {};
52
+
53
+ if (vm.cpu != null) {
54
+ try {
55
+ runVerbose('orb', ['config', 'set', 'cpu', String(vm.cpu)]);
56
+ } catch {
57
+ onMessage?.(`Warning: failed to apply OrbStack cpu=${vm.cpu}; resource limit may not take effect.`);
58
+ }
59
+ }
60
+
61
+ if (vm.memory != null) {
62
+ try {
63
+ runVerbose('orb', ['config', 'set', 'memory_mib', String(vm.memory * 1024)]);
64
+ } catch {
65
+ onMessage?.(`Warning: failed to apply OrbStack memory=${vm.memory}GiB; resource limit may not take effect.`);
66
+ }
67
+ }
68
+
69
+ if (vm.disk != null) {
70
+ onMessage?.(
71
+ `Warning: OrbStack does not expose a fixed disk size; sandbox.vm.disk=${vm.disk} is ignored. `
72
+ + 'Manage storage via OrbStack settings GUI.'
73
+ );
74
+ }
75
+ }
76
+ };
77
+
78
+ export default orbstackAdapter;
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+
3
+ const SELINUX_ENFORCE_PATH = '/sys/fs/selinux/enforce';
4
+ const detectionCache = new WeakMap();
5
+ const VALID_DISABLE_VALUES = new Set([undefined, '', '0', '1']);
6
+
7
+ type SelinuxFs = Pick<typeof fs, 'readFileSync'>;
8
+
9
+ function isDisabled(env?: NodeJS.ProcessEnv): boolean {
10
+ return env?.AGENT_INFRA_SELINUX_DISABLE === '1';
11
+ }
12
+
13
+ function readEnforceFlag(fsImpl: SelinuxFs): string | null {
14
+ try {
15
+ return fsImpl.readFileSync(SELINUX_ENFORCE_PATH, 'utf8').trim();
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function isSelinuxEnforcing(fsImpl: SelinuxFs, platform: NodeJS.Platform): boolean {
22
+ let cache = detectionCache.get(fsImpl);
23
+ if (!cache) {
24
+ cache = new Map();
25
+ detectionCache.set(fsImpl, cache);
26
+ }
27
+
28
+ if (cache.has(platform)) {
29
+ return cache.get(platform);
30
+ }
31
+
32
+ const enforcing = readEnforceFlag(fsImpl) === '1';
33
+ cache.set(platform, enforcing);
34
+ return enforcing;
35
+ }
36
+
37
+ export function selinuxLabelForMount(engine: string, options: {
38
+ fs?: SelinuxFs;
39
+ platform?: NodeJS.Platform;
40
+ env?: NodeJS.ProcessEnv;
41
+ } = {}): string | null {
42
+ const {
43
+ fs: fsImpl = fs,
44
+ platform = process.platform,
45
+ env = process.env
46
+ } = options;
47
+
48
+ if (engine !== 'native' || platform !== 'linux') {
49
+ return null;
50
+ }
51
+ if (isDisabled(env)) {
52
+ return null;
53
+ }
54
+ if (!isSelinuxEnforcing(fsImpl, platform)) {
55
+ return null;
56
+ }
57
+
58
+ return 'z';
59
+ }
60
+
61
+ export function validateSelinuxDisableEnv(env: NodeJS.ProcessEnv = process.env): void {
62
+ const value = env?.AGENT_INFRA_SELINUX_DISABLE;
63
+ if (!VALID_DISABLE_VALUES.has(value)) {
64
+ throw new Error('Invalid AGENT_INFRA_SELINUX_DISABLE value. Expected 1 to disable, or unset/0 for default.');
65
+ }
66
+ }
@@ -0,0 +1,65 @@
1
+ import path from 'node:path';
2
+ import { selinuxLabelForMount } from './selinux.ts';
3
+
4
+ const WINDOWS_DRIVE_PATH_PATTERN = /^([A-Za-z]):[\\/](.*)$/;
5
+ const UNC_PATH_PATTERN = /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+/;
6
+
7
+ export function hostJoin(basePath: string, ...segments: string[]): string {
8
+ return basePath.startsWith('/') ? path.posix.join(basePath, ...segments) : path.join(basePath, ...segments);
9
+ }
10
+
11
+ export function isWindowsDrivePath(value: unknown): boolean {
12
+ return typeof value === 'string' && WINDOWS_DRIVE_PATH_PATTERN.test(value);
13
+ }
14
+
15
+ export function isUncPath(value: unknown): boolean {
16
+ return typeof value === 'string' && UNC_PATH_PATTERN.test(value);
17
+ }
18
+
19
+ export function windowsPathToWslPath(value: string): string {
20
+ if (value.length === 0) {
21
+ return value;
22
+ }
23
+
24
+ if (isUncPath(value)) {
25
+ throw new Error(`UNC paths are not supported for WSL2 sandbox mounts: ${value}`);
26
+ }
27
+
28
+ const match = value.match(WINDOWS_DRIVE_PATH_PATTERN);
29
+ if (!match) {
30
+ return value.replace(/\\/g, '/');
31
+ }
32
+
33
+ const [, drive = '', rest = ''] = match;
34
+ const normalizedRest = rest.replace(/\\/g, '/').replace(/^\/+/, '');
35
+ return `/mnt/${drive.toLowerCase()}/${normalizedRest}`;
36
+ }
37
+
38
+ export function toEnginePath(engine: string, value: string): string {
39
+ if (engine !== 'wsl2') {
40
+ return value;
41
+ }
42
+
43
+ return windowsPathToWslPath(value);
44
+ }
45
+
46
+ export function volumeArg(
47
+ engine: string,
48
+ hostPath: string,
49
+ containerPath: string,
50
+ suffix = '',
51
+ options: { selinux?: 'shared' | 'none'; fs?: typeof import('node:fs'); platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}
52
+ ): string {
53
+ const { selinux = 'shared', ...selinuxOptions } = options;
54
+ const flags = suffix.replace(/^:/, '').split(',').filter(Boolean);
55
+
56
+ if (selinux !== 'none') {
57
+ const label = selinuxLabelForMount(engine, selinuxOptions);
58
+ if (label && !flags.includes(label)) {
59
+ flags.push(label);
60
+ }
61
+ }
62
+
63
+ const composedSuffix = flags.length > 0 ? `:${flags.join(',')}` : '';
64
+ return `${toEnginePath(engine, hostPath)}:${containerPath}${composedSuffix}`;
65
+ }
@@ -0,0 +1,74 @@
1
+ import type { OnMessage, RunFns, SandboxAdapter } from './index.ts';
2
+
3
+ function ensureWslAvailable(runOk: RunFns['runOk']): void {
4
+ if (runOk('wsl.exe', ['--status']) || runOk('wsl.exe', ['--', 'true'])) {
5
+ return;
6
+ }
7
+
8
+ throw new Error([
9
+ 'WSL2 is required for Windows sandbox support.',
10
+ 'Install WSL2, configure a default Linux distribution, and re-run "ai sandbox create".'
11
+ ].join('\n'));
12
+ }
13
+
14
+ function ensureDockerAvailable(runOk: RunFns['runOk']): void {
15
+ if (runOk('wsl.exe', ['--', 'docker', 'info'])) {
16
+ return;
17
+ }
18
+
19
+ throw new Error([
20
+ 'Docker is not available inside WSL2.',
21
+ 'Start Docker Desktop and enable WSL integration for your default distribution.'
22
+ ].join('\n'));
23
+ }
24
+
25
+ function wsl2BackendCheck(runOk: RunFns['runOk'], onMessage: OnMessage): void {
26
+ ensureWslAvailable(runOk);
27
+ onMessage?.('Checking Docker Desktop from WSL2...');
28
+ ensureDockerAvailable(runOk);
29
+ }
30
+
31
+ export const wsl2Adapter: SandboxAdapter = {
32
+ id: 'wsl2',
33
+ displayName: 'WSL2',
34
+ supportedPlatforms: ['win32'],
35
+ dockerContext: null,
36
+ managed: true,
37
+ canApplyResources: 'never',
38
+
39
+ defaultResources() {
40
+ return null;
41
+ },
42
+
43
+ async ensure(config, onMessage: OnMessage, { runOk }) {
44
+ wsl2BackendCheck(runOk, onMessage);
45
+ void config;
46
+ return false;
47
+ },
48
+
49
+ startVm(config, onMessage: OnMessage, { runOk }) {
50
+ wsl2BackendCheck(runOk, onMessage);
51
+ void config;
52
+ return 'already-running';
53
+ },
54
+
55
+ stopVm() {
56
+ throw new Error(
57
+ 'Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.'
58
+ );
59
+ },
60
+
61
+ syncResources(config, onMessage: OnMessage) {
62
+ if (!config.hasUserVmConfig?.(config.userVm)) {
63
+ return;
64
+ }
65
+
66
+ onMessage?.(
67
+ 'Warning: Docker Desktop manages CPU/memory/disk via Settings -> Resources. '
68
+ + 'sandbox.vm.* values and --cpu/--memory flags are not applied for this engine. '
69
+ + 'Please configure resources in Docker Desktop GUI to match.'
70
+ );
71
+ }
72
+ };
73
+
74
+ export default wsl2Adapter;
@@ -3,14 +3,15 @@ const USAGE = `Usage: ai sandbox <command> [options]
3
3
  Commands:
4
4
  create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
5
  exec <branch> [cmd...] Enter sandbox or run a command
6
+ refresh Sync host Claude Code credentials to all sandbox copies
6
7
  ls List sandboxes for the current project
7
8
  rm <branch> [--all] Remove a sandbox or all sandboxes
8
- vm status|start|stop Manage the sandbox VM (macOS only)
9
+ vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
9
10
  rebuild [--quiet] Rebuild the sandbox image
10
11
 
11
12
  Run 'ai sandbox <command> --help' for details.`;
12
13
 
13
- export async function runSandbox(args) {
14
+ export async function runSandbox(args: string[]): Promise<void> {
14
15
  const [subcommand, ...rest] = args;
15
16
 
16
17
  if (!subcommand) {
@@ -26,35 +27,43 @@ export async function runSandbox(args) {
26
27
 
27
28
  switch (subcommand) {
28
29
  case 'create': {
29
- const { create } = await import('./commands/create.js');
30
+ const { create } = await import('./commands/create.ts');
30
31
  await create(rest);
31
32
  break;
32
33
  }
33
34
  case 'exec': {
34
- const { enter } = await import('./commands/enter.js');
35
+ const { enter } = await import('./commands/enter.ts');
35
36
  const exitCode = enter(rest);
36
37
  if (typeof exitCode === 'number' && exitCode !== 0) {
37
38
  process.exitCode = exitCode;
38
39
  }
39
40
  break;
40
41
  }
42
+ case 'refresh': {
43
+ const { refresh } = await import('./commands/refresh.ts');
44
+ const exitCode = await refresh(rest);
45
+ if (typeof exitCode === 'number' && exitCode !== 0) {
46
+ process.exitCode = exitCode;
47
+ }
48
+ break;
49
+ }
41
50
  case 'ls': {
42
- const { ls } = await import('./commands/ls.js');
51
+ const { ls } = await import('./commands/ls.ts');
43
52
  ls(rest);
44
53
  break;
45
54
  }
46
55
  case 'rm': {
47
- const { rm } = await import('./commands/rm.js');
56
+ const { rm } = await import('./commands/rm.ts');
48
57
  await rm(rest);
49
58
  break;
50
59
  }
51
60
  case 'vm': {
52
- const { vm } = await import('./commands/vm.js');
61
+ const { vm } = await import('./commands/vm.ts');
53
62
  await vm(rest);
54
63
  break;
55
64
  }
56
65
  case 'rebuild': {
57
- const { rebuild } = await import('./commands/rebuild.js');
66
+ const { rebuild } = await import('./commands/rebuild.ts');
58
67
  await rebuild(rest);
59
68
  break;
60
69
  }
@@ -7,7 +7,10 @@ RUN if [ -z "${AI_TOOL_PACKAGES}" ]; then \
7
7
  echo "AI_TOOL_PACKAGES build arg is required"; \
8
8
  exit 1; \
9
9
  fi && \
10
- npm install -g ${AI_TOOL_PACKAGES}
10
+ set -e && \
11
+ for pkg in ${AI_TOOL_PACKAGES}; do \
12
+ npm install -g "$pkg"; \
13
+ done
11
14
 
12
15
  RUN npm install -g pyright
13
16
 
@@ -15,6 +18,16 @@ RUN mkdir -p /home/devuser/.local/share /home/devuser/.local/state
15
18
 
16
19
  RUN git config --global --add safe.directory /workspace
17
20
 
21
+ # Host shell-config is bind-mounted as a directory at this path; the four files
22
+ # inside (.gitconfig, .gitignore_global, .stCommitMsg, .bash_aliases) are exposed
23
+ # via symlinks in $HOME. Directory binds avoid the //deleted invalidation that
24
+ # single-file binds suffer when their source is rewritten on macOS/virtiofs.
25
+ RUN mkdir -p /home/devuser/.host-shell-config && \
26
+ ln -sf .host-shell-config/.gitconfig /home/devuser/.gitconfig && \
27
+ ln -sf .host-shell-config/.gitignore_global /home/devuser/.gitignore_global && \
28
+ ln -sf .host-shell-config/.stCommitMsg /home/devuser/.stCommitMsg && \
29
+ ln -sf .host-shell-config/.bash_aliases /home/devuser/.bash_aliases
30
+
18
31
  RUN echo 'export NPM_CONFIG_PREFIX=/home/devuser/.npm-global' >> /home/devuser/.bashrc && \
19
32
  echo 'export PATH="/home/devuser/.npm-global/bin:${PATH}"' >> /home/devuser/.bashrc && \
20
33
  echo 'export GIT_CONFIG_GLOBAL=/home/devuser/.gitconfig' >> /home/devuser/.bashrc && \
@@ -18,7 +18,7 @@ RUN if [ "${HOST_UID}" = "0" ]; then \
18
18
  fi
19
19
 
20
20
  RUN apt-get update && apt-get install -y \
21
- curl wget git vim file \
21
+ curl wget git vim file jq \
22
22
  build-essential ca-certificates gnupg lsb-release \
23
23
  libevent-core-2.1-7 libncursesw6 libtinfo6 \
24
24
  pkg-config bison libevent-dev libncurses-dev \
@@ -53,8 +53,123 @@ RUN printf '%s\n' \
53
53
  "set -as terminal-features 'xterm*:extkeys'" \
54
54
  "set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
55
55
  'set -g mouse on' \
56
+ 'set -g status-interval 1' \
57
+ 'set -g status-right-length 80' \
58
+ "set -g status-right '#(/usr/local/bin/cc-token-status) | %H:%M'" \
56
59
  > /etc/tmux.conf
57
60
 
61
+ RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/cc-token-status
62
+ #!/bin/sh
63
+ set -eu
64
+
65
+ CRED_FILE="/home/devuser/.claude/.credentials.json"
66
+ [ -r "$CRED_FILE" ] || exit 0
67
+
68
+ EXPIRES_MS=$(jq -r '(.claudeAiOauth.expiresAt // .expiresAt) // empty' "$CRED_FILE" 2>/dev/null || true)
69
+ case "$EXPIRES_MS" in
70
+ ''|*[!0-9]*) exit 0 ;;
71
+ esac
72
+
73
+ NOW_MS=$(($(date +%s) * 1000))
74
+ DIFF_MS=$((EXPIRES_MS - NOW_MS))
75
+ DIFF_S=$((DIFF_MS / 1000))
76
+
77
+ DIM='#[fg=colour245]'
78
+ YELLOW='#[fg=yellow]'
79
+ YELLOW_BOLD='#[fg=yellow,bold]'
80
+ RED_BOLD='#[fg=red,bold]'
81
+ RED_REV='#[fg=red,reverse]'
82
+ RESET='#[default]'
83
+
84
+ if [ "$DIFF_S" -le 0 ]; then
85
+ ELAPSED=$(( -DIFF_S ))
86
+ M=$((ELAPSED / 60))
87
+ printf '%sClaude Code auth EXPIRED %dm ago%s' "$RED_REV" "$M" "$RESET"
88
+ elif [ "$DIFF_S" -lt 60 ]; then
89
+ printf '%sClaude Code auth expires in %ds%s' "$RED_BOLD" "$DIFF_S" "$RESET"
90
+ elif [ "$DIFF_S" -lt 300 ]; then
91
+ M=$((DIFF_S / 60))
92
+ S=$((DIFF_S % 60))
93
+ printf '%sClaude Code auth expires in %dm %ds%s' "$RED_BOLD" "$M" "$S" "$RESET"
94
+ elif [ "$DIFF_S" -lt 1800 ]; then
95
+ M=$((DIFF_S / 60))
96
+ printf '%sClaude Code auth expires in %dm%s' "$YELLOW_BOLD" "$M" "$RESET"
97
+ elif [ "$DIFF_S" -lt 3600 ]; then
98
+ M=$((DIFF_S / 60))
99
+ printf '%sClaude Code auth expires in %dm%s' "$YELLOW" "$M" "$RESET"
100
+ else
101
+ TOTAL_M=$((DIFF_S / 60))
102
+ H=$((TOTAL_M / 60))
103
+ M=$((TOTAL_M % 60))
104
+ printf '%sClaude Code auth expires in %dh %dm%s' "$DIM" "$H" "$M" "$RESET"
105
+ fi
106
+ SCRIPT
107
+
108
+ RUN cat > /usr/local/bin/sandbox-dotfiles-link <<'SCRIPT' && chmod +x /usr/local/bin/sandbox-dotfiles-link
109
+ #!/bin/sh
110
+ # Mirror /dotfiles/ tree as symlinks under $HOME/, overwriting any image-baked
111
+ # defaults. Future preferences only need to land in the host directory.
112
+ set -eu
113
+
114
+ DOTFILES_SRC=/dotfiles
115
+ [ -d "$DOTFILES_SRC" ] || exit 0
116
+
117
+ cd "$DOTFILES_SRC"
118
+ find . -type f -print | while IFS= read -r rel; do
119
+ rel=${rel#./}
120
+ target="$HOME/$rel"
121
+ case "$rel" in
122
+ .ssh|.ssh/*|\
123
+ .gnupg|.gnupg/*|\
124
+ .claude|.claude/*|\
125
+ .codex|.codex/*|\
126
+ .gemini|.gemini/*|\
127
+ .config/opencode|.config/opencode/*|\
128
+ .local/share/opencode|.local/share/opencode/*|\
129
+ .host-shell-config|.host-shell-config/*|\
130
+ .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases)
131
+ continue ;;
132
+ esac
133
+
134
+ mkdir -p "$(dirname "$target")"
135
+ if [ -d "$target" ] && [ ! -L "$target" ]; then
136
+ printf 'sandbox-dotfiles-link: skipping %s (existing directory; use nested path like %s/<file> instead)\n' "$target" "$rel" >&2
137
+ continue
138
+ fi
139
+
140
+ ln -sfn "$DOTFILES_SRC/$rel" "$target" 2>/dev/null \
141
+ || printf 'sandbox-dotfiles-link: failed to link %s\n' "$target" >&2
142
+ done
143
+ SCRIPT
144
+
145
+ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bin/sandbox-tmux-entry
146
+ #!/bin/sh
147
+ set -eu
148
+
149
+ sandbox-dotfiles-link >/dev/null || true
150
+
151
+ SESSION=work
152
+
153
+ if ! command -v tmux >/dev/null 2>&1; then
154
+ exec bash
155
+ fi
156
+
157
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
158
+ exec tmux new-session -s "$SESSION"
159
+ fi
160
+
161
+ tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
162
+ while read -r name attached; do
163
+ [ "$name" = "$SESSION" ] && continue
164
+ case "$name" in
165
+ ''|*[!0-9]*) continue ;;
166
+ esac
167
+ [ "$attached" = "0" ] && tmux kill-session -t "$name" 2>/dev/null || true
168
+ done
169
+
170
+ exec tmux new-session -t "$SESSION"
171
+ SCRIPT
172
+
58
173
  ENV LANG=en_US.UTF-8
59
174
  ENV LC_ALL=en_US.UTF-8
60
175
  ENV TERM=xterm-256color