From 7a5a12ef10950082d755be7800129b121e0ddf24 Mon Sep 17 00:00:00 2001 From: rodriguez46p-ui Date: Sat, 27 Jun 2026 17:02:11 -0400 Subject: [PATCH 1/4] fix: harden archive and xurl portability --- .gitattributes | 3 ++ package.json | 1 + pnpm-lock.yaml | 8 +++ src/lib/archive-finder.ts | 9 +++- src/lib/archive-import.test.ts | 44 ++++++++++++---- src/lib/backup.ts | 33 ++++++++++-- src/lib/launchd.ts | 6 ++- src/lib/xurl.test.ts | 63 ++++++++++++++++++++++ src/lib/xurl.ts | 96 ++++++++++++++++++++++++++++++++-- src/test/setup.ts | 2 + 10 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1f4d0ee --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Birdclaw backup files are content-addressed by raw bytes in manifest.json. +*.jsonl text eol=lf +manifest.json text eol=lf diff --git a/package.json b/package.json index 83684de..9052448 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", + "fflate": "0.8.2", "jsdom": "^29.1.1", "oxfmt": "^0.55.0", "oxlint": "^1.70.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78e10fc..f5cf3e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.1 + fflate: + specifier: 0.8.2 + version: 0.8.2 jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -1433,6 +1436,9 @@ packages: fetchdts@0.1.7: resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3215,6 +3221,8 @@ snapshots: fetchdts@0.1.7: {} + fflate@0.8.2: {} + fsevents@2.3.2: optional: true diff --git a/src/lib/archive-finder.ts b/src/lib/archive-finder.ts index 1579b3e..9c936a7 100644 --- a/src/lib/archive-finder.ts +++ b/src/lib/archive-finder.ts @@ -44,6 +44,10 @@ function formatRelativeDate(date: Date): string { return `${Math.floor(days / 365)} years ago`; } +function normalizeArchivePath(filePath: string) { + return filePath.replaceAll(path.sep, path.posix.sep); +} + function getCandidateEffect(filePath: string) { return Effect.gen(function* () { const stats = yield* tryPromise(() => fs.stat(filePath)); @@ -51,9 +55,10 @@ function getCandidateEffect(filePath: string) { return null; } + const normalizedPath = normalizeArchivePath(filePath); return { - path: filePath, - name: path.basename(filePath), + path: normalizedPath, + name: path.posix.basename(normalizedPath), size: stats.size, sizeFormatted: formatFileSize(stats.size), modifiedTime: stats.mtime.toISOString(), diff --git a/src/lib/archive-import.test.ts b/src/lib/archive-import.test.ts index d424a13..f0f19a8 100644 --- a/src/lib/archive-import.test.ts +++ b/src/lib/archive-import.test.ts @@ -1,12 +1,13 @@ // @vitest-environment node -import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, + readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs"; +import { zipSync } from "fflate"; import path from "node:path"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; @@ -30,6 +31,31 @@ import { const testHome = useTestHome({ prefix: "birdclaw-home-" }); +function writeZipFromDirectory( + root: string, + archivePath: string, + entryRoot: string, +) { + const entries: Record = {}; + + function walk(directory: string, relativeDirectory: string) { + for (const name of readdirSync(directory)) { + const absolutePath = path.join(directory, name); + const relativePath = path.posix.join(relativeDirectory, name); + const stat = statSync(absolutePath); + + if (stat.isDirectory()) { + walk(absolutePath, relativePath); + } else if (stat.isFile()) { + entries[relativePath] = new Uint8Array(readFileSync(absolutePath)); + } + } + } + + walk(path.join(root, entryRoot), entryRoot); + writeFileSync(archivePath, zipSync(entries, { level: 0 })); +} + function makeArchive({ following = [], likeText = "liked archive item", @@ -172,7 +198,7 @@ function makeArchive({ } const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } @@ -185,7 +211,7 @@ function makeArchiveWithoutAccount() { 'window.YTD.tweets.part0 = [{ "tweet": { "id_str": "1", "created_at": "Tue Jun 03 19:32:20 +0000 2025", "full_text": "hello" } }]', ); const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } @@ -229,7 +255,7 @@ function makeRootDataArchive() { "root-media", ); const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "data"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "data"); return archivePath; } @@ -320,7 +346,7 @@ function makeWeirdArchive({ followers = [] }: { followers?: string[] } = {}) { } const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } @@ -371,7 +397,7 @@ function makeFollowArchive({ } const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } @@ -419,7 +445,7 @@ function makeFollowDmArchive(userId: string) { ); const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } @@ -454,7 +480,7 @@ function makeMediaArchive() { ); const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } @@ -538,7 +564,7 @@ function makeMediaVariantsArchive() { ); const archivePath = path.join(root, "archive.zip"); - execFileSync("zip", ["-qr", archivePath, "sample"], { cwd: root }); + writeZipFromDirectory(root, archivePath, "sample"); return archivePath; } diff --git a/src/lib/backup.ts b/src/lib/backup.ts index 3e766b3..983020c 100644 --- a/src/lib/backup.ts +++ b/src/lib/backup.ts @@ -30,6 +30,7 @@ const BACKUP_SCHEMA_VERSION = 2; const MIN_SUPPORTED_BACKUP_SCHEMA_VERSION = 1; const MANIFEST_PATH = "manifest.json"; const DATA_DIR = "data"; +const GITATTRIBUTES_PATH = ".gitattributes"; const AUTO_SYNC_CACHE_KEY = "backup:auto-sync"; const DEFAULT_STALE_AFTER_SECONDS = 15 * 60; let autoUpdateInFlight: Promise | null = null; @@ -438,6 +439,28 @@ function readPreviousManifestEffect( ); } +function ensureBackupGitattributesEffect(repoPath: string) { + return Effect.gen(function* () { + const attributesPath = yield* trySync(() => + resolveBackupFilePath(repoPath, GITATTRIBUTES_PATH), + ); + yield* assertNoSymlinkAncestorEffect(repoPath, attributesPath); + const content = [ + "# Birdclaw backup files are content-addressed by raw bytes in manifest.json.", + "*.jsonl text eol=lf", + `${MANIFEST_PATH} text eol=lf`, + "", + ].join("\n"); + const current = yield* tryPromise(() => + fs.readFile(attributesPath, "utf8"), + ).pipe(Effect.option); + if (current._tag === "Some" && current.value === content) { + return; + } + yield* tryPromise(() => fs.writeFile(attributesPath, content, "utf8")); + }); +} + function maybeCommitAndPushEffect({ repoPath, message, @@ -469,6 +492,7 @@ function maybeCommitAndPushEffect({ "-C", repoPath, "add", + GITATTRIBUTES_PATH, "README.md", MANIFEST_PATH, DATA_DIR, @@ -541,8 +565,10 @@ function maybeCommitAndPushEffect({ } function isGitRepoEffect(repoPath: string) { - return gitEffect(["-C", repoPath, "rev-parse", "--is-inside-work-tree"]).pipe( - Effect.as(true), + return gitEffect(["-C", repoPath, "rev-parse", "--show-toplevel"]).pipe( + Effect.map( + ({ stdout }) => path.resolve(stdout.trim()) === path.resolve(repoPath), + ), Effect.catchAll(() => Effect.succeed(false)), ); } @@ -604,7 +630,7 @@ function ensureBackupGitRepoEffect({ repoPath, "fetch", "origin", - "main", + "main:refs/remotes/origin/main", ]).pipe( Effect.flatMap(() => gitEffect(["-C", repoPath, "checkout", "-B", "main", "origin/main"]), @@ -667,6 +693,7 @@ export function exportBackupEffect({ new Error("Backup repository path must be a real directory"), ); } + yield* ensureBackupGitattributesEffect(resolvedRepoPath); yield* ensureBackupReadmeEffect(resolvedRepoPath); const shards = yield* trySync(() => buildShards(database)); diff --git a/src/lib/launchd.ts b/src/lib/launchd.ts index e7b5d3e..5c89cd0 100644 --- a/src/lib/launchd.ts +++ b/src/lib/launchd.ts @@ -87,8 +87,12 @@ function xmlEscape(value: string) { .replaceAll(">", ">"); } +function normalizeLaunchdValue(value: string) { + return value.replaceAll(path.sep, path.posix.sep); +} + function stringEntry(value: string) { - return `${xmlEscape(value)}`; + return `${xmlEscape(normalizeLaunchdValue(value))}`; } export function buildLaunchAgent({ diff --git a/src/lib/xurl.test.ts b/src/lib/xurl.test.ts index 2883456..3c60178 100644 --- a/src/lib/xurl.test.ts +++ b/src/lib/xurl.test.ts @@ -50,6 +50,10 @@ describe("xurl transport wrapper", () => { delete process.env.BIRDCLAW_XURL_RETRY_BASE_MS; delete process.env.BIRDCLAW_XURL_OAUTH2_APP; delete process.env.BIRDCLAW_XURL_OAUTH2_USERNAME; + delete process.env.BIRDCLAW_X_BEARER_TOKEN; + delete process.env.BIRDCLAW_X_USER_ID; + delete process.env.BIRDCLAW_DISABLE_BEARER_TRANSPORT; + vi.unstubAllGlobals(); }); it("falls back to local mode when xurl is missing", async () => { @@ -166,6 +170,65 @@ describe("xurl transport wrapper", () => { expect(result.statusText).toContain("unknown error"); }); + it("ignores bearer tokens when bearer transport is disabled", async () => { + process.env.BIRDCLAW_X_BEARER_TOKEN = "token"; + process.env.BIRDCLAW_DISABLE_BEARER_TRANSPORT = "1"; + execFileAsyncMock.mockRejectedValue(new Error("missing")); + const { getTransportStatus } = await import("./xurl"); + + await expect(getTransportStatus()).resolves.toMatchObject({ + availableTransport: "local", + installed: false, + }); + }); + + it("uses a configured bearer token without invoking xurl", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ data: [{ id: "42", username: "sam" }] }), + }); + vi.stubGlobal("fetch", fetchMock); + process.env.BIRDCLAW_X_BEARER_TOKEN = "token"; + const { getTransportStatus, lookupUsersByIds } = await import("./xurl"); + + await expect(lookupUsersByIds(["42"])).resolves.toEqual([ + { id: "42", username: "sam" }, + ]); + await expect(getTransportStatus()).resolves.toMatchObject({ + availableTransport: "xurl", + rawStatus: "bearer-token", + }); + expect(fetchMock).toHaveBeenCalledWith( + `https://api.x.com/2/users?ids=42&user.fields=${RICH_USER_FIELDS}`, + expect.objectContaining({ + headers: { Authorization: "Bearer token" }, + }), + ); + expect(execFileAsyncMock).not.toHaveBeenCalled(); + }); + + it("resolves the default bearer-token user from BIRDCLAW_X_USER_ID", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ data: { id: "1", username: "steipete" } }), + }), + ); + process.env.BIRDCLAW_X_BEARER_TOKEN = "token"; + process.env.BIRDCLAW_X_USER_ID = "1"; + const { lookupAuthenticatedUser } = await import("./xurl"); + + await expect(lookupAuthenticatedUser()).resolves.toEqual({ + id: "1", + username: "steipete", + }); + }); + it("looks up users and the authenticated account via raw json endpoints", async () => { execFileAsyncMock .mockResolvedValueOnce({ diff --git a/src/lib/xurl.ts b/src/lib/xurl.ts index a6e5d54..85217da 100644 --- a/src/lib/xurl.ts +++ b/src/lib/xurl.ts @@ -79,6 +79,31 @@ function e2eFakeLiveWritesEnabled() { ); } +function getBearerToken() { + if (process.env.BIRDCLAW_DISABLE_BEARER_TRANSPORT === "1") { + return undefined; + } + return ( + process.env.BIRDCLAW_X_BEARER_TOKEN?.trim() || + process.env.X_BEARER_TOKEN?.trim() || + process.env.TWITTER_BEARER_TOKEN?.trim() || + undefined + ); +} + +function getDefaultUserId() { + return process.env.BIRDCLAW_X_USER_ID?.trim() || undefined; +} + +function getEndpointArg(args: string[]) { + return args.find((arg) => arg.startsWith("/2/")); +} + +function isDirectBearerSupported(args: string[]) { + const endpoint = getEndpointArg(args); + return Boolean(endpoint && getBearerToken()); +} + function getJsonRetryBaseDelayMs() { const value = Number(process.env.BIRDCLAW_XURL_RETRY_BASE_MS ?? "2000"); return Number.isFinite(value) && value >= 0 ? value : 2000; @@ -195,6 +220,15 @@ function isUnauthenticatedXurlStatus(status: string) { function readTransportStatusEffect(): Effect.Effect { return Effect.gen(function* () { + if (getBearerToken()) { + return { + installed: true, + availableTransport: "xurl" as const, + statusText: "X API bearer token available", + rawStatus: "bearer-token", + }; + } + const installed = yield* hasXurlEffect(); if (!installed) { return { @@ -361,12 +395,63 @@ function parseJsonPayloadEffect( }); } +function runBearerJsonCommandEffect( + args: string[], + attempt: number, +): Effect.Effect, Error> { + return Effect.gen(function* () { + const endpoint = getEndpointArg(args); + const token = getBearerToken(); + if (!endpoint || !token) { + return yield* Effect.fail( + new Error("X bearer token transport is not configured"), + ); + } + + const response = yield* Effect.tryPromise({ + try: () => + fetch(`https://api.x.com${endpoint}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + catch: normalizeError, + }); + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: normalizeError, + }); + let payload: Record; + try { + payload = text ? (JSON.parse(text) as Record) : {}; + } catch { + payload = { error: text }; + } + if (!response.ok) { + const error = new Error( + `X API request failed with HTTP ${response.status}`, + ) as Error & { stdout?: string }; + error.stdout = JSON.stringify({ ...payload, status: response.status }); + const retryDelayMs = getRetryDelayMs(error, attempt); + if (retryDelayMs !== null && attempt < JSON_RETRY_LIMIT - 1) { + return yield* Effect.sleep(retryDelayMs).pipe( + Effect.flatMap(() => runBearerJsonCommandEffect(args, attempt + 1)), + ); + } + return yield* Effect.fail(error); + } + return payload; + }); +} + function runJsonCommandEffect( args: string[], options: JsonCommandOptions = {}, attempt = 0, ): Effect.Effect, Error> { return Effect.gen(function* () { + if (isDirectBearerSupported(args)) { + return yield* runBearerJsonCommandEffect(args, attempt); + } + const deadlineMs = options.deadlineMs ?? (typeof options.timeoutMs === "number" && @@ -760,9 +845,14 @@ function authenticatedUserFromPayload(payload: Record) { } export function lookupAuthenticatedUserFreshEffect() { - return runJsonCommandEffect(["whoami"]).pipe( - Effect.map(authenticatedUserFromPayload), - ); + const defaultUserId = getDefaultUserId(); + return ( + defaultUserId + ? runJsonCommandEffect([ + `/2/users/${defaultUserId}?user.fields=${RICH_USER_FIELDS}`, + ]) + : runJsonCommandEffect(["whoami"]) + ).pipe(Effect.map(authenticatedUserFromPayload)); } export function lookupAuthenticatedOAuth2UserEffect(username?: string) { diff --git a/src/test/setup.ts b/src/test/setup.ts index 0fd00e6..afaf6df 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -43,3 +43,5 @@ if (typeof window !== "undefined") { } await import("@testing-library/jest-dom/vitest"); + +process.env.BIRDCLAW_DISABLE_BEARER_TRANSPORT ??= "1"; From f72d6e16a979ae407ed3ce73daa0c7c95d03a0c3 Mon Sep 17 00:00:00 2001 From: rodriguez46p-ui Date: Sat, 27 Jun 2026 18:06:26 -0400 Subject: [PATCH 2/4] fix: preserve oauth2 xurl routing with bearer tokens --- src/lib/xurl.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/xurl.ts | 54 ++++++++++++++++++++++++++++++++----- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/src/lib/xurl.test.ts b/src/lib/xurl.test.ts index 3c60178..30f1761 100644 --- a/src/lib/xurl.test.ts +++ b/src/lib/xurl.test.ts @@ -209,6 +209,70 @@ describe("xurl transport wrapper", () => { expect(execFileAsyncMock).not.toHaveBeenCalled(); }); + it("keeps OAuth2-selected requests on xurl when bearer tokens are present", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + process.env.BIRDCLAW_X_BEARER_TOKEN = "token"; + execFileAsyncMock + .mockResolvedValueOnce({ + stdout: AUTH_STATUS_STEIPETE, + stderr: "", + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: [] }), + stderr: "", + }); + const { listHomeTimelineViaXurl } = await import("./xurl"); + + await listHomeTimelineViaXurl({ + maxResults: 100, + userId: "25401953", + username: "steipete", + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, "xurl", [ + "--auth", + "oauth2", + "--username", + "steipete", + `/2/users/25401953/timelines/reverse_chronological?max_results=100&expansions=${AUTHOR_MEDIA_EXPANSIONS}&tweet.fields=created_at%2Cconversation_id%2Centities%2Cpublic_metrics%2Creferenced_tweets&media.fields=${MEDIA_FIELDS}&user.fields=${RICH_USER_FIELDS}`, + ]); + }); + + it("passes abort signals and attempt telemetry through bearer fetches", async () => { + const controller = new AbortController(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ data: [{ id: "tweet_1" }] }), + }); + vi.stubGlobal("fetch", fetchMock); + process.env.BIRDCLAW_X_BEARER_TOKEN = "token"; + const attempts: Array<{ attempt: number; status: string }> = []; + const { searchRecentByConversationIdEffect } = await import("./xurl"); + + await expect( + Effect.runPromise( + searchRecentByConversationIdEffect("conversation_1", { + maxResults: 10, + signal: controller.signal, + onAttempt: (attempt) => + attempts.push({ + attempt: attempt.attempt, + status: attempt.status, + }), + }), + ), + ).resolves.toEqual({ data: [{ id: "tweet_1" }] }); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.x.com/2/tweets/search/recent?query=conversation_id%3Aconversation_1&max_results=10&expansions=${AUTHOR_MEDIA_EXPANSIONS}&tweet.fields=${THREAD_TWEET_FIELDS}&media.fields=${MEDIA_FIELDS}&user.fields=${RICH_USER_FIELDS}`, + expect.objectContaining({ signal: controller.signal }), + ); + expect(attempts).toEqual([{ attempt: 0, status: "ok" }]); + }); + it("resolves the default bearer-token user from BIRDCLAW_X_USER_ID", async () => { vi.stubGlobal( "fetch", diff --git a/src/lib/xurl.ts b/src/lib/xurl.ts index 85217da..1f84f80 100644 --- a/src/lib/xurl.ts +++ b/src/lib/xurl.ts @@ -99,9 +99,15 @@ function getEndpointArg(args: string[]) { return args.find((arg) => arg.startsWith("/2/")); } +function usesOAuth2Auth(args: string[]) { + return args.some( + (arg, index) => arg === "--auth" && args[index + 1] === "oauth2", + ); +} + function isDirectBearerSupported(args: string[]) { const endpoint = getEndpointArg(args); - return Boolean(endpoint && getBearerToken()); + return Boolean(endpoint && getBearerToken() && !usesOAuth2Auth(args)); } function getJsonRetryBaseDelayMs() { @@ -397,7 +403,9 @@ function parseJsonPayloadEffect( function runBearerJsonCommandEffect( args: string[], + options: JsonCommandOptions, attempt: number, + deadlineMs?: number, ): Effect.Effect, Error> { return Effect.gen(function* () { const endpoint = getEndpointArg(args); @@ -408,10 +416,20 @@ function runBearerJsonCommandEffect( ); } + const timeoutSignal = + deadlineMs !== undefined + ? AbortSignal.timeout(getRemainingTimeoutMs(deadlineMs) ?? 0) + : undefined; + const signal = + options.signal && timeoutSignal + ? AbortSignal.any([options.signal, timeoutSignal]) + : (options.signal ?? timeoutSignal); + const response = yield* Effect.tryPromise({ try: () => fetch(`https://api.x.com${endpoint}`, { headers: { Authorization: `Bearer ${token}` }, + ...(signal ? { signal } : {}), }), catch: normalizeError, }); @@ -431,13 +449,31 @@ function runBearerJsonCommandEffect( ) as Error & { stdout?: string }; error.stdout = JSON.stringify({ ...payload, status: response.status }); const retryDelayMs = getRetryDelayMs(error, attempt); + emitJsonCommandAttempt(options, { + args, + attempt, + status: retryDelayMs === null ? "error" : "rate_limited", + error, + }); if (retryDelayMs !== null && attempt < JSON_RETRY_LIMIT - 1) { + if (options.signal?.aborted) { + return yield* Effect.fail(error); + } + const remainingMs = deadlineMs + ? Math.max(0, deadlineMs - Date.now()) + : undefined; + if (remainingMs !== undefined && retryDelayMs >= remainingMs) { + return yield* Effect.fail(error); + } return yield* Effect.sleep(retryDelayMs).pipe( - Effect.flatMap(() => runBearerJsonCommandEffect(args, attempt + 1)), + Effect.flatMap(() => + runBearerJsonCommandEffect(args, options, attempt + 1, deadlineMs), + ), ); } return yield* Effect.fail(error); } + emitJsonCommandAttempt(options, { args, attempt, status: "ok" }); return payload; }); } @@ -448,10 +484,6 @@ function runJsonCommandEffect( attempt = 0, ): Effect.Effect, Error> { return Effect.gen(function* () { - if (isDirectBearerSupported(args)) { - return yield* runBearerJsonCommandEffect(args, attempt); - } - const deadlineMs = options.deadlineMs ?? (typeof options.timeoutMs === "number" && @@ -459,6 +491,16 @@ function runJsonCommandEffect( options.timeoutMs > 0 ? Date.now() + options.timeoutMs : undefined); + + if (isDirectBearerSupported(args)) { + return yield* runBearerJsonCommandEffect( + args, + options, + attempt, + deadlineMs, + ); + } + const timeoutMs = deadlineMs ? Math.max(0, deadlineMs - Date.now()) : undefined; From 29fbfcf64206caa28c2e0ee972758effb67dce32 Mon Sep 17 00:00:00 2001 From: rodriguez46p-ui Date: Sat, 27 Jun 2026 19:09:36 -0400 Subject: [PATCH 3/4] fix: guard nested backup repos and bearer status --- src/lib/backup.test.ts | 13 +++++++++++++ src/lib/backup.ts | 36 +++++++++++++++++++++++++++++++----- src/lib/profile-hydration.ts | 2 +- src/lib/types.ts | 2 +- src/lib/xurl.test.ts | 6 ++++-- src/lib/xurl.ts | 6 +++--- 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/lib/backup.test.ts b/src/lib/backup.test.ts index 1e2b629..96fde8c 100644 --- a/src/lib/backup.test.ts +++ b/src/lib/backup.test.ts @@ -750,6 +750,19 @@ describe("text backup", () => { ).toBe("1"); }, 20000); + it("fails closed instead of initializing a nested repo inside a parent worktree", async () => { + const parentPath = makeTempDir("birdclaw-parent-worktree-"); + execFileSync("git", ["-C", parentPath, "init"]); + const repoPath = path.join(parentPath, "backup"); + switchHome("birdclaw-nested-backup-"); + seedBackupFixture(); + + await expect(syncBackup({ repoPath })).rejects.toThrow( + /inside an existing Git worktree/i, + ); + expect(existsSync(path.join(repoPath, ".git"))).toBe(false); + }, 20000); + it("does not inherit commit signing for generated backup commits", async () => { switchHome("birdclaw-sync-signing-src-"); seedBackupFixture(); diff --git a/src/lib/backup.ts b/src/lib/backup.ts index 983020c..f322b6c 100644 --- a/src/lib/backup.ts +++ b/src/lib/backup.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { Data, Effect } from "effect"; import type { Database } from "./sqlite"; @@ -564,15 +565,37 @@ function maybeCommitAndPushEffect({ }); } -function isGitRepoEffect(repoPath: string) { +function getGitTopLevelEffect(repoPath: string) { return gitEffect(["-C", repoPath, "rev-parse", "--show-toplevel"]).pipe( - Effect.map( - ({ stdout }) => path.resolve(stdout.trim()) === path.resolve(repoPath), - ), - Effect.catchAll(() => Effect.succeed(false)), + Effect.map(({ stdout }) => path.resolve(stdout.trim())), + Effect.catchAll(() => Effect.succeed(undefined)), + ); +} + +function isGitRepoEffect(repoPath: string) { + return getGitTopLevelEffect(repoPath).pipe( + Effect.map((topLevel) => topLevel === path.resolve(repoPath)), ); } +function failOnNestedGitWorktreeEffect(repoPath: string) { + return Effect.gen(function* () { + const topLevel = yield* getGitTopLevelEffect(repoPath); + const homeTopLevel = path.resolve(os.homedir()); + if ( + topLevel && + topLevel !== path.resolve(repoPath) && + topLevel !== homeTopLevel + ) { + return yield* Effect.fail( + new Error( + `Backup repo path is inside an existing Git worktree (${topLevel}); choose the worktree root, an existing backup repo, or a path outside that worktree before Birdclaw initializes backup Git state.`, + ), + ); + } + }); +} + function hasGitCommitsEffect(repoPath: string) { return gitEffect(["-C", repoPath, "rev-parse", "--verify", "HEAD"]).pipe( Effect.as(true), @@ -590,9 +613,12 @@ function ensureBackupGitRepoEffect({ return Effect.gen(function* () { if (!(yield* isGitRepoEffect(repoPath))) { if (remote && !existsSync(repoPath)) { + yield* tryPromise(() => fs.mkdir(repoPath, { recursive: true })); + yield* failOnNestedGitWorktreeEffect(repoPath); yield* gitEffect(["clone", remote, repoPath]); } else { yield* tryPromise(() => fs.mkdir(repoPath, { recursive: true })); + yield* failOnNestedGitWorktreeEffect(repoPath); yield* gitEffect(["-C", repoPath, "init"]); } } diff --git a/src/lib/profile-hydration.ts b/src/lib/profile-hydration.ts index f3ffd51..28047f5 100644 --- a/src/lib/profile-hydration.ts +++ b/src/lib/profile-hydration.ts @@ -45,7 +45,7 @@ export function hydrateProfilesFromXEffect(): Effect.Effect< > { return Effect.gen(function* () { const transport = yield* tryPromise(() => getTransportStatus()); - if (transport.availableTransport !== "xurl") { + if (transport.availableTransport === "local") { return { ok: true, hydratedProfiles: 0, diff --git a/src/lib/types.ts b/src/lib/types.ts index 90ecbbe..9d9de7a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -375,7 +375,7 @@ export interface DmQuery { export interface TransportStatus { installed: boolean; - availableTransport: "xurl" | "local"; + availableTransport: "xurl" | "bearer" | "local"; statusText: string; rawStatus?: string; } diff --git a/src/lib/xurl.test.ts b/src/lib/xurl.test.ts index 30f1761..2eb23a1 100644 --- a/src/lib/xurl.test.ts +++ b/src/lib/xurl.test.ts @@ -182,7 +182,7 @@ describe("xurl transport wrapper", () => { }); }); - it("uses a configured bearer token without invoking xurl", async () => { + it("reports bearer transport separately without invoking xurl", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, @@ -197,7 +197,9 @@ describe("xurl transport wrapper", () => { { id: "42", username: "sam" }, ]); await expect(getTransportStatus()).resolves.toMatchObject({ - availableTransport: "xurl", + availableTransport: "bearer", + installed: false, + statusText: "X API bearer token available; xurl status not probed.", rawStatus: "bearer-token", }); expect(fetchMock).toHaveBeenCalledWith( diff --git a/src/lib/xurl.ts b/src/lib/xurl.ts index 1f84f80..2473202 100644 --- a/src/lib/xurl.ts +++ b/src/lib/xurl.ts @@ -228,9 +228,9 @@ function readTransportStatusEffect(): Effect.Effect { return Effect.gen(function* () { if (getBearerToken()) { return { - installed: true, - availableTransport: "xurl" as const, - statusText: "X API bearer token available", + installed: false, + availableTransport: "bearer" as const, + statusText: "X API bearer token available; xurl status not probed.", rawStatus: "bearer-token", }; } From a2d136c27457ecaee3f6d84e14f3f55ef7b0c393 Mon Sep 17 00:00:00 2001 From: rodriguez46p-ui Date: Sun, 28 Jun 2026 00:03:18 -0400 Subject: [PATCH 4/4] fix: accept bearer status in API contract --- src/lib/api-contracts.test.ts | 16 ++++++++++++++++ src/lib/api-contracts.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/api-contracts.test.ts b/src/lib/api-contracts.test.ts index 8774a6a..1663a84 100644 --- a/src/lib/api-contracts.test.ts +++ b/src/lib/api-contracts.test.ts @@ -9,6 +9,7 @@ import { liveDataSourcesResponseSchema, networkMapResponseSchema, profileHydrationResponseSchema, + queryEnvelopeSchema, queryResponseSchema, tweetMediaSchema, tweetConversationResponseSchema, @@ -92,6 +93,21 @@ describe("API contracts", () => { }); it("validates operational status wire responses", () => { + expect( + queryEnvelopeSchema.parse({ + accounts: [], + archives: [], + transport: { + installed: true, + availableTransport: "bearer", + statusText: "Bearer token configured; xurl status was not probed.", + rawStatus: "bearer-token", + }, + stats: { home: 0, mentions: 0, dms: 0, needsReply: 0, inbox: 0 }, + }), + ).toMatchObject({ + transport: { availableTransport: "bearer", rawStatus: "bearer-token" }, + }); expect( liveDataSourcesResponseSchema.parse({ generatedAt: "2026-06-18T00:00:00.000Z", diff --git a/src/lib/api-contracts.ts b/src/lib/api-contracts.ts index 1657238..9a9a77c 100644 --- a/src/lib/api-contracts.ts +++ b/src/lib/api-contracts.ts @@ -251,7 +251,7 @@ const archiveCandidateSchema: z.ZodType = z.object({ const transportStatusSchema: z.ZodType = z.object({ installed: z.boolean().default(false), - availableTransport: z.enum(["xurl", "local"]).default("local"), + availableTransport: z.enum(["xurl", "bearer", "local"]).default("local"), statusText: z.string(), rawStatus: z.string().optional(), });