mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox providers (`packages/plugins/sandbox-providers/*`) are standalone published packages. `pnpm-workspace.yaml` keeps them out of the root workspace on purpose. > - `.github/dependabot.yml` watches only `/`, so Dependabot never saw the providers. > - Because of this, the Kubernetes provider stayed on zod 3 (GHSA-mrhx-c7xc-m3w5) after the rest of the repo moved to zod `^4.6.6`. #18839 fixed it by hand. > - We do not want to keep these versions current by hand in many places. > - This pull request lets Dependabot update the providers, and keeps each provider standalone. > - The benefit is that a provider dependency that falls behind gets a pull request that can pass CI. ## Linked Issues or Issue Description Refs #18839 (adds `scripts/typecheck-sandbox-providers` and fixes Kubernetes zod by hand). Refs #18998 (a Dependabot pull request for the Pi distribution that fails CI by design). **What existing behavior does this improve?** Dependabot version updates. They skip every package that the root pnpm workspace excludes. **Subsystem affected** CI and dependency automation: `.github/dependabot.yml`, `scripts/typecheck-sandbox-providers`. **Current behavior** Dependabot does not update the sandbox providers. A provider can stay on an old or unsafe version, and nobody gets a pull request. **Proposed behavior** Dependabot updates the providers each week. Each pull request changes the provider manifests and the lockfiles that CI installs frozen, so the pull request can pass CI. **Reason and benefit** We stop having to find and fix old provider dependencies by hand. ## What Changed - `.github/dependabot.yml`: add an npm entry for `/scripts/typecheck-sandbox-providers`. Dependabot treats this workspace as one unit. One pull request per dependency changes every provider that uses the dependency and the shared `pnpm-lock.yaml` together. So the entry needs no `groups`. One entry per provider would open a separate pull request for each provider, and each one would fail the frozen install in CI. - `.github/dependabot.yml`: add an npm entry for `/packages/plugins/sandbox-providers/daytona`. Daytona is the only provider with its own lockfile, and the release build and the Daytona end-to-end job install it frozen. Each lockfile has exactly one Dependabot entry, so every pull request changes a manifest and its lockfile together. - Take Daytona out of the shared typecheck workspace. This pull request does not commit `scripts/typecheck-sandbox-providers/pnpm-lock.yaml`: since #19522, only the lockfile refresh workflow writes it. After merge, the bot removes Daytona's packages from it (no other version changes; checked with pnpm 9.15.4). Until then CI resolves it in place. `scripts/typecheck-test-programs.mjs` now installs Daytona from its own lockfile (`LOCKED_PROVIDERS`, with `--ignore-workspace`). A stale lockfile is resolved in place for the run, for both installs. - `.github/dependabot.yml`: add a comment that tells why the Pi and Kimi runtime distributions have no entry (see Risks). - `scripts/typecheck-sandbox-providers/pnpm-workspace.yaml`: list each provider by its full path. Dependabot does not expand a workspace glob that starts with `../`, so with the glob it saw no providers. - `scripts/typecheck-test-programs.test.mjs`: add tests. They check that the workspace list has no glob and matches `SANDBOX_PROVIDERS` without `LOCKED_PROVIDERS`, that each locked provider has its lockfile, that a locked provider installs frozen with `--ignore-workspace`, and that a stale locked-provider lockfile is resolved in place. - Add `.npmrc` (`auto-install-peers=true`) in `scripts/typecheck-sandbox-providers` and in the Daytona provider. Without it, Dependabot reads the root `.npmrc` (`false`). That rewrites the lockfile settings, and the frozen install then fails. npm and pnpm never publish `.npmrc`. - `scripts/typecheck-sandbox-providers/package.json`: add `"packageManager": "[email protected].4"`, the version CI uses. Without it, Dependabot guesses pnpm 10. - `doc/DEVELOPING.md`: say that contributors do not refresh or commit Daytona's lockfile either, and that a new provider goes in both lists. No provider moves into the root workspace. There is no pnpm catalog. The root `pnpm-lock.yaml` does not change. ## Verification I ran the real Dependabot updater on this branch with the Dependabot CLI (`dependabot update ... --local`, updater image `dependabot-updater-npm@sha256:c5a9451c…`): - Before the change, the workspace entry loaded only `cloudflare/bridge-template` (the one path without a glob). It skipped all the other providers. - After the change, `--dep typescript` gave one pull request. It changed the 9 non-Daytona provider `package.json` files and the shared `pnpm-lock.yaml`, and did not touch Daytona. - `--dep wrangler` changed only the wrangler closure in the shared lockfile. Before the `.npmrc` fix, the same run changed `autoInstallPeers` to `false` and 757 lockfile lines. - The Daytona entry (`--dep @daytonaio/sdk`) changed Daytona's `package.json` and its own lockfile in one pull request, and kept `autoInstallPeers: true`. It keeps the exact pin exact: `0.203.0` becomes `0.227.3`. - Both frozen installs (shared workspace, and Daytona with `--ignore-workspace`) pass with pnpm 9.15.4. - Dependabot installed `[email protected].4` from the new `packageManager` field. Other checks: - `.github/dependabot.yml` passes the SchemaStore `dependabot-2.0` schema (Ajv). A wrong key fails the same check. - With pnpm 9.15.4, `install --resolution-only --ignore-scripts --no-frozen-lockfile` in `scripts/typecheck-sandbox-providers` (the lockfile bot's command) gives exactly the lockfile that passed the frozen installs earlier in this pull request. - The lockfile rule from #19522 allows this pull request's diff: it changes no lock content. - `node --test scripts/typecheck-test-programs.test.mjs`: the new test and the provider tests pass. Two other tests need a root `node_modules` (`tsc`), which my worktree did not have. CI runs them. These checks cannot run before merge. GitHub reads `dependabot.yml` only from the default branch. After merge, open Insights → Dependency graph → Dependabot and make sure that the two new entries show no errors and run on Monday. ## Risks - A dependency in both a non-Daytona provider and Daytona (for example `typescript`) gets two pull requests: one from each entry. Each one passes alone, and they can merge in any order. - The typecheck job now typechecks Daytona's tests against Daytona's own lockfile instead of the shared one. Its versions are a little older (for example vitest 4.1.11), so a type difference can show up there. - Majors of shared dev tools (for example TypeScript 7, Vitest 5) come as one pull request across all providers. It can need code changes before it passes. That is the same as the root entry. - Pi and Kimi get no version updates. Every direct dependency there is a pinned provider release, and the pins feed the provider's ACPX `commandDigest`. The Runner checks compare the Pi lock with `PI_DISTRIBUTION_PINS`, and the Kimi build compares the lock SHA-256 with a pin. So any Dependabot change there fails CI (see #18998), and a real upgrade starts saved sessions fresh. GitHub security alerts still cover both lockfiles. - The Pi zod `3.25.76` pin is still affected by GHSA-mrhx-c7xc-m3w5 (open alert). It is deliberate: `[email protected]` has `zod ^3.25.0` as a direct dependency, not a peer. A fix needs a `pi-acp` release that allows zod 4. This pull request does not change it. - `packages/plugins/examples/plugin-orchestration-smoke-example` is also outside the root workspace. It uses `workspace:*` dependencies, so it cannot install alone, and this pull request does not cover it. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, local Docker runs of the Dependabot CLI). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
248 lines
9.9 KiB
JavaScript
248 lines
9.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
checkCoverage,
|
|
findUncheckedTests,
|
|
LOCKED_PROVIDERS,
|
|
PROGRAMS,
|
|
PROVIDER_WORKSPACE,
|
|
RESOLVE_ARGS,
|
|
SANDBOX_PROVIDERS,
|
|
typecheckTestPrograms,
|
|
} from "./typecheck-test-programs.mjs";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const tsc = path.join(root, "node_modules", "typescript", "bin", "tsc");
|
|
|
|
const harness = (overrides = {}) => {
|
|
const calls = { pnpm: [], tsc: [], linked: 0, errors: [] };
|
|
return {
|
|
calls,
|
|
options: {
|
|
root: "/repo",
|
|
programs: ["a/tsconfig.test.json", "b/tsconfig.test.json"],
|
|
pnpm: (args, { cwd }) => {
|
|
calls.pnpm.push({ args, cwd });
|
|
return 0;
|
|
},
|
|
linkPlugins: () => {
|
|
calls.linked += 1;
|
|
},
|
|
run: (args) => {
|
|
calls.tsc.push(args);
|
|
return { status: 0 };
|
|
},
|
|
checkFiles: () => [],
|
|
log: () => {},
|
|
logError: (message) => calls.errors.push(message),
|
|
...overrides,
|
|
},
|
|
};
|
|
};
|
|
|
|
test("findUncheckedTests reports only test files outside programs and other checks", () => {
|
|
assert.deepEqual(
|
|
findUncheckedTests({
|
|
trackedFiles: ["a/x.test.ts", "a/y.spec.tsx", "a/z.test.tsx", "a/helper.ts", "a/n.test.mjs", "server/src/s.test.ts"],
|
|
checkedFiles: ["a/x.test.ts", "a/y.spec.tsx"],
|
|
checkedElsewhere: ["server/src/"],
|
|
}),
|
|
["a/z.test.tsx"],
|
|
);
|
|
});
|
|
|
|
test("every program exists, and the provider list matches the tracked provider packages", () => {
|
|
for (const program of PROGRAMS) assert.ok(existsSync(path.join(root, program)), program);
|
|
assert.ok(existsSync(path.join(root, PROVIDER_WORKSPACE, "pnpm-lock.yaml")));
|
|
const tracked = execFileSync("git", ["ls-files", "packages/plugins/sandbox-providers"], { cwd: root, encoding: "utf8" });
|
|
const manifests = tracked.split("\n").filter((file) => file.endsWith("/package.json"));
|
|
assert.deepEqual(manifests.map((manifest) => path.dirname(manifest)).sort(), [...SANDBOX_PROVIDERS].sort());
|
|
});
|
|
|
|
test("the provider workspace lists every provider without its own lockfile by full path, so Dependabot sees them", () => {
|
|
const workspace = readFileSync(path.join(root, PROVIDER_WORKSPACE, "pnpm-workspace.yaml"), "utf8");
|
|
const listed = [...workspace.matchAll(/^\s+-\s+(\S+)\s*$/gm)].map((match) => match[1]);
|
|
assert.ok(listed.every((entry) => !entry.includes("*")), "Dependabot does not expand a glob that starts with ../");
|
|
const providers = listed.map((entry) => path.posix.normalize(path.posix.join(PROVIDER_WORKSPACE, entry)));
|
|
const unlocked = SANDBOX_PROVIDERS.filter((provider) => !LOCKED_PROVIDERS.includes(provider));
|
|
assert.deepEqual(providers.sort(), unlocked.sort());
|
|
});
|
|
|
|
test("each provider with its own lockfile is a provider and has that lockfile", () => {
|
|
for (const provider of LOCKED_PROVIDERS) {
|
|
assert.ok(SANDBOX_PROVIDERS.includes(provider), provider);
|
|
assert.ok(existsSync(path.join(root, provider, "pnpm-lock.yaml")), provider);
|
|
}
|
|
});
|
|
|
|
test("no provider commits a lockfile, except the one Daytona already has", () => {
|
|
const tracked = execFileSync("git", ["ls-files", "packages/plugins/sandbox-providers"], { cwd: root, encoding: "utf8" });
|
|
assert.deepEqual(
|
|
tracked.split("\n").filter((file) => file.endsWith("/pnpm-lock.yaml")),
|
|
["packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml"],
|
|
);
|
|
});
|
|
|
|
test("a type error in one program still checks the rest and fails the run", () => {
|
|
const { options, calls } = harness({
|
|
run: (args) => {
|
|
calls.tsc.push(args);
|
|
return { status: args[1].startsWith("a/") ? 2 : 0 };
|
|
},
|
|
});
|
|
assert.equal(typecheckTestPrograms(options), 2);
|
|
assert.deepEqual(calls.tsc, [
|
|
["-p", "a/tsconfig.test.json"],
|
|
["-p", "b/tsconfig.test.json"],
|
|
]);
|
|
});
|
|
|
|
test("a killed compiler stops the run", () => {
|
|
const { options, calls } = harness({
|
|
run: (args) => {
|
|
calls.tsc.push(args);
|
|
return { status: null, signal: "SIGKILL" };
|
|
},
|
|
});
|
|
assert.equal(typecheckTestPrograms(options), 1);
|
|
assert.equal(calls.tsc.length, 1);
|
|
});
|
|
|
|
test("providers install frozen without scripts, then relink the SDK", () => {
|
|
const { options, calls } = harness();
|
|
assert.equal(typecheckTestPrograms(options), 0);
|
|
const install = calls.pnpm.find(({ cwd }) => cwd === path.join("/repo", PROVIDER_WORKSPACE));
|
|
assert.ok(install.args.includes("--frozen-lockfile"));
|
|
assert.ok(install.args.includes("--ignore-scripts"));
|
|
assert.equal(calls.linked, 1);
|
|
});
|
|
|
|
test("a locked provider installs frozen from its own lockfile, outside any workspace", () => {
|
|
const { options, calls } = harness();
|
|
assert.equal(typecheckTestPrograms(options), 0);
|
|
for (const provider of LOCKED_PROVIDERS) {
|
|
const install = calls.pnpm.find(({ cwd }) => cwd === path.join("/repo", provider));
|
|
assert.ok(install.args.includes("--ignore-workspace"));
|
|
assert.ok(install.args.includes("--frozen-lockfile"));
|
|
assert.ok(install.args.includes("--ignore-scripts"));
|
|
}
|
|
});
|
|
|
|
// Each directory's first frozen install fails; resolution and the second frozen install pass.
|
|
const staleOnce = (directory, calls) => {
|
|
const cwd = path.join("/repo", directory);
|
|
let frozenAttempts = 0;
|
|
return (args, options) => {
|
|
calls.pnpm.push({ args, cwd: options.cwd });
|
|
if (options.cwd !== cwd || !args.includes("--frozen-lockfile")) return 0;
|
|
frozenAttempts += 1;
|
|
return frozenAttempts === 1 ? 1 : 0;
|
|
};
|
|
};
|
|
const installSteps = (calls, directory) =>
|
|
calls.pnpm
|
|
.filter(({ cwd }) => cwd === path.join("/repo", directory))
|
|
.map(({ args }) => (args.includes("--frozen-lockfile") ? "frozen" : args));
|
|
|
|
test("a stale provider lockfile is resolved in place, then installed frozen", () => {
|
|
const { options, calls } = harness();
|
|
options.pnpm = staleOnce(PROVIDER_WORKSPACE, calls);
|
|
assert.equal(typecheckTestPrograms(options), 0);
|
|
assert.deepEqual(installSteps(calls, PROVIDER_WORKSPACE), ["frozen", RESOLVE_ARGS, "frozen"]);
|
|
assert.ok(RESOLVE_ARGS.includes("--resolution-only") && RESOLVE_ARGS.includes("--ignore-scripts"));
|
|
assert.equal(calls.tsc.length, 2);
|
|
});
|
|
|
|
test("a stale locked-provider lockfile is resolved in place, outside any workspace", () => {
|
|
const [provider] = LOCKED_PROVIDERS;
|
|
const { options, calls } = harness();
|
|
options.pnpm = staleOnce(provider, calls);
|
|
assert.equal(typecheckTestPrograms(options), 0);
|
|
const [, resolve] = installSteps(calls, provider);
|
|
assert.deepEqual(installSteps(calls, provider), ["frozen", resolve, "frozen"]);
|
|
for (const flag of ["--ignore-workspace", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"]) {
|
|
assert.ok(resolve.includes(flag), flag);
|
|
}
|
|
assert.equal(calls.tsc.length, 2);
|
|
});
|
|
|
|
test("a locked provider install that still fails stops before any program runs", () => {
|
|
const [provider] = LOCKED_PROVIDERS;
|
|
const { options, calls } = harness({
|
|
pnpm: (args, { cwd }) => (cwd === path.join("/repo", provider) ? 1 : 0),
|
|
});
|
|
assert.equal(typecheckTestPrograms(options), 1);
|
|
assert.equal(calls.tsc.length, 0);
|
|
assert.ok(calls.errors[0].includes(provider), calls.errors[0]);
|
|
});
|
|
|
|
test("a provider install that still fails stops before any program runs", () => {
|
|
const { options, calls } = harness({
|
|
pnpm: (args, { cwd }) => (cwd === path.join("/repo", PROVIDER_WORKSPACE) ? 1 : 0),
|
|
});
|
|
assert.equal(typecheckTestPrograms(options), 1);
|
|
assert.equal(calls.tsc.length, 0);
|
|
assert.match(calls.errors[0], /could not install the sandbox providers/);
|
|
});
|
|
|
|
test("an uncovered test file fails the run and is named", () => {
|
|
const { options, calls } = harness({ checkFiles: () => ["a/z.test.tsx"] });
|
|
assert.equal(typecheckTestPrograms(options), 1);
|
|
assert.match(calls.errors[0], /a\/z\.test\.tsx/);
|
|
});
|
|
|
|
test("the coverage check fails closed when a program cannot be listed", () => {
|
|
const { options } = harness({ checkFiles: () => null });
|
|
assert.equal(typecheckTestPrograms(options), 1);
|
|
});
|
|
|
|
test("naming programs skips the coverage check", () => {
|
|
let checked = false;
|
|
const { options } = harness({
|
|
coverage: false,
|
|
checkFiles: () => {
|
|
checked = true;
|
|
return [];
|
|
},
|
|
});
|
|
assert.equal(typecheckTestPrograms(options), 0);
|
|
assert.equal(checked, false);
|
|
});
|
|
|
|
test("checkCoverage uses the compiler's file list, so a dropped sibling is caught", () => {
|
|
const directory = mkdtempSync(path.join(root, "node_modules", ".typecheck-test-programs-"));
|
|
try {
|
|
const relative = path.relative(root, directory).split(path.sep).join("/");
|
|
writeFileSync(path.join(directory, "foo.test.ts"), "export {};\n");
|
|
writeFileSync(path.join(directory, "foo.test.tsx"), "export {};\n");
|
|
writeFileSync(path.join(directory, "tsconfig.json"), JSON.stringify({ compilerOptions: { noEmit: true, jsx: "preserve" }, include: ["*"] }));
|
|
const unchecked = checkCoverage({
|
|
root,
|
|
programs: [`${relative}/tsconfig.json`],
|
|
workspaceConfigs: () => [],
|
|
trackedFiles: () => [`${relative}/foo.test.ts`, `${relative}/foo.test.tsx`],
|
|
});
|
|
assert.deepEqual(unchecked, [`${relative}/foo.test.tsx`]);
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("a real type error in a test-style program makes tsc exit non-zero", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "typecheck-test-programs-"));
|
|
try {
|
|
writeFileSync(path.join(directory, "bad.test.ts"), "export const value: number = 'text';\n");
|
|
writeFileSync(path.join(directory, "tsconfig.json"), JSON.stringify({ compilerOptions: { noEmit: true, types: [] }, include: ["*.ts"] }));
|
|
const { status, stdout } = spawnSync(process.execPath, [tsc, "-p", directory, "--pretty", "false"], { encoding: "utf8" });
|
|
assert.notEqual(status, 0);
|
|
assert.match(stdout, /bad\.test\.ts.*TS2322/);
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|