mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:12:18 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed Cloud instances must retain shared objects and secrets across server replacement. > - Implicit storage defaults can select local disk or an unintended bucket. > - Cloud runtime settings now come from a canonical database snapshot. > - This pull request validates bootstrap prerequisites before database setup and effective S3 settings after snapshot resolution. > - The benefit is an actionable refusal before local signing state or HTTP setup, while valid canonical Cloud settings work. ## Linked Issues or Issue Description **What happened?** A managed Cloud instance could start with local disk storage, an implicit bucket, or a local key file. After canonical Cloud configuration landed, checking only environment or file values would also reject a valid database bucket. Shared schema defaults could make an omitted raw bucket appear explicit. **Expected behavior** Managed Cloud requires external PostgreSQL, a stable environment master key, and effective S3 settings with a nonblank bucket. The canonical snapshot remains the configuration authority. Invalid configuration refuses startup before signing or HTTP effects. Self-hosted defaults remain compatible. **Steps to reproduce** 1. Select managed Cloud with its tenant token or managed configuration document. 2. Leave storage as local disk, or omit the S3 bucket from a raw canonical snapshot. 3. Start the server. Startup must refuse. An explicit valid bucket in the canonical snapshot must work without a file or environment bucket. **Paperclip version or commit** Current base: `4edd10804e2b42941aa2038e90c362c13a0b98dc`. **Deployment mode** Managed Cloud. Ordinary self-hosted defaults remain unchanged. Related PRs: #18335 owns canonical Cloud runtime configuration. #18327 owns S3 run logs. #18352 owns credential persistence. #18347 owns cutover checks. This PR adds configuration prerequisites to those existing boundaries. ## What Changed - Validate external PostgreSQL and a stable environment master key at the Cloud bootstrap boundary, before database setup. - Validate effective S3 settings after canonical snapshot publication and before Cloud signing, runtime consumers, or HTTP setup. - Require a nonblank bucket in the Cloud snapshot schema. Reject omitted or blank buckets in every legacy Cloud file before one-time import normalization, even when an environment bucket is supplied. Keep shared self-hosted schema defaults. - Preserve typed bucket provenance at the existing file-read boundary. Accept explicit canonical bucket names, including `paperclip`, and environment settings when the snapshot is null. - Validate effective legacy storage with the existing resolver before seeding. A rejected candidate leaves no canonical rows, so a corrected source file can be imported on retry. Environment overlays are checked but never persisted. - Add real PostgreSQL snapshot and migration-retry tests, startup refusal tests, and environment/file compatibility controls. Document that Cloud always requires its stable environment key, even with an external default secrets provider. ## Verification - Latest focused acceptance passed 199/199 in six files (54.29 seconds), using the command below plus `server/src/__tests__/cloud-configuration-migration-postgres.test.ts`. Real PostgreSQL proves rejected storage leaves all three migration authority rows absent, then a corrected legacy file imports on retry. An external-default-secrets control proves the deliberate unconditional Cloud master-key contract. - Before the peer correction, the frozen product diff passed 178 tests in five files: managed-cloud-prerequisites, cloud-runtime-config, server-startup-feedback-export, config-file, and cloud-configuration-readers. Command: `pnpm exec vitest run server/src/__tests__/{managed-cloud-prerequisites,cloud-runtime-config,server-startup-feedback-export,config-file,cloud-configuration-readers}.test.ts --maxWorkers=1 --no-file-parallelism`. Real PostgreSQL used a qualified POSIX test-only PG18.6 override because the host cannot supply the stock runtime's SysV semaphore allocation. - Coverage includes raw database and legacy omitted/blank bucket rejection, valid database and environment buckets, null snapshots, explicit default-named buckets, self-hosted compatibility, and refusal before local signing mutation. - The real AWS SDK configured-provider control uses a simulated loopback S3 service. It writes, reads from a fresh provider, and deletes an object. It also reads an encrypted credential with the stable environment key. This does not qualify production cloud permissions. - `git diff --check` passed. Final merges add unrelated master changes from #18241, #18363, and #18370; the focused storage product diff is unchanged. - Peer review reproduced a fresh legacy local-disk import that gained the default bucket before an S3 environment overlay. The importer now requires a raw explicit bucket for every legacy Cloud file. Six controls cover omission/blank values with and without an environment bucket; the full focused rerun passed 184/184 across all five files in 194.89 seconds. - Initial revised startup testing passed 167/168: one fixture omitted the database URL and failed before its intended key assertion. The corrected final run passed 178/178. An initial direct server typecheck failed because Runner declarations were not built; this is a setup failure, not a compile pass. Integration verification rebuilds dependencies before full checks. - Prior head `fae1e4aa` passed complete CI with 53 successes, two intended skips, and Greptile 5/5. `de71345f379cecb71dbfb219f35e14f942c10a3f` then passed complete CI (55 successes, three workflow skips, and a neutral external review), with Greptile 5/5 and zero unresolved threads. The external review identified the preseed rejection problem; its correction is committed at `75e04d0d77e19dcef0a8cb070b6b518f0f173b14`. The final merge head is `19b809ea2823a6a4e5ceb48ef2c2bac4721733d6`; [Fresh complete CI](https://github.com/paperclipai/paperclip/actions/runs/41127309762) passed on that head: 55 successful checks, three workflow skips, and one neutral external review. Greptile reviewed this exact head at 5/5 with no actionable issues. Both earlier external review threads are resolved with the tested fixes. Earlier infrastructure retries remain documented in [the first diagnosis](https://github.com/paperclipai/paperclip/pull/18350#issuecomment-6529964690) and [the runner shutdown evidence](https://github.com/paperclipai/paperclip/pull/18350#issuecomment-6530364769). Earlier missing SDK build and fixture metadata failures were repaired before those passes. - Current frozen-head CI first attempt failed one unchanged Antigravity authentication success case in Runner Vitest 1/2 (`outcome=failure`, 1994 tests passed). The same file passed 11/11 locally in 2.33 seconds, and identical Runner source passed the prior complete CI run. Attempt 2 retried only failed jobs (the Runner shard and dependent aggregate). Both passed without a source change. No second retry was used. The exact timing cause is not established. [Failed job](https://github.com/paperclipai/paperclip/actions/runs/41127309762/job/124309181778). - No production data, buckets, or live credentials were used. Complete local repository suites are not duplicated in this worktree; current-head required CI supplies the full suite gate. ## Risks - Managed deployments with local disk, omitted buckets, file-only keys, or missing external database URLs now refuse startup. Operators must supply valid prerequisites before rollout. - Required deployment environment overlays must remain stable across replacements. For example, a saved local-disk provider with an explicit bucket can use an environment S3 provider; removing that required overlay correctly refuses startup until the environment or canonical configuration is corrected. Environment settings are never silently copied into the canonical row. - Effective legacy storage must also pass before first seeding. A bad candidate does not create authority rows. Existing saved rows remain authoritative and can require environment overrides or a canonical configuration change if they are invalid. - A present legacy Cloud file needs an explicit raw bucket even if its saved provider is local disk or its environment supplies a bucket. Correct the file before one-time import, or use the existing absent-file/null-snapshot environment-only setup. Environment overlays are not persisted as operator choices. - A previously stored canonical nonblank `paperclip` bucket is authoritative. Prior normalization erased whether that value was originally explicit. This change cannot recover that provenance and does not reject existing nonblank canonical values. - Validation proves configuration shape, not cloud permissions, database reachability, or key compatibility with existing encrypted data. Keep the master key stable across replacements. - Cloud snapshot JSON parsing is stricter; omitted or blank raw S3 bucket values need correction. No database migration or provider operation changes are added. These prerequisites do not prove that every server persistence path is disposable. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, exact model ID `gpt-6.1-sol`. Context window: unknown. The model used reasoning, repository tools, code execution, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
339 lines
22 KiB
TypeScript
339 lines
22 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { applyPendingMigrations, closeRegisteredClients, createDb, instanceSettings, plugins, type Db } from "@paperclipai/db";
|
|
import { eq, inArray, sql } from "drizzle-orm";
|
|
import { paperclipConfigSchema } from "@paperclipai/shared";
|
|
import { adapterSettingsService, initializeAdapterSettings } from "../services/adapter-settings.js";
|
|
import { cloudRuntimeConfigService, initializeCloudRuntimeConfig } from "../services/cloud-runtime-config.js";
|
|
import { cloudConfigurationMigrationService } from "../services/cloud-configuration-migration.js";
|
|
import { qualifyCloudConfigurationForCurrentImage } from "../services/cloud-configuration-qualification.js";
|
|
import { runCloudConfigurationQualification } from "../qualify-cloud-configuration.js";
|
|
import { pluginLoader, type PluginRuntimeServices } from "../services/plugin-loader.js";
|
|
import { pluginRegistryService } from "../services/plugin-registry.js";
|
|
import { createPluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
import { ensureBundledPlugins } from "../services/bundled-plugins.js";
|
|
import { bundledImageFixture } from "./helpers/bundled-image-fixture.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
|
|
const externalDatabaseUrl = process.env.PAPERCLIP_CLOUD_CONFIGURATION_TEST_DATABASE_URL?.trim();
|
|
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
const keys = ["adapter-settings:v1", "cloud-runtime-config:v1", "cloud-configuration-migration:v1"];
|
|
(support.supported ? describe : describe.skip)("Cloud configuration migration and current-image qualification", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let peer: Db;
|
|
let homeA: string;
|
|
let homeB: string;
|
|
const roots: string[] = [];
|
|
const fixture = (version = "1.0.0") => { const image = bundledImageFixture({ version }); roots.push(image.root); return image; };
|
|
const env = (home = homeA) => ({ PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-cloud-token", PAPERCLIP_HOME: home, PAPERCLIP_CONFIG: path.join(home, "config.json") });
|
|
const migration = (client = db, home = homeA) => cloudConfigurationMigrationService(client, { runtimeEnv: env(home) });
|
|
beforeAll(async () => {
|
|
if (externalDatabaseUrl) {
|
|
await applyPendingMigrations(externalDatabaseUrl);
|
|
database = { connectionString: externalDatabaseUrl, cleanup: () => closeRegisteredClients(externalDatabaseUrl) };
|
|
} else database = await startEmbeddedPostgresTestDatabase("cloud-configuration-migration-");
|
|
db = createDb(database.connectionString);
|
|
peer = createDb(database.connectionString);
|
|
}, 90_000);
|
|
beforeEach(async () => {
|
|
homeA = await fs.mkdtemp(path.join(os.tmpdir(), "cloud-legacy-A-"));
|
|
homeB = await fs.mkdtemp(path.join(os.tmpdir(), "cloud-replacement-B-"));
|
|
roots.push(homeA, homeB);
|
|
await db.delete(instanceSettings).where(inArray(instanceSettings.singletonKey, keys));
|
|
await db.delete(plugins);
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true });
|
|
});
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
|
|
async function legacy() {
|
|
const config = paperclipConfigSchema.parse({
|
|
$meta: { version: 1, updatedAt: "2026-10-30T00:00:00.000Z", source: "configure" },
|
|
database: { mode: "postgres" }, logging: { mode: "cloud" },
|
|
storage: { provider: "s3", s3: { bucket: "migration-bucket" } },
|
|
server: { port: 3199 }, telemetry: { enabled: false },
|
|
});
|
|
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
|
|
await fs.writeFile(path.join(homeA, "adapter-settings.json"), JSON.stringify({ disabledTypes: ["claude_local", "future_adapter"] }));
|
|
await fs.writeFile(path.join(homeA, "adapter-plugins.json"), "[]");
|
|
return config;
|
|
}
|
|
|
|
function loaderFor(image: ReturnType<typeof fixture>, client = db, services?: PluginRuntimeServices) {
|
|
return pluginLoader(client, {
|
|
localPluginDir: path.join(homeB, "plugins"), prebuiltOnly: true,
|
|
resolvePackageRoot: image.resolver.resolve, assertPackageActivation: image.resolver.assertActivation,
|
|
enableLocalFilesystem: false, enableNpmDiscovery: false,
|
|
}, services);
|
|
}
|
|
async function install(image: ReturnType<typeof fixture>) {
|
|
await loaderFor(image).installPlugin({ localPath: image.packageRoot });
|
|
const registry = pluginRegistryService(db);
|
|
const row = (await registry.getByKey(image.manifest.id))!;
|
|
await registry.updateStatus(row.id, { status: "ready" });
|
|
return row;
|
|
}
|
|
function qualify(image: ReturnType<typeof fixture>, client = db, home = homeA, expectedPackageEvidenceDigest?: string) {
|
|
const loader = loaderFor(image, client);
|
|
return migration(client, home).qualify({ installs: image.installs, loadManifest: (root) => loader.loadManifest(root), expectedPackageEvidenceDigest });
|
|
}
|
|
|
|
it("imports A atomically, retains later DB revisions, and starts B's real image worker after A disappears", async () => {
|
|
await legacy();
|
|
const oldImage = fixture();
|
|
const newImage = fixture();
|
|
const row = await install(oldImage);
|
|
const first = await migration().migrate();
|
|
expect(first.migration.adapter.authority).toBe("legacy_file");
|
|
expect(first.migration.config.authority).toBe("legacy_file");
|
|
const original = await qualify(oldImage);
|
|
expect(original.configurationPackagesEligible).toBe(true);
|
|
expect(original.samePackageOverlapEligible).toBe(false);
|
|
await adapterSettingsService(peer, { runtimeEnv: env() }).setDisabled("codex_local", true);
|
|
const config = (await cloudRuntimeConfigService(peer).inspectStored())!;
|
|
expect(config.config).not.toBeNull();
|
|
await cloudRuntimeConfigService(peer).replace({ ...config.config!, server: { ...config.config!.server, port: 3299 } }, config.revision);
|
|
await fs.rm(homeA, { recursive: true });
|
|
await fs.rm(oldImage.root, { recursive: true });
|
|
expect((await migration(peer, homeB).migrate()).migration).toEqual(first.migration);
|
|
await initializeAdapterSettings(peer, { runtimeEnv: env(homeB) });
|
|
const restored = await initializeCloudRuntimeConfig(peer, { runtimeEnv: env(homeB) });
|
|
expect(restored?.current.config?.server.port).toBe(3299);
|
|
expect(await adapterSettingsService(peer, { runtimeEnv: env(homeB) }).getDisabledTypes()).toEqual(["claude_local", "codex_local", "future_adapter"]);
|
|
const replacement = await qualify(newImage, peer, homeB, original.packageEvidenceDigest);
|
|
expect(replacement).toMatchObject({ adapterRevision: 1, configRevision: 1, configurationPackagesEligible: true, samePackageOverlapEligible: true });
|
|
expect(replacement.configurationDigest).not.toBe(original.configurationDigest);
|
|
const manager = createPluginWorkerManager();
|
|
try {
|
|
const services = {
|
|
workerManager: manager, lifecycleManager: { markError: vi.fn() },
|
|
jobStore: { syncJobDeclarations: vi.fn() }, jobScheduler: { registerPlugin: vi.fn() },
|
|
toolDispatcher: {}, eventBus: { forPlugin: vi.fn(), subscriptionCount: vi.fn(() => 0) },
|
|
buildHostHandlers: vi.fn(() => ({})), instanceInfo: { instanceId: "qualification-fixture", hostVersion: "1.0.0" },
|
|
} as unknown as PluginRuntimeServices;
|
|
expect((await loaderFor(newImage, peer, services).loadSingle(row.id)).success).toBe(true);
|
|
expect(await manager.call(row.id, "health", {})).toMatchObject({ status: "ok", details: { source: expect.stringContaining(newImage.packageRoot) } });
|
|
} finally { await manager.stopAll(); }
|
|
});
|
|
|
|
it("does not seed rejected storage and imports the corrected legacy file on retry", async () => {
|
|
const config = await legacy();
|
|
config.storage.provider = "local_disk";
|
|
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
|
|
await expect(migration().migrate()).rejects.toThrow(/PAPERCLIP_STORAGE_PROVIDER=s3/);
|
|
expect(await peer.select().from(instanceSettings).where(inArray(instanceSettings.singletonKey, keys))).toEqual([]);
|
|
config.storage.provider = "s3";
|
|
await fs.writeFile(path.join(homeA, "config.json"), JSON.stringify(config));
|
|
expect((await migration(peer).migrate()).migration.config.authority).toBe("legacy_file");
|
|
expect((await cloudRuntimeConfigService(peer).inspectStored())?.config?.storage.provider).toBe("s3");
|
|
});
|
|
|
|
it("serializes concurrent attempts and never imports stale inputs over later DB changes", async () => {
|
|
await legacy();
|
|
const [first, second] = await Promise.all([migration().migrate(), migration(peer).migrate()]);
|
|
expect(first).toEqual(second);
|
|
await adapterSettingsService(peer, { runtimeEnv: env() }).setDisabled("claude_local", false);
|
|
await fs.writeFile(path.join(homeA, "config.json"), "invalid stale file");
|
|
await fs.writeFile(path.join(homeA, "adapter-settings.json"), "invalid stale file");
|
|
expect(await migration().migrate()).toEqual(first);
|
|
expect(await adapterSettingsService(db, { runtimeEnv: env() }).getDisabledTypes()).toEqual(["future_adapter"]);
|
|
});
|
|
|
|
it("rolls back both seeds and the receipt if completion fails, then retries after the crash boundary", async () => {
|
|
await legacy();
|
|
await db.execute(sql`CREATE FUNCTION reject_cloud_migration_receipt() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.singleton_key = 'cloud-configuration-migration:v1' THEN RAISE EXCEPTION 'test receipt failure'; END IF; RETURN NEW; END $$`);
|
|
await db.execute(sql`CREATE TRIGGER reject_cloud_migration_receipt BEFORE UPDATE ON instance_settings FOR EACH ROW EXECUTE FUNCTION reject_cloud_migration_receipt()`);
|
|
try {
|
|
await expect(migration().migrate()).rejects.toMatchObject({ cause: { message: expect.stringContaining("test receipt failure") } });
|
|
expect(await peer.select().from(instanceSettings).where(inArray(instanceSettings.singletonKey, keys))).toEqual([]);
|
|
} finally {
|
|
await db.execute(sql`DROP TRIGGER reject_cloud_migration_receipt ON instance_settings`);
|
|
await db.execute(sql`DROP FUNCTION reject_cloud_migration_receipt()`);
|
|
}
|
|
expect((await migration(peer).migrate()).revision).toBe(0);
|
|
expect((await peer.select().from(instanceSettings).where(inArray(instanceSettings.singletonKey, keys))).length).toBe(3);
|
|
});
|
|
|
|
it("fails without partial authority for malformed legacy config and preserves already-authoritative rows", async () => {
|
|
await legacy();
|
|
await fs.writeFile(path.join(homeA, "config.json"), "invalid");
|
|
await expect(migration().migrate()).rejects.toThrow("Legacy Cloud runtime configuration");
|
|
expect(await peer.select().from(instanceSettings).where(inArray(instanceSettings.singletonKey, keys))).toEqual([]);
|
|
await adapterSettingsService(db, { runtimeEnv: env() }).seedIfAbsent({ disabledTypes: ["codex_local"] });
|
|
await cloudRuntimeConfigService(db).seedIfAbsent(null);
|
|
await fs.writeFile(path.join(homeA, "adapter-settings.json"), "invalid");
|
|
const receipt = await migration(peer).migrate();
|
|
expect(receipt.migration.adapter.authority).toBe("database");
|
|
expect(receipt.migration.config.authority).toBe("database");
|
|
expect((await cloudRuntimeConfigService(peer).inspectStored())?.config).toBeNull();
|
|
});
|
|
|
|
it("returns database errors instead of falling back to files or a completed receipt", async () => {
|
|
await legacy();
|
|
await migration().migrate();
|
|
const broken = createDb(database.connectionString);
|
|
await broken.$client.end({ timeout: 0 });
|
|
await expect(migration(broken).migrate()).rejects.toThrow();
|
|
const image = fixture();
|
|
await expect(qualify(image, broken)).rejects.toThrow();
|
|
});
|
|
|
|
it("keeps external registration blockers durable when B has no legacy home and requires deliberate original-source resolution", async () => {
|
|
await legacy();
|
|
const sentinel = path.join(homeA, "external-code-executed");
|
|
await fs.writeFile(path.join(homeA, "external.js"), `require('node:fs').writeFileSync(${JSON.stringify(sentinel)}, 'executed');`);
|
|
await fs.writeFile(path.join(homeA, "adapter-plugins.json"), JSON.stringify([{ type: "legacy_external", packageName: "legacy-adapter", localPath: path.join(homeA, "external.js") }]));
|
|
const image = fixture();
|
|
await install(image);
|
|
await migration().migrate();
|
|
expect((await qualify(image)).blockers).toContainEqual(expect.objectContaining({ code: "unsupported_external_adapters" }));
|
|
await expect(fs.stat(sentinel)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await migration(peer, homeB).migrate();
|
|
const replacement = await qualify(image, peer, homeB);
|
|
expect(replacement.configurationPackagesEligible).toBe(false);
|
|
await expect(migration(peer, homeB).recheckLegacyExternalRegistrations(replacement.receiptRevision)).rejects.toThrow("original legacy source");
|
|
await fs.unlink(path.join(homeA, "adapter-plugins.json"));
|
|
await expect(migration().recheckLegacyExternalRegistrations(replacement.receiptRevision)).rejects.toThrow("present, valid registration file");
|
|
expect((await migration().inspectStored())?.migration.externalRegistrations.blocker).toBe("unsupported_external_adapters");
|
|
await fs.writeFile(path.join(homeA, "adapter-plugins.json"), "[]");
|
|
await expect(migration().recheckLegacyExternalRegistrations(0)).rejects.toThrow("revision conflict");
|
|
await migration().recheckLegacyExternalRegistrations(replacement.receiptRevision);
|
|
expect((await qualify(image)).configurationPackagesEligible).toBe(true);
|
|
});
|
|
|
|
it("retains a metadata blocker for malformed or oversized registration files", async () => {
|
|
await legacy();
|
|
await fs.writeFile(path.join(homeA, "adapter-plugins.json"), Buffer.alloc(65_537));
|
|
const receipt = await migration().migrate();
|
|
expect(receipt.migration.externalRegistrations.blocker).toBe("invalid_external_registration_metadata");
|
|
await fs.rm(homeA, { recursive: true });
|
|
expect((await migration(peer, homeB).migrate()).migration.externalRegistrations).toEqual(receipt.migration.externalRegistrations);
|
|
});
|
|
|
|
it("records image mismatches before reconciliation, permits maintenance upgrades, and requires explicit fresh overlap evidence", async () => {
|
|
await legacy();
|
|
const oldImage = fixture();
|
|
const nextImage = fixture("1.1.0");
|
|
await install(oldImage);
|
|
await migration().migrate();
|
|
const old = await qualify(oldImage);
|
|
const mismatch = await qualify(nextImage, peer, homeB, old.packageEvidenceDigest);
|
|
expect(mismatch.configurationPackagesEligible).toBe(false);
|
|
expect(mismatch.samePackageOverlapEligible).toBe(false);
|
|
expect(mismatch.blockers).toContainEqual(expect.objectContaining({ code: "incompatible_bundled_package", registeredVersion: "1.0.0", imageVersion: "1.1.0" }));
|
|
const registry = pluginRegistryService(db);
|
|
await ensureBundledPlugins(nextImage.installs, {
|
|
registry, loader: loaderFor(nextImage), lifecycle: { load: vi.fn() },
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}, { reinstallUninstalled: true, preservePackagePath: true });
|
|
const current = await qualify(nextImage);
|
|
expect(current.configurationPackagesEligible).toBe(true);
|
|
expect(current.samePackageOverlapEligible).toBe(false);
|
|
expect((await migration().inspectStored())?.lastPackageMismatch?.packageEvidenceDigest).toBe(mismatch.packageEvidenceDigest);
|
|
expect((await qualify(nextImage, peer, homeB, current.packageEvidenceDigest)).samePackageOverlapEligible).toBe(true);
|
|
});
|
|
|
|
it("fails qualification for missing package or manifest bytes without loading a home fallback", async () => {
|
|
await legacy();
|
|
const image = fixture();
|
|
await install(image);
|
|
await migration().migrate();
|
|
const good = await qualify(image);
|
|
await fs.rm(path.join(image.packageRoot, "dist", "manifest.js"));
|
|
const broken = await qualify(image, peer, homeB, good.packageEvidenceDigest);
|
|
expect(broken.configurationPackagesEligible).toBe(false);
|
|
expect(broken.samePackageOverlapEligible).toBe(false);
|
|
expect(broken.blockers.length).toBeGreaterThan(0);
|
|
await fs.rm(image.packageRoot, { recursive: true });
|
|
expect((await qualify(image)).blockers).toContainEqual(expect.objectContaining({ code: "missing_bundled_package" }));
|
|
});
|
|
|
|
it("rejects changed image bytes before manifest import and never uses stale completed qualification", async () => {
|
|
await legacy();
|
|
const image = fixture();
|
|
await install(image);
|
|
await migration().migrate();
|
|
const original = await qualify(image);
|
|
await fs.appendFile(path.join(image.packageRoot, "dist", "worker.js"), "\nprocess.exit(99);\n");
|
|
const loadManifest = vi.fn();
|
|
const current = await migration(peer, homeB).qualify({ installs: image.installs, loadManifest, expectedPackageEvidenceDigest: original.packageEvidenceDigest });
|
|
expect(current.configurationPackagesEligible).toBe(false);
|
|
expect(current.samePackageOverlapEligible).toBe(false);
|
|
expect(current.blockers).toContainEqual(expect.objectContaining({ code: "package_integrity_mismatch" }));
|
|
expect(loadManifest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows an administrator update while manifest inspection waits and refuses to certify the stale snapshot", async () => {
|
|
await legacy();
|
|
const image = fixture();
|
|
await install(image);
|
|
await migration().migrate();
|
|
let started!: () => void;
|
|
let resume!: () => void;
|
|
const entered = new Promise<void>((resolve) => { started = resolve; });
|
|
const release = new Promise<void>((resolve) => { resume = resolve; });
|
|
const loader = loaderFor(image);
|
|
const pending = migration().qualify({ installs: image.installs, loadManifest: async (root) => { started(); await release; return loader.loadManifest(root); } });
|
|
await entered;
|
|
try {
|
|
const change = adapterSettingsService(peer, { runtimeEnv: env() }).setDisabled("codex_local", true);
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
expect(await Promise.race([change, new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(new Error("Admin update was blocked by image inspection")), 2_000); })])).toBe(true);
|
|
} finally { clearTimeout(timer); }
|
|
} finally { resume(); }
|
|
const result = await pending;
|
|
expect(result).toMatchObject({ configurationPackagesEligible: false, adapterRevision: 1 });
|
|
expect(result.blockers).toContainEqual(expect.objectContaining({ code: "configuration_changed_during_inspection" }));
|
|
expect((await qualify(image)).configurationPackagesEligible).toBe(true);
|
|
});
|
|
|
|
it("uses the current managed catalog through the operator command and durably records catalog failure", async () => {
|
|
await legacy();
|
|
const image = fixture();
|
|
await install(image);
|
|
await migration().migrate();
|
|
const runtimeEnv = {
|
|
...env(homeB), DATABASE_URL: database.connectionString, PAPERCLIP_BUNDLED_PLUGIN_ROOT: image.root,
|
|
PAPERCLIP_MANAGED_CONFIG: JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "fixture", features: {}, plugins: { autoInstall: ["fixture"] } }),
|
|
};
|
|
const initial = await qualifyCloudConfigurationForCurrentImage(db, { runtimeEnv });
|
|
expect(initial?.configurationPackagesEligible).toBe(true);
|
|
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
try {
|
|
expect(await runCloudConfigurationQualification(["--expected-package-evidence", initial!.packageEvidenceDigest], runtimeEnv)).toBe(0);
|
|
const report = stdout.mock.calls.map((call) => String(call[0])).find((text) => text.startsWith('{\n "receiptRevision"'));
|
|
expect(report && JSON.parse(report).samePackageOverlapEligible).toBe(true);
|
|
} finally { stdout.mockRestore(); }
|
|
const missing = await qualifyCloudConfigurationForCurrentImage(peer, { runtimeEnv: { ...runtimeEnv, PAPERCLIP_MANAGED_CONFIG: runtimeEnv.PAPERCLIP_MANAGED_CONFIG.replace('"fixture"]', '"absent"]') } });
|
|
expect(missing?.configurationPackagesEligible).toBe(false);
|
|
expect(missing?.blockers).toContainEqual(expect.objectContaining({ code: "current_image_catalog_unavailable" }));
|
|
expect((await migration().inspectStored())?.lastPackageMismatch?.blockers).toContainEqual(expect.objectContaining({ code: "current_image_catalog_unavailable" }));
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")("detects changed pnpm dependency bytes in a builtin package overlap comparison", async () => {
|
|
await legacy();
|
|
const image = fixture();
|
|
await install(image);
|
|
await migration().migrate();
|
|
const dependency = path.join(image.packageRoot, "node_modules", ".pnpm", "dep@1.0.0", "node_modules", "dep");
|
|
await fs.mkdir(dependency, { recursive: true });
|
|
await fs.writeFile(path.join(dependency, "index.js"), "export const value = 1;");
|
|
await fs.symlink(path.relative(path.join(image.packageRoot, "node_modules"), dependency), path.join(image.packageRoot, "node_modules", "dep"));
|
|
const installs = image.installs.map(({ distribution: _distribution, ...install }) => install);
|
|
const loader = loaderFor(image);
|
|
const original = await migration().qualify({ installs, loadManifest: (root) => loader.loadManifest(root) });
|
|
expect(original.configurationPackagesEligible).toBe(true);
|
|
await fs.writeFile(path.join(dependency, "index.js"), "export const value = 2;");
|
|
const changed = await migration(peer, homeB).qualify({ installs, loadManifest: (root) => loader.loadManifest(root), expectedPackageEvidenceDigest: original.packageEvidenceDigest });
|
|
expect(changed.configurationPackagesEligible).toBe(true);
|
|
expect(changed.samePackageOverlapEligible).toBe(false);
|
|
expect(changed.overlapBlockers).toContainEqual(expect.objectContaining({ code: "package_evidence_changed" }));
|
|
});
|
|
});
|