mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Codex thread state arrives as provider-specific goals, lineage, notifications, and workspace paths > - That data must be normalized before the full driver can retain or project it > - Notifications also need run and thread binding so unrelated provider traffic is ignored > - This pull request adds pure normalization helpers before the full driver > - A later pull request will use these helpers for the Codex session lifecycle > - The benefit is a small, independently tested trust boundary for thread state ## Linked Issues or Issue Description **Subsystem affected** `packages/paperclip-runner` Codex thread-state normalization. **Problem or motivation** Provider thread data can contain unsupported goal shapes, unrelated notifications, unsafe workspace paths, or unbounded response values. Passing it through directly would weaken run isolation and durable-data bounds. **Proposed solution** Normalize goals and lineage into stable runner shapes, accept notifications only when their run and thread identities match, constrain workspace references to the assigned root, and retain only bounded safe provider responses. **Alternatives considered** Keeping these rules embedded in the full driver would make the trust boundary harder to review and test independently. **Roadmap alignment** This supports the Codex-first experimental runner. It does not enable the runner adapter. ## What Changed - Added normalized Codex thread goals and lineage. - Added run- and thread-bound notification filtering. - Added safe workspace-relative path and stat projection. - Added bounded provider-response retention. - Added focused normalization and isolation tests. ## Verification - `pnpm --filter @paperclipai/paperclip-runner test:typescript` - `pnpm -r typecheck` - `pnpm build` - The focused thread-normalization test has 3 passing cases. ## Risks The main risk is retaining data from the wrong provider thread or accepting an unsafe workspace reference. Tests cover identity binding, path normalization, response bounds, goal parsing, and lineage projection. ## Model Used OpenAI Codex with GPT-5.6 and repository tool use. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
boundedCodexWorkspaceStat,
|
|
codexThreadLineage,
|
|
codexWorkspaceRelativePath,
|
|
isBoundCodexNotification,
|
|
parseCodexThreadGoal,
|
|
safeCodexRequestResponse,
|
|
} from "./codex-thread-normalization.js";
|
|
|
|
describe("Codex thread normalization", () => {
|
|
it("normalizes goals and snake-case subagent lineage", () => {
|
|
expect(parseCodexThreadGoal({
|
|
threadId: "thread-1",
|
|
objective: "Complete the task",
|
|
status: "active",
|
|
tokenBudget: 10_000,
|
|
tokensUsed: 500,
|
|
timeUsedSeconds: 30,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
})).toMatchObject({
|
|
threadId: "thread-1",
|
|
status: "active",
|
|
tokenBudget: 10_000,
|
|
tokensUsed: 500,
|
|
});
|
|
expect(parseCodexThreadGoal({
|
|
threadId: "thread-1",
|
|
objective: "",
|
|
status: "active",
|
|
})).toBeNull();
|
|
for (const invalidNumber of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
|
|
expect(parseCodexThreadGoal({
|
|
threadId: "thread-1",
|
|
objective: "Invalid counters must fail closed",
|
|
status: "active",
|
|
tokensUsed: invalidNumber,
|
|
})).toBeNull();
|
|
}
|
|
|
|
expect(codexThreadLineage({
|
|
id: "child",
|
|
sessionId: "provider-child",
|
|
status: { type: "idle" },
|
|
source: {
|
|
subagent: {
|
|
thread_spawn: {
|
|
parent_thread_id: "parent",
|
|
depth: 2,
|
|
agent_nickname: "builder",
|
|
agent_role: "worker",
|
|
},
|
|
},
|
|
},
|
|
})).toEqual({
|
|
threadId: "child",
|
|
providerSessionId: "provider-child",
|
|
parentThreadId: "parent",
|
|
depth: 2,
|
|
nickname: "builder",
|
|
role: "worker",
|
|
status: "idle",
|
|
});
|
|
});
|
|
|
|
it("admits only bound notifications and safe workspace paths", () => {
|
|
const binding = { runId: "run-1", threadIds: ["thread-1"] };
|
|
expect(isBoundCodexNotification({
|
|
method: "turn/started",
|
|
params: { threadId: "thread-1" },
|
|
}, binding)).toBe(true);
|
|
expect(isBoundCodexNotification({
|
|
method: "item/completed",
|
|
params: {
|
|
thread: {
|
|
id: "unbound-child",
|
|
source: { subagent: { thread_spawn: { parent_thread_id: "thread-1" } } },
|
|
},
|
|
},
|
|
}, binding)).toBe(false);
|
|
expect(isBoundCodexNotification({
|
|
method: "item/completed",
|
|
params: { runId: "run-1" },
|
|
}, binding)).toBe(false);
|
|
expect(isBoundCodexNotification({
|
|
method: "thread/started",
|
|
params: {
|
|
thread: {
|
|
id: "child-thread",
|
|
source: { subagent: { thread_spawn: { parent_thread_id: "thread-1" } } },
|
|
},
|
|
},
|
|
}, binding)).toBe(true);
|
|
expect(isBoundCodexNotification({
|
|
method: "turn/started",
|
|
params: { threadId: "other-thread" },
|
|
}, binding)).toBe(false);
|
|
expect(isBoundCodexNotification({
|
|
method: "item/completed",
|
|
params: { runId: "other-run", threadId: "thread-1" },
|
|
}, binding)).toBe(false);
|
|
expect(isBoundCodexNotification({
|
|
method: "unknown/provider/event",
|
|
params: { runId: "run-1" },
|
|
}, binding)).toBe(false);
|
|
expect(isBoundCodexNotification({
|
|
method: "warning",
|
|
params: {},
|
|
}, binding)).toBe(false);
|
|
|
|
expect(codexWorkspaceRelativePath("src\\index.ts")).toBe("src/index.ts");
|
|
expect(codexWorkspaceRelativePath("../secret")).toBeNull();
|
|
expect(codexWorkspaceRelativePath("/absolute/path")).toBeNull();
|
|
expect(codexWorkspaceRelativePath("C:/host/path")).toBeNull();
|
|
expect(codexWorkspaceRelativePath("C:../secret")).toBeNull();
|
|
expect(codexWorkspaceRelativePath("c:..\\secret")).toBeNull();
|
|
expect(codexWorkspaceRelativePath("Z:relative\\host-path")).toBeNull();
|
|
expect(boundedCodexWorkspaceStat(12)).toBe(12);
|
|
expect(boundedCodexWorkspaceStat(-1)).toBeNull();
|
|
});
|
|
|
|
it("returns provider-safe terminal responses for unresolved requests", () => {
|
|
expect(safeCodexRequestResponse("item/permissions/requestApproval")).toEqual({
|
|
permissions: {},
|
|
scope: "turn",
|
|
});
|
|
expect(safeCodexRequestResponse("mcpServer/elicitation/request", "cancel")).toEqual({
|
|
action: "cancel",
|
|
content: null,
|
|
_meta: null,
|
|
});
|
|
expect(safeCodexRequestResponse("tool/requestUserInput")).toEqual({ answers: {} });
|
|
expect(safeCodexRequestResponse("applyPatchApproval")).toEqual({ decision: "decline" });
|
|
});
|
|
});
|