diff --git a/.changeset/mcp-android-integration.md b/.changeset/mcp-android-integration.md new file mode 100644 index 00000000..8438fcd9 --- /dev/null +++ b/.changeset/mcp-android-integration.md @@ -0,0 +1,14 @@ +--- +"@prover-coder-ai/docker-git": minor +--- + +Add Android MCP integration alongside the existing Playwright MCP support (issue #436). + +Projects can now opt into a nested Android emulator sidecar driven by the +first-party Rust `android-connection` MCP server, mirroring how Playwright MCP works. Enable it +with the new `--mcp-android` / `--no-mcp-android` create flags, the `mcp-android` +subcommand, the interactive create-flow prompt, or the `enableMcpAndroid` field +on the web/API create-project request. When enabled, the generated +`docker-compose.yml` adds a gated `docker-android` emulator service (KVM, +ADB port forwarding, headless CI mode) and the agent MCP config writers register +the Android server so it coexists with Playwright. diff --git a/README.md b/README.md index cda52047..bca9d692 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ bun run docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 - `--force` пересоздаёт окружение и удаляет volumes проекта. - `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации. +- `--mcp-android` включает first-party Android MCP (`android-connection`) и вложенный sidecar с Android-эмулятором (`docker-android`) для мобильной автоматизации. Автоматический запуск агента: diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e6efd92d..5abcdee4 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -476,6 +476,7 @@ export type CreateProjectRequest = { readonly dockerNetworkMode?: string | undefined readonly dockerSharedNetworkName?: string | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined readonly outDir?: string | undefined readonly gitTokenLabel?: string | undefined readonly skipGithubAuth?: boolean | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 6412840b..a91aed32 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -43,6 +43,7 @@ export const CreateProjectRequestSchema = Schema.Struct({ dockerNetworkMode: OptionalString, dockerSharedNetworkName: OptionalString, enableMcpPlaywright: OptionalBoolean, + enableMcpAndroid: OptionalBoolean, outDir: OptionalString, gitTokenLabel: OptionalString, skipGithubAuth: OptionalBoolean, diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 5f79f94f..173c2626 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -471,6 +471,7 @@ const toCreateRawOptions = (request: CreateProjectRequest): RawOptions => ({ ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }), ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), + ...(request.enableMcpAndroid === undefined ? {} : { enableMcpAndroid: request.enableMcpAndroid }), ...(request.outDir === undefined ? {} : { outDir: request.outDir }), ...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }), ...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }), diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 2cc86b25..d13ddbb3 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,14 @@ # @prover-coder-ai/docker-git +## 1.3.14 + +### Patch Changes + +- chore: automated version bump + +- Updated dependencies []: + - @prover-coder-ai/docker-git-session-sync@1.0.70 + ## 1.3.13 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 2caac76a..07ed7322 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git", - "version": "1.3.13", + "version": "1.3.14", "description": "docker-git Bun and Gridland CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { diff --git a/packages/app/src/docker-git/api-client-create.ts b/packages/app/src/docker-git/api-client-create.ts index 76231dd9..4ed30cce 100644 --- a/packages/app/src/docker-git/api-client-create.ts +++ b/packages/app/src/docker-git/api-client-create.ts @@ -41,6 +41,7 @@ export const buildCreateProjectRequest = ( dockerNetworkMode: config.dockerNetworkMode, dockerSharedNetworkName: config.dockerSharedNetworkName, enableMcpPlaywright: config.enableMcpPlaywright, + enableMcpAndroid: config.enableMcpAndroid ?? false, outDir: command.outDir, gitTokenLabel: config.gitTokenLabel, skipGithubAuth: config.skipGithubAuth, diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 3e6ac8b5..7af24f79 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -40,6 +40,7 @@ export const parseApply = ( playwrightCpuLimit, playwrightRamLimit, gpu, - enableMcpPlaywright: raw.enableMcpPlaywright + enableMcpPlaywright: raw.enableMcpPlaywright, + enableMcpAndroid: raw.enableMcpAndroid } }) diff --git a/packages/app/src/docker-git/cli/parser-mcp-android.ts b/packages/app/src/docker-git/cli/parser-mcp-android.ts new file mode 100644 index 00000000..9c24f94d --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-mcp-android.ts @@ -0,0 +1,24 @@ +import { Either } from "effect" + +import { type McpAndroidUpCommand, type ParseError } from "../frontend-lib/core/domain.js" + +import { parseProjectDirWithOptions } from "./parser-shared.js" + +// CHANGE: parse "mcp-android" command for existing docker-git projects +// WHY: allow enabling Android MCP in an already created container/project dir +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall argv: parseMcpAndroid(argv) = cmd -> deterministic(cmd) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: projectDir is never empty +// COMPLEXITY: O(n) where n = |argv| +export const parseMcpAndroid = ( + args: ReadonlyArray +): Either.Either => + Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ + _tag: "McpAndroidUp", + projectDir, + runUp: raw.up ?? true + })) diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 915e7c09..f1fc8177 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -110,6 +110,8 @@ const booleanFlagUpdaters: Readonly RawOptio "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), "--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }), + "--mcp-android": (raw) => ({ ...raw, enableMcpAndroid: true }), + "--no-mcp-android": (raw) => ({ ...raw, enableMcpAndroid: false }), "--wipe": (raw) => ({ ...raw, wipe: true }), "--no-wipe": (raw) => ({ ...raw, wipe: false }), "--web": (raw) => ({ ...raw, authWeb: true }), diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index b3fb68a5..e01abde2 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -7,6 +7,7 @@ import { parseAttach } from "./parser-attach.js" import { parseAuth } from "./parser-auth.js" import { parseClone } from "./parser-clone.js" import { buildCreateCommand } from "./parser-create.js" +import { parseMcpAndroid } from "./parser-mcp-android.js" import { parseMcpPlaywright } from "./parser-mcp-playwright.js" import { parseOpen } from "./parser-open.js" import { parseRawOptions } from "./parser-options.js" @@ -93,6 +94,7 @@ export const parseArgs = (args: ReadonlyArray): Either.Either parseSessions(rest)), Match.when("scrap", () => parseScrap(rest)), Match.when("mcp-playwright", () => parseMcpPlaywright(rest)), + Match.when("mcp-android", () => parseMcpAndroid(rest)), Match.when("help", () => Either.right(helpCommand)), Match.when("ps", () => Either.right(statusCommand)), Match.when("status", () => Either.right(statusCommand)), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 8de32045..4d7e0477 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -7,6 +7,7 @@ docker-git clone [options] docker-git open [] [options] docker-git apply [] [options] docker-git mcp-playwright [] [options] +docker-git mcp-android [] [options] docker-git attach [] [options] docker-git panes [] [options] docker-git scrap [] [options] @@ -27,6 +28,7 @@ Commands: open Open an existing docker-git project by selector, URL, or path apply Apply docker-git config to an existing project/container (current dir by default) mcp-playwright Enable Playwright MCP + nested Chromium browser for an existing project dir + mcp-android Enable Android MCP (android-connection) + nested Android emulator for an existing project dir attach, tmux Attach to an existing docker-git project workspace with tmux panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) @@ -78,6 +80,7 @@ Options: --up | --no-up Run docker compose up after init (default: --up) --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright) + --mcp-android | --no-mcp-android Enable Android MCP (android-connection) + nested Android emulator sidecar (default: --no-mcp-android) --auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available -d, --daemon browser: run the browser frontend server in the background after build --active apply-all: apply only to currently running containers (skip stopped ones) diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts index 152732f1..a5839cf5 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts @@ -20,6 +20,7 @@ export type BuildTemplateConfigInput = { readonly geminiAuthLabel: string | undefined readonly grokAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly agentMode: AgentMode | undefined readonly agentAuto: boolean /** @@ -96,6 +97,7 @@ export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateComm dockerNetworkMode: input.dockerNetworkMode, dockerSharedNetworkName: input.dockerSharedNetworkName, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, bunVersion: defaultTemplateConfig.bunVersion, agentMode: input.agentMode, agentAuto: input.agentAuto, diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index 5715ce52..421633f9 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -201,6 +201,7 @@ type CreateBehavior = { readonly force: boolean readonly forceEnv: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean } const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ @@ -209,7 +210,8 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ skipGithubAuth: raw.skipGithubAuth ?? false, force: raw.force ?? false, forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false + enableMcpPlaywright: raw.enableMcpPlaywright ?? false, + enableMcpAndroid: raw.enableMcpAndroid ?? false }) type TokenLabelConfig = { @@ -277,6 +279,7 @@ export const buildCreateCommand = ( ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, enableMcpPlaywright: behavior.enableMcpPlaywright, + enableMcpAndroid: behavior.enableMcpAndroid, agentMode, agentAuto: isAgentAuto, clonedOnHostname: raw.clonedOnHostname diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 8dbbf70f..7c52b036 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -34,6 +34,7 @@ export interface RawOptions { readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean + readonly enableMcpAndroid?: boolean readonly archivePath?: string readonly scrapMode?: string readonly wipe?: boolean diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index 525e1f28..5a77da7b 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -114,6 +114,7 @@ export interface TemplateConfig { readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid?: boolean | undefined readonly bunVersion: string readonly agentMode?: AgentMode | undefined readonly agentAuto?: boolean | undefined @@ -204,6 +205,12 @@ export interface McpPlaywrightUpCommand { readonly runUp: boolean } +export interface McpAndroidUpCommand { + readonly _tag: "McpAndroidUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface ApplyCommand { readonly _tag: "Apply" readonly projectDir: string @@ -219,6 +226,7 @@ export interface ApplyCommand { readonly playwrightRamLimit?: string | undefined readonly gpu?: GpuMode | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined } // CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag @@ -262,6 +270,7 @@ export type Command = | SessionsCommand | ScrapCommand | McpPlaywrightUpCommand + | McpAndroidUpCommand | ApplyCommand | ApplyAllCommand | HelpCommand diff --git a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts index b3bc52c1..c7b2f071 100644 --- a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts +++ b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts @@ -30,6 +30,7 @@ type DefaultTemplateConfig = Pick< | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" + | "enableMcpAndroid" | "bunVersion" > @@ -75,6 +76,7 @@ export const defaultTemplateConfig = { dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, + enableMcpAndroid: false, bunVersion: "1.3.11" } satisfies DefaultTemplateConfig /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/menu-create-command-parse.ts b/packages/app/src/docker-git/menu-create-command-parse.ts index c056bdd5..d3f49977 100644 --- a/packages/app/src/docker-git/menu-create-command-parse.ts +++ b/packages/app/src/docker-git/menu-create-command-parse.ts @@ -113,6 +113,7 @@ const unsupportedCreatePrefixes = new Set([ "gists", "help", "kill-all", + "mcp-android", "mcp-playwright", "menu", "open", @@ -164,6 +165,9 @@ const runUpCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partia const playwrightCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => raw.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: command.config.enableMcpPlaywright } +const androidCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.enableMcpAndroid === undefined ? {} : { enableMcpAndroid: command.config.enableMcpAndroid ?? false } + const forceCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => raw.force === undefined ? {} : { force: command.force } @@ -183,6 +187,7 @@ const createInputsFromCommand = ( ...gpuCreateInput(raw, command), ...runUpCreateInput(raw, command), ...playwrightCreateInput(raw, command), + ...androidCreateInput(raw, command), ...forceCreateInput(raw, command), ...forceEnvCreateInput(raw, command) }) diff --git a/packages/app/src/docker-git/menu-create-draft.ts b/packages/app/src/docker-git/menu-create-draft.ts index 478b4065..351caf1c 100644 --- a/packages/app/src/docker-git/menu-create-draft.ts +++ b/packages/app/src/docker-git/menu-create-draft.ts @@ -12,6 +12,7 @@ export const createProjectDraftFromInputs = ( readonly gpu: GpuMode readonly up: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean } => ({ @@ -23,6 +24,7 @@ export const createProjectDraftFromInputs = ( gpu: input.gpu, up: input.runUp, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, force: input.force, forceEnv: input.forceEnv }) diff --git a/packages/app/src/docker-git/menu-create-inputs.ts b/packages/app/src/docker-git/menu-create-inputs.ts index bbeb9dfc..8d92e6c1 100644 --- a/packages/app/src/docker-git/menu-create-inputs.ts +++ b/packages/app/src/docker-git/menu-create-inputs.ts @@ -247,6 +247,7 @@ export const resolveCreateInputs = ( gpu: values.gpu ?? defaultTemplateConfig.gpu, runUp: values.runUp !== false, enableMcpPlaywright: values.enableMcpPlaywright === true, + enableMcpAndroid: values.enableMcpAndroid === true, force: values.force === true, forceEnv: values.forceEnv === true } diff --git a/packages/app/src/docker-git/menu-create-labels.ts b/packages/app/src/docker-git/menu-create-labels.ts index 35459ffc..27a62399 100644 --- a/packages/app/src/docker-git/menu-create-labels.ts +++ b/packages/app/src/docker-git/menu-create-labels.ts @@ -23,6 +23,10 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): renderExplicitBooleanChoice(defaults.enableMcpPlaywright) }]` ), + Match.when( + "mcpAndroid", + () => `Enable Android MCP (nested Android emulator)? [${renderExplicitBooleanChoice(defaults.enableMcpAndroid)}]` + ), Match.when( "force", () => `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(defaults.force)}]` @@ -57,6 +61,12 @@ export const renderCreateStepLabelWithBufferPreview = ( ? renderCreateStepLabel(step, defaults) : `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]` }), + Match.when("mcpAndroid", () => { + const enableMcpAndroid = parseExplicitBooleanChoice(buffer) + return enableMcpAndroid === null + ? renderCreateStepLabel(step, defaults) + : `Enable Android MCP (nested Android emulator)? [${renderExplicitBooleanChoice(enableMcpAndroid)}]` + }), Match.when("force", () => { const force = parseExplicitBooleanChoice(buffer) return force === null diff --git a/packages/app/src/docker-git/menu-create-navigation.ts b/packages/app/src/docker-git/menu-create-navigation.ts index 89462acb..f0d7b364 100644 --- a/packages/app/src/docker-git/menu-create-navigation.ts +++ b/packages/app/src/docker-git/menu-create-navigation.ts @@ -224,6 +224,7 @@ export const resolveCreateSettingsChoiceBuffer = ( Match.when("gpu", () => gpuChoiceBuffer(direction)), Match.when("runUp", () => booleanChoiceBuffer(direction)), Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)), + Match.when("mcpAndroid", () => booleanChoiceBuffer(direction)), Match.when("force", () => booleanChoiceBuffer(direction)), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-create-step-apply.ts b/packages/app/src/docker-git/menu-create-step-apply.ts index 66c5dad4..b48baf63 100644 --- a/packages/app/src/docker-git/menu-create-step-apply.ts +++ b/packages/app/src/docker-git/menu-create-step-apply.ts @@ -41,12 +41,13 @@ const applyGpuStep = ( const applyBooleanStep = ( input: ApplyCreateStepInput, - key: "runUp" | "enableMcpPlaywright" | "force" + key: "runUp" | "enableMcpPlaywright" | "enableMcpAndroid" | "force" ): Either.Either>, ParseError> => { const isValue = isYesDefault(input.buffer, input.currentDefaults[key]) return Match.value(key).pipe( Match.when("runUp", () => Either.right({ runUp: isValue })), Match.when("enableMcpPlaywright", () => Either.right({ enableMcpPlaywright: isValue })), + Match.when("enableMcpAndroid", () => Either.right({ enableMcpAndroid: isValue })), Match.when("force", () => Either.right({ force: isValue })), Match.exhaustive ) @@ -64,6 +65,7 @@ const applyCreateStep = ( Match.when("gpu", () => applyGpuStep(input)), Match.when("runUp", () => applyBooleanStep(input, "runUp")), Match.when("mcpPlaywright", () => applyBooleanStep(input, "enableMcpPlaywright")), + Match.when("mcpAndroid", () => applyBooleanStep(input, "enableMcpAndroid")), Match.when("force", () => applyBooleanStep(input, "force")), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-create-steps.ts b/packages/app/src/docker-git/menu-create-steps.ts index 73083ce2..63f3d801 100644 --- a/packages/app/src/docker-git/menu-create-steps.ts +++ b/packages/app/src/docker-git/menu-create-steps.ts @@ -19,6 +19,7 @@ const isCreateStepSatisfied = ( Match.when("gpu", () => hasOwn(values, "gpu")), Match.when("runUp", () => hasOwn(values, "runUp")), Match.when("mcpPlaywright", () => hasOwn(values, "enableMcpPlaywright")), + Match.when("mcpAndroid", () => hasOwn(values, "enableMcpAndroid")), Match.when("force", () => hasOwn(values, "force")), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 17c7eff1..84bc9731 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -55,6 +55,7 @@ export type CreateInputs = { readonly gpu: GpuMode readonly runUp: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean } @@ -68,6 +69,7 @@ export type CreateStep = | "gpu" | "runUp" | "mcpPlaywright" + | "mcpAndroid" | "force" export const orderedCreateSteps: ReadonlyArray = [ @@ -77,6 +79,7 @@ export const orderedCreateSteps: ReadonlyArray = [ "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ] diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index bb33082f..1585f428 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -4,6 +4,7 @@ export type UnsupportedOperationalCommandTag = | "ScrapExport" | "ScrapImport" | "McpPlaywrightUp" + | "McpAndroidUp" | "Apply" | "AuthClaudeStatus" | "AuthClaudeLogout" @@ -22,6 +23,10 @@ export const unsupportedOperationalCommands: Record< command: "mcp-playwright", message: "Playwright browser management is disabled in API-only host mode." }, + McpAndroidUp: { + command: "mcp-android", + message: "Android emulator management is disabled in API-only host mode." + }, Apply: { command: "Apply", message: "Command Apply is not available in API-only host mode." diff --git a/packages/app/src/web/api-project-create-body.ts b/packages/app/src/web/api-project-create-body.ts index db0f7019..1a86e48e 100644 --- a/packages/app/src/web/api-project-create-body.ts +++ b/packages/app/src/web/api-project-create-body.ts @@ -44,6 +44,7 @@ export type OptionalProjectResourceFieldsBody = Readonly<{ export type BaseCreateProjectBody = Readonly<{ readonly cpuLimit: CreateProjectDraft["cpuLimit"] readonly enableMcpPlaywright: CreateProjectDraft["enableMcpPlaywright"] + readonly enableMcpAndroid: CreateProjectDraft["enableMcpAndroid"] readonly force: CreateProjectDraft["force"] readonly forceEnv: CreateProjectDraft["forceEnv"] readonly gpu: CreateProjectDraft["gpu"] @@ -94,6 +95,7 @@ export const optionalProjectResourceFields = ( export const baseCreateProjectBody = (draft: CreateProjectDraft): BaseCreateProjectBody => ({ cpuLimit: draft.cpuLimit, enableMcpPlaywright: draft.enableMcpPlaywright, + enableMcpAndroid: draft.enableMcpAndroid, force: draft.force, forceEnv: draft.forceEnv, gpu: draft.gpu, diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 0d564832..31769ef5 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -69,6 +69,7 @@ export type CreateProjectDraft = { readonly ramLimit: string readonly gpu: "none" | "all" readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean readonly up: boolean diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts index 39ac709f..278d5abf 100644 --- a/packages/app/tests/docker-git/actions-project-create.test.ts +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -26,6 +26,7 @@ vi.mock("../../src/web/project-events.js", () => ({ const inputConfig = { cpuLimit: "75%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/api-create-project.test.ts b/packages/app/tests/docker-git/api-create-project.test.ts index 21b944c4..1d2d358e 100644 --- a/packages/app/tests/docker-git/api-create-project.test.ts +++ b/packages/app/tests/docker-git/api-create-project.test.ts @@ -8,6 +8,7 @@ import type { CreateProjectRequestDraft } from "../../src/web/api-project-create const projectDraft = { cpuLimit: "80%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", @@ -61,6 +62,7 @@ describe("api create project request body", () => { async: true, cpuLimit: "80%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts index 85fbdf5b..80e9d1bf 100644 --- a/packages/app/tests/docker-git/app-ready-create-fixture.ts +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -40,6 +40,7 @@ export const validGithubStatus: GithubAuthStatus = { const defaultQuickCreateInputs = { cpuLimit: "", enableMcpPlaywright: false, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/app-ready-create-settings.test.ts b/packages/app/tests/docker-git/app-ready-create-settings.test.ts index ae2c4b45..47ae7ea6 100644 --- a/packages/app/tests/docker-git/app-ready-create-settings.test.ts +++ b/packages/app/tests/docker-git/app-ready-create-settings.test.ts @@ -124,7 +124,7 @@ describe("app-ready-create settings", () => { const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) expect(enterResult.handled).toBe(true) - expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(enteredView.values.enableMcpPlaywright).toBe(true) expect(enteredView.buffer).toBe("") }) @@ -135,7 +135,7 @@ describe("app-ready-create settings", () => { const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) expect(isHandled).toBe(true) - expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(nextView.values.enableMcpPlaywright).toBeUndefined() expect(nextView.buffer).toBe("") }) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 3de81b62..4e655099 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -55,7 +55,8 @@ describe("app-ready-create", () => { "ramLimit", "gpu", "runUp", - "mcpPlaywright" + "mcpPlaywright", + "mcpAndroid" ]) expect(context.setMessage).toHaveBeenCalledWith(null) }) diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts index 1aebb66f..f4445a70 100644 --- a/packages/app/tests/docker-git/menu-create-display-settings.test.ts +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -34,10 +34,11 @@ describe("menu-create-shared display settings", () => { const cwd = process.cwd() const isDisplaySettingStep = (step: CreateStep): step is Exclude => step !== "repoUrl" const displaySettingSteps = resolveCreateDisplaySteps().filter(isDisplaySettingStep) - const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "force"> = [ + const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "mcpAndroid" | "force"> = [ "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ] const validBufferByStep: Record = { @@ -45,6 +46,7 @@ describe("menu-create-shared display settings", () => { force: "y", gpu: "all", mcpPlaywright: "y", + mcpAndroid: "y", outDir: "/home/dev/.docker-git/org/repo-preview", ramLimit: "8g", repoRef: "main", @@ -69,6 +71,7 @@ describe("menu-create-shared display settings", () => { "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ]) }) @@ -90,7 +93,7 @@ describe("menu-create-shared display settings", () => { { ...mcpPlaywrightView, buffer: "y" } ))) - expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(next.buffer).toBe("") expect(next.values.enableMcpPlaywright).toBe(true) }) @@ -123,7 +126,7 @@ describe("menu-create-shared display settings", () => { const down = moveCreateDisplaySettingsStep(applied, "down") const up = moveCreateDisplaySettingsStep(applied, "up") - expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) expect(down?.buffer).toBe("") expect(up?.values.enableMcpPlaywright).toBe(true) diff --git a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts index 7fe2b3ae..fea34597 100644 --- a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts @@ -14,6 +14,7 @@ const settingsStepArbitrary: fc.Arbitrary = fc.constantFrom( "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ) @@ -22,12 +23,13 @@ const stepBufferByStep: Readonly> = { force: "y", gpu: "all", mcpPlaywright: "n", + mcpAndroid: "n", ramLimit: "4g", runUp: "y" } const satisfiedCreateSettingsArbitrary = fc.uniqueArray(settingsStepArbitrary, { - maxLength: 6 + maxLength: 7 }) /** @@ -60,6 +62,7 @@ const createSatisfiedStepValue = (step: CreateSettingStep): Partial => ({ gpu: "none" })), Match.when("runUp", (): Partial => ({ runUp: true })), Match.when("mcpPlaywright", (): Partial => ({ enableMcpPlaywright: false })), + Match.when("mcpAndroid", (): Partial => ({ enableMcpAndroid: false })), Match.when("force", (): Partial => ({ force: false })), Match.exhaustive ) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 63177803..a41a475d 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -58,6 +58,7 @@ describe("menu-create-shared", () => { "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ]) }) @@ -87,7 +88,8 @@ describe("menu-create-shared", () => { "repoUrl", "cpuLimit", "ramLimit", - "gpu" + "gpu", + "mcpAndroid" ]) }) @@ -110,7 +112,7 @@ describe("menu-create-shared", () => { const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, createInitialFlowView( - `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force` + `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --mcp-android --force` ) )) @@ -120,6 +122,7 @@ describe("menu-create-shared", () => { expect(inputs.gpu).toBe("all") expect(inputs.runUp).toBe(false) expect(inputs.enableMcpPlaywright).toBe(true) + expect(inputs.enableMcpAndroid).toBe(true) expect(inputs.force).toBe(true) }) @@ -304,6 +307,7 @@ describe("menu-create-shared", () => { const generatedSettingsArbitrary = fc.record({ cpuLimit: fc.constantFrom("", "25%", "50%"), enableMcpPlaywright: fc.boolean(), + enableMcpAndroid: fc.boolean(), force: fc.boolean(), gpu: fc.constantFrom("none", "all"), ramLimit: fc.constantFrom("", "2g", "4g"), diff --git a/packages/container/src/core/domain.ts b/packages/container/src/core/domain.ts index 41d9fda3..7466e2fe 100644 --- a/packages/container/src/core/domain.ts +++ b/packages/container/src/core/domain.ts @@ -74,6 +74,7 @@ export interface TemplateConfig { readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid?: boolean | undefined readonly bunVersion: string readonly agentMode?: AgentMode | undefined readonly agentAuto?: boolean | undefined diff --git a/packages/container/src/core/template-defaults.ts b/packages/container/src/core/template-defaults.ts index b17cbd85..98682b27 100644 --- a/packages/container/src/core/template-defaults.ts +++ b/packages/container/src/core/template-defaults.ts @@ -29,6 +29,7 @@ type DefaultTemplateConfig = Pick< | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" + | "enableMcpAndroid" | "bunVersion" > @@ -74,5 +75,6 @@ export const defaultTemplateConfig = { dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, + enableMcpAndroid: false, bunVersion: "1.3.11" } satisfies DefaultTemplateConfig diff --git a/packages/container/src/core/templates-entrypoint.ts b/packages/container/src/core/templates-entrypoint.ts index 96fdd684..7d74625f 100644 --- a/packages/container/src/core/templates-entrypoint.ts +++ b/packages/container/src/core/templates-entrypoint.ts @@ -17,6 +17,7 @@ import { renderEntrypointCodexHome, renderEntrypointCodexResumeHint, renderEntrypointCodexSharedAuth, + renderEntrypointMcpAndroid, renderEntrypointMcpPlaywright, renderEntrypointProjectCodexSkillsSync } from "./templates-entrypoint/codex.js" @@ -60,6 +61,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointDockerSocket(config), renderEntrypointRustBrowserConnection(), renderEntrypointMcpPlaywright(config), + renderEntrypointMcpAndroid(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), renderEntrypointGeminiConfig(config), diff --git a/packages/container/src/core/templates-entrypoint/base.ts b/packages/container/src/core/templates-entrypoint/base.ts index 0ad47619..5c6be2a1 100644 --- a/packages/container/src/core/templates-entrypoint/base.ts +++ b/packages/container/src/core/templates-entrypoint/base.ts @@ -41,6 +41,7 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-0}" +MCP_ANDROID_ENABLE="\${MCP_ANDROID_ENABLE:-${config.enableMcpAndroid === true ? "1" : "0"}}" SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" diff --git a/packages/container/src/core/templates-entrypoint/claude.ts b/packages/container/src/core/templates-entrypoint/claude.ts index e86f0167..599fd24a 100644 --- a/packages/container/src/core/templates-entrypoint/claude.ts +++ b/packages/container/src/core/templates-entrypoint/claude.ts @@ -251,6 +251,65 @@ NODE docker_git_sync_claude_playwright_mcp chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` +const renderClaudeMcpAndroidConfig = (): string => + String.raw`# Claude Code: keep Android MCP config in sync with container settings +CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" +docker_git_sync_claude_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + [[ -n "$android_project" ]] || android_project="$(hostname)" + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + [[ -n "$adb_endpoint" ]] || adb_endpoint="$android_project-android:5555" + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_SETTINGS_FILE +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +const enableAndroid = process.env.MCP_ANDROID_ENABLE === "1" +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const raw = fs.readFileSync(settingsPath, "utf8") + const parsed = JSON.parse(raw) + settings = isRecord(parsed) ? parsed : {} +} catch { settings = {} } + +const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} +const nextServers = { ...currentServers } +if (enableAndroid) { + nextServers.android = { type: "stdio", command: "android-connection", args: androidArgs, env: {} } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +if (Object.keys(nextServers).length > 0) { + nextSettings.mcpServers = nextServers +} else { + delete nextSettings.mcpServers +} + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_claude_android_mcp +chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` + const renderClaudeProfileSetup = (): string => String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh" printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE" @@ -277,6 +336,7 @@ export const renderEntrypointClaudeConfig = (config: TemplateConfig): string => renderClaudeCliInstall(), renderClaudePermissionSettingsConfig(), renderClaudeMcpPlaywrightConfig(), + renderClaudeMcpAndroidConfig(), renderClaudeGlobalPromptSetup(config), renderClaudeWrapperSetup(), renderClaudeProfileSetup() diff --git a/packages/container/src/core/templates-entrypoint/codex.ts b/packages/container/src/core/templates-entrypoint/codex.ts index a43bd715..564ade11 100644 --- a/packages/container/src/core/templates-entrypoint/codex.ts +++ b/packages/container/src/core/templates-entrypoint/codex.ts @@ -132,6 +132,94 @@ export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => .replaceAll("__CODEX_HOME__", () => config.codexHome) .replaceAll("__SERVICE_NAME__", () => config.serviceName) +// CHANGE: configure the first-party Android MCP server for Codex, mirroring the Playwright block +// WHY: issue-436 asks to wire mcp-android "the same way" Playwright MCP works; Codex reads its +// MCP servers from config.toml, so we add/remove an [mcp_servers.android] entry to match the build +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +const entrypointMcpAndroidTemplate = String.raw`# Optional: configure Android MCP for Codex (Rust android-connection) +CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" +DOCKER_GIT_ANDROID_PROJECT="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" +if [[ -z "$DOCKER_GIT_ANDROID_PROJECT" ]]; then + DOCKER_GIT_ANDROID_PROJECT="$(hostname)" +fi +DOCKER_GIT_ANDROID_NETWORK="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$DOCKER_GIT_ANDROID_PROJECT}" +DOCKER_GIT_ANDROID_ADB_ENDPOINT="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" +if [[ -z "$DOCKER_GIT_ANDROID_ADB_ENDPOINT" ]]; then + DOCKER_GIT_ANDROID_ADB_ENDPOINT="$DOCKER_GIT_ANDROID_PROJECT-android:5555" +fi + +# Keep config.toml consistent with the container build. +# If Android MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn android-connection. +if [[ "$MCP_ANDROID_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Android MCP/ { next } + /^\[mcp_servers[.]android([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.5" +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +shell_snapshot = true +multi_agent = true +apps = true +shell_tool = true + +[profiles.longcontx] +model = "gpt-5.5" +model_context_window = 1050000 +model_auto_compact_token_limit = 945000 +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + # Replace the docker-git Android MCP block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Android MCP/ { next } + /^\[mcp_servers[.]android([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Android MCP (rust android-connection) +[mcp_servers.android] +command = "android-connection" +args = ["--project", "$DOCKER_GIT_ANDROID_PROJECT", "--network", "$DOCKER_GIT_ANDROID_NETWORK", "--endpoint", "$DOCKER_GIT_ANDROID_ADB_ENDPOINT", "--workspace", "$TARGET_DIR"] +EOF +fi` + +export const renderEntrypointMcpAndroid = (config: TemplateConfig): string => + entrypointMcpAndroidTemplate + .replaceAll("__CODEX_HOME__", () => config.codexHome) + .replaceAll("__SERVICE_NAME__", () => config.serviceName) + const entrypointProjectCodexSkillsSyncTemplate = String .raw`# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. docker_git_sync_project_codex_skills() { diff --git a/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts new file mode 100644 index 00000000..f1a92e2c --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts @@ -0,0 +1,57 @@ +// CHANGE: extract the Gemini Android MCP config sync into its own module +// WHY: issue-436 wires mcp-android "the same way" Playwright MCP works; keeping the +// render helper in a dedicated file keeps gemini.ts under the max-lines lint budget +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +export const renderGeminiMcpAndroidConfig = (): string => + String.raw`# Gemini CLI: keep Android MCP config in sync with container settings +docker_git_sync_gemini_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + if [[ -z "$android_project" ]]; then + android_project="$(hostname)" + fi + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + if [[ -z "$adb_endpoint" ]]; then + adb_endpoint="$android_project-android:5555" + fi + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 + ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] + : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "android-connection", args: androidArgs, trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_android_mcp` diff --git a/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts new file mode 100644 index 00000000..c4dea291 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts @@ -0,0 +1,42 @@ +// CHANGE: house the Gemini Playwright MCP config sync in its own module +// WHY: gemini.ts also wires the Android MCP sidecar (issue-436); moving both optional MCP +// config helpers into sibling modules keeps gemini.ts under the max-lines lint budget +// REF: issue-436 +export const renderGeminiMcpPlaywrightConfig = (): string => + String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_gemini_playwright_mcp() { + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_playwright_mcp` diff --git a/packages/container/src/core/templates-entrypoint/gemini.ts b/packages/container/src/core/templates-entrypoint/gemini.ts index e8d35570..48599300 100644 --- a/packages/container/src/core/templates-entrypoint/gemini.ts +++ b/packages/container/src/core/templates-entrypoint/gemini.ts @@ -1,4 +1,6 @@ import type { TemplateConfig } from "../domain.js" +import { renderGeminiMcpAndroidConfig } from "./gemini-android-mcp.js" +import { renderGeminiMcpPlaywrightConfig } from "./gemini-playwright-mcp.js" // CHANGE: add Gemini CLI entrypoint configuration // WHY: enable Gemini CLI in Docker with automated auth, trust settings and MCP @@ -199,44 +201,6 @@ if [[ -d /etc/sudoers.d ]]; then chmod 0440 /etc/sudoers.d/gemini-agent fi` -const renderGeminiMcpPlaywrightConfig = (): string => - String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings -docker_git_sync_gemini_playwright_mcp() { - local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" - local browser_network="container:$browser_project" - GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" -const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } -} else { - delete nextServers.playwright -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_gemini_playwright_mcp` const renderGeminiProfileSetup = (config: TemplateConfig): string => String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" @@ -336,6 +300,7 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => renderGeminiAuthConfig(config), renderGeminiPermissionSettingsConfig(config), renderGeminiMcpPlaywrightConfig(), + renderGeminiMcpAndroidConfig(), renderGeminiSudoConfig(config), renderGeminiProfileSetup(config), entrypointGeminiNoticeTemplate diff --git a/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts new file mode 100644 index 00000000..84c529a5 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts @@ -0,0 +1,57 @@ +// CHANGE: extract the Grok Android MCP config sync into its own module +// WHY: issue-436 wires mcp-android "the same way" Playwright MCP works; keeping the +// render helper in a dedicated file keeps grok.ts under the max-lines lint budget +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +export const renderGrokMcpAndroidConfig = (): string => + String.raw`# Grok CLI: keep Android MCP config in sync with container settings +docker_git_sync_grok_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + if [[ -z "$android_project" ]]; then + android_project="$(hostname)" + fi + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + if [[ -z "$adb_endpoint" ]]; then + adb_endpoint="$android_project-android:5555" + fi + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 + ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] + : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "android-connection", args: androidArgs, trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_grok_android_mcp` diff --git a/packages/container/src/core/templates-entrypoint/grok.ts b/packages/container/src/core/templates-entrypoint/grok.ts index 86e80bcf..3e5296fd 100644 --- a/packages/container/src/core/templates-entrypoint/grok.ts +++ b/packages/container/src/core/templates-entrypoint/grok.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" +import { renderGrokMcpAndroidConfig } from "./grok-android-mcp.js" // CHANGE: add Grok CLI entrypoint configuration // WHY: issue #304 requires Grok auth, Playwright MCP and unrestricted agent permissions @@ -344,6 +345,7 @@ export const renderEntrypointGrokConfig = (config: TemplateConfig): string => renderGrokAuthConfig(config), renderGrokPermissionSettingsConfig(config), renderGrokMcpPlaywrightConfig(), + renderGrokMcpAndroidConfig(), renderGrokSudoConfig(config), renderGrokProfileSetup(config), renderEntrypointGrokNotice(config) diff --git a/packages/container/src/core/templates/docker-compose-android.ts b/packages/container/src/core/templates/docker-compose-android.ts new file mode 100644 index 00000000..f0e03d56 --- /dev/null +++ b/packages/container/src/core/templates/docker-compose-android.ts @@ -0,0 +1,65 @@ +import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" + +// CHANGE: render an Android emulator sidecar service for the first-party Android MCP wiring +// WHY: issue-436 asks to connect mcp-android "the same way" Playwright MCP works, exposing +// a docker-android emulator as a service reachable over ADB for android-connection. +// Extracted into its own module so docker-compose.ts stays under the max-lines budget. +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/budtmo/docker-android +// PURITY: CORE +// INVARIANT: only emitted when config.enableMcpAndroid === true; image/ports are env-overridable +export type AndroidFragments = { + readonly maybeAndroidEnv: string + readonly maybeAndroidService: string + readonly maybeAndroidVolume: string +} + +const defaultAndroidEmulatorImage = "budtmo/docker-android:emulator_14.0" + +export const buildAndroidFragments = ( + config: TemplateConfig, + resourceLimitsBlock: string +): AndroidFragments => { + if (config.enableMcpAndroid !== true) { + return { + maybeAndroidEnv: "", + maybeAndroidService: "", + maybeAndroidVolume: "" + } + } + + const androidContainerName = `${config.containerName}-android` + const androidVolumeName = `${config.volumeName}-android` + const androidImageRef = `\${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-${defaultAndroidEmulatorImage}}` + const networkName = resolveComposeNetworkName(config) + + const maybeAndroidEnv = + ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_PROJECT: "${config.containerName}"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` + + const maybeAndroidService = ` + ${config.serviceName}-android: + image: "${androidImageRef}" + container_name: ${androidContainerName} + privileged: true + environment: + EMULATOR_DEVICE: "\${DOCKER_GIT_ANDROID_DEVICE:-Samsung Galaxy S10}" + WEB_VNC: "\${DOCKER_GIT_ANDROID_WEB_VNC:-true}" + EMULATOR_HEADLESS: "\${DOCKER_GIT_ANDROID_HEADLESS:-true}" + devices: + - /dev/kvm + ports: + - "\${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555" + - "\${DOCKER_GIT_ANDROID_NOVNC_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_NOVNC_PORT:-6080}:6080" +${resourceLimitsBlock} volumes: + - ${androidVolumeName}:/root/.android + networks: + - ${networkName} +` + + return { + maybeAndroidEnv, + maybeAndroidService, + maybeAndroidVolume: ` ${androidVolumeName}:` + } +} diff --git a/packages/container/src/core/templates/docker-compose-playwright.ts b/packages/container/src/core/templates/docker-compose-playwright.ts new file mode 100644 index 00000000..abe6bc78 --- /dev/null +++ b/packages/container/src/core/templates/docker-compose-playwright.ts @@ -0,0 +1,61 @@ +import type { TemplateConfig } from "../domain.js" +import type { ResolvedComposeResourceLimits } from "../resource-limits.js" +import type { DockerComposeRenderOptions } from "./docker-compose.js" + +// CHANGE: house the Playwright MCP sidecar fragment builder in its own module +// WHY: docker-compose.ts hosts the optional Android sidecar (issue-436) too; moving both +// optional-sidecar builders into sibling modules keeps docker-compose.ts under the +// max-lines lint budget while grouping the parallel Playwright/Android wiring together +// REF: issue-436 +export type PlaywrightFragments = { + readonly maybeDependsOn: string + readonly maybeDockerSocketMount: string + readonly maybePlaywrightEnv: string + readonly maybeBrowserVolume: string +} + +const renderBrowserLimitEnv = ( + key: string, + value: number | string | undefined +): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` + +const renderOptionalDockerSocketMount = ( + shouldEnableLocalDockerSocket: boolean +): string => + shouldEnableLocalDockerSocket + ? ` - /var/run/docker.sock:/var/run/docker.sock` + : "" + +export const buildPlaywrightFragments = ( + config: TemplateConfig, + resourceLimits: ResolvedComposeResourceLimits | undefined, + options: DockerComposeRenderOptions +): PlaywrightFragments => { + if (!config.enableMcpPlaywright) { + return { + maybeDependsOn: "", + maybeDockerSocketMount: "", + maybePlaywrightEnv: "", + maybeBrowserVolume: "" + } + } + + const browserContainerName = `${config.containerName}-browser` + const browserVolumeName = `${config.volumeName}-browser` + const browserImageName = `${browserContainerName}:docker-git-browser` + + return { + maybeDependsOn: "", + maybeDockerSocketMount: renderOptionalDockerSocketMount( + options.enableLocalDockerSocket + ), + maybePlaywrightEnv: + ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ + renderBrowserLimitEnv( + "DOCKER_GIT_BROWSER_CPU_LIMIT", + resourceLimits?.cpuLimit + ) + }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, + maybeBrowserVolume: ` ${browserVolumeName}:` + } +} diff --git a/packages/container/src/core/templates/docker-compose.ts b/packages/container/src/core/templates/docker-compose.ts index a0b25547..7fa1f340 100644 --- a/packages/container/src/core/templates/docker-compose.ts +++ b/packages/container/src/core/templates/docker-compose.ts @@ -7,6 +7,8 @@ import { type TemplateConfig } from "../domain.js" import type { ResolvedComposeResourceLimits } from "../resource-limits.js" +import { buildAndroidFragments } from "./docker-compose-android.js" +import { buildPlaywrightFragments } from "./docker-compose-playwright.js" type ComposeFragments = { readonly networkMode: TemplateConfig["dockerNetworkMode"] @@ -23,18 +25,13 @@ type ComposeFragments = { readonly maybeDockerSocketMount: string readonly maybePlaywrightEnv: string readonly maybeBrowserVolume: string + readonly maybeAndroidEnv: string + readonly maybeAndroidService: string + readonly maybeAndroidVolume: string readonly maybeBootstrapMounts: string readonly forkRepoUrl: string } -type PlaywrightFragments = Pick< - ComposeFragments, - | "maybeDependsOn" - | "maybeDockerSocketMount" - | "maybePlaywrightEnv" - | "maybeBrowserVolume" -> - type AuthEnvFragments = Pick< ComposeFragments, | "maybeGitTokenLabelEnv" @@ -112,13 +109,6 @@ const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/ const renderYamlSingleQuoted = (value: string): string => `'${value.replaceAll("'", "''")}'` -const renderOptionalDockerSocketMount = ( - shouldEnableLocalDockerSocket: boolean -): string => - shouldEnableLocalDockerSocket - ? ` - /var/run/docker.sock:/var/run/docker.sock` - : "" - const renderEnvFiles = (config: TemplateConfig): string => ` env_file:\n - ${renderYamlSingleQuoted(config.envGlobalPath)}\n - ${ renderYamlSingleQuoted( @@ -151,45 +141,6 @@ const buildAgentEnvFragments = (config: TemplateConfig): AgentEnvFragments => ({ maybeAgentAutoEnv: renderAgentAutoEnv(config.agentAuto) }) -const renderBrowserLimitEnv = ( - key: string, - value: number | string | undefined -): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` - -const buildPlaywrightFragments = ( - config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined, - options: DockerComposeRenderOptions -): PlaywrightFragments => { - if (!config.enableMcpPlaywright) { - return { - maybeDependsOn: "", - maybeDockerSocketMount: "", - maybePlaywrightEnv: "", - maybeBrowserVolume: "" - } - } - - const browserContainerName = `${config.containerName}-browser` - const browserVolumeName = `${config.volumeName}-browser` - const browserImageName = `${browserContainerName}:docker-git-browser` - - return { - maybeDependsOn: "", - maybeDockerSocketMount: renderOptionalDockerSocketMount( - options.enableLocalDockerSocket - ), - maybePlaywrightEnv: - ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ - renderBrowserLimitEnv( - "DOCKER_GIT_BROWSER_CPU_LIMIT", - resourceLimits?.cpuLimit - ) - }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, - maybeBrowserVolume: ` ${browserVolumeName}:` - } -} - const isResolvedComposeResourceLimits = ( value: ResolvedComposeResourceLimits | ComposeResourceLimits ): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value @@ -225,6 +176,10 @@ const buildComposeFragments = ( resourceLimits.playwright, options ) + const android = buildAndroidFragments( + config, + renderResourceLimits(resourceLimits.playwright) + ) return { networkMode, @@ -236,6 +191,9 @@ const buildComposeFragments = ( maybeDockerSocketMount: playwright.maybeDockerSocketMount, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserVolume: playwright.maybeBrowserVolume, + maybeAndroidEnv: android.maybeAndroidEnv, + maybeAndroidService: android.maybeAndroidService, + maybeAndroidVolume: android.maybeAndroidVolume, maybeBootstrapMounts: renderBootstrapMounts(), forkRepoUrl } @@ -269,7 +227,7 @@ ${fragments.maybeGrokAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.mayb DOCKER_GIT_PROJECT_DOCKER_HOST: "\${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" TARGET_DIR: "${config.targetDir}" CODEX_HOME: "${config.codexHome}" -${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap +${fragments.maybePlaywrightEnv}${fragments.maybeAndroidEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap ports: - "\${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-127.0.0.1}:${config.sshPort}:22" ${renderResourceLimits(resourceLimits.main)} volumes: @@ -286,7 +244,7 @@ ${fragments.maybeDockerSocketMount} - 1.1.1.1 networks: - ${fragments.networkName} -` +${fragments.maybeAndroidService}` const renderComposeNetworks = ( networkMode: TemplateConfig["dockerNetworkMode"], @@ -302,7 +260,8 @@ const renderComposeNetworks = ( const renderComposeVolumes = ( config: TemplateConfig, - maybeBrowserVolume: string + maybeBrowserVolume: string, + maybeAndroidVolume: string ): string => [ "volumes:", @@ -315,7 +274,8 @@ const renderComposeVolumes = ( ` ${sharedCodexVolumeKey}:`, " external: true", ` name: ${dockerGitSharedCodexVolumeName}`, - maybeBrowserVolume + maybeBrowserVolume, + maybeAndroidVolume ] .filter((entry) => entry.length > 0) .join("\n") @@ -331,6 +291,10 @@ export const renderDockerCompose = ( `name: ${resolveComposeProjectName(config)}`, renderComposeServices(config, fragments, limits), renderComposeNetworks(fragments.networkMode, fragments.networkName), - renderComposeVolumes(config, fragments.maybeBrowserVolume) + renderComposeVolumes( + config, + fragments.maybeBrowserVolume, + fragments.maybeAndroidVolume + ) ].join("\n\n") } diff --git a/packages/container/src/core/templates/dockerfile.ts b/packages/container/src/core/templates/dockerfile.ts index 14b756f2..9037f3b4 100644 --- a/packages/container/src/core/templates/dockerfile.ts +++ b/packages/container/src/core/templates/dockerfile.ts @@ -83,6 +83,7 @@ RUN set -eu; \ rtk gain >/dev/null 2>&1 || true` const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest" +const rustAndroidConnectionRevision = "7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c" const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => config.enableMcpPlaywright @@ -91,6 +92,24 @@ const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => # Old browser-vnc + cdp-guard duplication removed per #347` : "" +// CHANGE: install the first-party Android MCP module when Android MCP is enabled +// WHY: issue-436 requires a separately proven module instead of an unpinned runtime npx server or vendored source copy +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/ProverCoderAI/rust-android-connection/commit/7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c +// PURITY: CORE (pure template renderer) +const renderDockerfileAndroidRuntime = (config: TemplateConfig): string => + config.enableMcpAndroid === true + ? `# Android MCP runtime: ADB client + first-party Rust android-connection module. +RUN apt-get update \ + && apt-get install -y --no-install-recommends android-tools-adb \ + && rm -rf /var/lib/apt/lists/* \ + && adb --version \ + && cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev ${rustAndroidConnectionRevision} --locked --bins --root /usr/local \ + && /usr/local/bin/docker-git-android-connection --version \ + && /usr/local/bin/android-connection --version` + : "" + /** * Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension. * @@ -241,6 +260,7 @@ export const renderDockerfile = (config: TemplateConfig): string => renderDockerfileNode(), renderDockerfileBun(config), renderDockerfilePlaywrightRuntime(config), + renderDockerfileAndroidRuntime(config), renderDockerfileRtk(), renderDockerfileOpenCode(), renderDockerfileGitleaks(), diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index b3feec3b..29f3599a 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -1130,6 +1130,85 @@ describe("renderDockerCompose", () => { expect(compose).toContain('DOCKER_GIT_BROWSER_RAM_LIMIT: "${DOCKER_GIT_BROWSER_RAM_LIMIT:-2g}"') }) + it("renders the Android emulator sidecar service when Android MCP is enabled", () => { + const compose = renderDockerCompose( + makeTemplateConfig({ + enableMcpAndroid: true, + gpu: "none", + }), + { + cpuLimit: 1.5, + ramLimit: "2g", + swapLimit: "4g" + } + ) + + expect(compose).toContain('MCP_ANDROID_ENABLE: "1"') + expect(compose).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(compose).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') + expect(compose).toContain( + 'DOCKER_GIT_ANDROID_ADB_ENDPOINT: "${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-dg-test-android:5555}"' + ) + expect(compose).toContain( + 'DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-budtmo/docker-android:emulator_14.0}"' + ) + // emulator runs as a real compose service (unlike the externally-managed browser container) + expect(compose).toContain("\n dg-test-android:\n") + expect(compose).toContain(" - /dev/kvm") + expect(compose).toContain( + '- "${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555"' + ) + expect(compose).toContain(" dg-test-home-android:") + // the sidecar reuses the Playwright sidecar resource budget + expect(compose).toContain(" cpus: 1.5\n") + }) + + it("omits all Android emulator wiring when Android MCP is disabled", () => { + const compose = renderDockerCompose(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(compose).not.toContain("MCP_ANDROID_ENABLE") + expect(compose).not.toContain("\n dg-test-android:\n") + expect(compose).not.toContain("dg-test-home-android:") + expect(compose).not.toContain("/dev/kvm") + }) + + it("installs the first-party Android connection module only when Android MCP is enabled", () => { + const enabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: true })) + const disabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(enabled).toContain("android-tools-adb") + expect(enabled).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c --locked --bins --root /usr/local" + ) + expect(enabled).toContain("/usr/local/bin/docker-git-android-connection --version") + expect(enabled).toContain("/usr/local/bin/android-connection --version") + expect(enabled).not.toContain(".docker-git-tools/android-connection") + expect(disabled).not.toContain(".docker-git-tools/android-connection") + expect(disabled).not.toContain("android-tools-adb") + }) + + it("configures the Android MCP server for every agent and defaults the enable flag", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpAndroid: true })) + + expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-1}"') + // Codex (TOML) + expect(entrypoint).toContain("[mcp_servers.android]") + expect(entrypoint).toContain('command = "android-connection"') + expect(entrypoint).toContain('"--endpoint", "$DOCKER_GIT_ANDROID_ADB_ENDPOINT"') + expect(entrypoint).not.toContain("@mobilenext/mobile-mcp") + // Claude / Gemini / Grok (JSON sync helpers) + expect(entrypoint).toContain("docker_git_sync_claude_android_mcp") + expect(entrypoint).toContain("docker_git_sync_gemini_android_mcp") + expect(entrypoint).toContain("docker_git_sync_grok_android_mcp") + expect(entrypoint).toContain('command: "android-connection"') + }) + + it("defaults MCP_ANDROID_ENABLE to 0 when Android MCP is disabled", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-0}"') + }) + it("renders explicit anonymous GitHub clone override for public repos", () => { const compose = renderDockerCompose( makeTemplateConfig({ diff --git a/packages/docker-git-session-sync/CHANGELOG.md b/packages/docker-git-session-sync/CHANGELOG.md index 424da984..0b49da7b 100644 --- a/packages/docker-git-session-sync/CHANGELOG.md +++ b/packages/docker-git-session-sync/CHANGELOG.md @@ -1,5 +1,11 @@ # @prover-coder-ai/docker-git-session-sync +## 1.0.70 + +### Patch Changes + +- chore: automated version bump + ## 1.0.69 ### Patch Changes diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 44aebcee..f8caac85 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.69", + "version": "1.0.70", "description": "Standalone docker-git AI agent session synchronization tool", "main": "dist/docker-git-session-sync.js", "bin": { diff --git a/packages/lib/src/core/command-builders-template.ts b/packages/lib/src/core/command-builders-template.ts index d0bcce72..9e7b1f8d 100644 --- a/packages/lib/src/core/command-builders-template.ts +++ b/packages/lib/src/core/command-builders-template.ts @@ -19,6 +19,7 @@ export type BuildTemplateConfigInput = { readonly geminiAuthLabel: string | undefined readonly grokAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly agentMode: AgentMode | undefined readonly agentAuto: boolean /** @@ -95,6 +96,7 @@ export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateComm dockerNetworkMode: input.dockerNetworkMode, dockerSharedNetworkName: input.dockerSharedNetworkName, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, bunVersion: defaultTemplateConfig.bunVersion, agentMode: input.agentMode, agentAuto: input.agentAuto, diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 99c5a058..07515123 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -200,6 +200,7 @@ type CreateBehavior = { readonly force: boolean readonly forceEnv: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean } const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ @@ -208,7 +209,8 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ skipGithubAuth: raw.skipGithubAuth ?? false, force: raw.force ?? false, forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false + enableMcpPlaywright: raw.enableMcpPlaywright ?? false, + enableMcpAndroid: raw.enableMcpAndroid ?? false }) type TokenLabelConfig = { @@ -276,6 +278,7 @@ export const buildCreateCommand = ( ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, enableMcpPlaywright: behavior.enableMcpPlaywright, + enableMcpAndroid: behavior.enableMcpAndroid, agentMode, agentAuto: isAgentAuto, clonedOnHostname: raw.clonedOnHostname diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 036e43df..742a5623 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -33,6 +33,7 @@ export interface RawOptions { readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean + readonly enableMcpAndroid?: boolean readonly archivePath?: string readonly scrapMode?: string readonly wipe?: boolean diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 0a76dd79..de9b9bcf 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -144,6 +144,12 @@ export interface McpPlaywrightUpCommand { readonly runUp: boolean } +export interface McpAndroidUpCommand { + readonly _tag: "McpAndroidUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface ApplyCommand { readonly _tag: "Apply" readonly projectDir: string @@ -159,6 +165,7 @@ export interface ApplyCommand { readonly playwrightRamLimit?: string | undefined readonly gpu?: GpuMode | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined } // CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag @@ -201,6 +208,7 @@ export type Command = | SessionsCommand | ScrapCommand | McpPlaywrightUpCommand + | McpAndroidUpCommand | ApplyCommand | ApplyAllCommand | HelpCommand diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index d165a0a9..b0b45e1e 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -85,6 +85,9 @@ const TemplateConfigInputSchema = Schema.Struct({ enableMcpPlaywright: Schema.optionalWith(Schema.Boolean, { default: () => defaultTemplateConfig.enableMcpPlaywright }), + enableMcpAndroid: Schema.optionalWith(Schema.Boolean, { + default: () => defaultTemplateConfig.enableMcpAndroid + }), bunVersion: Schema.optional(Schema.String), pnpmVersion: Schema.optional(Schema.String), clonedOnHostname: Schema.optional(HostnameSchema) diff --git a/packages/lib/src/shell/errors.ts b/packages/lib/src/shell/errors.ts index 5928f0e8..612216c5 100644 --- a/packages/lib/src/shell/errors.ts +++ b/packages/lib/src/shell/errors.ts @@ -39,6 +39,8 @@ export type DockerIdentityConflictKind = | "serviceName" | "volumeName" | "browserVolumeName" + | "androidContainerName" + | "androidVolumeName" | "bootstrapVolumeName" export type DockerIdentityConflict = { diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 8036ef28..3d50a423 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -214,6 +214,16 @@ const provisionDockerGitSessionSyncTool = ( ) }) +const provisionDockerGitBuildContext = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(provisionDockerGitScripts(fs, path, baseDir)) + yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) + }) + // CHANGE: write generated docker-git files to disk // WHY: isolate all filesystem effects in a thin shell // QUOTE(ТЗ): "создавать докер образы" @@ -267,11 +277,7 @@ export const writeProjectFiles = ( } } - // CHANGE: provision docker-git scripts into project build context - // WHY: Dockerfile COPY scripts/ requires scripts to be in the build context - // REF: issue-176 - yield* _(provisionDockerGitScripts(fs, path, baseDir)) - yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) + yield* _(provisionDockerGitBuildContext(fs, path, baseDir)) return created }) diff --git a/packages/lib/src/usecases/actions/create-project-conflicts.ts b/packages/lib/src/usecases/actions/create-project-conflicts.ts index 21365037..1016e3a9 100644 --- a/packages/lib/src/usecases/actions/create-project-conflicts.ts +++ b/packages/lib/src/usecases/actions/create-project-conflicts.ts @@ -16,7 +16,7 @@ type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor. type DockerIdentityOwner = Pick< TemplateConfig, - "containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright" + "containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright" | "enableMcpAndroid" > type DockerIdentityNamespace = "container" | "composeProject" | "volume" @@ -52,14 +52,30 @@ const resolveBrowserVolumeClaims = ( ? [{ namespace: "volume", kind: "browserVolumeName", name: `${config.volumeName}-browser` }] : [] +const resolveAndroidContainerClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => + config.enableMcpAndroid + ? [{ namespace: "container", kind: "androidContainerName", name: `${config.containerName}-android` }] + : [] + +const resolveAndroidVolumeClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => + config.enableMcpAndroid + ? [{ namespace: "volume", kind: "androidVolumeName", name: `${config.volumeName}-android` }] + : [] + const resolveDockerIdentityClaims = ( config: DockerIdentityOwner ): ReadonlyArray => [ { namespace: "container", kind: "containerName", name: config.containerName }, ...resolveBrowserContainerClaims(config), + ...resolveAndroidContainerClaims(config), { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, { namespace: "volume", kind: "volumeName", name: config.volumeName }, ...resolveBrowserVolumeClaims(config), + ...resolveAndroidVolumeClaims(config), { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } ] diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts index bf10b913..86194525 100644 --- a/packages/lib/src/usecases/apply-overrides.ts +++ b/packages/lib/src/usecases/apply-overrides.ts @@ -12,7 +12,8 @@ const applyOverrideKeys = [ "playwrightCpuLimit", "playwrightRamLimit", "gpu", - "enableMcpPlaywright" + "enableMcpPlaywright", + "enableMcpAndroid" ] satisfies ReadonlyArray export const hasApplyOverrides = (command: ApplyCommand): boolean => @@ -58,6 +59,9 @@ const applyResourceOverrides = (template: TemplateConfig, command: ApplyCommand) if (command.enableMcpPlaywright !== undefined) { next = { ...next, enableMcpPlaywright: command.enableMcpPlaywright } } + if (command.enableMcpAndroid !== undefined) { + next = { ...next, enableMcpAndroid: command.enableMcpAndroid } + } return next } diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 111e0f90..172e37e6 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -126,6 +126,8 @@ const formatDockerIdentityConflictKind = ( serviceName: "compose project name", volumeName: "volume name", browserVolumeName: "browser volume name", + androidContainerName: "android container name", + androidVolumeName: "android volume name", bootstrapVolumeName: "bootstrap volume name" })[kind] diff --git a/packages/lib/src/usecases/mcp-android.ts b/packages/lib/src/usecases/mcp-android.ts new file mode 100644 index 00000000..96b106de --- /dev/null +++ b/packages/lib/src/usecases/mcp-android.ts @@ -0,0 +1,90 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import type { McpAndroidUpCommand, TemplateConfig } from "../core/domain.js" +import { readProjectConfig } from "../shell/config.js" +import { ensureDockerDaemonAccess } from "../shell/docker.js" +import type { + ConfigDecodeError, + ConfigNotFoundError, + DockerAccessError, + DockerCommandError, + FileExistsError, + PortProbeError +} from "../shell/errors.js" +import { writeProjectFiles } from "../shell/files.js" +import { ensureCodexConfigFile } from "./auth-sync.js" +import { runDockerComposeUpWithPortCheck } from "./projects-up.js" + +type McpAndroidFilesError = ConfigNotFoundError | ConfigDecodeError | FileExistsError | PlatformError +type McpAndroidFilesEnv = FileSystem | Path + +const enableInTemplate = (template: TemplateConfig): TemplateConfig => ({ + ...template, + enableMcpAndroid: true +}) + +// CHANGE: enable Android MCP in an existing docker-git project directory (files only) +// WHY: allow adding the Android emulator sidecar + android-connection MCP config without wiping env or volumes +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall p: enable(p) -> template(p).enableMcpAndroid = true +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: does not rewrite .orch/env/project.env (only managed templates + docker-git.json) +// COMPLEXITY: O(n) where n = |managed_files| +export const enableMcpAndroidProjectFiles = ( + projectDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const config = yield* _(readProjectConfig(projectDir)) + const wasAlreadyEnabled = config.template.enableMcpAndroid + const updated = wasAlreadyEnabled ? config.template : enableInTemplate(config.template) + + yield* _( + wasAlreadyEnabled + ? Effect.log("Android MCP is already enabled for this project.") + : Effect.log("Enabling Android MCP for this project (templates only)...") + ) + + yield* _(writeProjectFiles(projectDir, updated, true)) + yield* _(ensureCodexConfigFile(projectDir, updated.codexAuthPath)) + + return updated + }) + +export type McpAndroidUpError = + | McpAndroidFilesError + | DockerAccessError + | DockerCommandError + | PortProbeError + +type McpAndroidUpEnv = McpAndroidFilesEnv | CommandExecutor + +// CHANGE: enable Android MCP in an existing project dir and bring docker compose up +// WHY: upgrade already created containers to support Android automation without forcing full recreation flows +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall p: up(p) -> running(p-android) OR docker_error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: volumes are preserved (no docker compose down -v) +// COMPLEXITY: O(command) +export const mcpAndroidUp = ( + command: McpAndroidUpCommand +): Effect.Effect => + Effect.gen(function*(_) { + const updated = yield* _(enableMcpAndroidProjectFiles(command.projectDir)) + + if (!command.runUp) { + return updated + } + + yield* _(ensureDockerDaemonAccess(command.projectDir)) + return yield* _(runDockerComposeUpWithPortCheck(command.projectDir)) + }) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index 3c39f51a..5fbdcebe 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -227,6 +227,7 @@ describe("applyProjectFiles", () => { cpuLimit: "2", ramLimit: "4g", enableMcpPlaywright: true, + enableMcpAndroid: true, gpu: "none", }) ) @@ -236,6 +237,7 @@ describe("applyProjectFiles", () => { expect(appliedTemplate.cpuLimit).toBe("2") expect(appliedTemplate.ramLimit).toBe("4g") expect(appliedTemplate.enableMcpPlaywright).toBe(true) + expect(appliedTemplate.enableMcpAndroid).toBe(true) const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(composeAfter).toContain('GITHUB_AUTH_LABEL: "AGIEN_MAIN"') @@ -247,10 +249,19 @@ describe("applyProjectFiles", () => { expect(composeAfter).toContain('memswap_limit: "8192m"') expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"') expect(composeAfter).toContain("dg-test-browser") + expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(composeAfter).toContain("dg-test-android") + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c --locked --bins --root /usr/local" + ) const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) expect(configAfter).toContain('"cpuLimit": "2"') expect(configAfter).toContain('"ramLimit": "4g"') + expect(configAfter).toContain('"enableMcpAndroid": true') }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/usecases/mcp-android.test.ts b/packages/lib/tests/usecases/mcp-android.test.ts new file mode 100644 index 00000000..6a4fdbdc --- /dev/null +++ b/packages/lib/tests/usecases/mcp-android.test.ts @@ -0,0 +1,141 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../src/core/domain.js" +import { enableMcpAndroidProjectFiles } from "../../src/usecases/mcp-android.js" +import { prepareProjectFiles } from "../../src/usecases/actions/prepare-files.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-mcp-android-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + skipGithubAuth: false, + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + enableMcpAndroid: false, + gpu: "none", + bunVersion: "1.3.11" +}) + +const makeProjectConfig = ( + outDir: string, + enableMcpAndroid: boolean, + path: Path.Path +): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + skipGithubAuth: false, + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(outDir, ".docker-git"), + authorizedKeysPath: path.join(outDir, "authorized_keys"), + envGlobalPath: path.join(outDir, ".orch/env/global.env"), + envProjectPath: path.join(outDir, ".orch/env/project.env"), + codexAuthPath: path.join(outDir, ".orch/auth/codex"), + codexSharedAuthPath: path.join(outDir, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + enableMcpAndroid, + bunVersion: "1.3.11" +}) + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const readEnableMcpAndroidFlag = (value: unknown): boolean | undefined => { + if (!isRecord(value)) { + return undefined + } + + const template = value.template + if (!isRecord(template)) { + return undefined + } + + const flag = template.enableMcpAndroid + return typeof flag === "boolean" ? flag : undefined +} + +describe("enableMcpAndroidProjectFiles", () => { + it.effect("enables Android MCP for an existing project without rewriting env files", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const outDir = path.join(root, "project") + const globalConfig = makeGlobalConfig(root, path) + const withoutMcp = makeProjectConfig(outDir, false, path) + + yield* _( + prepareProjectFiles(outDir, root, globalConfig, withoutMcp, { + force: false, + forceEnv: false + }) + ) + + const envProjectPath = path.join(outDir, ".orch/env/project.env") + yield* _(fs.writeFileString(envProjectPath, "# custom env\nCUSTOM_KEY=1\n")) + + yield* _(enableMcpAndroidProjectFiles(outDir)) + + const envAfter = yield* _(fs.readFileString(envProjectPath)) + expect(envAfter).toContain("CUSTOM_KEY=1") + + const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) + expect(composeAfter).toContain("dg-test-android") + expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') + expect(composeAfter).toContain("/dev/kvm") + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain("android-tools-adb") + expect(dockerfileAfter).toContain( + "cargo install --git https://github.com/ProverCoderAI/rust-android-connection --rev 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c --locked --bins --root /usr/local" + ) + expect(dockerfileAfter).toContain("/usr/local/bin/android-connection --version") + expect(dockerfileAfter).not.toContain(".docker-git-tools/android-connection") + + const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) + const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText))) + expect(readEnableMcpAndroidFlag(configAfter)).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/openapi/openapi.json b/packages/openapi/openapi.json index 1e797e52..5d9d6c4d 100644 --- a/packages/openapi/openapi.json +++ b/packages/openapi/openapi.json @@ -1081,6 +1081,9 @@ "enableMcpPlaywright": { "type": "boolean" }, + "enableMcpAndroid": { + "type": "boolean" + }, "outDir": { "type": "string" }, diff --git a/packages/openapi/src/openapi-paths.ts b/packages/openapi/src/openapi-paths.ts index 21e1bdd6..c38bc92f 100644 --- a/packages/openapi/src/openapi-paths.ts +++ b/packages/openapi/src/openapi-paths.ts @@ -1188,6 +1188,7 @@ export interface operations { dockerNetworkMode?: string; dockerSharedNetworkName?: string; enableMcpPlaywright?: boolean; + enableMcpAndroid?: boolean; outDir?: string; gitTokenLabel?: string; skipGithubAuth?: boolean;