@ai-support-agent/cli 0.0.39 → 0.0.40-beta.1

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 (75) hide show
  1. package/dist/agent-runner.d.ts +2 -6
  2. package/dist/agent-runner.d.ts.map +1 -1
  3. package/dist/agent-runner.js +6 -13
  4. package/dist/agent-runner.js.map +1 -1
  5. package/dist/api-client.d.ts.map +1 -1
  6. package/dist/api-client.js +3 -7
  7. package/dist/api-client.js.map +1 -1
  8. package/dist/commands/chat-executor.d.ts +0 -14
  9. package/dist/commands/chat-executor.d.ts.map +1 -1
  10. package/dist/commands/chat-executor.js +16 -20
  11. package/dist/commands/chat-executor.js.map +1 -1
  12. package/dist/commands/claude-code-runner.d.ts +2 -0
  13. package/dist/commands/claude-code-runner.d.ts.map +1 -1
  14. package/dist/commands/claude-code-runner.js +5 -1
  15. package/dist/commands/claude-code-runner.js.map +1 -1
  16. package/dist/commands/claude-code-stream.d.ts +13 -1
  17. package/dist/commands/claude-code-stream.d.ts.map +1 -1
  18. package/dist/commands/claude-code-stream.js +10 -2
  19. package/dist/commands/claude-code-stream.js.map +1 -1
  20. package/dist/docker/docker-runner.d.ts +16 -70
  21. package/dist/docker/docker-runner.d.ts.map +1 -1
  22. package/dist/docker/docker-runner.js +50 -989
  23. package/dist/docker/docker-runner.js.map +1 -1
  24. package/dist/docker/docker-security.d.ts +11 -0
  25. package/dist/docker/docker-security.d.ts.map +1 -0
  26. package/dist/docker/docker-security.js +25 -0
  27. package/dist/docker/docker-security.js.map +1 -0
  28. package/dist/docker/docker-supervisor.d.ts +30 -0
  29. package/dist/docker/docker-supervisor.d.ts.map +1 -0
  30. package/dist/docker/docker-supervisor.js +408 -0
  31. package/dist/docker/docker-supervisor.js.map +1 -0
  32. package/dist/docker/docker-utils.d.ts +46 -0
  33. package/dist/docker/docker-utils.d.ts.map +1 -0
  34. package/dist/docker/docker-utils.js +147 -0
  35. package/dist/docker/docker-utils.js.map +1 -0
  36. package/dist/docker/dockerfile-generator.d.ts +13 -0
  37. package/dist/docker/dockerfile-generator.d.ts.map +1 -0
  38. package/dist/docker/dockerfile-generator.js +50 -0
  39. package/dist/docker/dockerfile-generator.js.map +1 -0
  40. package/dist/docker/dockerfile-sync.d.ts +13 -0
  41. package/dist/docker/dockerfile-sync.d.ts.map +1 -0
  42. package/dist/docker/dockerfile-sync.js +76 -0
  43. package/dist/docker/dockerfile-sync.js.map +1 -0
  44. package/dist/docker/project-config.d.ts +22 -0
  45. package/dist/docker/project-config.d.ts.map +1 -0
  46. package/dist/docker/project-config.js +80 -0
  47. package/dist/docker/project-config.js.map +1 -0
  48. package/dist/docker/project-image-builder.d.ts +12 -0
  49. package/dist/docker/project-image-builder.d.ts.map +1 -0
  50. package/dist/docker/project-image-builder.js +90 -0
  51. package/dist/docker/project-image-builder.js.map +1 -0
  52. package/dist/docker/update-handler.d.ts +14 -0
  53. package/dist/docker/update-handler.d.ts.map +1 -0
  54. package/dist/docker/update-handler.js +93 -0
  55. package/dist/docker/update-handler.js.map +1 -0
  56. package/dist/docker/version-manager.d.ts +21 -0
  57. package/dist/docker/version-manager.d.ts.map +1 -0
  58. package/dist/docker/version-manager.js +67 -0
  59. package/dist/docker/version-manager.js.map +1 -0
  60. package/dist/docker/volume-mount-builder.d.ts +38 -0
  61. package/dist/docker/volume-mount-builder.d.ts.map +1 -0
  62. package/dist/docker/volume-mount-builder.js +237 -0
  63. package/dist/docker/volume-mount-builder.js.map +1 -0
  64. package/dist/project-agent.d.ts.map +1 -1
  65. package/dist/project-agent.js +2 -6
  66. package/dist/project-agent.js.map +1 -1
  67. package/dist/utils/token-utils.d.ts +30 -0
  68. package/dist/utils/token-utils.d.ts.map +1 -0
  69. package/dist/utils/token-utils.js +44 -0
  70. package/dist/utils/token-utils.js.map +1 -0
  71. package/dist/utils.d.ts +6 -0
  72. package/dist/utils.d.ts.map +1 -1
  73. package/dist/utils.js +11 -0
  74. package/dist/utils.js.map +1 -1
  75. package/package.json +1 -1
@@ -1,4 +1,10 @@
1
1
  "use strict";
2
+ /**
3
+ * Docker runner — main entry point for Docker-based execution
4
+ *
5
+ * Orchestrates Docker availability check, image build, and container supervision.
6
+ * Supports both legacy single-container mode and per-project supervisor mode.
7
+ */
2
8
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
9
  if (k2 === undefined) k2 = k;
4
10
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -33,375 +39,24 @@ var __importStar = (this && this.__importStar) || (function () {
33
39
  };
34
40
  })();
35
41
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.checkDockerAvailable = checkDockerAvailable;
37
- exports.imageExists = imageExists;
38
- exports.buildImage = buildImage;
39
- exports.validatePackageNames = validatePackageNames;
40
- exports.generateProjectDockerfile = generateProjectDockerfile;
41
- exports.buildProjectImage = buildProjectImage;
42
- exports.buildContainerName = buildContainerName;
43
- exports.removeStaleContainer = removeStaleContainer;
44
- exports.buildVolumeMounts = buildVolumeMounts;
45
- exports.buildEnvArgs = buildEnvArgs;
42
+ exports.DockerSupervisor = exports.migrateProjectConfigDir = exports.ensureImage = exports.resetInstalledVersionCache = exports.getInstalledVersion = exports.buildProjectVolumeMounts = exports.buildEnvArgs = exports.buildVolumeMounts = exports.buildProjectImage = exports.syncDockerfileToConfigDir = exports.generateProjectDockerfile = exports.validatePackageNames = exports.dockerLogin = exports.removeStaleContainer = exports.buildContainerName = exports.buildImage = exports.imageExists = exports.checkDockerAvailable = void 0;
46
43
  exports.buildContainerArgs = buildContainerArgs;
47
- exports.getInstalledVersion = getInstalledVersion;
48
- exports.resetInstalledVersionCache = resetInstalledVersionCache;
49
- exports.ensureImage = ensureImage;
50
- exports.dockerLogin = dockerLogin;
51
- exports.syncDockerfileToConfigDir = syncDockerfileToConfigDir;
52
- exports.migrateProjectConfigDir = migrateProjectConfigDir;
53
44
  exports.resetIsDockerRunning = resetIsDockerRunning;
54
45
  exports.runInDocker = runInDocker;
55
46
  const child_process_1 = require("child_process");
56
- const fs = __importStar(require("fs"));
57
47
  const os = __importStar(require("os"));
58
- const path = __importStar(require("path"));
59
- const dockerfile_path_1 = require("./dockerfile-path");
60
48
  const constants_1 = require("../constants");
61
49
  const config_manager_1 = require("../config-manager");
62
50
  const pid_manager_1 = require("../pid-manager");
63
51
  const i18n_1 = require("../i18n");
64
52
  const logger_1 = require("../logger");
65
- const security_1 = require("../security");
66
53
  const claude_config_validator_1 = require("../utils/claude-config-validator");
67
- const version_1 = require("../utils/version");
68
- const update_checker_1 = require("../update-checker");
69
- const api_client_1 = require("../api-client");
70
- function makeSessionId() {
71
- const d = new Date();
72
- const pad = (n, len = 2) => String(n).padStart(len, '0');
73
- return (String(d.getFullYear()) +
74
- pad(d.getMonth() + 1) +
75
- pad(d.getDate()) +
76
- pad(d.getHours()) +
77
- pad(d.getMinutes()) +
78
- pad(d.getSeconds()));
79
- }
80
- /** Convert a path.relative() result to POSIX format for container use */
81
- function toPosixRelative(relativePath) {
82
- return relativePath.split(path.sep).join('/');
83
- }
84
- /**
85
- * Returns true when running via ts-node (i.e. `npm run dev`).
86
- * In this case, local dist/ should be mounted into containers instead of
87
- * relying on the npm-installed package inside the image.
88
- */
89
- function isRunningViaTsNode() {
90
- const sym = Symbol.for('ts-node.register.instance');
91
- return !!process[sym];
92
- }
93
- /**
94
- * Returns extra volume mount args to overlay the local dist/ into the container
95
- * when running in dev mode (ts-node), so the container uses local source code.
96
- * Returns an empty array when not in dev mode.
97
- */
98
- function buildDevMounts() {
99
- if (!isRunningViaTsNode())
100
- return [];
101
- // __dirname is agent/src/docker — walk up two levels to get agent/
102
- const agentRoot = path.resolve(__dirname, '..', '..');
103
- const distDir = path.join(agentRoot, 'dist');
104
- const localesDir = path.join(agentRoot, 'src', 'locales');
105
- const containerBase = '/usr/local/lib/node_modules/@ai-support-agent/cli';
106
- return [
107
- '-v', `${distDir}:${containerBase}/dist:ro`,
108
- '-v', `${localesDir}:${containerBase}/dist/locales:ro`,
109
- ];
110
- }
111
- const IMAGE_NAME = 'ai-support-agent';
112
- const PASSTHROUGH_ENV_VARS = [
113
- 'AI_SUPPORT_AGENT_TOKEN',
114
- 'AI_SUPPORT_AGENT_API_URL',
115
- 'AI_SUPPORT_AGENT_CONFIG_DIR',
116
- 'ANTHROPIC_API_KEY',
117
- 'CLAUDE_CODE_OAUTH_TOKEN',
118
- ];
119
- function checkDockerAvailable() {
120
- try {
121
- (0, child_process_1.execFileSync)('docker', ['info'], { stdio: 'ignore' });
122
- return true;
123
- }
124
- catch {
125
- return false;
126
- }
127
- }
128
- function imageExists(version) {
129
- try {
130
- (0, child_process_1.execFileSync)('docker', ['image', 'inspect', `${IMAGE_NAME}:${version}`], { stdio: 'ignore' });
131
- return true;
132
- }
133
- catch {
134
- return false;
135
- }
136
- }
137
- function buildImage(version, customDockerfile) {
138
- const { dockerfilePath, contextDir } = (0, dockerfile_path_1.resolveDockerfile)(customDockerfile);
139
- logger_1.logger.info((0, i18n_1.t)('docker.building'));
140
- if (customDockerfile) {
141
- logger_1.logger.info((0, i18n_1.t)('docker.usingCustomDockerfile', { path: dockerfilePath }));
142
- }
143
- (0, child_process_1.execFileSync)('docker', ['build', '-t', `${IMAGE_NAME}:${version}`, '--pull=false', '--build-arg', `AGENT_VERSION=${version}`, '-f', dockerfilePath, contextDir], { stdio: 'inherit' });
144
- logger_1.logger.success((0, i18n_1.t)('docker.buildComplete'));
145
- }
146
- /** Allowlist for apt package names: letters, digits, hyphens, dots, plus signs, colons */
147
- const APT_PACKAGE_RE = /^[a-zA-Z0-9][a-zA-Z0-9+\-.:]*$/;
148
- /** Allowlist for npm package names: scoped (@scope/name) or plain, with version (@x.y.z) */
149
- const NPM_PACKAGE_RE = /^(@[a-zA-Z0-9_\-.]+\/)?[a-zA-Z0-9_\-.]+(@[a-zA-Z0-9._\-^~*]+)?$/;
150
- /**
151
- * Validate a list of package names against an allowlist regex.
152
- * Throws if any name is invalid to prevent Dockerfile injection.
153
- */
154
- function validatePackageNames(packages, type) {
155
- const re = type === 'apt' ? APT_PACKAGE_RE : NPM_PACKAGE_RE;
156
- for (const pkg of packages) {
157
- if (!re.test(pkg)) {
158
- throw new Error(`Invalid ${type} package name: "${pkg}"`);
159
- }
160
- }
161
- }
162
- /**
163
- * Generate a per-project Dockerfile that extends the base agent image.
164
- */
165
- function generateProjectDockerfile(baseVersion, aptPackages, npmPackages, commands = [], timezone) {
166
- validatePackageNames(aptPackages, 'apt');
167
- validatePackageNames(npmPackages, 'npm');
168
- const lines = [`FROM ${IMAGE_NAME}:${baseVersion}`];
169
- if (timezone) {
170
- // Override the TZ env var set by docker run, allowing per-project custom timezone
171
- lines.push(`ENV TZ=${timezone}`);
172
- }
173
- if (aptPackages.length > 0) {
174
- lines.push(`RUN apt-get update && apt-get install -y --no-install-recommends \\`, ` ${aptPackages.join(' \\\n ')} \\`, ` && rm -rf /var/lib/apt/lists/*`);
175
- }
176
- if (npmPackages.length > 0) {
177
- lines.push(`RUN npm install -g ${npmPackages.join(' ')} && npm cache clean --force`);
178
- }
179
- for (const cmd of commands) {
180
- if (/[\n\r|`;$()]/.test(cmd)) {
181
- throw new Error(`Invalid command (contains forbidden character): "${cmd.substring(0, 50)}"`);
182
- }
183
- lines.push(`RUN ${cmd}`);
184
- }
185
- return lines.join('\n') + '\n';
186
- }
187
- /**
188
- * Build a per-project Docker image using the given Dockerfile.
189
- * Streams stdout/stderr to both the host terminal and the API (for real-time log viewing).
190
- */
191
- /** Maximum total log size kept in memory per session (2 MB). Older content is discarded. */
192
- const MAX_SESSION_LOG_BYTES = 2 * 1024 * 1024;
193
- /**
194
- * docker build プロセスに渡す環境変数を必要最小限に絞る。
195
- * process.env をそのまま渡すと API キー等の機密情報が漏洩するため、
196
- * ビルドに必要な変数のみを明示的に選択する。
197
- */
198
- function buildDockerEnv() {
199
- const ALLOWED_KEYS = ['PATH', 'HOME', 'USER', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL'];
200
- const env = {};
201
- for (const key of ALLOWED_KEYS) {
202
- if (process.env[key] !== undefined) {
203
- env[key] = process.env[key];
204
- }
205
- }
206
- env['BUILDKIT_PROGRESS'] = 'plain';
207
- return env;
208
- }
209
- async function buildProjectImage(tenantCode, projectCode, baseVersion, dockerfilePath, apiClient, agentId) {
210
- const imageTag = (0, dockerfile_path_1.getProjectImageTag)(tenantCode, projectCode, baseVersion);
211
- const contextDir = (0, dockerfile_path_1.getDockerContextDir)();
212
- const projectKey = `${tenantCode}#${projectCode}`;
213
- const color = (0, logger_1.getProjectColor)(projectKey);
214
- const reset = '\x1b[0m';
215
- const prefix = `${color}[${projectKey}]${reset} `;
216
- logger_1.logger.info(`[docker] Building project image: ${imageTag}`);
217
- const sessionId = makeSessionId();
218
- let seq = 0;
219
- let fullLog = '';
220
- let logTruncated = false;
221
- let buf = '';
222
- const flush = async () => {
223
- if (!buf)
224
- return;
225
- const text = buf;
226
- buf = '';
227
- if (!logTruncated) {
228
- if (fullLog.length + text.length <= MAX_SESSION_LOG_BYTES) {
229
- fullLog += text;
230
- }
231
- else {
232
- const remaining = MAX_SESSION_LOG_BYTES - fullLog.length;
233
- fullLog += remaining > 0 ? text.slice(0, remaining) : '';
234
- logTruncated = true;
235
- logger_1.logger.warn('[docker] Build log exceeded 2 MB limit; remaining output will not be saved to S3');
236
- }
237
- }
238
- if (apiClient) {
239
- await apiClient.submitLogChunk({ agentId: agentId ?? '', projectCode, logType: 'docker-build', sessionId, seq: ++seq, text })
240
- .catch((e) => logger_1.logger.warn(`[docker] Failed to send log chunk: ${e}`));
241
- }
242
- };
243
- let buildError;
244
- await new Promise((resolve, reject) => {
245
- const proc = (0, child_process_1.spawn)('docker', [
246
- 'build', '-t', imageTag, '--pull=false', '--progress=plain',
247
- '--build-arg', `AGENT_VERSION=${baseVersion}`,
248
- '-f', dockerfilePath, contextDir,
249
- ], { stdio: ['ignore', 'pipe', 'pipe'], env: buildDockerEnv() });
250
- const writePrefixed = (0, logger_1.makeLinePrefixer)(prefix, (s) => process.stdout.write(s));
251
- const onData = (d) => {
252
- const text = d.toString();
253
- writePrefixed(text);
254
- buf += text;
255
- if (buf.length > 4096) {
256
- void flush();
257
- }
258
- };
259
- proc.stdout?.on('data', onData);
260
- proc.stderr?.on('data', onData);
261
- proc.on('error', (err) => reject(err));
262
- proc.on('close', (code) => {
263
- if (code === 0)
264
- resolve();
265
- else
266
- reject(new Error(`docker build exited with code ${code}`));
267
- });
268
- }).catch((e) => { buildError = e instanceof Error ? e : new Error(String(e)); });
269
- await flush();
270
- if (apiClient && fullLog) {
271
- await apiClient.saveSessionLog({ agentId: agentId ?? '', projectCode, logType: 'docker-build', sessionId, content: fullLog })
272
- .catch((e) => logger_1.logger.warn(`[docker] Failed to upload build log to S3: ${e}`));
273
- }
274
- if (buildError)
275
- throw buildError;
276
- logger_1.logger.success(`[docker] Project image built: ${imageTag}`);
277
- }
278
- /** Container-internal base path for project directories */
279
- const CONTAINER_PROJECTS_BASE = '/workspace/projects';
280
- /** Container-internal home directory */
281
- const CONTAINER_HOME = '/home/node';
282
- /**
283
- * Build a deterministic container name for a project.
284
- * Format: asa-{tenantCode}-{projectCode}-{agentId}
285
- * All components are lowercased and non-alphanumeric chars (except hyphens) are replaced with hyphens.
286
- */
287
- function buildContainerName(tenantCode, projectCode, agentId) {
288
- const sanitize = (s) => s.toLowerCase().replace(/[^a-z0-9-]/g, '-');
289
- const parts = ['ai', sanitize(tenantCode), sanitize(projectCode)];
290
- if (agentId)
291
- parts.push(sanitize(agentId));
292
- return parts.join('-');
293
- }
294
- /**
295
- * Remove a stale container with the given name if it exists.
296
- * This handles the case where a previous run crashed and left a container behind,
297
- * which would prevent `docker run --name` from succeeding.
298
- */
299
- function removeStaleContainer(containerName) {
300
- try {
301
- (0, child_process_1.execFileSync)('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
302
- }
303
- catch {
304
- // Container does not exist or docker rm failed — ignore
305
- }
306
- }
307
- function buildVolumeMounts() {
308
- const home = os.homedir();
309
- const mounts = [];
310
- const projectMappings = [];
311
- // Claude Code OAuth tokens and config — mount to container home
312
- const claudeDir = path.join(home, '.claude');
313
- if (fs.existsSync(claudeDir)) {
314
- mounts.push('-v', `${claudeDir}:${path.posix.join(CONTAINER_HOME, '.claude')}:rw`);
315
- }
316
- const claudeJson = path.join(home, '.claude.json');
317
- if (fs.existsSync(claudeJson)) {
318
- mounts.push('-v', `${claudeJson}:${path.posix.join(CONTAINER_HOME, '.claude.json')}:rw`);
319
- }
320
- // Agent config — mount to container home
321
- const agentConfigDir = (0, config_manager_1.getConfigDir)();
322
- if (fs.existsSync(agentConfigDir)) {
323
- const relativeToHome = path.relative(home, agentConfigDir);
324
- const isUnderHome = !relativeToHome.startsWith('..');
325
- const containerConfigDir = isUnderHome
326
- ? path.posix.join(CONTAINER_HOME, toPosixRelative(relativeToHome))
327
- : `/workspace/.config/ai-support-agent`;
328
- mounts.push('-v', `${agentConfigDir}:${containerConfigDir}:rw`);
329
- }
330
- // AWS credentials — mount to container home
331
- const awsDir = path.join(home, '.aws');
332
- if (fs.existsSync(awsDir)) {
333
- mounts.push('-v', `${awsDir}:${path.posix.join(CONTAINER_HOME, '.aws')}:ro`);
334
- }
335
- // Custom project directories — mount to /workspace/projects/{projectCode}
336
- const config = (0, config_manager_1.loadConfig)();
337
- if (config?.projects) {
338
- const mounted = new Set();
339
- const blockedPrefixes = [...security_1.BLOCKED_PATH_PREFIXES, ...(0, security_1.getSensitiveHomePaths)()];
340
- for (const project of config.projects) {
341
- if (project.projectDir && !mounted.has(project.projectDir) && fs.existsSync(project.projectDir)) {
342
- let resolved;
343
- try {
344
- resolved = fs.realpathSync(project.projectDir);
345
- }
346
- catch {
347
- logger_1.logger.warn(`[docker] Cannot resolve path, skipping: ${project.projectDir}`);
348
- continue;
349
- }
350
- const isBlocked = blockedPrefixes.some((prefix) => {
351
- const prefixWithoutSlash = prefix.replace(/\/$/, '');
352
- return resolved === prefixWithoutSlash || resolved.startsWith(prefix);
353
- });
354
- if (isBlocked) {
355
- logger_1.logger.warn(`[docker] Skipping blocked path for volume mount: ${project.projectDir}`);
356
- continue;
357
- }
358
- const containerDir = `${CONTAINER_PROJECTS_BASE}/${project.projectCode}`;
359
- mounts.push('-v', `${project.projectDir}:${containerDir}:rw`);
360
- projectMappings.push({
361
- hostDir: project.projectDir,
362
- containerDir,
363
- projectCode: project.projectCode,
364
- });
365
- mounted.add(project.projectDir);
366
- }
367
- }
368
- }
369
- return { mounts, projectMappings };
370
- }
371
- function buildEnvArgs(projectMappings) {
372
- const args = [];
373
- // Mark process as running inside a Docker container
374
- args.push('-e', 'AI_SUPPORT_AGENT_IN_DOCKER=1');
375
- // Set HOME to container-internal path (not host path)
376
- args.push('-e', `HOME=${CONTAINER_HOME}`);
377
- // Map config dir to container-internal path
378
- const hostConfigDir = (0, config_manager_1.getConfigDir)();
379
- const home = os.homedir();
380
- const relativeToHome = path.relative(home, hostConfigDir);
381
- const isUnderHome = !relativeToHome.startsWith('..');
382
- const containerConfigDir = isUnderHome
383
- ? path.posix.join(CONTAINER_HOME, toPosixRelative(relativeToHome))
384
- : `/workspace/.config/ai-support-agent`;
385
- for (const key of PASSTHROUGH_ENV_VARS) {
386
- if (process.env[key]) {
387
- if (key === 'AI_SUPPORT_AGENT_CONFIG_DIR') {
388
- args.push('-e', `${key}=${containerConfigDir}`);
389
- }
390
- else {
391
- args.push('-e', `${key}=${process.env[key]}`);
392
- }
393
- }
394
- }
395
- // Pass project directory mappings so agent uses container-internal paths
396
- if (projectMappings.length > 0) {
397
- // Format: projectCode=containerDir;projectCode2=containerDir2
398
- const mapping = projectMappings
399
- .map((m) => `${m.projectCode}=${m.containerDir}`)
400
- .join(';');
401
- args.push('-e', `AI_SUPPORT_AGENT_PROJECT_DIR_MAP=${mapping}`);
402
- }
403
- return args;
404
- }
54
+ const docker_utils_1 = require("./docker-utils");
55
+ const version_manager_1 = require("./version-manager");
56
+ const dockerfile_sync_1 = require("./dockerfile-sync");
57
+ const volume_mount_builder_1 = require("./volume-mount-builder");
58
+ const docker_supervisor_1 = require("./docker-supervisor");
59
+ const update_handler_1 = require("./update-handler");
405
60
  function buildContainerArgs(opts) {
406
61
  const args = ['ai-support-agent', 'start', '--no-docker'];
407
62
  if (opts.token) {
@@ -430,626 +85,13 @@ function buildContainerArgs(opts) {
430
85
  }
431
86
  return args;
432
87
  }
433
- let cachedInstalledVersion = null;
434
- /**
435
- * Get the currently installed version of @ai-support-agent/cli from npm global.
436
- * Falls back to AGENT_VERSION if npm query fails.
437
- * Result is cached after the first call.
438
- *
439
- * NOTE: Must only be called from host-side code (i.e. runInDocker).
440
- * Inside a Docker container the process runs with --no-docker, so
441
- * ensureImage() / getInstalledVersion() are never reached.
442
- */
443
- function getInstalledVersion() {
444
- if (cachedInstalledVersion !== null)
445
- return cachedInstalledVersion;
446
- try {
447
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
448
- const output = (0, child_process_1.execFileSync)(npmCmd, ['list', '-g', '--json', '--depth=0'], {
449
- encoding: 'utf-8',
450
- timeout: 10_000,
451
- });
452
- const parsed = JSON.parse(output);
453
- const version = parsed.dependencies?.['@ai-support-agent/cli']?.version;
454
- if (version && (0, version_1.isValidVersion)(version)) {
455
- cachedInstalledVersion = version;
456
- return version;
457
- }
458
- }
459
- catch {
460
- // npm list failed — fall back to compile-time version
461
- }
462
- cachedInstalledVersion = constants_1.AGENT_VERSION;
463
- return constants_1.AGENT_VERSION;
464
- }
465
- /**
466
- * Reset the cached installed version (for testing).
467
- */
468
- function resetInstalledVersionCache() {
469
- cachedInstalledVersion = null;
470
- }
471
- function ensureImage(customDockerfile) {
472
- const installedVersion = getInstalledVersion();
473
- // Use the installed version if it is newer than the compile-time version
474
- const version = (0, version_1.isNewerVersion)(constants_1.AGENT_VERSION, installedVersion) ? installedVersion : constants_1.AGENT_VERSION;
475
- if (!imageExists(version)) {
476
- buildImage(version, customDockerfile);
477
- }
478
- else {
479
- logger_1.logger.info((0, i18n_1.t)('docker.imageFound', { version }));
480
- }
481
- return version;
482
- }
483
- function dockerLogin() {
484
- logger_1.logger.info((0, i18n_1.t)('docker.loginStep1'));
485
- console.log('');
486
- console.log(' claude setup-token');
487
- console.log('');
488
- logger_1.logger.info((0, i18n_1.t)('docker.loginStep2'));
489
- console.log('');
490
- console.log(' export CLAUDE_CODE_OAUTH_TOKEN=<token>');
491
- console.log(' ai-support-agent start');
492
- console.log('');
493
- logger_1.logger.info((0, i18n_1.t)('docker.loginStep3'));
494
- }
495
- /**
496
- * Called on the host after a container exits with DOCKER_UPDATE_EXIT_CODE.
497
- * Reads the new version from a project-specific config dir (written by the container),
498
- * installs it on the host with npm, then re-execs the process so ensureImage()
499
- * picks up the newly installed version and rebuilds the Docker image.
500
- */
501
- async function installUpdateAndRestart(projectConfigDir) {
502
- let newVersion;
503
- // First try project-specific config dir, fall back to global config dir
504
- const searchDirs = projectConfigDir
505
- ? [projectConfigDir, (0, config_manager_1.getConfigDir)()]
506
- : [(0, config_manager_1.getConfigDir)()];
507
- for (const dir of searchDirs) {
508
- try {
509
- const versionFile = path.join(dir, 'update-version.json');
510
- const raw = fs.readFileSync(versionFile, 'utf-8');
511
- const parsed = JSON.parse(raw);
512
- if (parsed.version && (0, version_1.isValidVersion)(parsed.version)) {
513
- newVersion = parsed.version;
514
- }
515
- fs.unlinkSync(versionFile);
516
- break;
517
- }
518
- catch {
519
- // File does not exist in this dir — try next
520
- }
521
- }
522
- if (newVersion) {
523
- logger_1.logger.info(`[docker] Installing @ai-support-agent/cli@${newVersion} on host...`);
524
- // Always use 'global' install method on the host side regardless of how the
525
- // host process was originally launched (it may be detected as 'local' in
526
- // service/systemd environments where the script path is under node_modules).
527
- const result = await (0, update_checker_1.performUpdate)(newVersion, 'global');
528
- if (!result.success) {
529
- logger_1.logger.warn(`[docker] Host npm install failed: ${result.error ?? 'unknown'}. Proceeding with existing version.`);
530
- }
531
- else {
532
- // Invalidate the cached installed version so ensureImage() re-reads it
533
- resetInstalledVersionCache(); // defined in this file
534
- }
535
- }
536
- (0, update_checker_1.reExecProcess)();
537
- }
538
- /**
539
- * Copy the bundled Dockerfile (and entrypoint.sh) to the config directory
540
- * on first run so users can customise it.
541
- * Does NOT overwrite an existing file — user edits are preserved.
542
- * Exported for testing.
543
- */
544
- function syncDockerfileToConfigDir() {
545
- const configDir = (0, config_manager_1.getConfigDir)();
546
- const destDockerfile = path.join(configDir, 'Dockerfile');
547
- if (fs.existsSync(destDockerfile))
548
- return; // preserve existing file
549
- try {
550
- const srcDockerfile = (0, dockerfile_path_1.getDockerfilePath)();
551
- fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
552
- fs.copyFileSync(srcDockerfile, destDockerfile);
553
- // Also copy entrypoint.sh which the bundled Dockerfile references via COPY
554
- const srcEntrypoint = path.join((0, dockerfile_path_1.getDockerContextDir)(), 'docker', 'entrypoint.sh');
555
- const destEntrypoint = path.join(configDir, 'docker', 'entrypoint.sh');
556
- if (fs.existsSync(srcEntrypoint)) {
557
- fs.mkdirSync(path.dirname(destEntrypoint), { recursive: true });
558
- fs.copyFileSync(srcEntrypoint, destEntrypoint);
559
- }
560
- logger_1.logger.info((0, i18n_1.t)('docker.dockerfileSynced', { path: destDockerfile }));
561
- }
562
- catch (err) {
563
- logger_1.logger.warn((0, i18n_1.t)('docker.dockerfileSyncFailed', { message: err instanceof Error ? err.message : String(err) }));
564
- }
565
- }
566
- // ---------------------------------------------------------------------------
567
- // Per-project volume mount helpers
568
- // ---------------------------------------------------------------------------
569
- /**
570
- * Build volume mounts and env args for a single project container.
571
- * Each project gets its own isolated config dir under ~/.ai-support-agent/projects/{tenantCode}/{projectCode}/.ai-support-agent/
572
- */
573
- function buildProjectVolumeMounts(project, projectConfigHostDir) {
574
- const home = os.homedir();
575
- const mounts = [];
576
- const envArgs = [];
577
- // Claude Code OAuth tokens and config — mount to container home
578
- const claudeDir = path.join(home, '.claude');
579
- if (fs.existsSync(claudeDir)) {
580
- mounts.push('-v', `${claudeDir}:${path.posix.join(CONTAINER_HOME, '.claude')}:rw`);
581
- }
582
- const claudeJson = path.join(home, '.claude.json');
583
- if (fs.existsSync(claudeJson)) {
584
- mounts.push('-v', `${claudeJson}:${path.posix.join(CONTAINER_HOME, '.claude.json')}:rw`);
585
- }
586
- // Per-project isolated config dir — mounts to /home/node/.ai-support-agent inside container
587
- const containerConfigDir = path.posix.join(CONTAINER_HOME, '.ai-support-agent');
588
- fs.mkdirSync(projectConfigHostDir, { recursive: true, mode: 0o700 });
589
- mounts.push('-v', `${projectConfigHostDir}:${containerConfigDir}:rw`);
590
- // Standard env vars for per-project container
591
- envArgs.push('-e', 'AI_SUPPORT_AGENT_IN_DOCKER=1');
592
- envArgs.push('-e', `HOME=${CONTAINER_HOME}`);
593
- envArgs.push('-e', `AI_SUPPORT_AGENT_CONFIG_DIR=${containerConfigDir}`);
594
- // Pass token and apiUrl per-project
595
- if (project.token) {
596
- envArgs.push('-e', `AI_SUPPORT_AGENT_TOKEN=${project.token}`);
597
- }
598
- if (project.apiUrl) {
599
- // Replace localhost/127.0.0.1 with host.docker.internal so the container can reach the host
600
- const containerApiUrl = project.apiUrl.replace(/^(https?:\/\/)(localhost|127\.0\.0\.1)(:\d+)?/, (_, scheme, _host, port) => `${scheme}host.docker.internal${port ?? ''}`);
601
- envArgs.push('-e', `AI_SUPPORT_AGENT_API_URL=${containerApiUrl}`);
602
- }
603
- // Pass ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN if set
604
- for (const key of ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']) {
605
- if (process.env[key]) {
606
- envArgs.push('-e', `${key}=${process.env[key]}`);
607
- }
608
- }
609
- // Pass host timezone so container clocks match the host by default.
610
- // The per-project Dockerfile can override this with ENV TZ= if a custom timezone is configured.
611
- const hostTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
612
- envArgs.push('-e', `TZ=${hostTz}`);
613
- // Project dir mapping: single project only
614
- const containerProjectDir = `${CONTAINER_PROJECTS_BASE}/${project.projectCode}`;
615
- const blockedPrefixes = [...security_1.BLOCKED_PATH_PREFIXES, ...(0, security_1.getSensitiveHomePaths)()];
616
- if (project.projectDir && fs.existsSync(project.projectDir)) {
617
- let resolved;
618
- try {
619
- resolved = fs.realpathSync(project.projectDir);
620
- const isBlocked = blockedPrefixes.some((prefix) => {
621
- const prefixWithoutSlash = prefix.replace(/\/$/, '');
622
- return resolved === prefixWithoutSlash || resolved.startsWith(prefix);
623
- });
624
- if (!isBlocked) {
625
- mounts.push('-v', `${project.projectDir}:${containerProjectDir}:rw`);
626
- envArgs.push('-e', `AI_SUPPORT_AGENT_PROJECT_DIR_MAP=${project.projectCode}=${containerProjectDir}`);
627
- }
628
- else {
629
- logger_1.logger.warn(`[docker] Skipping blocked path for volume mount: ${project.projectDir}`);
630
- }
631
- }
632
- catch {
633
- logger_1.logger.warn(`[docker] Cannot resolve path, skipping: ${project.projectDir}`);
634
- }
635
- }
636
- return { mounts, envArgs };
637
- }
638
- /**
639
- * Migrate per-project config directory from the legacy layout to the current layout.
640
- *
641
- * Legacy: ~/.ai-support-agent/projects/{projectCode}/
642
- * Current: ~/.ai-support-agent/projects/{tenantCode}/{projectCode}/
643
- *
644
- * Older agent versions stored project data without a tenantCode path segment.
645
- * This function moves the directory once so the current code finds it in the right place.
646
- */
647
- function migrateProjectConfigDir(project) {
648
- const configBase = path.join((0, config_manager_1.getConfigDir)(), 'projects');
649
- const legacyDir = path.join(configBase, project.projectCode);
650
- const newDir = path.join(configBase, project.tenantCode, project.projectCode);
651
- if (!fs.existsSync(legacyDir))
652
- return; // nothing to migrate
653
- if (fs.existsSync(newDir))
654
- return; // already migrated
655
- try {
656
- fs.mkdirSync(path.join(configBase, project.tenantCode), { recursive: true, mode: 0o700 });
657
- fs.renameSync(legacyDir, newDir);
658
- logger_1.logger.info(`[docker] Migrated project config dir: ${legacyDir} → ${newDir}`);
659
- }
660
- catch (err) {
661
- logger_1.logger.warn(`[docker] Failed to migrate project config dir for ${project.projectCode}: ${err instanceof Error ? err.message : String(err)}`);
662
- }
663
- }
664
- /**
665
- * Get the host-side per-project config directory.
666
- * Located at: ~/.ai-support-agent/projects/{tenantCode}/{projectCode}/.ai-support-agent/
667
- */
668
- function getProjectConfigHostDir(project) {
669
- return path.join((0, config_manager_1.getConfigDir)(), 'projects', project.tenantCode, project.projectCode, '.ai-support-agent');
670
- }
671
- /**
672
- * Manages one Docker container per project.
673
- * Spawned from runInDocker() when multiple projects are configured.
674
- */
675
- class DockerSupervisor {
676
- handles = new Map();
677
- updating = false;
678
- opts;
679
- version;
680
- onAllStopped;
681
- defaultAgentId;
682
- /** Per-project agentId updated when the container registers with the API. */
683
- projectAgentIds = new Map();
684
- sigintHandler;
685
- sigtermHandler;
686
- constructor(version, opts) {
687
- this.version = version;
688
- this.opts = opts;
689
- this.defaultAgentId = opts.agentId;
690
- }
691
- projectKey(project) {
692
- return `${project.tenantCode}/${project.projectCode}`;
693
- }
694
- getProjectAgentId(project) {
695
- return this.projectAgentIds.get(this.projectKey(project)) ?? this.defaultAgentId;
696
- }
697
- setProjectAgentId(project, agentId) {
698
- this.projectAgentIds.set(this.projectKey(project), agentId);
699
- }
700
- createProjectApiClient(project) {
701
- return new api_client_1.ApiClient(project.apiUrl, project.token);
702
- }
703
- start(projects, onStop) {
704
- this.onAllStopped = onStop;
705
- for (const project of projects) {
706
- migrateProjectConfigDir(project);
707
- this.spawnProject(project);
708
- }
709
- // Setup shutdown handlers
710
- let shuttingDown = false;
711
- const shutdown = () => {
712
- if (shuttingDown)
713
- return;
714
- shuttingDown = true;
715
- // Set updating flag so the close handler does not call process.exit again
716
- this.updating = true;
717
- if (this.sigintHandler)
718
- process.removeListener('SIGINT', this.sigintHandler);
719
- if (this.sigtermHandler)
720
- process.removeListener('SIGTERM', this.sigtermHandler);
721
- (0, pid_manager_1.removePidFile)();
722
- logger_1.logger.info((0, i18n_1.t)('runner.shuttingDown'));
723
- const closedPromises = [...this.handles.values()].map((h) => h.closedPromise);
724
- this.stopAll();
725
- onStop?.();
726
- const shutdownTimer = setTimeout(() => {
727
- logger_1.logger.warn('[docker] Shutdown timed out waiting for log flush; forcing exit');
728
- process.exit(0);
729
- }, this.opts.shutdownTimeoutMs ?? 10_000);
730
- void Promise.all(closedPromises).then(() => {
731
- clearTimeout(shutdownTimer);
732
- process.exit(0);
733
- });
734
- };
735
- this.sigintHandler = () => { logger_1.logger.info('[docker] SIGINT received, shutting down...'); shutdown(); };
736
- this.sigtermHandler = () => { logger_1.logger.info('[docker] SIGTERM received, shutting down...'); shutdown(); };
737
- process.on('SIGINT', this.sigintHandler);
738
- process.on('SIGTERM', this.sigtermHandler);
739
- // On macOS/Linux, Ctrl+C may not deliver SIGINT to Node.js when the terminal
740
- // is in cooked mode and child processes share the process group. As a fallback,
741
- // read \x03 (ETX) directly from stdin when it is a TTY in raw mode.
742
- /* istanbul ignore next -- TTY-only path, not testable in CI */
743
- if (process.stdin.isTTY) {
744
- process.stdin.setRawMode(true);
745
- process.stdin.resume();
746
- process.stdin.on('data', (chunk) => {
747
- if (chunk[0] === 0x03) { // Ctrl+C
748
- shutdown();
749
- }
750
- });
751
- }
752
- }
753
- getImageTag(project) {
754
- const projectTag = (0, dockerfile_path_1.getProjectImageTag)(project.tenantCode, project.projectCode, this.version);
755
- try {
756
- (0, child_process_1.execFileSync)('docker', ['image', 'inspect', projectTag], { stdio: 'ignore' });
757
- return projectTag;
758
- }
759
- catch /* istanbul ignore next */ {
760
- return `${IMAGE_NAME}:${this.version}`;
761
- }
762
- }
763
- async rebuildAndRestart(project, projectConfigHostDir, forceIfDockerfileExists = false) {
764
- const rebuildMarker = path.join(projectConfigHostDir, 'docker-rebuild-needed');
765
- const projectDockerfile = path.join(projectConfigHostDir, 'Dockerfile');
766
- const hasMarker = fs.existsSync(rebuildMarker);
767
- const shouldBuild = hasMarker || (forceIfDockerfileExists && fs.existsSync(projectDockerfile));
768
- if (shouldBuild) {
769
- if (hasMarker)
770
- fs.unlinkSync(rebuildMarker);
771
- // Load the registered agentId before building so build logs are stored under
772
- // the correct agentId (the one shown in the Web UI), not the host agentId.
773
- const registeredAgentIdPath = path.join(projectConfigHostDir, 'docker-registered-agent-id');
774
- if (fs.existsSync(registeredAgentIdPath)) {
775
- const registeredId = fs.readFileSync(registeredAgentIdPath, 'utf-8').trim();
776
- if (registeredId && registeredId !== this.getProjectAgentId(project)) {
777
- this.setProjectAgentId(project, registeredId);
778
- }
779
- }
780
- // The container writes Dockerfile into its configDir root, which maps to projectConfigHostDir on host.
781
- if (fs.existsSync(projectDockerfile)) {
782
- try {
783
- await buildProjectImage(project.tenantCode, project.projectCode, this.version, projectDockerfile, this.createProjectApiClient(project), this.getProjectAgentId(project));
784
- // Copy the docker-customization-hash file written by the container so the
785
- // next container startup knows the current customization was already built.
786
- const srcHash = path.join(projectConfigHostDir, 'docker-customization-hash');
787
- const dstHash = path.join(projectConfigHostDir, 'docker-built-hash');
788
- if (fs.existsSync(srcHash)) {
789
- fs.copyFileSync(srcHash, dstHash);
790
- }
791
- // Clear any previous build error so the container can report success
792
- const buildErrorPath = path.join(projectConfigHostDir, 'docker-build-error');
793
- /* istanbul ignore next */
794
- if (fs.existsSync(buildErrorPath)) {
795
- fs.unlinkSync(buildErrorPath);
796
- }
797
- }
798
- catch (err) {
799
- const errorMsg = err instanceof Error ? err.message : String(err);
800
- logger_1.logger.error(`[docker] Image build failed: ${errorMsg}`);
801
- logger_1.logger.warn(`[docker] Container ${this.projectKey(project)} will start with previous image due to build failure.`);
802
- // Write the error message so the container can report it via heartbeat
803
- // Truncate to 3000 chars to avoid DynamoDB item size limits
804
- const buildErrorPath = path.join(projectConfigHostDir, 'docker-build-error');
805
- const truncatedError = errorMsg.length > 3000 ? errorMsg.substring(0, 3000) + '...(truncated)' : errorMsg;
806
- /* istanbul ignore next */
807
- try {
808
- fs.writeFileSync(buildErrorPath, truncatedError, 'utf-8');
809
- }
810
- catch (writeErr) {
811
- logger_1.logger.warn(`[docker] Failed to write build error file: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
812
- }
813
- // Write docker-built-hash even on failure so the next startup does not
814
- // attempt the same failed build again. The container starts with the
815
- // previous (base) image until the configuration is fixed and changed.
816
- const srcHash = path.join(projectConfigHostDir, 'docker-customization-hash');
817
- const dstHash = path.join(projectConfigHostDir, 'docker-built-hash');
818
- if (fs.existsSync(srcHash)) {
819
- fs.copyFileSync(srcHash, dstHash);
820
- }
821
- }
822
- }
823
- }
824
- this.spawnProject(project);
825
- }
826
- spawnProject(project) {
827
- const key = this.projectKey(project);
828
- const projectConfigHostDir = getProjectConfigHostDir(project);
829
- // Pre-startup hash check: if docker-customization-hash !== docker-built-hash,
830
- // rebuild before starting the container (handles the case where config changed while agent was stopped)
831
- const customizationHashPath = path.join(projectConfigHostDir, 'docker-customization-hash');
832
- const builtHashPath = path.join(projectConfigHostDir, 'docker-built-hash');
833
- if (fs.existsSync(customizationHashPath) && fs.existsSync(builtHashPath)) {
834
- const customizationHash = fs.readFileSync(customizationHashPath, 'utf-8').trim();
835
- const builtHash = fs.readFileSync(builtHashPath, 'utf-8').trim();
836
- if (customizationHash !== builtHash) {
837
- logger_1.logger.info(`[docker] Pre-startup hash mismatch for ${key}, rebuilding before start...`);
838
- void this.rebuildAndRestart(project, projectConfigHostDir, true);
839
- return;
840
- }
841
- }
842
- // Load the server-assigned agentId written by the container after registration.
843
- // This ensures logs are stored under the same agentId shown in the Web UI.
844
- const registeredAgentIdPath = path.join(projectConfigHostDir, 'docker-registered-agent-id');
845
- if (fs.existsSync(registeredAgentIdPath)) {
846
- const registeredId = fs.readFileSync(registeredAgentIdPath, 'utf-8').trim();
847
- if (registeredId && registeredId !== this.getProjectAgentId(project)) {
848
- logger_1.logger.info(`[docker] Using registered agentId for ${key}: ${registeredId}`);
849
- this.setProjectAgentId(project, registeredId);
850
- }
851
- }
852
- const { mounts, envArgs } = buildProjectVolumeMounts(project, projectConfigHostDir);
853
- const containerArgs = [
854
- 'ai-support-agent', 'start', '--no-docker',
855
- '--project', key,
856
- ];
857
- if (this.opts.pollInterval !== undefined) {
858
- containerArgs.push('--poll-interval', String(this.opts.pollInterval));
859
- }
860
- if (this.opts.heartbeatInterval !== undefined) {
861
- containerArgs.push('--heartbeat-interval', String(this.opts.heartbeatInterval));
862
- }
863
- if (this.opts.verbose) {
864
- containerArgs.push('--verbose');
865
- }
866
- if (this.opts.autoUpdate === false) {
867
- containerArgs.push('--no-auto-update');
868
- }
869
- if (this.opts.updateChannel) {
870
- containerArgs.push('--update-channel', this.opts.updateChannel);
871
- }
872
- // No -i/-t flags: the container does not need stdin. stdout/stderr are
873
- // captured via pipe. Shutdown is handled exclusively via docker stop.
874
- const imageTag = this.getImageTag(project);
875
- const containerName = buildContainerName(project.tenantCode, project.projectCode, this.opts.agentId);
876
- removeStaleContainer(containerName);
877
- const cidFile = path.join(os.tmpdir(), `ai-support-agent-${project.tenantCode}-${project.projectCode}-${Date.now()}.cid`);
878
- const dockerArgs = [
879
- 'run', '--rm', '--name', containerName, '--cidfile', cidFile,
880
- ...(process.getuid ? ['--user', `${process.getuid()}:${process.getgid()}`] : []),
881
- ...mounts,
882
- ...buildDevMounts(),
883
- ...envArgs,
884
- imageTag,
885
- ...containerArgs,
886
- ];
887
- const projectColor = (0, logger_1.getProjectColor)(key);
888
- const colorReset = '\x1b[0m';
889
- const logPrefix = `${projectColor}[${key}]${colorReset} `;
890
- logger_1.logger.info(`[docker] Starting container for project: ${key}`);
891
- const child = (0, child_process_1.spawn)('docker', dockerArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
892
- let resolveClosed;
893
- const closedPromise = new Promise((resolve) => { resolveClosed = resolve; });
894
- const handle = {
895
- project,
896
- child,
897
- version: this.version,
898
- closeHandled: false,
899
- closedPromise,
900
- resolveClosed,
901
- cidFile,
902
- };
903
- this.handles.set(key, handle);
904
- // Stream container stdout/stderr to host terminal and API for real-time log viewing
905
- // Use project-specific ApiClient so the token matches the projectCode in the request
906
- const projectApiClient = this.createProjectApiClient(project);
907
- if (projectApiClient) {
908
- const sessionId = makeSessionId();
909
- let seq = 0;
910
- let fullLog = '';
911
- let logTruncated = false;
912
- let buf = '';
913
- const apiClient = projectApiClient;
914
- // Use a getter so that if the container writes docker-registered-agent-id after startup,
915
- // subsequent log chunks are stored under the correct server-assigned agentId.
916
- const supervisor = this;
917
- const getAgentId = () => supervisor.getProjectAgentId(project) ?? '';
918
- // Watch for the registered agentId file written by the container after registration
919
- const noopWatcher = { close: () => undefined };
920
- let registeredIdWatcher = noopWatcher;
921
- try {
922
- registeredIdWatcher = fs.watch(projectConfigHostDir, (eventType, filename) => {
923
- if (filename === 'docker-registered-agent-id' && (eventType === 'rename' || eventType === 'change')) {
924
- try {
925
- const newId = fs.readFileSync(registeredAgentIdPath, 'utf-8').trim();
926
- const currentId = supervisor.getProjectAgentId(project);
927
- if (newId && newId !== currentId) {
928
- logger_1.logger.info(`[docker] Container registered with agentId: ${newId} (was: ${currentId})`);
929
- supervisor.setProjectAgentId(project, newId);
930
- }
931
- }
932
- catch {
933
- // File may not exist yet if rename event fires before write completes
934
- }
935
- }
936
- });
937
- }
938
- catch {
939
- // Directory watch may fail if projectConfigHostDir doesn't exist yet — ignore
940
- }
941
- const flush = async () => {
942
- if (!buf)
943
- return;
944
- const text = buf;
945
- buf = '';
946
- if (!logTruncated) {
947
- if (fullLog.length + text.length <= MAX_SESSION_LOG_BYTES) {
948
- fullLog += text;
949
- }
950
- else {
951
- const remaining = MAX_SESSION_LOG_BYTES - fullLog.length;
952
- fullLog += remaining > 0 ? text.slice(0, remaining) : '';
953
- logTruncated = true;
954
- logger_1.logger.warn(`[docker] Container log for ${key} exceeded 2 MB limit; remaining output will not be saved to S3`);
955
- }
956
- }
957
- await apiClient.submitLogChunk({ agentId: getAgentId(), projectCode: project.projectCode, logType: 'container', sessionId, seq: ++seq, text })
958
- .catch((e) => logger_1.logger.warn(`[docker] log chunk failed: ${e}`));
959
- };
960
- const flushTimer = setInterval(() => { void flush(); }, 1_000).unref();
961
- const writeStdout = (0, logger_1.makeLinePrefixer)(logPrefix, (s) => process.stdout.write(s));
962
- const writeStderr = (0, logger_1.makeLinePrefixer)(logPrefix, (s) => process.stderr.write(s));
963
- child.stdout?.on('data', (d) => { const t = d.toString(); writeStdout(t); buf += t; });
964
- child.stderr?.on('data', (d) => { const t = d.toString(); writeStderr(t); buf += t; });
965
- child.on('close', () => {
966
- registeredIdWatcher.close();
967
- clearInterval(flushTimer);
968
- void flush().then(() => {
969
- if (fullLog) {
970
- void apiClient.saveSessionLog({ agentId: getAgentId(), projectCode: project.projectCode, logType: 'container', sessionId, content: fullLog })
971
- .catch((e) => logger_1.logger.warn(`[docker] S3 upload failed: ${e}`))
972
- .finally(() => { handle.resolveClosed(); });
973
- }
974
- else {
975
- handle.resolveClosed();
976
- }
977
- }).catch(/* istanbul ignore next */ () => { handle.resolveClosed(); }).catch(/* istanbul ignore next */ () => { handle.resolveClosed(); });
978
- });
979
- }
980
- child.on('error', (err) => {
981
- logger_1.logger.error(`[docker] Container error for ${key}: ${err.message}`);
982
- });
983
- child.on('close', (code) => {
984
- if (handle.closeHandled)
985
- return;
986
- handle.closeHandled = true;
987
- this.handles.delete(key);
988
- // Clean up cidfile
989
- try {
990
- fs.unlinkSync(handle.cidFile);
991
- }
992
- catch { /* ignore */ }
993
- if (code === constants_1.DOCKER_UPDATE_EXIT_CODE && !this.updating) {
994
- this.updating = true;
995
- logger_1.logger.info(`[docker] Container ${key} exited for update. Stopping all containers and rebuilding...`);
996
- this.stopAll();
997
- void installUpdateAndRestart(projectConfigHostDir).catch((err) => {
998
- logger_1.logger.error(`[docker] Update failed: ${err instanceof Error ? err.message : String(err)}`);
999
- process.exit(1);
1000
- });
1001
- return;
1002
- }
1003
- if (code === constants_1.DOCKER_RESTART_EXIT_CODE && !this.updating) {
1004
- logger_1.logger.info(`[docker] Container ${key} requested restart. Rebuilding image if needed...`);
1005
- void this.rebuildAndRestart(project, projectConfigHostDir, true).catch((err) => {
1006
- logger_1.logger.error(`[docker] Restart failed: ${err instanceof Error ? err.message : String(err)}`);
1007
- });
1008
- return;
1009
- }
1010
- if (this.handles.size === 0 && !this.updating) {
1011
- // All containers have exited cleanly
1012
- this.onAllStopped?.();
1013
- process.exit(code ?? 0);
1014
- }
1015
- });
1016
- }
1017
- stopAll() {
1018
- for (const [key, handle] of this.handles) {
1019
- if (!handle.closeHandled) {
1020
- logger_1.logger.info(`[docker] Stopping container for project: ${key}`);
1021
- // Try docker stop via cidfile (more reliable than SIGTERM to docker run process).
1022
- // Use spawn (non-blocking) so we don't stall the Node.js event loop during shutdown.
1023
- let stopped = false;
1024
- try {
1025
- /* istanbul ignore next */
1026
- if (fs.existsSync(handle.cidFile)) {
1027
- const containerId = fs.readFileSync(handle.cidFile, 'utf-8').trim();
1028
- /* istanbul ignore next */
1029
- if (containerId) {
1030
- (0, child_process_1.spawn)('docker', ['stop', '--time', '5', containerId], { stdio: 'ignore' });
1031
- stopped = true;
1032
- }
1033
- }
1034
- }
1035
- catch /* istanbul ignore next */ {
1036
- // Ignore errors — fall through to child.kill
1037
- }
1038
- if (!stopped) {
1039
- handle.child.kill('SIGTERM');
1040
- }
1041
- }
1042
- }
1043
- }
1044
- }
1045
88
  let isDockerRunning = false;
1046
89
  /** Reset the running flag (for testing). */
1047
90
  function resetIsDockerRunning() {
1048
91
  isDockerRunning = false;
1049
92
  }
1050
93
  function runInDocker(opts) {
1051
- // Guard against multiple concurrent invocations (e.g. auto-updater and
1052
- // server-triggered update firing at the same time).
94
+ // Guard against multiple concurrent invocations
1053
95
  if (isDockerRunning) {
1054
96
  logger_1.logger.warn('[docker] runInDocker called while already running — ignoring duplicate call');
1055
97
  return;
@@ -1061,7 +103,7 @@ function runInDocker(opts) {
1061
103
  process.exit(1);
1062
104
  return;
1063
105
  }
1064
- if (!checkDockerAvailable()) {
106
+ if (!(0, docker_utils_1.checkDockerAvailable)()) {
1065
107
  logger_1.logger.error((0, i18n_1.t)('docker.notAvailable'));
1066
108
  process.exit(1);
1067
109
  return;
@@ -1070,16 +112,15 @@ function runInDocker(opts) {
1070
112
  // Sync bundled Dockerfile to config dir on first run (unless disabled)
1071
113
  const shouldSync = opts.dockerfileSync !== false && config?.dockerfileSync !== false;
1072
114
  if (shouldSync) {
1073
- syncDockerfileToConfigDir();
115
+ (0, dockerfile_sync_1.syncDockerfileToConfigDir)();
1074
116
  }
1075
117
  // Resolve Dockerfile: CLI flag > config.dockerfilePath > configDir/Dockerfile > bundled default
1076
118
  const customDockerfile = opts.dockerfile ?? config?.dockerfilePath;
1077
- const version = ensureImage(customDockerfile);
119
+ const version = (0, version_manager_1.ensureImage)(customDockerfile);
1078
120
  // Determine projects to run (skip entries without tenantCode)
1079
121
  const allProjects = config ? (0, config_manager_1.getProjectList)(config) : [];
1080
122
  let projects;
1081
123
  if (opts.project) {
1082
- // Single-project mode (e.g. for testing or external invocation)
1083
124
  const slashIdx = opts.project.indexOf('/');
1084
125
  if (slashIdx < 0) {
1085
126
  logger_1.logger.error(`[docker] --project must be in "tenantCode/projectCode" format: ${opts.project}`);
@@ -1091,7 +132,6 @@ function runInDocker(opts) {
1091
132
  projects = allProjects.filter((p) => p.tenantCode === tenantCode && p.projectCode === projectCode);
1092
133
  if (projects.length === 0) {
1093
134
  if (opts.token || process.env.AI_SUPPORT_AGENT_TOKEN) {
1094
- // Fallback: create a temporary project from env/CLI args
1095
135
  projects = [{
1096
136
  tenantCode,
1097
137
  projectCode,
@@ -1107,16 +147,13 @@ function runInDocker(opts) {
1107
147
  }
1108
148
  }
1109
149
  else if (allProjects.length > 0) {
1110
- // Multi-project supervisor mode: one container per project
1111
150
  projects = allProjects;
1112
151
  }
1113
152
  else {
1114
- // No config: run single container with legacy volume mount approach
1115
153
  projects = [];
1116
154
  }
1117
155
  (0, claude_config_validator_1.ensureClaudeJsonIntegrity)();
1118
156
  if (projects.length > 0) {
1119
- // Per-project container mode (new architecture)
1120
157
  logger_1.logger.info(`[docker] Starting ${projects.length} project container(s)...`);
1121
158
  const agentId = config?.agentId ?? os.hostname();
1122
159
  const enrichedOpts = {
@@ -1124,15 +161,14 @@ function runInDocker(opts) {
1124
161
  agentId: opts.agentId ?? agentId,
1125
162
  };
1126
163
  (0, pid_manager_1.writePidFile)();
1127
- const supervisor = new DockerSupervisor(version, enrichedOpts);
164
+ const supervisor = new docker_supervisor_1.DockerSupervisor(version, enrichedOpts);
1128
165
  supervisor.start(projects, () => { isDockerRunning = false; });
1129
166
  return;
1130
167
  }
1131
168
  // Legacy fallback: single container handling all projects via shared volume mount
1132
- // (used when no projects are registered in config — e.g. token/apiUrl passed via CLI)
1133
169
  logger_1.logger.info((0, i18n_1.t)('docker.starting'));
1134
- const { mounts, projectMappings } = buildVolumeMounts();
1135
- const envArgs = buildEnvArgs(projectMappings);
170
+ const { mounts, projectMappings } = (0, volume_mount_builder_1.buildVolumeMounts)();
171
+ const envArgs = (0, volume_mount_builder_1.buildEnvArgs)(projectMappings);
1136
172
  const containerArgs = buildContainerArgs(opts);
1137
173
  const interactive = process.stdin.isTTY ? ['-it'] : ['-i'];
1138
174
  const dockerArgs = [
@@ -1140,7 +176,7 @@ function runInDocker(opts) {
1140
176
  ...(process.getuid ? ['--user', `${process.getuid()}:${process.getgid()}`] : []),
1141
177
  ...mounts,
1142
178
  ...envArgs,
1143
- `${IMAGE_NAME}:${version}`,
179
+ `${docker_utils_1.IMAGE_NAME}:${version}`,
1144
180
  ...containerArgs,
1145
181
  ];
1146
182
  const child = (0, child_process_1.spawn)('docker', dockerArgs, {
@@ -1158,19 +194,44 @@ function runInDocker(opts) {
1158
194
  });
1159
195
  let closeHandled = false;
1160
196
  child.on('close', (code) => {
1161
- // Guard against duplicate close events
1162
197
  if (closeHandled)
1163
198
  return;
1164
199
  closeHandled = true;
1165
200
  isDockerRunning = false;
1166
- // DOCKER_UPDATE_EXIT_CODE signals "update installed, rebuild image and restart".
1167
- // Any other exit (including 0 from SIGINT) exits the host process as-is.
1168
201
  if (code === constants_1.DOCKER_UPDATE_EXIT_CODE) {
1169
202
  logger_1.logger.info('[docker] Container exited for update. Rebuilding image and restarting...');
1170
- void installUpdateAndRestart();
203
+ void (0, update_handler_1.installUpdateAndRestart)();
1171
204
  return;
1172
205
  }
1173
206
  process.exit(code ?? 0);
1174
207
  });
1175
208
  }
209
+ // Re-exports for backward compatibility
210
+ var docker_utils_2 = require("./docker-utils");
211
+ Object.defineProperty(exports, "checkDockerAvailable", { enumerable: true, get: function () { return docker_utils_2.checkDockerAvailable; } });
212
+ Object.defineProperty(exports, "imageExists", { enumerable: true, get: function () { return docker_utils_2.imageExists; } });
213
+ Object.defineProperty(exports, "buildImage", { enumerable: true, get: function () { return docker_utils_2.buildImage; } });
214
+ Object.defineProperty(exports, "buildContainerName", { enumerable: true, get: function () { return docker_utils_2.buildContainerName; } });
215
+ Object.defineProperty(exports, "removeStaleContainer", { enumerable: true, get: function () { return docker_utils_2.removeStaleContainer; } });
216
+ Object.defineProperty(exports, "dockerLogin", { enumerable: true, get: function () { return docker_utils_2.dockerLogin; } });
217
+ var docker_security_1 = require("./docker-security");
218
+ Object.defineProperty(exports, "validatePackageNames", { enumerable: true, get: function () { return docker_security_1.validatePackageNames; } });
219
+ var dockerfile_generator_1 = require("./dockerfile-generator");
220
+ Object.defineProperty(exports, "generateProjectDockerfile", { enumerable: true, get: function () { return dockerfile_generator_1.generateProjectDockerfile; } });
221
+ var dockerfile_sync_2 = require("./dockerfile-sync");
222
+ Object.defineProperty(exports, "syncDockerfileToConfigDir", { enumerable: true, get: function () { return dockerfile_sync_2.syncDockerfileToConfigDir; } });
223
+ var project_image_builder_1 = require("./project-image-builder");
224
+ Object.defineProperty(exports, "buildProjectImage", { enumerable: true, get: function () { return project_image_builder_1.buildProjectImage; } });
225
+ var volume_mount_builder_2 = require("./volume-mount-builder");
226
+ Object.defineProperty(exports, "buildVolumeMounts", { enumerable: true, get: function () { return volume_mount_builder_2.buildVolumeMounts; } });
227
+ Object.defineProperty(exports, "buildEnvArgs", { enumerable: true, get: function () { return volume_mount_builder_2.buildEnvArgs; } });
228
+ Object.defineProperty(exports, "buildProjectVolumeMounts", { enumerable: true, get: function () { return volume_mount_builder_2.buildProjectVolumeMounts; } });
229
+ var version_manager_2 = require("./version-manager");
230
+ Object.defineProperty(exports, "getInstalledVersion", { enumerable: true, get: function () { return version_manager_2.getInstalledVersion; } });
231
+ Object.defineProperty(exports, "resetInstalledVersionCache", { enumerable: true, get: function () { return version_manager_2.resetInstalledVersionCache; } });
232
+ Object.defineProperty(exports, "ensureImage", { enumerable: true, get: function () { return version_manager_2.ensureImage; } });
233
+ var project_config_1 = require("./project-config");
234
+ Object.defineProperty(exports, "migrateProjectConfigDir", { enumerable: true, get: function () { return project_config_1.migrateProjectConfigDir; } });
235
+ var docker_supervisor_2 = require("./docker-supervisor");
236
+ Object.defineProperty(exports, "DockerSupervisor", { enumerable: true, get: function () { return docker_supervisor_2.DockerSupervisor; } });
1176
237
  //# sourceMappingURL=docker-runner.js.map