diff --git a/Makefile b/Makefile
index 4b06f10..23a2269 100644
--- a/Makefile
+++ b/Makefile
@@ -62,7 +62,7 @@ verify-rules-live: tools/verify-rules-live.c tools/flip_classify.h udev_db.h ude
verify-eprops-live: tools/verify-eprops-live.c udev_db.h udev_rules.h udev_builtins.h hwdb.h udev_ruleset.h path_id.h udev_exec.h fido_id.h ata_id.h v4l_id.h cdrom_id.h optical_fs.h dissect_image.h schema-udev.h
$(CC) $(CFLAGS) $(LDFLAGS) -o verify-eprops-live tools/verify-eprops-live.c
-schema-dbus: schema-dbus.c sdbus_wire.h sdbus_policy.h sdbus_names.h sdbus_match.h sdbus_reply.h sdbus_codec.h sdbus_auth.h sdbus_conn.h sdbus_route.h sdbus_driver.h
+schema-dbus: schema-dbus.c sdbus_wire.h sdbus_policy.h sdbus_names.h sdbus_match.h sdbus_reply.h sdbus_codec.h sdbus_auth.h sdbus_conn.h sdbus_route.h sdbus_driver.h sdbus_activate.h
$(CC) $(CFLAGS) $(DBUS_CFLAGS) $(LDFLAGS) schema-dbus.c -o $@ $(DBUS_LIBS)
%.o: %.c
@@ -159,6 +159,7 @@ test:
$(CC) $(CFLAGS) $(DBUS_CFLAGS) tests/test_sdbus_driver.c -o /tmp/schema-test-sdbus-driver $(DBUS_LIBS) && /tmp/schema-test-sdbus-driver
$(CC) $(CFLAGS) $(DBUS_CFLAGS) tests/test_sdbus_route.c -o /tmp/schema-test-sdbus-route $(DBUS_LIBS) && /tmp/schema-test-sdbus-route
$(CC) $(CFLAGS) $(DBUS_CFLAGS) tests/test_sdbus_wire.c -o /tmp/schema-test-sdbus-wire $(DBUS_LIBS) && /tmp/schema-test-sdbus-wire
+ $(CC) $(CFLAGS) tests/test_sdbus_activate.c -o /tmp/schema-test-sdbus-activate && /tmp/schema-test-sdbus-activate
verify-live:
sh tests/sdbus_live_interop.sh
diff --git a/docs/superpowers/plans/2026-09-04-schema-dbus-activation.md b/docs/superpowers/plans/2026-09-04-schema-dbus-activation.md
new file mode 100644
index 0000000..fe25790
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-04-schema-dbus-activation.md
@@ -0,0 +1,963 @@
+# schema-dbus service activation — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give the schema-dbus broker on-demand service activation — a method call to an unowned-but-activatable name (or an explicit `StartServiceByName`) spawns the service, holds the triggering message, and delivers it once the name is claimed.
+
+**Architecture:** A new pure-logic header `sdbus_activate.h` owns the service-file table and the pending-activation state machine (no I/O, no fork, no dbus — unit-testable with C asserts, in the style of `sdbus_reply.h`). The main loop in `schema-dbus.c` owns the side effects: fork+exec with uid drop, a `signalfd(SIGCHLD)` in the epoll set, the spawn-timeout deadline folded into `epoll_wait`, and the four hook points. PID 1 is untouched.
+
+**Tech Stack:** C11, libdbus (wire codec only), epoll, signalfd, POSIX fork/exec/setuid. Build via the existing Makefile; tests are freestanding C `main()` programs run from the `test:` target.
+
+**Spec:** `docs/superpowers/specs/2026-09-04-schema-dbus-activation-design.md`
+
+## Global Constraints
+
+- Behavioral parity with stock dbus-daemon; no per-caller activation policy gate; no audit log.
+- Always drop privilege to the file's `User=` (default `root`) before `execv`; sanitize env; rely on CLOEXEC for fd hygiene.
+- Skip service files whose `Exec=` is `/bin/false` — they stay non-activatable (keep erroring `ServiceUnknown`).
+- Spawn timeout: `25000` ms (stock's value). Error names come from `dbus/dbus-protocol.h` macros: `DBUS_ERROR_TIMED_OUT`, `DBUS_ERROR_SPAWN_FAILED`, `DBUS_ERROR_SPAWN_CHILD_EXITED`. Start-reply codes from `dbus/dbus-shared.h`: `DBUS_START_REPLY_SUCCESS` (1), `DBUS_START_REPLY_ALREADY_RUNNING` (2).
+- No changes to policy, names, route, reply, match, conn, auth, wire, codec modules or `init.c`.
+- Commit messages end with the two trailers already used on this branch (`Co-Authored-By: Claude Opus 4.8 …` + `Claude-Session: …`).
+- Never reboot to deploy without a green `make test` **and** a green `schema-vmtest` first; deploy/reboot is Jonathan's call, not the executor's.
+
+---
+
+### Task 1: Service-file table (parse)
+
+**Files:**
+- Create: `sdbus_activate.h`
+- Test: `tests/test_sdbus_activate.c`
+- Modify: `Makefile:156` area (append a test line after the `test_sdbus_reply` line)
+
+**Interfaces:**
+- Produces:
+ - `typedef struct { char *name; char **argv; char *user; } sdbus_svc_ent;`
+ - `typedef struct { sdbus_svc_ent *v; int n; } sdbus_svctab;`
+ - `sdbus_svctab *sdbus_svctab_parse_dir(const char *dir);` — glob `
/*.service`, parse `Name=`/`Exec=`/`User=`; skip entries with no `Name=`, no real `Exec=`, or `Exec=/bin/false`. `argv` is NULL-terminated (Exec split on spaces). `user` defaults to `"root"`.
+ - `const sdbus_svc_ent *sdbus_svctab_find(sdbus_svctab *t, const char *name);`
+ - `void sdbus_svctab_free(sdbus_svctab *t);`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/test_sdbus_activate.c`:
+
+```c
+#include "../sdbus_activate.h"
+#include
+#include
+#include
+#include
+#include
+
+/* write a .service file into dir */
+static void put(const char *dir, const char *fn, const char *body) {
+ char p[512]; snprintf(p, sizeof p, "%s/%s", dir, fn);
+ FILE *f = fopen(p, "w"); assert(f); fputs(body, f); fclose(f);
+}
+
+static void test_parse(void) {
+ char dir[] = "/tmp/sdbus-act-XXXXXX";
+ assert(mkdtemp(dir));
+ put(dir, "real.service",
+ "[D-BUS Service]\nName=com.example.Real\nExec=/usr/libexec/realsvc --arg\nUser=root\n");
+ put(dir, "nouser.service",
+ "[D-BUS Service]\nName=com.example.NoUser\nExec=/usr/libexec/nu\n");
+ put(dir, "false.service",
+ "[D-BUS Service]\nName=com.example.Systemd\nExec=/bin/false\nSystemdService=x.service\n");
+ put(dir, "noname.service", "[D-BUS Service]\nExec=/usr/libexec/x\n");
+
+ sdbus_svctab *t = sdbus_svctab_parse_dir(dir);
+ assert(t);
+
+ const sdbus_svc_ent *r = sdbus_svctab_find(t, "com.example.Real");
+ assert(r);
+ assert(!strcmp(r->argv[0], "/usr/libexec/realsvc"));
+ assert(!strcmp(r->argv[1], "--arg"));
+ assert(r->argv[2] == NULL);
+ assert(!strcmp(r->user, "root"));
+
+ const sdbus_svc_ent *n = sdbus_svctab_find(t, "com.example.NoUser");
+ assert(n && !strcmp(n->user, "root")); /* default */
+
+ assert(sdbus_svctab_find(t, "com.example.Systemd") == NULL); /* /bin/false skipped */
+ assert(sdbus_svctab_find(t, "com.example.Missing") == NULL); /* absent */
+ assert(t->n == 2); /* real + nouser only */
+
+ sdbus_svctab_free(t);
+ /* cleanup */
+ char cmd[600]; snprintf(cmd, sizeof cmd, "rm -rf %s", dir); (void)system(cmd);
+ printf("test_parse OK\n");
+}
+
+int main(void) {
+ test_parse();
+ printf("all sdbus_activate tests passed\n");
+ return 0;
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cc -std=c11 -Wall tests/test_sdbus_activate.c -o /tmp/t-act`
+Expected: FAIL — `sdbus_activate.h: No such file or directory`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `sdbus_activate.h` with header guard and the parse half:
+
+```c
+#ifndef SDBUS_ACTIVATE_H
+#define SDBUS_ACTIVATE_H
+
+#include
+#include
+#include
+#include
+
+typedef struct { char *name; char **argv; char *user; } sdbus_svc_ent;
+typedef struct { sdbus_svc_ent *v; int n; } sdbus_svctab;
+
+/* split "a b c" into a NULL-terminated argv (whitespace-delimited, no quoting —
+ dbus service Exec lines are plain paths + flags). */
+static inline char **sdbus__split_argv(const char *s) {
+ int cap = 4, n = 0;
+ char **a = malloc(cap * sizeof *a);
+ char *dup = strdup(s), *save = NULL;
+ for (char *tok = strtok_r(dup, " \t", &save); tok; tok = strtok_r(NULL, " \t", &save)) {
+ if (n + 1 >= cap) { cap *= 2; a = realloc(a, cap * sizeof *a); }
+ a[n++] = strdup(tok);
+ }
+ a[n] = NULL;
+ free(dup);
+ return a;
+}
+
+static inline void sdbus__svc_add(sdbus_svctab *t, const char *name,
+ const char *exec, const char *user) {
+ t->v = realloc(t->v, (t->n + 1) * sizeof *t->v);
+ sdbus_svc_ent *e = &t->v[t->n++];
+ e->name = strdup(name);
+ e->argv = sdbus__split_argv(exec);
+ e->user = strdup(user && *user ? user : "root");
+}
+
+static inline sdbus_svctab *sdbus_svctab_parse_dir(const char *dir) {
+ sdbus_svctab *t = calloc(1, sizeof *t);
+ DIR *d = opendir(dir);
+ if (!d) return t; /* empty table, not NULL */
+ struct dirent *de;
+ while ((de = readdir(d))) {
+ size_t l = strlen(de->d_name);
+ if (l < 9 || strcmp(de->d_name + l - 8, ".service")) continue;
+ char path[1024];
+ snprintf(path, sizeof path, "%s/%s", dir, de->d_name);
+ FILE *f = fopen(path, "r");
+ if (!f) continue;
+ char line[2048], name[512] = "", exec[1536] = "", user[256] = "";
+ while (fgets(line, sizeof line, f)) {
+ line[strcspn(line, "\r\n")] = '\0';
+ if (!strncmp(line, "Name=", 5)) snprintf(name, sizeof name, "%s", line + 5);
+ else if (!strncmp(line, "Exec=", 5)) snprintf(exec, sizeof exec, "%s", line + 5);
+ else if (!strncmp(line, "User=", 5)) snprintf(user, sizeof user, "%s", line + 5);
+ }
+ fclose(f);
+ if (!name[0] || !exec[0]) continue; /* need both */
+ /* skip Exec=/bin/false (systemd-only activatables) */
+ if (!strcmp(exec, "/bin/false") || !strcmp(exec, "/usr/bin/false")) continue;
+ sdbus__svc_add(t, name, exec, user);
+ }
+ closedir(d);
+ return t;
+}
+
+static inline const sdbus_svc_ent *sdbus_svctab_find(sdbus_svctab *t, const char *name) {
+ if (!name) return NULL;
+ for (int i = 0; i < t->n; i++)
+ if (!strcmp(t->v[i].name, name)) return &t->v[i];
+ return NULL;
+}
+
+static inline void sdbus_svctab_free(sdbus_svctab *t) {
+ if (!t) return;
+ for (int i = 0; i < t->n; i++) {
+ free(t->v[i].name); free(t->v[i].user);
+ for (char **a = t->v[i].argv; a && *a; a++) free(*a);
+ free(t->v[i].argv);
+ }
+ free(t->v); free(t);
+}
+
+#endif
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cc -std=c11 -Wall tests/test_sdbus_activate.c -o /tmp/t-act && /tmp/t-act`
+Expected: PASS — prints `test_parse OK` then `all sdbus_activate tests passed`.
+
+- [ ] **Step 5: Wire into the Makefile test target**
+
+Add after `Makefile:156` (`test_sdbus_reply` line), matching the existing tab-indented style:
+
+```make
+ $(CC) $(CFLAGS) tests/test_sdbus_activate.c -o /tmp/schema-test-sdbus-activate && /tmp/schema-test-sdbus-activate
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add sdbus_activate.h tests/test_sdbus_activate.c Makefile
+git commit -m "feat(sdbus): service-file activation table parser"
+```
+
+---
+
+### Task 2: Pending-activation state machine
+
+**Files:**
+- Modify: `sdbus_activate.h` (append the pending-table half, before `#endif`)
+- Modify: `tests/test_sdbus_activate.c` (add table tests, call them from `main`)
+
+**Interfaces:**
+- Consumes: nothing from Task 1 (independent structs in the same header).
+- Produces:
+ - `typedef enum { SDBUS_HELD_IMPLICIT, SDBUS_HELD_EXPLICIT } sdbus_held_kind;`
+ - `typedef struct { unsigned char *bytes; int len; int *fds; int nfds; int caller_id; uint32_t serial; int expects_reply; sdbus_held_kind kind; } sdbus_held_msg;`
+ - `typedef struct { char *name; int child_pid; long deadline_ms; sdbus_held_msg *held; int n_held; } sdbus_pending_act;`
+ - `typedef struct { sdbus_pending_act *v; int n; } sdbus_acts;`
+ - `sdbus_acts *sdbus_acts_new(void);`
+ - `sdbus_pending_act *sdbus_acts_find(sdbus_acts *a, const char *name);`
+ - `sdbus_pending_act *sdbus_acts_begin(sdbus_acts *a, const char *name, int pid, long deadline_ms);` — create empty entry for a name that is now SPAWNING.
+ - `void sdbus_acts_hold(sdbus_pending_act *e, const sdbus_held_msg *m);` — deep-copies `bytes`/`fds`.
+ - `int sdbus_acts_take(sdbus_acts *a, const char *name, sdbus_held_msg **out, int *n);` — remove the entry for `name`, hand its held array to the caller (caller frees each `bytes`/`fds` then `free(out)`). Returns 1 if found, 0 if not.
+ - `sdbus_pending_act *sdbus_acts_by_pid(sdbus_acts *a, int pid);`
+ - `long sdbus_acts_next_deadline(sdbus_acts *a);` — earliest `deadline_ms`, or -1 if none.
+ - `int sdbus_acts_reap_expired(sdbus_acts *a, long now_ms, sdbus_held_msg **out, int *n);` — same take-semantics for the first expired entry; returns 1 if one was reaped.
+ - `void sdbus_acts_free(sdbus_acts *a);`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_sdbus_activate.c` and call `test_pending()` from `main` before the final print:
+
+```c
+static sdbus_held_msg mk(int caller, uint32_t serial, sdbus_held_kind k) {
+ sdbus_held_msg m = {0};
+ m.bytes = (unsigned char *)strdup("wire"); m.len = 4;
+ m.caller_id = caller; m.serial = serial; m.expects_reply = 1; m.kind = k;
+ return m;
+}
+
+static void test_pending(void) {
+ sdbus_acts *a = sdbus_acts_new();
+ assert(sdbus_acts_find(a, "com.x") == NULL);
+ assert(sdbus_acts_next_deadline(a) == -1);
+
+ sdbus_pending_act *e = sdbus_acts_begin(a, "com.x", 4242, 1000);
+ assert(e && e->child_pid == 4242 && e->n_held == 0);
+ assert(sdbus_acts_find(a, "com.x") == e);
+ assert(sdbus_acts_by_pid(a, 4242) == e);
+ assert(sdbus_acts_next_deadline(a) == 1000);
+
+ sdbus_held_msg m1 = mk(10, 1, SDBUS_HELD_IMPLICIT);
+ sdbus_held_msg m2 = mk(11, 2, SDBUS_HELD_EXPLICIT);
+ sdbus_acts_hold(e, &m1); sdbus_acts_hold(e, &m2);
+ free(m1.bytes); free(m2.bytes); /* hold deep-copies */
+ assert(sdbus_acts_find(a, "com.x")->n_held == 2);
+
+ /* take removes the entry and hands back the held array */
+ sdbus_held_msg *out = NULL; int n = 0;
+ assert(sdbus_acts_take(a, "com.x", &out, &n) == 1);
+ assert(n == 2 && out[0].caller_id == 10 && out[1].kind == SDBUS_HELD_EXPLICIT);
+ assert(sdbus_acts_find(a, "com.x") == NULL); /* gone */
+ for (int i = 0; i < n; i++) { free(out[i].bytes); free(out[i].fds); }
+ free(out);
+
+ /* reap_expired picks the entry whose deadline has passed */
+ sdbus_acts_begin(a, "com.y", 5, 500);
+ sdbus_acts_begin(a, "com.z", 6, 3000);
+ sdbus_held_msg *o2 = NULL; int n2 = 0;
+ assert(sdbus_acts_reap_expired(a, 600, &o2, &n2) == 1); /* com.y expired */
+ assert(sdbus_acts_find(a, "com.y") == NULL);
+ assert(sdbus_acts_find(a, "com.z") != NULL); /* not yet */
+ free(o2);
+ assert(sdbus_acts_reap_expired(a, 600, &o2, &n2) == 0); /* none left expired */
+
+ sdbus_acts_free(a);
+ printf("test_pending OK\n");
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cc -std=c11 -Wall tests/test_sdbus_activate.c -o /tmp/t-act`
+Expected: FAIL — implicit declarations / unknown types (`sdbus_acts` etc.).
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `sdbus_activate.h` before `#endif` (add `#include ` at the top of the file):
+
+```c
+typedef enum { SDBUS_HELD_IMPLICIT, SDBUS_HELD_EXPLICIT } sdbus_held_kind;
+typedef struct {
+ unsigned char *bytes; int len; /* captured wire message (implicit) */
+ int *fds; int nfds; /* its passed fds (implicit) */
+ int caller_id; uint32_t serial; int expects_reply;
+ sdbus_held_kind kind;
+} sdbus_held_msg;
+typedef struct {
+ char *name; int child_pid; long deadline_ms;
+ sdbus_held_msg *held; int n_held;
+} sdbus_pending_act;
+typedef struct { sdbus_pending_act *v; int n; } sdbus_acts;
+
+static inline sdbus_acts *sdbus_acts_new(void) { return calloc(1, sizeof(sdbus_acts)); }
+
+static inline sdbus_pending_act *sdbus_acts_find(sdbus_acts *a, const char *name) {
+ if (!name) return NULL;
+ for (int i = 0; i < a->n; i++)
+ if (!strcmp(a->v[i].name, name)) return &a->v[i];
+ return NULL;
+}
+
+static inline sdbus_pending_act *sdbus_acts_by_pid(sdbus_acts *a, int pid) {
+ for (int i = 0; i < a->n; i++) if (a->v[i].child_pid == pid) return &a->v[i];
+ return NULL;
+}
+
+static inline sdbus_pending_act *sdbus_acts_begin(sdbus_acts *a, const char *name,
+ int pid, long deadline_ms) {
+ a->v = realloc(a->v, (a->n + 1) * sizeof *a->v);
+ sdbus_pending_act *e = &a->v[a->n++];
+ memset(e, 0, sizeof *e);
+ e->name = strdup(name); e->child_pid = pid; e->deadline_ms = deadline_ms;
+ return e;
+}
+
+static inline void sdbus_acts_hold(sdbus_pending_act *e, const sdbus_held_msg *m) {
+ e->held = realloc(e->held, (e->n_held + 1) * sizeof *e->held);
+ sdbus_held_msg *d = &e->held[e->n_held++];
+ *d = *m;
+ if (m->len > 0 && m->bytes) { d->bytes = malloc(m->len); memcpy(d->bytes, m->bytes, m->len); }
+ else { d->bytes = NULL; d->len = 0; }
+ if (m->nfds > 0 && m->fds) { d->fds = malloc(m->nfds * sizeof(int)); memcpy(d->fds, m->fds, m->nfds * sizeof(int)); }
+ else { d->fds = NULL; d->nfds = 0; }
+}
+
+/* remove entry at index i, handing its held[] array to *out (ownership transfers). */
+static inline void sdbus__acts_pop(sdbus_acts *a, int i, sdbus_held_msg **out, int *n) {
+ *out = a->v[i].held; *n = a->v[i].n_held;
+ free(a->v[i].name);
+ a->v[i] = a->v[--a->n]; /* swap-remove */
+}
+
+static inline int sdbus_acts_take(sdbus_acts *a, const char *name,
+ sdbus_held_msg **out, int *n) {
+ for (int i = 0; i < a->n; i++)
+ if (!strcmp(a->v[i].name, name)) { sdbus__acts_pop(a, i, out, n); return 1; }
+ *out = NULL; *n = 0; return 0;
+}
+
+static inline long sdbus_acts_next_deadline(sdbus_acts *a) {
+ long best = -1;
+ for (int i = 0; i < a->n; i++)
+ if (best < 0 || a->v[i].deadline_ms < best) best = a->v[i].deadline_ms;
+ return best;
+}
+
+static inline int sdbus_acts_reap_expired(sdbus_acts *a, long now_ms,
+ sdbus_held_msg **out, int *n) {
+ for (int i = 0; i < a->n; i++)
+ if (a->v[i].deadline_ms <= now_ms) { sdbus__acts_pop(a, i, out, n); return 1; }
+ *out = NULL; *n = 0; return 0;
+}
+
+static inline void sdbus_acts_free(sdbus_acts *a) {
+ if (!a) return;
+ for (int i = 0; i < a->n; i++) {
+ free(a->v[i].name);
+ for (int j = 0; j < a->v[i].n_held; j++) { free(a->v[i].held[j].bytes); free(a->v[i].held[j].fds); }
+ free(a->v[i].held);
+ }
+ free(a->v); free(a);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cc -std=c11 -Wall tests/test_sdbus_activate.c -o /tmp/t-act && /tmp/t-act`
+Expected: PASS — `test_parse OK`, `test_pending OK`, `all sdbus_activate tests passed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add sdbus_activate.h tests/test_sdbus_activate.c
+git commit -m "feat(sdbus): pending-activation state machine + tests"
+```
+
+---
+
+### Task 3: Broker wiring — include header, parse table at startup, build dep
+
+**Files:**
+- Modify: `schema-dbus.c` (include + globals + startup parse)
+- Modify: `Makefile:65` (add `sdbus_activate.h` to the `schema-dbus` prerequisite list)
+
+**Interfaces:**
+- Consumes: `sdbus_svctab_parse_dir`, `sdbus_acts_new` (Tasks 1-2).
+- Produces: file-scope globals `static sdbus_svctab *g_svctab;` and `static sdbus_acts *g_acts;` used by later tasks; `#define SDBUS_SVC_DIR "/usr/share/dbus-1/system-services"`.
+
+- [ ] **Step 1: Add the include** — after `schema-dbus.c:27` (`#include "sdbus_route.h"`):
+
+```c
+#include "sdbus_activate.h"
+```
+
+- [ ] **Step 2: Add globals + dir macro** — after `schema-dbus.c:39` (`static dbus_uint32_t g_bcast_serial;`):
+
+```c
+static sdbus_svctab *g_svctab;
+static sdbus_acts *g_acts;
+#define SDBUS_SVC_DIR "/usr/share/dbus-1/system-services"
+#define SDBUS_SPAWN_TIMEOUT_MS 25000
+```
+
+- [ ] **Step 3: Parse at startup** — after `schema-dbus.c:408` (`g_replies = sdbus_replies_new();`):
+
+```c
+ g_svctab = sdbus_svctab_parse_dir(SDBUS_SVC_DIR);
+ g_acts = sdbus_acts_new();
+ fprintf(stderr, "schema-dbus: %d activatable services\n", g_svctab->n);
+```
+
+- [ ] **Step 4: Add build dependency** — on `Makefile:65`, append `sdbus_activate.h` to the `schema-dbus:` prerequisite list.
+
+- [ ] **Step 5: Verify it builds**
+
+Run: `make schema-dbus`
+Expected: compiles clean, no warnings; running `./schema-dbus --system` on a scratch socket prints the "N activatable services" line. (Do NOT install/replace the live bus.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add schema-dbus.c Makefile
+git commit -m "feat(sdbus): load activatable-service table at broker startup"
+```
+
+---
+
+### Task 4: Spawn helper (fork + uid-drop + exec)
+
+**Files:**
+- Modify: `schema-dbus.c` (add `spawn_service`, plus signal/pwd includes)
+
+**Interfaces:**
+- Consumes: `sdbus_svc_ent` (Task 1).
+- Produces: `static pid_t spawn_service(const sdbus_svc_ent *e, const char *bus_addr);` — fork+exec; returns child pid, or `-1` on fork failure. Also `static char g_bus_addr[256];` holding the `unix:path=` string, set in `main`.
+
+- [ ] **Step 1: Add includes** — with the other `#include`s at the top of `schema-dbus.c`:
+
+```c
+#include
+#include
+#include
+#include
+#include
+```
+
+- [ ] **Step 2: Implement `spawn_service`** — near the top of the helpers section (e.g. after `make_listen_socket`, before `main`):
+
+```c
+/* fork+exec an activatable service, dropping to its User= before exec. Returns
+ the child pid, or -1 if fork failed. Matches stock dbus-daemon: clean env with
+ DBUS_STARTER_*; all broker fds are CLOEXEC so exec closes them. */
+static pid_t spawn_service(const sdbus_svc_ent *e, const char *bus_addr) {
+ pid_t pid = fork();
+ if (pid < 0) return -1;
+ if (pid > 0) return pid;
+
+ /* --- child --- */
+ setsid();
+ struct passwd *pw = getpwnam(e->user);
+ if (pw && pw->pw_uid != 0) {
+ if (initgroups(e->user, pw->pw_gid) != 0) _exit(127);
+ if (setgid(pw->pw_gid) != 0) _exit(127);
+ if (setuid(pw->pw_uid) != 0) _exit(127);
+ }
+ char starter[320];
+ snprintf(starter, sizeof starter, "DBUS_STARTER_ADDRESS=%s", bus_addr);
+ char *env[] = {
+ (char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin",
+ starter,
+ (char *)"DBUS_STARTER_BUS_TYPE=system",
+ NULL
+ };
+ execve(e->argv[0], e->argv, env);
+ _exit(127); /* exec failed */
+}
+```
+
+- [ ] **Step 3: Set `g_bus_addr` in main** — after the socket path is known (`schema-dbus.c:384`, right after `if (!sock) sock = DEFAULT_SOCKET;`):
+
+```c
+ static char g_bus_addr[256];
+ snprintf(g_bus_addr, sizeof g_bus_addr, "unix:path=%s", sock);
+```
+
+Move the `g_bus_addr` declaration to file scope (with the other globals in Task 3) if later tasks in the same session need it; for now local-to-main is fine since only `spawn_service` callers in `main`'s loop use it. (Note for executor: Task 5 needs it at file scope — declare `static char g_bus_addr[256];` among the globals and assign it here without the `static` keyword.)
+
+- [ ] **Step 4: Verify it builds**
+
+Run: `make schema-dbus`
+Expected: compiles clean, no warnings. (No unit test — the fork/uid path is exercised by the live smoke test in Task 9.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add schema-dbus.c
+git commit -m "feat(sdbus): spawn helper with User= drop + DBUS_STARTER env"
+```
+
+---
+
+### Task 5: Implicit trigger — hold the message + spawn
+
+**Files:**
+- Modify: `schema-dbus.c` (`handle_message` synth branch + a helper)
+
+**Interfaces:**
+- Consumes: `g_svctab`, `g_acts`, `spawn_service`, `g_bus_addr` (Tasks 3-4); `sdbus_acts_find`/`_begin`/`_hold` (Task 2).
+- Produces: `static void activate_or_hold(sdbus_conn *c, sdbus_wire_msg *w, const unsigned char *raw, int rawlen, int nfds, int *transferred);` returning 1 if it handled (held) the message, 0 if the name is not activatable (caller should fall through to ServiceUnknown).
+
+- [ ] **Step 1: Implement `activate_or_hold`** — above `handle_message`:
+
+```c
+/* If w->destination is an activatable name with no current owner, capture this
+ message (bytes + fds + caller + serial), spawn the service if not already
+ spawning, and return 1 (message is held, not answered). Return 0 if the name
+ is not activatable (caller emits ServiceUnknown as before). */
+static int activate_or_hold(sdbus_conn *c, sdbus_wire_msg *w,
+ const unsigned char *raw, int rawlen,
+ int nfds, int *transferred) {
+ if (!w->destination) return 0;
+ const sdbus_svc_ent *e = sdbus_svctab_find(g_svctab, w->destination);
+ if (!e) return 0;
+
+ sdbus_held_msg m = {0};
+ /* capture the reforwarded bytes (sender stamped), so release re-injects a
+ ready-to-route message. */
+ unsigned char *fwd = NULL; int fwlen = 0;
+ if (sdbus_wire_reforward(raw, w, c->unique, &fwd, &fwlen) != 0) return 0;
+ m.bytes = fwd; m.len = fwlen;
+ m.caller_id = c->id; m.serial = w->serial;
+ m.expects_reply = !(w->flags & SDBUS_FLAG_NO_REPLY);
+ m.kind = SDBUS_HELD_IMPLICIT;
+ if (nfds > 0) { m.fds = c->pending_fds; m.nfds = nfds; *transferred = 1; }
+
+ sdbus_pending_act *pa = sdbus_acts_find(g_acts, w->destination);
+ if (!pa) {
+ pid_t pid = spawn_service(e, g_bus_addr);
+ if (pid < 0) { free(fwd); return 0; } /* fork failed -> ServiceUnknown path */
+ pa = sdbus_acts_begin(g_acts, w->destination, pid, sdbus__now_ms() + SDBUS_SPAWN_TIMEOUT_MS);
+ }
+ sdbus_acts_hold(pa, &m); /* deep-copies bytes + fds */
+ free(fwd);
+ return 1;
+}
+```
+
+- [ ] **Step 2: Hook the synth branch** — replace `schema-dbus.c:249-250`:
+
+```c
+ } else if (synth) {
+ synth_error_wire(c, w->serial, DBUS_ERROR_SERVICE_UNKNOWN, "name has no owner");
+```
+
+with:
+
+```c
+ } else if (synth) {
+ if (!activate_or_hold(c, w, raw, rawlen, nfds, &transferred))
+ synth_error_wire(c, w->serial, DBUS_ERROR_SERVICE_UNKNOWN, "name has no owner");
+```
+
+(`transferred` is already declared at `schema-dbus.c:246`; when `activate_or_hold` sets it, the existing `consume_msg_fds(c, nfds, transferred)` at the end of `handle_message` correctly leaves the fds for the held copy — but note the held copy dup'd them via `sdbus_acts_hold`, so set `*transferred = 1` there means the original fds are NOT closed by consume; they remain owned by `c->pending_fds` and are consumed normally. See Step 3.)
+
+- [ ] **Step 3: Fix fd ownership** — the held message deep-copies fd *numbers*, not duplicated descriptors. To avoid double-close, do NOT set `*transferred` for the held path; instead let `consume_msg_fds` close the originals and have the held copy `dup()` them. Change the fd-capture line in `activate_or_hold` (Step 1) to dup:
+
+```c
+ if (nfds > 0) {
+ m.fds = malloc(nfds * sizeof(int));
+ for (int i = 0; i < nfds; i++) m.fds[i] = dup(c->pending_fds[i]);
+ m.nfds = nfds;
+ }
+```
+
+and remove the `*transferred = 1;` assignment and the `int *transferred` use for this path (drop that param — signature becomes `activate_or_hold(sdbus_conn *c, sdbus_wire_msg *w, const unsigned char *raw, int rawlen, int nfds)`). `sdbus_acts_hold` then copies the dup'd fd array; free `m.fds` after the hold call (hold deep-copies). Update the Step 2 call accordingly (drop `&transferred`).
+
+- [ ] **Step 4: Verify it builds**
+
+Run: `make schema-dbus`
+Expected: clean build. Behavior verified in Task 9 (live) — a cold call to an activatable name no longer returns ServiceUnknown immediately (it hangs pending until release/timeout, which arrive in Tasks 6-8).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add schema-dbus.c
+git commit -m "feat(sdbus): hold + spawn on implicit activation trigger"
+```
+
+---
+
+### Task 6: SIGCHLD reaping + fail-on-child-death
+
+**Files:**
+- Modify: `schema-dbus.c` (`main`: block SIGCHLD, signalfd in epoll, reap block)
+
+**Interfaces:**
+- Consumes: `g_acts`, `sdbus_acts_by_pid`, `sdbus_acts_take`, `send_no_reply_to` / `synth_error_wire` (existing).
+- Produces: a `signalfd`-driven reaper; a helper `static void fail_held(sdbus_held_msg *held, int n, const char *errname, const char *text);`.
+
+- [ ] **Step 1: Add `fail_held` helper** — after `send_no_reply_to`:
+
+```c
+/* reply an error to each held caller of an activation that failed, and free the
+ held array (bytes/fds owned by the array). */
+static void fail_held(sdbus_held_msg *held, int n, const char *errname, const char *text) {
+ for (int i = 0; i < n; i++) {
+ sdbus_conn *caller = conn_by_id(held[i].caller_id);
+ if (caller && held[i].expects_reply) {
+ synth_error_wire(caller, held[i].serial, errname, text);
+ ep_update(caller);
+ }
+ for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ free(held[i].bytes); free(held[i].fds);
+ }
+ free(held);
+}
+```
+
+- [ ] **Step 2: Block SIGCHLD + create signalfd** — in `main`, before `g_epfd = epoll_create1(...)` (`schema-dbus.c:413`):
+
+```c
+ sigset_t scmask;
+ sigemptyset(&scmask);
+ sigaddset(&scmask, SIGCHLD);
+ sigprocmask(SIG_BLOCK, &scmask, NULL);
+ int sigfd = signalfd(-1, &scmask, SFD_NONBLOCK | SFD_CLOEXEC);
+```
+
+- [ ] **Step 3: Register signalfd in epoll** — after the listen fd is added (`schema-dbus.c:416`). Use a distinct sentinel: define near the top of `main` `static int sigfd_marker;` and register `ev.data.ptr = &sigfd_marker;`:
+
+```c
+ struct epoll_event sev = {0};
+ sev.events = EPOLLIN; sev.data.ptr = &sigfd_marker;
+ epoll_ctl(g_epfd, EPOLL_CTL_ADD, sigfd, &sev);
+```
+
+- [ ] **Step 4: Handle the signalfd event** — in the event loop, add a branch alongside the listen-fd check (`schema-dbus.c:432`), before the `sdbus_conn *c = evs[i].data.ptr;` line:
+
+```c
+ if (evs[i].data.ptr == &sigfd_marker) {
+ struct signalfd_siginfo si;
+ while (read(sigfd, &si, sizeof si) == (ssize_t)sizeof si) { } /* drain */
+ int st; pid_t p;
+ while ((p = waitpid(-1, &st, WNOHANG)) > 0) {
+ sdbus_pending_act *pa = sdbus_acts_by_pid(g_acts, (int)p);
+ if (!pa) continue; /* exited after claiming its name: normal */
+ sdbus_held_msg *held = NULL; int nh = 0;
+ sdbus_acts_take(g_acts, pa->name, &held, &nh);
+ fail_held(held, nh, DBUS_ERROR_SPAWN_CHILD_EXITED,
+ "Activated service exited before acquiring its name");
+ }
+ continue;
+ }
+```
+
+- [ ] **Step 5: Verify it builds**
+
+Run: `make schema-dbus`
+Expected: clean build. (Behavior: a spawned binary that exits without claiming its name now sends `Spawn.ChildExited` to held callers — verified live in Task 9 with a deliberately-broken service, optional.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add schema-dbus.c
+git commit -m "feat(sdbus): SIGCHLD reaper, fail held callers on early child exit"
+```
+
+---
+
+### Task 7: Release held messages on name acquisition
+
+**Files:**
+- Modify: `schema-dbus.c` (`broadcast_transitions` name-acquire branch)
+
+**Interfaces:**
+- Consumes: `g_acts`, `sdbus_acts_take` (Task 2), `sdbus_route_targets` (existing), `sdbus_conn_enqueue` (existing).
+- Produces: nothing new; extends `broadcast_transitions`.
+
+- [ ] **Step 1: Add a release helper** — above `broadcast_transitions`:
+
+```c
+/* a name just got an owner: deliver every message held for it. IMPLICIT ones are
+ re-routed as a fresh send (records a pending reply so the answer routes back);
+ EXPLICIT StartServiceByName callers get a SUCCESS reply. */
+static void release_activation(const char *name) {
+ sdbus_held_msg *held = NULL; int n = 0;
+ if (!sdbus_acts_take(g_acts, name, &held, &n)) return;
+ for (int i = 0; i < n; i++) {
+ sdbus_conn *caller = conn_by_id(held[i].caller_id);
+ if (held[i].kind == SDBUS_HELD_EXPLICIT) {
+ if (caller) {
+ DBusMessage *r = dbus_message_new(DBUS_MESSAGE_TYPE_METHOD_RETURN);
+ dbus_message_set_reply_serial(r, held[i].serial);
+ dbus_message_set_serial(r, ++g_bcast_serial);
+ dbus_message_set_sender(r, SDBUS_DRIVER_NAME);
+ if (caller->unique) dbus_message_set_destination(r, caller->unique);
+ dbus_uint32_t code = DBUS_START_REPLY_SUCCESS;
+ dbus_message_append_args(r, DBUS_TYPE_UINT32, &code, DBUS_TYPE_INVALID);
+ char *b = NULL; int len = 0;
+ if (dbus_message_marshal(r, &b, &len)) { sdbus_conn_enqueue(caller, (unsigned char *)b, len, NULL, 0); dbus_free(b); ep_update(caller); }
+ dbus_message_unref(r);
+ }
+ for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ free(held[i].bytes); free(held[i].fds);
+ continue;
+ }
+ /* IMPLICIT: re-route the captured (already sender-stamped) message. */
+ sdbus_wire_msg w2;
+ if (sdbus_wire_parse(held[i].bytes, held[i].len, &w2) == held[i].len && caller) {
+ int targets[MAX_TARGETS], synth2 = 0, denied2 = 0;
+ int nt = sdbus_route_targets(&w2, caller, g_names, g_conns, g_nconns,
+ g_policy, g_replies, &synth2, &denied2, targets, MAX_TARGETS);
+ for (int k = 0; k < nt; k++) {
+ sdbus_conn *dst = conn_by_id(targets[k]);
+ if (!dst) continue;
+ if (k == 0 && held[i].nfds > 0) sdbus_conn_enqueue(dst, held[i].bytes, held[i].len, held[i].fds, held[i].nfds);
+ else sdbus_conn_enqueue(dst, held[i].bytes, held[i].len, NULL, 0);
+ ep_update(dst);
+ }
+ if (!(held[i].nfds > 0 && nt > 0)) for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ } else {
+ for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ }
+ free(held[i].bytes); free(held[i].fds);
+ }
+ free(held);
+}
+```
+
+Note for executor: `sdbus_conn_enqueue` takes ownership semantics for fds identical to the send path in `handle_message` (fds closed after the outbound chunk is sent). Passing `held[i].fds` on the `k==0` branch transfers them; the guard closes them only when they were not transferred.
+
+- [ ] **Step 2: Call it on name-acquire** — inside `broadcast_transitions`, in the `if (t[i].new_owner >= 0)` block (`schema-dbus.c:145`), after the NameAcquired signal is enqueued:
+
+```c
+ if (t[i].new_owner >= 0) {
+ sdbus_conn *o = conn_by_id(t[i].new_owner);
+ if (o) {
+ DBusMessage *s = dbus_message_new_signal(SDBUS_DRIVER_PATH, SDBUS_DRIVER_NAME, "NameAcquired");
+ dbus_message_append_args(s, DBUS_TYPE_STRING, &name, DBUS_TYPE_INVALID);
+ enqueue_signal(o, s); dbus_message_unref(s);
+ }
+ release_activation(name); /* deliver anything held for this name */
+ }
+```
+
+- [ ] **Step 3: Verify it builds**
+
+Run: `make schema-dbus`
+Expected: clean build.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add schema-dbus.c
+git commit -m "feat(sdbus): deliver held messages when activated name is acquired"
+```
+
+---
+
+### Task 8: Spawn-timeout integration
+
+**Files:**
+- Modify: `schema-dbus.c` (`main`: fold activation deadline into `epoll_wait`, add reap block)
+
+**Interfaces:**
+- Consumes: `sdbus_acts_next_deadline`, `sdbus_acts_reap_expired` (Task 2), `fail_held` (Task 6).
+- Produces: nothing new.
+
+- [ ] **Step 1: Fold the activation deadline into the wait timeout** — replace the deadline computation at `schema-dbus.c:426-428`:
+
+```c
+ int wait_ms = -1;
+ long nd = sdbus_replies_next_deadline(g_replies);
+ if (nd >= 0) { long now = sdbus__now_ms(); wait_ms = nd <= now ? 0 : (int)(nd - now); }
+```
+
+with:
+
+```c
+ int wait_ms = -1;
+ long nd = sdbus_replies_next_deadline(g_replies);
+ long ad = sdbus_acts_next_deadline(g_acts);
+ if (ad >= 0 && (nd < 0 || ad < nd)) nd = ad;
+ if (nd >= 0) { long now = sdbus__now_ms(); wait_ms = nd <= now ? 0 : (int)(nd - now); }
+```
+
+- [ ] **Step 2: Reap expired activations** — after the pending-reply reap block (`schema-dbus.c:450-460`), before the oq_over sweep:
+
+```c
+ /* time out activations whose service never claimed its name */
+ for (;;) {
+ long now = sdbus__now_ms();
+ sdbus_held_msg *held = NULL; int nh = 0;
+ if (!sdbus_acts_reap_expired(g_acts, now, &held, &nh)) break;
+ fail_held(held, nh, DBUS_ERROR_TIMED_OUT,
+ "Activated service failed to acquire its name in time");
+ }
+```
+
+- [ ] **Step 3: Verify it builds**
+
+Run: `make schema-dbus`
+Expected: clean build.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add schema-dbus.c
+git commit -m "feat(sdbus): spawn-timeout reaping -> TimedOut to held callers"
+```
+
+---
+
+### Task 9: Explicit StartServiceByName + live smoke test + full suite
+
+**Files:**
+- Modify: `schema-dbus.c` (`handle_message` to_driver path: intercept StartServiceByName)
+
+**Interfaces:**
+- Consumes: `activate_or_hold` pattern (Task 5), `g_svctab`, `g_acts`, `spawn_service`, `sdbus_names_owner` (existing).
+- Produces: explicit-start handling; the whole feature is now live-testable.
+
+- [ ] **Step 1: Intercept StartServiceByName before driver dispatch** — in `handle_message`, at the top of the `if (to_driver)` block (`schema-dbus.c:219`), before the `sdbus_codec_take` demarshal, add:
+
+```c
+ if (to_driver && w->member && !strcmp(w->member, "StartServiceByName")) {
+ /* arg0 is the target name; demarshal just to read it */
+ sdbus_msg dm;
+ const char *target = NULL;
+ if (sdbus_codec_take(raw, rawlen, &dm) == rawlen)
+ target = dm.n_args > 0 && dm.args[0].type == 's' ? dm.args[0].str : NULL;
+ if (target) {
+ if (sdbus_names_owner(g_names, target) >= 0) {
+ /* already running -> ALREADY_RUNNING */
+ DBusMessage *r = dbus_message_new(DBUS_MESSAGE_TYPE_METHOD_RETURN);
+ dbus_message_set_reply_serial(r, w->serial);
+ dbus_message_set_serial(r, ++g_bcast_serial);
+ dbus_message_set_sender(r, SDBUS_DRIVER_NAME);
+ if (c->unique) dbus_message_set_destination(r, c->unique);
+ dbus_uint32_t code = DBUS_START_REPLY_ALREADY_RUNNING;
+ dbus_message_append_args(r, DBUS_TYPE_UINT32, &code, DBUS_TYPE_INVALID);
+ char *b = NULL; int len = 0;
+ if (dbus_message_marshal(r, &b, &len)) { sdbus_conn_enqueue(c, (unsigned char *)b, len, NULL, 0); dbus_free(b); }
+ dbus_message_unref(r);
+ } else {
+ const sdbus_svc_ent *e = sdbus_svctab_find(g_svctab, target);
+ if (!e) {
+ synth_error_wire(c, w->serial, DBUS_ERROR_SERVICE_UNKNOWN, "no such activatable service");
+ } else {
+ sdbus_pending_act *pa = sdbus_acts_find(g_acts, target);
+ if (!pa) {
+ pid_t pid = spawn_service(e, g_bus_addr);
+ if (pid < 0) synth_error_wire(c, w->serial, DBUS_ERROR_SPAWN_FAILED, "fork failed");
+ else pa = sdbus_acts_begin(g_acts, target, pid, sdbus__now_ms() + SDBUS_SPAWN_TIMEOUT_MS);
+ }
+ if (pa) {
+ sdbus_held_msg m = {0};
+ m.caller_id = c->id; m.serial = w->serial; m.expects_reply = 1;
+ m.kind = SDBUS_HELD_EXPLICIT;
+ sdbus_acts_hold(pa, &m);
+ }
+ }
+ }
+ } else {
+ synth_error_wire(c, w->serial, DBUS_ERROR_INVALID_ARGS, "StartServiceByName needs a name");
+ }
+ if (sdbus_codec_take(raw, rawlen, &dm) == rawlen) sdbus_msg_free(&dm);
+ drain_scratch(c); ep_update(c);
+ consume_msg_fds(c, nfds, 0);
+ return;
+ }
+```
+
+Note for executor: verify `sdbus_msg` field names against `sdbus_codec.h` (`n_args`, `args[].type`, `args[].str`) and adjust if they differ. Free `dm` exactly once — restructure the double-`take` above into a single demarshal held in a local if the codec does not tolerate a second `take` on the same buffer.
+
+- [ ] **Step 2: Build + full unit suite**
+
+Run: `make schema-dbus && make test`
+Expected: broker builds clean; `make test` reports every suite passing, including `test_sdbus_activate`.
+
+- [ ] **Step 3: Live smoke test (scratch bus, not the live system bus)**
+
+```bash
+# start the new broker on a private socket
+SCHEMA_DBUS_SOCKET=/tmp/smoke_bus ./schema-dbus --system &
+BROKER=$!
+export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/smoke_bus
+# pick a real (b1) activatable service that is safe to spawn, e.g. fwupd or a
+# kauth helper; a cold call should now spawn it and get a reply, not ServiceUnknown:
+busctl --system call org.freedesktop.DBus /org/freedesktop/DBus \
+ org.freedesktop.DBus StartServiceByName su org.freedesktop.fwupd 0
+# expect: u 1 (DBUS_START_REPLY_SUCCESS) or u 2 (already running)
+kill $BROKER
+```
+
+Expected: `u 1` or `u 2` (not "unknown method", not ServiceUnknown). If the chosen service is not installed, pick another from `scripts/sdbus-activatable-audit.sh` output.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add schema-dbus.c
+git commit -m "feat(sdbus): explicit StartServiceByName activation + live smoke"
+```
+
+---
+
+### Task 10: vmtest gate + audit re-verify
+
+**Files:** none (verification only). Deploy/reboot is Jonathan's decision, NOT part of this task.
+
+- [ ] **Step 1: Boot-test as PID 1 under QEMU**
+
+Invoke the `schema-vmtest` skill (boots schema-init with the new broker binary in the rail). Expected: bus comes up, desktop-equivalent smoke passes, no fallback to stock dbus-daemon.
+
+- [ ] **Step 2: Re-run the activatable audit**
+
+Run: `sh scripts/sdbus-activatable-audit.sh`
+Expected: unchanged inventory (the audit is static); note in the run that the (b1) regression set is now covered by the activation mechanism rather than by the rail.
+
+- [ ] **Step 3: Hand off to Jonathan**
+
+Report: unit suite green, live smoke green, vmtest green. State the deploy path (`make schema-dbus` → back up `/usr/local/bin/schema-dbus` → `sudo make install-dbus-sp1` → reboot) and the rollback binary, and STOP — do not install or reboot without his go-ahead.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Service table + parse (skip /bin/false) → Task 1. ✔
+- Pending-activation table + hold/release/timeout/by-pid → Task 2. ✔
+- Startup parse + globals → Task 3. ✔
+- Fork+exec + User= drop + DBUS_STARTER env + CLOEXEC hygiene → Task 4. ✔
+- Implicit trigger at the synth branch, hold not answer → Task 5. ✔
+- SIGCHLD signalfd reaper + Spawn.ChildExited on early death → Task 6. ✔
+- Release on name-acquire (implicit re-route, explicit SUCCESS) → Task 7. ✔
+- Spawn timeout folded into epoll_wait + TimedOut → Task 8. ✔
+- Explicit StartServiceByName + ALREADY_RUNNING race → Task 9. ✔
+- vmtest gate + no unauthorized reboot → Task 10. ✔
+- Out-of-scope items (session bus, SIGHUP reload, PID1 delegation, b2 set) — correctly excluded, no tasks. ✔
+
+**Placeholder scan:** two explicit "Note for executor" items (Task 4 g_bus_addr file-scope; Task 9 codec field-name/double-take verification) are deliberate verification callouts against real code the plan cannot see the internals of, each with the concrete fix to apply — not open-ended TODOs. No "TBD"/"add error handling"/"write tests for the above".
+
+**Type consistency:** `sdbus_svc_ent`/`sdbus_svctab`/`sdbus_acts`/`sdbus_pending_act`/`sdbus_held_msg`, `sdbus_held_kind` enum, and function names (`sdbus_svctab_parse_dir`/`_find`/`_free`, `sdbus_acts_new`/`_find`/`_begin`/`_hold`/`_take`/`_by_pid`/`_next_deadline`/`_reap_expired`/`_free`, `spawn_service`, `activate_or_hold`, `release_activation`, `fail_held`) are consistent across Tasks 1-9. `SDBUS_SPAWN_TIMEOUT_MS`, `SDBUS_SVC_DIR`, `g_svctab`, `g_acts`, `g_bus_addr` defined in Task 3-4, used in 5-9.
diff --git a/docs/superpowers/specs/2026-09-04-schema-dbus-activation-design.md b/docs/superpowers/specs/2026-09-04-schema-dbus-activation-design.md
new file mode 100644
index 0000000..0046493
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-04-schema-dbus-activation-design.md
@@ -0,0 +1,202 @@
+# schema-dbus service activation — design
+
+**Date:** 2026-09-04
+**Status:** approved, pre-implementation
+**Depends on:** 2026-09-02-schema-dbus-sp1-broker-design.md (SP1 broker, live), schema-dbus v1.1 (six-bug fixes, deployed)
+
+## Problem
+
+The SP1 broker has no bus activation: `org.freedesktop.DBus.StartServiceByName`
+is a stub returning `ServiceUnknown`, and a method call to an unowned but
+activatable name errors instead of spawning the service. Stock dbus-daemon does
+both. Under the broker, every D-Bus service that was previously started
+on-demand goes dark unless the schema-init rail already launches its daemon
+eagerly.
+
+Live audit (`scripts/sdbus-activatable-audit.sh`, 67 activatable services):
+
+- **(a) 11 railed** — already started by the schema-init rail (wpa_supplicant1,
+ Accounts, Avahi, PolicyKit1, UDisks2, UPower, + the shims). Covered today.
+- **(b1) 43 regression set** — real `Exec=`, direct-activated on demand by stock
+ dbus-daemon, dark under the broker. Overwhelmingly KDE kauth/polkit helpers
+ (short-lived, spawned per privileged GUI action) plus fwupd, PackageKit,
+ ColorManager, GeoClue2, bolt, fprintd, RealtimeKit1, ModemManager1, lvmdbus1.
+- **(b2) 13 not a regression** — `Exec=/bin/false` + `SystemdService=`, already
+ dark pre-broker (no systemd as PID 1). Out of scope.
+
+This is not a per-service fix; it is a general mechanism the broker lacks. Build
+the mechanism and all 43 in (b1) are covered.
+
+## Decisions (locked in brainstorming)
+
+1. **Trigger scope: full auto-activation.** Any method call to an unowned-but-
+ activatable name triggers spawn + holds the triggering message + delivers it
+ once the name is claimed. Also handles explicit `StartServiceByName`. The
+ kauth helpers are reached by implicit calls, not explicit starts — implicit
+ is what actually covers (b1).
+2. **Spawn model: broker forks + execs directly.** Traditional dbus-daemon
+ model. Self-contained in the broker; PID 1 stays untouched. On-demand helpers
+ are not boot-critical, so the rail's restart/cgroup/watchdog guarantees do not
+ apply to them anyway. (schema-init's control socket only starts services
+ already declared in the rail; there is no "spawn arbitrary Exec= as User="
+ verb, so delegating would mean changing PID 1 — rejected.)
+3. **Security: match stock, always sanitize.** Any bus client can trigger
+ activation (the subsequent method call is still policy-gated, exactly as stock
+ dbus-daemon). Always honor `User=` from the file, always `setuid` down,
+ sanitize env, rely on CLOEXEC for fd hygiene. No per-caller activation policy
+ gate (would diverge from the parity goal and risk silently breaking flows).
+ No audit log.
+
+## Architecture
+
+New header `sdbus_activate.h` owns the tables and the pure table logic; the
+main loop in `schema-dbus.c` owns fork/exec, the epoll signalfd, and the
+deadline integration (main-loop concerns).
+
+### Data model (`sdbus_activate.h`)
+
+**Service table** — parsed once at startup:
+
+```
+typedef struct { char *name; char **argv; char *user; } sdbus_svc_ent;
+```
+
+One entry per `/usr/share/dbus-1/system-services/*.service` whose `Exec=` is a
+real binary. Entries with `Exec=/bin/false` are skipped (stay non-activatable →
+keep erroring `ServiceUnknown`, matching today).
+
+**Pending-activation table** — one entry per name currently being brought up:
+
+```
+typedef enum { SDBUS_HELD_IMPLICIT, SDBUS_HELD_EXPLICIT } sdbus_held_kind;
+typedef struct {
+ unsigned char *bytes; int len; /* captured wire message (implicit only) */
+ int *fds; int nfds; /* its passed fds (implicit only) */
+ int caller_id; uint32_t serial; /* who to answer, and their serial */
+ int expects_reply;
+ sdbus_held_kind kind;
+} sdbus_held_msg;
+
+typedef struct {
+ char *name; pid_t child_pid; long spawn_deadline_ms;
+ sdbus_held_msg *held; int n_held;
+} sdbus_pending_act;
+```
+
+### Parse (startup)
+
+Glob the system-services dir; line-parse `Name=` / `Exec=` / `User=` (same
+style as the audit script). `Exec=` is split into argv on whitespace. `User=`
+defaults to `root`, resolved via `getpwnam` at spawn time (not parse time).
+Malformed and `Exec=/bin/false` entries dropped. Fixed in-memory table; no
+reload for v1 (SIGHUP reload is existing backlog, out of scope).
+
+### Trigger + hold — the core hook
+
+Both entry points funnel to `activate_or_hold(name, captured_request)`:
+
+- **Implicit** — in `handle_message`, the `synth` branch (currently
+ `schema-dbus.c:249-250`, "name has no owner"). Before erroring: if
+ `msg->destination` is in the service table, capture the message (raw bytes +
+ its fds + `caller_id` + `serial` + `expects_reply`), append it to the pending
+ entry for that name, and — if no entry existed yet — spawn. The message is
+ **held**, not answered. If the destination is not activatable, fall through to
+ the existing `ServiceUnknown` error (unchanged).
+- **Explicit** — `StartServiceByName` special-cased in `handle_message`'s
+ `to_driver` path, *before* `sdbus_driver_dispatch` (it needs fork/epoll
+ concerns the driver module cannot reach). Same hold, `kind=EXPLICIT`, no
+ captured body.
+
+Races: if the name is *already owned* when we reach the hook (claimed between
+route decision and here) → implicit re-routes normally (fresh
+`sdbus_route_targets`); explicit replies `ALREADY_RUNNING` (2). If an entry is
+already `SPAWNING` → append to it, no second fork.
+
+### Spawn mechanics + security
+
+`fork()`. In the child:
+
+1. `setsid()`.
+2. Resolve `User=` via `getpwnam` → `initgroups()` + `setgid()` + `setuid()`
+ (privileges dropped before exec).
+3. Build a clean environment: `PATH`, `DBUS_STARTER_ADDRESS=`,
+ `DBUS_STARTER_BUS_TYPE=system` (stock parity).
+4. `execv(argv[0], argv)`.
+
+All broker fds (listen, epoll, per-conn, signalfd) are `CLOEXEC`, so exec closes
+them — no descriptor leak into the activated service. On `execv` failure the
+child writes nothing and `_exit(127)`; the parent learns via SIGCHLD (see
+Failure paths).
+
+Parent records `child_pid` and `spawn_deadline_ms = now + 25000` (stock's
+timeout).
+
+### Release (success)
+
+Hook into the existing name-acquisition path. `broadcast_transitions`
+(`schema-dbus.c:145`) already fires when a name gets `new_owner >= 0`. Add: on a
+newly-acquired name, look up the pending table; if a pending entry exists, flush
+every held message and clear the entry:
+
+- **IMPLICIT** — re-inject exactly as a fresh send: re-run `sdbus_route_targets`
+ now that an owner exists (records the pending-reply entry normally so the
+ eventual reply routes back), reforward the captured bytes + fds to the new
+ owner.
+- **EXPLICIT** — synthesize a `StartServiceByName` method_return carrying
+ `DBUS_START_REPLY_SUCCESS` (1) to the caller.
+
+### Failure + timeout paths
+
+- **Timeout** — fold a second deadline source into the `epoll_wait` timeout
+ computation (alongside `sdbus_replies_next_deadline`, `schema-dbus.c:427`). On
+ expiry: reply `org.freedesktop.DBus.Error.TimedOut` to every held caller,
+ drop the entry. Do not kill the child (it may still be coming up or will die
+ on its own; it will be reaped by SIGCHLD).
+- **Spawn failure** (fork fails) — immediate
+ `org.freedesktop.DBus.Error.Spawn.Failed` to held callers, no entry created.
+- **Child died before claiming its name** (includes exec failure → `_exit(127)`)
+ — `org.freedesktop.DBus.Error.Spawn.ChildExited`, via SIGCHLD below.
+
+### SIGCHLD / child lifecycle
+
+The broker has no signal handling today. Add a `signalfd` for `SIGCHLD`
+(`SFD_CLOEXEC`, with `SIGCHLD` blocked via `sigprocmask`), registered in epoll
+with a dedicated sentinel `data.ptr`. On its event: drain the signalfd, then
+`waitpid(-1, WNOHANG)` in a loop reaping all exited children (no zombies). For
+each reaped pid: if it maps to a **still-pending** activation (name never
+claimed), fail that entry's held messages with `Spawn.ChildExited` and drop the
+entry. A child that exits *after* claiming its name is normal (short-lived
+kauth helpers) — its entry is already cleared, so it is simply reaped.
+
+## Blast radius
+
+- **New:** `sdbus_activate.h`, `tests/test_sdbus_activate.c`.
+- **Modified `schema-dbus.c`:** ~4 hook points — the `synth` branch (implicit
+ trigger), the `to_driver` path (explicit `StartServiceByName`),
+ `broadcast_transitions` (release on name-acquire), and `main` (signalfd setup
+ + second deadline in the `epoll_wait` computation + the SIGCHLD reap block).
+- **Unchanged:** policy, names, route, reply, match, conn, auth, wire, codec
+ modules. PID 1 (`init.c`) untouched.
+
+## Testing
+
+- **Unit** (`tests/test_sdbus_activate.c`, added to the existing `make test`
+ harness): service-file parse (real `Exec=` vs `/bin/false` skip, `User=`
+ parse, argv split), hold/release table logic (implicit + explicit), timeout
+ expiry produces `TimedOut`, child-died-while-pending produces
+ `Spawn.ChildExited`, already-owned race → re-route / `ALREADY_RUNNING`. Spawn
+ path exercised with a stub helper binary that claims a name; table/hold logic
+ needs no root.
+- **Live**: pick a real (b1) service (e.g. `fwupd` or a kauth helper), call it
+ cold, confirm it spawns and answers instead of `ServiceUnknown`. Re-run the
+ audit to confirm the regression set is now covered by mechanism.
+- **vmtest** (`schema-vmtest`) before any hardware reboot — mandatory. Then the
+ standard deploy path (`make schema-dbus` → back up `/usr/local/bin/schema-dbus`
+ → `sudo make install-dbus-sp1` → reboot), rollback binary preserved.
+
+## Out of scope
+
+- Session-bus activation (SP4 / "SP2" — separate subproject).
+- Service-file reload on SIGHUP (existing v1.1 backlog).
+- Delegating spawn to PID 1 (rejected — decision 2).
+- `SystemdService=`-only entries (b2 set) — cannot spawn, stay `ServiceUnknown`.
diff --git a/schema-dbus.c b/schema-dbus.c
index a7698c0..326d1bb 100644
--- a/schema-dbus.c
+++ b/schema-dbus.c
@@ -14,6 +14,11 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
#include "sdbus_codec.h"
#include "sdbus_wire.h"
@@ -25,6 +30,7 @@
#include "sdbus_auth.h"
#include "sdbus_driver.h"
#include "sdbus_route.h"
+#include "sdbus_activate.h"
#define DEFAULT_SOCKET "/run/dbus/system_bus_socket"
#define MAX_TARGETS 256
@@ -37,6 +43,13 @@ static int g_nconns;
static int g_epfd;
static unsigned g_next_id = 1;
static dbus_uint32_t g_bcast_serial;
+static sdbus_svctab *g_svctab;
+static sdbus_acts *g_acts;
+static char g_bus_addr[256];
+#define SDBUS_SVC_DIR "/usr/share/dbus-1/system-services"
+#define SDBUS_SPAWN_TIMEOUT_MS 25000
+
+static pid_t spawn_service(const sdbus_svc_ent *e, const char *bus_addr);
/* ---- epoll registration ---- */
static void ep_update(sdbus_conn *c) {
@@ -112,6 +125,53 @@ static const char *unique_or_empty(int conn_id) {
return u ? u : "";
}
+/* a name just got an owner: deliver every message held for it. IMPLICIT ones are
+ re-routed as a fresh send (records a pending reply so the answer routes back);
+ EXPLICIT StartServiceByName callers get a SUCCESS reply. */
+static void release_activation(const char *name) {
+ sdbus_held_msg *held = NULL; int n = 0;
+ if (!sdbus_acts_take(g_acts, name, &held, &n)) return;
+ for (int i = 0; i < n; i++) {
+ sdbus_conn *caller = conn_by_id(held[i].caller_id);
+ if (held[i].kind == SDBUS_HELD_EXPLICIT) {
+ if (caller) {
+ DBusMessage *r = dbus_message_new(DBUS_MESSAGE_TYPE_METHOD_RETURN);
+ dbus_message_set_reply_serial(r, held[i].serial);
+ dbus_message_set_serial(r, ++g_bcast_serial);
+ dbus_message_set_sender(r, SDBUS_DRIVER_NAME);
+ if (caller->unique) dbus_message_set_destination(r, caller->unique);
+ dbus_uint32_t code = DBUS_START_REPLY_SUCCESS;
+ dbus_message_append_args(r, DBUS_TYPE_UINT32, &code, DBUS_TYPE_INVALID);
+ char *b = NULL; int len = 0;
+ if (dbus_message_marshal(r, &b, &len)) { sdbus_conn_enqueue(caller, (unsigned char *)b, len, NULL, 0); dbus_free(b); ep_update(caller); }
+ dbus_message_unref(r);
+ }
+ for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ free(held[i].bytes); free(held[i].fds);
+ continue;
+ }
+ /* IMPLICIT: re-route the captured (already sender-stamped) message. */
+ sdbus_wire_msg w2;
+ if (sdbus_wire_parse(held[i].bytes, held[i].len, &w2) == held[i].len && caller) {
+ int targets[MAX_TARGETS], synth2 = 0, denied2 = 0;
+ int nt = sdbus_route_targets(&w2, caller, g_names, g_conns, g_nconns,
+ g_policy, g_replies, &synth2, &denied2, targets, MAX_TARGETS);
+ for (int k = 0; k < nt; k++) {
+ sdbus_conn *dst = conn_by_id(targets[k]);
+ if (!dst) continue;
+ if (k == 0 && held[i].nfds > 0) sdbus_conn_enqueue(dst, held[i].bytes, held[i].len, held[i].fds, held[i].nfds);
+ else sdbus_conn_enqueue(dst, held[i].bytes, held[i].len, NULL, 0);
+ ep_update(dst);
+ }
+ if (!(held[i].nfds > 0 && nt > 0)) for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ } else {
+ for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ }
+ free(held[i].bytes); free(held[i].fds);
+ }
+ free(held);
+}
+
static void broadcast_transitions(void *ctx, sdbus_transition *t, int n) {
(void)ctx;
for (int i = 0; i < n; i++) {
@@ -149,6 +209,7 @@ static void broadcast_transitions(void *ctx, sdbus_transition *t, int n) {
dbus_message_append_args(s, DBUS_TYPE_STRING, &name, DBUS_TYPE_INVALID);
enqueue_signal(o, s); dbus_message_unref(s);
}
+ release_activation(name); /* deliver anything held for this name */
}
}
}
@@ -187,6 +248,21 @@ static void send_no_reply_to(int caller_id, uint32_t reply_serial, const char *t
ep_update(caller);
}
+/* reply an error to each held caller of an activation that failed, and free the
+ held array (bytes/fds owned by the array). */
+static void fail_held(sdbus_held_msg *held, int n, const char *errname, const char *text) {
+ for (int i = 0; i < n; i++) {
+ sdbus_conn *caller = conn_by_id(held[i].caller_id);
+ if (caller && held[i].expects_reply) {
+ synth_error_wire(caller, held[i].serial, errname, text);
+ ep_update(caller);
+ }
+ for (int j = 0; j < held[i].nfds; j++) close(held[i].fds[j]);
+ free(held[i].bytes); free(held[i].fds);
+ }
+ free(held);
+}
+
/* drop this message's fds from the head of pending_fds; close them unless they
were transferred to an outbound chunk (which will close them after sending). */
static void consume_msg_fds(sdbus_conn *c, int nfds, int transferred) {
@@ -197,6 +273,53 @@ static void consume_msg_fds(sdbus_conn *c, int nfds, int transferred) {
c->n_pending_fds -= nfds;
}
+/* If w->destination is an activatable name with no current owner, capture this
+ message (reforwarded bytes + dup'd fds + caller + serial), spawn the service if
+ not already spawning, and return 1 (message is held, not answered). Return 0 if
+ the name is not activatable (caller emits ServiceUnknown as before). The dup'd
+ fds are owned by the held copy; the caller still consumes the originals. */
+static int activate_or_hold(sdbus_conn *c, sdbus_wire_msg *w,
+ const unsigned char *raw, int rawlen, int nfds) {
+ (void)rawlen;
+ if (!w->destination) return 0;
+ const sdbus_svc_ent *e = sdbus_svctab_find(g_svctab, w->destination);
+ if (!e) return 0;
+
+ /* capture the reforwarded (sender-stamped) bytes so release re-injects a
+ ready-to-route message. */
+ unsigned char *fwd = NULL; int fwlen = 0;
+ if (sdbus_wire_reforward(raw, w, c->unique, &fwd, &fwlen) != 0) return 0;
+
+ sdbus_held_msg m = {0};
+ m.bytes = fwd; m.len = fwlen;
+ m.caller_id = c->id; m.serial = w->serial;
+ m.expects_reply = !(w->flags & SDBUS_FLAG_NO_REPLY);
+ m.kind = SDBUS_HELD_IMPLICIT;
+ if (nfds > 0) {
+ m.fds = malloc(nfds * sizeof(int));
+ for (int i = 0; i < nfds; i++) m.fds[i] = dup(c->pending_fds[i]);
+ m.nfds = nfds;
+ }
+
+ sdbus_pending_act *pa = sdbus_acts_find(g_acts, w->destination);
+ if (!pa) {
+ pid_t pid = spawn_service(e, g_bus_addr);
+ if (pid < 0) { /* fork failed -> ServiceUnknown path */
+ free(fwd);
+ for (int i = 0; i < m.nfds; i++) close(m.fds[i]);
+ free(m.fds);
+ return 0;
+ }
+ pa = sdbus_acts_begin(g_acts, w->destination, pid,
+ sdbus__now_ms() + SDBUS_SPAWN_TIMEOUT_MS);
+ }
+ sdbus_acts_hold(pa, &m); /* deep-copies bytes + fds */
+ free(fwd);
+ for (int i = 0; i < m.nfds; i++) close(m.fds[i]);
+ free(m.fds);
+ return 1;
+}
+
/* ---- process one fully-buffered message (parsed header w, raw bytes) ---- */
static void handle_message(sdbus_conn *c, sdbus_wire_msg *w, const unsigned char *raw, int rawlen) {
int nfds = sdbus_wire_n_fds(w); /* this message's fds sit at head of pending_fds */
@@ -216,6 +339,57 @@ static void handle_message(sdbus_conn *c, sdbus_wire_msg *w, const unsigned char
if (!w->destination && w->interface && !strcmp(w->interface, "org.freedesktop.DBus.Peer"))
to_driver = 1; /* Peer methods (Ping/GetMachineId) may omit the destination */
+ if (to_driver && w->member && !strcmp(w->member, "StartServiceByName")) {
+ /* explicit activation: arg0 = target name, arg1 = flags (su) */
+ sdbus_msg dm;
+ const char *target = NULL;
+ dbus_uint32_t sflags = 0;
+ int parsed = (sdbus_codec_take(raw, rawlen, &dm) == rawlen);
+ if (parsed) {
+ DBusError e; dbus_error_init(&e);
+ if (!dbus_message_get_args(dm.msg, &e, DBUS_TYPE_STRING, &target,
+ DBUS_TYPE_UINT32, &sflags, DBUS_TYPE_INVALID)) {
+ dbus_error_free(&e); target = NULL;
+ }
+ }
+ if (!target) {
+ synth_error_wire(c, w->serial, DBUS_ERROR_INVALID_ARGS, "StartServiceByName needs a name");
+ } else if (sdbus_names_owner(g_names, target) >= 0) {
+ DBusMessage *r = dbus_message_new(DBUS_MESSAGE_TYPE_METHOD_RETURN);
+ dbus_message_set_reply_serial(r, w->serial);
+ dbus_message_set_serial(r, ++g_bcast_serial);
+ dbus_message_set_sender(r, SDBUS_DRIVER_NAME);
+ if (c->unique) dbus_message_set_destination(r, c->unique);
+ dbus_uint32_t code = DBUS_START_REPLY_ALREADY_RUNNING;
+ dbus_message_append_args(r, DBUS_TYPE_UINT32, &code, DBUS_TYPE_INVALID);
+ char *b = NULL; int len = 0;
+ if (dbus_message_marshal(r, &b, &len)) { sdbus_conn_enqueue(c, (unsigned char *)b, len, NULL, 0); dbus_free(b); }
+ dbus_message_unref(r);
+ } else {
+ const sdbus_svc_ent *ent = sdbus_svctab_find(g_svctab, target);
+ if (!ent) {
+ synth_error_wire(c, w->serial, DBUS_ERROR_SERVICE_UNKNOWN, "no such activatable service");
+ } else {
+ sdbus_pending_act *pa = sdbus_acts_find(g_acts, target);
+ if (!pa) {
+ pid_t pid = spawn_service(ent, g_bus_addr);
+ if (pid < 0) synth_error_wire(c, w->serial, DBUS_ERROR_SPAWN_FAILED, "fork failed");
+ else pa = sdbus_acts_begin(g_acts, target, pid, sdbus__now_ms() + SDBUS_SPAWN_TIMEOUT_MS);
+ }
+ if (pa) {
+ sdbus_held_msg m = {0};
+ m.caller_id = c->id; m.serial = w->serial; m.expects_reply = 1;
+ m.kind = SDBUS_HELD_EXPLICIT;
+ sdbus_acts_hold(pa, &m);
+ }
+ }
+ }
+ if (parsed) sdbus_msg_free(&dm);
+ drain_scratch(c); ep_update(c);
+ consume_msg_fds(c, nfds, 0);
+ return;
+ }
+
if (to_driver) {
/* driver methods never carry fds -> libdbus can demarshal to read args */
sdbus_msg dm;
@@ -247,7 +421,8 @@ static void handle_message(sdbus_conn *c, sdbus_wire_msg *w, const unsigned char
if (denied) {
synth_error_wire(c, w->serial, DBUS_ERROR_ACCESS_DENIED, "rejected by policy");
} else if (synth) {
- synth_error_wire(c, w->serial, DBUS_ERROR_SERVICE_UNKNOWN, "name has no owner");
+ if (!activate_or_hold(c, w, raw, rawlen, nfds))
+ synth_error_wire(c, w->serial, DBUS_ERROR_SERVICE_UNKNOWN, "name has no owner");
} else if (nt > 0) {
unsigned char *bytes = NULL; int len = 0;
if (sdbus_wire_reforward(raw, w, c->unique, &bytes, &len) == 0) {
@@ -376,12 +551,42 @@ static int make_listen_socket(const char *path) {
return fd;
}
+/* fork+exec an activatable service, dropping to its User= before exec. Returns
+ the child pid, or -1 if fork failed. Matches stock dbus-daemon: clean env with
+ DBUS_STARTER_*; all broker fds are CLOEXEC so exec closes them. */
+static pid_t spawn_service(const sdbus_svc_ent *e, const char *bus_addr) {
+ pid_t pid = fork();
+ if (pid < 0) return -1;
+ if (pid > 0) return pid;
+
+ /* --- child --- */
+ setsid();
+ struct passwd *pw = getpwnam(e->user);
+ if (!pw) _exit(127); /* unknown User= -> fail closed, never run as root */
+ if (pw->pw_uid != 0) {
+ if (initgroups(e->user, pw->pw_gid) != 0) _exit(127);
+ if (setgid(pw->pw_gid) != 0) _exit(127);
+ if (setuid(pw->pw_uid) != 0) _exit(127);
+ }
+ char starter[320];
+ snprintf(starter, sizeof starter, "DBUS_STARTER_ADDRESS=%s", bus_addr);
+ char *env[] = {
+ (char *)"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin",
+ starter,
+ (char *)"DBUS_STARTER_BUS_TYPE=system",
+ NULL
+ };
+ execve(e->argv[0], e->argv, env);
+ _exit(127); /* exec failed */
+}
+
int main(int argc, char **argv) {
int system_bus = 0;
for (int i = 1; i < argc; i++) if (!strcmp(argv[i], "--system")) system_bus = 1;
const char *sock = getenv("SCHEMA_DBUS_SOCKET");
if (!sock) sock = DEFAULT_SOCKET;
+ snprintf(g_bus_addr, sizeof g_bus_addr, "unix:path=%s", sock);
/* load the dissolved policy: prefer a precompiled file, else dissolve live */
const char *polfile = getenv("SCHEMA_DBUS_POLICY");
@@ -406,14 +611,29 @@ int main(int argc, char **argv) {
free(poltext);
g_names = sdbus_names_new();
g_replies = sdbus_replies_new();
+ const char *svcdir = getenv("SCHEMA_DBUS_SVCDIR");
+ g_svctab = sdbus_svctab_parse_dir(svcdir ? svcdir : SDBUS_SVC_DIR);
+ g_acts = sdbus_acts_new();
+ fprintf(stderr, "schema-dbus: %d activatable services\n", g_svctab->n);
int lfd = make_listen_socket(sock);
if (lfd < 0) { fprintf(stderr, "schema-dbus: cannot bind %s: %s\n", sock, strerror(errno)); return 1; }
+ /* SIGCHLD via signalfd so activated children are reaped in the event loop */
+ static int sigfd_marker;
+ sigset_t scmask;
+ sigemptyset(&scmask);
+ sigaddset(&scmask, SIGCHLD);
+ sigprocmask(SIG_BLOCK, &scmask, NULL);
+ int sigfd = signalfd(-1, &scmask, SFD_NONBLOCK | SFD_CLOEXEC);
+
g_epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event lev = {0};
lev.events = EPOLLIN; lev.data.ptr = NULL; /* NULL ptr marks the listen fd */
epoll_ctl(g_epfd, EPOLL_CTL_ADD, lfd, &lev);
+ struct epoll_event sev = {0};
+ sev.events = EPOLLIN; sev.data.ptr = &sigfd_marker;
+ epoll_ctl(g_epfd, EPOLL_CTL_ADD, sigfd, &sev);
int vmaj = 0, vmin = 0, vmic = 0;
dbus_get_version(&vmaj, &vmin, &vmic);
@@ -425,6 +645,8 @@ int main(int argc, char **argv) {
/* block until an event, or wake at the next pending-reply deadline */
int wait_ms = -1;
long nd = sdbus_replies_next_deadline(g_replies);
+ long ad = sdbus_acts_next_deadline(g_acts);
+ if (ad >= 0 && (nd < 0 || ad < nd)) nd = ad;
if (nd >= 0) { long now = sdbus__now_ms(); wait_ms = nd <= now ? 0 : (int)(nd - now); }
int nev = epoll_wait(g_epfd, evs, 64, wait_ms);
if (nev < 0) { if (errno == EINTR) continue; break; }
@@ -437,6 +659,20 @@ int main(int argc, char **argv) {
}
continue;
}
+ if (evs[i].data.ptr == &sigfd_marker) { /* SIGCHLD: reap activated children */
+ struct signalfd_siginfo si;
+ while (read(sigfd, &si, sizeof si) == (ssize_t)sizeof si) { } /* drain */
+ int st; pid_t p;
+ while ((p = waitpid(-1, &st, WNOHANG)) > 0) {
+ sdbus_pending_act *pa = sdbus_acts_by_pid(g_acts, (int)p);
+ if (!pa) continue; /* exited after claiming its name: normal */
+ sdbus_held_msg *held = NULL; int nh = 0;
+ sdbus_acts_take(g_acts, pa->name, &held, &nh);
+ fail_held(held, nh, DBUS_ERROR_SPAWN_CHILD_EXITED,
+ "Activated service exited before acquiring its name");
+ }
+ continue;
+ }
sdbus_conn *c = evs[i].data.ptr;
if (evs[i].events & (EPOLLHUP | EPOLLERR)) { remove_conn(c); continue; }
if (evs[i].events & EPOLLIN) { if (!on_readable(c)) continue; } /* c may be freed */
@@ -458,6 +694,14 @@ int main(int argc, char **argv) {
"Did not receive a reply within the bus timeout");
free(callers); free(serials);
}
+ /* time out activations whose service never claimed its name */
+ for (;;) {
+ long now = sdbus__now_ms();
+ sdbus_held_msg *held = NULL; int nh = 0;
+ if (!sdbus_acts_reap_expired(g_acts, now, &held, &nh)) break;
+ fail_held(held, nh, DBUS_ERROR_TIMED_OUT,
+ "Activated service failed to acquire its name in time");
+ }
/* reap connections whose outbound backlog overflowed the cap. Swept after
the event batch (and after the NoReply enqueues above) so no evs[] entry
can reference a freed conn; looped because a reap's disconnect signals can
diff --git a/sdbus_activate.h b/sdbus_activate.h
new file mode 100644
index 0000000..a392f2c
--- /dev/null
+++ b/sdbus_activate.h
@@ -0,0 +1,167 @@
+#ifndef SDBUS_ACTIVATE_H
+#define SDBUS_ACTIVATE_H
+
+#include
+#include
+#include
+#include
+#include
+
+typedef struct { char *name; char **argv; char *user; } sdbus_svc_ent;
+typedef struct { sdbus_svc_ent *v; int n; } sdbus_svctab;
+
+/* split "a b c" into a NULL-terminated argv (whitespace-delimited, no quoting —
+ dbus service Exec lines are plain paths + flags). */
+static inline char **sdbus__split_argv(const char *s) {
+ int cap = 4, n = 0;
+ char **a = malloc(cap * sizeof *a);
+ char *dup = strdup(s), *save = NULL;
+ for (char *tok = strtok_r(dup, " \t", &save); tok; tok = strtok_r(NULL, " \t", &save)) {
+ if (n + 1 >= cap) { cap *= 2; a = realloc(a, cap * sizeof *a); }
+ a[n++] = strdup(tok);
+ }
+ a[n] = NULL;
+ free(dup);
+ return a;
+}
+
+static inline void sdbus__svc_add(sdbus_svctab *t, const char *name,
+ const char *exec, const char *user) {
+ t->v = realloc(t->v, (t->n + 1) * sizeof *t->v);
+ sdbus_svc_ent *e = &t->v[t->n++];
+ e->name = strdup(name);
+ e->argv = sdbus__split_argv(exec);
+ e->user = strdup(user && *user ? user : "root");
+}
+
+static inline sdbus_svctab *sdbus_svctab_parse_dir(const char *dir) {
+ sdbus_svctab *t = calloc(1, sizeof *t);
+ DIR *d = opendir(dir);
+ if (!d) return t; /* empty table, not NULL */
+ struct dirent *de;
+ while ((de = readdir(d))) {
+ size_t l = strlen(de->d_name);
+ if (l < 9 || strcmp(de->d_name + l - 8, ".service")) continue;
+ char path[1024];
+ snprintf(path, sizeof path, "%s/%s", dir, de->d_name);
+ FILE *f = fopen(path, "r");
+ if (!f) continue;
+ char line[2048], name[2048] = "", exec[2048] = "", user[2048] = "";
+ while (fgets(line, sizeof line, f)) {
+ line[strcspn(line, "\r\n")] = '\0';
+ if (!strncmp(line, "Name=", 5)) snprintf(name, sizeof name, "%s", line + 5);
+ else if (!strncmp(line, "Exec=", 5)) snprintf(exec, sizeof exec, "%s", line + 5);
+ else if (!strncmp(line, "User=", 5)) snprintf(user, sizeof user, "%s", line + 5);
+ }
+ fclose(f);
+ if (!name[0] || !exec[0]) continue; /* need both */
+ /* skip Exec=/bin/false (systemd-only activatables) */
+ if (!strcmp(exec, "/bin/false") || !strcmp(exec, "/usr/bin/false")) continue;
+ sdbus__svc_add(t, name, exec, user);
+ }
+ closedir(d);
+ return t;
+}
+
+static inline const sdbus_svc_ent *sdbus_svctab_find(sdbus_svctab *t, const char *name) {
+ if (!name) return NULL;
+ for (int i = 0; i < t->n; i++)
+ if (!strcmp(t->v[i].name, name)) return &t->v[i];
+ return NULL;
+}
+
+static inline void sdbus_svctab_free(sdbus_svctab *t) {
+ if (!t) return;
+ for (int i = 0; i < t->n; i++) {
+ free(t->v[i].name); free(t->v[i].user);
+ for (char **a = t->v[i].argv; a && *a; a++) free(*a);
+ free(t->v[i].argv);
+ }
+ free(t->v); free(t);
+}
+
+typedef enum { SDBUS_HELD_IMPLICIT, SDBUS_HELD_EXPLICIT } sdbus_held_kind;
+typedef struct {
+ unsigned char *bytes; int len; /* captured wire message (implicit) */
+ int *fds; int nfds; /* its passed fds (implicit) */
+ int caller_id; uint32_t serial; int expects_reply;
+ sdbus_held_kind kind;
+} sdbus_held_msg;
+typedef struct {
+ char *name; int child_pid; long deadline_ms;
+ sdbus_held_msg *held; int n_held;
+} sdbus_pending_act;
+typedef struct { sdbus_pending_act *v; int n; } sdbus_acts;
+
+static inline sdbus_acts *sdbus_acts_new(void) { return calloc(1, sizeof(sdbus_acts)); }
+
+static inline sdbus_pending_act *sdbus_acts_find(sdbus_acts *a, const char *name) {
+ if (!name) return NULL;
+ for (int i = 0; i < a->n; i++)
+ if (!strcmp(a->v[i].name, name)) return &a->v[i];
+ return NULL;
+}
+
+static inline sdbus_pending_act *sdbus_acts_by_pid(sdbus_acts *a, int pid) {
+ for (int i = 0; i < a->n; i++) if (a->v[i].child_pid == pid) return &a->v[i];
+ return NULL;
+}
+
+static inline sdbus_pending_act *sdbus_acts_begin(sdbus_acts *a, const char *name,
+ int pid, long deadline_ms) {
+ a->v = realloc(a->v, (a->n + 1) * sizeof *a->v);
+ sdbus_pending_act *e = &a->v[a->n++];
+ memset(e, 0, sizeof *e);
+ e->name = strdup(name); e->child_pid = pid; e->deadline_ms = deadline_ms;
+ return e;
+}
+
+static inline void sdbus_acts_hold(sdbus_pending_act *e, const sdbus_held_msg *m) {
+ e->held = realloc(e->held, (e->n_held + 1) * sizeof *e->held);
+ sdbus_held_msg *d = &e->held[e->n_held++];
+ *d = *m;
+ if (m->len > 0 && m->bytes) { d->bytes = malloc(m->len); memcpy(d->bytes, m->bytes, m->len); }
+ else { d->bytes = NULL; d->len = 0; }
+ if (m->nfds > 0 && m->fds) { d->fds = malloc(m->nfds * sizeof(int)); memcpy(d->fds, m->fds, m->nfds * sizeof(int)); }
+ else { d->fds = NULL; d->nfds = 0; }
+}
+
+/* remove entry at index i, handing its held[] array to *out (ownership transfers). */
+static inline void sdbus__acts_pop(sdbus_acts *a, int i, sdbus_held_msg **out, int *n) {
+ *out = a->v[i].held; *n = a->v[i].n_held;
+ free(a->v[i].name);
+ a->v[i] = a->v[--a->n]; /* swap-remove */
+}
+
+static inline int sdbus_acts_take(sdbus_acts *a, const char *name,
+ sdbus_held_msg **out, int *n) {
+ for (int i = 0; i < a->n; i++)
+ if (!strcmp(a->v[i].name, name)) { sdbus__acts_pop(a, i, out, n); return 1; }
+ *out = NULL; *n = 0; return 0;
+}
+
+static inline long sdbus_acts_next_deadline(sdbus_acts *a) {
+ long best = -1;
+ for (int i = 0; i < a->n; i++)
+ if (best < 0 || a->v[i].deadline_ms < best) best = a->v[i].deadline_ms;
+ return best;
+}
+
+static inline int sdbus_acts_reap_expired(sdbus_acts *a, long now_ms,
+ sdbus_held_msg **out, int *n) {
+ for (int i = 0; i < a->n; i++)
+ if (a->v[i].deadline_ms <= now_ms) { sdbus__acts_pop(a, i, out, n); return 1; }
+ *out = NULL; *n = 0; return 0;
+}
+
+static inline void sdbus_acts_free(sdbus_acts *a) {
+ if (!a) return;
+ for (int i = 0; i < a->n; i++) {
+ free(a->v[i].name);
+ for (int j = 0; j < a->v[i].n_held; j++) { free(a->v[i].held[j].bytes); free(a->v[i].held[j].fds); }
+ free(a->v[i].held);
+ }
+ free(a->v); free(a);
+}
+
+#endif
diff --git a/tests/test_sdbus_activate.c b/tests/test_sdbus_activate.c
new file mode 100644
index 0000000..d4c9fb0
--- /dev/null
+++ b/tests/test_sdbus_activate.c
@@ -0,0 +1,99 @@
+#include "../sdbus_activate.h"
+#include
+#include
+#include
+#include
+#include
+
+/* write a .service file into dir */
+static void put(const char *dir, const char *fn, const char *body) {
+ char p[512]; snprintf(p, sizeof p, "%s/%s", dir, fn);
+ FILE *f = fopen(p, "w"); assert(f); fputs(body, f); fclose(f);
+}
+
+static void test_parse(void) {
+ char dir[] = "/tmp/sdbus-act-XXXXXX";
+ assert(mkdtemp(dir));
+ put(dir, "real.service",
+ "[D-BUS Service]\nName=com.example.Real\nExec=/usr/libexec/realsvc --arg\nUser=root\n");
+ put(dir, "nouser.service",
+ "[D-BUS Service]\nName=com.example.NoUser\nExec=/usr/libexec/nu\n");
+ put(dir, "false.service",
+ "[D-BUS Service]\nName=com.example.Systemd\nExec=/bin/false\nSystemdService=x.service\n");
+ put(dir, "noname.service", "[D-BUS Service]\nExec=/usr/libexec/x\n");
+
+ sdbus_svctab *t = sdbus_svctab_parse_dir(dir);
+ assert(t);
+
+ const sdbus_svc_ent *r = sdbus_svctab_find(t, "com.example.Real");
+ assert(r);
+ assert(!strcmp(r->argv[0], "/usr/libexec/realsvc"));
+ assert(!strcmp(r->argv[1], "--arg"));
+ assert(r->argv[2] == NULL);
+ assert(!strcmp(r->user, "root"));
+
+ const sdbus_svc_ent *n = sdbus_svctab_find(t, "com.example.NoUser");
+ assert(n && !strcmp(n->user, "root")); /* default */
+
+ assert(sdbus_svctab_find(t, "com.example.Systemd") == NULL); /* /bin/false skipped */
+ assert(sdbus_svctab_find(t, "com.example.Missing") == NULL); /* absent */
+ assert(t->n == 2); /* real + nouser only */
+
+ sdbus_svctab_free(t);
+ /* cleanup */
+ char cmd[600]; snprintf(cmd, sizeof cmd, "rm -rf %s", dir); (void)system(cmd);
+ printf("test_parse OK\n");
+}
+
+static sdbus_held_msg mk(int caller, uint32_t serial, sdbus_held_kind k) {
+ sdbus_held_msg m = {0};
+ m.bytes = (unsigned char *)strdup("wire"); m.len = 4;
+ m.caller_id = caller; m.serial = serial; m.expects_reply = 1; m.kind = k;
+ return m;
+}
+
+static void test_pending(void) {
+ sdbus_acts *a = sdbus_acts_new();
+ assert(sdbus_acts_find(a, "com.x") == NULL);
+ assert(sdbus_acts_next_deadline(a) == -1);
+
+ sdbus_pending_act *e = sdbus_acts_begin(a, "com.x", 4242, 1000);
+ assert(e && e->child_pid == 4242 && e->n_held == 0);
+ assert(sdbus_acts_find(a, "com.x") == e);
+ assert(sdbus_acts_by_pid(a, 4242) == e);
+ assert(sdbus_acts_next_deadline(a) == 1000);
+
+ sdbus_held_msg m1 = mk(10, 1, SDBUS_HELD_IMPLICIT);
+ sdbus_held_msg m2 = mk(11, 2, SDBUS_HELD_EXPLICIT);
+ sdbus_acts_hold(e, &m1); sdbus_acts_hold(e, &m2);
+ free(m1.bytes); free(m2.bytes); /* hold deep-copies */
+ assert(sdbus_acts_find(a, "com.x")->n_held == 2);
+
+ /* take removes the entry and hands back the held array */
+ sdbus_held_msg *out = NULL; int n = 0;
+ assert(sdbus_acts_take(a, "com.x", &out, &n) == 1);
+ assert(n == 2 && out[0].caller_id == 10 && out[1].kind == SDBUS_HELD_EXPLICIT);
+ assert(sdbus_acts_find(a, "com.x") == NULL); /* gone */
+ for (int i = 0; i < n; i++) { free(out[i].bytes); free(out[i].fds); }
+ free(out);
+
+ /* reap_expired picks the entry whose deadline has passed */
+ sdbus_acts_begin(a, "com.y", 5, 500);
+ sdbus_acts_begin(a, "com.z", 6, 3000);
+ sdbus_held_msg *o2 = NULL; int n2 = 0;
+ assert(sdbus_acts_reap_expired(a, 600, &o2, &n2) == 1); /* com.y expired */
+ assert(sdbus_acts_find(a, "com.y") == NULL);
+ assert(sdbus_acts_find(a, "com.z") != NULL); /* not yet */
+ free(o2);
+ assert(sdbus_acts_reap_expired(a, 600, &o2, &n2) == 0); /* none left expired */
+
+ sdbus_acts_free(a);
+ printf("test_pending OK\n");
+}
+
+int main(void) {
+ test_parse();
+ test_pending();
+ printf("all sdbus_activate tests passed\n");
+ return 0;
+}