mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
Connect Copilot candidate installation, events, diagnostics and pack registries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2b19bd22be
commit
4fc5dbd4ec
8 files changed
+122
-14
No files matched your search
@@ -174,7 +174,8 @@ Reasons and priorities are explicit:
|
||||
| Native external-tool/sampling/limits callbacks | No qualified ACP responder. P0 prove no unresolved request on admitted model/tools; otherwise keep release unqualified. |
|
||||
| Native capability/model/session lifecycle/config notices | Initial handshake and normalized session/config are admission authority. P1 detect capability/model drift and fail closed rather than treat a notice as authorization. |
|
||||
|
||||
Subagent subscribed fields are preserved in full. Empty native
|
||||
Subagent subscribed fields are preserved within the declared text bounds;
|
||||
truncated display strings carry an explicit truncation marker. Empty native
|
||||
`pending_messages.modified` and `session.background_tasks_changed` events have no
|
||||
queue/task list to preserve; their projection explicitly says refresh unavailable.
|
||||
Native context repository/git-root strings, completion receipt finalTool,
|
||||
@@ -196,7 +197,7 @@ python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /p
|
||||
python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /path/to/copilot-darwin-arm64/package --scenario attached-shell
|
||||
node --test packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs packages/paperclip-runner/scripts/build-copilot-distribution.test.mjs
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-events.test.ts src/drivers/acpx/copilot-profile.test.ts src/drivers/acpx/copilot-evidence.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-extension-adapter.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-extension-adapter.test.ts src/drivers/acpx/copilot-registry.test.ts
|
||||
```
|
||||
|
||||
Retained real-binary evidence:
|
||||
@@ -239,3 +240,13 @@ builder, returning the profile digest above and closure
|
||||
The temporary output was removed after verification. This packaging proof used
|
||||
no model credentials, executed no provider turn, and incurred $0 model spend;
|
||||
it does not qualify either local product behavior or Daytona execution.
|
||||
|
||||
The Copilot branch connects all three closed registries: profile installation
|
||||
selects the pinned native verifier, profile extensions advertise only the 22
|
||||
selected native event types and create the Copilot adapter, and candidate packs
|
||||
select the verified archive builder. Registry conformance checks the complete
|
||||
subagent field projection through the shared turn binder, attribution, canonical
|
||||
schema validation, meaningful display details, and stale/cross-session rejection.
|
||||
Admission error classification distinguishes missing authentication, account or
|
||||
organization denial, and unavailable explicit models using fixed safe messages;
|
||||
unrelated runner integrity errors keep their original classification.
|
||||
@@ -18,5 +18,9 @@ export function parseProviderPackArguments(args) {
|
||||
/** Closed source-owned builder registry; provider branches add their exact pins. */
|
||||
export async function materializeCandidateProviderPack({ provider, outputRoot }) {
|
||||
if (!CANDIDATES.has(provider)) throw new Error("Unknown candidate provider");
|
||||
if (provider === "copilot") {
|
||||
const { buildPinnedCopilotDistribution } = await import("./build-copilot-distribution.mjs");
|
||||
return buildPinnedCopilotDistribution({ outputRoot });
|
||||
}
|
||||
throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`);
|
||||
}
|
||||
@@ -12,3 +12,16 @@ test("candidate builder cannot admit unknown providers, options or duplicate ass
|
||||
}
|
||||
await assert.rejects(materializeCandidateProviderPack({ provider: "arbitrary", outputRoot: "/tmp/unused" }), /Unknown candidate/);
|
||||
});
|
||||
|
||||
test("Copilot pack selection reaches only the pinned native archive builder", async t => {
|
||||
const requests = [];
|
||||
t.mock.method(globalThis, "fetch", async (url, options) => {
|
||||
requests.push({ url, redirect: options.redirect, credentials: options.credentials });
|
||||
return new Response("corrupt archive");
|
||||
});
|
||||
await assert.rejects(materializeCandidateProviderPack({ provider: "copilot", outputRoot: "/unused-copilot-build" }), /integrity mismatch/);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.match(requests[0].url, /^https:\/\/registry\.npmjs\.org\/@github\/copilot-(darwin-(arm64|x64)|linux-x64)\/-\/copilot-.*-1\.0\.88\.tgz$/);
|
||||
assert.deepEqual({ redirect: requests[0].redirect, credentials: requests[0].credentials }, { redirect: "error", credentials: "omit" });
|
||||
await assert.rejects(materializeCandidateProviderPack({ provider: "pi", outputRoot: "/unused" }), /not included/);
|
||||
});
|
||||
@@ -3,13 +3,11 @@ import type { CanonicalProviderEvent } from "../../provider-events.js";
|
||||
import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js";
|
||||
import { COPILOT_ACP_EVENT_METHOD, normalizeCopilotSessionEvent, type CopilotSessionEvent } from "./copilot-events.js";
|
||||
import type { AcpxProfileExtensionAdapter, AcpxProfileExtensionContext } from "./profile-extensions.js";
|
||||
import { classifyCopilotFailure } from "./copilot-profile.js";
|
||||
|
||||
/** Display projection only. Native notices cannot settle or charge a runner turn. */
|
||||
export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter & { classifyError: typeof classifyCopilotFailure } {
|
||||
export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter {
|
||||
let sequence = 0;
|
||||
return {
|
||||
classifyError: classifyCopilotFailure,
|
||||
async request() {
|
||||
throw new Error("Copilot 1.0.88 has no qualified inbound ACP extension request responder");
|
||||
},
|
||||
|
||||
@@ -65,13 +65,13 @@ export function classifyCopilotFailure(error: unknown): { code: CopilotFailureCo
|
||||
: typeof error === "string" ? error
|
||||
: isRecord(error) && typeof error.message === "string" ? error.message : "";
|
||||
const message = source.slice(0, 8192).toLowerCase();
|
||||
if (/authentication required|unauthorized|invalid (?:github )?token|not (?:logged|signed) in/.test(message)) {
|
||||
if (/authentication required|unauthorized|invalid (?:github )?token|not (?:logged|signed) in|copilot_github_token.*(?:missing|required|not configured)|(?:missing|no) (?:copilot |github )?(?:credential|token|authentication)/.test(message)) {
|
||||
return { code: "COPILOT_AUTH_REQUIRED", message: "Bind a valid COPILOT_GITHUB_TOKEN credential for this runner." };
|
||||
}
|
||||
if (/entitlement|subscription|organization policy|organisation policy|policy.*(?:denied|disabled)|access denied|forbidden|not entitled/.test(message)) {
|
||||
return { code: "COPILOT_ENTITLEMENT_DENIED", message: "Copilot access is denied by the account entitlement or organization policy." };
|
||||
}
|
||||
if (/(?:model.*(?:not found|unavailable|not available|unsupported|invalid)|unknown model|invalid model)/.test(message)) {
|
||||
if (/(?:model.*(?:not found|unavailable|not available|not supported|unsupported|invalid)|unknown model|invalid model)/.test(message)) {
|
||||
return { code: "COPILOT_MODEL_UNAVAILABLE", message: "The explicitly selected Copilot model is unavailable for this account." };
|
||||
}
|
||||
return { code: "COPILOT_REQUEST_FAILED", message: "The Copilot ACP request failed." };
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CanonicalProviderEvent } from "../../provider-events.js";
|
||||
import { COPILOT_ACP_CLIENT_CAPABILITIES, COPILOT_ACP_EVENT_METHOD } from "./copilot-events.js";
|
||||
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, createAcpxProfileExtensionAdapter } from "./profile-extensions.js";
|
||||
import { classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { verifyCopilotInstallation } from "./copilot-installation.js";
|
||||
|
||||
vi.mock("./copilot-installation.js", () => ({ verifyCopilotInstallation: vi.fn(async () => ({ commandDigest: "verified-by-native-factory" })) }));
|
||||
|
||||
const context = { workspacePath: "/workspace", sessionId: "backend-1", turnId: "turn-1" };
|
||||
describe("Copilot provider registry conformance", () => {
|
||||
it("selects only the Copilot native installer and preserves the exact caller-selected profile", async () => {
|
||||
const profile = resolveQualifiedAcpxProfile("copilot", "explicit-exact-model");
|
||||
expect(await verifyAcpxProfileInstallation(profile)).toMatchObject({ commandDigest: "verified-by-native-factory" });
|
||||
expect(verifyCopilotInstallation).toHaveBeenCalledExactlyOnceWith(profile);
|
||||
await expect(verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", "exact-model"))).rejects.toThrow(/not installed/);
|
||||
});
|
||||
|
||||
it("negotiates an isolated native event subscription and preserves every safe subagent field through the shared turn binder", async () => {
|
||||
const capabilities = acpxProfileClientCapabilities("copilot");
|
||||
expect(capabilities).toEqual(COPILOT_ACP_CLIENT_CAPABILITIES);
|
||||
(capabilities._meta as Record<string, unknown>)["github.com/copilot"] = {};
|
||||
expect(acpxProfileClientCapabilities("copilot")).toEqual(COPILOT_ACP_CLIENT_CAPABILITIES);
|
||||
expect(acpxProfileClientCapabilities("codex")).toEqual({});
|
||||
const emitted: CanonicalProviderEvent[] = [];
|
||||
const data = {
|
||||
toolCallId: "tool-1", agentName: "review", agentDisplayName: "Reviewer", agentDescription: "Review the change",
|
||||
model: "exact-model", parentId: "parent-1", agentType: "task", executionMode: "background", reasoningEffort: "high",
|
||||
contextTier: "large", firstDispatchedModel: "model-initial", configuredModelPreference: "model-preferred",
|
||||
explicitModelOverride: "model-explicit", modelOverrideReason: "task request", modelSelectionSource: "agent",
|
||||
taskModelSource: "task", factoryRunId: "factory-1", totalToolCalls: 3, totalTokens: 40, durationMs: 12.5,
|
||||
cancelled: false, resumable: true, multiTurn: true, explicitModelMatchesPreference: false, configuredModelMatchesActual: false,
|
||||
};
|
||||
const binder = bindAcpxExtensionTurn({ adapter: createAcpxProfileExtensionAdapter("copilot", context),
|
||||
active: () => true, sessionId: context.sessionId, waitForInput: async () => { throw new Error("No input allowed"); }, emit: event => emitted.push(event) });
|
||||
binder.onExtensionNotification(COPILOT_ACP_EVENT_METHOD, { sessionId: context.sessionId, type: "subagent.completed", data,
|
||||
agentId: "agent-2", timestamp: "2026-09-28T00:00:00Z" });
|
||||
await binder.drain();
|
||||
expect(emitted.map(event => event.eventType)).toEqual(["delegation.completed", "provider.notice.recorded"]);
|
||||
expect(emitted[0]!.payload).toMatchObject({ schema: "paperclip.delegation.v1", action: "spawn", status: "completed",
|
||||
children: [{ role: "Reviewer", model: "exact-model", status: "completed", summary: "Review the change" }] });
|
||||
expect(emitted[1]!.payload).toMatchObject({ summary: "Copilot subagent completed.", provenance: {
|
||||
method: COPILOT_ACP_EVENT_METHOD, eventType: "subagent.completed", agentId: "agent-2",
|
||||
sessionId: "backend-1", turnId: "turn-1", timestamp: "2026-09-28T00:00:00Z",
|
||||
} });
|
||||
expect(emitted[1]!.payload.details).toEqual(Object.entries(data).map(([name, value]) => ({ name, value: String(value) })));
|
||||
});
|
||||
|
||||
it.each(["wrong_session", "stale_turn"])("rejects %s events at the shared boundary before presentation", async scenario => {
|
||||
const emitted: CanonicalProviderEvent[] = [];
|
||||
const binder = bindAcpxExtensionTurn({ adapter: createAcpxProfileExtensionAdapter("copilot", context),
|
||||
active: () => scenario !== "stale_turn", sessionId: context.sessionId, waitForInput: async () => ({}), emit: event => emitted.push(event) });
|
||||
binder.onExtensionNotification(COPILOT_ACP_EVENT_METHOD, { sessionId: scenario === "wrong_session" ? "agent-1" : "backend-1", type: "session.idle", data: {} });
|
||||
await expect(binder.drain()).rejects.toThrow();
|
||||
expect(emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies admission failures without copying credentials or masking unrelated runner errors", () => {
|
||||
for (const [message, code] of [
|
||||
["COPILOT_GITHUB_TOKEN is required", "COPILOT_AUTH_REQUIRED"],
|
||||
["Unauthorized token ghp_abcdefghijklmnopqrstuvwxyz", "COPILOT_AUTH_REQUIRED"],
|
||||
["Organization policy denied with Bearer secretsecretsecret", "COPILOT_ENTITLEMENT_DENIED"],
|
||||
["Model custom-model is not supported", "COPILOT_MODEL_UNAVAILABLE"],
|
||||
]) {
|
||||
const error = classifyAcpxProfileError("copilot", new Error(message));
|
||||
expect(error).toMatchObject({ code, retryable: false });
|
||||
expect(String(error)).not.toMatch(/ghp_|secretsecretsecret|custom-model/);
|
||||
}
|
||||
expect(classifyAcpxProfileError("codex", new Error("Unauthorized"))).toBeNull();
|
||||
expect(classifyAcpxProfileError("copilot", new Error("native distribution digest mismatch"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js";
|
||||
import { COPILOT_ACP_CLIENT_CAPABILITIES } from "./copilot-events.js";
|
||||
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
|
||||
import { parsePaperclipQuestionSet, type PaperclipQuestionSet } from "../../contracts/question-set.js";
|
||||
import { isCanonicalProviderEventType, type CanonicalProviderEvent } from "../../provider-events.js";
|
||||
@@ -33,13 +35,14 @@ export interface AcpxProfileExtensionContext {
|
||||
|
||||
/** Provider branches install their closed, pinned adapters here after qualification research. */
|
||||
export function createAcpxProfileExtensionAdapter(
|
||||
_agent: QualifiedAcpxAgent,
|
||||
_context: AcpxProfileExtensionContext,
|
||||
agent: QualifiedAcpxAgent,
|
||||
context: AcpxProfileExtensionContext,
|
||||
): AcpxProfileExtensionAdapter | null {
|
||||
if (agent === "copilot") return createCopilotProfileExtensionAdapter(context);
|
||||
return null;
|
||||
}
|
||||
export function acpxProfileClientCapabilities(_agent: QualifiedAcpxAgent): Record<string, unknown> {
|
||||
return {};
|
||||
export function acpxProfileClientCapabilities(agent: QualifiedAcpxAgent): Record<string, unknown> {
|
||||
return agent === "copilot" ? structuredClone(COPILOT_ACP_CLIENT_CAPABILITIES) : {};
|
||||
}
|
||||
|
||||
/** Reject an oversized approval document; never silently approve a truncated revision. */
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { classifyCopilotFailure } from "./copilot-profile.js";
|
||||
import { verifyCopilotInstallation } from "./copilot-installation.js";
|
||||
import type { QualifiedAcpxAgent, QualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import { verifyQualifiedAcpxInstallation, type VerifiedAcpxInstallation } from "./installation-integrity.js";
|
||||
|
||||
/** Closed build-owned registry. Provider branches add their pinned installations here. */
|
||||
export async function verifyAcpxProfileInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
|
||||
if (profile.agent === "copilot") return verifyCopilotInstallation(profile);
|
||||
if (profile.agent !== "claude" && profile.agent !== "codex" && profile.agent !== "grok") {
|
||||
throw new Error(`ACPX ${profile.agent} verified candidate distribution is not installed in this build`);
|
||||
}
|
||||
@@ -15,7 +18,10 @@ export async function assertAcpxProfileWorkspace(_agent: QualifiedAcpxAgent, _wo
|
||||
/** Candidate branches validate only explicitly bound, sanitized launch credentials. */
|
||||
export function assertAcpxProfileEnvironment(_agent: QualifiedAcpxAgent, _environment: Readonly<NodeJS.ProcessEnv>): void {}
|
||||
|
||||
/** Optional provider-specific classification; never changes whether admission succeeded. */
|
||||
export function classifyAcpxProfileError(_agent: QualifiedAcpxAgent, _error: unknown): Error | null {
|
||||
return null;
|
||||
/** Provider admission diagnostics expose no raw provider strings or credentials. */
|
||||
export function classifyAcpxProfileError(agent: QualifiedAcpxAgent, error: unknown): Error | null {
|
||||
if (agent !== "copilot") return null;
|
||||
const failure = classifyCopilotFailure(error);
|
||||
if (failure.code === "COPILOT_REQUEST_FAILED") return null;
|
||||
return Object.assign(new Error(failure.message), { code: failure.code, retryable: false });
|
||||
}
|
||||
Reference in new issue
Block a user