OXYGENHUB

Secrets mount as files: secret blocks, the claim's secrets, flow-init writes them e2c125610f43

A job's secret "name" { mount-path "/run/secrets/…" } names a secret
the hub holds for the repository; the claim carries each with its path
and value, and flow-init writes it there before the job starts, 0400,
the job's user's, on a tmpfs of its own. Never in the environment, the
receipt or an artifact (spec §9). flow run --secret NAME=VALUE supplies
one locally. refs oxygenhub#107
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 16:13 UTC
Signature
✔ signed by Andy McCall
Hash
e2c125610f43c2ce8301e12710ac1f915bee4f17f93a632229aed319b234cad3
Depends on
a67b97e9a717 c23d519168d8 c3db750ed96e c4285535b03a c58efc6a74db e0918b887850 e576e08617dc eb5c4db673e5 f98a8f7881e0 fc10d9e80383
Depended on by
2be1fe4ad345 f4e51e63582d e20897216221 46fe2c815cc3 ea81796bb757 6d53fb7d00ec 9a6fac2b40a9 ea56c56eaf90 1abadd4a2fa6 720eb8191059
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
38 atoms, 0 tree operations, 12 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Secrets as files (oxygenhub#107).** A job's `secret "name" { mount-path "/run/secrets/…" }` names a secret the hub holds; it comes with the claim and `flow-init` writes it at that path, 0400, on a tmpfs, before the job starts. Never in the environment, the receipt or an artifact (spec §9). `flow run --secret NAME=VALUE` locally.
8 +
7 9
- **Variables from the hub (oxygenhub#102).** A claim carries `env`, the variables the hub holds for the repository; they join the job's environment under `FLOW_*` and the pipeline's own `env`. `flow run --var NAME=VALUE` does the same locally.
8 10
9 11
- **A job says how long it may run (#23).** `timeout "90m"` on a job, or on a sandbox for its jobs (hours, minutes, seconds; a minute to a day). The hub caps it at the site's maximum.
…
10 unchanged lines
modified crates/flow-core/src/api.rs +14 −0
…
101 unchanged lines
102 102
    pub heads: Vec<String>,
103 103
}
104 104
105 +
/// A secret for a job, as the hub supplies it (spec §9): written by the
106 +
/// runner at `path` in the sandbox, 0400, on an in-memory mount; never in
107 +
/// the environment, the receipt or an artifact.
108 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109 +
pub struct SecretInput {
110 +
    pub name: String,
111 +
    pub path: String,
112 +
    pub value: String,
113 +
}
114 +
105 115
/// A claimed job: everything the runner needs, nothing more.
106 116
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107 117
pub struct Claim {
…
33 unchanged lines
141 151
    /// those are mounted, not put in the environment (spec §9).
142 152
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
143 153
    pub env: std::collections::BTreeMap<String, String>,
154 +
    /// The secrets the job declares (`secret` blocks), as the hub holds
155 +
    /// them for the repository (oxygenhub#107).
156 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 +
    pub secrets: Vec<SecretInput>,
144 158
}
145 159
146 160
impl Claim {
…
63 unchanged lines
modified crates/flow-core/src/canonical.rs +4 −0
…
72 unchanged lines
73 73
    if !j.outputs.is_empty() {
74 74
        fields.push((7, Value::Array(j.outputs.iter().map(|o| Value::Array(vec![text(&o.name), text(&o.path)])).collect())));
75 75
    }
76 +
    // Secrets as [name, mount path] sorted by name; absent when there are none.
77 +
    if !j.secrets.is_empty() {
78 +
        fields.push((8, Value::Array(j.secrets.iter().map(|s| Value::Array(vec![text(&s.name), text(&s.mount_path)])).collect())));
79 +
    }
76 80
    Value::map(fields)
77 81
}
78 82
…
41 unchanged lines
modified crates/flow-core/src/lib.rs +1 −1
…
9 unchanged lines
10 10
pub mod receipt;
11 11
12 12
pub use canonical::{object_hash, scope_tree_hash, Hash};
13 -
pub use pipeline::{Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, PIPELINE_PATH};
13 +
pub use pipeline::{Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, Secret, PIPELINE_PATH};
14 14
pub use receipt::{Environment, Receipt};
15 15
16 16
#[cfg(test)]
…
1 unchanged lines
modified crates/flow-core/src/pipeline.rs +67 −2
…
95 unchanged lines
96 96
    /// Files the job produces, sorted by name (spec §5.2 `output`).
97 97
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 98
    pub outputs: Vec<Output>,
99 +
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
100 +
    /// supplies each by name, the runner mounts it as a file.
101 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 +
    pub secrets: Vec<Secret>,
99 103
    /// Labels a runner must have to take this job (`runs-on`), sorted;
100 104
    /// with its sandbox's, what the hub matches against a runner's.
101 105
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
…
66 unchanged lines
168 172
    pub name: String,
169 173
    /// A clean absolute path under `/src`, not on a tmpfs mount.
170 174
    pub path: String,
175 +
}
176 +
177 +
/// A secret the job reads as a file (spec §9): never in the environment.
178 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179 +
pub struct Secret {
180 +
    /// Which secret, as the hub names it; unique in the job.
181 +
    pub name: String,
182 +
    /// A clean absolute path under `/run/secrets/`, unique in the job;
183 +
    /// the file is 0400, the job's user's, on an in-memory mount.
184 +
    pub mount_path: String,
171 185
}
172 186
187 +
/// A secret's mount path: under `/run/secrets/`, no `.` or `..`.
188 +
pub fn secret_path_ok(p: &str) -> bool {
189 +
    p.strip_prefix("/run/secrets/").is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
190 +
}
191 +
173 192
/// Artifacts a job may declare.
174 193
pub const MAX_OUTPUTS: usize = 16;
175 194
…
9 unchanged lines
185 204
186 205
/// Mounts a job may declare.
187 206
pub const MAX_MOUNTS: usize = 4;
207 +
/// The most secrets a job may mount.
208 +
pub const MAX_SECRETS: usize = 8;
188 209
/// Sizes a mount may have: 16 MiB to 64 GiB.
189 210
pub const MIN_MOUNT_MIB: u64 = 16;
190 211
pub const MAX_MOUNT_MIB: u64 = 64 * 1024;
…
34 unchanged lines
225 246
226 247
/// Nodes of the full spec that M0 recognises and refuses.
227 248
const LATER_TOP: &[&str] = &["publish", "secret"];
228 -
const LATER_JOB: &[&str] = &["secret", "attest"];
249 +
const LATER_JOB: &[&str] = &["attest"];
229 250
230 251
struct Ctx<'a> {
231 252
    src: &'a str,
…
553 unchanged lines
785 806
}
786 807
787 808
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
809 +
/// `secret "deploy-token" { mount-path "/run/secrets/token" }` (spec §9.1).
810 +
/// The hub is the vault: the secret of that name set for the repository,
811 +
/// its organisation or the site. `vault-ref` is not in this slice.
812 +
fn parse_secret(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
813 +
    let Some(name) = ctx.one_string(node) else {
814 +
        ctx.err(node.span(), "`secret` takes a name and a block with mount-path");
815 +
        return;
816 +
    };
817 +
    if !ctx.name_ok(node.span(), "secret", &name) {
818 +
        return;
819 +
    }
820 +
    let mut path = None;
821 +
    for c in block(ctx, node) {
822 +
        ctx.no_children(c);
823 +
        match c.name().value() {
824 +
            "mount-path" => match ctx.one_string(c) {
825 +
                Some(p) if secret_path_ok(&p) => path = Some(p),
826 +
                Some(p) => {
827 +
                    ctx.err(c.span(), format!("secret mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
828 +
                }
829 +
                None => {}
830 +
            },
831 +
            "vault-ref" => ctx.err(c.span(), "`vault-ref` is not in M0; the hub supplies the secret of that name"),
832 +
            other => ctx.err(c.span(), format!("unknown secret setting `{other}`")),
833 +
        }
834 +
    }
835 +
    let Some(mount_path) = path else {
836 +
        ctx.err(node.span(), format!("secret {name:?} needs a mount-path"));
837 +
        return;
838 +
    };
839 +
    if job.secrets.iter().any(|s| s.name == name) {
840 +
        ctx.err(node.span(), format!("secret {name:?} is given twice"));
841 +
    } else if job.secrets.iter().any(|s| s.mount_path == mount_path) {
842 +
        ctx.err(node.span(), format!("two secrets mount at {mount_path:?}"));
843 +
    } else if job.secrets.len() == MAX_SECRETS {
844 +
        ctx.err(node.span(), format!("a job has at most {MAX_SECRETS} secrets"));
845 +
    } else {
846 +
        job.secrets.push(Secret { name, mount_path });
847 +
    }
848 +
}
849 +
788 850
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
789 851
    if !node.entries().is_empty() {
790 852
        ctx.err(node.span(), "`output` takes a block of artifacts");
…
52 unchanged lines
843 905
        env: BTreeMap::new(),
844 906
        exec: vec![],
845 907
        mounts: vec![],
908 +
        secrets: vec![],
846 909
        outputs: vec![],
847 910
        runs_on: vec![],
848 911
        timeout_s: None,
…
2 unchanged lines
851 914
    let mut outputs_at = None;
852 915
    for c in block(ctx, node) {
853 916
        let key = c.name().value();
854 -
        if key != "scope" && key != "depends-on" && key != "mount" && key != "runs-on" && !seen.insert(key.to_string()) {
917 +
        if key != "scope" && key != "depends-on" && key != "mount" && key != "secret" && key != "runs-on" && !seen.insert(key.to_string()) {
855 918
            ctx.err(c.span(), format!("`{key}` is given twice"));
856 919
        }
857 920
        match key {
…
54 unchanged lines
912 975
                outputs_at = Some(c.span());
913 976
                parse_output(ctx, c, &mut job);
914 977
            }
978 +
            "secret" => parse_secret(ctx, c, &mut job),
915 979
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
916 980
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
917 981
        }
918 982
    }
919 983
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
920 984
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
985 +
    job.secrets.sort_by(|a, b| a.name.cmp(&b.name));
921 986
    // A tmpfs is gone when the job ends; nothing on it can be kept.
922 987
    for o in &job.outputs {
923 988
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
…
21 unchanged lines
modified crates/flow-core/src/tests.rs +49 −1
…
261 unchanged lines
262 262
}
263 263
264 264
#[test]
265 +
fn secrets_mount_as_files_and_are_part_of_the_job() {
266 +
    let plain = Pipeline::parse(&doc("job \"d\" {\n    sandbox \"alpine\"\n    exec \"deploy\"\n}\n")).unwrap();
267 +
    let p = Pipeline::parse(&doc(r#"job "d" {
268 +
    sandbox "alpine"
269 +
    secret "token" {
270 +
        mount-path "/run/secrets/token"
271 +
    }
272 +
    secret "aws" {
273 +
        mount-path "/run/secrets/aws/credentials"
274 +
    }
275 +
    exec "deploy"
276 +
}"#))
277 +
    .unwrap();
278 +
    let d = &p.jobs["d"];
279 +
    assert_eq!(
280 +
        d.secrets,
281 +
        vec![
282 +
            Secret { name: "aws".into(), mount_path: "/run/secrets/aws/credentials".into() },
283 +
            Secret { name: "token".into(), mount_path: "/run/secrets/token".into() },
284 +
        ],
285 +
        "sorted by name"
286 +
    );
287 +
    assert_ne!(p.hash(), plain.hash(), "secrets are part of the job");
288 +
    for (bad, why) in [
289 +
        ("secret \"t\" {\n    mount-path \"/tmp/t\"\n}", "under /run/secrets/"),
290 +
        ("secret \"t\" {\n    mount-path \"/run/secrets/../x\"\n}", "under /run/secrets/"),
291 +
        ("secret \"t\" {\n}", "needs a mount-path"),
292 +
        ("secret \"t\" {\n    mount-path \"/run/secrets/a\"\n}\nsecret \"t\" {\n    mount-path \"/run/secrets/b\"\n}", "given twice"),
293 +
        (
294 +
            "secret \"t\" {\n    mount-path \"/run/secrets/a\"\n}\nsecret \"u\" {\n    mount-path \"/run/secrets/a\"\n}",
295 +
            "two secrets mount at",
296 +
        ),
297 +
        ("secret \"t\" {\n    vault-ref \"x\"\n    mount-path \"/run/secrets/a\"\n}", "vault-ref"),
298 +
    ] {
299 +
        let e = Pipeline::parse(&doc(&format!("job \"d\" {{\n    sandbox \"alpine\"\n    {bad}\n    exec \"deploy\"\n}}\n"))).unwrap_err();
300 +
        assert!(e.to_string().contains(why), "{bad}: {e}");
301 +
    }
302 +
}
303 +
304 +
#[test]
265 305
fn jobs_are_told_what_they_build() {
266 306
    let p = Pipeline::parse(&doc(r#"job "build" {
267 307
    sandbox "alpine"
…
20 unchanged lines
288 328
        bookmark: "feature/x".into(),
289 329
        run_number: 3,
290 330
        env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(),
331 +
        secrets: vec![],
291 332
    };
292 333
    let env = job.with_flow_env(&claim.flow_env()).env;
293 334
    assert_eq!(env["CC"], "clang", "the hub's variables reach the job");
…
4 unchanged lines
298 339
    assert_eq!(env["FLOW_JOB"], "build");
299 340
    assert_eq!(env["FLOW_BOOKMARK"], "pinned-by-the-pipeline", "the pipeline's own value wins");
300 341
    // Unknown values (an older hub) are left out, not empty.
301 -
    let old = crate::api::Claim { state: String::new(), bookmark: String::new(), run_number: 0, env: Default::default(), ..claim };
342 +
    let old = crate::api::Claim {
343 +
        state: String::new(),
344 +
        bookmark: String::new(),
345 +
        run_number: 0,
346 +
        env: Default::default(),
347 +
        secrets: vec![],
348 +
        ..claim
349 +
    };
302 350
    let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect();
303 351
    assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]);
304 352
}
…
113 unchanged lines
modified crates/flow-init/src/main.rs +33 −0
…
23 unchanged lines
24 24
    /// `network "loopback"`: bring up `lo` (the guest has no other device).
25 25
    #[serde(default)]
26 26
    loopback: bool,
27 +
    /// Secrets to write before the job starts (spec §9): each at `path`
28 +
    /// under `/run/secrets`, 0400 and the job's user's, on a tmpfs.
29 +
    #[serde(default)]
30 +
    secrets: Vec<SecretSpec>,
31 +
}
32 +
33 +
#[derive(serde::Deserialize)]
34 +
struct SecretSpec {
35 +
    path: String,
36 +
    value: String,
27 37
}
28 38
29 39
/// An ephemeral mount from the host: a tmpfs of `size_mib`, or the disk at
…
196 unchanged lines
226 236
                chown(&target, uid, gid)?;
227 237
            }
228 238
            (kind, _) => return Err(format!("unknown mount kind {kind}")),
239 +
        }
240 +
    }
241 +
    // Secrets: an in-memory mount of their own, only the job's user's,
242 +
    // each file 0400; the job file they came from is outside the new root.
243 +
    if !job.secrets.is_empty() {
244 +
        if !std::path::Path::new("/newroot/run").is_dir() {
245 +
            return Err("the image has no /run, where secrets are mounted".into());
246 +
        }
247 +
        let dir = "/newroot/run/secrets";
248 +
        std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {dir}: {e}"))?;
249 +
        mount("tmpfs", dir, "tmpfs", nosuid | libc::MS_NOEXEC, &format!("size=1m,uid={uid},gid={gid},mode=700"))?;
250 +
        for s in &job.secrets {
251 +
            let Some(rel) = s.path.strip_prefix("/run/secrets/") else {
252 +
                return Err(format!("secret path {:?} is not under /run/secrets/", s.path));
253 +
            };
254 +
            let full = format!("{dir}/{rel}");
255 +
            if let Some(parent) = std::path::Path::new(&full).parent().and_then(|p| p.to_str()) {
256 +
                make_dirs_owned(parent, uid, gid)?;
257 +
            }
258 +
            std::fs::write(&full, s.value.as_bytes()).map_err(|e| format!("write {}: {e}", s.path))?;
259 +
            chown(&full, uid, gid)?;
260 +
            std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400))
261 +
                .map_err(|e| format!("chmod {}: {e}", s.path))?;
229 262
        }
230 263
    }
231 264
    if job.loopback {
…
145 unchanged lines
modified crates/flow/src/main.rs +36 −12
…
76 unchanged lines
77 77
        /// the pipeline's own `env` wins
78 78
        #[arg(long = "var", value_name = "NAME=VALUE")]
79 79
        vars: Vec<String>,
80 +
        /// A secret a job's `secret` block names, as the hub would supply
81 +
        /// it; mounted as a file, never in the environment
82 +
        #[arg(long = "secret", value_name = "NAME=VALUE")]
83 +
        secrets: Vec<String>,
80 84
    },
81 85
    /// Claim and run jobs for the hub in flow.toml's [runner] (or the
82 86
    /// runner.toml `flow runner register` wrote)
…
63 unchanged lines
146 150
                }
147 151
            }
148 152
        }
149 -
        Cmd::Run { jobs, dir, timeout, vars } => {
153 +
        Cmd::Run { jobs, dir, timeout, vars, secrets } => {
150 154
            let home = Home::locate(cli.home.as_deref())?;
151 -
            let vars = vars
152 -
                .iter()
153 -
                .map(|e| {
154 -
                    e.split_once('=')
155 -
                        .map(|(k, v)| (k.to_string(), v.to_string()))
156 -
                        .ok_or_else(|| anyhow::anyhow!("--var {e:?}: expected NAME=VALUE"))
157 -
                })
158 -
                .collect::<Result<Vec<_>>>()?;
159 -
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars)
155 +
            let pairs = |what: &str, list: &[String]| {
156 +
                list.iter()
157 +
                    .map(|e| {
158 +
                        e.split_once('=')
159 +
                            .map(|(k, v)| (k.to_string(), v.to_string()))
160 +
                            .ok_or_else(|| anyhow::anyhow!("--{what} {e:?}: expected NAME=VALUE"))
161 +
                    })
162 +
                    .collect::<Result<Vec<_>>>()
163 +
            };
164 +
            let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?);
165 +
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets)
160 166
        }
161 167
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
162 168
            let home = Home::locate(cli.home.as_deref())?;
…
123 unchanged lines
286 292
    Ok(())
287 293
}
288 294
289 -
fn run_local(home: &Home, dir: &Path, only: &[String], timeout: Duration, vars: &[(String, String)]) -> Result<()> {
295 +
fn run_local(
296 +
    home: &Home,
297 +
    dir: &Path,
298 +
    only: &[String],
299 +
    timeout: Duration,
300 +
    vars: &[(String, String)],
301 +
    secrets: &[(String, String)],
302 +
) -> Result<()> {
290 303
    let p = load(&dir.join(PIPELINE_PATH))?;
291 304
    // The named jobs and everything they depend on.
292 305
    let mut wanted: BTreeSet<String> = only.iter().cloned().collect();
…
39 unchanged lines
332 345
            .in_sandbox(sb)
333 346
            .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())])
334 347
            .with_flow_env(vars);
335 -
        let out = microvm::run(home, sb, job, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
348 +
        // Each secret the job declares must be given, as the hub would.
349 +
        let inputs = job
350 +
            .secrets
351 +
            .iter()
352 +
            .map(|s| {
353 +
                let value = secrets.iter().find(|(k, _)| *k == s.name).map(|(_, v)| v.clone());
354 +
                value
355 +
                    .map(|value| flow_core::api::SecretInput { name: s.name.clone(), path: s.mount_path.clone(), value })
356 +
                    .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name))
357 +
            })
358 +
            .collect::<Result<Vec<_>>>()?;
359 +
        let out = microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
336 360
        let _ = std::fs::remove_dir_all(&js);
337 361
        match out.exit_code {
338 362
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
…
41 unchanged lines
modified crates/flow/src/microvm.rs +9 −1
…
4 unchanged lines
5 5
6 6
use crate::home::Home;
7 7
use anyhow::{bail, Context, Result};
8 -
use flow_core::api::Record;
8 +
use flow_core::api::{Record, SecretInput};
9 9
use flow_core::{Environment, Job, MountSource, Sandbox};
10 10
use std::io::{BufRead, BufReader, Read};
11 11
use std::path::Path;
…
30 unchanged lines
42 42
}
43 43
44 44
/// Boot, run, collect. `scratch` is an empty directory this call may fill.
45 +
#[allow(clippy::too_many_arguments)]
45 46
pub fn run(
46 47
    home: &Home,
47 48
    sandbox: &Sandbox,
48 49
    job: &Job,
50 +
    secrets: &[SecretInput],
49 51
    workspace: &Path,
50 52
    scratch: &Path,
51 53
    timeout: Duration,
…
12 unchanged lines
64 66
65 67
    let mut spec = guest_spec(job);
66 68
    spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback);
69 +
    // Secrets ride in the guest's job file, which only flow-init reads,
70 +
    // outside the root the job is confined to; it writes each as a file.
71 +
    if !secrets.is_empty() {
72 +
        spec["secrets"] =
73 +
            serde_json::Value::Array(secrets.iter().map(|s| serde_json::json!({ "path": s.path, "value": s.value })).collect());
74 +
    }
67 75
    // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the
68 76
    // scratch directory, attached after the workspace (vdc, vdd, ...).
69 77
    let mut disks = Vec::new();
…
157 unchanged lines
modified crates/flow/src/outputs.rs +1 −0
…
68 unchanged lines
69 69
            depends_on: vec![],
70 70
            env: Default::default(),
71 71
            exec: vec!["make".into()],
72 +
            secrets: vec![],
72 73
            mounts: vec![
73 74
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 },
74 75
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 },
…
32 unchanged lines
modified crates/flow/src/runner.rs +1 −0
…
442 unchanged lines
443 443
            home,
444 444
            &claim.sandbox,
445 445
            &job,
446 +
            &claim.secrets,
446 447
            &scratch.join("workspace.ext4"),
447 448
            &scratch,
448 449
            Duration::from_secs(claim.timeout_s),
…
265 unchanged lines
modified docs/m0.md +10 −2
…
52 unchanged lines
53 53
- Names are 1 to 64 characters from `[A-Za-z0-9._-]`.
54 54
- `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1).
55 55
  A job that wants a shell names one, as `sh` above; nothing adds one.
56 -
- `publish` and `secret` are recognised and refused with "not in M0", so
57 -
  a pipeline written for the full spec fails clearly.
56 +
- `publish` and a job's `attest` are recognised and refused with "not in
57 +
  M0", so a pipeline written for the full spec fails clearly.
58 +
- `secret "token" { mount-path "/run/secrets/token" }` on a job (up to 8)
59 +
  names a secret the hub holds for the repository (its own, its
60 +
  organisation's or the site's; oxygenhub#107) and where the job reads it:
61 +
  a clean path under `/run/secrets/`. The claim carries each as `secrets`
62 +
  and `flow-init` writes it there, 0400 and the job's user's, on a tmpfs
63 +
  of its own, before the job starts; it is never in the environment, the
64 +
  receipt or an artifact (spec §9). Locally, `flow run --secret
65 +
  NAME=VALUE` supplies it. `vault-ref` is not in M0: the hub is the vault.
58 66
- `output { artifact "oxy" { path "/src/target/release/oxy" } }` declares
59 67
  up to 16 **artifacts** (spec §5.2). The path is clean, absolute, under
60 68
  `/src`, and not on a tmpfs mount, which is gone when the job ends. After
…
158 unchanged lines