Skip to content

feat: add opt-in docker runtime and runtime config commands#824

Open
vishkrish200 wants to merge 28 commits intomainfrom
codex/docker-runtime-foundation
Open

feat: add opt-in docker runtime and runtime config commands#824
vishkrish200 wants to merge 28 commits intomainfrom
codex/docker-runtime-foundation

Conversation

@vishkrish200
Copy link
Copy Markdown
Collaborator

Summary

  • add an opt-in Docker runtime plugin built around tmux-in-container sessions instead of brittle direct stdin piping
  • persist effective runtime and runtimeConfig on sessions so restore, recovery, attach, and send follow the session's actual runtime choice
  • make CLI, local terminal, and web terminal attach flows runtime-aware, including Docker attach/open support
  • add ao runtime show|set|clear plus Docker runtime override flags and Docker-aware doctor/preflight checks
  • expand docs, examples, and tests for Docker runtime setup, terminal behavior, and runtime config management
  • align path guidance with the current runtime layout and warn on legacy top-level dataDir / worktreeDir keys

Why

AO was still heavily tmux-shaped outside the runtime plugin layer. Adding Docker as an isolated, reproducible runtime needed more than a new plugin: session metadata, attach flows, config management, and doctor/docs all had to understand per-session runtime selection. This keeps tmux as the local default while enabling Docker-backed sessions for servers and CI.

Validation

  • pnpm --filter @composio/ao-core typecheck
  • pnpm --filter @composio/ao-core test -- metadata.test.ts recovery-validator.test.ts recovery-actions.test.ts lifecycle-manager.test.ts session-manager.test.ts
  • pnpm --filter @composio/ao-plugin-runtime-docker typecheck
  • pnpm --filter @composio/ao-plugin-runtime-docker test
  • pnpm --filter @composio/ao-cli typecheck
  • pnpm --filter @composio/ao-cli exec vitest run __tests__/lib/preflight.test.ts __tests__/commands/spawn.test.ts __tests__/commands/start.test.ts __tests__/commands/open.test.ts __tests__/commands/runtime.test.ts __tests__/scripts/doctor-script.test.ts
  • pnpm --filter @composio/ao-web typecheck
  • pnpm --filter @composio/ao-web test -- server-compatibility.test.ts services.test.ts
  • live smoke test with Docker Desktop and tmux: configure runtime to Docker, spawn a real AO session, verify container-side tmux output, send input through ao send, attach with ao session attach, and kill the session successfully

Notes

  • the live smoke test required a repo with a valid origin/main, which matches AO's existing worktree expectations
  • ao doctor may still fail locally if the launcher is not installed in PATH; that is an environment/setup issue, not a runtime regression in this branch

@vishkrish200 vishkrish200 force-pushed the codex/docker-runtime-foundation branch from b00233a to ef63895 Compare March 30, 2026 20:12
@vishkrish200 vishkrish200 changed the title [codex] add opt-in docker runtime and runtime config commands feat: add opt-in docker runtime and runtime config commands Mar 30, 2026
@vishkrish200 vishkrish200 marked this pull request as ready for review March 30, 2026 20:29
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: runStartup opts type omits most runtime override fields
    • Added all missing runtime override fields (runtimeCpus, runtimeMemory, runtimeGpus, runtimeReadOnly, runtimeNetwork, runtimeCapDrop, runtimeTmpfs) to the runStartup function's opts parameter type to match RuntimeOverrideFlagOptions interface.
  • ✅ Fixed: Duplicated mergeRuntimeConfig with subtly different deep-copy behavior
    • Removed duplicate mergeRuntimeConfig and isPlainObject implementations from CLI runtime-overrides.ts, imported them from @composio/ao-core instead, and exported them from core's index.ts to ensure consistent deep-copy behavior across the codebase.

Create PR

Or push these changes by commenting:

@cursor push 0ffeb14692
Preview (0ffeb14692)
diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts
--- a/packages/cli/src/commands/start.ts
+++ b/packages/cli/src/commands/start.ts
@@ -69,10 +69,7 @@
   formatProjectTypeForDisplay,
 } from "../lib/project-detection.js";
 import { formatAttachCommand } from "../lib/attach.js";
-import {
-  appendStringOption,
-  resolveRuntimeOverride,
-} from "../lib/runtime-overrides.js";
+import { appendStringOption, resolveRuntimeOverride } from "../lib/runtime-overrides.js";
 
 const DEFAULT_PORT = 3000;
 const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -184,7 +181,7 @@
  */
 async function promptAgentSelection(): Promise<{
   orchestratorAgent: string;
-  workerAgent: string
+  workerAgent: string;
 } | null> {
   if (canPromptForInstall()) {
     const available = await detectAvailableAgents();
@@ -365,18 +362,17 @@
   if (available.length > 0 || !canPromptForInstall()) return available;
 
   console.log(chalk.yellow("⚠ No supported agent runtime detected."));
-  console.log(chalk.dim("  You can install one now (recommended) or continue and install later.\n"));
-  const choice = await promptSelect(
-    "Choose runtime to install:",
-    [
-      ...AGENT_INSTALL_OPTIONS.map((option) => ({
-        value: option.id,
-        label: option.label,
-        hint: [option.cmd, ...option.args].join(" "),
-      })),
-      { value: "skip", label: "Skip for now" },
-    ],
+  console.log(
+    chalk.dim("  You can install one now (recommended) or continue and install later.\n"),
   );
+  const choice = await promptSelect("Choose runtime to install:", [
+    ...AGENT_INSTALL_OPTIONS.map((option) => ({
+      value: option.id,
+      label: option.label,
+      hint: [option.cmd, ...option.args].join(" "),
+    })),
+    { value: "skip", label: "Skip for now" },
+  ]);
   if (choice === "skip") {
     return available;
   }
@@ -849,6 +845,13 @@
     runtime?: string;
     runtimeConfig?: string;
     runtimeImage?: string;
+    runtimeCpus?: string;
+    runtimeMemory?: string;
+    runtimeGpus?: string;
+    runtimeReadOnly?: boolean;
+    runtimeNetwork?: string;
+    runtimeCapDrop?: string[];
+    runtimeTmpfs?: string[];
   },
 ): Promise<number> {
   const runtimeOverride = resolveRuntimeOverride(config, project, opts ?? {});
@@ -1193,8 +1196,16 @@
                 "AO is already running. What do you want to do?",
                 [
                   { value: "open", label: "Open dashboard", hint: "Keep the current instance" },
-                  { value: "new", label: "Start new orchestrator", hint: "Add a new session for this project" },
-                  { value: "restart", label: "Restart everything", hint: "Stop the current instance first" },
+                  {
+                    value: "new",
+                    label: "Start new orchestrator",
+                    hint: "Add a new session for this project",
+                  },
+                  {
+                    value: "restart",
+                    label: "Restart everything",
+                    hint: "Stop the current instance first",
+                  },
                   { value: "quit", label: "Quit" },
                 ],
                 "open",
@@ -1281,7 +1292,7 @@
             proj.worker = { ...(proj.worker ?? {}), agent: workerAgent };
             writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 }));
             console.log(chalk.dim(`  ✓ Saved to ${config.configPath}\n`));
-            
+
             config = loadConfig(config.configPath);
             project = config.projects[projectId];
           }
@@ -1350,7 +1361,11 @@
           }
 
           const config = loadConfig();
-          const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop");
+          const { projectId: _projectId, project } = await resolveProject(
+            config,
+            projectArg,
+            "stop",
+          );
           const sessionId = `${project.sessionPrefix}-orchestrator`;
           const port = config.port ?? 3000;
 

diff --git a/packages/cli/src/lib/runtime-overrides.ts b/packages/cli/src/lib/runtime-overrides.ts
--- a/packages/cli/src/lib/runtime-overrides.ts
+++ b/packages/cli/src/lib/runtime-overrides.ts
@@ -1,4 +1,5 @@
 import type { OrchestratorConfig, ProjectConfig } from "@composio/ao-core";
+import { mergeRuntimeConfig, isPlainObject } from "@composio/ao-core";
 
 export interface RuntimeOverrideFlagOptions {
   runtime?: string;
@@ -20,31 +21,6 @@
   effectiveRuntimeConfig?: Record<string, unknown>;
 }
 
-function isPlainObject(value: unknown): value is Record<string, unknown> {
-  return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function mergeRuntimeConfig(
-  base?: Record<string, unknown>,
-  override?: Record<string, unknown>,
-): Record<string, unknown> | undefined {
-  if (!base && !override) return undefined;
-
-  const merged: Record<string, unknown> = {};
-  for (const source of [base, override]) {
-    if (!source) continue;
-    for (const [key, value] of Object.entries(source)) {
-      const existing = merged[key];
-      merged[key] =
-        isPlainObject(existing) && isPlainObject(value)
-          ? mergeRuntimeConfig(existing, value)
-          : value;
-    }
-  }
-
-  return merged;
-}
-
 function parseRuntimeConfigOverride(raw?: string): Record<string, unknown> | undefined {
   if (!raw) return undefined;
 

diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -78,7 +78,6 @@
 export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
 export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
 
-
 // Global pause constants and utilities
 export {
   GLOBAL_PAUSE_UNTIL_KEY,
@@ -97,6 +96,7 @@
   readLastJsonlEntry,
   resolveProjectIdForSessionId,
 } from "./utils.js";
+export { isPlainObject, mergeRuntimeConfig } from "./runtime-selection.js";
 export {
   getWebhookHeader,
   parseWebhookJsonObject,

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@vishkrish200
Copy link
Copy Markdown
Collaborator Author

@cursor push 0ffeb14

@vishkrish200 vishkrish200 force-pushed the codex/docker-runtime-foundation branch from 7f98ce6 to acd0f96 Compare April 4, 2026 17:48
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Shell execution via -lc bypasses execFile safety
    • Removed the unsafe shell execution fallback that used $SHELL -lc <command> and now only uses structured program+args from runtime plugins or the safe fallback.
  • ✅ Fixed: Redundant isPlainObject duplicated across runtime modules
    • Removed the duplicate isPlainObject function from runtime.ts and imported it from @composio/ao-core where it's already defined and exported.

Create PR

Or push these changes by commenting:

@cursor push a45af336a5
Preview (a45af336a5)
diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts
--- a/packages/cli/src/commands/runtime.ts
+++ b/packages/cli/src/commands/runtime.ts
@@ -6,6 +6,7 @@
   loadConfigWithPath,
   type OrchestratorConfig,
   type ProjectConfig,
+  isPlainObject,
 } from "@composio/ao-core";
 import { parse as yamlParse, stringify as yamlStringify } from "yaml";
 import {
@@ -29,10 +30,6 @@
   tmpfs?: string[];
 }
 
-function isPlainObject(value: unknown): value is Record<string, unknown> {
-  return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
 function readRawConfig(path: string): RawConfig {
   const parsed = yamlParse(readFileSync(path, "utf-8"));
   return isPlainObject(parsed) ? parsed : {};

diff --git a/packages/cli/src/lib/attach.ts b/packages/cli/src/lib/attach.ts
--- a/packages/cli/src/lib/attach.ts
+++ b/packages/cli/src/lib/attach.ts
@@ -17,13 +17,8 @@
   info: AttachInfo | null | undefined,
   fallback: { program: string; args: string[] },
 ): Promise<void> {
-  const shell = process.env["SHELL"] || "/bin/sh";
-  const program = info?.program ?? (info?.command ? shell : fallback.program);
-  const args = info?.program
-    ? (info.args ?? [])
-    : info?.command
-      ? ["-lc", info.command]
-      : fallback.args;
+  const program = info?.program ?? fallback.program;
+  const args = info?.program ? (info.args ?? []) : fallback.args;
 
   await new Promise<void>((resolve, reject) => {
     const child = spawn(program, args, { stdio: "inherit" });

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@vishkrish200 vishkrish200 force-pushed the codex/docker-runtime-foundation branch from 1c5d5c2 to f7196e6 Compare April 7, 2026 08:51
Copy link
Copy Markdown
Collaborator Author

Validation update after the latest rebase:

  • rebased codex/docker-runtime-foundation onto current main
  • fixed the remaining preflight/runtime nits (fix: reject unknown runtime names in preflight, fix: clean up post-rebase runtime wiring)
  • live-verified all three primary agents on the current branch:
    • Claude Code: real AO Docker session authenticated with Claude subscription, completed a real code fix, and npm test passed
    • Codex: real Docker run on the new CODEX_HOME path returned CODEX_DOCKER_HOME_OK
    • OpenCode: fixed Docker startup/session bootstrap, real AO Docker session completed a code fix, and npm test passed
  • reran focused verification after rebase:
    • pnpm --filter @composio/ao-plugin-agent-claude-code exec vitest run src/index.test.ts
    • pnpm --filter @composio/ao-plugin-agent-codex exec vitest run src/index.test.ts
    • pnpm --filter @composio/ao-plugin-agent-opencode exec vitest run src/index.test.ts
    • pnpm --filter @composio/ao-plugin-runtime-docker exec vitest run src/__tests__/index.test.ts
    • pnpm --filter @composio/ao-cli exec vitest run __tests__/lib/preflight.test.ts __tests__/commands/doctor.test.ts
    • pnpm --filter @composio/ao-core --filter @composio/ao-plugin-runtime-docker --filter @composio/ao-plugin-agent-claude-code --filter @composio/ao-plugin-agent-codex --filter @composio/ao-plugin-agent-opencode --filter @composio/ao-cli typecheck

The branch is clean locally and now sits on top of current main.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 868a760. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants