Files
PaperClipAI/server/src/adapters/process/execute.ts
T
DottaandPaperclip b3343dbd64 feat(connections): add self-serve intent runtime (#12345)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents need a governed way to request app connections during issue
work.
> - The catalog now describes the available providers and setup methods.
> - A request must become a durable, company-scoped intent before an
operator acts on it.
> - This pull request adds that intent runtime across server, agent,
CLI, and shared contracts.
> - The benefit is a safe bridge from agent need to operator-approved
setup.

## Linked Issues or Issue Description

Refs #11965

This is stack 7 of 11. It depends on stack 6 and replaces another
reviewable part of #11965.

## What Changed

- Add connection intent types, validation, service logic, and routes.
- Add agent runtime tools and CLI support for connection requests.
- Add issue-thread interaction support for connection intents.
- Add runtime, route, adapter, and contract tests.
- Hold the final resolved-continuation row lock through asynchronous
adapter preparation until an actual process spawn, so parking or
reassignment cannot cross that boundary.
- Report Hermes Gateway's first remote run request through the shared
dispatch hook so the resolved-intent lock is released at the true
dispatch boundary.
- Revalidate the addressed user's live non-viewer membership and
connection-management authority for every intent mutation, including
OAuth completion.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 176 tests passed.
- `pnpm build`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-stale-queue-invalidation.test.ts` (32 passed;
includes non-process dispatch lock-release coverage)
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/connection-intents-service.test.ts -t
"addressed-user mutation"` (1 passed)
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/tool-access-service.test.ts -t "binds OAuth
callback completion to the initiating board session"` (1 passed)
- `pnpm --filter @paperclipai/hermes-paperclip-adapter test --
src/gateway/server/execute.test.ts` (23 passed; includes dispatch-hook
ordering and exactly-once coverage)
- `pnpm --filter @paperclipai/hermes-paperclip-adapter typecheck`

## Risks

- A malformed intent could create an unusable operator request.
- Validators and company checks reject invalid or cross-company
requests.
- The final continuation gate holds the issue row lock through adapter
preparation until process or remote dispatch; later operator changes use
the normal active-run interruption path.
- The change does not add a database migration.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## 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 linked the public source pull request with `Refs #`
- [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 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-29 12:08:34 -05:00

106 lines
3.0 KiB
TypeScript

import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
import {
asString,
asNumber,
asStringArray,
parseObject,
buildPaperclipEnv,
buildRuntimeToolsEnv,
isForbiddenConfigEnvKey,
isPaperclipRuntimeEnvKey,
buildInvocationEnvForLogs,
ensurePathInEnv,
resolveCommandForLogs,
runChildProcess,
} from "../utils.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, config, onLog, onMeta, authToken } = ctx;
const command = asString(config.command, "");
if (!command) throw new Error("Process adapter missing command");
const args = asStringArray(config.args);
const cwd = asString(config.cwd, process.cwd());
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v !== "string") continue;
// Runtime PAPERCLIP_* always wins over config, and PAPERCLIP_API_KEY is
// never accepted from config — the harness-minted run token is the only
// source. Other PAPERCLIP_* keys Paperclip did not assign flow through.
if (isForbiddenConfigEnvKey(k)) continue;
if (isPaperclipRuntimeEnvKey(k) && k in env) continue;
env[k] = v;
}
env.PAPERCLIP_RUN_ID = runId;
if (authToken) env.PAPERCLIP_API_KEY = authToken;
// runtimeEnv is only used to resolve the command path and log HOME below;
// the child env is built inside runChildProcess from
// sanitizeInheritedPaperclipEnv(process.env) + env, so a PAPERCLIP_API_KEY
// on the server process never reaches the child.
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 15);
if (onMeta) {
await onMeta({
adapterType: "process",
command: resolvedCommand,
cwd,
commandArgs: args,
env: loggedEnv,
});
}
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog,
onSpawn: ctx.onSpawn,
});
if (proc.timedOut) {
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
};
}
if ((proc.exitCode ?? 0) !== 0) {
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: false,
errorMessage: `Process exited with code ${proc.exitCode ?? -1}`,
resultJson: {
stdout: proc.stdout,
stderr: proc.stderr,
},
};
}
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: false,
resultJson: {
stdout: proc.stdout,
stderr: proc.stderr,
},
};
}