Repository navigation
[Feature]: Support interactive postStartCommand input #1569
Description
Problem or motivation
Dev container projects may use postStartCommand for setup that asks the user for input. Devsy's current lifecycle execution path does not connect stdin or allocate a terminal, so users cannot answer those prompts.
Source inspection shows that executeAndCapture in pkg/devcontainer/setup/lifecyclehooks.go calls subprocess.Run without setting Options.Stdin. The helper in pkg/subprocess/capture.go assigns that unset value to cmd.Stdin and captures stdout/stderr without a PTY.
An example to validate is:
{
"image": "mcr.microsoft.com/devcontainers/base:debian",
"postStartCommand": [
"bash",
"-c",
"read -r -p 'Name: ' name && echo \"Hello, $name\""
]
}When starting the workspace from an interactive terminal, the user should be able to see the prompt, enter a name, and see the greeting. With stdin disconnected, this command cannot read the user's answer.
This request follows specification and source review; the example has not yet been run in a container.
Proposed solution
Support interactive postStartCommand execution when a user starts a workspace from an attached terminal. Forward user input, display command output as it runs, and provide a PTY when needed.
Define and document how this behaves when lifecycle hooks run in the background or no interactive terminal is attached, including through the desktop app. Account for the existing waitFor behavior and parallel object-form commands so prompts do not silently wait for inaccessible input.
Alternatives considered
- Keep startup scripts noninteractive and supply configuration through arguments, environment variables, files, or secrets.
- Run interactive setup manually after attaching. Moving the same prompt to
postAttachCommandalone does not solve the missing input connection in the shared runner.
Area
CLI, Dev containers
Acceptance criteria
- A user starting a workspace from an attached terminal can answer a prompt in
postStartCommandand see its output. - Commands requiring a terminal can run through a supported PTY execution path.
- Behavior is documented for background/headless execution and desktop launches.
- Interaction with
waitForand parallel lifecycle commands is defined without silently waiting on input the user cannot provide. - Existing noninteractive hooks continue to work, with coverage for input forwarding and terminal handling.
Additional context
The Dev Container specification defines when postStartCommand runs and its command syntax. It neither prohibits interactive commands nor guarantees stdin or a terminal, so this is an implementation capability rather than a specification compliance requirement.
Activity
skevetter commentedon Oct 31, 2026
Implementation plan: attached-terminal input for postStartCommand
Status: not implemented. The current hook runner still provides neither stdin nor a PTY. No matching implementation PR was found in searches for this issue and interactive post-start work. Keep this issue as the implementation tracker.
Reviewed 30 October 2026 against
main@9bc12ee041ca854c6f81de95fd8c6e49d7cc028c. The findings below come from source inspection; the interactive customer example has not been executed in this audit.1. Verified architecture and failure mechanism
executeAndCapturecallssubprocess.Runwith working directory, environment and redactor, but no stdin.lifecyclehooks.go:614-659cmd.Stdinand captures both output streams.capture.goRunPreAttachHooks, worker launchexecuteLifecycleHookrunHooktunnel.protopkg/ptyalready starts PTYs, exposes input/output, resizes and waits. Existing SSH code owns/restores host terminal modes.--terminal-columnsand--terminal-rowsare already registered for lifecycle scripts.up_flags.go:96-112workspace up --detachand follows task logs.ipc.ts:2113-2166daemonclient/up.goThe Dev Container lifecycle specification defines phase ordering, command forms and
waitFor; it does not promise stdin or a TTY. The reference CLI does request a PTY for lifecycle execution. This is a supported execution capability, not a reason to change the devcontainer schema.2. Product behavior and bounded first-release scope
Add one explicit CLI option:
The option connects the attached terminal to
postStartCommandin the normal startup sequence. It does not replay hooks after a completed launch. It supports string, argv-array and single-named-command forms, including sequential merged post-start hooks. No new property is required indevcontainer.json.Use opt-in behavior because automatic input on every TTY invocation would change existing automation and the default foreground/deferred split.
waitForpostStartCommandusing the existingPreAttachOptions.WaitFormechanism. Earlier phases still run in order. Do not edit or persist a different projectwaitFor.waitFor--detach, detached worker, headless CI/API, prebuild, recovery,--skip-post-start, or--skip-non-blocking-commandstogether with this optionCIalone is not a capability test; the attached-PTY acceptance harness may run inside CI.DEVSY_UI=true, or missing terminal stdin/stdout-tflag.The Desktop app keeps its current detached launch behavior. Document that the first release's interactive create/start is performed in an attached CLI terminal, after which the workspace can be opened from Desktop. Existing Desktop launches remain noninteractive. Adding a persistent interactive setup panel would require a separate UI/session ownership design; it is not needed to satisfy this CLI capability.
There is no generic way to identify every program that expects input before running it. The guarantee is that an explicitly requested interactive session is either available or rejected. The ordinary noninteractive path keeps its existing stdin behavior; do not invent a heuristic that recognizes prompts by scanning output.
3. Terminal stream contract
Add a bidirectional
LifecycleTerminalRPC topkg/agent/tunnel/tunnel.proto, with generated Go and gRPC files. Use distinct request/response messages withoneofframe types.Identity and ownership
ContainerWorkspaceInfocurrently has no UID/context andWorkspace.Contextis excluded from JSON, so these values cannot be reconstructed inside the container from the existing serialized types. Do not substitute the runner ID, which can be a legacy workspace ID, for UID. Runtime terminal readers/writers remain process-local.FailedPrecondition; a wrong nonce/workspace returnsPermissionDenied. The session cannot ask the CLI to execute a command.Frames and ordering
openreadybeginpostStartCommand, merged index and optional named key. Labels are data, never executable text.beginAckoutputinputresizecancelexitexitAckcompleteHandshake and acknowledgements have five-second bounds. The active input wait has no arbitrary user-thinking timeout. Parent context cancellation and broken transport end the session. Unexpected EOF of the host input reader is a disconnect/cancel condition. Ctrl-D is ordinary terminal input, handled by the PTY line discipline; do not interpret it as a gRPC half-close.
Cap each data frame at 32 KiB and queued data in either direction at 256 KiB. Use flow control/backpressure rather than dropping input or prompt bytes. Limit textual identities and TERM to bounded lengths, validate enum values and sequence/state transitions, and reject oversized/out-of-order frames. Do not retain the entire transcript in memory. Close and join all owned read/write goroutines on every terminal path.
Two-hop forwarding
The container agent connects to the provider-host setup server; that server forwards the stream over its existing upstream tunnel client to the initiating CLI. Neither hop consumes its own process stdin as user input. The outer agent stdio remains gRPC/SSH protocol traffic, and terminal frames never enter
Log,Status,SendResultor the Desktop envelope parser.4. File-level implementation tasks
A1. Add the explicit option and fail before side effects
pkg/flags/names/names.go: add the canonicalinteractive-post-startname and its name test.cmd/workspace/up/up_flags.go:registerLifecycleFlags: register the boolean, default false. Reuse existing terminal dimensions rather than adding width/height aliases.cmd/workspace/up/up.go,up_validate.go,detach.go: validate flag conflicts beforerunDetachedcan submit a task and beforeprepareClientcan start provider work. Validate again in public non-Cobra/headless entry points. Accept only attached stdin/stdout and resolved plain result presentation. Do not turn JSON into plain output silently.cmd/workspace/up/agent.go:dispatchClient: reject the explicit mode for proxy/daemon/platform task routes before theirUpcall. For the supported classic workspace route, create and own the terminal endpoint for this one invocation.pkg/provider/workspace.go:CLIOptions: add an optional typed invocation request containing interactive intent, protocol version, nonce, originating context and workspace UID, withomitempty. Keep terminal handles and cancellation functions out of this serializable type. Audit copies inup_client.go,buildWorkspaceOptions,withoutSecretValuesand setup compression so the complete request survives only the current handoff. Both forwarding endpoints compare it with their bound invocation.pkg/agent/agent.go:writeWorkspaceInfoalready clears the clonedCLIOptionsbefore persistence; preserve that invariant and test it.compressWorkspaceConfigmust still carry the live invocation into setup, without saving it as a later default.--lifecycle-terminal-protocol=1argument to the workspace-agent and container-setup commands only when this mode is requested. Register and validate it incmd/internal/agentworkspace/up.goandcmd/internal/agentcontainer/setup.go, checking it agrees with the serialized intent. Older agents must reject this unknown internal flag before executing hooks; silently ignoring an unfamiliar JSON field is not a capability check.A2. Implement the additive transport and host owner
pkg/agent/tunnel/tunnel.protoand generated files: implement the typed contract above without changing existing field numbers or RPC semantics.pkg/agent/tunnelserver/lifecycle_terminal.go: implement the server adapter and bounded forwarding adapter. Add focused options inoptions.gofor a local terminal owner or an upstream tunnel client. An unconfigured server explicitly rejects the new RPC.cmd/workspace/up/agent.go:devsyUpMachineSSH: pass the terminal owner to the outerRunUpServer; append the internal protocol guard to the generated agent-workspace command with the existing shell-quoting convention.cmd/internal/agentworkspace/up.go:devsyUp: pass its existingtunnelClientthrough a runtime-only field indevcontainer.UpOptions. Inpkg/devcontainer/run.go, retain that field only for the currentUpcall and clear it on exit; do not serialize it.pkg/devcontainer/setup.go:executeSetup: add the terminal forwarding option toRunSetupServerusing that upstream client. InbuildSetupCommand, add the internal protocol guard for interactive requests. Preserve the existing raw transport streams and startup watchdog.cmd/workspace/up/interactive_post_start.go: own the local session state, one stdin reader, raw-mode entry/restoration, output writer, resize forwarding and cancellation. Reusepkg/pty.MakeInputRaw,MakeOutputRaw,RestoreTerminal, andpkg/ssh.WatchWindowSizebehavior already used by machine SSH.pkg/terminalfor Unix and Windows. Use an owned descriptor/handle and cancellation signaling with the existing OS dependencies; closing it must unblock its read without closing caller-ownedos.Stdin. Native tests must establish that no goroutine remains blocked and no old reader consumes input after the session ends.--terminal-rows/--terminal-columnsas explicit requested dimensions; zero means automatic. Reject negative or greater-than-65535 values. Auto dimensions follow terminal resize; explicit dimensions remain fixed. Use 80x24 only when a genuinely attached terminal cannot report a size. TERM defaults toxterm-256colorwhen absent and is passed as one environment value.exit, including failures. Respect merged--no-statusbehavior: it must never suppress terminal bytes. Existing logger diagnostics remain on their diagnostic stream.A3. Integrate scheduling and prevent duplicate execution
cmd/internal/agentcontainer/setup.go:finalizeSetup: create the bounded stream adapter from the current setup tunnel. Validate all merged post-start hook shapes and reserve the terminal beforeSetupContainerPreAttachcan run lifecycle hooks or create their markers.pkg/devcontainer/setup/setup.go:ContainerSetupConfigandlifecyclehooks.go:PreAttachOptions: add a process-local interactive executor/session reference. When enabled, raise the currentWaitForfloor toPhasePostStartusingresolveWaitFor. Leave the storedMergedConfig.WaitForunchanged.validateInteractivePostStarthelper that checks every merged post-start entry. Permit zero/one nonempty named command per entry; reject a multi-command object before running any container lifecycle phase. Include merged index/key in the error and preserve ordinary map parallelism.preAttachPhaseParamsandrunSingleHookCommand, attach the interactive executor only to post-start. Other phases keep their existing executor and environment resolution.shouldSkipHookandContainerDetails.State.StartedAtmarker behavior. A normal reopen of the same running container must not prompt again. A normal stop/start produces a new start identity and runs post-start once. Transport loss does not authorize deleting a marker or automatically retrying a possibly non-idempotent hook.setupPostAttach,buildDeferredHooksCmd,deferred_hooks.goandpost_attach.go: never carry a terminal owner or nonce into detached workers. Post-start is already consumed by the foreground floor. Preserve worker readiness/activity protection, including the recent Kubernetes inactivity ownership changes.A4. Run the selected hook in the existing PTY implementation
pkg/devcontainer/setup/lifecycle_terminal.gofor the interactive executor. Factor command interpretation from the user-switch wrapper inbuildCommandArgs, retaining its string/argv quoting and shell-selection semantics, resolved remote user, workspace directory and lifecycle environment. Do not execute a reconstructed display string. Keep the normal noninteractive runner unchanged.su <user> -c ...; util-linux documents that this creates a new session without a controlling terminal. Wrapping it unchanged in a PTY does not establish the proposed foreground-signal/process-group contract. Resolve the target UID, primary GID and supplementary groups, and applysyscall.Credentialbefore the PTY child starts when switching identities is required. Factor reusable credential-resolution logic frompkg/ssh/server/exec_identity_unix.goif appropriate; do not call its wholeprepareExecIdentityhelper, which also changes the working directory and environment. When identity already matches, retain the rootless path without unnecessary credential syscalls. Preserve the target-account shell behavior currently selected bysufor a different user, and preserve the current same-user command interpretation. Verify HOME/USER/LOGNAME/SHELL, remoteEnv precedence, workspace directory and supplementary groups against the existing lifecycle contract. Direct credential execution does not invoke PAM; document that opt-in compatibility boundary rather than promising all implementation-specificsu/PAM side effects. Resolve known identity/shell errors before the lifecycle marker and never silently run as the setup agent's user.remoteEnv, workspace-env, user probe and secret resolution behavior. A value containing=must remain intact.pty.WithPTYOption(pty.WithSSHRequest(...))with the accepted initial window, aspkg/ssh/server/exec.godoes;pkg/pty/ssh_other.go:applyTerminalModesToFdapplies that window during allocation. A firstResizeafterpty.Startraces a fast command's initial terminal-size read. Then pump literal combined output to the bounded stream, forward input toInputWriter, and apply later validated resize events viaResize.executeAndCaptureso ordinary and PTY runners produce consistent phase/key/exit context.pty.Process.Signal/Killaddresses the direct process, andpty.Cmd.AsExecusesexec.CommandContext, whose default cancellation immediately kills that child. Add a lifecycle-only PTY start/cancellation option that replacesexec.Cmd.CancelbeforeStartand records the newly created process group. The same owner handles parent-context cancellation and transport disconnect exactly once: send SIGTERM to the owned group, allow two seconds for exit, then SIGKILL the remaining owned group. Do not leave the default direct-child kill racing this grace period. Keep the child attached to the parent's cancellation lifetime and preserve the original context error. Do not signal container PID 1 or unrelated processes, and leave ordinary SSH PTY startup/cancellation unchanged. The guarantee covers the owned process group; explicitly self-daemonizing commands that escape it are outside this terminal-session contract.subprocess.Options.Stdout, which is an existing functional/protocol sink.A5. Documentation and Desktop compatibility
sites/docs-devsy-sh/content/docs/developing-in-workspaces/create-a-workspace.mdxanddevcontainer-json.mdxwith the new option, the issue'sreadexample, effective wait boundary, supported command forms, normal restart versus same-start reopen, and attached-terminal requirement.continuous-integration.mdxand troubleshooting documentation to state that headless CI, Desktop detached launches and management tasks remain noninteractive. Offer argument/environment/secret-based setup as the existing unattended path. Moving the prompt to post-attach alone does not supply input.workspace task logs.combinedprovenance and the same redaction/bounds as other retained output. Input frames are excluded. This capability does not depend on log retention being finished and does not rerun commands when logs are opened.5. Validation and acceptance
Use signed Draft PRs and the existing CI suites. Tests need to cross the process and transport boundaries that cause the defect.
CI; default invocation remains unchanged.test -t 0andtest -t 1, can open/dev/tty, prints a prompt without newline, receives a supplied answer, and writes a success marker. Verify the very first size read and live resize. Run same-user/rootless and different named-remoteUser cases, checking shell/environment, working directory and supplementary groups.Extend
pkg/devcontainer/setup/lifecyclehooks_test.goand add focusedlifecycle_terminal_test.go. Use existingpkg/pty/ptytestfor process-level terminal assertions; add actual Unix/Windows local-input ownership tests rather than relying only on mock readers. Transport tests belong besidepkg/agent/tunnelserverwith a real bounded gRPC stream and two-hop forwarding fixture.In
e2e/tests/up, add a fixture undertestdata/interactive-post-start/.devcontainerand a focused Ginkgo test that launches the actual CLI inside a PTY, waits for the prompt, writes an answer, and verifies the marker through a separate normal workspace command. Run this on the existing Docker CI leg, then one SSH-host route and one Kubernetes route. Use the existing suite manifest inhack/ci/suites.yaml; a new label must map to an executable job. Include a Compose case because lifecycle/config merging follows a separate container-start route. Native Windows/macOS tests must prove host terminal restoration and input-reader cancellation; a Linux-only fake is not that evidence.Customer completion flow: from an attached terminal, start the issue's devcontainer with
--interactive-post-start, seeName:, enter a name, see the greeting and reach workspace-ready. Confirm normal reopen does not repeat the same start hook, then stop/start and answer once again. Confirm an unattended/desktop launch does not acquire an invisible input session. The issue is complete when this flow and the stated transport/compatibility cases pass in the published CLI/Desktop bundle version.