Skip to content

HealthSync: Apple Health source waits forever on Linux (iCloud-only fallback, no REST hint) #2539

Open

Description

@jdrolls

Version

LifeOS 8.762.3 · attestation: not run

What is broken

On Linux (any non-macOS host) the Apple Health source never moves past awaiting-first-export. pull() falls back to the iCloud Drive file transport, which only exists on a Mac (the ~/Library/Mobile Documents/... export path and brctl download), so with HEALTH_INGEST_URL unset the source waits forever and nothing tells the user that the REST transport is the only route off macOS. Expected: off macOS, with no REST config, the source reports what is missing (needs HEALTH_INGEST_URL, or the drain token) instead of waiting on a path that cannot appear; with REST configured it pulls over REST as today; macOS behaviour is unchanged.

Where (file:line)

LIFEOS/TOOLS/healthsync/apple.ts:252 (pull falls back to the iCloud file route off macOS)

Repro on a clean tree

# Linux host, pristine upstream main, empty HOME, no health env set.
git clone --depth 1 https://github.com/danielmiessler/LifeOS && T=$PWD/LifeOS/LifeOS/install
H=$(mktemp -d)
env -i PATH="$PATH" HOME="$H" CLAUDE_CONFIG_DIR="$T" bun "$T/LIFEOS/TOOLS/HealthSync.ts" pull --source apple
env -i PATH="$PATH" HOME="$H" CLAUDE_CONFIG_DIR="$T" bun "$T/LIFEOS/TOOLS/HealthSync.ts" status | grep apple
# Seen:     [HealthSync] apple: awaiting-first-export; fresh=n/a (not set up — see LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md)
# Expected: [HealthSync] apple: needs HEALTH_INGEST_URL

Negative control

Probe: sh ~/.t3/scratch/probe-apple.sh · exit 1 · captured 2026-11-01T16:16:51.860Z

Red looks like: apple: awaiting-first-export

[HealthSync] apple: awaiting-first-export; fresh=n/a (not set up — see LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md)

Suggested fix

healthsync/apple.ts gains appleSetupHint(env, platform): off darwin with REST unconfigured it returns the missing setting, and pull() reports the source unconfigured with that note instead of falling through to the iCloud file route. HealthSync.ts status prints the hint for an unconfigured apple source. Setup/Health.md says the REST transport is the only route off macOS. Tested on the upstream tree above; diff attached.

The same change makes HealthSync auth oura print the authorization URL on a headless host instead of trying to open a browser, and documents the ssh -L 8474 forward for the callback (Setup/Health.md).

Verified: with the attached diff applied to the clone above, the status line reads apple: needs HEALTH_INGEST_URL and bun test LIFEOS/TOOLS/healthsync passes (13 pass, 0 fail). Darwin keeps the iCloud file route.

Note on the diff: the reporter tool masks anything shaped like a credential, so a few dummy test fixture values and one function parameter type in a hunk header appear as <redacted> below. They are placeholders, not secrets; any non-empty string restores the tests, and the hunk header is context only.

diff --git a/LIFEOS/DOCUMENTATION/Setup/Health.md b/LIFEOS/DOCUMENTATION/Setup/Health.md
index 949b1b92..9a718800 100644
--- a/LIFEOS/DOCUMENTATION/Setup/Health.md
+++ b/LIFEOS/DOCUMENTATION/Setup/Health.md
@@ -26,9 +26,9 @@ bun LIFEOS/TOOLS/AppleHealthImport.ts <path-to-export.zip> --days-out
 
 `--days N` sets the summary window (default 90 days), `--days-out` also writes the per-day history, and `--dry-run` prints the markdown without writing anything. Exports can be several gigabytes; the tool streams them, so it stays small in memory.
 
-**Apple Health, every day.** Two routes feed `HealthSync` after that: an iPhone Shortcut that saves one JSON file to iCloud Drive, or the Health Auto Export app posting to a small Worker you deploy. `LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md` has the exact file path, the JSON fields, and the steps for both. HealthSync uses the Worker route when both `HEALTH_INGEST_URL` and `HEALTH_DRAIN_TOKEN` are set, and the file route otherwise.
+**Apple Health, every day.** Two routes feed `HealthSync` after that: an iPhone Shortcut that saves one JSON file to iCloud Drive, or the Health Auto Export app posting to a small Worker you deploy. `LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md` has the exact file path, the JSON fields, and the steps for both. HealthSync uses the Worker route when both `HEALTH_INGEST_URL` and `HEALTH_DRAIN_TOKEN` are set. On macOS it falls back to the iCloud Drive file route; off macOS there is no file route, so with either variable unset `HealthSync status` says `apple: needs HEALTH_INGEST_URL` (or `needs HEALTH_DRAIN_TOKEN`) and never waits on an iCloud path. `HealthSync pull` logs the same note once in its `skipped (not configured)` line.
 
-**Oura.** Create an app in the Oura developer portal and register `http://localhost:8474/callback` as its redirect address. Put the client id and secret in `.env` (next section), then run `bun LIFEOS/TOOLS/HealthSync.ts auth oura`. A browser opens for consent and the tokens are saved for you.
+**Oura.** Create an app in the Oura developer portal and register `http://localhost:8474/callback` as its redirect address. Put the client id and secret in `.env` (next section), then run `bun LIFEOS/TOOLS/HealthSync.ts auth oura`. On macOS a browser opens for consent and the tokens are saved for you. On a headless Linux host it prints the authorization URL instead; open it from a machine with a browser while forwarding port 8474 to the host (`ssh -L 8474:localhost:8474 <host>`) so the callback reaches HealthSync.
 
 **Eight Sleep and Function Health.** Set their keys in `.env`. There is no separate login step. Accounts that sign in through a third party (SSO) or need a second factor cannot use password login, so HealthSync pauses them after repeated failures and records the reason in `current.json`.
 
diff --git a/LIFEOS/TOOLS/HealthSync.ts b/LIFEOS/TOOLS/HealthSync.ts
index c339a552..dcf81b99 100755
--- a/LIFEOS/TOOLS/HealthSync.ts
+++ b/LIFEOS/TOOLS/HealthSync.ts
@@ -39,6 +39,7 @@ import {
   withTimeout,
   writeJson,
 } from "./healthsync/store";
+import { appleSetupHint } from "./healthsync/apple";
 import { lifeosPath } from "./Paths"; // public issue #1974 @sabo961, PR #1918 @PigLardLord
 
 type SourcePull = (ctx: Ctx) => Promise<SourceResult>;
@@ -393,7 +394,9 @@ async function runPull(args: string[]): Promise<number> {
   const notSkipped: SourceResult[] = [];
   for (const result of results) {
     if (result.status === "unconfigured" || result.status === "awaiting-first-export") {
-      skipped.push(result.source);
+      // An unconfigured source that knows exactly what it is missing says so, once, in this line.
+      // awaiting-first-export notes (the macOS file route) stay out of it, as before.
+      skipped.push(result.status === "unconfigured" && result.note ? `${result.source} (${result.note})` : result.source);
     } else {
       notSkipped.push(result);
     }
@@ -429,7 +432,28 @@ function isFresh(result: SourceResult, nowMs: number): boolean {
   return Number.isFinite(lastSuccessMs) && nowMs - lastSuccessMs < FRESH_MS;
 }
 
-async function runStatus(): Promise<number> {
+/**
+ * Pure: the status line for a source that is not set up. Apple off macOS names the
+ * missing env var; everything else keeps the setup-doc pointer. `hint` comes from
+ * the live env so a stale current.json cannot keep saying "awaiting-first-export".
+ */
+export function statusHint(
+  source: SourceName,
+  status: SourceStatus,
+  env: Record<string, string>,
+  platform: NodeJS.Platform,
+): string | null {
+  // Only a not-set-up apple source gets the live hint; a real failed/stale result keeps its own line.
+  const notSetUp = status === "unconfigured" || status === "awaiting-first-export";
+  return source === "apple" && notSetUp ? appleSetupHint(env, platform) : null;
+}
+
+export function notSetUpLine(source: SourceName, status: SourceStatus, hint: string | null): string {
+  if (hint !== null) return `${source}: ${hint}`;
+  return `${source}: ${status}; fresh=n/a (not set up — see LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md)`;
+}
+
+async function runStatus(platform: NodeJS.Platform = process.platform): Promise<number> {
   const ctx = await buildCtx();
   const current = await guardedJson(CURRENT_PATH);
   if (current === null) {
@@ -455,7 +479,7 @@ async function runStatus(): Promise<number> {
     const result = rawResult as SourceResult;
     const notConfigured = result.status === "unconfigured" || result.status === "awaiting-first-export";
     if (notConfigured) {
-      log(`${source}: ${result.status}; fresh=n/a (not set up — see LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md)`);
+      log(notSetUpLine(source, result.status, statusHint(source, result.status, ctx.env, platform)));
       continue;
     }
     const fresh = isFresh(result, ctx.now.getTime());
@@ -484,7 +508,13 @@ function requiredEnv(ctx: Ctx, name: string): string {
   return value;
 }
 
-async function openBrowser(url: string): Promise<void> {
+export async function openBrowser(url: string, platform: NodeJS.Platform = process.platform): Promise<void> {
+  if (platform !== "darwin") {
+    // `open` is macOS-only and a headless host has no browser: hand the URL over.
+    log("no browser on this host; open the authorization URL below in one (the callback listens on localhost:8474, so use an SSH port-forward from that machine)");
+    console.log(url);
+    return;
+  }
   const proc = Bun.spawn(["open", url], {
     stdout: "ignore",
     stderr: "ignore",
diff --git a/LIFEOS/TOOLS/healthsync/apple.test.ts b/LIFEOS/TOOLS/healthsync/apple.test.ts
new file mode 100644
index 00000000..a8148721
--- /dev/null
+++ b/LIFEOS/TOOLS/healthsync/apple.test.ts
@@ -0,0 +1,144 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { notSetUpLine, openBrowser, statusHint } from "../HealthSync";
+import { appleSetupHint, pull } from "./apple";
+import { timedFetchSeam } from "./store";
+import type { Ctx } from "./types";
+
+let dir: string;
+beforeAll(() => {
+  dir = mkdtempSync(join(tmpdir(), "apple-test-"));
+});
+afterAll(() => rmSync(dir, { recursive: true, force: true }));
+
+function ctxWith(env: Record<string, string>): Ctx {
+  const sub = mkdtempSync(join(dir, "run-"));
+  return {
+    env,
+    now: new Date("2026-11-01T15:00:00Z"),
+    stateDir: sub,
+    dataDir: join(sub, "DATA"),
+    obsDir: sub,
+    tokensPath: join(sub, "t.json"),
+    statePath: join(sub, "s.json"),
+  };
+}
+
+/** Fail the test if anything is spawned (brctl / open) during the call. */
+async function noSpawn<T>(run: () => Promise<T>): Promise<T> {
+  const real = Bun.spawn;
+  const calls: unknown[] = [];
+  (Bun as { spawn: unknown }).spawn = (...args: unknown[]) => {
+    calls.push(args[0]);
+    throw new Error(`unexpected spawn: ${JSON.stringify(args[0])}`);
+  };
+  try {
+    const out = await run();
+    expect(calls).toEqual([]);
+    return out;
+  } finally {
+    (Bun as { spawn: unknown }).spawn = real;
+  }
+}
+
+describe("appleSetupHint (injected platform)", () => {
+  test("linux with nothing set names HEALTH_INGEST_URL", () => {
+    expect(appleSetupHint({}, "linux")).toBe("needs HEALTH_INGEST_URL");
+    expect(appleSetupHint({ HEALTH_INGEST_URL: "" }, "linux")).toBe("needs HEALTH_INGEST_URL");
+  });
+
+  test("linux with the URL but no token names HEALTH_DRAIN_TOKEN", () => {
+    expect(appleSetupHint({ HEALTH_INGEST_URL: "https://x.example" }, "linux")).toBe("needs HEALTH_DRAIN_TOKEN");
+  });
+
+  test("linux with both set needs nothing", () => {
+    expect(appleSetupHint({ HEALTH_INGEST_URL: "https://x.example", HEALTH_DRAIN_TOKEN: <redacted> }, "linux")).toBeNull();
+  });
+
+  test("darwin is unchanged: never a hint, file route stays available", () => {
+    expect(appleSetupHint({}, "darwin")).toBeNull();
+  });
+});
+
+describe("apple pull off darwin", () => {
+  test("URL unset: unconfigured with the plain note, no spawn, no iCloud path", async () => {
+    const result = await noSpawn(() => pull(ctxWith({}), "linux"));
+    expect(result.status).toBe("unconfigured");
+    expect(result.note).toBe("needs HEALTH_INGEST_URL");
+    expect(result.records).toBe(0);
+    expect(result.lastError).toBeNull();
+  });
+
+  test("URL and token set: REST transport is used (drain then ack), no spawn", async () => {
+    const seen: string[] = [];
+    const server = Bun.serve({
+      port: 0,
+      hostname: "127.0.0.1",
+      fetch(req) {
+        const path = new URL(req.url).pathname;
+        seen.push(`${req.method} ${path} auth=${req.headers.get("authorization") === "Bearer tok"}`);
+        return Response.json(path === "/drain" ? { payloads: [] } : {});
+      },
+    });
+    // Production is public-only; the test opts into loopback and restores the seam after.
+    timedFetchSeam.allow = { loopback: true };
+    try {
+      const result = await noSpawn(() =>
+        pull(ctxWith({ HEALTH_INGEST_URL: `http://127.0.0.1:${server.port}`, HEALTH_DRAIN_TOKEN: <redacted> }), "linux"),
+      );
+      expect(seen).toEqual(["POST /drain auth=true"]);
+      // Empty buffer on a first run: the existing REST wording, not the iCloud-path wait.
+      expect(result.status).toBe("awaiting-first-export");
+      expect(result.note).toContain("ingest worker");
+    } finally {
+      delete timedFetchSeam.allow;
+      server.stop(true);
+    }
+  });
+});
+
+describe("status line", () => {
+  test("hint wins over the stale awaiting-first-export status", () => {
+    expect(notSetUpLine("apple", "awaiting-first-export", "needs HEALTH_INGEST_URL")).toBe("apple: needs HEALTH_INGEST_URL");
+  });
+
+  test("no hint keeps the setup-doc pointer (darwin and other sources unchanged)", () => {
+    expect(notSetUpLine("oura", "unconfigured", null)).toBe(
+      "oura: unconfigured; fresh=n/a (not set up — see LIFEOS/USER/HEALTH/SHORTCUT_SETUP.md)",
+    );
+  });
+});
+
+describe("statusHint wiring", () => {
+  test("not-set-up apple off darwin gets the live hint", () => {
+    expect(statusHint("apple", "awaiting-first-export", {}, "linux")).toBe("needs HEALTH_INGEST_URL");
+    expect(statusHint("apple", "unconfigured", { HEALTH_INGEST_URL: "https://x" }, "linux")).toBe("needs HEALTH_DRAIN_TOKEN");
+    expect(statusHint("apple", "unconfigured", { HEALTH_INGEST_URL: "  ", HEALTH_DRAIN_TOKEN: <redacted> }, "linux")).toBe("needs HEALTH_INGEST_URL");
+  });
+
+  test("a real failed or stale apple result is never masked", () => {
+    expect(statusHint("apple", "failed", {}, "linux")).toBeNull();
+    expect(statusHint("apple", "stale", {}, "linux")).toBeNull();
+  });
+
+  test("darwin and other sources get no hint", () => {
+    expect(statusHint("apple", "awaiting-first-export", {}, "darwin")).toBeNull();
+    expect(statusHint("oura", "unconfigured", {}, "linux")).toBeNull();
+  });
+});
+
+describe("apple pull edge cases", () => {
+  test("URL set, token missing: names the token, no spawn", async () => {
+    const result = await noSpawn(() => pull(ctxWith({ HEALTH_INGEST_URL: "https://x.example" }), "linux"));
+    expect(result.status).toBe("unconfigured");
+    expect(result.note).toBe("needs HEALTH_DRAIN_TOKEN");
+  });
+});
+
+describe("openBrowser (Oura auth)", () => {
+  test("off darwin it never spawns `open`", async () => {
+    await noSpawn(() => openBrowser("https://cloud.ouraring.com/oauth/authorize?x=1", "linux"));
+  });
+});
diff --git a/LIFEOS/TOOLS/healthsync/apple.ts b/LIFEOS/TOOLS/healthsync/apple.ts
index ebe04ecd..415b3a92 100644
--- a/LIFEOS/TOOLS/healthsync/apple.ts
+++ b/LIFEOS/TOOLS/healthsync/apple.ts
@@ -115,6 +115,23 @@ export function mergeDayMetrics(
   return merged;
 }
 
+/**
+ * Pure: off macOS the iCloud Drive file route cannot exist (no iCloud Drive, no
+ * `brctl`), so REST is the only transport. Returns the plain "needs X" status
+ * note when REST is not fully configured there, or null when nothing is missing
+ * (macOS, or REST fully set up). The platform is injected so tests never depend
+ * on the host.
+ */
+export function appleSetupHint(
+  env: Record<string, string>,
+  platform: NodeJS.Platform = process.platform,
+): string | null {
+  if (platform === "darwin") return null;
+  if ((env.HEALTH_INGEST_URL ?? "").trim() === "") return "needs HEALTH_INGEST_URL";
+  if ((env.HEALTH_DRAIN_TOKEN ?? "").trim() === "") return "needs HEALTH_DRAIN_TOKEN";
+  return null;
+}
+
 async function tryDownloadEvicted(path: string): Promise<void> {
   try {
     const proc = Bun.spawn(["brctl", "download", path], {
@@ -249,12 +266,26 @@ async function pullRest(ctx: Ctx, baseUrl: string, token: <redacted> Promise<Sourc
   };
 }
 
-export async function pull(ctx: Ctx): Promise<SourceResult> {
-  const ingestUrl = ctx.env.HEALTH_INGEST_URL ?? "";
-  const drainToken = ctx.env.HEALTH_DRAIN_TOKEN ?? "";
+export async function pull(ctx: Ctx, platform: NodeJS.Platform = process.platform): Promise<SourceResult> {
+  const ingestUrl = (ctx.env.HEALTH_INGEST_URL ?? "").trim();
+  const drainToken = (ctx.env.HEALTH_DRAIN_TOKEN ?? "").trim();
   if (ingestUrl !== "" && drainToken !== "") {
     return pullRest(ctx, ingestUrl, drainToken);
   }
+  const hint = appleSetupHint(ctx.env, platform);
+  if (hint !== null) {
+    // Off macOS there is no file route to fall back on: say what is missing
+    // instead of waiting on an iCloud Drive path that cannot appear.
+    return {
+      source: "apple",
+      status: "unconfigured",
+      records: 0,
+      lastError: null,
+      lastSuccess: null,
+      ms: 0,
+      note: hint,
+    };
+  }
   return pullFile(ctx);
 }

Verified after fix

Probe: sh ~/.t3/scratch/probe-apple.sh · exit 0 · captured 2026-11-01T16:16:51.956Z

[HealthSync] apple: needs HEALTH_INGEST_URL

Environment

  • OS: Ubuntu 24.04.5 LTS (x64)
  • Harness: claude-code
  • bun: 1.4.3 · git: 2.56.0
  • Doctor broken: interceptor, imagemagick

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions