activator/host: runner mints the token, runs without a store, redacts paths
I1: activator.EnsureAdminToken (exported, serialized on a process-wide mutex) is now the runner's Token too — ensure-then-read in host.scheduleToken. An instance asleep since before the roll has no CARLOS_ADMIN_TOKEN and the tick IS the wake that would mint it; a read-only Token recorded "no instance token" five times and never woke it. Spec §6.4 says "first spawn, seed, or first tick — whichever comes first". I6: the runner starts whenever the activator is armed, store or no store — once-timers land in the registry off the control socket, and a box that mounts the verb must fire what it accepts. No store = no converger and no report (scheduleDeps.conv nil; sd.converge is the nil-safe call the three legs use), one log line saying so. I7: EnsureAdminToken chmods the file 0600 after the append — O_CREATE's mode never applied to a pre-existing 0644 file. Redaction: activator.RedactPaths is exported; the host's Token and Deliver wrappers redact their errors (they become last_status, then the bucket status object and `carlos schedule ls`). Context errors are left alone so the runner's errors.Is on the deadline still matches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+244
−75
docs/superpowers/specs/2026-08-23-scheduled-work-design.md+15 −7internal/activator/admintoken.go+29 −8internal/activator/admintoken_test.go+18 −3internal/activator/backend_exec.go+3 −3internal/activator/logsink.go+6 −3internal/host/agent.go+33 −24internal/host/schedule_wiring.go+87 −27internal/host/schedule_wiring_test.go+53 −0
diff --git a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md| index dc9dcf132..cf1622e1e 100644 |
| --- a/docs/superpowers/specs/2026-08-23-scheduled-work-design.md |
| +++ b/docs/superpowers/specs/2026-08-23-scheduled-work-design.md |
| @@ -336,13 +336,21 @@ runner sees it (box was down) fires **once** — §6.1's coalescing. |
| ### 6.4 The instance secret: `CARLOS_ADMIN_TOKEN` becomes agent-minted |
| The tick's authenticity rests on the instance's own admin token, and today |
| -nothing mints it (§1). **(decision)** `ExecBackend.Start` (and `Seed`, which |
| -shares `instanceEnv`) ensures `<DataDir>/<host>.env` carries |
| -`CARLOS_ADMIN_TOKEN` before the child is spawned: absent, it mints 32 random |
| -bytes as hex and appends the line (file created `0600` if missing; an |
| -existing value is never touched or rotated). The overlay rule already makes |
| -this file win over bucket config (`mergeEnv`), so the token cannot be |
| -clobbered by `carlos env`. |
| +nothing mints it (§1). **(decision)** `activator.EnsureAdminToken` ensures |
| +`<DataDir>/<host>.env` carries `CARLOS_ADMIN_TOKEN`: absent, it mints 32 |
| +random bytes as hex and appends the line (the file ends up `0600` whether it |
| +was created or already existed in a looser mode; an existing value is never |
| +touched or rotated). It is called by whichever comes first — the first |
| +spawn (`ExecBackend.Start`), the first seed (`Seed`, which shares |
| +`instanceEnv`), or the runner's first tick for the host (the runner's |
| +`Token` is ensure-then-read, `internal/host/schedule_wiring.go`). The third |
| +caller matters: an instance hibernating since before the roll has no token, |
| +and the tick is the wake that would have minted it — a read-only `Token` |
| +would record "no instance token" five times and never wake it. The three |
| +callers are serialized on one process-wide mutex, so a tick and a visitor |
| +racing on a fresh host mint one token, not two lines. The overlay rule |
| +already makes this file win over bucket config (`mergeEnv`), so the token |
| +cannot be clobbered by `carlos env`. |
| This also silently repairs the existing `/api/next-due` probe for every |
| console-created instance — a bug fix riding along, noted in the PR. |
diff --git a/internal/activator/admintoken.go b/internal/activator/admintoken.go| index d1835492f..225de9f2b 100644 |
| --- a/internal/activator/admintoken.go |
| +++ b/internal/activator/admintoken.go |
| @@ -13,8 +13,11 @@ package activator |
| // are the actual fix. Until this file nothing on the platform minted it: Eleven's own |
| // tooling wrote it by hand, console-created instances never had one at all, |
| // and the next-due probe failed silently for every one of them. Minted here, |
| -// once, on the first spawn (or seed) that finds it missing; never rotated by |
| -// the platform, because the running instance already holds the value and a |
| +// once, by whichever comes first: the first spawn, the first seed, or the |
| +// schedule runner's first tick for the host (internal/host/schedule_wiring.go |
| +// — an instance asleep since before the roll would otherwise wait for a |
| +// visitor to mint the token its own tick needs). Never rotated by the |
| +// platform, because the running instance already holds the value and a |
| // rotation behind its back would lock the agent out of its own instance. |
| import ( |
| @@ -23,15 +26,26 @@ import ( |
| "fmt" |
| "os" |
| "strings" |
| + "sync" |
| ) |
| -// ensureAdminToken makes sure path (an instance's own <host>.env) carries a |
| +// adminTokenMu serializes minting across the process. Two callers can find |
| +// the token missing at once — the runner's tick and a visitor-triggered |
| +// Start for the same host — and without this both would append a line, |
| +// leaving a file with two tokens of which readers see the last and the |
| +// first spawn's child holds the first. |
| +var adminTokenMu sync.Mutex |
| + |
| +// EnsureAdminToken makes sure path (an instance's own <host>.env) carries a |
| // CARLOS_ADMIN_TOKEN, minting one if it does not. The file is the instance's, |
| // not the agent's: every other line in it is preserved untouched and an |
| -// existing token is never rewritten. An absent file is created 0600 — the |
| -// same mode the env file carries everywhere else, and the only thing standing |
| -// between another UNIX user on the box and the instance's secret. |
| -func ensureAdminToken(path string) error { |
| +// existing token is never rewritten. The file ends up 0600 whether it was |
| +// created here or already existed in a looser mode — the same mode the env |
| +// file carries everywhere else, and the only thing standing between another |
| +// UNIX user on the box and the instance's secret. |
| +func EnsureAdminToken(path string) error { |
| + adminTokenMu.Lock() |
| + defer adminTokenMu.Unlock() |
| // Read the raw bytes once: they answer both questions this needs — is a |
| // token already there, and does the file end on a line boundary. |
| raw, err := os.ReadFile(path) |
| @@ -61,12 +75,19 @@ func ensureAdminToken(path string) error { |
| if _, err := fmt.Fprintf(f, "%sCARLOS_ADMIN_TOKEN=%s\n", prefix, hex.EncodeToString(secret)); err != nil { |
| return fmt.Errorf("write %s: %w", path, err) |
| } |
| + // O_CREATE's mode applies only to a file created here. A pre-existing |
| + // file written 0644 by hand (or by an older tool) now holds a secret, so |
| + // it is tightened after the append, not before: chmod on a file the |
| + // write then failed on would be a change for nothing. |
| + if err := os.Chmod(path, 0o600); err != nil { |
| + return fmt.Errorf("chmod %s: %w", path, err) |
| + } |
| return nil |
| } |
| // parseEnvBytes parses an EnvironmentFile already in hand: KEY=VALUE lines, |
| // blank lines and # comments skipped. It is readEnvFile's whole body (that |
| -// one adds the read), split out so ensureAdminToken can answer "is the token |
| +// one adds the read), split out so EnsureAdminToken can answer "is the token |
| // set" and "does the file end in a newline" from ONE read of the file — two |
| // reads could disagree, and the second one decides where the token lands. |
| func parseEnvBytes(raw []byte) map[string]string { |
diff --git a/internal/activator/admintoken_test.go b/internal/activator/admintoken_test.go| index 0a5c2a898..a2c095408 100644 |
| --- a/internal/activator/admintoken_test.go |
| +++ b/internal/activator/admintoken_test.go |
| @@ -9,7 +9,7 @@ import ( |
| func TestEnsureAdminTokenMintsOnce(t *testing.T) { |
| p := filepath.Join(t.TempDir(), "h.env") |
| - if err := ensureAdminToken(p); err != nil { |
| + if err := EnsureAdminToken(p); err != nil { |
| t.Fatal(err) |
| } |
| st, _ := os.Stat(p) |
| @@ -21,7 +21,7 @@ func TestEnsureAdminTokenMintsOnce(t *testing.T) { |
| if len(tok) != 64 { |
| t.Fatalf("token = %q", tok) |
| } |
| - if err := ensureAdminToken(p); err != nil { |
| + if err := EnsureAdminToken(p); err != nil { |
| t.Fatal(err) |
| } |
| env, _ = readEnvFile(p) |
| @@ -33,7 +33,7 @@ func TestEnsureAdminTokenMintsOnce(t *testing.T) { |
| func TestEnsureAdminTokenPreservesOtherVars(t *testing.T) { |
| p := filepath.Join(t.TempDir(), "h.env") |
| os.WriteFile(p, []byte("DATABASE_URL=sqlite\n# comment\n"), 0o600) |
| - if err := ensureAdminToken(p); err != nil { |
| + if err := EnsureAdminToken(p); err != nil { |
| t.Fatal(err) |
| } |
| raw, _ := os.ReadFile(p) |
| @@ -45,3 +45,18 @@ func TestEnsureAdminTokenPreservesOtherVars(t *testing.T) { |
| t.Fatal("other var lost") |
| } |
| } |
| + |
| +// A looser-mode file that already exists is tightened by the mint: O_CREATE's |
| +// 0600 only ever applied to a file created here, and a 0644 file now holds a |
| +// secret every other UNIX user on the box could read. |
| +func TestEnsureAdminTokenTightensAnExistingFile(t *testing.T) { |
| + p := filepath.Join(t.TempDir(), "h.env") |
| + os.WriteFile(p, []byte("DATABASE_URL=sqlite\n"), 0o644) |
| + if err := EnsureAdminToken(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _ := os.Stat(p) |
| + if st.Mode().Perm() != 0o600 { |
| + t.Fatalf("mode after mint = %o, want 600", st.Mode().Perm()) |
| + } |
| +} |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 08cfcedf4..beb11f9f4 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -284,7 +284,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| // thing a fresh instance runs, so this is usually where the token comes |
| // into existence, and instanceEnv's contract is that a seed sees the same |
| // environment the instance will. |
| - if err := ensureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil { |
| + if err := EnsureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil { |
| return fmt.Errorf("admin token for %s: %w", host, err) |
| } |
| fileEnv, err := b.instanceEnv(host) |
| @@ -513,7 +513,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // instance with no token can neither answer /api/next-due nor accept a |
| // tick, and a wake that quietly skipped it would report a healthy instance |
| // that silently never runs its scheduled work — the #110 kind of lie. |
| - if err := ensureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil { |
| + if err := EnsureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil { |
| return nil, "", fmt.Errorf("admin token for %s: %w", host, err) |
| } |
| @@ -657,7 +657,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // line with no "stopping" line before it — that pairing, not the |
| // exit code alone, is what tells a member crash from lifecycle. |
| if p.err != nil { |
| - emitEvent(b.Logs, rt, "warn", "instance exited: "+redactPaths(p.err.Error())) |
| + emitEvent(b.Logs, rt, "warn", "instance exited: "+RedactPaths(p.err.Error())) |
| } else { |
| emitEvent(b.Logs, rt, "info", "instance exited (exit 0)") |
| } |
diff --git a/internal/activator/logsink.go b/internal/activator/logsink.go| index e8fa84242..3e3eb4508 100644 |
| --- a/internal/activator/logsink.go |
| +++ b/internal/activator/logsink.go |
| @@ -56,11 +56,14 @@ func memberSafeWakeReason(err error) string { |
| case strings.Contains(s, "no adoptable head"): |
| return "wake failed: nothing is promoted for this instance's channel (or the head is still bake-held) — promote a build, or check `carlos channels`" |
| } |
| - return "wake failed: " + redactPaths(s) |
| + return "wake failed: " + RedactPaths(s) |
| } |
| -// redactPaths reduces absolute-path tokens to their base names. |
| -func redactPaths(s string) string { |
| +// RedactPaths reduces absolute-path tokens to their base names. Exported |
| +// for the host package's schedule wiring, whose token and delivery errors |
| +// reach the bucket status object and `carlos schedule ls` (#131's rule: |
| +// no box filesystem layout in a member-facing string). |
| +func RedactPaths(s string) string { |
| return absPathToken.ReplaceAllStringFunc(s, func(m string) string { |
| i := strings.Index(m, "/") |
| prefix, path := m[:i], m[i:] |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a39f1cd88..23b14482e 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -322,23 +322,36 @@ func RunAgent(args []string) { |
| // a console bump fires the fast one); Converger.Pass serializes itself on |
| // its own mutex, so no call site here needs one. |
| // |
| - // A box with no deploy store simply has no scheduled work: schedules live |
| - // in the bucket, so there is nothing to converge and nowhere to report. |
| - // One line, like every other optional loop. |
| + // The two halves arm on different conditions. Declared schedules live in |
| + // the bucket, so the converger needs the deploy store. The RUNNER needs |
| + // only the registry: once-timers are registered by instances on their |
| + // control socket (mounted above, Timers: reg, whenever the activator is |
| + // armed) straight into this box's rows, and a box that mounts the verb |
| + // must also fire what it accepts — so the runner starts whenever the |
| + // activator is armed, store or no store. One line each way, like every |
| + // other optional loop. |
| + // |
| + // Guarded assignment, not a direct pass: logSink is a *applog.Sink, and |
| + // a nil one handed to an interface parameter is NOT a nil interface — |
| + // the same trap the activator's Logs assignment above avoids. |
| + var sink activator.EventSink |
| + if logSink != nil { |
| + sink = logSink |
| + } |
| var sd *scheduleDeps |
| - if schedStore, err := openDeployStore(context.Background()); err != nil { |
| - log.Printf("agent: scheduled work disabled: %v", err) |
| - } else { |
| - // Guarded assignment, not a direct pass: logSink is a *applog.Sink, and |
| - // a nil one handed to an interface parameter is NOT a nil interface — |
| - // the same trap the activator's Logs assignment above avoids. |
| - var sink activator.EventSink |
| - if logSink != nil { |
| - sink = logSink |
| - } |
| + schedStore, schedErr := openDeployStore(context.Background()) |
| + switch { |
| + case schedErr == nil: |
| sd = newScheduleDeps(reg, schedStore, svc, sink, dd) |
| - sd.runner.Start(context.Background()) // runs until the process ends, like every loop below |
| log.Printf("agent: scheduled work armed (converge on serial + hourly; runner every 15s)") |
| + case svc != nil: |
| + sd = newScheduleDeps(reg, nil, svc, sink, dd) |
| + log.Printf("agent: schedules from the bucket are off (%v); once-timers registered by instances still run (runner every 15s)", schedErr) |
| + default: |
| + log.Printf("agent: scheduled work disabled: %v", schedErr) |
| + } |
| + if sd != nil { |
| + sd.runner.Start(context.Background()) // runs until the process ends, like every loop below |
| } |
| go adoptLoop(reg, svc, sd) |
| @@ -497,8 +510,8 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st |
| // hibernation armed simply has no children to bounce. |
| // |
| // sd carries the scheduled-work converger; this loop is its HOURLY floor (the |
| -// fast one rides the serial in fastConvergeLoop). Nil when the box has no |
| -// deploy store — the agent logged that once at arming. |
| +// fast one rides the serial in fastConvergeLoop). sd.converge is a no-op when |
| +// the box has no deploy store — the agent logged that once at arming. |
| func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps) { |
| ctx := context.Background() |
| store, err := openDeployStore(ctx) |
| @@ -557,10 +570,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps) |
| // release pass: an app whose schedules.json arrived with a new version |
| // gets its rows against the version that is actually staged. Die-free |
| // per app inside Pass — the joined error is one log line, never fatal. |
| - if sd != nil { |
| - if err := sd.conv.Pass(ctx); err != nil { |
| - log.Printf("agent: schedule converge (hourly): %v", err) |
| - } |
| + if err := sd.converge(ctx); err != nil { |
| + log.Printf("agent: schedule converge (hourly): %v", err) |
| } |
| } |
| } |
| @@ -1288,10 +1299,8 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // up to an hour of a schedule silently not running. It also seeds the |
| // converger's etag memory, which is what makes a run signal older than this |
| // process read as history rather than as a fresh request to run. |
| - if sd != nil { |
| - if err := sd.conv.Pass(ctx); err != nil { |
| - log.Printf("agent: schedule converge (start): %v", err) |
| - } |
| + if err := sd.converge(ctx); err != nil { |
| + log.Printf("agent: schedule converge (start): %v", err) |
| } |
| d := &fastDeps{ |
| reg: reg, store: store, pub: pub, optDir: optDir, |
diff --git a/internal/host/schedule_wiring.go b/internal/host/schedule_wiring.go| index 683f3484b..d6c26a17e 100644 |
| --- a/internal/host/schedule_wiring.go |
| +++ b/internal/host/schedule_wiring.go |
| @@ -7,6 +7,7 @@ import ( |
| "log" |
| "net/http" |
| "os" |
| + "path/filepath" |
| "strings" |
| "time" |
| @@ -36,65 +37,117 @@ import ( |
| // etag memory is what keeps an idle box at one GET per app per pass and makes |
| // a run signal exactly-once, so a second instance in another loop would |
| // re-seed that memory and either replay a run or swallow one. |
| +// |
| +// The two halves have different preconditions. The converger needs the deploy |
| +// store (schedules live in the bucket). The runner does not: once-timers are |
| +// registered by instances on their own control socket (spec §7.2) straight |
| +// into this box's registry, so a box with an armed activator and no store |
| +// still has rows to fire — conv is nil there, and nothing is reported. |
| type scheduleDeps struct { |
| - conv *schedule.Converger |
| + conv *schedule.Converger // nil when the box has no deploy store |
| runner *schedule.Runner |
| } |
| // newScheduleDeps assembles the converger and the runner around this agent's |
| // registry, deploy store and activator. |
| // |
| -// svc may be nil (no hibernation armed on this box): delivery then proxies |
| -// straight to the instance's socket, which is exactly right — an instance that |
| -// is not activator-managed is either already running or not this leg's problem. |
| -// sink may be nil (no log store): the runner simply emits no member events. |
| -// Callers must pass a genuinely nil interface for sink, not a nil *applog.Sink |
| -// — see the trap agent.go documents where logSink is threaded. |
| +// store may be nil (no deploy store on this box): no converger is built and |
| +// the runner reports nothing, but it still fires the once rows instances |
| +// register. svc may be nil (no hibernation armed on this box): delivery then |
| +// proxies straight to the instance's socket, which is exactly right — an |
| +// instance that is not activator-managed is either already running or not |
| +// this leg's problem. sink may be nil (no log store): the runner simply emits |
| +// no member events. Callers must pass a genuinely nil interface for sink, not |
| +// a nil *applog.Sink — see the trap agent.go documents where logSink is |
| +// threaded. |
| func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator.Service, sink activator.EventSink, dd string) *scheduleDeps { |
| box, err := os.Hostname() |
| if err != nil || box == "" { |
| box = "unknown" |
| } |
| - // The reporter is shared by both halves on purpose: a converge that adds a |
| - // row and a tick that fires it write the SAME status object, so whichever |
| - // ran last is what a member reads. Its own timeout, not the caller's — a |
| - // slow bucket must not hold a converge pass or a tick goroutine open. |
| - report := func(rt registry.Route, st schedule.Status) { |
| - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| - defer cancel() |
| - if err := schedule.WriteStatus(ctx, store, release.RouteAccount(rt), rt.App, st); err != nil { |
| - log.Printf("schedule: report %s: %v", rt.Host, err) |
| - } |
| - } |
| var event func(rt registry.Route, level, msg string) |
| if sink != nil { |
| event = func(rt registry.Route, level, msg string) { |
| sink.Event(release.RouteAccount(rt), rt.App, rt.Host, level, msg) |
| } |
| } |
| - return &scheduleDeps{ |
| - conv: schedule.NewConverger(reg, store, box, report), |
| + sd := &scheduleDeps{ |
| runner: &schedule.Runner{ |
| Reg: reg, |
| Deliver: activatorDeliverer{svc: svc}, |
| - Token: schedule.TokenFromEnvFile(dd), // the activator's own <host>.env |
| + Token: scheduleToken(dd), |
| Event: event, |
| - Report: report, |
| Box: box, |
| Build: version.Build, |
| }, |
| } |
| + if store == nil { |
| + return sd |
| + } |
| + // The reporter is shared by both halves on purpose: a converge that adds a |
| + // row and a tick that fires it write the SAME status object, so whichever |
| + // ran last is what a member reads. Its own timeout, not the caller's — a |
| + // slow bucket must not hold a converge pass or a tick goroutine open. |
| + report := func(rt registry.Route, st schedule.Status) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + if err := schedule.WriteStatus(ctx, store, release.RouteAccount(rt), rt.App, st); err != nil { |
| + log.Printf("schedule: report %s: %v", rt.Host, err) |
| + } |
| + } |
| + sd.conv = schedule.NewConverger(reg, store, box, report) |
| + sd.runner.Report = report |
| + return sd |
| +} |
| + |
| +// scheduleToken is the runner's Token: ensure-then-read on the activator's |
| +// own <dataDir>/<host>.env (spec §6.4). |
| +// |
| +// Ensure, not just read, because the token is otherwise minted only when the |
| +// activator spawns the instance — and the tick is what would spawn it. An |
| +// instance asleep since before this build rolled has no token; a read-only |
| +// Token would record "no instance token" five times and give up, and the |
| +// wake that mints it would never come. EnsureAdminToken is the same call |
| +// Start and Seed make, serialized with them, so a tick and a visitor racing |
| +// on a fresh host mint ONE token. |
| +// |
| +// The error is redacted: it reaches the row's last_status and from there the |
| +// bucket status object and `carlos schedule ls`, and a member must not learn |
| +// the box's filesystem layout from it. |
| +func scheduleToken(dd string) func(host string) (string, error) { |
| + read := schedule.TokenFromEnvFile(dd) |
| + return func(host string) (string, error) { |
| + if err := activator.EnsureAdminToken(filepath.Join(dd, host+".env")); err != nil { |
| + return "", errors.New(activator.RedactPaths(err.Error())) |
| + } |
| + tok, err := read(host) |
| + if err != nil { |
| + return "", errors.New(activator.RedactPaths(err.Error())) |
| + } |
| + return tok, nil |
| + } |
| +} |
| + |
| +// converge runs one converger pass, or nothing on a box with no deploy |
| +// store. The agent's three call sites go through this so none of them can |
| +// reach into a nil conv. |
| +func (sd *scheduleDeps) converge(ctx context.Context) error { |
| + if sd == nil || sd.conv == nil { |
| + return nil |
| + } |
| + return sd.conv.Pass(ctx) |
| } |
| // scheduleLegFor is fastDeps.scheduleLeg for sd — a genuinely nil func when |
| -// scheduled work is not armed, so the tick skips the leg rather than calling a |
| -// closure over a nil converger. |
| +// there is nothing to converge (no scheduled work armed, or no deploy store |
| +// to read declarations from), so the tick skips the leg rather than calling |
| +// a closure over a nil converger. |
| func scheduleLegFor(sd *scheduleDeps) func(ctx context.Context) { |
| - if sd == nil { |
| + if sd == nil || sd.conv == nil { |
| return nil |
| } |
| return func(ctx context.Context) { |
| - if err := sd.conv.Pass(ctx); err != nil { |
| + if err := sd.converge(ctx); err != nil { |
| log.Printf("agent: schedule converge: %v", err) |
| } |
| } |
| @@ -129,7 +182,14 @@ func (d activatorDeliverer) Deliver(ctx context.Context, rt registry.Route, req |
| proxy.ServeHTTP(w, r) |
| } |
| }) |
| - return deliverVia(ctx, h, rt, req) |
| + status, err := deliverVia(ctx, h, rt, req) |
| + if err != nil && ctx.Err() == nil { |
| + // Redacted for the same reason as the token error: this string is |
| + // the row's last_status. The context's own error is left alone — |
| + // the runner matches it with errors.Is to tell a timeout apart. |
| + err = errors.New(activator.RedactPaths(err.Error())) |
| + } |
| + return status, err |
| } |
| // deliverVia is the pure core: run the handler against a recorder and turn |
diff --git a/internal/host/schedule_wiring_test.go b/internal/host/schedule_wiring_test.go| index b25138a3e..2cd397fc8 100644 |
| --- a/internal/host/schedule_wiring_test.go |
| +++ b/internal/host/schedule_wiring_test.go |
| @@ -4,9 +4,12 @@ import ( |
| "context" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -105,3 +108,53 @@ func TestStatusRecorderHeadersAndFirstLine(t *testing.T) { |
| t.Fatalf("Retry-After = %q", got) |
| } |
| } |
| + |
| +// The runner's Token MINTS: an instance asleep since before the roll has no |
| +// CARLOS_ADMIN_TOKEN, and the wake that would mint one is the tick itself. So |
| +// a route with no env file gets a token from the runner's Token func, and the |
| +// spawn that follows (EnsureAdminToken, as Start and Seed call it) finds the |
| +// same one rather than appending a second. |
| +func TestScheduleTokenMintsForASleepingInstance(t *testing.T) { |
| + dd := t.TempDir() |
| + const host = "asleep.bab.oncarlos.com" |
| + envPath := filepath.Join(dd, host+".env") |
| + if _, err := os.Stat(envPath); !os.IsNotExist(err) { |
| + t.Fatalf("fixture: env file already exists (%v)", err) |
| + } |
| + tok, err := scheduleToken(dd)(host) |
| + if err != nil { |
| + t.Fatalf("Token on a host with no env file: %v", err) |
| + } |
| + if len(tok) != 64 { |
| + t.Fatalf("minted token = %q, want 32 random bytes as hex", tok) |
| + } |
| + if st, err := os.Stat(envPath); err != nil || st.Mode().Perm() != 0o600 { |
| + t.Fatalf("env file after mint: %v mode %v, want 0600", err, st.Mode().Perm()) |
| + } |
| + // The spawn's own ensure is a no-op now, and reads back the same token. |
| + if err := activator.EnsureAdminToken(envPath); err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := scheduleToken(dd)(host) |
| + if err != nil || again != tok { |
| + t.Fatalf("second read = %q (%v), want the first token %q", again, err, tok) |
| + } |
| + raw, _ := os.ReadFile(envPath) |
| + if strings.Count(string(raw), "CARLOS_ADMIN_TOKEN=") != 1 { |
| + t.Fatalf("env file carries %d tokens, want 1:\n%s", strings.Count(string(raw), "CARLOS_ADMIN_TOKEN="), raw) |
| + } |
| +} |
| + |
| +// A Token error becomes the row's last_status and from there the bucket |
| +// status object and `carlos schedule ls`: it must not carry the box's |
| +// filesystem layout. |
| +func TestScheduleTokenErrorCarriesNoAbsolutePath(t *testing.T) { |
| + dd := filepath.Join(t.TempDir(), "missing-data-dir") |
| + _, err := scheduleToken(dd)("x.bab.oncarlos.com") |
| + if err == nil { |
| + t.Fatal("Token succeeded with no data dir") |
| + } |
| + if strings.Contains(err.Error(), dd) || strings.Contains(err.Error(), "/") { |
| + t.Fatalf("error leaks a path: %q", err) |
| + } |
| +} |