Skip to main content

kranz_engine/
workspace_remote.rs

1//! Thin remote WorkspaceProvider adapter (ticket
2//! `.kranz/tickets/workspace-remote-coder-provider.md`, design D-B
3//! implementation #3 in `docs/scoping/workspace-contract.md`) — provisions a
4//! mission workspace on a **Coder-shaped substrate** (or documented
5//! equivalent): create from a pinned template/image, surface
6//! preview/takeover URLs, inject secret *names* via the provider. kranz does
7//! NOT build a VM scheduler or cloud IDE here — the substrate owns
8//! scheduling, images, hibernation economics, and reachability.
9//!
10//! The substrate boundary is the injectable [`SubstrateClient`] trait (there
11//! is no Coder deployment to test against): production drives
12//! [`CoderHttpClient`] (reqwest; base URL + token from config/env), tests
13//! drive a scripted fake, so all adapter logic is testable offline. The
14//! minimal Coder-shaped surface:
15//!
16//! ```text
17//! create_workspace { template, name, env_names, idle_after_hours? } -> { id, urls, takeover }
18//! workspace_status(id) -> ready | pending | failed
19//! delete_workspace(id)        // TeardownMode::Destroy
20//! stop_workspace(id)          // TeardownMode::Hibernate
21//! ```
22//!
23//! Selection + fail-closed config (design D-B): `workspace.provider:
24//! "remote"` resolves ONLY when `workspace.remote.baseUrl`,
25//! `workspace.remote.template`, and `workspace.remote.tokenEnv` are all
26//! configured — a missing key fails closed at resolve (plan approval AND run
27//! start) with the key named (owner: operator), never a silent fallback to
28//! local. The substrate token is read from the environment variable NAMED by
29//! `tokenEnv`, lazily at provision/teardown — never a value in config, logs,
30//! or events, and never at resolve time (approval-time pinning stays pure).
31//!
32//! Secrets (design D-A): v1 passes the contract's `secrets[]` — NAMES only —
33//! to `create_workspace` and records which were injected (never values).
34//! Where the substrate supports it, **OIDC workload identity is preferred
35//! over injected secret values** (docs/reviews/ampcode.md §3): short-lived
36//! tokens minted per workspace with mission-scoped claims (repo, mission id,
37//! profile), so services trust the issuer and no secret exists to leak. That
38//! is a substrate/template capability and is documented here, not built —
39//! v1's contract surface stays "names, never values."
40//!
41//! Previews/takeover (design D-E): the substrate's reported URLs are
42//! name-matched onto the contract's `previews[]` placeholders and recorded
43//! on `workspace.provisioned` (unmatched placeholders stay UNFILLED — never
44//! a fabricated URL). The takeover artifact is the substrate's SSH/web URL
45//! from create, recorded in the handle and surfaced by the workspace
46//! endpoint's `takeover` field. Whether the substrate reports a preview URL
47//! is fronted with auth is RECORDED (`auth`); the adapter never disables
48//! auth fronting (docs/reviews/ampcode.md §8: previews authenticated by
49//! default).
50//!
51//! Lifecycle: provision = `create_workspace` + poll `workspace_status` until
52//! ready (bounded by [`READY_TIMEOUT`]). Substrate failures (create error,
53//! `failed` status, poll timeout) are RECORDED on the handle and surfaced by
54//! `readiness` as a provider-OWNED block (`owner: provider` — distinct from
55//! `repo-setup` contract failures and `operator` config failures); the block
56//! deliberately does NOT carry the workspace-gate prefix, so a later gate
57//! pass does not auto-lift it — the operator unblocks once the substrate
58//! recovers. Config/credential failures are `Err` at provision (fail closed
59//! before spend, owner: operator). Teardown: the engine drives the
60//! configured `workspace.teardownMode` at terminal states (ticket
61//! `workspace-idle-hibernate`) — hibernate → `stop_workspace`, destroy →
62//! `delete_workspace`, keep → no call. The optional
63//! `workspace.remote.idleAfterHours` VALUE is passed through at create and
64//! recorded in the provisioned detail; the SUBSTRATE owns the idle policy's
65//! scheduling/execution (kranz never schedules VMs).
66//!
67//! v1 honesty notes (deliberate):
68//! - **Readiness is substrate-reported only.** Contract bootstrap/readiness
69//!   commands do NOT execute on the remote substrate in v1 (the minimal
70//!   surface has no exec channel; running them over SSH is a later ticket,
71//!   documented in design D-B). The readiness decision line says this out
72//!   loud — a remote `workspace.readiness: ready` means "the substrate
73//!   reports ready," no more.
74//! - **Agent sessions still run in the local mission worktree**
75//!   (`handle.cwd = spec.repo_root`): the substrate supplies the runnable
76//!   services environment, previews, and takeover; backend-remote session
77//!   execution is a later ticket and NOT implied here.
78//! - **No public-IP requirement.** The substrate may live behind a VPN or be
79//!   reachable only over SSH; reachability is an operator/network concern
80//!   (see the `workspace.remote.*` config comments), never something the
81//!   adapter probes or opens.
82//! - **A contract-less provision starts nothing** (D-H, mirroring the
83//!   container provider): no substrate workspace, no credentials required —
84//!   never imply a runnable environment that does not exist.
85//! - Provision always calls `create_workspace`; resume semantics
86//!   (dedupe-by-name, adopt-existing) are the substrate's in v1 — teardown
87//!   `Destroy` releases a leftover before re-running.
88
89use crate::error::{EngineError, Result};
90use crate::types::{ProvisionedPreview, RemoteWorkspaceConfig};
91use crate::workspace_contract::PreviewSpec;
92use crate::workspace_provider::{
93    PreviewPlaceholder, ProgressSink, ProvisionSpec, ReadinessOutcome, TeardownMode,
94    WorkspaceHandle, WorkspaceProvider, WorkspaceProviderKind,
95};
96use std::sync::Arc;
97use std::time::Duration;
98
99/// The adapter version pinned at approval (`workspace.provider.pinned`'s
100/// `version` field for the remote kind, design D-B) — the pin records the
101/// adapter contract version, not a substrate version (no substrate contact
102/// at approval).
103pub const ADAPTER_VERSION: &str = "coder-v1";
104
105/// Remote workspace names are mission-owned: `kranz-remote-<sanitized
106/// mission id>` — self-describing on a substrate dashboard and never shared
107/// between missions.
108const WORKSPACE_NAME_PREFIX: &str = "kranz-remote-";
109
110/// One substrate HTTP call (create/status/stop/delete).
111const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
112/// A substrate workspace must report ready within this overall window…
113const READY_TIMEOUT: Duration = Duration::from_secs(300);
114/// …polled at this interval (VM/image boot takes real seconds).
115const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
116
117/// Every provider-owned block reason starts here. DISTINCT from the
118/// workspace gate's prefix (`workspace gate:`) on purpose: the gate's pass
119/// path auto-lifts only ITS blocks, and a provider block is not lifted by a
120/// later passing gate — the operator unblocks after the substrate recovers.
121pub(crate) const PROVIDER_REASON_PREFIX: &str = "workspace provider:";
122
123/// The `milestone.blocked` reason for a provider-owned failure (design D-C's
124/// owner taxonomy): names the substrate detail and owner `provider` —
125/// distinct from `repo-setup` (contract commands) and `operator` (config).
126/// Scrubbed: substrate error text must never put a credential into
127/// events.jsonl.
128pub(crate) fn provider_block_reason(detail: &str) -> String {
129    crate::scrub::scrub(&format!(
130        "{PROVIDER_REASON_PREFIX} {detail} (owner: provider — fix the substrate, then unblock \
131         and re-run)"
132    ))
133}
134
135/// What one provision asks the substrate to create. `env_names` are secret
136/// NAMES (the contract's validated `secrets[]`) — values never cross this
137/// boundary.
138#[derive(Debug, Clone, PartialEq)]
139pub struct SubstrateWorkspaceSpec {
140    /// The pinned template/image id (from `workspace.remote.template`).
141    pub template: String,
142    /// The mission-owned workspace name (`kranz-remote-<mission id>`).
143    pub name: String,
144    /// Secret NAMES the substrate injects from its own secret store.
145    pub env_names: Vec<String>,
146    /// Substrate-side idle policy VALUE (`workspace.remote.idleAfterHours`,
147    /// ticket `workspace-idle-hibernate`): hours of inactivity after which
148    /// the SUBSTRATE hibernates the workspace. Passed through verbatim when
149    /// the substrate accepts an idle policy — the substrate owns
150    /// scheduling/execution; kranz never schedules. `None` = no idle
151    /// policy requested.
152    pub idle_after_hours: Option<f64>,
153}
154
155/// A URL the substrate reported for one workspace endpoint.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SubstrateUrl {
158    pub name: String,
159    pub url: String,
160    /// Whether the substrate reports the URL is fronted with auth (design
161    /// D-E — recorded, never disabled). `None` = the substrate did not say.
162    pub auth: Option<bool>,
163}
164
165/// What the substrate created: the workspace id, its reported endpoint URLs,
166/// and the human takeover URL (SSH/web) when it reported one.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct SubstrateWorkspace {
169    pub id: String,
170    pub urls: Vec<SubstrateUrl>,
171    pub takeover: Option<String>,
172}
173
174/// The substrate-reported workspace lifecycle state.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum SubstrateStatus {
177    Ready,
178    Pending,
179    Failed { reason: String },
180}
181
182/// The substrate boundary, injectable for tests — the minimal Coder-shaped
183/// surface the adapter drives (see the module docs for the wire mapping
184/// [`CoderHttpClient`] uses). No VM scheduling semantics cross this trait:
185/// the substrate owns images, placement, and hibernation economics.
186#[async_trait::async_trait]
187pub trait SubstrateClient: Send + Sync {
188    /// Create (and start) one workspace from a pinned template, injecting
189    /// the named secrets — NAMES only, never values.
190    async fn create_workspace(&self, spec: &SubstrateWorkspaceSpec) -> Result<SubstrateWorkspace>;
191    /// The workspace's current lifecycle state.
192    async fn workspace_status(&self, id: &str) -> Result<SubstrateStatus>;
193    /// Release the workspace entirely ([`TeardownMode::Destroy`]).
194    async fn delete_workspace(&self, id: &str) -> Result<()>;
195    /// Provider-owned idle suspension ([`TeardownMode::Hibernate`]).
196    async fn stop_workspace(&self, id: &str) -> Result<()>;
197}
198
199/// The validated, complete remote config — every field required. Produced
200/// ONLY by [`RemoteConfig::require`], so a `RemoteWorkspaceProvider` can
201/// never exist with partial remote config.
202#[derive(Debug, Clone, PartialEq)]
203pub struct RemoteConfig {
204    pub base_url: String,
205    pub template: String,
206    /// NAME of the env var holding the substrate token — never the value.
207    pub token_env: String,
208    /// Substrate-side idle policy VALUE (`workspace.remote.idleAfterHours`,
209    /// ticket `workspace-idle-hibernate`) — passed through to the substrate
210    /// at provision when set; the SUBSTRATE owns scheduling/execution of the
211    /// policy (kranz never schedules VMs).
212    pub idle_after_hours: Option<f64>,
213}
214
215impl RemoteConfig {
216    /// Validate the additive mission config into a complete remote config,
217    /// failing CLOSED with the first missing key named (owner: operator) —
218    /// never a silent fallback to local. Pure: no env, no network.
219    pub fn require(config: Option<&RemoteWorkspaceConfig>) -> Result<RemoteConfig> {
220        fn missing(key: &str) -> EngineError {
221            EngineError::Config(format!(
222                "workspace.provider \"remote\" needs {key} configured (owner: operator — set the \
223                 mission config key); refusing rather than silently falling back to local"
224            ))
225        }
226        let Some(config) = config else {
227            return Err(missing("workspace.remote.baseUrl"));
228        };
229        Ok(RemoteConfig {
230            base_url: config
231                .base_url
232                .clone()
233                .ok_or_else(|| missing("workspace.remote.baseUrl"))?,
234            template: config
235                .template
236                .clone()
237                .ok_or_else(|| missing("workspace.remote.template"))?,
238            token_env: config
239                .token_env
240                .clone()
241                .ok_or_else(|| missing("workspace.remote.tokenEnv"))?,
242            // Optional — the only non-required remote key (absent = no idle
243            // policy passed to the substrate).
244            idle_after_hours: config.idle_after_hours,
245        })
246    }
247}
248
249/// How the provider gets a substrate client at provision/teardown time.
250/// Production reads the token from the env var NAMED by `tokenEnv` and
251/// builds the [`CoderHttpClient`]; tests inject a scripted fake.
252type ClientHook = Arc<dyn Fn(&RemoteConfig) -> Result<Arc<dyn SubstrateClient>> + Send + Sync>;
253
254/// The production client hook: read the substrate token from the env var
255/// NAMED by `workspace.remote.tokenEnv` — failing CLOSED with the var NAME
256/// (never a value) when unset/empty — and build the Coder-shaped HTTP
257/// client. Lazy on purpose: resolve/pin stay pure, and the token value
258/// touches only this process's memory.
259fn coder_client_from_env(config: &RemoteConfig) -> Result<Arc<dyn SubstrateClient>> {
260    let token = std::env::var(&config.token_env)
261        .ok()
262        .filter(|value| !value.is_empty())
263        .ok_or_else(|| {
264            EngineError::Config(format!(
265                "workspace.provider \"remote\": the substrate token env var {} (from \
266                 workspace.remote.tokenEnv) is not set (owner: operator — export it before \
267                 `kranz work`); refusing rather than silently falling back to local",
268                config.token_env
269            ))
270        })?;
271    Ok(Arc::new(CoderHttpClient::new(&config.base_url, &token)?))
272}
273
274/// What the provision-time poll concluded — recorded on the handle so
275/// `readiness` surfaces a provider-owned block instead of re-polling.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub enum PollOutcome {
278    Ready,
279    Failed { reason: String },
280    TimedOut { waited_secs: u64 },
281}
282
283/// The remote provider's handle state — everything `readiness` and
284/// `teardown` need across the seam's calls (the provider itself stays
285/// stateless).
286#[derive(Debug, Clone)]
287pub struct RemoteWorkspace {
288    /// The mission-owned workspace name (always known).
289    pub name: String,
290    /// The substrate workspace id — EMPTY when create failed (teardown then
291    /// no-ops: nothing exists to release).
292    pub id: String,
293    /// The substrate's takeover URL (SSH/web) as reported at create.
294    pub takeover: Option<String>,
295    /// Substrate URLs name-matched onto the contract's `previews[]` —
296    /// recorded on `workspace.provisioned`.
297    pub previews: Vec<ProvisionedPreview>,
298    /// The secret NAMES the substrate was asked to inject (never values).
299    pub injected_env_names: Vec<String>,
300    /// What the provision-time readiness poll concluded.
301    pub poll: PollOutcome,
302}
303
304/// The remote provider: thin adapter over a [`SubstrateClient`]. See the
305/// module docs for the config gate, owner taxonomy, and v1 honesty notes.
306pub struct RemoteWorkspaceProvider {
307    config: RemoteConfig,
308    client_hook: ClientHook,
309    ready_timeout: Duration,
310    poll_interval: Duration,
311}
312
313impl RemoteWorkspaceProvider {
314    /// From mission config — fails CLOSED (owner: operator) with the first
315    /// missing `workspace.remote.*` key named. The production client hook
316    /// reads the token env var lazily, at provision/teardown.
317    pub(crate) fn from_config(config: Option<&RemoteWorkspaceConfig>) -> Result<Self> {
318        Ok(Self {
319            config: RemoteConfig::require(config)?,
320            client_hook: Arc::new(coder_client_from_env),
321            ready_timeout: READY_TIMEOUT,
322            poll_interval: READY_POLL_INTERVAL,
323        })
324    }
325
326    /// A provider driving a scripted substrate boundary: the full
327    /// provision/readiness/teardown logic runs without a substrate, with
328    /// millisecond poll bounds so the timeout path stays fast.
329    #[cfg(test)]
330    pub(crate) fn with_client(config: RemoteConfig, client: Arc<dyn SubstrateClient>) -> Self {
331        Self {
332            config,
333            client_hook: Arc::new(move |_config| Ok(Arc::clone(&client))),
334            ready_timeout: Duration::from_millis(120),
335            poll_interval: Duration::from_millis(5),
336        }
337    }
338
339    /// Poll `workspace_status` until ready, failed, or out of time. Never
340    /// `Err`: every substrate outcome is a recorded [`PollOutcome`] so
341    /// `readiness` can block with the provider owner (a status-call error is
342    /// a substrate failure too, not a config error).
343    async fn poll_until_ready(&self, client: &dyn SubstrateClient, id: &str) -> PollOutcome {
344        let deadline = std::time::Instant::now() + self.ready_timeout;
345        loop {
346            match client.workspace_status(id).await {
347                Ok(SubstrateStatus::Ready) => return PollOutcome::Ready,
348                Ok(SubstrateStatus::Failed { reason }) => return PollOutcome::Failed { reason },
349                Ok(SubstrateStatus::Pending) => {}
350                Err(e) => {
351                    return PollOutcome::Failed {
352                        reason: format!("status poll failed: {e}"),
353                    };
354                }
355            }
356            if std::time::Instant::now() >= deadline {
357                return PollOutcome::TimedOut {
358                    waited_secs: self.ready_timeout.as_secs(),
359                };
360            }
361            tokio::time::sleep(self.poll_interval).await;
362        }
363    }
364}
365
366/// `kranz-remote-<sanitized-mission-id>` — Coder-shaped names are lowercase
367/// alnum plus `-`; the prefix guarantees a valid leading character even when
368/// the mission id sanitizes to nothing. (Same sanitization idiom as the
369/// container provider's compose project names.)
370fn workspace_name(mission_id: &str) -> String {
371    let sanitized: String = mission_id
372        .chars()
373        .map(|c| {
374            if c.is_ascii_alphanumeric() {
375                c.to_ascii_lowercase()
376            } else if c == '-' || c == '_' {
377                c
378            } else {
379                '-'
380            }
381        })
382        .collect();
383    if sanitized.is_empty() {
384        format!("{WORKSPACE_NAME_PREFIX}mission")
385    } else {
386        format!("{WORKSPACE_NAME_PREFIX}{sanitized}")
387    }
388}
389
390/// The handle's preview placeholders (the seam's existing field): a
391/// substrate URL whose name matches a contract preview FILLS the template;
392/// unmatched placeholders keep the contract's URL template UNFILLED (design
393/// D-E — never a fabricated URL).
394fn preview_placeholders(
395    previews: &[PreviewSpec],
396    urls: &[SubstrateUrl],
397) -> Vec<PreviewPlaceholder> {
398    previews
399        .iter()
400        .map(|preview| {
401            let url_template = urls
402                .iter()
403                .find(|url| url.name == preview.name)
404                .map(|url| url.url.clone())
405                .unwrap_or_else(|| preview.url_template.clone());
406            PreviewPlaceholder {
407                name: preview.name.clone(),
408                url_template,
409            }
410        })
411        .collect()
412}
413
414/// The event-recorded previews: ONLY substrate-reported URLs, name-matched
415/// onto the contract's `previews[]` and carrying the substrate's auth report.
416/// Unmatched contract previews are absent — the substrate never reported a
417/// URL for them, so nothing is recorded (never fabricated).
418fn map_previews(previews: &[PreviewSpec], urls: &[SubstrateUrl]) -> Vec<ProvisionedPreview> {
419    previews
420        .iter()
421        .filter_map(|preview| {
422            urls.iter()
423                .find(|url| url.name == preview.name)
424                .map(|url| ProvisionedPreview {
425                    name: preview.name.clone(),
426                    url: url.url.clone(),
427                    auth: url.auth,
428                })
429        })
430        .collect()
431}
432
433/// The `workspace.provisioned` detail string: names the substrate workspace
434/// and WHICH secret names were injected (never values), plus the configured
435/// substrate-owned idle policy when one was passed through (ticket
436/// `workspace-idle-hibernate` — recorded, never scheduled by kranz). Empty
437/// id = create failed before the substrate assigned one.
438fn provision_detail(
439    name: &str,
440    id: &str,
441    env_names: &[String],
442    idle_after_hours: Option<f64>,
443) -> String {
444    let injected = if env_names.is_empty() {
445        "none".to_string()
446    } else {
447        env_names.join(",")
448    };
449    let idle = match idle_after_hours {
450        Some(hours) => format!("; idle policy: hibernate after {hours}h (substrate-owned)"),
451        None => String::new(),
452    };
453    if id.is_empty() {
454        format!("substrate workspace {name}: create failed (injected env names: {injected}){idle}")
455    } else {
456        format!("substrate workspace {name} (id {id}); injected env names: {injected}{idle}")
457    }
458}
459
460#[async_trait::async_trait]
461impl WorkspaceProvider for RemoteWorkspaceProvider {
462    fn kind(&self) -> WorkspaceProviderKind {
463        WorkspaceProviderKind::Remote
464    }
465
466    async fn provision(&self, spec: &ProvisionSpec) -> Result<WorkspaceHandle> {
467        if !spec.repo_root.is_dir() {
468            return Err(EngineError::InvalidState(format!(
469                "remote provision: execution cwd {} does not exist",
470                spec.repo_root.display()
471            )));
472        }
473        let env = crate::runner::contract_env(spec.base_sha.as_deref());
474        let Some(contract) = &spec.contract else {
475            // No contract ⇒ nothing runnable to provision remotely (D-H,
476            // mirroring the container provider): no substrate workspace, no
477            // credentials required — never imply a runnable environment that
478            // does not exist.
479            return Ok(WorkspaceHandle {
480                cwd: spec.repo_root.clone(),
481                env,
482                previews: Vec::new(),
483                contract: None,
484                detail: None,
485                container: None,
486                remote: None,
487                gate_env: spec.gate_env.clone(),
488            });
489        };
490
491        // Credentials fail closed HERE, at provision (run start, before
492        // spend), with the env var NAME named — never at resolve/pin.
493        let client = (self.client_hook)(&self.config)?;
494        let name = workspace_name(&spec.mission_id);
495        let env_names = contract.secrets.clone();
496
497        // Substrate failures (create error, failed status, poll timeout) are
498        // RECORDED on the handle and surfaced by `readiness` as a
499        // provider-owned block — never an error that loses the audit trail.
500        let (id, urls, takeover, poll) = match client
501            .create_workspace(&SubstrateWorkspaceSpec {
502                template: self.config.template.clone(),
503                name: name.clone(),
504                env_names: env_names.clone(),
505                idle_after_hours: self.config.idle_after_hours,
506            })
507            .await
508        {
509            Ok(created) => {
510                let poll = self.poll_until_ready(&*client, &created.id).await;
511                (created.id, created.urls, created.takeover, poll)
512            }
513            Err(e) => (
514                String::new(),
515                Vec::new(),
516                None,
517                PollOutcome::Failed {
518                    reason: format!("create_workspace failed: {e}"),
519                },
520            ),
521        };
522
523        Ok(WorkspaceHandle {
524            cwd: spec.repo_root.clone(),
525            env,
526            previews: preview_placeholders(&contract.previews, &urls),
527            contract: Some(contract.clone()),
528            detail: Some(provision_detail(
529                &name,
530                &id,
531                &env_names,
532                self.config.idle_after_hours,
533            )),
534            container: None,
535            remote: Some(RemoteWorkspace {
536                name,
537                id,
538                takeover,
539                previews: map_previews(&contract.previews, &urls),
540                injected_env_names: env_names,
541                poll,
542            }),
543            gate_env: spec.gate_env.clone(),
544        })
545    }
546
547    async fn readiness(
548        &self,
549        handle: &WorkspaceHandle,
550        progress: &mut ProgressSink<'_>,
551    ) -> Result<ReadinessOutcome> {
552        let Some(remote) = &handle.remote else {
553            // Contract-less provision: nothing was created — trivially ready,
554            // no progress lines (byte-identical to the local kinds).
555            return Ok(ReadinessOutcome::Ready);
556        };
557        match &remote.poll {
558            PollOutcome::Ready => {
559                // Substrate-reported readiness ONLY (v1): say out loud that
560                // the contract's bootstrap/readiness commands did not execute
561                // on the remote substrate — the decision line's prefix is
562                // deliberately NOT the gate's, so no false gate outcome is
563                // derived from it.
564                progress(
565                    "workspace remote: substrate reports ready (substrate-reported readiness \
566                     only — contract bootstrap/readiness commands do not execute on the remote \
567                     substrate in v1)",
568                    None,
569                )?;
570                Ok(ReadinessOutcome::Ready)
571            }
572            PollOutcome::Failed { reason } => Ok(ReadinessOutcome::ProviderFailed {
573                detail: format!(
574                    "substrate workspace {} failed to provision: {reason}",
575                    remote.name
576                ),
577            }),
578            PollOutcome::TimedOut { waited_secs } => Ok(ReadinessOutcome::ProviderFailed {
579                detail: format!(
580                    "substrate workspace {} did not become ready within {waited_secs}s",
581                    remote.name
582                ),
583            }),
584        }
585    }
586
587    async fn teardown(&self, handle: WorkspaceHandle, mode: TeardownMode) -> Result<()> {
588        let Some(remote) = &handle.remote else {
589            return Ok(()); // contract-less provision: nothing was created
590        };
591        if remote.id.is_empty() {
592            return Ok(()); // create never succeeded: nothing exists to release
593        }
594        match mode {
595            // Leave the workspace running/present (resume, inspection,
596            // takeover — documented: previews and the takeover URL stay live).
597            TeardownMode::Keep => Ok(()),
598            TeardownMode::Hibernate => {
599                let client = (self.client_hook)(&self.config)?;
600                client.stop_workspace(&remote.id).await.map_err(|e| {
601                    EngineError::InvalidState(format!(
602                        "remote teardown (hibernate): stop_workspace failed (owner: provider): {}",
603                        crate::scrub::scrub(&e.to_string())
604                    ))
605                })
606            }
607            TeardownMode::Destroy => {
608                let client = (self.client_hook)(&self.config)?;
609                client.delete_workspace(&remote.id).await.map_err(|e| {
610                    EngineError::InvalidState(format!(
611                        "remote teardown (destroy): delete_workspace failed (owner: provider): {}",
612                        crate::scrub::scrub(&e.to_string())
613                    ))
614                })
615            }
616        }
617    }
618}
619
620/// The Coder-shaped HTTP client (production [`SubstrateClient`]): base URL +
621/// session token, Coder's `Coder-Session-Token` auth header, one bounded
622/// timeout per call. Wire mapping (the documented v1 substrate shape —
623/// validated against a loopback mock only, no live Coder deployment):
624///
625/// - `create_workspace` → `POST {base}/api/v2/users/me/workspaces` with body
626///   `{"template_id", "name", "env_names"}` (names only — the substrate
627///   injects the named secrets from its own store), plus `"idle_after_hours"`
628///   when `workspace.remote.idleAfterHours` is configured (the substrate
629///   owns the idle policy; a substrate without support ignores the key).
630///   Response: `{"id",
631///   "urls"?, "takeover"?}` — `urls`/`takeover` optional so a stock Coder
632///   create response (which carries the id but not flattened app URLs)
633///   degrades honestly to "no previews reported."
634/// - `workspace_status` → `GET {base}/api/v2/workspaces/{id}`; reads
635///   top-level `status` or `latest_build.status`: `"running"` → ready,
636///   `"failed"` → failed, anything else (starting/stopping/unknown) →
637///   pending (keep polling, bounded).
638/// - `delete_workspace` / `stop_workspace` →
639///   `POST {base}/api/v2/workspaces/{id}/builds` with `{"transition":
640///   "delete" | "stop"}` (Coder models both as workspace builds).
641pub struct CoderHttpClient {
642    base_url: String,
643    token: String,
644    client: reqwest::Client,
645}
646
647impl CoderHttpClient {
648    /// Build the client, validating the base URL eagerly (fail closed at
649    /// provision with the config key named, not mid-request).
650    pub fn new(base_url: &str, token: &str) -> Result<Self> {
651        let parsed = reqwest::Url::parse(base_url).map_err(|e| {
652            EngineError::Config(format!(
653                "workspace.remote.baseUrl {base_url:?} is not a valid URL: {e} (owner: operator)"
654            ))
655        })?;
656        if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
657            return Err(EngineError::Config(format!(
658                "workspace.remote.baseUrl {base_url:?} needs an http(s) URL with a host (owner: \
659                 operator)"
660            )));
661        }
662        let client = reqwest::Client::builder()
663            .timeout(REQUEST_TIMEOUT)
664            .build()
665            .map_err(|e| {
666                EngineError::Config(format!("could not build the substrate HTTP client: {e}"))
667            })?;
668        Ok(Self {
669            base_url: base_url.trim_end_matches('/').to_string(),
670            token: token.to_string(),
671            client,
672        })
673    }
674
675    /// One authenticated JSON request; non-2xx maps to an error naming the
676    /// op, the status, and a scrubbed bounded body tail (never the token).
677    async fn send(
678        &self,
679        request: reqwest::RequestBuilder,
680        op: &'static str,
681    ) -> Result<reqwest::Response> {
682        let response = request
683            .header("Coder-Session-Token", &self.token)
684            .send()
685            .await
686            .map_err(|e| {
687                EngineError::InvalidState(format!(
688                    "coder substrate {op}: request failed: {}",
689                    crate::scrub::scrub(&e.to_string())
690                ))
691            })?;
692        let status = response.status();
693        if !status.is_success() {
694            let body = response.text().await.unwrap_or_default();
695            let tail = crate::command_exec::last_chars_local(&body, 500);
696            return Err(EngineError::InvalidState(format!(
697                "coder substrate {op}: HTTP {status}: {}",
698                crate::scrub::scrub(tail.trim())
699            )));
700        }
701        Ok(response)
702    }
703
704    /// Parse one response body as JSON.
705    async fn json(response: reqwest::Response, op: &'static str) -> Result<serde_json::Value> {
706        response.json().await.map_err(|e| {
707            EngineError::InvalidState(format!("coder substrate {op}: response was not JSON: {e}"))
708        })
709    }
710
711    /// A workspace build transition (delete/stop share the shape).
712    async fn transition(&self, id: &str, transition: &str, op: &'static str) -> Result<()> {
713        let url = format!("{}/api/v2/workspaces/{id}/builds", self.base_url);
714        self.send(
715            self.client
716                .post(&url)
717                .json(&serde_json::json!({ "transition": transition })),
718            op,
719        )
720        .await?;
721        Ok(())
722    }
723}
724
725/// Parse one substrate-reported endpoint URL (`{"name", "url", "auth"?}`).
726fn parse_substrate_url(value: &serde_json::Value) -> Option<SubstrateUrl> {
727    Some(SubstrateUrl {
728        name: value.get("name")?.as_str()?.to_string(),
729        url: value.get("url")?.as_str()?.to_string(),
730        auth: value.get("auth").and_then(serde_json::Value::as_bool),
731    })
732}
733
734/// Map a status response body onto the lifecycle state: top-level `status`
735/// wins, else `latest_build.status` (stock Coder nests it). Unknown/absent
736/// states are PENDING — keep polling (bounded), never guess ready.
737fn parse_status(value: &serde_json::Value) -> SubstrateStatus {
738    let status = value
739        .get("status")
740        .and_then(|v| v.as_str())
741        .or_else(|| {
742            value
743                .get("latest_build")
744                .and_then(|build| build.get("status"))
745                .and_then(|v| v.as_str())
746        })
747        .unwrap_or("");
748    match status {
749        "running" => SubstrateStatus::Ready,
750        "failed" => SubstrateStatus::Failed {
751            reason: "substrate reported workspace status \"failed\"".to_string(),
752        },
753        _ => SubstrateStatus::Pending,
754    }
755}
756
757#[async_trait::async_trait]
758impl SubstrateClient for CoderHttpClient {
759    async fn create_workspace(&self, spec: &SubstrateWorkspaceSpec) -> Result<SubstrateWorkspace> {
760        let url = format!("{}/api/v2/users/me/workspaces", self.base_url);
761        let mut body = serde_json::json!({
762            "template_id": spec.template,
763            "name": spec.name,
764            // Secret NAMES only — the substrate injects values from
765            // its own store; values never cross this wire.
766            "env_names": spec.env_names,
767        });
768        if let Some(hours) = spec.idle_after_hours {
769            // The substrate-side idle policy VALUE (ticket
770            // workspace-idle-hibernate): passed through verbatim — the
771            // substrate owns scheduling/execution of the policy (a
772            // substrate without idle-policy support ignores the key).
773            body["idle_after_hours"] = serde_json::json!(hours);
774        }
775        let response = self
776            .send(self.client.post(&url).json(&body), "create_workspace")
777            .await?;
778        let value = Self::json(response, "create_workspace").await?;
779        let id = value
780            .get("id")
781            .and_then(|v| v.as_str())
782            .filter(|id| !id.is_empty())
783            .ok_or_else(|| {
784                EngineError::InvalidState(
785                    "coder substrate create_workspace: response carried no workspace id"
786                        .to_string(),
787                )
788            })?;
789        let urls = value
790            .get("urls")
791            .and_then(|v| v.as_array())
792            .map(|entries| entries.iter().filter_map(parse_substrate_url).collect())
793            .unwrap_or_default();
794        let takeover = value
795            .get("takeover")
796            .and_then(|v| v.as_str())
797            .map(str::to_string);
798        Ok(SubstrateWorkspace {
799            id: id.to_string(),
800            urls,
801            takeover,
802        })
803    }
804
805    async fn workspace_status(&self, id: &str) -> Result<SubstrateStatus> {
806        let url = format!("{}/api/v2/workspaces/{id}", self.base_url);
807        let response = self.send(self.client.get(&url), "workspace_status").await?;
808        let value = Self::json(response, "workspace_status").await?;
809        Ok(parse_status(&value))
810    }
811
812    async fn delete_workspace(&self, id: &str) -> Result<()> {
813        self.transition(id, "delete", "delete_workspace").await
814    }
815
816    async fn stop_workspace(&self, id: &str) -> Result<()> {
817        self.transition(id, "stop", "stop_workspace").await
818    }
819}
820
821// ---------------------------------------------------------------------------
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use crate::workspace_contract::{parse_workspace_contract, WorkspaceContract};
827    use std::collections::VecDeque;
828    use std::sync::Mutex;
829
830    fn config() -> RemoteConfig {
831        RemoteConfig {
832            base_url: "https://coder.internal.example.com".to_string(),
833            template: "tmpl-baked-ami".to_string(),
834            token_env: "CODER_SESSION_TOKEN".to_string(),
835            idle_after_hours: None,
836        }
837    }
838
839    fn spec(root: &std::path::Path, contract: Option<WorkspaceContract>) -> ProvisionSpec {
840        let runtime_dir = root.join(".kranz").join("missions").join("m-test");
841        ProvisionSpec {
842            mission_id: "m-test".to_string(),
843            repo_root: root.to_path_buf(),
844            gate_env: crate::workspace_provider::GateEnvPolicy::for_mission(&runtime_dir, &[]),
845            runtime_dir,
846            base_sha: Some("deadbeefcafe".to_string()),
847            contract,
848        }
849    }
850
851    fn contract(json: &[u8]) -> WorkspaceContract {
852        parse_workspace_contract(json).expect("valid contract")
853    }
854
855    /// Contract with two secret names, two previews (only `app` gets a
856    /// substrate URL back — `db` stays an unfilled placeholder).
857    fn remote_contract() -> WorkspaceContract {
858        contract(
859            br#"{
860                "schemaVersion": 1,
861                "bootstrap": ["echo never-run-remotely"],
862                "readiness": ["true"],
863                "previews": [
864                    { "name": "app", "urlTemplate": "http://localhost:{port}/" },
865                    { "name": "db", "urlTemplate": "postgres://localhost:{port}/" }
866                ],
867                "secrets": ["DATABASE_URL", "STRIPE_API_KEY"]
868            }"#,
869        )
870    }
871
872    #[derive(Default)]
873    struct Progress(Vec<String>);
874
875    impl Progress {
876        fn sink(&mut self) -> impl FnMut(&str, Option<String>) -> Result<()> + Send + use<'_> {
877            |summary, _detail| {
878                self.0.push(summary.to_string());
879                Ok(())
880            }
881        }
882    }
883
884    /// The scripted substrate boundary: records every call and answers from
885    /// its script. `statuses` repeats the LAST entry forever once drained, so
886    /// `[Pending]` is "never ready" and `[Pending, Ready]` is "ready on the
887    /// second poll."
888    struct FakeSubstrateClient {
889        created: Mutex<Vec<SubstrateWorkspaceSpec>>,
890        ops: Mutex<Vec<String>>,
891        statuses: Mutex<VecDeque<SubstrateStatus>>,
892        fail_create: Mutex<Option<String>>,
893        workspace: SubstrateWorkspace,
894    }
895
896    impl FakeSubstrateClient {
897        fn new(statuses: Vec<SubstrateStatus>) -> Arc<Self> {
898            Arc::new(Self {
899                created: Mutex::new(Vec::new()),
900                ops: Mutex::new(Vec::new()),
901                statuses: Mutex::new(statuses.into()),
902                fail_create: Mutex::new(None),
903                workspace: SubstrateWorkspace {
904                    id: "ws-abc123".to_string(),
905                    urls: vec![SubstrateUrl {
906                        name: "app".to_string(),
907                        url: "https://app--m-test.coder.internal.example.com".to_string(),
908                        auth: Some(true),
909                    }],
910                    takeover: Some(
911                        "ssh://coder.internal.example.com/kranz-remote-m-test".to_string(),
912                    ),
913                },
914            })
915        }
916
917        fn failing_create(message: &str) -> Arc<Self> {
918            let client = Self::new(vec![]);
919            client
920                .fail_create
921                .lock()
922                .unwrap()
923                .replace(message.to_string());
924            client
925        }
926
927        fn ops(&self) -> Vec<String> {
928            self.ops.lock().unwrap().clone()
929        }
930    }
931
932    #[async_trait::async_trait]
933    impl SubstrateClient for FakeSubstrateClient {
934        async fn create_workspace(
935            &self,
936            spec: &SubstrateWorkspaceSpec,
937        ) -> Result<SubstrateWorkspace> {
938            self.created.lock().unwrap().push(spec.clone());
939            if let Some(message) = self.fail_create.lock().unwrap().as_ref() {
940                return Err(EngineError::InvalidState(message.clone()));
941            }
942            Ok(self.workspace.clone())
943        }
944
945        async fn workspace_status(&self, id: &str) -> Result<SubstrateStatus> {
946            self.ops.lock().unwrap().push(format!("status:{id}"));
947            let mut statuses = self.statuses.lock().unwrap();
948            if statuses.len() > 1 {
949                Ok(statuses.pop_front().expect("len > 1"))
950            } else {
951                Ok(statuses.front().cloned().unwrap_or(SubstrateStatus::Ready))
952            }
953        }
954
955        async fn delete_workspace(&self, id: &str) -> Result<()> {
956            self.ops.lock().unwrap().push(format!("delete:{id}"));
957            Ok(())
958        }
959
960        async fn stop_workspace(&self, id: &str) -> Result<()> {
961            self.ops.lock().unwrap().push(format!("stop:{id}"));
962            Ok(())
963        }
964    }
965
966    fn provider_with(client: Arc<FakeSubstrateClient>) -> RemoteWorkspaceProvider {
967        RemoteWorkspaceProvider::with_client(config(), client)
968    }
969
970    async fn provisioned(
971        provider: &RemoteWorkspaceProvider,
972        root: &std::path::Path,
973    ) -> WorkspaceHandle {
974        provider
975            .provision(&spec(root, Some(remote_contract())))
976            .await
977            .expect("provision")
978    }
979
980    /// Provision asks the substrate to create from the PINNED template with
981    /// the mission-owned name and the contract's secret NAMES — never values
982    /// (the spec type carries no value field at all).
983    #[tokio::test]
984    async fn remote_workspace_provision_creates_with_template_name_and_secret_names() {
985        let dir = tempfile::tempdir().expect("tempdir");
986        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
987        let provider = provider_with(Arc::clone(&client));
988        let handle = provisioned(&provider, dir.path()).await;
989
990        let created = client.created.lock().unwrap().clone();
991        assert_eq!(created.len(), 1, "exactly one create_workspace call");
992        assert_eq!(created[0].template, "tmpl-baked-ami");
993        assert_eq!(created[0].name, "kranz-remote-m-test");
994        assert_eq!(
995            created[0].env_names,
996            vec!["DATABASE_URL".to_string(), "STRIPE_API_KEY".to_string()],
997            "the contract's secret NAMES — and only names — cross to the substrate"
998        );
999
1000        assert_eq!(
1001            handle.cwd,
1002            dir.path(),
1003            "sessions stay in the local worktree in v1"
1004        );
1005        assert_eq!(
1006            handle.env.get("KRANZ_BASE_SHA").map(String::as_str),
1007            Some("deadbeefcafe")
1008        );
1009        let remote = handle.remote.as_ref().expect("remote state");
1010        assert_eq!(remote.id, "ws-abc123");
1011        assert_eq!(remote.injected_env_names, created[0].env_names);
1012        assert!(
1013            handle.detail.as_deref().unwrap().contains("ws-abc123")
1014                && handle
1015                    .detail
1016                    .as_deref()
1017                    .unwrap()
1018                    .contains("DATABASE_URL,STRIPE_API_KEY"),
1019            "the provisioned detail records the workspace and injected names: {:?}",
1020            handle.detail
1021        );
1022    }
1023
1024    /// A ready poll lands the substrate's URLs in the handle: `app` filled
1025    /// with the substrate URL (+ auth report), the unmatched `db` placeholder
1026    /// UNFILLED, and the takeover URL carried for the endpoint.
1027    #[tokio::test]
1028    async fn remote_workspace_ready_poll_lands_previews_and_takeover() {
1029        let dir = tempfile::tempdir().expect("tempdir");
1030        let client =
1031            FakeSubstrateClient::new(vec![SubstrateStatus::Pending, SubstrateStatus::Ready]);
1032        let provider = provider_with(Arc::clone(&client));
1033        let handle = provisioned(&provider, dir.path()).await;
1034
1035        assert!(
1036            client
1037                .ops()
1038                .iter()
1039                .filter(|op| op.starts_with("status:"))
1040                .count()
1041                >= 2,
1042            "the poll looped past the pending status: {:?}",
1043            client.ops()
1044        );
1045        let remote = handle.remote.as_ref().expect("remote state");
1046        assert_eq!(remote.poll, PollOutcome::Ready);
1047        assert_eq!(
1048            remote.takeover.as_deref(),
1049            Some("ssh://coder.internal.example.com/kranz-remote-m-test")
1050        );
1051        assert_eq!(
1052            remote.previews,
1053            vec![ProvisionedPreview {
1054                name: "app".to_string(),
1055                url: "https://app--m-test.coder.internal.example.com".to_string(),
1056                auth: Some(true),
1057            }],
1058            "only the substrate-reported URL is recorded — db stays unfabricated"
1059        );
1060        // The seam's placeholder field: app filled, db's template UNFILLED.
1061        assert_eq!(
1062            handle.previews,
1063            vec![
1064                PreviewPlaceholder {
1065                    name: "app".to_string(),
1066                    url_template: "https://app--m-test.coder.internal.example.com".to_string(),
1067                },
1068                PreviewPlaceholder {
1069                    name: "db".to_string(),
1070                    url_template: "postgres://localhost:{port}/".to_string(),
1071                },
1072            ]
1073        );
1074
1075        // Readiness is substrate-reported ONLY, and says so out loud.
1076        let mut progress = Progress::default();
1077        let outcome = provider
1078            .readiness(&handle, &mut progress.sink())
1079            .await
1080            .expect("readiness");
1081        assert!(matches!(outcome, ReadinessOutcome::Ready), "{outcome:?}");
1082        assert_eq!(progress.0.len(), 1);
1083        assert!(
1084            progress.0[0].starts_with("workspace remote: substrate reports ready")
1085                && progress.0[0].contains("substrate-reported readiness only"),
1086            "honest readiness wording, no gate-outcome prefix: {}",
1087            progress.0[0]
1088        );
1089    }
1090
1091    /// A substrate `failed` status maps to the provider-OWNED block shape —
1092    /// owner `provider`, never `repo-setup`, and not the gate's prefix (no
1093    /// auto-lift on a later passing gate).
1094    #[tokio::test]
1095    async fn remote_workspace_failed_status_is_a_provider_owned_block() {
1096        let dir = tempfile::tempdir().expect("tempdir");
1097        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Failed {
1098            reason: "template build exited 1".to_string(),
1099        }]);
1100        let provider = provider_with(Arc::clone(&client));
1101        let handle = provisioned(&provider, dir.path()).await;
1102
1103        let mut progress = Progress::default();
1104        let outcome = provider
1105            .readiness(&handle, &mut progress.sink())
1106            .await
1107            .expect("readiness");
1108        let ReadinessOutcome::ProviderFailed { detail } = outcome else {
1109            panic!("a failed substrate status must be ProviderFailed, got {outcome:?}");
1110        };
1111        assert!(detail.contains("kranz-remote-m-test"), "{detail}");
1112        assert!(detail.contains("template build exited 1"), "{detail}");
1113
1114        let reason = provider_block_reason(&detail);
1115        assert!(reason.starts_with("workspace provider:"), "{reason}");
1116        assert!(reason.contains("owner: provider"), "{reason}");
1117        assert!(!reason.contains("repo-setup"), "{reason}");
1118        assert!(
1119            !reason.starts_with(crate::workspace_gate::GATE_REASON_PREFIX),
1120            "not gate-owned ⇒ the pass path never auto-lifts it: {reason}"
1121        );
1122        assert!(
1123            progress.0.is_empty(),
1124            "a failed workspace reports no ready line"
1125        );
1126    }
1127
1128    /// A workspace that never becomes ready inside the bound is the same
1129    /// provider-owned failure (bounded — the poll cannot hang the run).
1130    #[tokio::test]
1131    async fn remote_workspace_poll_timeout_is_provider_owned_and_bounded() {
1132        let dir = tempfile::tempdir().expect("tempdir");
1133        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Pending]);
1134        let provider = provider_with(Arc::clone(&client));
1135        let handle =
1136            tokio::time::timeout(Duration::from_secs(10), provisioned(&provider, dir.path()))
1137                .await
1138                .expect("the provision poll must terminate inside its bound");
1139
1140        assert!(matches!(
1141            handle.remote.as_ref().expect("remote").poll,
1142            PollOutcome::TimedOut { .. }
1143        ));
1144        let mut progress = Progress::default();
1145        let outcome = provider
1146            .readiness(&handle, &mut progress.sink())
1147            .await
1148            .expect("readiness");
1149        let ReadinessOutcome::ProviderFailed { detail } = outcome else {
1150            panic!("a poll timeout must be ProviderFailed, got {outcome:?}");
1151        };
1152        assert!(detail.contains("did not become ready within"), "{detail}");
1153    }
1154
1155    /// A create failure records a provider failure too (nothing exists: the
1156    /// id is empty and teardown no-ops) — substrate failures never lose the
1157    /// audit trail to a bare run error.
1158    #[tokio::test]
1159    async fn remote_workspace_create_failure_is_recorded_and_teardown_noops() {
1160        let dir = tempfile::tempdir().expect("tempdir");
1161        let client = FakeSubstrateClient::failing_create("HTTP 401: bad token");
1162        let provider = provider_with(Arc::clone(&client));
1163        let handle = provisioned(&provider, dir.path()).await;
1164
1165        let remote = handle.remote.as_ref().expect("remote state");
1166        assert!(remote.id.is_empty(), "no id when create failed");
1167        assert!(
1168            matches!(&remote.poll, PollOutcome::Failed { reason } if reason.contains("HTTP 401")),
1169            "{:?}",
1170            remote.poll
1171        );
1172        let mut progress = Progress::default();
1173        let outcome = provider
1174            .readiness(&handle, &mut progress.sink())
1175            .await
1176            .expect("readiness");
1177        assert!(
1178            matches!(outcome, ReadinessOutcome::ProviderFailed { .. }),
1179            "{outcome:?}"
1180        );
1181
1182        for mode in [
1183            TeardownMode::Keep,
1184            TeardownMode::Hibernate,
1185            TeardownMode::Destroy,
1186        ] {
1187            provider
1188                .teardown(handle.clone(), mode)
1189                .await
1190                .expect("teardown with no workspace no-ops");
1191        }
1192        assert!(
1193            !client
1194                .ops()
1195                .iter()
1196                .any(|op| op.starts_with("stop:") || op.starts_with("delete:")),
1197            "nothing exists ⇒ no substrate teardown calls: {:?}",
1198            client.ops()
1199        );
1200    }
1201
1202    /// Teardown modes map to the substrate lifecycle: Destroy=delete,
1203    /// Hibernate=stop, Keep=leave running (no call).
1204    #[tokio::test]
1205    async fn remote_workspace_teardown_modes_map_to_delete_stop_keep() {
1206        let dir = tempfile::tempdir().expect("tempdir");
1207
1208        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1209        let provider = provider_with(Arc::clone(&client));
1210        let handle = provisioned(&provider, dir.path()).await;
1211        provider
1212            .teardown(handle, TeardownMode::Keep)
1213            .await
1214            .expect("keep");
1215        assert!(
1216            !client
1217                .ops()
1218                .iter()
1219                .any(|op| op.starts_with("stop:") || op.starts_with("delete:")),
1220            "Keep leaves the workspace running — no teardown call: {:?}",
1221            client.ops()
1222        );
1223
1224        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1225        let provider = provider_with(Arc::clone(&client));
1226        let handle = provisioned(&provider, dir.path()).await;
1227        provider
1228            .teardown(handle, TeardownMode::Hibernate)
1229            .await
1230            .expect("hibernate");
1231        assert!(
1232            client.ops().contains(&"stop:ws-abc123".to_string())
1233                && !client.ops().iter().any(|op| op.starts_with("delete:")),
1234            "Hibernate is stop_workspace: {:?}",
1235            client.ops()
1236        );
1237
1238        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1239        let provider = provider_with(Arc::clone(&client));
1240        let handle = provisioned(&provider, dir.path()).await;
1241        provider
1242            .teardown(handle, TeardownMode::Destroy)
1243            .await
1244            .expect("destroy");
1245        assert!(
1246            client.ops().contains(&"delete:ws-abc123".to_string())
1247                && !client.ops().iter().any(|op| op.starts_with("stop:")),
1248            "Destroy is delete_workspace: {:?}",
1249            client.ops()
1250        );
1251    }
1252
1253    /// `workspace.remote.idleAfterHours` (ticket `workspace-idle-hibernate`):
1254    /// the VALUE passes through require → provision → `create_workspace`
1255    /// verbatim and is recorded in the provisioned detail as substrate-owned
1256    /// policy — kranz records and passes through, never schedules.
1257    #[tokio::test]
1258    async fn idle_after_hours_passes_through_to_create_and_the_provisioned_detail() {
1259        // require() carries the optional value through (the only
1260        // non-required remote key); absent config leaves it None.
1261        let validated = RemoteConfig::require(Some(&RemoteWorkspaceConfig {
1262            base_url: Some("https://coder.internal.example.com".to_string()),
1263            template: Some("tmpl-baked-ami".to_string()),
1264            token_env: Some("CODER_SESSION_TOKEN".to_string()),
1265            idle_after_hours: Some(24.0),
1266        }))
1267        .expect("complete remote config validates with an idle policy");
1268        assert_eq!(validated.idle_after_hours, Some(24.0));
1269        let without = RemoteConfig::require(Some(&RemoteWorkspaceConfig {
1270            base_url: Some("https://coder.internal.example.com".to_string()),
1271            template: Some("tmpl-baked-ami".to_string()),
1272            token_env: Some("CODER_SESSION_TOKEN".to_string()),
1273            idle_after_hours: None,
1274        }))
1275        .expect("complete remote config validates without an idle policy");
1276        assert_eq!(without.idle_after_hours, None);
1277
1278        let dir = tempfile::tempdir().expect("tempdir");
1279        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1280        let provider = RemoteWorkspaceProvider::with_client(
1281            validated,
1282            Arc::clone(&client) as Arc<dyn SubstrateClient>,
1283        );
1284        let handle = provisioned(&provider, dir.path()).await;
1285
1286        let created = client.created.lock().unwrap().clone();
1287        assert_eq!(created.len(), 1);
1288        assert_eq!(
1289            created[0].idle_after_hours,
1290            Some(24.0),
1291            "the idle policy VALUE crosses to the substrate verbatim"
1292        );
1293        assert!(
1294            handle
1295                .detail
1296                .as_deref()
1297                .unwrap()
1298                .contains("idle policy: hibernate after 24h (substrate-owned)"),
1299            "the provisioned event records the substrate-owned policy: {:?}",
1300            handle.detail
1301        );
1302    }
1303
1304    /// No contract ⇒ nothing provisioned remotely (D-H): no substrate call,
1305    /// no credentials consulted, and readiness is trivially ready + silent.
1306    #[tokio::test]
1307    async fn remote_workspace_contract_less_provision_never_touches_the_substrate() {
1308        let dir = tempfile::tempdir().expect("tempdir");
1309        let client = FakeSubstrateClient::new(vec![SubstrateStatus::Ready]);
1310        let provider = provider_with(Arc::clone(&client));
1311        let handle = provider
1312            .provision(&spec(dir.path(), None))
1313            .await
1314            .expect("contract-less provision");
1315        assert!(handle.remote.is_none() && handle.contract.is_none());
1316        assert!(
1317            client.created.lock().unwrap().is_empty() && client.ops().is_empty(),
1318            "no contract ⇒ no substrate workspace (D-H)"
1319        );
1320        let mut progress = Progress::default();
1321        let outcome = provider
1322            .readiness(&handle, &mut progress.sink())
1323            .await
1324            .expect("readiness");
1325        assert!(matches!(outcome, ReadinessOutcome::Ready));
1326        assert!(progress.0.is_empty(), "contract-less ⇒ silent");
1327    }
1328
1329    /// The production hook fails CLOSED when the token env var is unset —
1330    /// naming the var NAME (never a value) and the config key, owner
1331    /// operator.
1332    #[tokio::test]
1333    async fn remote_workspace_missing_creds_fail_closed_naming_the_env_var() {
1334        let dir = tempfile::tempdir().expect("tempdir");
1335        let var = format!("KRANZ_TEST_REMOTE_TOKEN_UNSET_{}", std::process::id());
1336        std::env::remove_var(&var); // defensive: prove unset
1337        let mut cfg = config();
1338        cfg.token_env = var.clone();
1339        let provider = RemoteWorkspaceProvider {
1340            config: cfg,
1341            client_hook: Arc::new(coder_client_from_env),
1342            ready_timeout: Duration::from_millis(50),
1343            poll_interval: Duration::from_millis(5),
1344        };
1345        let err = provider
1346            .provision(&spec(dir.path(), Some(remote_contract())))
1347            .await
1348            .expect_err("missing creds must fail closed at provision");
1349        let msg = err.to_string();
1350        assert!(msg.contains(&var), "names the env var NAME: {msg}");
1351        assert!(msg.contains("workspace.remote.tokenEnv"), "{msg}");
1352        assert!(msg.contains("owner: operator"), "{msg}");
1353        assert!(
1354            msg.contains("refusing rather than silently falling back"),
1355            "{msg}"
1356        );
1357    }
1358
1359    #[test]
1360    fn remote_workspace_name_is_mission_owned_and_coder_shaped() {
1361        assert_eq!(workspace_name("m-test"), "kranz-remote-m-test");
1362        assert_eq!(workspace_name("M.Test X"), "kranz-remote-m-test-x");
1363        assert_eq!(
1364            workspace_name("..."),
1365            "kranz-remote----",
1366            "every non-alnum sanitizes to '-'"
1367        );
1368        assert_eq!(
1369            workspace_name(""),
1370            "kranz-remote-mission",
1371            "the prefix guarantees a valid leading character"
1372        );
1373    }
1374
1375    #[test]
1376    fn coder_http_client_rejects_a_bad_base_url() {
1377        let err = CoderHttpClient::new("not a url", "tok")
1378            .err()
1379            .expect("invalid URL fails closed");
1380        assert!(
1381            err.to_string().contains("workspace.remote.baseUrl"),
1382            "{err}"
1383        );
1384        let err = CoderHttpClient::new("file:///etc/passwd", "tok")
1385            .err()
1386            .expect("non-http(s) schemes fail closed");
1387        assert!(err.to_string().contains("http(s)"), "{err}");
1388    }
1389
1390    #[test]
1391    fn coder_http_status_mapping_is_conservative() {
1392        assert_eq!(
1393            parse_status(&serde_json::json!({"status": "running"})),
1394            SubstrateStatus::Ready
1395        );
1396        assert_eq!(
1397            parse_status(&serde_json::json!({"latest_build": {"status": "running"}})),
1398            SubstrateStatus::Ready,
1399            "stock Coder nests the state under latest_build"
1400        );
1401        assert!(matches!(
1402            parse_status(&serde_json::json!({"latest_build": {"status": "failed"}})),
1403            SubstrateStatus::Failed { .. }
1404        ));
1405        for unknown in [
1406            serde_json::json!({"latest_build": {"status": "starting"}}),
1407            serde_json::json!({"status": "stopping"}),
1408            serde_json::json!({}),
1409        ] {
1410            assert_eq!(
1411                parse_status(&unknown),
1412                SubstrateStatus::Pending,
1413                "unknown/absent states keep polling, never guess ready: {unknown}"
1414            );
1415        }
1416    }
1417
1418    /// Find a subslice (the header/body separator) in a byte buffer.
1419    fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1420        haystack
1421            .windows(needle.len())
1422            .position(|window| window == needle)
1423    }
1424
1425    /// A one-request canned-response HTTP server on 127.0.0.1 — the ONLY
1426    /// network the tests touch. Reads one request (headers + content-length
1427    /// body), records it, and responds with `body` as JSON. Driven with
1428    /// `tokio::join!` against the client call, so no spawned task outlives
1429    /// the exchange.
1430    async fn serve_once(
1431        listener: tokio::net::TcpListener,
1432        body: &str,
1433        recorded: Arc<Mutex<Vec<String>>>,
1434    ) {
1435        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1436        let (mut socket, _) = listener.accept().await.expect("accept");
1437        let mut buf = Vec::new();
1438        let mut chunk = [0u8; 4096];
1439        loop {
1440            let n = socket.read(&mut chunk).await.expect("read request");
1441            assert!(n > 0, "connection closed before the full request arrived");
1442            buf.extend_from_slice(&chunk[..n]);
1443            if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
1444                let headers = String::from_utf8_lossy(&buf[..pos]).to_string();
1445                let content_length = headers
1446                    .lines()
1447                    .find_map(|line| {
1448                        line.to_ascii_lowercase()
1449                            .strip_prefix("content-length:")
1450                            .and_then(|value| value.trim().parse::<usize>().ok())
1451                    })
1452                    .unwrap_or(0);
1453                if buf.len() >= pos + 4 + content_length {
1454                    break;
1455                }
1456            }
1457        }
1458        recorded
1459            .lock()
1460            .unwrap()
1461            .push(String::from_utf8_lossy(&buf).to_string());
1462        let response = format!(
1463            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
1464            body.len()
1465        );
1466        socket
1467            .write_all(response.as_bytes())
1468            .await
1469            .expect("write response");
1470    }
1471
1472    /// The Coder-shaped wire mapping, proven against a loopback mock: auth
1473    /// header, create body (template + name + secret NAMES, no values), and
1474    /// response parsing (id/urls/auth/takeover).
1475    #[tokio::test]
1476    async fn coder_http_client_maps_the_coder_shaped_wire_over_loopback() {
1477        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1478            .await
1479            .expect("bind loopback");
1480        let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1481        let recorded = Arc::new(Mutex::new(Vec::new()));
1482        let create_body = serde_json::json!({
1483            "id": "ws-loop1",
1484            "urls": [{"name": "app", "url": "https://app.example.com", "auth": true}],
1485            "takeover": "https://coder.example.com/@me/ws-loop1",
1486        })
1487        .to_string();
1488
1489        let client = CoderHttpClient::new(&base_url, "test-session-token").expect("client");
1490        let create_spec = SubstrateWorkspaceSpec {
1491            template: "tmpl-1".to_string(),
1492            name: "kranz-remote-m-1".to_string(),
1493            env_names: vec!["DATABASE_URL".to_string()],
1494            idle_after_hours: None,
1495        };
1496        let ((), workspace) = tokio::join!(
1497            serve_once(listener, &create_body, Arc::clone(&recorded)),
1498            tokio::time::timeout(
1499                Duration::from_secs(10),
1500                client.create_workspace(&create_spec)
1501            )
1502        );
1503        let workspace = workspace.expect("bounded").expect("create_workspace");
1504
1505        assert_eq!(workspace.id, "ws-loop1");
1506        assert_eq!(
1507            workspace.urls,
1508            vec![SubstrateUrl {
1509                name: "app".to_string(),
1510                url: "https://app.example.com".to_string(),
1511                auth: Some(true),
1512            }]
1513        );
1514        assert_eq!(
1515            workspace.takeover.as_deref(),
1516            Some("https://coder.example.com/@me/ws-loop1")
1517        );
1518
1519        let requests = recorded.lock().unwrap().clone();
1520        assert_eq!(requests.len(), 1);
1521        let request = &requests[0];
1522        assert!(
1523            request.starts_with("POST /api/v2/users/me/workspaces "),
1524            "the create verb+path: {}",
1525            request.lines().next().unwrap_or("")
1526        );
1527        assert!(
1528            request.contains("coder-session-token: test-session-token"),
1529            "Coder's auth header carries the token (and the token appears NOWHERE else): {request}"
1530        );
1531        let body = request.split("\r\n\r\n").nth(1).expect("a JSON body");
1532        let body: serde_json::Value = serde_json::from_str(body).expect("body is JSON");
1533        assert_eq!(body["template_id"], "tmpl-1");
1534        assert_eq!(body["name"], "kranz-remote-m-1");
1535        assert_eq!(
1536            body["env_names"],
1537            serde_json::json!(["DATABASE_URL"]),
1538            "secret NAMES on the wire — never values"
1539        );
1540    }
1541
1542    /// The create body carries `idle_after_hours` only when configured
1543    /// (ticket `workspace-idle-hibernate`) — additive on the wire, so a
1544    /// substrate without idle-policy support is never sent a key it must
1545    /// understand. The substrate owns the policy's execution.
1546    #[tokio::test]
1547    async fn coder_http_create_body_carries_idle_after_hours_only_when_configured() {
1548        for (idle_after_hours, expected) in
1549            [(Some(24.0), Some(serde_json::json!(24.0))), (None, None)]
1550        {
1551            let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1552                .await
1553                .expect("bind loopback");
1554            let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1555            let recorded = Arc::new(Mutex::new(Vec::new()));
1556            let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1557            let spec = SubstrateWorkspaceSpec {
1558                template: "tmpl-1".to_string(),
1559                name: "kranz-remote-m-1".to_string(),
1560                env_names: vec![],
1561                idle_after_hours,
1562            };
1563            let ((), created) = tokio::join!(
1564                serve_once(listener, r#"{"id":"ws-1"}"#, Arc::clone(&recorded)),
1565                tokio::time::timeout(Duration::from_secs(10), client.create_workspace(&spec))
1566            );
1567            created.expect("bounded").expect("create_workspace");
1568            let request = recorded.lock().unwrap()[0].clone();
1569            let body = request.split("\r\n\r\n").nth(1).expect("a JSON body");
1570            let body: serde_json::Value = serde_json::from_str(body).expect("body is JSON");
1571            assert_eq!(
1572                body.get("idle_after_hours").cloned(),
1573                expected,
1574                "idle_after_hours rides the wire only when configured: {body}"
1575            );
1576        }
1577    }
1578
1579    /// Status + transitions over the same loopback: nested `latest_build`
1580    /// parsing and the delete/stop transition bodies.
1581    #[tokio::test]
1582    async fn coder_http_client_status_and_transitions_over_loopback() {
1583        let recorded = Arc::new(Mutex::new(Vec::new()));
1584
1585        // Status: stock Coder nests the state under latest_build.
1586        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1587            .await
1588            .expect("bind loopback");
1589        let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1590        let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1591        let ((), status) = tokio::join!(
1592            serve_once(
1593                listener,
1594                r#"{"latest_build":{"status":"running"}}"#,
1595                Arc::clone(&recorded)
1596            ),
1597            tokio::time::timeout(Duration::from_secs(10), client.workspace_status("ws-9"))
1598        );
1599        let status = status.expect("bounded").expect("status");
1600        assert_eq!(status, SubstrateStatus::Ready);
1601        assert!(
1602            recorded.lock().unwrap()[0].starts_with("GET /api/v2/workspaces/ws-9 "),
1603            "status path: {:?}",
1604            recorded.lock().unwrap()[0].lines().next()
1605        );
1606
1607        // stop + delete are workspace-build transitions.
1608        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1609            .await
1610            .expect("bind loopback");
1611        let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1612        let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1613        let ((), stopped) = tokio::join!(
1614            serve_once(listener, "{}", Arc::clone(&recorded)),
1615            tokio::time::timeout(Duration::from_secs(10), client.stop_workspace("ws-9"))
1616        );
1617        stopped.expect("bounded").expect("stop");
1618        let stop_request = recorded.lock().unwrap().last().unwrap().clone();
1619        assert!(
1620            stop_request.starts_with("POST /api/v2/workspaces/ws-9/builds "),
1621            "{stop_request}"
1622        );
1623        assert!(
1624            stop_request.contains(r#""transition":"stop""#),
1625            "the stop transition body: {stop_request}"
1626        );
1627
1628        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1629            .await
1630            .expect("bind loopback");
1631        let base_url = format!("http://{}", listener.local_addr().expect("addr"));
1632        let client = CoderHttpClient::new(&base_url, "tok").expect("client");
1633        let ((), deleted) = tokio::join!(
1634            serve_once(listener, "{}", Arc::clone(&recorded)),
1635            tokio::time::timeout(Duration::from_secs(10), client.delete_workspace("ws-9"))
1636        );
1637        deleted.expect("bounded").expect("delete");
1638        let delete_request = recorded.lock().unwrap().last().unwrap().clone();
1639        assert!(
1640            delete_request.contains(r#""transition":"delete""#),
1641            "the delete transition body: {delete_request}"
1642        );
1643    }
1644}