mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
Carry verified current-master Copilot corrections into qualification
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/copilot-setup-v34: Preserve Copilot model access and durable connection catalog source Keep guarded recovery fixture synthetic under installed Codex selection Repair Copilot packaging and isolate current instructions in profile 35
This commit is contained in:
commit
470cdae69c
25 files changed
+321
-40
No files matched your search
@@ -12,12 +12,11 @@ import {
|
||||
} from "./paperclip-runner-permissions.js";
|
||||
|
||||
describe("Paperclip Runner permission defaults", () => {
|
||||
it("admits Pi with provider credentials while keeping Copilot pending", () => {
|
||||
it("marks Pi and Copilot qualified with their provider credential bindings", () => {
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi"))
|
||||
.toMatchObject({ qualified: true, credentialEnvironment: expect.arrayContaining(["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) });
|
||||
for (const agent of ["copilot"]) {
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === agent)?.qualified).toBe(false);
|
||||
}
|
||||
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "copilot"))
|
||||
.toMatchObject({ qualified: true, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] });
|
||||
});
|
||||
it("defaults Codex to the only qualified non-interactive mode", () => {
|
||||
expect(resolvePaperclipRunnerPermissionMode("codex", undefined)).toBe(
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 34,
|
||||
"agentProfileVersion": 35,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed",
|
||||
"commandDigest": "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"claude": {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"paperclip-runner-eval-session": "./dist/cli/eval-session.js",
|
||||
"paperclip-runner-codex-proxy": "./dist/cli/codex-app-server-unix-proxy.js",
|
||||
"paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js",
|
||||
"paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js"
|
||||
"paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js",
|
||||
"paperclip-runner-copilot-metadata-probe": "./dist/cli/copilot-metadata-probe.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -4083,7 +4083,7 @@ mod tests {
|
||||
descriptor_value["agentRuntimePackage"] = Value::Null;
|
||||
descriptor_value["agentRuntimeVersion"] = Value::Null;
|
||||
descriptor_value["commandDigest"] = serde_json::from_str::<Value>(include_str!(
|
||||
"../../../../test/fixtures/copilot-profile-v34-identity.json"
|
||||
"../../../../test/fixtures/copilot-profile-v35-identity.json"
|
||||
))
|
||||
.unwrap()["commandDigest"]
|
||||
.clone();
|
||||
@@ -4127,6 +4127,7 @@ mod tests {
|
||||
effective_model: original_descriptor.model.clone(),
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed",
|
||||
"sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
|
||||
+1
-1
@@ -1030,7 +1030,7 @@ fn provider_death_expires_input_and_commits_failure_without_querying_the_dead_pr
|
||||
let mut config = acpx_config(&directory, mode);
|
||||
let mut payload = prepare_payload_with_mode(&directory, "codex", mode);
|
||||
let identity: Value = serde_json::from_str(include_str!(
|
||||
"../../../../test/fixtures/copilot-profile-v34-identity.json"
|
||||
"../../../../test/fixtures/copilot-profile-v35-identity.json"
|
||||
))
|
||||
.unwrap();
|
||||
let digest = identity["commandDigest"].as_str().unwrap();
|
||||
|
||||
@@ -206,6 +206,7 @@ try {
|
||||
writePortableExecutableShim(temporaryRoot, "opencode", "opencode-ai/bin/opencode.exe");
|
||||
writePortableCopilotShims(temporaryRoot);
|
||||
writePortableNodeShim("acpx", "acpx/dist/cli.js");
|
||||
writePortableNodeShim("paperclip-runner-copilot-metadata-probe", "../dist/cli/copilot-metadata-probe.js");
|
||||
writePortableNodeShim(
|
||||
"claude-agent-acp",
|
||||
"@agentclientprotocol/claude-agent-acp/dist/index.js",
|
||||
|
||||
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const fixture = resolve(root, "test/fixtures/copilot-profile-v34-identity.json");
|
||||
const fixture = resolve(root, "test/fixtures/copilot-profile-v35-identity.json");
|
||||
const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8"));
|
||||
const declaration = { ...historical.declaration, agentProfileVersion: 34 };
|
||||
const declaration = { ...historical.declaration, agentProfileVersion: 35 };
|
||||
delete declaration.permissionIdentitySourceSha256;
|
||||
const sources = {
|
||||
displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts",
|
||||
|
||||
@@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v34-identity.json"]]) {
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v35-identity.json"]]) {
|
||||
const identity = await readJson(path);
|
||||
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
|
||||
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
|
||||
|
||||
@@ -70,7 +70,7 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi
|
||||
|
||||
describe("Copilot build-owned installation", () => {
|
||||
it("admits the current declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v34-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v35-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
@@ -103,7 +103,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...COPILOT_DISPLAY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v34-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v35-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyCopilotFailure, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js";
|
||||
import { classifyCopilotFailure, composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS, copilotConfiguration, copilotSandboxEnvironment, COPILOT_LAUNCH_ARGUMENTS } from "./copilot-profile.js";
|
||||
|
||||
describe("Copilot admission policy", () => {
|
||||
it("bounds the complete native instruction file without retaining prior instructions", () => {
|
||||
const suffix = `\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}`;
|
||||
expect(composeCopilotSystemInstructions("Current instructions.")).toBe(`Current instructions.${suffix}`);
|
||||
expect(composeCopilotSystemInstructions("")).toBe(suffix);
|
||||
const remaining = 32 * 1024 - Buffer.byteLength(`${suffix}\n`);
|
||||
expect(Buffer.byteLength(`${composeCopilotSystemInstructions("x".repeat(remaining))}\n`)).toBe(32 * 1024);
|
||||
expect(() => composeCopilotSystemInstructions("x".repeat(remaining + 1))).toThrow("bounded size");
|
||||
expect(() => composeCopilotSystemInstructions("é".repeat(Math.floor(remaining / 2) + 1))).toThrow("bounded size");
|
||||
expect(() => composeCopilotSystemInstructions("embedded\0instruction")).toThrow("bounded size");
|
||||
});
|
||||
it("uses ACP directly without disabling questions or bypassing permissions", () => {
|
||||
expect(COPILOT_LAUNCH_ARGUMENTS.slice(0, 2)).toEqual(["--acp", "--stdio"]);
|
||||
expect(COPILOT_LAUNCH_ARGUMENTS).toContain("--no-auto-update");
|
||||
|
||||
@@ -7,6 +7,15 @@ export const COPILOT_SYSTEM_INSTRUCTION_DELIVERY = "COPILOT_HOME/copilot-instruc
|
||||
export const COPILOT_CREDENTIAL_ENVIRONMENT_NAME = "COPILOT_GITHUB_TOKEN" as const;
|
||||
export const COPILOT_TASK_ORIENTATION_INSTRUCTIONS = "At the start of every task turn, use the paperclip MCP server's dedicated get_task_context tool with {} and wait for its successful result before task work, report_progress, or any other task-dependent mutation. This is an advertised MCP tool, not an HTTP API route: do not use search_api or call_api to discover or substitute for get_task_context. Loaded instructions, seed state, task descriptions, and prior-turn context do not replace this read. Decide the next action from the returned context. Do not issue the context read and a dependent action in parallel. If this tool is unavailable or its read fails, report the failure and do not perform dependent actions." as const;
|
||||
|
||||
/** Compose Copilot-only guidance without changing other providers' sandbox identity. */
|
||||
export function composeCopilotSystemInstructions(instructions: string): string {
|
||||
const composed = `${instructions}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}`;
|
||||
if (composed.includes("\0") || Buffer.byteLength(`${composed}\n`) > 32 * 1024) {
|
||||
throw new Error("Provider runtime instructions exceed their bounded size");
|
||||
}
|
||||
return composed;
|
||||
}
|
||||
|
||||
// Keep permission callbacks enabled even when Paperclip's policy is approve-all.
|
||||
// In particular COPILOT_ALLOW_ALL=true also trusts workspace hooks/plugins/MCP.
|
||||
export const COPILOT_LAUNCH_ARGUMENTS = Object.freeze([
|
||||
|
||||
@@ -67,26 +67,25 @@ it("does not refresh Copilot instructions for a contender rejected by the lifeti
|
||||
normalizedSessionId: "same-native-session", runtimeDirectory, workingDirectory,
|
||||
providerPolicy: { readOnly: false }, environment: { COPILOT_GITHUB_TOKEN: "synthetic-fixture" },
|
||||
};
|
||||
const composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`;
|
||||
const winner = AcpxRuntimeHost.open({ ...options, systemInstructions: "Winner instructions." }, dependencies);
|
||||
// Attach the rejection handler before deliberately releasing the failed launch.
|
||||
const winnerResult = expect(winner).rejects.toThrow("fixture stops before native process launch");
|
||||
try {
|
||||
await winnerEntered;
|
||||
expect(await readFile(instructionPath, "utf8")).toContain(COPILOT_TASK_ORIENTATION_INSTRUCTIONS);
|
||||
expect(await readFile(instructionPath, "utf8")).toContain("Winner instructions.\n");
|
||||
expect(await readFile(instructionPath, "utf8")).toBe(composed("Winner instructions."));
|
||||
await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "Losing contender." }, dependencies))
|
||||
.rejects.toThrow("already has an active lease");
|
||||
expect(openRuntime).toHaveBeenCalledOnce();
|
||||
expect(await readFile(instructionPath, "utf8")).toContain("Winner instructions.\n");
|
||||
expect(await readFile(instructionPath, "utf8")).toBe(composed("Winner instructions."));
|
||||
releaseWinner();
|
||||
await winnerResult;
|
||||
await expect(AcpxRuntimeHost.open({ ...options, systemInstructions: "New owner instructions." }, dependencies))
|
||||
.rejects.toThrow("fixture stops before native process launch");
|
||||
expect(await readFile(instructionPath, "utf8")).toContain(COPILOT_TASK_ORIENTATION_INSTRUCTIONS);
|
||||
expect(await readFile(instructionPath, "utf8")).toContain("New owner instructions.\n");
|
||||
expect(await readFile(instructionPath, "utf8")).toBe(composed("New owner instructions."));
|
||||
await expect(AcpxRuntimeHost.open({ ...options, environment: {}, systemInstructions: "Unauthenticated admission." }, dependencies))
|
||||
.rejects.toMatchObject({ code: "COPILOT_AUTH_REQUIRED" });
|
||||
expect(await readFile(instructionPath, "utf8")).toContain("New owner instructions.\n");
|
||||
expect(await readFile(instructionPath, "utf8")).toBe(composed("New owner instructions."));
|
||||
} finally {
|
||||
releaseWinner();
|
||||
await winnerResult;
|
||||
|
||||
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 34,
|
||||
"agentProfileVersion": 35,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:6dde98a9dbadf22f17ca28f133dbe2a65fa199827741190f569704e44bdc20ed",
|
||||
"commandDigest": "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"claude": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { composeCopilotSystemInstructions } from "./copilot-profile.js";
|
||||
import { piProviderConfiguration } from "./pi-provider-config.js";
|
||||
import { withAcpxTurnCancellation } from "./turn-cancellation.js";
|
||||
import { resolveAcpxProviderMode } from "./provider-mode.js";
|
||||
@@ -497,7 +498,10 @@ export class AcpxRuntimeHost {
|
||||
// Do not race this write against cancellation: retain the lifetime lease
|
||||
// until the atomic refresh settles, then honor an intervening abort.
|
||||
options.signal?.throwIfAborted();
|
||||
await refreshCopilotSystemInstructions(sandbox, boundedInstructions(options.systemInstructions));
|
||||
await refreshCopilotSystemInstructions(
|
||||
sandbox,
|
||||
composeCopilotSystemInstructions(boundedInstructions(options.systemInstructions)),
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
}
|
||||
if (options.agent === "pi") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js";
|
||||
import { composeCopilotSystemInstructions, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js";
|
||||
import { configuredEnvironmentProjection } from "../../configured-environment.js";
|
||||
import {
|
||||
chmod,
|
||||
@@ -430,7 +430,7 @@ it("refreshes Copilot native instructions privately on every admission, includin
|
||||
const sandbox = await prepareAcpxRuntimeSandbox({
|
||||
binding: fixture.binding, agent: "copilot", providerPolicy: { readOnly: false, systemInstructions },
|
||||
});
|
||||
await refreshCopilotSystemInstructions(sandbox, systemInstructions);
|
||||
await refreshCopilotSystemInstructions(sandbox, composeCopilotSystemInstructions(systemInstructions));
|
||||
return sandbox;
|
||||
};
|
||||
const composed = (value: string) => `${value}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { piProviderConfiguration } from "./pi-provider-config.js";
|
||||
import { configuredEnvironmentKeys } from "../../configured-environment.js";
|
||||
import { COPILOT_SYSTEM_INSTRUCTIONS_FILE, COPILOT_TASK_ORIENTATION_INSTRUCTIONS } from "./copilot-profile.js";
|
||||
import { COPILOT_SYSTEM_INSTRUCTIONS_FILE } from "./copilot-profile.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
constants,
|
||||
@@ -646,15 +646,13 @@ export async function refreshCopilotSystemInstructions(
|
||||
sandbox: Pick<AcpxRuntimeSandbox, "agentHomeDirectory">,
|
||||
instructions: string,
|
||||
): Promise<void> {
|
||||
const current = `${instructions}\n\n# Paperclip task orientation\n${COPILOT_TASK_ORIENTATION_INSTRUCTIONS}\n`;
|
||||
if (current.includes("\0") || Buffer.byteLength(current) > 32 * 1024) {
|
||||
if (instructions.includes("\0") || Buffer.byteLength(instructions) > 32 * 1024) {
|
||||
throw new Error("Provider runtime instructions exceed their bounded size");
|
||||
}
|
||||
// Native Copilot reloads this file on session/load. Empty caller text clears
|
||||
// old caller instructions while retaining the current task-orientation rule.
|
||||
// Native Copilot reloads this file on session/load. Empty text clears old text.
|
||||
await writePrivateFile(
|
||||
join(sandbox.agentHomeDirectory, COPILOT_SYSTEM_INSTRUCTIONS_FILE),
|
||||
current,
|
||||
`${instructions}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"commandDigest": "sha256:6ecb42102c9440e5c8fe5b462b894f9457f255b29f8c584d8b713057cffe4327",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 35,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b",
|
||||
"permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
|
||||
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "7fd06053db98cc763c7205424069a5286c9d4cfefb3d197a4a765c693dccb7e6",
|
||||
"semanticDirectDriverSourceSha256": "77882e21ad45b66a688fd1933dbd4fc89fad38e8c1f092805cb9485bdd5223c5",
|
||||
"semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594",
|
||||
"semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5",
|
||||
"semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e",
|
||||
"semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
|
||||
"displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877",
|
||||
"displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e",
|
||||
"configuredEnvironmentSourceSha256": "a03575b3a737dea60da94cf0fa1feed80719a9aaa7f09456eb57a845d9e8e087",
|
||||
"configuredEnvironmentRustSourceSha256": "55d44e48e217385b5c64b1b7d8df16dda62319ca3fb524e452fd78c9ed804d3b",
|
||||
"profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282",
|
||||
"profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571",
|
||||
"profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382",
|
||||
"attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2",
|
||||
"singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f",
|
||||
"nativeSessionRuntimeSourceSha256": "aac818c1cf50a25899ae95cafc70f093c8cd8af37d19f88cbb7847b4d202d2b6",
|
||||
"nativeRuntimeContextSourceSha256": "0b350f5f4044f342b974254738bab45a470c24db9352b04c92d785d26e7853c8",
|
||||
"nativeSessionContractSourceSha256": "93251b1eadc686e9d8339d4dfc50b995a3d0a9e6ad8aedc6f2bdf7e30c16138d",
|
||||
"harnessSessionContractSourceSha256": "9e3d62b712cae91168c96d11ff850b97a144809b5cddfe08ec9a91229e9bfc40",
|
||||
"harnessBackendSourceSha256": "46fd6523f986d5406eca3b420fe920f1d51a6435fb7afc2acf4d21be13af447f",
|
||||
"harnessSessionSourceSha256": "4dff9bb3370c5560cfc910a7ee814a27725b469c3b1b5dc021e3c0b158ebe746",
|
||||
"transportContractSourceSha256": "5cd208fe11c3bedd476494b0609fb32efbabe4a1170e2c5854d0aa5e429bd6f4",
|
||||
"nativeBackendSourceSha256": "294b9d830bc58c05985087875523db133d32cf999703140fd927e843399424db",
|
||||
"nativeBackendFactorySourceSha256": "ce22825a53669f8c2db5aaa890bae94af19ede22295bda4ed7987a31d804cd8a",
|
||||
"nativeInstructionsSourceSha256": "ea85be2aca8dfbd6b4042dcd7b1ed9db9a57baf8c379649a3f5d7d80ca54a6a2",
|
||||
"runtimeRequestBridgeSourceSha256": "a66ee14f858ccf75c69dfbce132900a30724fe1e6baffc26b02d58a6bc5924ac",
|
||||
"runtimeRequestFacadeSourceSha256": "e1993906ac9f47eb4ea04ae815db869ecde72f582af460cc0080308670612c8f",
|
||||
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
|
||||
"guardedInstallationSourceSha256": "2881b655112f165320a73753385cd54286139a0273a5fa30c1b43fc3b5981a25",
|
||||
"nativeExecutionSourceSha256": "9c702e518862c396ccfa12d84d11cb8dec1fc064c595359e31cc3e299a7378cc",
|
||||
"profileCompatibilitySourceSha256": "20f8aaa363496a4cbede0f33aeacf869fb50171eaee2d63a892c65b3bda4c6a1",
|
||||
"installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d",
|
||||
"assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c",
|
||||
"assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f",
|
||||
"credentialBindingSourceSha256": "f8dc40ea1aa894bd44754a35a8f65fbcaa2f4c7ffc88cc5fdaa965e0d378d9bc",
|
||||
"sidecarErrorSourceSha256": "23f3dbdc1ce488c690d218a854c820f4b4f3edd822bad574d67707314f8c512a",
|
||||
"profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456",
|
||||
"metadataProbeSourceSha256": "826e74747a89b83a6f546ee1355de64ae5265eb920b50ae498a41dd561a493ee",
|
||||
"permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"runtimeHostSourceSha256": "fd62912cdbaf76e73664024fcec835e899e967a4889fdfdccde7eb215c545f8a",
|
||||
"runtimeAdapterSourceSha256": "3aea6448dc6474e465e5267b61798ad5449bdaec3f4a41bc4be831426893a9bb",
|
||||
"runtimeSandboxSourceSha256": "1c1ddb0b24c3417250ce161cda897ba59b1adce7c7609ed553be298464e6c1c4",
|
||||
"cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca",
|
||||
"agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba",
|
||||
"nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b"
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@
|
||||
"required": true,
|
||||
"placeholder": "github_pat_...",
|
||||
"secret": true,
|
||||
"helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions."
|
||||
"helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slug": "github",
|
||||
"name": "GitHub",
|
||||
"description": "Connect GitHub repository access or authenticate GitHub Copilot agents.",
|
||||
"categories": [
|
||||
"developer",
|
||||
"ai"
|
||||
],
|
||||
"featured": true,
|
||||
"urlPatterns": [
|
||||
"https://api.githubcopilot.com/mcp/*",
|
||||
"https://github.com/*"
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"key": "managed",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "oauth",
|
||||
"ownershipModes": [
|
||||
"platform_shared"
|
||||
],
|
||||
"whenToUse": "Connect your GitHub account for durable MCP, shell Git, gh, and repository access.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://api.githubcopilot.com/mcp/"
|
||||
},
|
||||
"guidanceMd": "Authorize Paperclip, then choose selected repositories in GitHub. You can edit repository access later from GitHub's installation settings.",
|
||||
"riskTier": "S3",
|
||||
"label": "Connect GitHub",
|
||||
"purpose": "tool",
|
||||
"oauthStrategy": "paperclip_cloud_connector",
|
||||
"connectorProfile": "github.code",
|
||||
"grantKinds": [
|
||||
"user",
|
||||
"agent"
|
||||
],
|
||||
"warnings": [
|
||||
"Shell Git and gh receive this identity for the run and are not constrained by per-tool Ask-first controls."
|
||||
],
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "mcp-key",
|
||||
"transport": "mcp_remote",
|
||||
"auth": "api_key",
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://api.githubcopilot.com/mcp/"
|
||||
},
|
||||
"guidanceMd": "Create a fine-grained token limited to the repositories agents should use.",
|
||||
"riskTier": "S3",
|
||||
"label": "Personal access token (advanced)",
|
||||
"purpose": "tool",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "GitHub token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "github_pat_...",
|
||||
"secret": true,
|
||||
"helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "header",
|
||||
"name": "Authorization",
|
||||
"prefix": "Bearer "
|
||||
},
|
||||
"requiredResourceFilters": [
|
||||
"organization",
|
||||
"repository"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "copilot-token",
|
||||
"label": "GitHub Copilot personal access token",
|
||||
"purpose": "ai",
|
||||
"transport": "runtime_auth",
|
||||
"auth": "api_key",
|
||||
"ai": {
|
||||
"provider": "github",
|
||||
"method": "api_key"
|
||||
},
|
||||
"grantKinds": [
|
||||
"user",
|
||||
"organization"
|
||||
],
|
||||
"ownershipModes": [
|
||||
"customer"
|
||||
],
|
||||
"whenToUse": "Authenticate the GitHub Copilot agent runtime with a personal or explicitly shared account.",
|
||||
"guidanceMd": "Create a personal fine-grained token with the Copilot Requests permission. Copilot authentication is separate from repository and MCP access. [GitHub authentication instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli).",
|
||||
"riskTier": "S3",
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "apiKey",
|
||||
"label": "Personal access token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "github_pat_...",
|
||||
"secret": true,
|
||||
"helperMd": "Use a personal fine-grained token with Copilot Requests permission."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
"location": "env",
|
||||
"name": "COPILOT_GITHUB_TOKEN"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -122,13 +122,16 @@ vi.mock("../routes/ai-connections.js", async (importOriginal) => ({
|
||||
validateAiApiKey: mockValidateAiApiKey,
|
||||
}));
|
||||
const mockMarkAuthenticationFailed = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const mockSelectAiConnection = vi.hoisted(() => vi.fn(async (_input: unknown) => ({ connectionId: "copilot-connection", grantId: "copilot-grant" })));
|
||||
const mockAiCredential = vi.hoisted(() => vi.fn(async (_selection: unknown) => "synthetic-copilot-token"));
|
||||
const mockCopilotModels = vi.hoisted(() => vi.fn(async () => ({ models: [{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }], promptSent: false })));
|
||||
const mockCopilotProbe = vi.hoisted(() => vi.fn(async () => ({ status: "verified", promptSent: false })));
|
||||
vi.mock("../services/copilot-connection-probe.js", async importOriginal => ({
|
||||
...(await importOriginal<object>()), probeCopilotExecutionTarget: mockCopilotProbe,
|
||||
...(await importOriginal<object>()), probeCopilotExecutionTarget: mockCopilotProbe, probeCopilotConnection: mockCopilotModels,
|
||||
}));
|
||||
vi.mock("../services/ai-connections.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../services/ai-connections.js")>()),
|
||||
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed }),
|
||||
aiConnectionService: () => ({ markAuthenticationFailed: mockMarkAuthenticationFailed, select: mockSelectAiConnection, credential: mockAiCredential }),
|
||||
}));
|
||||
|
||||
function mockManagedRuntime(method: "api_key" | "subscription") {
|
||||
@@ -259,6 +262,44 @@ describe("agent test-environment route", () => {
|
||||
await unregisterTestAdapter("external_test");
|
||||
});
|
||||
|
||||
it("lets a company task author discover Copilot models without agent configuration permission", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false, explanation: "No configuration grant" });
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
|
||||
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
||||
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna" }]);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(mockAccessService.decide).not.toHaveBeenCalled();
|
||||
expect(mockSelectAiConnection).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
companyId: "company-1", userId: "local-board", agentId: "agent-1", runnerProvider: "acpx", acpxAgent: "copilot",
|
||||
binding: { provider: "github", method: "api_key", mode: "responsible_user" }, allowUninstalledShared: false,
|
||||
}));
|
||||
expect(mockCopilotModels).toHaveBeenCalledExactlyOnceWith(expect.anything(), "company-1", "synthetic-copilot-token", null, undefined, expect.anything());
|
||||
});
|
||||
|
||||
it("refuses model discovery for a foreign agent before selecting credentials", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({ id: "agent-foreign", companyId: "company-2" });
|
||||
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
||||
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-foreign" });
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockSelectAiConnection).not.toHaveBeenCalled();
|
||||
expect(mockAiCredential).not.toHaveBeenCalled();
|
||||
expect(mockCopilotModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps saved connection access enforced before Copilot model probing", async () => {
|
||||
const { forbidden } = await import("../errors.js");
|
||||
mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1" });
|
||||
mockSelectAiConnection.mockRejectedValueOnce(forbidden("Saved connection access denied"));
|
||||
const response = await request(await createApp()).get("/api/companies/company-1/adapters/paperclip_runner/models")
|
||||
.query({ provider: "acpx", acpxAgent: "copilot", agentId: "agent-1" });
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockAiCredential).not.toHaveBeenCalled();
|
||||
expect(mockCopilotModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tests the instance default sandbox when the agent inherits its environment", async () => {
|
||||
const environmentId = "11111111-1111-4111-8111-111111111111";
|
||||
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: environmentId });
|
||||
|
||||
@@ -3415,7 +3415,7 @@ export function agentRoutes(
|
||||
if (modelAgentId) {
|
||||
const modelAgent = await svc.getById(modelAgentId);
|
||||
if (!modelAgent || modelAgent.companyId !== companyId) throw notFound("Agent not found");
|
||||
await assertCanUpdateAgent(req, modelAgent);
|
||||
await assertCanReadAgent(req, modelAgent);
|
||||
} else await assertCanCreateAgentsForCompany(req, companyId);
|
||||
const allowUninstalledShared = !modelAgentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding);
|
||||
const service = aiConnectionService(db);
|
||||
|
||||
@@ -734,6 +734,7 @@ const recoveryFakeCodex = resolve(
|
||||
const previousStateBase = process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||
const server = createServer();
|
||||
let firstSession: NativeSession | undefined;
|
||||
let restoreCodexCommand: (() => void) | undefined;
|
||||
const ownedSessions = new Set<NativeSession>();
|
||||
const ownedSpawns: Array<{ pid: number; processGroupId: number | null; startedAt: string }> = [];
|
||||
const onSpawn = async (meta: typeof ownedSpawns[number]) => { ownedSpawns.push(meta); };
|
||||
@@ -759,6 +760,11 @@ const recoveryFakeCodex = resolve(
|
||||
`#!/bin/sh\nexec ${quote(process.execPath)} ${quote(recoveryFakeCodex)} "$CODEX_HOME/fake-state.json" 16\n`,
|
||||
);
|
||||
await chmod(command, 0o700);
|
||||
// Installed dependency selection intentionally precedes PATH on mainline.
|
||||
// Bind this fixture's synthetic provider explicitly at that boundary.
|
||||
const codexCommandModule = await import("../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js");
|
||||
const codexCommandSelection = vi.spyOn(codexCommandModule, "resolveCodexCommand").mockReturnValue(command);
|
||||
restoreCodexCommand = () => codexCommandSelection.mockRestore();
|
||||
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string")
|
||||
@@ -1048,6 +1054,11 @@ const recoveryFakeCodex = resolve(
|
||||
async onContinuityBreak(value) {
|
||||
continuity = value;
|
||||
},
|
||||
}).then((result) => {
|
||||
if (result.terminal.runTerminalState !== "succeeded") {
|
||||
throw new Error(`Disposable recovery fixture failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
return result;
|
||||
}).catch(async (error: unknown) => {
|
||||
const eventRows = await db
|
||||
.select({
|
||||
@@ -1227,6 +1238,7 @@ const recoveryFakeCodex = resolve(
|
||||
Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })),
|
||||
));
|
||||
const closeFailures = closed.filter((result) => result.status === "rejected");
|
||||
restoreCodexCommand?.();
|
||||
runnerPrpWebSocketInternals.resetForTests();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((done) => server.close(() => done()));
|
||||
|
||||
@@ -107,16 +107,23 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
kind: "native",
|
||||
profile: { backend: "acpx_runtime" },
|
||||
});
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
||||
})).toMatchObject({
|
||||
kind: "native",
|
||||
profile: { backend: "acpx_runtime" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed OpenCode and unqualified ACPX profiles", () => {
|
||||
it("rejects malformed OpenCode and unregistered ACPX profiles", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "opencode", model: "gpt-5.6-sol" },
|
||||
})).toThrow(expect.objectContaining({
|
||||
code: "paperclip_runner_opencode_model_invalid",
|
||||
}));
|
||||
for (const acpxAgent of ["copilot"]) {
|
||||
for (const acpxAgent of ["unregistered-acpx-agent"]) {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
adapterConfig: { provider: "acpx", acpxAgent, model: "explicit-provider-model" },
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("adapter configuration sections", () => {
|
||||
expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") })))
|
||||
.toEqual(expect.arrayContaining([
|
||||
{ label: "Claude", disabled: false }, { label: "Pi", disabled: false },
|
||||
{ label: "Cursor", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true },
|
||||
{ label: "Cursor", disabled: false }, { label: "GitHub Copilot", disabled: false },
|
||||
]));
|
||||
expect(adapter).not.toContain("Runner lifecycle");
|
||||
expect(policy).toContain("Runner lifecycle");
|
||||
|
||||
Reference in new issue
Block a user