Lane runtime isolation turns a lane from "just a worktree" into a full
parallel dev environment: its own port range, its own .localhost
hostname, its own OAuth callback routing, its own health signals, and
optional per-lane env init. Shipped as Phase 5 workstreams W1–W6.
Every service below executes inside the active runtime for the
window's project binding — the local ADE daemon (ade serve) for
local-bound windows or the SSH-attached remote runtime for
remote-bound windows. Port leases, proxy hostname routing, OAuth
callback handling, env init, and runtime diagnostics all run on the
machine that owns the lane's worktree. The desktop main-process copies
under apps/desktop/src/main/services/lanes/ are kept as fallback
implementations only; the canonical ones now live alongside the runtime
services in apps/ade-cli/. The renderer's window.ade.lanes.* APIs
that touch this subsystem (initEnv, getEnvStatus, port.*,
proxy.*, oauth.*, diagnostics.*) are routed through preload's
callProjectRuntimeActionOr("lane", …) helper, which prefers the
runtime daemon and only falls back to in-process handlers when no
runtime is bound.
For remote-bound windows the listening sockets, the *.localhost
proxy, and the OAuth callback URL all live on the remote host. Preview
URLs reflect that hostname.
Services keyed by workstream. Code paths shown for the desktop fallback target; the runtime daemon hosts the canonical instances.
| Service | Workstream | Responsibility |
|---|---|---|
laneEnvironmentService.ts |
W1 | Env file templating, docker services, dependency install, mount points, copy paths |
laneTemplateService.ts |
W2 | CRUD for reusable init recipes, platform-specific setup scripts, default-template selection |
portAllocationService.ts |
W3 | Lease-based port range allocation, conflict detection, orphan recovery |
laneProxyService.ts |
W4 | *.localhost reverse proxy, per-lane hostname routes |
oauthRedirectService.ts |
W5 | OAuth callback routing (see oauth-redirect.md) |
runtimeDiagnosticsService.ts |
W6 | Aggregated health checks (port/process/proxy), fallback mode |
Renderer surfaces:
| Component | Role |
|---|---|
renderer/components/run/LaneRuntimeBar.tsx |
Compact status bar at the top of the Run page for the selected lane |
renderer/components/run/RunPage.tsx |
Runtime dashboard (processes, commands, network) |
renderer/components/run/RunNetworkPanel.tsx |
Proxy + port + preview details |
renderer/components/lanes/LaneEnvInitProgress.tsx |
Per-step env init progress inside CreateLaneDialog |
renderer/components/settings/ProxyAndPreviewSection.tsx |
Settings surface for proxy start/stop, OAuth redirect setup |
renderer/components/settings/DiagnosticsDashboardSection.tsx |
Global diagnostics view |
renderer/components/settings/LaneTemplatesSection.tsx |
Template management |
renderer/components/settings/LaneBehaviorSection.tsx |
Auto-rebase + cleanup policy |
laneEnvironmentService.initializeLane(laneId) runs initialization
steps in order:
env-files— copy/template.envfiles with lane-specific substitutions (port, hostname, API keys). Both source and destination paths are validated against their roots viaresolvePathWithinRoot(symlink-aware) to prevent path traversal.docker— start lane-specific Docker Compose services. Compose file path validated against the project root.dependencies— run install commands from an allowlist:npm,yarn,pnpm,pip,pip3,bundle,cargo,go,composer,poetry,pipenv,bun. Any command outside this set is rejected. Working directories must resolve inside the worktree.mount-points— configure runtime mount points for agent profiles/context. Source and destination validated.copy-paths— same validation as env files; used for copying non-template files from the project root into the worktree.
Each step is reported through LaneEnvInitProgress IPC events with
status (pending | running | done | failed) and a duration.
CreateLaneDialog renders LaneEnvInitProgress inline so the user
can watch the lane bootstrap.
Config types live in src/shared/types/config.ts:
LaneEnvInitConfig— top-level config with arrays of stepsLaneEnvFileConfig,LaneDockerConfig,LaneDependencyInstallConfig,LaneMountPointConfig,LaneCopyPathConfigLaneSetupScriptConfig— optional post-init script with platform-specific variants (commands/unixCommands/windowsCommands, similar forscriptPath). SupportsinjectPrimaryPathto expose$PRIMARY_WORKTREE_PATHto shell commands.
Templates package a complete LaneEnvInitConfig + overlay overrides
- setup script.
laneTemplateService.resolveSetupScript(template)returns the platform-appropriate command/script path at runtime ornullif no script is configured.
The NO_DEFAULT_LANE_TEMPLATE sentinel distinguishes "no default
set" from "default explicitly cleared" so the Settings UI can surface
the difference.
IPC: ade.lanes.templates.list / get / getDefault / setDefault / apply.
LaneOverlayOverrides extends the base overlay fields with Phase 5
additions:
type LaneOverlayOverrides = {
env?: Record<string, string>;
cwd?: string;
processIds?: string[];
testSuiteIds?: string[];
portRange?: { start: number; end: number };
proxyHostname?: string;
computeBackend?: "local" | "vps" | "daytona"; // legacy; see note
envInit?: LaneEnvInitConfig;
};The matcher in src/shared/laneOverlayMatcher.ts evaluates policies
at lane creation:
portRange,proxyHostname,computeBackend: last-wins mergeenvInit: deep-merged (env files, docker configs, dependencies, and mount points concatenate across policies)
computeBackend is retained for back-compat with older configs but
is no longer part of the active lane runtime direction.
Deterministic, lease-based. Defaults: basePort = 3000, portsPerLane = 100, maxPort = 9999. Lane N gets [basePort + N*100, basePort + N*100 + 99].
PortLease:
type PortLease = {
laneId: string;
rangeStart: number;
rangeEnd: number;
status: "active" | "released" | "orphaned";
leasedAt: string;
releasedAt?: string;
};Conflict detection runs automatically after orphan recovery. When
conflicts are detected, PortConflict records are emitted and the
UI surfaces them in the diagnostics panel with a "Reassign port"
action.
Config validation at service creation:
basePortmust be a positive integerportsPerLanemust be a positive integermaxPort >= basePortmaxSlots()clamps to zero for degenerate configs so the service can still boot and return empty allocations.
IPC: ade.lanes.port.getLease / listLeases / listConflicts / acquire / release / recoverOrphans / event.
laneProxyService runs a single HTTP reverse proxy on proxyPort
(default 8080). Traffic is routed by Host header:
incoming: feat-auth.localhost:8080
proxy strips suffix → "feat-auth"
looks up route by hostname → route.targetPort
forwards to 127.0.0.1:<targetPort>
Hostname collision-safety: buildHostname appends -lane or
-<laneIdSlug> suffixes when the preferred slug is already used by a
different lane's active route.
IPv6 normalization ([::1], ::ffff:127.0.0.1) is handled in
normalizeHostHeader so localhost traffic still resolves.
Cookie/auth isolation is automatic: browsers scope cookies by
hostname, so feat-auth.localhost and bugfix.localhost never
share session cookies.
Preview URLs are generated via getPreviewInfo(laneId) and opened
with openPreview(laneId) (uses the OS default browser).
Hardening (commit 6677edf): Host header validation, route lookup
hardening, proxy error page sanitization (HTML-escaped lane id +
message).
IPC: ade.lanes.proxy.getStatus / start / stop / addRoute / removeRoute / getPreviewInfo / openPreview / event.
runtimeDiagnosticsService aggregates signals from the port, proxy,
and process services into a per-lane LaneHealthCheck:
type LaneHealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
type LaneHealthIssue = {
type: "process-dead" | "port-unresponsive" | "proxy-route-missing"
| "port-conflict" | "env-init-failed";
message: string;
actionLabel?: string;
actionType?: "reassign-port" | "restart-proxy" | "reinit-env"
| "enable-fallback" | "refresh-preview";
};
type LaneHealthCheck = {
laneId: string;
status: LaneHealthStatus;
processAlive: boolean;
portResponding: boolean;
respondingPort: number | null;
proxyRouteActive: boolean;
fallbackMode: boolean;
lastCheckedAt: string;
issues: LaneHealthIssue[];
};Check steps inside runCheck(laneId):
- Port responding —
findResponsivePortprobes the route's target port first, then the lease'srangeStart, then sweeps the rest of the range with a 75 ms per-port timeout in parallel. - Process alive — inferred from port responsiveness; a live port implies a live process, and a non-responding port means either the process is down or the lease hasn't been used yet.
- Proxy route active — route exists, proxy server is running,
route's target port matches the actually responding port. When
any condition fails, the service emits a precise
proxy-route-missingissue with a context-specific message (proxy stopped, port mismatch, route missing, etc). - Port conflicts — scan
getPortConflicts()for unresolved conflicts involving this lane.
Status derivation (deriveStatus):
- No issues, no fallback →
healthy - No issues, fallback active →
degraded - Has
process-deadorport-unresponsive→unhealthy - Otherwise →
degraded
Proxy status unavailable short-circuits to unhealthy with a
single proxy-route-missing issue. This is the load-bearing check
that tells the UI "the proxy itself failed" vs "this one lane is
broken."
De-duplication: if both process-dead and port-unresponsive are
reported, only port-unresponsive is kept (it subsumes the other).
Fallback mode (activateFallback(laneId)):
- Adds the lane to the
fallbackLanesset. - Re-derives the cached health so the lane reports
degradedrather thanunhealthywhen isolation is bypassed. - Emits
fallback-activated/fallback-deactivatedevents.
deactivateFallback is idempotent. Both activate/deactivate are
safe to call on a lane that has no cached health (no-op).
IPC: ade.lanes.diagnostics.getStatus / getLaneHealth / runHealthCheck / runFullCheck / activateFallback / deactivateFallback / event.
renderer/components/run/LaneRuntimeBar.tsx is the compact runtime
status bar rendered at the top of the Run page. For a given
laneId:
refreshHealthStatereads health +processes.listRuntimetogether. The cheap passive pass runs every 10s while the document is visible; the heavierdiagnosticsRunHealthCheckpass is deferred 160ms on mount and then no more often than every 30s.refreshRoutingStatereads preview routing, port lease, proxy status, OAuth status, and the generated Google callback URL. It runs on mount, on proxy/port events, and on a 30s safety poll while visible.- Uses separate
healthRefreshSeqRefandroutingRefreshSeqRefcounters to discard out-of-order responses whenlaneIdchanges mid-flight. - On mount: run an immediate refresh with
runHealthCheck: false, run the routing refresh once, schedule the deferred health check, then start independent health and routing intervals. - Subscribes to
onDiagnosticsEvent,onProxyEvent,onPortEvent,processes.onEvent. Proxy events schedule only a routing refresh; process events update the runtime list immediately and schedule a health refresh; port events update the lease and schedule both. The two event paths have separate debounce timers. - Uses
inlineBadge/outlineButton/healthColorhelpers fromlaneDesignTokens.tsto keep the bar visually coherent with the rest of the Lanes tab.
Props:
type LaneRuntimeBarProps = {
laneId: string | null;
onOpenPreviewRouting?: () => void;
};When laneId === null it renders a "Select a lane" placeholder and
clears all local state so stale info from the previous lane doesn't
flash.
- Probe timing.
checkPortuses a 500 ms default timeout;findResponsivePortshortens to 150 ms for preferred ports and 75 ms for sweeps. A slow dev server may momentarily flap intoport-unresponsiveon cold start. If this happens, the event stream will settle once the server finishes binding. - Preferred-port list.
findResponsivePortprefers the proxy route'stargetPortfirst, then the lease'srangeStart. If the dev server binds to a different port in the lease range, detection still works but takes longer. - Fallback is a manual opt-in. When isolation fails, the UI prompts but does not auto-enable fallback. This is intentional: fallback disables cookie isolation, and silently breaking that contract has caused bug reports before.
- Orphaned leases on crash. If ADE crashes while a lease is
active, recovery on next boot marks itorphanedand frees the slot for reallocation.recoverOrphansis called after persistence load during service init. - Proxy hardening. Proxy error pages HTML-escape all
user-controlled fields. Do not relax this — a proxy error can be
triggered by a malicious OAuth provider redirecting to
<script>…. - Runtime bar refresh storms. Keep health/process refreshes separate from preview routing / port / OAuth refreshes. Process events should not force preview and port reads; those are handled by proxy/port events plus the 30s routing safety poll.