@getforgeai/cli 0.1.0-beta.4 → 0.1.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { sessionRespondAction } from "./commands/session-respond.js";
20
20
  import { taskOpenAction } from "./commands/task-open.js";
21
21
  import { workflowOpenAction } from "./commands/workflow-open.js";
22
22
  import { vmInitAction, vmStatusAction, vmCleanupAction, vmStopAllAction, setupClaudeTokenAction, } from "./commands/vm.js";
23
- import { setupCodexAuthAction, setupOpenCodeAuthAction, } from "./commands/auth-setup-agent.js";
23
+ import { setupCodexAuthAction, setupKiloAuthAction, setupOpenCodeAuthAction, } from "./commands/auth-setup-agent.js";
24
24
  const program = new Command();
25
25
  program
26
26
  .name("forge")
@@ -55,6 +55,10 @@ authCmd
55
55
  .command("setup-opencode")
56
56
  .description("Store OpenCode credentials (import opencode login or provider API key)")
57
57
  .action(() => setupOpenCodeAuthAction());
58
+ authCmd
59
+ .command("setup-kilo")
60
+ .description("Store Kilo credentials (import kilo login or gateway API key)")
61
+ .action(() => setupKiloAuthAction());
58
62
  program
59
63
  .command("connect")
60
64
  .description("Connect to ForgeAI Cloud (spawns background daemon by default)")
@@ -123,7 +127,7 @@ const configSetCmd = configCmd
123
127
  .description("Set a configuration value");
124
128
  configSetCmd
125
129
  .command("agent-type <value>")
126
- .description("Set agent type (CLAUDE_CODE, CODEX or OPENCODE)")
130
+ .description("Set agent type (CLAUDE_CODE, CODEX, OPENCODE or KILO)")
127
131
  .action((value) => configSetAgentTypeAction(value));
128
132
  configSetCmd
129
133
  .command("agent-slots <value>")
@@ -11,5 +11,6 @@
11
11
  * encrypted file elsewhere) and injected into agent containers at spawn.
12
12
  */
13
13
  export declare function setupCodexAuthAction(): Promise<void>;
14
+ export declare function setupKiloAuthAction(): Promise<void>;
14
15
  export declare function setupOpenCodeAuthAction(): Promise<void>;
15
16
  //# sourceMappingURL=auth-setup-agent.d.ts.map
@@ -59,6 +59,36 @@ export async function setupCodexAuthAction() {
59
59
  process.exit(1);
60
60
  }
61
61
  }
62
+ export async function setupKiloAuthAction() {
63
+ try {
64
+ const imported = await tryImportHostAuthJson("KILO");
65
+ if (!imported) {
66
+ console.log(chalk.dim("No Kilo login found to import. You can paste a Kilo gateway API key instead\n" +
67
+ "(from your profile at app.kilo.ai — or run `kilo auth login` first, then\n" +
68
+ "re-run this command to import it)."));
69
+ const key = await ask("Kilo API key: ");
70
+ if (!key) {
71
+ console.error(chalk.red("No API key provided."));
72
+ process.exit(1);
73
+ }
74
+ await saveAgentAuth("KILO", {
75
+ kind: "api_key",
76
+ envVar: "KILO_API_KEY",
77
+ value: key,
78
+ });
79
+ console.log(chalk.green("Kilo API key stored. Agents can now authenticate."));
80
+ }
81
+ const model = await ask("Kilo model (e.g. kilo/anthropic/claude-sonnet-4.5) — leave empty for Kilo's default: ");
82
+ if (model) {
83
+ setConfig({ kiloModel: model });
84
+ console.log(chalk.green(`Kilo model set to ${model}.`));
85
+ }
86
+ }
87
+ catch (err) {
88
+ console.error(chalk.red(`Failed: ${err instanceof Error ? err.message : String(err)}`));
89
+ process.exit(1);
90
+ }
91
+ }
62
92
  const OPENCODE_PROVIDER_ENV_VARS = {
63
93
  anthropic: "ANTHROPIC_API_KEY",
64
94
  openai: "OPENAI_API_KEY",
@@ -7,6 +7,7 @@ const VALID_OPENERS = ["terminal", "vscode", "finder"];
7
7
  const AGENT_AUTH_SETUP_HINTS = {
8
8
  CODEX: "forge auth setup-codex",
9
9
  OPENCODE: "forge auth setup-opencode",
10
+ KILO: "forge auth setup-kilo",
10
11
  };
11
12
  export async function configSetAgentTypeAction(value) {
12
13
  const upper = value.toUpperCase();
@@ -51,6 +52,9 @@ export function configGetAction() {
51
52
  if (config.agentType === "OPENCODE") {
52
53
  console.log(` OpenCode model: ${config.opencodeModel ?? "(OpenCode default)"}`);
53
54
  }
55
+ if (config.agentType === "KILO") {
56
+ console.log(` Kilo model: ${config.kiloModel ?? "(Kilo default)"}`);
57
+ }
54
58
  console.log(` Agent slots: ${config.agentSlots ?? 5}`);
55
59
  console.log(` Opener: ${config.opener ?? "terminal"}`);
56
60
  console.log(` Server URL: ${config.serverUrl}`);
@@ -123,14 +123,14 @@ async function connectForeground(options) {
123
123
  if (!ctx)
124
124
  return;
125
125
  // Ensure agent credentials are available for container authentication
126
- if (ctx.agentType === "CODEX" || ctx.agentType === "OPENCODE") {
126
+ if (ctx.agentType === "CODEX" ||
127
+ ctx.agentType === "OPENCODE" ||
128
+ ctx.agentType === "KILO") {
127
129
  try {
128
130
  const { hasAgentAuth } = await import("../vm/agent-auth-manager.js");
129
131
  if (!(await hasAgentAuth(ctx.agentType))) {
130
- const setupCmd = ctx.agentType === "CODEX"
131
- ? "forge auth setup-codex"
132
- : "forge auth setup-opencode";
133
- console.warn(chalk.yellow(`[connect] No ${ctx.agentType} credentials stored. Agents may fail to authenticate. Run: ${setupCmd}`));
132
+ const { getAgentProfile } = await import("../orchestrator/agent-profiles.js");
133
+ console.warn(chalk.yellow(`[connect] No ${ctx.agentType} credentials stored. Agents may fail to authenticate. Run: ${getAgentProfile(ctx.agentType).authSetupHint}`));
134
134
  }
135
135
  }
136
136
  catch {
@@ -13,6 +13,8 @@ export type CliConfig = {
13
13
  agentType?: string;
14
14
  /** OpenCode model in "provider/model" form (used when agentType is OPENCODE). */
15
15
  opencodeModel?: string;
16
+ /** Kilo model, e.g. "kilo/anthropic/claude-sonnet-4.5" (used when agentType is KILO). */
17
+ kiloModel?: string;
16
18
  agentSlots?: number;
17
19
  opener?: Opener;
18
20
  lastSyncCursor?: string;
@@ -18,14 +18,14 @@ if (!ctx) {
18
18
  }
19
19
  // Check agent credential availability for the configured engine
20
20
  try {
21
- if (ctx.agentType === "CODEX" || ctx.agentType === "OPENCODE") {
21
+ if (ctx.agentType === "CODEX" ||
22
+ ctx.agentType === "OPENCODE" ||
23
+ ctx.agentType === "KILO") {
22
24
  const { hasAgentAuth } = await import("../vm/agent-auth-manager.js");
23
25
  if (!(await hasAgentAuth(ctx.agentType))) {
24
- const setupCmd = ctx.agentType === "CODEX"
25
- ? "forge auth setup-codex"
26
- : "forge auth setup-opencode";
26
+ const { getAgentProfile } = await import("../orchestrator/agent-profiles.js");
27
27
  console.warn(chalk.yellow(`[daemon] No ${ctx.agentType} credentials stored — agents will fail to authenticate.`));
28
- console.warn(chalk.yellow(`[daemon] Run: ${setupCmd}`));
28
+ console.warn(chalk.yellow(`[daemon] Run: ${getAgentProfile(ctx.agentType).authSetupHint}`));
29
29
  }
30
30
  }
31
31
  else {
@@ -9,6 +9,10 @@
9
9
  * | CLAUDE_CODE | claude | /session/workspace/.claude/mcp_config.json (via --mcp-config) | --continue |
10
10
  * | CODEX | codex | /home/agent/.codex/config.toml | exec resume --last |
11
11
  * | OPENCODE | opencode | /home/agent/.config/opencode/opencode.json | run --continue |
12
+ * | KILO | kilo | /home/agent/.config/kilo/kilo.json | run --continue |
13
+ *
14
+ * KILO (kilo.ai) is an OpenCode fork: same config shape, same JSONL event
15
+ * stream (so it shares OpenCodeJsonlExtractor), Kilo gateway auth on top.
12
16
  */
13
17
  import type { AgentType } from "@getforgeai/protocol";
14
18
  import type { AgentStreamExtractor, QuestionData, ToolActivityData, TokenUsageData } from "./stream-json-extractor.js";
@@ -18,7 +22,12 @@ export type McpBridgeConfig = {
18
22
  token: string;
19
23
  };
20
24
  export type AgentConfigOptions = {
21
- /** OpenCode model in "provider/model" form (from CLI config, optional). */
25
+ /**
26
+ * Model in "provider/model" form (from CLI config, optional).
27
+ * OpenCode: e.g. "anthropic/claude-sonnet-4-5".
28
+ * Kilo: gateway models are "kilo/<vendor>/<model>", e.g.
29
+ * "kilo/anthropic/claude-sonnet-4.5".
30
+ */
22
31
  model?: string;
23
32
  };
24
33
  export type AgentAuthErrorPatterns = {
@@ -9,6 +9,10 @@
9
9
  * | CLAUDE_CODE | claude | /session/workspace/.claude/mcp_config.json (via --mcp-config) | --continue |
10
10
  * | CODEX | codex | /home/agent/.codex/config.toml | exec resume --last |
11
11
  * | OPENCODE | opencode | /home/agent/.config/opencode/opencode.json | run --continue |
12
+ * | KILO | kilo | /home/agent/.config/kilo/kilo.json | run --continue |
13
+ *
14
+ * KILO (kilo.ai) is an OpenCode fork: same config shape, same JSONL event
15
+ * stream (so it shares OpenCodeJsonlExtractor), Kilo gateway auth on top.
12
16
  */
13
17
  import { DEFAULT_AGENT_TYPE, isAgentType } from "@getforgeai/protocol";
14
18
  import { CodexJsonlExtractor } from "./codex-jsonl-extractor.js";
@@ -224,12 +228,88 @@ const openCodeProfile = {
224
228
  authSetupHint: "forge auth setup-opencode",
225
229
  };
226
230
  // ---------------------------------------------------------------------------
231
+ // Kilo (kilo.ai — OpenCode fork with the Kilo gateway on top)
232
+ // ---------------------------------------------------------------------------
233
+ /**
234
+ * Permission override merged over any config (KILO_PERMISSION env is merged
235
+ * over file config), so a repo-level kilo.json can never re-introduce "ask"
236
+ * prompts. Belt to the --auto flag's suspenders.
237
+ */
238
+ const KILO_PERMISSION_JSON = JSON.stringify({
239
+ "*": "allow",
240
+ external_directory: { "**": "allow" },
241
+ });
242
+ const kiloProfile = {
243
+ type: "KILO",
244
+ binary: "kilo",
245
+ configTargetPath: "/home/agent/.config/kilo/kilo.json",
246
+ buildConfig(mcp, opts) {
247
+ return JSON.stringify({
248
+ $schema: "https://app.kilo.ai/config.json",
249
+ ...(opts?.model ? { model: opts.model } : {}),
250
+ permission: {
251
+ "*": "allow",
252
+ external_directory: { "**": "allow" },
253
+ },
254
+ // Headless container hygiene: no auto-upgrade, no product telemetry
255
+ autoupdate: false,
256
+ experimental: { openTelemetry: false },
257
+ mcp: {
258
+ forgeai: {
259
+ type: "local",
260
+ command: ["node", "/session/forge-mcp-relay.mjs"],
261
+ environment: {
262
+ FORGEAI_MCP_HOST: mcp.host,
263
+ FORGEAI_MCP_PORT: String(mcp.port),
264
+ FORGEAI_MCP_TOKEN: mcp.token,
265
+ },
266
+ enabled: true,
267
+ },
268
+ },
269
+ }, null, 2);
270
+ },
271
+ buildArgs(prompt) {
272
+ // --auto: auto-approve permissions (headless runs REJECT asks otherwise).
273
+ // Spawn must close stdin: with a non-TTY open stdin, `kilo run` blocks
274
+ // reading it to EOF before sending the prompt (Bun.stdin.text()).
275
+ return ["run", "--auto", "--format", "json", prompt];
276
+ },
277
+ buildContinueArgs(prompt) {
278
+ return ["run", "--continue", "--auto", "--format", "json", prompt];
279
+ },
280
+ spawnEnv: {
281
+ KILO_PERMISSION: KILO_PERMISSION_JSON,
282
+ KILO_DISABLE_AUTOUPDATE: "1",
283
+ KILO_TELEMETRY_LEVEL: "off",
284
+ },
285
+ // Kilo scans .claude/skills/**/SKILL.md natively (OpenCode heritage)
286
+ nativeSkillDiscovery: true,
287
+ // Sessions live in SQLite (kilo.db + -wal/-shm) keyed to the project dir
288
+ stateDir: "/home/agent/.local/share/kilo",
289
+ snapshotExcludes: ["auth.json", "mcp-auth.json", "log", "repos", "bin"],
290
+ authErrorPatterns: {
291
+ // Kilo surfaces auth failures as error events from the gateway/provider —
292
+ // it never prints "Not logged in" on the run path, and env-var names in
293
+ // the stdout tail would be tool-output false positives (ForgeAI itself
294
+ // injects KILO_API_KEY). Keep only API-error-shaped tokens.
295
+ stderr: [],
296
+ combined: [
297
+ '"authentication_error"',
298
+ "invalid_api_key",
299
+ "Incorrect API key",
300
+ "AuthError",
301
+ ],
302
+ },
303
+ authSetupHint: "forge auth setup-kilo",
304
+ };
305
+ // ---------------------------------------------------------------------------
227
306
  // Registry
228
307
  // ---------------------------------------------------------------------------
229
308
  const PROFILES = {
230
309
  CLAUDE_CODE: claudeProfile,
231
310
  CODEX: codexProfile,
232
311
  OPENCODE: openCodeProfile,
312
+ KILO: kiloProfile,
233
313
  };
234
314
  export function getAgentProfile(agentType) {
235
315
  return PROFILES[resolveAgentType(agentType)];
@@ -245,6 +325,9 @@ export function createStreamExtractor(agentType, onText, onQuestion, onToolActiv
245
325
  case "CODEX":
246
326
  return new CodexJsonlExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
247
327
  case "OPENCODE":
328
+ // Kilo is an OpenCode fork emitting the same JSONL event stream
329
+ // (text/tool_use/step_start/step_finish/error with sessionID + part).
330
+ case "KILO":
248
331
  return new OpenCodeJsonlExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
249
332
  default:
250
333
  return new StreamJsonExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
@@ -40,7 +40,9 @@ async function resolveMcpRelayHost() {
40
40
  * Format and install path depend on the agent engine (see agent-profiles.ts).
41
41
  */
42
42
  function buildAgentConfig(profile, sessionIndex, mcpSecret, mcpHost) {
43
- return profile.buildConfig({ host: mcpHost, port: 20000 + sessionIndex, token: mcpSecret }, { model: getConfig().opencodeModel });
43
+ const cliConfig = getConfig();
44
+ const model = profile.type === "KILO" ? cliConfig.kiloModel : cliConfig.opencodeModel;
45
+ return profile.buildConfig({ host: mcpHost, port: 20000 + sessionIndex, token: mcpSecret }, { model });
44
46
  }
45
47
  /**
46
48
  * Bash script installing the staged config + context files into the container.
@@ -13,7 +13,7 @@
13
13
  * Storage: macOS Keychain / encrypted file via secure-store.ts. The payload
14
14
  * is stored as a JSON string.
15
15
  */
16
- export type AuthAgent = "CODEX" | "OPENCODE";
16
+ export type AuthAgent = "CODEX" | "OPENCODE" | "KILO";
17
17
  export type AgentAuthPayload = {
18
18
  kind: "api_key";
19
19
  value: string;
@@ -30,17 +30,29 @@ const STORE_LOCATIONS = {
30
30
  fileName: "opencode-auth.json",
31
31
  fileSalt: "forgeai-opencode-auth-salt",
32
32
  },
33
+ KILO: {
34
+ keychainService: "ForgeAI-kilo-auth",
35
+ keychainAccount: "kilo-auth",
36
+ fileName: "kilo-auth.json",
37
+ fileSalt: "forgeai-kilo-auth-salt",
38
+ },
33
39
  };
34
40
  /** Container path where each engine expects its auth.json. */
35
41
  export const AGENT_AUTH_JSON_PATHS = {
36
42
  CODEX: "/home/agent/.codex/auth.json",
37
43
  OPENCODE: "/home/agent/.local/share/opencode/auth.json",
44
+ KILO: "/home/agent/.local/share/kilo/auth.json",
38
45
  };
39
46
  /** Host path each engine's own CLI stores its auth.json at (for import). */
40
47
  export function getHostAuthJsonPath(agent) {
41
- return agent === "CODEX"
42
- ? join(homedir(), ".codex", "auth.json")
43
- : join(homedir(), ".local", "share", "opencode", "auth.json");
48
+ switch (agent) {
49
+ case "CODEX":
50
+ return join(homedir(), ".codex", "auth.json");
51
+ case "OPENCODE":
52
+ return join(homedir(), ".local", "share", "opencode", "auth.json");
53
+ case "KILO":
54
+ return join(homedir(), ".local", "share", "kilo", "auth.json");
55
+ }
44
56
  }
45
57
  export async function saveAgentAuth(agent, payload) {
46
58
  await saveSecret(STORE_LOCATIONS[agent], JSON.stringify(payload));
@@ -318,6 +318,11 @@ export class SessionManager {
318
318
  secretEnv: await this.getAgentSecretEnv(resolveAgentType(agentType)),
319
319
  user: "agent",
320
320
  });
321
+ // Agents run one-shot and never receive stdin input. Close it so engines
322
+ // that read piped stdin to EOF before acting (OpenCode and Kilo both
323
+ // concatenate non-TTY stdin into the prompt via Bun.stdin.text()) don't
324
+ // block forever on the open docker-exec pipe.
325
+ dockerExecProcess.stdin?.end();
321
326
  // Create a kill function that removes the container when the process exits
322
327
  const killFn = async (signal) => {
323
328
  try {
@@ -359,6 +364,13 @@ export class SessionManager {
359
364
  return { [auth.envVar ?? "ANTHROPIC_API_KEY"]: auth.value };
360
365
  }
361
366
  }
367
+ if (agentType === "KILO") {
368
+ const auth = await this.getAgentAuth("KILO");
369
+ if (auth.kind === "api_key") {
370
+ // Kilo gateway key (app.kilo.ai profile) — read at request time
371
+ return { [auth.envVar ?? "KILO_API_KEY"]: auth.value };
372
+ }
373
+ }
362
374
  return {};
363
375
  }
364
376
  /** Load (and cache) stored Codex/OpenCode credentials, or throw with a fix hint. */
@@ -494,7 +506,9 @@ export class SessionManager {
494
506
  */
495
507
  async refreshToken(agentType = "CLAUDE_CODE") {
496
508
  const resolved = resolveAgentType(agentType);
497
- if (resolved === "CODEX" || resolved === "OPENCODE") {
509
+ if (resolved === "CODEX" ||
510
+ resolved === "OPENCODE" ||
511
+ resolved === "KILO") {
498
512
  const previous = this.agentAuthCache[resolved];
499
513
  delete this.agentAuthCache[resolved];
500
514
  const fresh = await loadAgentAuth(resolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getforgeai/cli",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "ForgeAI CLI — runs AI coding agents in local Docker containers with your own AI tokens, and connects them to the ForgeAI cockpit.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "ora": "^8.2.0",
48
48
  "socket.io-client": "^4.8.3",
49
49
  "zod": "^4.3.6",
50
- "@getforgeai/protocol": "0.1.0-beta.2"
50
+ "@getforgeai/protocol": "0.1.0-beta.3"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^25.0.2",
package/rootfs/Dockerfile CHANGED
@@ -6,23 +6,25 @@ ENV DEBIAN_FRONTEND=noninteractive
6
6
  RUN apt-get update -qq && \
7
7
  apt-get install -y -qq --no-install-recommends \
8
8
  ca-certificates curl git openssh-client build-essential \
9
- python3 procps less sudo gnupg && \
9
+ python3 procps less sudo gnupg passwd xz-utils && \
10
10
  rm -rf /var/lib/apt/lists/*
11
11
 
12
- # Node.js 20 LTS via NodeSource
13
- RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
14
- gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
15
- echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | \
16
- tee /etc/apt/sources.list.d/nodesource.list && \
17
- apt-get update -qq && \
18
- apt-get install -y -qq nodejs && \
19
- rm -rf /var/lib/apt/lists/*
12
+ # Node.js 20 LTS from the official nodejs.org tarball — pinned version, no
13
+ # NodeSource apt repo (which intermittently 403s and breaks builds).
14
+ ARG TARGETARCH=arm64
15
+ ARG NODE_VERSION=20.20.2
16
+ RUN NODE_ARCH=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
17
+ curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" | \
18
+ tar -xJ -C /usr/local --strip-components=1 && \
19
+ node --version && npm --version
20
20
 
21
- # Agent CLIs: Claude Code (primary), Codex and OpenCode (alternative engines).
21
+ # Agent CLIs: Claude Code (primary), Codex, OpenCode and Kilo (alternative engines).
22
22
  # No output pipe: `| tail` would mask npm failures (pipeline exit = tail's).
23
23
  # The `command -v` checks make a partial install fail the build loudly.
24
- RUN npm install -g --loglevel=error @anthropic-ai/claude-code @openai/codex opencode-ai && \
25
- command -v claude && command -v codex && command -v opencode
24
+ # @kilocode/cli ships its platform binary via optionalDependencies + postinstall,
25
+ # so scripts must stay enabled for it.
26
+ RUN npm install -g --loglevel=error @anthropic-ai/claude-code @openai/codex opencode-ai @kilocode/cli && \
27
+ command -v claude && command -v codex && command -v opencode && command -v kilo
26
28
 
27
29
  # ForgeAI MCP relay (baked-in copy for reference only — at runtime the CLI
28
30
  # stages its own copy into /session/forge-mcp-relay.mjs so the relay version
@@ -30,10 +32,10 @@ RUN npm install -g --loglevel=error @anthropic-ai/claude-code @openai/codex open
30
32
  RUN mkdir -p /opt/forgeai
31
33
  COPY forge-mcp-relay.mjs /opt/forgeai/forge-mcp-relay.mjs
32
34
 
33
- # Create non-root user (Claude Code refuses --dangerously-skip-permissions as root)
34
- RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends passwd && \
35
- rm -rf /var/lib/apt/lists/* && \
36
- useradd -m -s /bin/bash -u 1001 agent
35
+ # Create non-root user (Claude Code refuses --dangerously-skip-permissions as root).
36
+ # passwd is installed with the base packages above: re-running apt-get update
37
+ # here hits the NodeSource repo again, which intermittently 403s and fails builds.
38
+ RUN useradd -m -s /bin/bash -u 1001 agent
37
39
 
38
40
  # Working directories (owned by agent user)
39
41
  RUN mkdir -p /workspace /session /shared && \