diff --git a/scripts/__tests__/public-install-registry.test.mjs b/scripts/__tests__/public-install-registry.test.mjs index 5669ed5f461..1edd1ff7573 100644 --- a/scripts/__tests__/public-install-registry.test.mjs +++ b/scripts/__tests__/public-install-registry.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; +import { grokPublicInstallVersion } from '../grok-public-install-sandbox.mjs'; import { startPackedRegistry, writePackedRegistryCatalog } from '../public-install-registry.mjs'; const script = fileURLToPath(new URL('../public-install-registry.mjs', import.meta.url)); @@ -15,8 +16,8 @@ const scratch = async body => { }; const environment = root => ({ ...process.env, HOME: root, npm_config_cache: join(root, 'cache'), npm_config_userconfig: join(root, 'no-npmrc'), npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_fetch_retries: '0', npm_config_fetch_timeout: '10000' }); -const run = (command, args, cwd) => new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd, env: environment(cwd), stdio: ['ignore', 'pipe', 'pipe'] }); +const run = (command, args, cwd, env = environment(cwd)) => new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { output += chunk; }); child.once('error', reject); @@ -59,6 +60,37 @@ const installedFiles = directory => { return entries.sort(([a], [b]) => a.localeCompare(b)); }; +test('synthetic Grok versions install from packed bytes even when the hash starts with leading-zero digits', { timeout: 30_000 }, async () => scratch(async root => { + const sourceRevision = '096091501644cf20f2e35d1ab02b28c35579b973'; + const cases = [ + { version: `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`, rejected: true }, + { version: grokPublicInstallVersion(sourceRevision) }, + { version: grokPublicInstallVersion('8fd48a8d52038fd0d8b286f3742ea13ce40a7ab9') }, + ]; + for (const [index, { version, rejected }] of cases.entries()) { + const cwd = join(root, String(index)), source = join(cwd, 'source'), assets = join(cwd, 'assets'), consumer = join(cwd, 'consumer'); + for (const directory of [join(source, 'package'), assets, consumer]) mkdirSync(directory, { recursive: true }); + writeFileSync(join(source, 'package/package.json'), JSON.stringify({ name: '@ci/grok-version', version })); + execFileSync('tar', ['-czf', join(assets, 'package.tgz'), '-C', source, 'package']); + await writePackedRegistryCatalog(assets); + writeFileSync(join(consumer, 'package.json'), '{"private":true}'); + const userconfig = join(cwd, 'user.npmrc'), globalconfig = join(cwd, 'global.npmrc'); + writeFileSync(userconfig, ''); writeFileSync(globalconfig, ''); + const env = { PATH: process.env.PATH, HOME: cwd, npm_config_cache: join(cwd, 'cache'), + npm_config_userconfig: userconfig, npm_config_globalconfig: globalconfig, + npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_fetch_retries: '0' }; + const registry = await startPackedRegistry(assets); + try { + const install = run('npm', ['install', '--registry', registry.url, '--replace-registry-host=never', '--ignore-scripts', '--omit=dev', ...registry.specs], consumer, env); + if (rejected) await assert.rejects(install, /ETARGET/); + else { + await install; + assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules/@ci/grok-version/package.json'))).version, version); + } + } finally { await registry.close(); } + } +})); + test('real npm installs identical packed bytes, bundles, optional dependencies and bins without running scripts early', { timeout: 90_000 }, async () => scratch(async root => { const assets = fixture(root, { publicDependency: true }), catalog = await writePackedRegistryCatalog(assets); assert.ok(catalog.every(entry => entry.manifest.version === '1.0.0'), 'metadata must come from packed bytes, not changed source'); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 53a3e270577..e7d752b6ea0 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -11,6 +11,12 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; +export function grokPublicInstallVersion(sourceRevision) { + assert.match(sourceRevision, /^[a-f0-9]{40}$/); + // An all-numeric hash with a leading zero is not a valid numeric prerelease. + return `0.0.0-grok-verify.g${sourceRevision.slice(0, 12)}`; +} + export function runMacInitialInstallWithDiagnostics(install, { sourceRevision, platform, architecture, cacheDirectory, receiptPath }) { const started = performance.now(); try { diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 8efa4beef23..9fa2c7eb33c 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url'; import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs'; import { materializePublishManifest } from './prepare-bundled-package.mjs'; import { publicInstallClosure } from './public-install-closure.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, grokPublicInstallVersion, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs'; import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'; import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs'; @@ -204,7 +204,7 @@ mkdirSync(cache, { recursive: true }); const env = { ...process.env, NODE_PATH: '', ...(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? { npm_config_cache: cache } : {}), PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' }; const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 }); const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim(); -const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`; +const releaseVersion = grokPublicInstallVersion(sourceRevision); // The two network downloads the probes need do not depend on anything packed // below, so they overlap the CPU-bound staging instead of running after it. // The consumer image is pinned by digest and the Grok binary is checked against diff --git a/server/src/__tests__/onboarding-first-task-admission.test.ts b/server/src/__tests__/onboarding-first-task-admission.test.ts index 39759102f07..5a8fd2087a6 100644 --- a/server/src/__tests__/onboarding-first-task-admission.test.ts +++ b/server/src/__tests__/onboarding-first-task-admission.test.ts @@ -1,23 +1,41 @@ import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db"; +import { agentWakeupRequests, authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db"; +import { createIssueThreadInteractionSchema } from "@paperclipai/shared"; import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { documentService } from "../services/documents.js"; import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; -import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js"; +import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js"; import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; -const support = await getEmbeddedPostgresTestSupport(); +async function nativeProposalRecipe(kind: "confirmation" | "checkbox", planRevisionId = randomUUID()) { + const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8"); + const recipes = [...skill.matchAll(/^ {0,3}```json\n([\s\S]*?)^ {0,3}```/gm)].map(match => JSON.parse(match[1]!) as Record); + const matches = recipes.filter(recipe => recipe.interactionKind === kind); + expect(matches, `one executable native ${kind} recipe must ship with the first-task skill`).toHaveLength(1); + return JSON.parse(JSON.stringify(matches[0]).replaceAll("", "Approved child") + .replaceAll("", planRevisionId)) as Record; +} + +it.each(["confirmation", "checkbox"] as const)("ships a native %s recipe accepted by the production interaction schema", async kind => { + const recipe = await nativeProposalRecipe(kind); + const parsed = createIssueThreadInteractionSchema.parse({ kind: kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation", + idempotencyKey: recipe.idempotencyKey, title: recipe.title, continuationPolicy: recipe.continuationPolicy, + payload: { ...(recipe.payload as Record), version: 1, prompt: recipe.prompt } }); + expect(parsed.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation"); +}); + if (!support.supported) console.warn(`Skipping first-task admission database tests: ${support.reason}`); describe.skipIf(!support.supported)("onboarding first-task execution admission", () => { let server: Awaited>; const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); - server = await startRunnerApiTestServer(); + server = await startRunnerApiTestServer({ humanSessions: true }); }, 60_000); afterAll(async () => { await server?.close(); @@ -86,6 +104,72 @@ describe.skipIf(!support.supported)("onboarding first-task execution admission", await p.svc.acceptInteraction(p.issue, p.card.id, p.plan ? { selectedOptionIds: ["execute"] } : {}, { userId: f.responsibleUserId! }); }; + it("an explicit Approve/Revise question remains input rather than proposal authority after the human answers", async () => { + const f = await fixture(); + const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort(); + const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: { + idempotencyKey: "first-task-proposal-1", interactionKind: "questions", title: "Approve child task", + prompt: "I propose exactly one child task and will wait for approval.", continuationPolicy: "wake_assignee", + payload: { questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "approve_child_task", + prompt: 'Approve exactly one child task named "Approved child"?', required: true, answerMode: "single_select", + options: [{ id: "approve", label: "Approve" }, { id: "revise", label: "Revise" }] }] } }, + } }) as { interaction: { id: string; kind: string } }; + expect(interaction.kind).toBe("ask_user_questions"); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + const answered = await issueThreadInteractionService(server.db).answerQuestions({ id: f.issueId, companyId: f.companyId }, interaction.id, + { answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] }, { userId: f.responsibleUserId! }); + expect(answered).toMatchObject({ status: "answered", resolvedByUserId: f.responsibleUserId, + result: { answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] } }); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds); + }); + + it.each(["confirmation", "checkbox"] as const)("the shipped native %s recipe requires a human decision before it admits child work", async kind => { + for (const actor of ["human", "agent"] as const) { + const f = await fixture(); + const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", + title: "Approved child plan", format: "markdown", body: "Create one child for the proposed deliverable.", + createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null; + const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined); + const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string; kind: string } }; + expect(interaction.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation"); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + const selectedOptionIds = kind === "checkbox" ? ((args.payload as { options: { id: string }[] }).options.map(option => option.id)) : undefined; + await issueThreadInteractionService(server.db).acceptInteraction({ id: f.issueId, companyId: f.companyId }, interaction.id, + selectedOptionIds ? { selectedOptionIds } : {}, actor === "human" ? { userId: f.responsibleUserId! } : { agentId: f.agentId, runId: f.runId }); + if (actor === "human") await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } }); + else await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + } + }); + + it.each(["confirmation", "checkbox"] as const)("rejecting the shipped native %s recipe saves an assignee continuation without authorizing child work", async kind => { + const f = await fixture(); + const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort(); + const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", + title: "Proposed child plan", format: "markdown", body: "Create one child for the proposed deliverable.", + createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null; + const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined); + const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string } }; + const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/interactions/${interaction.id}/reject`, { + method: "POST", headers: { cookie: await server.humanSession(f.responsibleUserId!), "Content-Type": "application/json", origin: server.apiUrl }, + body: JSON.stringify({ reason: "Please revise the proposed scope." }), + }); + const rejected = await response.json(); + expect(rejected, JSON.stringify(rejected)).toMatchObject({ status: "rejected", continuationPolicy: "wake_assignee", + resolvedByUserId: f.responsibleUserId, result: { outcome: "rejected", reason: "Please revise the proposed scope." } }); + expect(response.status).toBe(200); + const wakes = (await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))) + .filter(wake => wake.payload?.interactionId === interaction.id); + expect(wakes).toHaveLength(1); + expect(wakes[0]).toMatchObject({ agentId: f.agentId, requestedByActorType: "user", requestedByActorId: f.responsibleUserId, + payload: { issueId: f.issueId, interactionId: interaction.id, interactionStatus: "rejected", + paperclipAgentMessage: { text: "Please revise the proposed scope.", source: "interaction_rejection" } } }); + // The fixture disables execution: observe the real durable wake, not a model call. + expect((await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).map(run => run.id)).toEqual([f.runId]); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds); + }); + it.each([false, true])("admits persisted human proposal acceptance (checkbox=%s)", async checkbox => { const f = await fixture(); const p = await proposal(f, checkbox); diff --git a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md index b69019bf93b..dffbc7f3186 100644 --- a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md +++ b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md @@ -35,8 +35,45 @@ Work in this order. - If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Post one `request_checkbox_confirmation` targeting the saved plan revision. A card or thread message alone is not a saved plan. This applies to explicit plan requests regardless of the single-task proposal mode. Proposing a team does not authorize hiring it. - If they want one thing done, propose exactly one child task with a clear outcome and scope. Ask them to accept it before creating the child. Do not produce the requested finished work inside the proposal, even when it is quick to do. - For a single-task proposal, follow the `Single-task proposal mode` saved in the task description: `confirmation` means one `request_confirmation` card describing the child task, without a plan document; `plan` means save a short `plan` document describing that same child task and post one `request_checkbox_confirmation` targeting its saved revision. + - With native tools, create those proposal cards using `request_human_input`: `interactionKind: "confirmation"` creates `request_confirmation`; `interactionKind: "checkbox"` creates `request_checkbox_confirmation`. Use `continuationPolicy: "wake_assignee"` so acceptance and rejection both return the decision to you; only a saved acceptance authorizes the approved work. Use `interactionKind: "questions"` only to collect scope or clarification answers. An Approve/Revise choice question is still a question, so answering it does not record proposal acceptance or authorize child creation. Present the proposal through the matching confirmation kind instead. - Keep this task `in_review` while waiting. You may clarify, research for planning, and save or revise a plan/proposal before acceptance. Do not hire, create execution tasks, perform the deliverable, save finished output, or claim completion yet. + For a native single-task confirmation, call `request_human_input` with arguments like these. Replace `` with the proposed title and describe its actual scope in the prompt. Use a new idempotency key for each changed or revised proposal. Reuse a key only for the exact same proposal when reconciling an interrupted call; the example key below is for the first proposal. + + ```json + { + "idempotencyKey": "first-task-proposal-confirmation", + "interactionKind": "confirmation", + "title": "Approve child task", + "prompt": "Approve creating exactly one child task named \"\" for the scope described above?", + "continuationPolicy": "wake_assignee", + "payload": { + "acceptLabel": "Approve", + "rejectLabel": "Revise", + "rejectRequiresReason": true + } + } + ``` + + For a native single-task proposal in plan mode, save the plan first, then call `request_human_input` with a checkbox card bound to its actual revision. Replace `` and ``; make the options describe the scope the user is approving. For a plan-only request, the option approves the plan alone and does not authorize implementation or hiring. + + ```json + { + "idempotencyKey": "first-task-proposal-plan:", + "interactionKind": "checkbox", + "title": "Approve child task plan", + "prompt": "Approve the proposed scope in this saved plan?", + "continuationPolicy": "wake_assignee", + "payload": { + "options": [{ "id": "scope", "label": "Approve the proposed scope for " }], + "defaultSelectedOptionIds": ["scope"], + "minSelected": 1, + "maxSelected": 1, + "target": { "type": "issue_document", "key": "plan", "revisionId": "" } + } + } + ``` + 3. Interpret the next reply against the latest proposal. - Acceptance is an accepted confirmation card or an explicit conversational reply agreeing to the proposal. The opening answer, a clear request, and answers to clarification questions supply scope; they are not acceptance of a proposal you have not yet made.