fix: preserve shared auth and build receipts across runtimes (#163243)

Preserve read-through OAuth inheritance and declaration receipt reuse when filesystem enumeration order differs. Prioritize the discovered shared auth owner and canonicalize compiler membership, keeping the existing primary write and target set.

Make cleanup fixtures explicitly own separate connections, pending response bodies, child admission, and pre-removal holds. Keep all concurrency, resource-drain, and native database-exclusion assertions.

Validation: 136 focused tests pass on Node 24.21.0 and checksum-verified fork Bun b368; changed-file checks, Madge, and P2 Codex review pass. Production delta: +3 lines.
This commit is contained in:
Peter Steinberger 2026-10-01 23:42:15 -05:00 • committed by GitHub
parent 7683225850
commit 7968463a06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 114 additions and 35 deletions

View file

@ -84,6 +84,8 @@ and first-run noninteractive setup retain their existing behavior.
Agent auth inheritance is read-through. When an agent has no local profile, it resolves profiles from the shared auth store at runtime without copying secret material into its own credential store (`agents/<agentId>/agent/openclaw-agent.sqlite`). The shared store lives in `state/openclaw.sqlite` after `openclaw doctor --fix` performs the one-time relocation. Until then, doctor reports the legacy `agents/main/agent/openclaw-agent.sqlite` owner and leaves that agent undeletable.
OAuth sibling synchronization writes the selected primary profile first, then the discovered shared owner before other siblings. This preserves read-through inheritance regardless of filesystem directory order. A shared owner outside the selected agents tree is not added to the synchronization targets.
Auth usage and cooldown updates wait for write admission on their actual agent
database owner, including the legacy shared store. Relocated shared-state auth
uses its own coordinator. Queued updates retain their selected state root and

View file

@ -122,7 +122,7 @@ The iOS, macOS, and both shared OpenClawKit Periphery scans use Xcode 27 on GitH
- **PR check families** select static checks and guards from their own changed-path owners, independently of whether Node test targeting finds a precise plan. Lint and formatting reuse the local changed-check owner; lint configuration changes retain full lint. Semantic lint runs changed files and their transitive import consumers, including type-only imports and workspace/path aliases, through the existing shard owner. Each row receives only its assigned file list; workers do not rediscover the graph. Affected root tests retain their central compiler-config admission. Deleted, ambient, unsupported, and oversized selections retain full lint. Hourly main and Full Release Validation continue running full repository lint. Hosted profiles keep selected files in their existing stripes, while scripts, root tests, formatting, and localization checks retain their central owner. Preflight remains dependency-free and owns admission, static-check selection, and the original row/resource templates. For admitted narrow PRs, the hosted `check-plan` job installs through the existing Node setup owner, then materializes selected-file lint and compiler facts. It validates compiler ownership and selects every graph that consumes a changed file, including erased type imports into UI, plugins, scripts, and tests. Its consumers require a successful plan; failure cannot silently select a full fallback, and `openclaw/ci-gate` requires the planner itself. Existing production, test, and stripe jobs retain their runner placement and compiler concurrency; only rows with selected graphs run. GitHub and hybrid keep changed core-test consumers in their canonical stripes; the central row owns the selected extensions, scripts, and root-test graphs. Markdown and UI styles do not widen a mixed TypeScript change. Deleted, ambient, configuration, and unclassified inputs retain all compiler graphs. Shared fixtures and scanner policy inputs retain conservative check families. Conflict-marker and wall-clock deprecation guards run after Node setup in the required planner job. Other guards retain their generated native inputs. SDK subpath exports, extension import boundaries, declaration compile/canary proof, protocol generation, schema drift, and Knip remain blocking. Plugin and channel runtime contract tests use the exact changed-owner Node plan, including their watched SDK documentation and packaged skills. Narrow code changes run coercion checks once in the guard job. Main, ordinary manual CI, and historical targets skip this additional planner and retain their full check families. Static full fallback never widens runtime families. Bundled config metadata follows the shared schema owner map. The bundled/protocol row always verifies protocol generation; its full bundled runtime suite runs only on hourly main and ordinary manual/release validation. Specialized Bun launcher proof opts in for its direct launcher and module-generation owners. Startup, plugin, and channel tests retain their canonical configs within the changed-owner plan.
- **Browser integration on PRs** uses the Control UI file selection above while retaining complete ordinary real-Gateway and browser-extension families when their existing owners change. Unrelated families remain omitted unless protected or affected tests select a precise subset. Named release-only real-Gateway compositions retain their existing opt-in policy. Existing row caps, worker limits, serial/parallel ownership, and runner routes stay unchanged; small Control UI selections omit empty rows. Empty real-Gateway phase groups are removed; a desktop-only carrier keeps its required desktop proof without invoking a test phase with no files. Hourly main and Full Release Validation retain the full Control UI E2E set; historical manual targets without the selector retain their existing full target-owned path.
- **Extension package boundary selection** uses the PR's own merge-base diff, independently of restored receipt age. The job reuses the extension-lint planner’s bounded base-fetch action. A depth-one PR merge is compared with its verified raw first-parent tree, without requiring a complete ancestry walk. It checks directly touched plugin packages. Core/SDK declaration and shared compiler/dependency changes add a fixed smoke set: Telegram, Codex and Slack, chosen to cover 119 distinct directly consumed public SDK entries. Changes to a public SDK entry file also select packages that directly import that entry, including type-only imports. Transitive declaration consumers and main-only drift are intentionally left to the complete hourly main and manual/release checks. Selected packages retain full compiler diagnostics and normal receipt validation. Declaration preparation follows their packaged entry points with the native compiler, emitting only consumed SDK roots and required plugin declaration producers, including transitive inputs and ambient declarations. Hourly and manual/release checks still prepare the complete declaration set. The negative boundary canary always runs. The job summary lists selected and skipped packages with reasons. Unset repository variable `OPENCLAW_CI_EXTENSION_BOUNDARY_FULL` enables this aggressive policy; `true`, `1` or `full` restores complete PR checks. An unavailable comparison or import inventory also retains full scope.
- **Shared SDK declarations** keep the existing boundary check as a standalone producer on supported hybrid PRs targeting the canonical repository that already select both the boundary row and the check planner. The job count and check name stay unchanged. That producer validates a full native SDK and publishes its declaration tree and checked receipt; extension lint waits for that artifact and validates it through the usual preparation owner. Other additional checks and test-type jobs retain their existing dependencies. A content-keyed main cache supplies the same SDK to other consumers, with receipt validation on every restore. Scheduled, manual, release, frozen, and unsupported targets retain their complete existing checks.
- **Shared SDK declarations** keep the existing boundary check as a standalone producer on supported hybrid PRs targeting the canonical repository that already select both the boundary row and the check planner. The job count and check name stay unchanged. That producer validates a full native SDK and publishes its declaration tree and checked receipt; extension lint waits for that artifact and validates it through the usual preparation owner. Other additional checks and test-type jobs retain their existing dependencies. A content-keyed main cache supplies the same SDK to other consumers, with receipt validation on every restore. Compiler directory lookups use sorted file and directory membership, so relocation can retain a valid receipt when filesystem enumeration order changes. Scheduled, manual, release, frozen, and unsupported targets retain their complete existing checks.
- **Extension selection** uses exact tests from the changed plugin owner, transitive test importers, protected regressions, and explicit policy watches. Global dependency, shared-runtime, SDK, and planner inputs do not append a whole-plugin fallback. The existing Plugin Prerelease workflow owns the complete extension runtime inventory hourly and in Full Release Validation; normal CI does not append a second partial inventory.
- **PR builds** select `build-artifacts` for a dist-dependent row, an affected build/package owner test, or an individually selected built-process proof. Pipeline ownership comes from the existing changed-target map for build, declaration, package-tarball, and dist-artifact tests; generic runtime changes do not request the full artifact job. Source boundary guards and affected channel tests keep their Node owners. An artifact build does not also select an unrelated dist boundary, channel family, or every process verifier.
- **PR wrapper extraction** selects `pr-worktree-provision.test.ts` when the wrapper, its library, or a file in `scripts/pr-lib/wrapper-components.txt` changes. This manifest-derived policy watch supplements ordinary source tests because filesystem copying is invisible to the import graph. Manifest-only changes also run provisioning, including its duplicate-inventory and eager runtime import-closure checks.

View file

@ -145,7 +145,7 @@ export function createDeclarationFileSystem(
const isDirectory = file === undefined ? entry.isDirectory() : stat(file)?.isDirectory();
(isDirectory ? entries.directories : entries.files).push(entry.name);
}
return entries;
return { files: entries.files.toSorted(), directories: entries.directories.toSorted() };
} catch (error) {
if (missing(error)) {
return entries;

View file

@ -45,7 +45,9 @@ it.each(["overlap", "provider-error", "callback-drain", "cancel-drain"] as const
if (response.writableEnded || response.destroyed) {
return;
}
response.writeHead(200, { "content-type": "text/event-stream" });
if (!response.headersSent) {
response.writeHead(200, { "content-type": "text/event-stream" });
}
response.end(
`data: ${JSON.stringify({
id: "cli-response",
@ -223,7 +225,9 @@ it.each(["overlap", "provider-error", "callback-drain", "cancel-drain"] as const
finish(requests[0]!, 0);
await Promise.race([callbackStarted.promise, first]);
} else if (mode === "cancel-drain") {
finish(requests[0]!, 0);
// Keep the accepted body open so prefetch cannot settle it before cancellation.
requests[0]!.writeHead(200, { "content-type": "text/event-stream" });
requests[0]!.flushHeaders();
await Promise.race([cancelStarted.promise, first]);
await expect(first).rejects.toThrow("CLI failed");
drainage = parent.drain().then(() => {

View file

@ -69,7 +69,7 @@ it.skipIf(process.platform === "win32")(
const resumeRemoval = createDeferred();
const realRm = fsPromises.rm;
const remove = vi.spyOn(fsPromises, "rm").mockImplementation(async (target, settings) => {
if (String(target) === configPath) {
if (String(target) === configPath || String(target) === path.dirname(databasePath)) {
started.resolve();
await resumeRemoval.promise;
}
@ -207,9 +207,24 @@ it("drains the local cache and excludes reopening throughout awaited removal", a
const resumeRemoval = createDeferred();
const unlinked = createDeferred();
const resumeFinalization = createDeferred();
const entryName = (entry: string | Buffer | fs.Dirent<string | Buffer>) =>
typeof entry === "string" || Buffer.isBuffer(entry) ? entry.toString() : entry.name.toString();
const readdir = fsPromises.readdir.bind(fsPromises);
const discovery = vi
.spyOn(fsPromises, "readdir")
.mockImplementation(async (directory, settings) => {
const entries = await readdir(directory, settings);
// Directory enumeration may reach the database before the config file.
return String(directory) === stateDir
? entries.toSorted(
(left, right) =>
Number(entryName(right) === "state") - Number(entryName(left) === "state"),
)
: entries;
});
const realRm = fsPromises.rm;
const remove = vi.spyOn(fsPromises, "rm").mockImplementation(async (target, settings) => {
if (String(target) === configPath) {
if (String(target) === configPath || String(target) === path.dirname(database.path)) {
started.resolve();
await resumeRemoval.promise;
}
@ -232,7 +247,11 @@ it("drains the local cache and excludes reopening throughout awaited removal", a
);
try {
expect(
await Promise.race([started.promise.then(() => "removing"), deleting.then(() => "removed")]),
await Promise.race([
started.promise.then(() => "removing"),
unlinked.promise.then(() => "unlinked"),
deleting.then(() => "removed"),
]),
).toBe("removing");
expect(database.db.isOpen).toBe(false);
expect(() => retainedStatement.get()).toThrow(/finalized/);
@ -253,6 +272,7 @@ it("drains the local cache and excludes reopening throughout awaited removal", a
resumeFinalization.resolve();
await deleting.finally(() => {
remove.mockRestore();
discovery.mockRestore();
closeOpenClawStateDatabaseForTest();
});
}

View file

@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
// Onboard auth tests cover provider auth setup, credential persistence, and auth-profile state.
@ -128,9 +129,25 @@ describe("writeOAuthCredentials", () => {
expires: Date.now() + 60_000,
} satisfies OAuthCredentials;
await writeOAuthCredentials("openai", creds, undefined, {
syncSiblingAgents: true,
const readdir = fsSync.readdirSync;
const discovery = vi.spyOn(fsSync, "readdirSync").mockImplementation((...args) => {
const entries = readdir(...args);
if (args[0] === path.join(tempStateDir, "agents")) {
// The shared owner must precede siblings even when the filesystem lists it last.
entries.sort(
(left, right) =>
Number(left.name.toString() === "kid") - Number(right.name.toString() === "kid"),
);
}
return entries;
});
try {
await writeOAuthCredentials("openai", creds, undefined, {
syncSiblingAgents: true,
});
} finally {
discovery.mockRestore();
}
for (const dir of [mainAgentDir, kidAgentDir]) {
const effectiveStore = readEffectiveAuthProfiles(dir);

View file

@ -847,9 +847,13 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
}
const createsBefore = createEmbeddingProviderMock.mock.calls.length;
const closesBefore = closeEmbeddingProviderMock.mock.calls.length;
// Concurrent provider admission needs distinct connections; each socket owns serial requests.
const firstPromise = postEmbeddings(
{ model: "openclaw/default", input: "first" },
modelOverride ? { "x-openclaw-model": "openai/model-a" } : undefined,
{
connection: "close",
...(modelOverride ? { "x-openclaw-model": "openai/model-a" } : {}),
},
);
const requests = [firstPromise];
try {
@ -863,7 +867,10 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
});
const secondPromise = postEmbeddings(
{ model: "openclaw/default", input: "second" },
modelOverride ? { "x-openclaw-model": "openai/model-b" } : undefined,
{
connection: "close",
...(modelOverride ? { "x-openclaw-model": "openai/model-b" } : {}),
},
);
requests.push(secondPromise);
await waitForProviderEntry(secondEntered.promise, secondPromise);

View file

@ -2,7 +2,11 @@
// hook emission, thread bindings, and browser/MCP cleanup side effects.
import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import {
awaitGateBeforeSettlement,
createDeferred,
withinTest,
} from "../../test/helpers/promise.js";
import {
readAcpSessionMeta,
writeAcpSessionMetaForMigration,
@ -880,7 +884,9 @@ test("sessions.reset closes a spawned ACP child that lives in a different agent
expect(closedKeys).toContain("agent:codex:acp:cross-store-child");
});
test("sessions.reset closes child ACP runtimes concurrently so stuck children do not serialize cleanup", async () => {
test("sessions.reset closes child ACP runtimes concurrently so stuck children do not serialize cleanup", async ({
signal,
}) => {
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({
@ -936,6 +942,7 @@ test("sessions.reset closes child ACP runtimes concurrently so stuck children do
// Parent cancel resolves immediately; child cancels hang until released. With
// sequential cleanup only the first child would dispatch; concurrent cleanup
// dispatches all three before any resolves.
const childrenEntered = createDeferred();
const releaseChildren: Array<() => void> = [];
acpManagerMocks.cancelSession.mockImplementation(async (...args: unknown[]) => {
const req = args[0] as { sessionKey?: string } | undefined;
@ -944,20 +951,26 @@ test("sessions.reset closes child ACP runtimes concurrently so stuck children do
}
await new Promise<void>((resolve) => {
releaseChildren.push(resolve);
if (releaseChildren.length === 3) {
childrenEntered.resolve();
}
});
});
const resetPromise = directSessionReq<{ ok: true }>("sessions.reset", { key: "main" });
try {
const resetPromise = directSessionReq<{ ok: true }>("sessions.reset", {
key: "main",
});
await vi.waitFor(() => {
const childCancels = (
acpManagerMocks.cancelSession.mock.calls as unknown as Array<[{ sessionKey?: string }]>
).filter((call) => call[0]?.sessionKey?.startsWith("agent:main:acp-child"));
expect(childCancels.length).toBe(3);
});
await withinTest(
awaitGateBeforeSettlement(
childrenEntered.promise,
resetPromise,
"Reset settled before all child cancellations entered",
),
signal,
);
const childCancels = (
acpManagerMocks.cancelSession.mock.calls as unknown as Array<[{ sessionKey?: string }]>
).filter((call) => call[0]?.sessionKey?.startsWith("agent:main:acp-child"));
expect(childCancels.length).toBe(3);
for (const release of releaseChildren) {
release();
@ -966,6 +979,10 @@ test("sessions.reset closes child ACP runtimes concurrently so stuck children do
expect(reset.ok).toBe(true);
} finally {
acpManagerMocks.cancelSession.mockImplementation(async () => {});
for (const release of releaseChildren) {
release();
}
await resetPromise.catch(() => {});
}
});

View file

@ -8,6 +8,7 @@ import {
upsertAuthProfileWithLock,
upsertAuthProfileWithLockOrThrow,
} from "../agents/auth-profiles/profiles.js";
import { resolveSharedMainAuthAgentDir } from "../agents/auth-profiles/shared-main-dir.js";
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@ -295,12 +296,14 @@ function resolveSiblingAgentDirs(primaryAgentDir: string): string[] {
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map((entry) => path.join(agentsRoot, entry.name, "agent"));
// Publish the shared profile before siblings decide whether to inherit it.
const sharedAgentDir = safeRealpathSync(resolveSharedMainAuthAgentDir());
return uniqueStrings(
[normalized, ...discovered].flatMap((dir) => {
const real = safeRealpathSync(path.resolve(dir));
return real ? [real] : [];
}),
);
).toSorted((left, right) => Number(right === sharedAgentDir) - Number(left === sharedAgentDir));
}
export async function writeOAuthCredentials(

View file

@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
ARTIFACT_CACHE_VERSION,
acquireBuildArtifactLock,
@ -550,16 +550,25 @@ describe("native owner content records", () => {
expect(matches()).toBe(true);
const relocated = fs.realpathSync.native(roots.make("native-boundary-relocated-"));
fs.cpSync(f.root, relocated, { recursive: true, mode: fs.constants.COPYFILE_FICLONE });
expect(
new BoundaryInputSnapshot(relocated).matchesReceipt(
record,
f.config,
f.args,
Object.keys(record.outputs),
f.inputReceipt,
noEmit ? undefined : "dist",
),
).toBe(true);
// Relocation may change enumeration order without changing membership.
const readdir = fs.readdirSync;
const reordered = vi
.spyOn(fs, "readdirSync")
.mockImplementation((target, options) => readdir(target, options).toReversed());
try {
expect(
new BoundaryInputSnapshot(relocated).matchesReceipt(
record,
f.config,
f.args,
Object.keys(record.outputs),
f.inputReceipt,
noEmit ? undefined : "dist",
),
).toBe(true);
} finally {
reordered.mockRestore();
}
f.write("unrelated/source.ts", "export const unrelated = 2;");
f.write("src/api.test.ts", "export const test = 2;");
expect(matches()).toBe(true);