Files
PaperClipAI/server/src/services/issue-thread-interaction-resolution.ts
T
DottaandDev Agent 25cf079ec5 feat(runner): add Codex-native application integration (#12591)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package is useful only when the application can start,
observe, and recover a native Codex run safely.
> - Existing direct adapters must keep their current execution and
finalization paths.
> - The application boundary therefore needs additive persistence,
authorization, coordination, and recovery behind an explicit
experimental adapter.
> - This pull request adds that Codex-only boundary without activating
generalized providers, remote environments, or the later task/SDK
surfaces.

## Linked Issues or Issue Description

**Subsystem affected**

Shared contracts, database persistence, adapter utilities, server
native-runtime services, and the experimental Paperclip Runner adapter.

**Problem or motivation**

The already-landed runner package has a qualified Codex path, but the
application needs durable native-run state, guarded runtime selection,
authenticated coordination, tool security, finalization, and recovery
before the experimental adapter can be exercised safely.

**Proposed solution**

Add a Codex-only `paperclip_runner` application path behind the existing
default-off native-runner setting. Bind native state and coordination to
company/run identity, preserve persisted-run recovery, and leave every
direct adapter on its existing legacy execution path.

**Alternatives considered**

The earlier stack boundary introduced a generalized executor and
remote-environment lifecycle here. That made this PR depend on
implementations in higher PRs and changed reusable sandbox behavior
globally. Those pieces are now deferred together to #12592.

**Roadmap alignment**

ROADMAP.md does not list a conflicting native-runner integration
project. This change adds the application boundary for the existing
Runner architecture.

## What Changed

- Added native run/result/finalization/provider-trace persistence,
shared validators, and idempotent migration/replay coverage.
- Added guarded Codex-only runtime selection, authenticated PRP
coordination, recovery, finalization, and interaction services.
- Added run/company-bound tool-gateway authorization, credential
redaction, SSRF protections, and replay-safe behavior.
- Added the explicit `paperclip_runner` adapter behind the default-off
rollout setting.
- Preserved legacy answered-question wake projection and direct-adapter
execution/finalization paths.
- Hardened cancellation so only owned in-memory child processes are
signaled; persisted recycled PIDs/process groups are never trusted.
- Retained the narrow Claude ACPX isolated-context security follow-up
discovered after #12590.
- Deferred the generalized executor, provider ingress, remote lifecycle,
SDK/lab/eval work, release-process changes, and lockfile.

## Verification

- Changed-file delta against `master`: 133 files.
- GitHub Actions is the authoritative verification environment for this
PR.
- Full CI, security, and Greptile review will run on this lowest
unmerged stack PR.
- Local tests/build/typecheck were not run because this checkout is
resource constrained.
- Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged.

## Risks

- This touches central heartbeat and agent-route code, so legacy
compatibility is the primary risk.
- Runtime selection remains Codex-only and explicit; direct Codex,
Claude, OpenCode, process, HTTP, and plugin adapters remain on their
existing paths.
- Fresh native starts fail closed while the rollout flag is off;
persisted native records remain readable and recoverable.
- Cancellation, company/run binding, tool calls, status decisions, and
completion writes are guarded or replay-safe.

> For core feature work, check [ROADMAP.md](ROADMAP.md) first and
discuss it in #dev before opening the PR. Feature PRs that overlap with
planned core work may need to be redirected.

## Model Used

OpenAI Codex, GPT-5.6, with repository tools, code execution, and
parallel agent review.

## 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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass — GitHub Actions is
authoritative for this resource-constrained checkout
- [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 risks above
- [ ] All Paperclip CI and security gates are green
- [ ] Greptile is 5/5 with no open actionable findings
- [x] I will address all Greptile and reviewer comments before merge

## Stack

- Position: 3 of 5 overall; lowest of 3 currently unmerged
- Base: `master`
- Previous:
[#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified
Claude ACPX runtime — merged
- Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592),
generalized Codex executor, task experience, and developer SDKs

---------

Co-authored-by: Dev Agent <dev@paperclip.ing>
2026-08-31 14:38:38 -05:00

363 lines
12 KiB
TypeScript

import type {
IssueThreadInteractionCanonicalResolverPolicy,
IssueThreadInteractionResolverPolicy,
} from "@paperclipai/shared";
import { normalizeIssueThreadInteractionResolverPolicy } from "@paperclipai/shared";
import { HttpError } from "../errors.js";
export const ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES = [
"interaction_not_found",
"interaction_run_attribution_required",
"interaction_scope_denied",
"interaction_human_only",
"interaction_creator_excluded",
"interaction_addressee_mismatch",
"interaction_stale_target",
"interaction_superseded",
"interaction_already_resolved",
"interaction_issue_closed",
"interaction_governed_action_denied",
"review_policy_denied",
] as const;
export type IssueThreadInteractionResolutionDenialCode =
(typeof ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES)[number];
export type IssueThreadInteractionResolverActor =
| { type: "user"; userId: string }
| { type: "agent"; agentId: string | null | undefined; runId: string | null | undefined }
| { type: "system"; systemId: string };
export type IssueThreadInteractionResolverRestriction = {
policy: IssueThreadInteractionCanonicalResolverPolicy;
/**
* A server-resolved identity excluded by the binding flow in addition to the
* interaction creator. `null` means the binding requires an identity but it
* could not be resolved, so the evaluator must fail closed.
*/
excludedActor?: { type: "agent" | "user"; id: string } | null;
source?: "issue_review";
};
export type IssueThreadInteractionResolverAudienceInput = {
actor: IssueThreadInteractionResolverActor;
interaction: {
createdByAgentId?: string | null;
createdByUserId?: string | null;
sourceRunId?: string | null;
addresseeAgentId?: string | null;
addresseeUserId?: string | null;
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string;
resolverPolicyProvenance?: string | null;
};
/**
* A server-derived restriction owned by a binding flow such as issue review.
* It may only narrow the interaction's persisted effective audience.
*/
additionalRestriction?:
| IssueThreadInteractionCanonicalResolverPolicy
| IssueThreadInteractionResolverRestriction
| null;
governedAction?: boolean;
};
export type IssueThreadInteractionResolverAudienceDecision =
| {
allowed: true;
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
reason: "allow_anyone" | "allow_addressee" | "allow_human" | "allow_system" | "allow_human_override";
}
| {
allowed: false;
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
status: 403 | 422;
code: Extract<
IssueThreadInteractionResolutionDenialCode,
| "interaction_run_attribution_required"
| "interaction_human_only"
| "interaction_creator_excluded"
| "interaction_addressee_mismatch"
| "interaction_governed_action_denied"
| "review_policy_denied"
>;
message: string;
details?: Record<string, unknown>;
};
/**
* Canonicalize one stored resolver-policy value.
*
* Before provenance existed, `board_or_agents` excluded both the creator agent
* and the creating run, so a pre-migration row must stay `not_creator` rather
* than widening to canonical `anyone`. Every reader of the stored columns —
* hydration, the audience evaluator, and the attention feed — goes through here
* so none of them can disagree about a legacy row.
*/
export function canonicalizeStoredResolverPolicy(
policy: IssueThreadInteractionResolverPolicy | string,
resolverPolicyProvenance: string | null | undefined,
): IssueThreadInteractionCanonicalResolverPolicy {
if (resolverPolicyProvenance === "legacy_inherited_restriction" && policy === "board_or_agents") {
return "not_creator";
}
return normalizeIssueThreadInteractionResolverPolicy(policy as IssueThreadInteractionResolverPolicy);
}
function canonicalStoredPolicy(input: IssueThreadInteractionResolverAudienceInput["interaction"]) {
return canonicalizeStoredResolverPolicy(
input.effectiveResolverPolicy,
input.resolverPolicyProvenance,
);
}
function reviewRestrictionExcludesActor(
restriction: IssueThreadInteractionResolverRestriction | null | undefined,
actor: { type: "agent" | "user"; id: string },
) {
return restriction?.policy === "not_creator"
&& restriction.source === "issue_review"
&& (
restriction.excludedActor === null
|| (
restriction.excludedActor?.type === actor.type
&& restriction.excludedActor.id === actor.id
)
);
}
function reviewPolicyDeniedDecision(
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy,
requesterKnown: boolean,
): Extract<IssueThreadInteractionResolverAudienceDecision, { allowed: false }> {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "review_policy_denied",
message: requesterKnown
? "Review policy `not_creator` requires someone other than the writer who moved the issue into `in_review` to approve or reject it."
: "Review policy `not_creator` requires a different writer, but the review requester could not be determined.",
details: {
policy: "not_creator",
allowedActor: "writer_other_than_review_requester",
remediation: requesterKnown
? "Have another writer with issue write access submit the verdict, or change reviewPolicy to `anyone`."
: "Change reviewPolicy to `anyone`, or move the issue out of and back into `in_review` to record a requester before another writer submits the verdict.",
},
};
}
/**
* The single pure audience evaluator for issue-thread interaction resolution.
* Resource access, run validation, containment, and current-target checks must
* happen before this evaluator. Mutation routes must re-run it at use time.
*/
export function evaluateIssueThreadInteractionResolverAudience(
input: IssueThreadInteractionResolverAudienceInput,
): IssueThreadInteractionResolverAudienceDecision {
const persistedPolicy = canonicalStoredPolicy(input.interaction);
const additionalRestriction = typeof input.additionalRestriction === "string"
? { policy: input.additionalRestriction }
: input.additionalRestriction;
// human_only and not_creator are independent constraints, not a linear
// severity scale: their intersection means "a human other than the
// creator." Keep both predicates even though the persisted public policy
// enum can only name one of them.
const creatorExcluded =
persistedPolicy === "not_creator"
|| additionalRestriction?.policy === "not_creator";
const humanOnly =
Boolean(input.governedAction)
|| persistedPolicy === "human_only"
|| additionalRestriction?.policy === "human_only";
const effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy = humanOnly
? "human_only"
: creatorExcluded
? "not_creator"
: "anyone";
if (input.actor.type === "system") {
return { allowed: true, effectiveResolverPolicy, reason: "allow_system" };
}
if (input.actor.type === "user") {
if (
input.interaction.addresseeUserId
&& input.interaction.addresseeUserId !== input.actor.userId
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_addressee_mismatch",
message: "Only the addressed user may resolve this issue-thread interaction",
};
}
if (
creatorExcluded
&& input.interaction.createdByUserId === input.actor.userId
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_creator_excluded",
message: "This issue-thread interaction requires a resolver other than its creator",
};
}
if (reviewRestrictionExcludesActor(additionalRestriction, {
type: "user",
id: input.actor.userId,
})) {
return reviewPolicyDeniedDecision(
effectiveResolverPolicy,
additionalRestriction?.excludedActor !== null,
);
}
return {
allowed: true,
effectiveResolverPolicy,
reason: input.interaction.addresseeUserId
? "allow_addressee"
: input.interaction.addresseeAgentId
? "allow_human_override"
: "allow_human",
};
}
if (!input.actor.agentId || !input.actor.runId?.trim()) {
return {
allowed: false,
effectiveResolverPolicy,
status: 422,
code: "interaction_run_attribution_required",
message: "A valid authenticated agent run is required to resolve this issue-thread interaction",
};
}
if (input.governedAction) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_governed_action_denied",
message: "This interaction is bound to a governed action that requires independent authorization",
};
}
if (effectiveResolverPolicy === "human_only") {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_human_only",
message: "This issue-thread interaction is human-only",
};
}
if (input.interaction.addresseeUserId) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_addressee_mismatch",
message: "This issue-thread interaction is addressed to a specific user",
};
}
if (
input.interaction.addresseeAgentId
&& input.interaction.addresseeAgentId !== input.actor.agentId
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_addressee_mismatch",
message: "Only the addressed agent or an authorized human may resolve this issue-thread interaction",
};
}
if (
creatorExcluded
&& (
input.interaction.createdByAgentId === input.actor.agentId
|| (
Boolean(input.interaction.sourceRunId)
&& input.interaction.sourceRunId === input.actor.runId
)
)
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_creator_excluded",
message: "This issue-thread interaction requires a resolver other than its creator or creating run",
};
}
if (reviewRestrictionExcludesActor(additionalRestriction, {
type: "agent",
id: input.actor.agentId,
})) {
return reviewPolicyDeniedDecision(
effectiveResolverPolicy,
additionalRestriction?.excludedActor !== null,
);
}
return {
allowed: true,
effectiveResolverPolicy,
reason: input.interaction.addresseeAgentId ? "allow_addressee" : "allow_anyone",
};
}
export function issueThreadInteractionResolutionError(
status: number,
code: IssueThreadInteractionResolutionDenialCode,
message: string,
details: Record<string, unknown> = {},
) {
return new HttpError(status, message, { code, ...details });
}
export function assertIssueThreadInteractionResolverAudience(
input: IssueThreadInteractionResolverAudienceInput,
) {
const decision = evaluateIssueThreadInteractionResolverAudience(input);
if (!decision.allowed) {
throw issueThreadInteractionResolutionError(
decision.status,
decision.code,
decision.message,
{ effectiveResolverPolicy: decision.effectiveResolverPolicy, ...(decision.details ?? {}) },
);
}
return decision;
}
/**
* Resolves agent-owned attention with the same audience rules used by mutation
* routes. Run attribution is deliberately represented by the interaction's
* source run for the creator and by an opaque non-source run for other agents;
* the real run is still authenticated at mutation time.
*/
export function issueThreadInteractionAttentionAgentAllowed(input: {
agentId: string;
interaction: IssueThreadInteractionResolverAudienceInput["interaction"];
additionalRestriction?: IssueThreadInteractionResolverAudienceInput["additionalRestriction"];
governedAction?: boolean;
}) {
const attentionRunId = input.interaction.createdByAgentId === input.agentId
? input.interaction.sourceRunId ?? "attention-owner-creator-run"
: "attention-owner-non-source-run";
return evaluateIssueThreadInteractionResolverAudience({
actor: { type: "agent", agentId: input.agentId, runId: attentionRunId },
interaction: input.interaction,
additionalRestriction: input.additionalRestriction,
governedAction: input.governedAction,
}).allowed;
}