fix(runtime): adopt surviving shell-command services (#11744)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Managed workspace services must continue after a control-plane
restart
> - A service command can use shell control operators before it starts
the final process
> - The final process command line then differs from the stored shell
expression
> - Paperclip rejected that valid process even when its listener,
process group, and workspace matched
> - This pull request uses the stronger ownership checks for shell
expressions
> - The benefit is that Paperclip can adopt a valid service after a
restart

## Linked Issues or Issue Description

Refs #11740

**What happened?**

A managed service could use a command such as `env | sort > file; exec
pnpm dev`. After a control-plane restart, the surviving process command
line contained only the final program. Paperclip compared it with the
complete shell expression and rejected the service.

**Expected behavior**

Paperclip must adopt the surviving service when the listener, process
group, and workspace directory prove ownership.

**Steps to reproduce**

1. Configure a managed workspace service with a shell pipeline or
command sequence.
2. Start the service.
3. Restart the control plane while the service stays alive.
4. Observe that Paperclip starts a replacement instead of adopting the
live service.

**Paperclip version or commit**

`bd059a073d`

**Deployment mode**

Local dev with managed workspace services.

## What Changed

- Detect shell control syntax outside quoted strings.
- Skip the weak command-line comparison for these shell expressions.
- Require the live port owner to remain in the recorded process group.
- Keep the existing workspace directory check.
- Add unit and restart-adoption regression tests.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/local-service-supervisor.test.ts
src/__tests__/workspace-runtime.test.ts -t 'does not compare shell
expressions|re-adopts a live service whose shell command differs'
--reporter=verbose` — 2 passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `git diff --check origin/master...HEAD` — passed.

## Risks

- Low risk. The relaxed command comparison applies only to shell
expressions.
- Listener ownership, process-group ownership, and workspace directory
checks still fail closed.
- This change does not change the database schema, lockfile, workflow
files, or user interface.

> 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 — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5. The serving suffix and context-window size are
not exposed. The model used agentic reasoning, repository tools, code
execution, test execution, and GitHub tools.

## 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
- [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>
This commit is contained in:
Dotta 2026-08-19 16:42:43 -05:00 committed by GitHub
parent 1b259d7be4
commit 2eb9a09c0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 56 additions and 2 deletions

View File

@ -11,6 +11,7 @@ import {
} from "../services/workspace-runtime.js";
import {
doesLocalServiceCommandLineMatch,
isLocalServiceCommandLineComparable,
listLocalServiceRegistryRecords,
readLocalServicePortOwner,
resolveLocalServiceLogPath,
@ -137,6 +138,15 @@ describe("local service supervision", () => {
})).toBe(true);
});
it("does not compare shell expressions with the surviving process argv", () => {
expect(isLocalServiceCommandLineComparable(
"env | sort > /tmp/service.env; exec pnpm dev --bind loopback",
)).toBe(false);
expect(isLocalServiceCommandLineComparable(
"node -e \"process.stdout.write('left | right')\"",
)).toBe(true);
});
it("does not accept a different command merely because it uses node", () => {
expect(doesLocalServiceCommandLineMatch({
commandLine: "/usr/bin/node /workspace/server/dist/index.js",

View File

@ -7434,7 +7434,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
await expect(fetch(service!.url!)).rejects.toThrow();
});
it("re-adopts a desired service when pnpm is represented as the pnpm.cjs launcher", async () => {
it("re-adopts a live service whose shell command differs from the surviving process argv", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-pnpm-reconcile-"));
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
@ -7455,7 +7455,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
const projectId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
const command = "pnpm dev";
const command = "env | sort > /tmp/guest-$$.env; exec pnpm dev --bind loopback";
const service = {
name: "web",
command,

View File

@ -311,6 +311,46 @@ function normalizeCommandToken(value: string) {
return /^(?:bun|node|nodejs|npm|npx|pnpm|yarn)$/i.test(launcher) ? launcher : unquoted;
}
/**
* Return whether the configured shell command has a stable argv that can be
* compared with the operating system's process command line.
*
* Managed local services are started through `shell -lc`. Once a command uses
* shell control syntax, the surviving process-group leader can be the result of
* that program rather than the configured shell expression. In that case a
* literal argv comparison is not evidence that the process belongs to a
* different service; adoption instead relies on the listener, process group,
* and workspace cwd checks.
*/
export function isLocalServiceCommandLineComparable(recordedCommand: string) {
let quote: "'" | '"' | null = null;
let escaped = false;
for (const character of recordedCommand) {
if (escaped) {
escaped = false;
continue;
}
if (character === "\\" && quote !== "'") {
escaped = true;
continue;
}
if (quote) {
if (character === quote) quote = null;
continue;
}
if (character === "'" || character === '"') {
quote = character;
continue;
}
if ([";", "|", "&", "<", ">", "\n"].includes(character)) {
return false;
}
}
return true;
}
/**
* Compare a configured service command with the argv exposed by the OS.
*
@ -346,6 +386,7 @@ export function doesLocalServiceCommandLineMatch(input: {
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
if (process.platform === "win32") return true;
if (!isLocalServiceCommandLineComparable(record.command)) return true;
try {
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
const commandLine = stdout.trim();
@ -624,6 +665,9 @@ async function doesLocalServiceRecordMatchCwd(record: LocalServiceRegistryRecord
if (!record.port) return true;
const ownerPid = await readLocalServicePortOwner(record.port);
if (!ownerPid) return false;
if (!(await isLocalServiceProcessOwnedBy(ownerPid, record.processGroupId ?? record.pid))) {
return false;
}
const ownerCwd = await readLocalServiceProcessCwd(ownerPid);
return isLocalServiceRegistryCwdCompatible(ownerCwd, record.cwd);
}