a4a5010441
* gemini-3: default thinkingLevel to medium + don't `npm ci` without a lockfile upstream opencode hardcodes `thinkingLevel: "high"` for every gemini-3 model on the direct google SDK (see `packages/opencode/src/provider/transform.ts` `options()`). that added 30-60s of pre-tool-call TTFT and 5-46s of post-tool jabber per turn, which is overkill for the tool-routing decisions that dominate agentic loops — and the variance caused the `providers-live (google/gemini-pro)` smoke job to time out at 4 minutes (see job 75405504847 on run 25684766415). three changes: - inject `provider.google.models.<api-id>.options.thinkingConfig.thinkingLevel = "medium"` for the two curated gemini-3 slugs in `buildSecurityConfig`. deep-merges over the upstream default; explicit `--variant high` / user opencode config still wins. flash stays at medium too — low-effort flash is visibly worse and the latency win isn't meaningful (flash is already fast). - bump the `providers-live` harness step from 4 → 6 minutes. the job-level 8-minute cap stays as the upper bound, but gemini's intrinsic TTFT variance was eating most of the 4-minute slack on its own. - in `installNodeDependencies`, pick `frozen` only when a lockfile was actually detected. previously a package.json-only repo (like the smoke fixture's `pullfrog/test-repo`) always triggered `npm ci` and emitted a noisy `EUSAGE` error before falling through. * prep: skip eager install when neither lockfile nor `packageManager` field present the previous commit changed the no-lockfile path from `npm ci` (always errored `EUSAGE`, never wrote any artifact) to a successful `npm install`, which had an unintended side effect: it generated `package-lock.json` in the working tree, tripping the post-run dirty-tree gate. the agent then committed the lockfile and opened a real PR — and in the openai/gpt smoke run on PR #663, the agent overwrote the `SMOKE TEST PASSED` output with the PR URL, failing the smoke validator. a repo with `package.json` but no lockfile and no `packageManager` field has not committed dependency state. eagerly installing produces state the repo doesn't track, which is the dirty-tree problem above. skip the eager install entirely in that case; the agent can opt in via `await_dependency_installation` when it actually needs deps. repos with a lockfile or a `packageManager` field keep the existing frozen-install behavior unchanged. * post-run: suppress dirty-tree gate in non-committing modes (Review / IncrementalReview / Plan) the dirty-tree post-run gate currently fires for every mode and tells the agent to commit and push whatever is in the working tree. that's wrong for modes that complete by submitting a review (`Review` / `IncrementalReview`) or posting a Plan comment (`Plan`) — those modes never touch files as part of their contract, so any tree dirt at end-of-run is incidental tool noise on an ephemeral worktree. nudging the agent to commit it can produce a spurious PR, as seen in the openai/gpt smoke run on PR #663 where a stray `package-lock.json` from `npm install` led the agent to open pullfrog/test-repo#32 and overwrite the smoke output. introduce `NON_COMMITTING_MODES` in `action/modes.ts` and consult it in `collectPostRunIssues`. when the selected mode is read-only, log the suppression for visibility but skip populating `issues.dirtyTree`. modes that legitimately commit (`Build`, `AddressReviews`, `Fix`, `ResolveConflicts`, `Task`) keep the existing nudge. * prep: restore eager frozen-install, drop non-frozen fallback eager dependency prep is non-mutating by contract — it runs before the agent starts and any artifact it leaves in the tree (e.g. a generated `package-lock.json`) trips the dirty-tree post-run gate and can lead the agent to open a spurious PR (seen on the openai/gpt smoke run earlier in this PR). revert the previous skip-when-no-lockfile branch: that was the wrong layer to enforce the invariant. instead, run `frozen` (`npm ci` / `pnpm install --frozen-lockfile` / etc.) unconditionally and drop the `|| install` fallback that could silently mutate the tree when `frozen` is missing. frozen commands fail cleanly without writing artifacts when there's no lockfile, which is exactly the safety contract we want. repos that need a real install must opt in explicitly via a `setup` lifecycle hook. * review nits: single getGitStatus call, tighten gemini-3 override scope comment addresses two inline nits from the PR review: - `collectPostRunIssues` was calling `getGitStatus()` (spawns `git status --porcelain`) in both branches of the mode check. lift the call above the conditional and branch on the result; same behavior, one git invocation. - the JSDoc on `GEMINI_3_DIRECT_API_IDS` said the override applies "across the board," but the constant only covers the two curated slugs in `action/models.ts`. tighten the wording to call out that other gemini-3 ids in models.dev keep the upstream "high" default. skipped the bot's yarn-1 concern after reading yarn 1's `install.js`: `bailout()` (lines 461-465) throws `frozenLockfileError` when `frozenLockfile && (!lockfileClean || missingPatterns.length > 0)`, which fires before `linker.init()` writes node_modules or runs lifecycle scripts. the existing comment's claim that frozen commands fail without artifacts holds for yarn 1 too.
196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { isKeyOf } from "@ark/util";
|
|
import { detect } from "package-manager-detector";
|
|
import { resolveCommand } from "package-manager-detector/commands";
|
|
import { log } from "../utils/cli.ts";
|
|
import { spawn } from "../utils/subprocess.ts";
|
|
import type { NodePackageManager, NodePrepResult, PrepDefinition, PrepOptions } from "./types.ts";
|
|
|
|
// install command templates for each package manager (version placeholder: {version})
|
|
const nodePackageManagers: Record<NodePackageManager, string[]> = {
|
|
npm: ["echo", "npm is already installed"],
|
|
pnpm: ["npm", "install", "-g", "{version}"],
|
|
yarn: ["npm", "install", "-g", "{version}"],
|
|
bun: ["npm", "install", "-g", "{version}"],
|
|
deno: ["sh", "-c", "curl -fsSL https://deno.land/install.sh | sh"],
|
|
};
|
|
|
|
async function isCommandAvailable(command: string): Promise<boolean> {
|
|
const result = await spawn({
|
|
cmd: "which",
|
|
args: [command],
|
|
env: { PATH: process.env.PATH || "" },
|
|
});
|
|
return result.exitCode === 0;
|
|
}
|
|
|
|
interface PackageManagerSpec {
|
|
name: NodePackageManager;
|
|
installSpec: string; // e.g., "pnpm@8.15.0" (without hash suffix)
|
|
}
|
|
|
|
function getPackageManagerFromPackageJson(): PackageManagerSpec | null {
|
|
const packageJsonPath = join(process.cwd(), "package.json");
|
|
try {
|
|
const content = readFileSync(packageJsonPath, "utf-8");
|
|
const pkg = JSON.parse(content) as { packageManager?: string };
|
|
if (!pkg.packageManager) return null;
|
|
|
|
// format: "pnpm@8.15.0" or "pnpm@8.15.0+sha512.abc123..."
|
|
// strip the hash suffix (+sha256.xxx) as npm install doesn't understand it
|
|
const withoutHash = pkg.packageManager.split("+")[0];
|
|
const name = withoutHash.split("@")[0];
|
|
if (isKeyOf(name, nodePackageManagers)) {
|
|
return { name, installSpec: withoutHash };
|
|
}
|
|
log.warning(`unknown packageManager in package.json: ${pkg.packageManager}`);
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function installPackageManager(
|
|
name: NodePackageManager,
|
|
installSpec: string
|
|
): Promise<string | null> {
|
|
if (name === "npm") return null; // npm is always available
|
|
log.info(`» installing ${installSpec}...`);
|
|
const [cmd, ...templateArgs] = nodePackageManagers[name];
|
|
const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg));
|
|
const result = await spawn({
|
|
cmd,
|
|
args,
|
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
|
onStderr: (chunk) => process.stderr.write(chunk),
|
|
});
|
|
|
|
if (result.exitCode !== 0) {
|
|
return result.stderr || `failed to install ${name}`;
|
|
}
|
|
|
|
// deno installs to $HOME/.deno/bin - add to PATH for subsequent commands
|
|
if (name === "deno") {
|
|
const denoPath = join(process.env.HOME || "", ".deno", "bin");
|
|
process.env.PATH = `${denoPath}:${process.env.PATH}`;
|
|
}
|
|
|
|
log.info(`» installed ${name}`);
|
|
return null;
|
|
}
|
|
|
|
export const installNodeDependencies: PrepDefinition = {
|
|
name: "installNodeDependencies",
|
|
|
|
shouldRun: () => {
|
|
const packageJsonPath = join(process.cwd(), "package.json");
|
|
return existsSync(packageJsonPath);
|
|
},
|
|
|
|
run: async (options: PrepOptions): Promise<NodePrepResult> => {
|
|
// check packageManager field in package.json first (takes priority)
|
|
const fromPackageJson = getPackageManagerFromPackageJson();
|
|
|
|
// detect from lockfile as fallback
|
|
const detected = await detect({ cwd: process.cwd() });
|
|
|
|
// prefer package.json field, fall back to lockfile detection, default to npm
|
|
const packageManager = fromPackageJson?.name || (detected?.name as NodePackageManager) || "npm";
|
|
const installSpec = fromPackageJson?.installSpec || packageManager;
|
|
const agent = detected?.agent || packageManager;
|
|
|
|
if (fromPackageJson) {
|
|
log.info(`» using packageManager from package.json: ${fromPackageJson.installSpec}`);
|
|
} else if (detected) {
|
|
log.info(`» detected package manager: ${packageManager} (${agent})`);
|
|
} else {
|
|
log.info(`» no package manager detected, defaulting to npm`);
|
|
}
|
|
|
|
// check if package manager is available, install if needed
|
|
if (!(await isCommandAvailable(packageManager))) {
|
|
// SECURITY: when shell is disabled, don't install package managers.
|
|
// installPackageManager runs `npm install -g` or `curl | sh` (for deno),
|
|
// both of which execute code. the package manager must already be available.
|
|
if (options.ignoreScripts) {
|
|
return {
|
|
language: "node",
|
|
packageManager,
|
|
dependenciesInstalled: false,
|
|
issues: [
|
|
`${packageManager} is not available and cannot be installed when shell is disabled (would execute code)`,
|
|
],
|
|
};
|
|
}
|
|
log.info(`» ${packageManager} not found, attempting to install...`);
|
|
const installError = await installPackageManager(packageManager, installSpec);
|
|
if (installError) {
|
|
return {
|
|
language: "node",
|
|
packageManager,
|
|
dependenciesInstalled: false,
|
|
issues: [installError],
|
|
};
|
|
}
|
|
}
|
|
|
|
// frozen-lockfile install only. eager prep is non-mutating by contract:
|
|
// we run it before the agent starts and any artifact it leaves in the
|
|
// tree (e.g. a generated `package-lock.json`) trips the dirty-tree
|
|
// post-run gate and produces a spurious PR. `frozen` commands
|
|
// (`npm ci`, `pnpm install --frozen-lockfile`, etc.) fail cleanly
|
|
// without modifying state when there's no lockfile, which is exactly
|
|
// what we want — repos that need a non-frozen install must opt in via
|
|
// a `setup` lifecycle hook (`action/utils/lifecycle.ts`).
|
|
const resolved = resolveCommand(agent, "frozen", []);
|
|
if (!resolved) {
|
|
return {
|
|
language: "node",
|
|
packageManager,
|
|
dependenciesInstalled: false,
|
|
issues: [`no frozen-install command available for ${agent}`],
|
|
};
|
|
}
|
|
|
|
// SECURITY: when shell is disabled, suppress lifecycle scripts to prevent
|
|
// agents from injecting arbitrary code execution via package.json scripts
|
|
if (options.ignoreScripts) {
|
|
resolved.args.push("--ignore-scripts");
|
|
log.info("» --ignore-scripts enabled (shell disabled)");
|
|
}
|
|
|
|
const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`;
|
|
log.info(`» running: ${fullCommand}`);
|
|
const result = await spawn({
|
|
cmd: resolved.command,
|
|
args: resolved.args,
|
|
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "" },
|
|
});
|
|
|
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
if (output) {
|
|
log.startGroup(`${fullCommand} output`);
|
|
log.info(output);
|
|
log.endGroup();
|
|
}
|
|
|
|
if (result.exitCode !== 0) {
|
|
const errorMessage = output || `exited with code ${result.exitCode}`;
|
|
return {
|
|
language: "node",
|
|
packageManager,
|
|
dependenciesInstalled: false,
|
|
issues: [`\`${fullCommand}\` failed:\n${errorMessage}`],
|
|
};
|
|
}
|
|
|
|
return {
|
|
language: "node",
|
|
packageManager,
|
|
dependenciesInstalled: true,
|
|
issues: [],
|
|
};
|
|
},
|
|
};
|