/** * Runner for the `server_setup_exec` command (git-artifact-platform model). * * The payload is `{ executionId, sshHostId, body }` where `body` is a recipe * body: a top-level YAML **list of Ansible tasks** authored by a tenant admin * (built-in steps appear as `include_role` tasks referencing the 6 bundled * roles). This runner: * 1. re-validates `body` with the authoritative task guard * (`validateAnsibleTasks`), route-aware (`ecs` strict / `resident` * lenient) — see `resolveRouteMode`, * 2. fetches the target host's SSH private key Just-In-Time from the API, * 3. generates a single enclosing play (`hosts`/`become`/`gather_facts` all * fixed by the agent, never by the caller) around system-generated * precheck tasks (explicit fact-gathering, a NOPASSWD-sudo probe, an OS * check, and an `acl`-package install so become_user temp-file handoff * works) plus the validated body tasks, and runs it with * `ansible-playbook`, * 4. reports **per-task** results, and — critically — always removes the * temp directory (private key included) afterwards, whether the run * succeeded or failed. * * SECURITY: the SSH private key must never be logged, and the temp directory * holding it must never survive past this function's execution. */ import { type CommandResult, type ServerSetupExecPayload, type SelfRestartDeclarationAck, type ServerSetupProgressEvent, type ServerSetupTaskResult, type ServerSetupVariablesResponse, type SshExecCredential } from '../types'; import { type AnsibleProgressEvent } from './progress-tailer'; import { redactSecretValues } from '../utils/secret-redaction'; import { type AnsibleTaskRouteMode } from './ansible-task-guard'; import type { ApiClient } from '../api-client'; /** * `register` name for `SUDO_PRECHECK_PROBE_TASK`'s result. Ansible's `-e` * extra-vars (which is exactly how project `ANSIBLE#` variables reach the * play — see `runServerSetup`'s `extra-vars.json` write) **always** win over * a `register`ed variable, regardless of evaluation order. If a tenant admin * happened to name a project variable the same as this register target, its * value would silently shadow the probe result and corrupt * `SUDO_PRECHECK_ASSERT_TASK`'s `when` condition. This name is namespaced and * long enough to make an accidental collision practically impossible, and * `validateNoReservedVariableCollision` rejects the run outright (rather than * silently misbehaving) on the astronomically unlikely case a project * variable is named exactly this. */ export declare const SUDO_PROBE_REGISTER_VAR = "__ai_support_agent_server_setup_sudo_probe"; /** * Reserved extra-var carrying **this agent process's** replica instance id * (`resolveInstanceId()`: `AI_SUPPORT_AGENT_INSTANCE_ID`, else `HOSTNAME`). * * The `ai_support_agent_k8s` bundled role deploys agents as StatefulSets, and a * recipe may legitimately include the very agent that is executing it. Since a * StatefulSet Pod is named `-`, the role compares * this value against each entry's `name` to recognize "this project is myself" * and defers its own `kubectl rollout restart` until every other project has * been deployed. Without it, the first self-targeting entry restarts this Pod * mid-run: the ansible process dies with the container, the remaining projects * are never deployed, and the server-side execution stays `running` because * nothing is left alive to report a result (observed on the MBC k3s cluster). * * Written to `extra-vars.json` **after** the tenant's project (`ANSIBLE#`) * variables, exactly like {@link SHARED_FILE_STAGING_DIR_VAR}: extra-vars * outrank every other Ansible precedence level, so key order inside the file is * the whole game — a project variable of this name must not be able to make the * role believe it is looking at a different agent (which would re-enable the * kill-yourself-first behavior this defends against). */ export declare const SELF_INSTANCE_ID_VAR = "ai_support_agent_k8s_self_instance_id"; /** * Reserved extra-vars naming the two sides of the self-restart handshake. * * When the `ai_support_agent_k8s` role is about to apply/restart the agent that * is executing the play (tasks/self.yml), the run ends without ever reporting a * result — the Pod is replaced mid-play. Before it gets there the role writes * {@link SELF_RESTART_MARKER_VAR} on the controller (this agent's own * filesystem) and waits for {@link SELF_RESTART_ACK_VAR} to appear; the agent * declares "awaiting self restart" to the API and only then writes the ack. The * wait is what makes the report happen *before* the restart rather than racing * it (see self-restart-declaration.ts). * * Both keys are written to `extra-vars.json` **after** the tenant's project * (`ANSIBLE#`) variables and are always present — empty when nothing is * watching. Always-present matters twice over: extra-vars outrank every other * precedence level, so an empty value both switches the handshake off (the role * skips it instead of waiting for an ack nobody will write) and stops a recipe * from supplying a path of its own — which would turn the role's `copy` into an * arbitrary-write primitive on the agent host. */ export declare const SELF_RESTART_MARKER_VAR = "ai_support_agent_k8s_self_restart_marker_file"; export declare const SELF_RESTART_ACK_VAR = "ai_support_agent_k8s_self_restart_ack_file"; export interface RunServerSetupContext { commandId: string; client: ApiClient; agentId?: string; } /** * Resolve the authoritative route mode for the guard, **fail-closed**. * * The lenient `resident` allowlist is only ever selected when the execution is * *positively* known to be the customer's own closed-network resident agent. * Anything else — including any state we cannot positively classify — uses the * strict `ecs` allowlist. Resolution order: * * 1. **`payload.dispatchMode` (authoritative)**: the api dispatch service * (`ServerSetupDispatchService`) now stamps every `server_setup_exec` * command with `dispatchMode`: `ecs_oneshot`=当社基盤 → strict `ecs`; * `resident_agent`=顧客の自機・閉域 → lenient `resident`. When present it is * always preferred over the local environment. * 2. **`AGENT_MODE` (fallback, only when `dispatchMode` is absent)**: a payload * from an older/tampered api build may omit `dispatchMode`. The local * `AGENT_MODE=oneshot` (set via `containerOverrides` on our ECS controller) * still positively confirms the strict `ecs` route. `AGENT_MODE` is **never** * a positive signal for `resident` — a resident agent has no env value that * proves it is one — so it can only ever confirm `ecs`. * 3. **Fail closed**: an absent/unknown `dispatchMode` combined with a * non-oneshot/unset `AGENT_MODE` cannot be positively resolved to * `resident`, so it defaults to the strictest `ecs` allowlist. (The previous * implementation defaulted the other way — to lenient `resident` — which * fail-*open*ed the guard whenever the env was misconfigured or the payload * tampered.) */ export declare function resolveRouteMode(payload?: Pick | null, env?: NodeJS.ProcessEnv): AnsibleTaskRouteMode; /** * JIT fetch of project (`ANSIBLE#`-prefixed `ConfigSetting`) variables for this * `server_setup_exec` command's Ansible tasks. Thin wrapper around * `ApiClient.getServerSetupVariables` — kept as its own function (rather than * calling the client inline in `runServerSetup`) so it can be unit-tested in * isolation and so its call site reads the same way as * `getServerSetupSshCredential`'s. * * The returned `secretNames` feed both `validateAnsibleTasks`'s `no_log` * annotation and this module's post-execution redaction (see * `redactSecretValues`) — the belt-and-suspenders fallback for a task that * somehow still printed a secret's plaintext despite `no_log`. */ export declare function fetchServerSetupVariables(client: ApiClient, commandId: string, agentId: string): Promise; /** * Build the playbook YAML for a run: a single play with agent-fixed * `hosts`/`become`/`gather_facts`, whose `tasks` are the system-generated * prechecks — explicit fact-gathering, a NOPASSWD-sudo probe/assert, the OS * check, then the `acl`-package install (so become_user temp-file handoff * works in the bundled roles) — followed by the tenant admin's validated * (+`no_log`-annotated) body tasks. The caller never supplies play-level keys * (the guard rejects any `hosts`/`roles`/`vars_files` element), so the play * here cannot be hijacked. */ export declare function generatePlaybook(bodyTasks: readonly Record[]): string; export { redactSecretValues }; /** * Reject a fetched SSH credential whose hostname/username/port isn't a plain, * unambiguous value. Without this, a hostname or username containing e.g. a * space or an embedded `ansible_connection=local` could — once written into * the inventory — be parsed as *additional* inventory variables for the host, * redirecting the `become: true` playbook run away from the intended target. * * When `connectionType === 'tailscale'`, `tailnetHostname` is what actually * gets written to `ansible_host` (see `buildInventory`), so it is held to the * exact same `HOSTNAME_RE` standard as `hostname`. `socksPort`, when present, * is validated as a port number the same way `port` already is. */ export declare function validateSshCredential(credential: SshExecCredential): string | null; export declare function toProgressPayload(events: AnsibleProgressEvent[], secretValues: string[]): ServerSetupProgressEvent[]; interface ParsedAnsibleOutput { taskResults: ServerSetupTaskResult[]; /** * True when `rawOutput` was empty or not valid JSON at all — as opposed to * parsing successfully into an object that merely lacks tasks. * `runServerSetup` must never report `successResult` on top of this: an * empty/unparseable stdout despite a `0` exit code means the `json` callback * plugin never ran, so there is no reliable signal that anything actually * happened. */ outputUnparseable: boolean; } /** * Parse the `ansible-playbook --stdout-callback=json`-style output into one * result **per task** (the previous per-stepType grouping is gone). Tasks with * no name and no host results are skipped entirely. */ export declare function parseAnsibleOutput(rawOutput: string): ParsedAnsibleOutput; /** * Sweep orphaned server-setup temp dirs (SSH private key, inventory, * extra-vars.json, generated playbook — see `runServerSetup`'s doc comment). * * `runServerSetup` always removes its own temp dir in a `finally` block, but * that `finally` never runs if the resident agent process itself is * SIGKILL'd / OOM-killed / crashes / is forcibly restarted mid-run — the * private-key-holding dir is then orphaned in `/tmp` forever. Over a resident * agent's long uptime these accumulate and can exhaust `/tmp`, causing * `mkdtempSync` itself to fail with ENOSPC (same failure mode already fixed * for `terminal-sandbox-*` dirs by `TerminalSession.cleanupStaleSandboxes` * and for per-command MCP config files by `cleanupStaleCommandMcpConfigs` — * this mirrors that same pattern here). * * Defaults to only dirs at least 24h old so a concurrently in-flight * `runServerSetup` call on another process is never touched. `maxAgeMs=0` * removes all matching dirs regardless of age. * * A dedicated, fully-namespaced prefix (rather than a short generic one) is * used so this sweep can never mistake an unrelated app's `/tmp` entry for * one of ours. Individual removal failures (e.g. a permissions error) are * logged rather than silently swallowed, since the resource at stake is a * plaintext SSH private key. * * @param maxAgeMs delete dirs at least this old (ms); 0 removes all * @param baseDir directory to scan, overridable so tests never sweep the * real `/tmp` (defaults to `os.tmpdir()`) * @returns number of dirs removed */ export declare function cleanupStaleServerSetupDirs(maxAgeMs?: number, baseDir?: string): number; /** * Fully-resolved inputs for `executeServerSetupAnsible` — everything the core * ansible-execution stage needs after `runServerSetup` has finished route-mode * resolution, payload validation, the JIT credential/variable fetch, and the * reserved-variable-name check. * * This is the seam between the api-driven `server_setup_exec` orchestration * (`runServerSetup`) and the API-, dispatch-, and KMS-free local dev path * (`server-setup-local-run.ts`): both assemble one of these and hand it to * `executeServerSetupAnsible`, so the bundled-path/known_hosts resolution, the * play generation, the authoritative guard re-validation, the ansible-playbook * invocation, secret redaction, and the always-run temp-dir cleanup are * byte-for-byte identical between production and local runs. * * Note: known_hosts resolution is NOT the caller's responsibility — the core * resolves it internally from `tenantCode` + `sshHostId` (see * `executeServerSetupAnsible`), so both callers share the same fail-closed * "never leave a private-key temp dir behind on a resolution failure" behavior. */ export interface ExecuteServerSetupAnsibleInput { /** Used only in log lines to correlate this run; carries no control flow. */ executionId: string; /** The recipe body: a top-level YAML list of Ansible tasks (already guard-validated by the caller). */ body: string; /** Guard allowlist mode — must match the mode the caller validated `body` under. */ mode: AnsibleTaskRouteMode; /** SSH connection parameters (already passed through `validateSshCredential` by the caller). */ credential: SshExecCredential; /** Project (`ANSIBLE#`) variables written verbatim to `extra-vars.json`. */ variables: Record; /** Subset of `variables` names that are secrets — drives `no_log` + post-run redaction. */ secretNames: string[]; /** Tenant code — namespaces the persistent known_hosts file (with `sshHostId`) for TOFU across runs. */ tenantCode: string; /** SSH host id — namespaces the persistent known_hosts file (with `tenantCode`) for TOFU across runs. */ sshHostId: string; /** API work command id used to cancel the ansible child process. */ commandId?: string; /** * API client used to fetch the project's shared files when the body * distributes them (`shared_file` role). Absent on the local dev path, where a * body that needs shared files is rejected instead of silently skipped. */ client?: ApiClient; /** * Sink for mid-run task progress. When omitted (the local dev path) the * progress side-channel is not enabled at all, so ansible writes no progress * file. Rejections are absorbed by the tailer — progress is best-effort and * never affects the run's own result. */ onProgress?: (events: ServerSetupProgressEvent[]) => Promise; /** * Reports that this run is about to restart the agent executing it, so the * server can show that state instead of a plain `running` that only the * two-hour watchdog resolves. Wired on the api-driven path only; without it * the handshake extra-vars stay empty and the role skips the handshake. */ onAwaitingSelfRestart?: () => Promise; } /** * Core ansible-execution stage shared by the api-driven `runServerSetup` and the * local dev `server-setup-local-run`: resolve the bundled roles/callback-plugins * paths, create the per-run temp dir, write the private key / inventory / * extra-vars / generated playbook, re-validate the body with the real * `secretNames`, run `ansible-playbook`, redact secrets from its output, parse * per-task results, and — critically — always remove the temp directory (private * key included) afterwards, whether the run succeeded or failed. * * The caller is responsible for everything upstream of here (route-mode * resolution, payload/credential validation, and the reserved-variable-name * check). known_hosts resolution is done here (from `tenantCode` + `sshHostId`) * rather than by the caller, so both call sites share the identical fail-closed * behavior on a resolution failure. This function also performs the * authoritative guard re-validation itself (fail-closed) — it never trusts the * caller to have gated the body — so the local dev path cannot bypass the task * guard. */ export declare function executeServerSetupAnsible(input: ExecuteServerSetupAnsibleInput): Promise; /** * Execute a `server_setup_exec` command: re-validate the body, fetch the SSH * credential and project variables, generate + run the playbook, and report * per-task results. The temp directory holding the private key is always * removed, on every exit path (in `executeServerSetupAnsible`). */ export declare function runServerSetup(payload: ServerSetupExecPayload, ctx: RunServerSetupContext): Promise; //# sourceMappingURL=server-setup-runner.d.ts.map