mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
merge: preserve Codex defaults with current Grok runtime
This commit is contained in:
commit
651a3de64f
152 files changed
+5610
-757
No files matched your search
+1
-1
@@ -43,7 +43,7 @@ packages/paperclip-runner/test/**
|
||||
!packages/paperclip-runner/test/fixtures
|
||||
packages/paperclip-runner/test/fixtures/**
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v17-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json
|
||||
!packages/paperclip-runner/test-fixtures
|
||||
packages/paperclip-runner/test-fixtures/**
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp
|
||||
|
||||
@@ -788,6 +788,25 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
|
||||
# The public-install verifier at the end of this lane downloads the whole
|
||||
# public registry graph into an empty npm cache: its consumer install
|
||||
# took 119-129s of the 226-241s step, more than half of this ~415-446s
|
||||
# lane, in runs 38426120144, 38432086560 and 38423713537 (2026-10-13).
|
||||
# That graph is fixed by source manifests, so this fetches it in the
|
||||
# background while the Rust restore, Evalbook and `pnpm build` run, about
|
||||
# 150s before the verifier installs anything. Only the cache is handed
|
||||
# over, and the verify step waits for this first. A failed or partial
|
||||
# warm-up leaves the verifier to fetch what is missing as before.
|
||||
- name: Start warming the public-install npm cache
|
||||
if: ${{ hashFiles('scripts/warm-public-install-npm-cache.mjs') != '' }}
|
||||
run: |
|
||||
(
|
||||
# The default `bash -e` would skip the status line on a failure.
|
||||
set +e
|
||||
PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE="$RUNNER_TEMP/public-install-npm-cache" node scripts/warm-public-install-npm-cache.mjs
|
||||
echo $? > "$RUNNER_TEMP/public-install-npm-cache.status"
|
||||
) > "$RUNNER_TEMP/public-install-npm-cache.log" 2>&1 &
|
||||
|
||||
# pnpm build reaches the Runner package's build:binary step; without the
|
||||
# shared Rust cache that is a ~3m40s cold compile of all third-party
|
||||
# crates (run 35036001734, 2026-09-15). Same restore-only contract as
|
||||
@@ -914,7 +933,20 @@ jobs:
|
||||
# docker-runner-check.yml runs the same verifier after `pnpm build`.
|
||||
- name: Verify built-in Grok from a clean public npm install
|
||||
if: ${{ hashFiles('scripts/verify-grok-npm-install.mjs') != '' }}
|
||||
run: node scripts/verify-grok-npm-install.mjs
|
||||
run: |
|
||||
status="$RUNNER_TEMP/public-install-npm-cache.status"
|
||||
if [ -f "$RUNNER_TEMP/public-install-npm-cache.log" ]; then
|
||||
# Normally finished long before this; never let it hold the lane.
|
||||
for _ in $(seq 180); do [ -f "$status" ] && break; sleep 1; done
|
||||
if [ "$(cat "$status" 2>/dev/null)" = 0 ]; then
|
||||
tail -n 1 "$RUNNER_TEMP/public-install-npm-cache.log"
|
||||
else
|
||||
echo '::notice title=Public-install npm cache::warm-up did not finish cleanly; the verifier fetches what is missing'
|
||||
tail -n 20 "$RUNNER_TEMP/public-install-npm-cache.log"
|
||||
fi
|
||||
export PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE="$RUNNER_TEMP/public-install-npm-cache"
|
||||
fi
|
||||
node scripts/verify-grok-npm-install.mjs
|
||||
|
||||
verify_serialized_server:
|
||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureServiceShim,
|
||||
handleOnboardService,
|
||||
handoffToOnboardedService,
|
||||
isInstallableReleaseVersion,
|
||||
@@ -7,6 +10,97 @@ import {
|
||||
shouldOfferForegroundStart,
|
||||
} from "../onboard-service.js";
|
||||
|
||||
import { readInstallManifest, resolveInstallStorePaths, writeManagedShim } from "../install-store.js";
|
||||
import { installCommand, runCommandWithDiagnostics, smokePayload } from "../commands/install.js";
|
||||
import { isExecutableFile, resolveServiceShimPath } from "../services/service-manager.js";
|
||||
|
||||
vi.mock("../version.js", () => ({ packageVersion: "2026.924.0" }));
|
||||
vi.mock("../install-store.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../install-store.js")>(),
|
||||
readInstallManifest: vi.fn(),
|
||||
writeManagedShim: vi.fn(),
|
||||
}));
|
||||
vi.mock("../commands/install.js", () => ({
|
||||
installCommand: vi.fn(),
|
||||
runCommandWithDiagnostics: vi.fn(),
|
||||
smokePayload: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/service-manager.js", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("../services/service-manager.js")>(),
|
||||
isExecutableFile: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not install a missing shim when installation is disabled", async () => {
|
||||
vi.mocked(isExecutableFile).mockResolvedValue(false);
|
||||
|
||||
await expect(ensureServiceShim({ installIfMissing: false })).resolves.toEqual({
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: `no executable exists at ${resolveServiceShimPath()}`,
|
||||
});
|
||||
expect(installCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores a missing shim without reinstalling the managed payload", async () => {
|
||||
vi.mocked(readInstallManifest).mockReturnValueOnce({
|
||||
schemaVersion: 1,
|
||||
source: "npm",
|
||||
version: "2026.824.1",
|
||||
channel: "latest",
|
||||
payloadPath: "/managed/payload",
|
||||
installedAt: "2026-08-24T00:00:00.000Z",
|
||||
previous: [],
|
||||
});
|
||||
vi.mocked(isExecutableFile).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
|
||||
const entrypoint = path.join(resolveInstallStorePaths().currentPath, "node_modules", "paperclipai", "dist", "index.js");
|
||||
const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((filePath) => filePath === entrypoint);
|
||||
try {
|
||||
await expect(ensureServiceShim()).resolves.toEqual({ ok: true, installedNow: true });
|
||||
expect(smokePayload).toHaveBeenCalledExactlyOnceWith(
|
||||
resolveInstallStorePaths().currentPath, "2026.824.1", runCommandWithDiagnostics,
|
||||
);
|
||||
expect(writeManagedShim).toHaveBeenCalledExactlyOnceWith();
|
||||
expect(vi.mocked(smokePayload).mock.invocationCallOrder[0])
|
||||
.toBeLessThan(vi.mocked(writeManagedShim).mock.invocationCallOrder[0]);
|
||||
expect(installCommand).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
existsSync.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restore a shim when the managed payload smoke check fails", async () => {
|
||||
vi.mocked(readInstallManifest).mockReturnValueOnce({
|
||||
schemaVersion: 1,
|
||||
source: "npm",
|
||||
version: "2026.824.1",
|
||||
channel: "latest",
|
||||
payloadPath: "/managed/payload",
|
||||
installedAt: "2026-08-24T00:00:00.000Z",
|
||||
previous: [],
|
||||
});
|
||||
vi.mocked(isExecutableFile).mockResolvedValue(false);
|
||||
vi.mocked(smokePayload).mockRejectedValueOnce(new Error("payload dependency is missing"));
|
||||
|
||||
const entrypoint = path.join(resolveInstallStorePaths().currentPath, "node_modules", "paperclipai", "dist", "index.js");
|
||||
const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((filePath) => filePath === entrypoint);
|
||||
try {
|
||||
await expect(ensureServiceShim()).resolves.toEqual({
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: "payload dependency is missing",
|
||||
});
|
||||
expect(writeManagedShim).not.toHaveBeenCalled();
|
||||
expect(installCommand).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
existsSync.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
function dashboardConfig(overrides: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerServiceCommands } from "../commands/service.js";
|
||||
import { ensureServiceShim } from "../onboard-service.js";
|
||||
import { detectServiceManager, type ServiceManager } from "../services/service-manager.js";
|
||||
|
||||
vi.mock("../onboard-service.js", () => ({ ensureServiceShim: vi.fn() }));
|
||||
vi.mock("../services/service-manager.js", () => ({ detectServiceManager: vi.fn() }));
|
||||
|
||||
const install = vi.fn(async () => ({ changed: true }));
|
||||
|
||||
function runInstall(...args: string[]) {
|
||||
const program = new Command();
|
||||
registerServiceCommands(program);
|
||||
return program.parseAsync(["service", "install", ...args], { from: "user" });
|
||||
}
|
||||
|
||||
describe("service install command", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.mocked(ensureServiceShim).mockResolvedValue({ ok: true, installedNow: false });
|
||||
vi.mocked(detectServiceManager).mockResolvedValue({
|
||||
supported: true,
|
||||
manager: {
|
||||
platform: "systemd",
|
||||
serviceName: "paperclipai.service",
|
||||
definitionPath: "/service/paperclipai.service",
|
||||
install,
|
||||
} as unknown as ServiceManager,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("checks the shim before installing and preserves JSON output and start flags", async () => {
|
||||
await runInstall("--json", "--no-start-now", "--no-start-on-login");
|
||||
|
||||
expect(ensureServiceShim).toHaveBeenCalledWith({ installIfMissing: false });
|
||||
expect(vi.mocked(ensureServiceShim).mock.invocationCallOrder[0]).toBeLessThan(install.mock.invocationCallOrder[0]!);
|
||||
expect(install).toHaveBeenCalledWith({ startNow: false, startOnLogin: false });
|
||||
expect(console.log).toHaveBeenCalledExactlyOnceWith(JSON.stringify({
|
||||
installed: true,
|
||||
changed: true,
|
||||
platform: "systemd",
|
||||
serviceName: "paperclipai.service",
|
||||
definitionPath: "/service/paperclipai.service",
|
||||
lingerEnabled: false,
|
||||
}, null, 2));
|
||||
});
|
||||
|
||||
it("rejects a missing shim without installing or printing success JSON", async () => {
|
||||
vi.mocked(ensureServiceShim).mockResolvedValue({ ok: false, installedNow: false, reason: "missing shim" });
|
||||
|
||||
await expect(runInstall("--json")).rejects.toThrow(
|
||||
"Background service not installed: missing shim. Run `paperclipai install`, then `paperclipai service install`.",
|
||||
);
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows creating the managed shim without --json", async () => {
|
||||
await runInstall();
|
||||
|
||||
expect(ensureServiceShim).toHaveBeenCalledWith({ installIfMissing: true });
|
||||
expect(install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { Command } from "commander";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
|
||||
import { detectServiceManager, type ServiceManager, type ServiceStatus } from "../services/service-manager.js";
|
||||
import { ensureServiceShim } from "../onboard-service.js";
|
||||
import { buildLocalHealthUrl } from "../utils/health-url.js";
|
||||
|
||||
type CommonOptions = { instance?: string; json?: boolean };
|
||||
@@ -172,6 +173,16 @@ export function registerServiceCommands(program: Command): void {
|
||||
.option("--enable-linger", "Allow systemd startup without an active login session", false)
|
||||
.action(async (opts) => {
|
||||
const manager = await resolveManager(opts); if (!manager) return;
|
||||
const shim = await ensureServiceShim({ installIfMissing: !opts.json });
|
||||
if (!shim.ok) {
|
||||
throw new Error(
|
||||
`Background service not installed: ${shim.reason ?? "the managed install could not be completed"}. ` +
|
||||
"Run `paperclipai install`, then `paperclipai service install`.",
|
||||
);
|
||||
}
|
||||
if (shim.installedNow) {
|
||||
p.log.success("Installed the managed paperclipai payload and command shim for the service.");
|
||||
}
|
||||
const result = await manager.install({ startNow: opts.startNow, startOnLogin: opts.startOnLogin });
|
||||
let lingerEnabled = false;
|
||||
if (manager.enableLinger) {
|
||||
|
||||
+68
-53
@@ -1,14 +1,16 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import type { PaperclipConfig } from "./config/schema.js";
|
||||
import { openUrl } from "./client/board-auth.js";
|
||||
import { installCommand } from "./commands/install.js";
|
||||
import { installCommand, runCommandWithDiagnostics, smokePayload } from "./commands/install.js";
|
||||
import { resolvePaperclipInstanceId } from "./config/home.js";
|
||||
import { readRuntimeInfo, type PaperclipRuntimeInfo } from "./runtime-info.js";
|
||||
import {
|
||||
readInstallManifest,
|
||||
resolveInstallStorePaths,
|
||||
writeManagedShim,
|
||||
type InstallManifest,
|
||||
} from "./install-store.js";
|
||||
import {
|
||||
@@ -133,62 +135,75 @@ type OnboardServiceDependencies = {
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
const defaultDependencies: OnboardServiceDependencies = {
|
||||
detect: (instanceId) => detectServiceManager({ instanceId }),
|
||||
// The service definition targets the managed shim. An ephemeral run (npx)
|
||||
// never lays it down, so installing the service without this step creates
|
||||
// a definition that crash-loops on a missing binary.
|
||||
ensureServiceShim: async () => {
|
||||
const shimPath = resolveServiceShimPath();
|
||||
if (await isExecutableFile(shimPath)) {
|
||||
return { ok: true, installedNow: false };
|
||||
}
|
||||
const storeShimPath = resolveInstallStorePaths().shimPath;
|
||||
if (path.resolve(shimPath) !== path.resolve(storeShimPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: `no executable exists at ${shimPath} (PAPERCLIP_SHIM_PATH), and it is outside the managed install store`,
|
||||
};
|
||||
}
|
||||
let manifest: InstallManifest | null = null;
|
||||
try {
|
||||
manifest = readInstallManifest();
|
||||
} catch {}
|
||||
try {
|
||||
if (manifest?.source === "git" && manifest.repo) {
|
||||
// A managed git payload must be preserved as-is: reinstall the
|
||||
// exact revision the manifest records, not an npm release.
|
||||
await installCommand({ repo: manifest.repo, ref: manifest.sha ?? manifest.ref, yes: true });
|
||||
} else if (isInstallableReleaseVersion(packageVersion)) {
|
||||
// packageVersion, not cliVersion: a managed executable's cliVersion
|
||||
// carries provenance text that is not an installable npm spec.
|
||||
await installCommand({ version: packageVersion, yes: true });
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason:
|
||||
`this build reports version ${packageVersion}, which is not an installable release; ` +
|
||||
"run `paperclipai install` (or `paperclipai install --repo <repo> --ref <ref>` for source builds) first",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
if (await isExecutableFile(shimPath)) {
|
||||
return { ok: true, installedNow: true };
|
||||
}
|
||||
// The service definition targets the managed shim. An ephemeral run (npx)
|
||||
// never lays it down, so installing the service without this step creates
|
||||
// a definition that crash-loops on a missing binary.
|
||||
export async function ensureServiceShim(options: { installIfMissing?: boolean } = {}): Promise<EnsureShimResult> {
|
||||
const shimPath = resolveServiceShimPath();
|
||||
if (await isExecutableFile(shimPath)) {
|
||||
return { ok: true, installedNow: false };
|
||||
}
|
||||
if (options.installIfMissing === false) {
|
||||
return { ok: false, installedNow: false, reason: `no executable exists at ${shimPath}` };
|
||||
}
|
||||
const storeShimPath = resolveInstallStorePaths().shimPath;
|
||||
if (path.resolve(shimPath) !== path.resolve(storeShimPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: `the managed install completed but no executable shim appeared at ${shimPath}`,
|
||||
reason: `no executable exists at ${shimPath} (PAPERCLIP_SHIM_PATH), and it is outside the managed install store`,
|
||||
};
|
||||
},
|
||||
}
|
||||
let manifest: InstallManifest | null = null;
|
||||
try {
|
||||
manifest = readInstallManifest();
|
||||
} catch {}
|
||||
try {
|
||||
if (
|
||||
manifest &&
|
||||
fs.existsSync(path.join(resolveInstallStorePaths().currentPath, "node_modules", "paperclipai", "dist", "index.js"))
|
||||
) {
|
||||
// Only the shim is missing: smoke-test and restore it for the recorded payload,
|
||||
// keeping its version and update channel instead of reinstalling.
|
||||
await smokePayload(resolveInstallStorePaths().currentPath, manifest.version, runCommandWithDiagnostics);
|
||||
writeManagedShim();
|
||||
} else if (manifest?.source === "git" && manifest.repo) {
|
||||
// A managed git payload must be preserved as-is: reinstall the
|
||||
// exact revision the manifest records, not an npm release.
|
||||
await installCommand({ repo: manifest.repo, ref: manifest.sha ?? manifest.ref, yes: true });
|
||||
} else if (isInstallableReleaseVersion(packageVersion)) {
|
||||
// packageVersion, not cliVersion: a managed executable's cliVersion
|
||||
// carries provenance text that is not an installable npm spec.
|
||||
await installCommand({ version: packageVersion, yes: true });
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason:
|
||||
`this build reports version ${packageVersion}, which is not an installable release; ` +
|
||||
"run `paperclipai install` (or `paperclipai install --repo <repo> --ref <ref>` for source builds) first",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
if (await isExecutableFile(shimPath)) {
|
||||
return { ok: true, installedNow: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
installedNow: false,
|
||||
reason: `the managed install completed but no executable shim appeared at ${shimPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultDependencies: OnboardServiceDependencies = {
|
||||
detect: (instanceId) => detectServiceManager({ instanceId }),
|
||||
ensureServiceShim: () => ensureServiceShim(),
|
||||
confirm: async () => {
|
||||
const answer = await p.confirm({
|
||||
message: "Install Paperclip as a background service?",
|
||||
|
||||
@@ -933,6 +933,11 @@ No Docker or external database is required for this mode.
|
||||
- `off`: skip the predicate and shadow logging
|
||||
|
||||
Grant checks read current database state; there is no positive privacy cache.
|
||||
|
||||
A run-history list is one read operation. It checks each distinct company,
|
||||
run scope, and task binding once, then applies that decision to the related
|
||||
rows in the same response. Every new list operation reads permissions again.
|
||||
|
||||
Sharing a task grants access to that task and its private descendants. Parents
|
||||
and siblings need separate grants. An agent's access is intersected with its
|
||||
run's responsible user's current access.
|
||||
|
||||
@@ -1025,6 +1025,14 @@ In the normal non-terminal case, critical silence remains a UI signal and does n
|
||||
|
||||
Source-resolved watchdog folding concerns stale active-run bookkeeping after a valid terminal disposition. It does not infer productivity from run counts, comment frequency, or elapsed task time.
|
||||
|
||||
Terminal task activity does not prove that the execution runtime has finished.
|
||||
The scan leaves native runs with their native lifecycle owner. An active managed
|
||||
environment lease, running or failed workspace finalization, or recorded
|
||||
copy-back failure also prevents folding. These obligations are checked before
|
||||
local process cleanup and again when the run is committed. The owning runtime
|
||||
or its bounded recovery saves the files and settles the run; the watchdog does
|
||||
not replace that work with a successful result or release its execution locks.
|
||||
|
||||
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
|
||||
|
||||
## 13. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
|
||||
@@ -2013,3 +2013,24 @@ Master advances once more to c8efdbfba43377d39f57e4822e92d796a8a044b6 through St
|
||||
The supported Cloud CLI is re-read from current private master8472ece87f8d9a9e17a6116b9f4631d98ce63486 and is byte-identical to the previously used maintained command. Its one-target preview request d227da49-fd73-44c0-9377-f25711e529bc builds literal6c remotely in38448052890, preserving the existing pinned/user-managed QA stack and leaving fleet/default promotion disabled. Build, deployment and actual serving-source verification remain pending. A separate QA environment with the same immutable execution image and autoDeleteInterval60 is saved through ordinary UI, with instance default unchanged and no probe yet. The prior failed environment, durable teardown record and cost holds remain intact. It cannot borrow the older environment's qualification facts; it needs its own new saved probe and confirmed matching cleanup before automatic native onboarding.
|
||||
|
||||
Existing ordinary board lease APIs and CLI diagnostics are identified, but anonymous challenge creation and the installed CLI's existing board context both receive401 at this tenant's public edge. No browser/provider credential is extracted and no new inspection API/workflow is introduced. The plugin settings route correctly honors Cloud's hidden-settings gate. Existing Fleet Admin provider links reach the normal signed-in Railway log UI; two successful aggregate orphan-cleanup sweeps prove the controller operates, but neither receipt binds the exact outstanding probe lease. Preserve that uncertainty and its hold. Current-source installed JS/DB migration/account preservation, independent managed task/follow-up and normal protected merge remain the next gates. No timer or production deployment is started.
|
||||
|
||||
|
||||
#### 2026-10-14 Codex qualification and new Test environment reconciliation
|
||||
|
||||
Frozen product9546f41e964953c801ad8c2d8bdecadbd5cd3830 passes current-source required CI and fresh5/5 review with30 resolved threads. Its existing four-platform/package campaign38485482843 succeeds: all18 tarballs/four daemon manifests, normal isolated Apple Silicon install, native credential-free startup/open/close and fresh installed DB initialization through0343 are independently verified. This source-built qualification package is not a registry-published npm canary. No Codex payload is bundled, and no local Cargo/Rust/Docker/native source build occurs.
|
||||
|
||||
The original managed onboarding case38485488621 passes its19 assertions and completed judge, without retry. Four actual native turns execute the child-bound240-byte file and real completion callback/reply with parent session/workspace continuity. Its checkout reports dirty state: the verified generated target lock replaces the committed lock, but full final porcelain is unavailable. Literal HEAD, runtime/assets and effective graph are verified separately. The reused signed execution image keeps its historical producer identity. Guarded environment removal and server shutdown pass; physical deletion and invoices are not certified. Earlier failed attempts and unconfirmed cleanup holds remain intact. Public evidence: https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html.
|
||||
|
||||
Ordinary fresh9546 Mac onboarding persists native Codex without a runner override before any QA bounds. The first useful document task and actual Done-parent completion callback execute successfully. One human follow-up updates the same child document to revision2 and publishes a child-bound1,101-byte file. Independent API and real browser downloads match SHA256b758f6d23b33cfa9cfc597a4c3bc2abbf74dc56b7b5d2b30fc45f65c1a4d1905. All seven actual native runs succeed; parent sessions/workspace continue, child execution has its own task session, observed concurrency1, longest run under47seconds, reload preserved. Two recovered proposal-format errors remain a nonblocking quality follow-up.
|
||||
|
||||
Existing installed Mac upgrade preserves all three execution configurations, safe account summaries and eight historical recorded run profiles. The existing Legacy agent's ordinary title edit/reload keeps execution settings, and its shared Advanced dropdown retains Legacy. Two actual Legacy/direct-adapter runs complete the same task and independently checked15-byte then34-byte files. Prior session state is passed and workspace continues; returned provider UUID changes, so stable provider-ID continuity is not claimed. After explicit human authorization, the root-created company QA guard is restored to its original0 and a500-cent hard stop is scoped to the selected Legacy agent. Historical unpriced costs/holds remain unchanged, normal service resolves only the new QA incident, other agents remain paused, and the company is paused again after all runs finish. Rate-card estimates: Mac3.4154336, managed1.170946, Legacy0.114767 dollars; none is an invoice. The separate100-dollar ceiling and all original250/25-dollar unknown/cleanup holds remain enforced.
|
||||
|
||||
The human selected the actual new Test environment for pre-merge acceptance. Its deployed policy supports verified branch previews, unlike Staging's current master-ancestry restriction. Normal Test email-code sign-in has sent a code and awaits the human handoff; no OTP redaction workaround, session impersonation or credential extraction is used. No actual owned Test tenant, schema admission, deployment or live task is claimed yet. The maintained one-target CLI and deliberately core-only preview path are prepared, with existing snapshot/migration-role/health/lock protections and set_default=false. Private Cloud composition and production/default promotion remain separate post-merge gates. The human also authorized a code-owner-review exception for PR15683 only, after all current-head CI/review/live gates pass; no global repository rule is changed and no other PR's approval is waived.
|
||||
|
||||
Remote preview38498802456 completes at exact9546, through trusted workflow definitionb8ffeb973a2a1d430efabffd755f15b6c7cf112a. The ready result matches its single request; immutable Core image digest4cba5c3546c3af3b948f0ee9c3532ada7c754491ce2d175a00164adc46698976 and exact9546 DB/shared registry archives match producer bytes/integrities. This Core preview has no standard signed-image attestation; npm SLSA structure is source/invocation-bound but cryptographic verification is not claimed. The signed execution image is a distinct artifact. No image layers or large transfer archive are downloaded locally. Terminal admission receipt: /private/tmp/current-9546-preview-preparation/TERMINAL-PREVIEW-PAIR-QUALIFICATION.json, SHA25692faae84c89e9e37e23129b6a5cd18580ed1c09517db91286afdf1445e3b5c6f.
|
||||
|
||||
Before merge, actual master advances to875943ac83e730fd4cfbe1e1860b3f4d7bf0ac78 and GitHub reports five content conflicts. Ordinary reconciliation preserves the Codex shared exports, flag-free Codex runtime, unified harness picker/display and incoming Grok Bot exports, three-flag admission, dedicated connection and Execution/Usage summary. The shared display helper additionally preserves Grok Bot branding; it must not label that existing external provider as Paperclip Runner. Seven narrow controls extend the existing display, mounted configuration and runtime-selection suites. An initial new test expected a saved connection section from an unsaved fixture; it is corrected to the existing save-first guidance without changing production behavior. Required maps now align35areas/176 capability IDs and direct token gates pass. Incoming shared Rust/backend/protocol bytes and migration0344 are materially different:9546 artifacts/live proof remain literal, and final integrated native/package/DB qualification must be refreshed remotely. No evidence is relabeled as combined-source proof.
|
||||
|
||||
The resolved composition passes226 cases across the five existing runtime/display/mounted/selection/heartbeat suites. Direct server and UI no-emit checks pass after JavaScript-only Runner SDK declaration preparation and isolated installation of the exact declared Speko0.0.13 dependency with lifecycle scripts disabled. Initial missing-dependency/stale-declaration diagnostics remain setup limitations; no production workaround or dependency/lockfile change is made. All three existing token checks and staged diff checks pass. Fresh hosted full checks/review and remote native/package qualification remain required.
|
||||
|
||||
Current inspectable QA results remain on local3136/3137 with preserved data. The normal board context publishes the sanitized qualification checklist as an attachment-backed primary work product on NPM-3; Done/unassigned status remains and no agent is woken. Canonical PR: https://github.com/paperclipai/paperclip/pull/15683; branch codex/codex-runner-default. Test login/owned target, refreshed reconciled-head gates/artifacts and real Test onboarding/task/file/manual follow-up remain the next action. No timer, merge, production deployment or provisioning-default promotion is performed in this checkpoint.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Grok Bot production readiness
|
||||
|
||||
Status: implementation and qualification in progress. Actual client OAuth and a routine wake with an exact chat acknowledgement are observed. The candidate has local authenticated challenge receipts, real Rust assigned-work/file/finalization tests, and durable proactive-intake tests. Deployed two-way work acceptance remains a release gate.
|
||||
|
||||
## Candidate and release contract
|
||||
|
||||
The production candidate started at `origin/master` commit `833769ab557255b11dd8f650f43ed7333653f5e4` and incorporates master through `e0f30a4c71` in the managed `codex/grokbot-production` worktree. The complete earlier prototype is preserved in snapshot `e0a8150ca248c1b80c58a7e26c10ccda9951907b`; its October 2 test results are historical, not evidence for this candidate. Reuse its provider-specific work where appropriate. Do not import its old gateway baseline, migration numbers, independent session coordinator, or retired UI entry point wholesale.
|
||||
|
||||
Use `.agents/skills/create-external-bot-integration/SKILL.md` and its release, Runner, onboarding, conformance, and deployment references. The user selected an existing staging tenant for acceptance; its hostname and verified stack identity are recorded in the private qualification environment manifest. Cloud permits one company per workspace. The user chose a dedicated synthetic qualification project in the selected existing staging company, and authorized a new Bot because Night Shift’s old web link is unavailable. Use the new Paperclip Qualification Bot and one identifiable app/ingress/Runner candidate. Preserve existing tasks, agents and connections.
|
||||
|
||||
| Area | Required release behavior | Evidence / current state |
|
||||
| --- | --- | --- |
|
||||
| Bot identity | New Paperclip Qualification Bot for acceptance; one Bot to one Paperclip agent; fixed company, agent, client, resource, authorizer, and binding generation | New Bot and temporary fixed agent grant observed; production provider binding implemented and locally tested; not deployed |
|
||||
| Entry point | Agents → Invite an external agent → Grok Bot; existing shared invitation, copy-prompt component, role/manager controls, Back and Save & exit | Grok preset, persisted setup and secure credential fields implemented; rendered Storybook journey inspected |
|
||||
| Setup | Copy a complete prompt, approve scoped OAuth, create a webhook-only routine, copy its desktop URL/key into secure Paperclip fields, watch both checks, Done | No terminal or config edits; account-wide plugin access disclosed |
|
||||
| Outbound / inbound | Supported routine webhook / authenticated agent-purpose remote MCP | Historical outbound observed September 30; current desktop OAuth, authenticated reads, continued access, revocation and reconnect observed; one current routine wake and exact chat acknowledgement observed; candidate’s authenticated receipt pending in the real client |
|
||||
| Idle work | Bot starts governed work without a placeholder task or echo assignment; can create/assign an authorized task to its responsible human | First production milestone alongside assigned work |
|
||||
| Cloud placement | Ordinary managed Rust Runner per admitted assignment, reused across messages/tools; no compute for idle connection/readiness traffic | Must prove shipped Linux artifact, configured environment, lease and cleanup on the selected tenant |
|
||||
| Native capabilities | Bot retains its relevant native browser/apps/skills; Paperclip permissions govern Paperclip calls | Public-source native-tool case required; no new third-party grant solely for testing |
|
||||
| Paperclip capabilities | First-party context, identity/people, authorized search/coordination, progress, questions, documents, output files and canonical completion/review | Share native operations, receipts and authority; do not maintain a second broad tool executor |
|
||||
| File powers | Output artifact publishing through bounded task/assignment grants; input attachment access explicit; Paperclip workspace and shell access deferred | Cannot read Grok's filesystem or inherit file/workspace grants from connection pairing |
|
||||
| Lifecycle | One active assignment; duplicate/lost-response reconciliation; read-aware follow-ups; durable waits; real restart recovery; stale calls fenced | Require run/session/assignment/controller/lease/process evidence |
|
||||
| Stop | Revoke Paperclip work authority and future dispatch; account for managed Runner cleanup; remote Grok activity remains unknown | “Stop Paperclip work” and “Open Grok”; no claim of global Bot termination |
|
||||
| Economics | Recorded budgets gate admission; Grok usage/spend unknown; managed Runner cost separate | Never report zero Grok cost or promise control over its billing |
|
||||
| Distribution | Experimental private connection on authenticated HTTPS instances and Cloud | Localhost relays, internal Grok APIs and public marketplace distribution remain deferred |
|
||||
| Qualification bounds | One active Bot assignment; short synthetic tasks; no paid soak; bounded attempts and explicit resource ownership | Start with at most six principal task cases and twenty webhook invocations; inspect ambiguity before another send; pause to review any additional spend or scope |
|
||||
|
||||
## Architecture decision
|
||||
|
||||
Adapt the merged external-agent Rust Runner lifecycle used by Dot, retaining provider identity as `grok_bot` and preserving `openai_dot` behavior. Grok-specific code owns Bot URL validation, routine secrets, wake dispatch and provider instructions. Shared code owns agent-purpose OAuth/pairing, admission, durable mailbox/operation receipts, authority, canonical completion, finalization and managed recovery.
|
||||
|
||||
On Cloud, reasoning remains at Grok while the admitted Rust Runner uses the configured managed execution environment. Do not run the assignment coordinator on the control-plane host as a fallback. Self-hosted local Runner placement follows the existing explicit local configuration. A sandbox containing Runner does not confine Grok's native computer or apps, and does not grant it workspace tools.
|
||||
|
||||
Reuse `/mcp/runner` and its agent-purpose issuer where the current protocol supports the fixed Grok binding. Keep personal `/mcp/paperclip` grants separate. Preserve existing Dot tool/API compatibility while adding a truthful Grok/provider-neutral presentation. Extend the actual negotiated backend and artifact capability contract; a TypeScript-only reference driver is not production Runner evidence.
|
||||
|
||||
Persist a routine dispatch intent before network activity. Send references, generation and correlation IDs only; retrieve authoritative content over MCP. Store URL/key through company secret storage. Apply public HTTPS/DNS/redirect policy, bounded timeouts and redaction. Authenticated readiness acknowledgement is distinct from HTTP acceptance. Lost or uncertain delivery requires reconciliation and never an automatic second invocation. Do not assume Grok can implement Dot's signed MCP Events subscription protocol.
|
||||
|
||||
Persistent agent authorization permits idle contact. Task effects additionally require the admitted current assignment and run/turn; shared checks enforce private-task visibility, responsible-user access, pause, reassignment, current grant, governance and recorded budgets. Use stable operation IDs and argument digests, current generation checks and durable receipts. Do not manufacture a native identity or use a board/person grant to satisfy Runner checks.
|
||||
|
||||
Normal comments need the shared read-aware wake protocol: retain the ordinary deferred wake until the active assignment reads that exact comment. Preserve unread comments at completion; consume read comments once. Test input replies, new-session requests and self-authored messages separately. A webhook or inbox acknowledgement alone is not proof that feedback was read.
|
||||
|
||||
Use current lease-renewal and durable waiting paths. Distinguish pairing-code, access-token, persistent grant, assignment and dispatch lifetimes. Do not repeat the prototype's arbitrary one-hour work-session expiry as an undisclosed human-input limitation. Expired or revoked authority denies effects; unknown remote work keeps its recovery hold before another assignment.
|
||||
|
||||
## Delivery sequence and finish conditions
|
||||
|
||||
| Gate | Work and concrete exit condition | Owner / evidence |
|
||||
| --- | --- | --- |
|
||||
| P0 — current candidate | Preserve snapshot, start on current master, inspect actual staging serving revision and current Cloud ingress/Runner support; record one credential-free environment manifest | Codex; branch, revisions, public origin, owned resources |
|
||||
| P1 — provider boundary | Fresh scoped connection, actual Grok read tool, token refresh/reconnect, harmless routine → authenticated MCP challenge; verify tools are callable in Night Shift | Codex + human at required provider consent; real ingress/OAuth/ack receipts, no work task or managed job |
|
||||
| P2 — governed vertical slice | Assigned task and idle intake both pass through the real Rust Runner and normal finalizer; one task/run/assignment, first-party document saved/read back, no self-dispatch loop | Codex; real provider and actual Rust/DB evidence kept distinct |
|
||||
| P3 — onboarding and capabilities | Shared invitation Grok preset, supported generated prompt and secure routine setup; persisted Back/Save & exit, automatic safe pending-code renewal, real lifecycle/readiness states, Done; questions, output files and native tools | Codex; existing UI components, Storybook states, real rendered transitions and artifacts |
|
||||
| P4 — reliability | Duplicate/reordered delivery, ambiguous effects, completion follow-up race, durable input/dependency waits, real controller restart, stale authority, stop and cleanup pass | Codex; compare original run/session/assignment/binding/controller/Runner fingerprint/lease |
|
||||
| P5 — review candidate | Focused auth/protocol/Runner/UI tests, full required checks, maps/token/Storybook checks and PR review green on one frozen candidate; compatible packaged Runner artifact | Codex; PR template, exact head/checks and immutable artifact digest |
|
||||
| P6 — deployed acceptance | Selected staging tenant serves candidate app, ingress and ordinary configured managed Runner image; fresh onboarding and both work directions pass without chat nudges or manual repair; cleanup accounted for | Codex; actual qualification Bot campaign, screenshots/private evidence, run/document/artifact links and provider deletion receipts |
|
||||
|
||||
The next critical gate is the remainder of P1. Scoped staging prerequisites, a temporary agent grant, the new Bot and its webhook-only qualification routine were explicitly approved. The temporary shared Dot gateway is client interoperability evidence only; it is not a production Grok provider or a passed Runner acceptance test. Keep the endpoint/data alive while the user/Bot tests it, and announce any intentional shutdown with resumption instructions.
|
||||
|
||||
## Acceptance campaign
|
||||
|
||||
Use the skill's C01–C10 evidence format, one row per candidate/case. Record PASS, FAIL, BLOCKED, UNVERIFIED or NOT_APPLICABLE; list every operator nudge, app reattachment, consent, simulated dependency, manual call and repair.
|
||||
|
||||
- **C01 fresh setup:** enter the normal invitation UI; actual read, OAuth renewal/reconnect, routine challenge, fixed identity, Done and refresh persistence. Saved setup, expiry, incorrect routine credentials, outbound-only success and revoked access must remain truthful.
|
||||
- **C02 assigned useful work:** normal task with a fresh nonce → autonomous acceptance → progress → question/answer → report document and bounded output file → completion/review. Check bytes/hash, revisions, canonical result, disposition and cleanup.
|
||||
- **C03 idle work:** from no assignment, Night Shift creates governed intake and an authorized task assigned to its responsible human. No placeholder, duplicate task/run or echo wake. Same ID replays before/after completion; changed arguments conflict; admission failure settles visibly.
|
||||
- **C04 native capability:** use a public synthetic source through Grok's existing browser/native tools and save a useful result. End transport-only test restrictions explicitly. No implication that Paperclip's catalog disables those tools.
|
||||
- **C05 restart/follow-up:** accept, capture identities, restart the owned controller and prove its identity changed; ordinary comment resumes the same assignment/session/Runner. Test read-before-finish and unread-at-finish races. Simulated recovery is separate evidence.
|
||||
- **C06 stop/authority:** stop, pause, reassignment, membership loss, grant revocation, refresh replay and stale callbacks deny effects; fence/reconciliation and managed resource cleanup are inspectable. Remote Grok activity remains unknown.
|
||||
- **Additional protocol cases:** concurrent claims/queues, callback destination policy, mutation response loss, mailbox cursor concurrency, lease renewal, missing completion/checkpoint, artifact corruption/limits and cross-company/private-task access.
|
||||
- **C07–C10 deployed contracts:** no idle compute, one managed Runner reused per assignment, incompatible artifact rejected before offer, allocation/admission failures settled, real tenant ingress and shipped images validated.
|
||||
|
||||
## Validation and release disposition
|
||||
|
||||
Reuse `server/src/__tests__/dot-runner.test.ts`, current Runner protocol cases and Product E2E fixtures, adding provider-specific regressions rather than a parallel harness. Read the current eval skills before extending their contracts. Use the actual managed fixture only after inspecting its current cost bounds and `finally` cleanup. Scripted MCP/managed successes do not qualify Grok's tool loading or wake behavior.
|
||||
|
||||
Before review-ready handoff run `pnpm -r typecheck`, `pnpm test:run`, `pnpm build`, UI token gates, relevant map checks, Storybook catalog/build and guided browser checks. Do not commit `pnpm-lock.yaml`; current repository policy assigns it to CI. The prototype's old broad-test failures are retained history; they are neither current failures nor a current green aggregate result.
|
||||
|
||||
Inspect current private Cloud runbooks and serving versions. Reuse existing tenant `/mcp/runner` routing first; change ingress only for a demonstrated missing route/auth behavior. Keep private Cloud source and qualification secrets out of public plans/PRs. Record the app image, ingress SHA, Runner digest, configured managed environment and resource receipts. A local binary override is not proof of the shipped managed image.
|
||||
|
||||
Production ready means one reviewed candidate, required checks green, the named staging deployment and shipped Runner verified, the ordinary C01/C02/C03 journeys witnessed with the approved qualification Bot, required recovery/capability cases passed, and all resources released or intentionally retained with an owner and resume path. Report review-ready, merged, staged and canary-verified independently. Fleet-default enablement and a production-wide rollout require their own explicit authorization.
|
||||
|
||||
## Provider sources checked October 13
|
||||
|
||||
- [Grok Bot custom connectors, vendor support](https://forum.cursor.com/t/grokbot-custom-connectors/169965/5): a chat request adds a publicly reachable custom MCP server. Actual desktop OAuth and authenticated reads were observed. Continued access beyond the initial token lifetime was observed; token rotation receipts and routine callbacks remain unverified. The separate Grok chat app’s connector documentation does not establish Grok Bot support.
|
||||
- [Grok Bot routines](https://cursor.com/help/grok-bot/routines): bearer-authenticated JSON POST is documented; HTTP 200 is acceptance, not completion. Missing/ambiguous transport evidence remains uncertain.
|
||||
- [Grok Bot plugins](https://cursor.com/help/grok-bot/connect-plugins): plugin connections belong to the account and are available across its Bots; installation is not proof of authenticated runtime access.
|
||||
|
||||
There is no verified production API in this record for resetting Night Shift's remote memory, stopping all its execution, streaming its full transcript, or reporting its spending. These remain unavailable claims until supported provider evidence says otherwise.
|
||||
|
||||
## Current execution record
|
||||
|
||||
- The production worktree is on the fetched master baseline above. The old implementation is recoverable from its complete saved snapshot; it has not been represented as upgraded production code.
|
||||
- Exact Node 24 / pnpm 9 workspace installation succeeded with a frozen lockfile. No lockfile change was made.
|
||||
- The selected staging stack's Primary host was verified through its signed-in admin detail page. It currently follows fleet releases, so an immutable acceptance candidate will need an explicit per-tenant pin.
|
||||
- Public agent resource and issuer discovery returned HTTP 503 with “Assistant connections are disabled.” The signed-in tenant UI is usable; network access and provider compatibility have not been blamed for this configuration gate.
|
||||
- Approved prerequisite overrides were applied to exactly the selected stack. One canary campaign succeeded; desired/applied configuration hashes converged. Agent resource and issuer discovery subsequently returned HTTP 200.
|
||||
- Cloud’s company-create endpoint rejects tenant-side creation. The user selected a dedicated test project in the existing company. That project was created without repository or third-party app access.
|
||||
- Night Shift’s original browser URL returned Page not found. At the user’s request, a new Paperclip Qualification Bot was created through the running Grok Bot desktop app. Existing Bots were preserved.
|
||||
- The actual Bot added a private remote MCP connection through chat and completed OAuth through the current Cursor client. A one-use operator invitation fixed the company and agent in the consent preview; no persistent credentials were copied into chat. Paperclip shows the temporary agent connection as established, and the Bot’s inbox read returned its binding generation and an empty inbox. This was assisted setup, not prompt-only onboarding.
|
||||
- The temporary protocol agent is labeled Grok Gateway Qualification / Temporary Grok client interoperability test, with the shared Dot provider. Attachment and workspace powers are off. Readiness still requires a verified wake and acknowledgement; no readiness flag or subscription was faked.
|
||||
- The Bot saved Paperclip MCP Qualification as a webhook-only routine. Its detail row and search result only focused the Routines section during computer use; the documented Bot-generated Webhook URL navigation link opened the fields. Credentials were saved privately, and one guarded routine test returned HTTP 200 and an exact nonce acknowledgement. No real work task, managed job or candidate deployment has been initiated.
|
||||
- The new candidate’s narrow routine transport uses the existing DNS-pinned public-network guard, closed reference-only payloads, redacted outcomes and single-send behavior. Its transport and existing public-network guard/rebinding regressions pass: 77 tests across three files. Server typecheck also passes. It is now wired into durable dispatch, the shared agent authority/Runner and setup UI. Full repository typecheck, production build, token gates and Storybook build passed before the latest baseline update; final checks and deployed acceptance remain outstanding.
|
||||
|
||||
|
||||
## Updated provider access contract
|
||||
|
||||
The current verified client surface is the **Grok Bot desktop app**, signed in with its Cursor account. Do not use the unrelated Grok chat custom-connector UI as a substitute. The observed setup starts with a chat request to add the public MCP URL, followed by an Authorize card and browser OAuth. Record app version and verify the generated prompt against this actual flow before calling setup self-serve. The observed client is Grok Bot 0.70.2, built October 11, on its stable track; its update settings reported up to date.
|
||||
|
||||
Do not require a working `grok.com/bot/...` URL for onboarding when the current provider no longer exposes that surface. Bot identification must support its displayed name and a provider-supported link only when available. “Open Grok” needs a supported app handoff or clear desktop instruction; the old dead browser URL is not a recovery action.
|
||||
|
||||
The existing Dot readiness contract requires its authenticated MCP Events callback verification. A Grok routine’s HTTP 200 cannot satisfy that contract. The Grok transport must own a distinct routine wake verification, durably acknowledge its challenge through the bound agent grant, and then enter the shared external assignment lifecycle. Preserve Dot’s actual event-subscription checks unchanged.
|
||||
|
||||
|
||||
## Current P1 evidence and remaining gate
|
||||
|
||||
| Claim | Observed evidence | Scope / limit |
|
||||
| --- | --- | --- |
|
||||
| Fresh custom agent OAuth | Actual Grok Bot Authorize card, current Cursor client consent, fixed company/agent preview, paired connection persisted in Paperclip | Temporary shared gateway; assisted setup; production Grok provider not enabled |
|
||||
| Callable authenticated tools | Actual inbox response; subsequent capabilities returned the agent name changed only in Paperclip and `ready: false` | Native client response plus persisted binding/configuration; no admitted task |
|
||||
| Continued authorization | Capabilities call succeeded after the initial 15-minute token lifetime without a new consent step | Supports client renewal; token endpoint/rotation receipt not independently inspected |
|
||||
| Revocation | Paperclip revoked the temporary connection; real stale inbox attempt returned authentication-required and no data | Current connection boundary only; no task mutation/reassignment test yet |
|
||||
| Reconnect | Fresh one-use invitation and consent for the same agent; real read returned a replacement binding and empty inbox | Existing plugin reused; old binding revoked; operator-driven test |
|
||||
| Routine wake | Vendor-confirmed desktop row-click regression; Bot-generated Webhook URL navigation link opened the panel. One guarded invocation returned HTTP 200 and the exact nonce reply | Operator workaround required; chat acknowledgement is not a persisted authenticated nonce receipt in Paperclip. One of twenty permitted webhook calls used |
|
||||
| Current source checks | Local authenticated MCP challenge, Rust task → document/file → finalization, proactive intake/deduplication, and renewed-session recovery tests pass. Repository typecheck, build, token gates, maps and Storybook build pass | Source candidate is not deployed; complete final suite and candidate-bound real managed acceptance remain required |
|
||||
|
||||
Do not promote the temporary shared Dot connection to ready or substitute its event subscription with an unverified Grok webhook. The remaining P1 gate is the candidate’s durable authenticated challenge acknowledgement through the actual Grok client. Assigned work, idle intake, restart and managed cleanup still require real deployed acceptance. Local tests use scripted clients and synthetic storage; they do not count as provider acceptance.
|
||||
|
||||
## Current implementation evidence
|
||||
|
||||
- Separate `enableGrokBot` gate defaults off. Existing Dot event readiness and person MCP behavior remain distinct.
|
||||
- Shared Rust external backend identifies `grok_bot_mcp`; session completion does not assert remote termination, transcript streaming or usage accounting. Existing native provider identity guarantees remain required.
|
||||
- Routine URL/key are one immutable encrypted secret version. Closed reference payloads, DNS-pinned public egress, no redirects, durable single-send intents and authenticated nonce receipts distinguish delivery from reply.
|
||||
- Recovery holds span old bindings. Inspection cancels uncertain intents and requires re-verification; stop fences session authority before cancellation, without a global Grok stop claim.
|
||||
- First-party native operations, deferred feedback and proactive admission reuse existing ownership, receipts and finalization. File grants bind current company/agent/assignment/grant and exact bytes; they use existing artifact storage.
|
||||
- Guided setup and management include resume, fixed consent identity/authorizer, account-wide plugin disclosure, both verification checks, observed contact, queued work and recovery. Pairing codes remain in browser authorization; routine credentials never enter chat instructions.
|
||||
- The latest upstream fetch advanced beyond the original baseline. Integrate it and regenerate the unshipped migrations before freezing a review candidate. The newer baseline resolves the earlier unrelated Storybook catalog drift; the reconciled catalog passes structural checks, with no new runtime-proof claim.
|
||||
- Cloud’s current ingress allows the existing agent protocol but needs exact GET/PUT exceptions for bounded file tickets. Extend the current Cloud baseline in isolation and test header stripping and denied adjacent routes. No Cloud primary edits or fleet-default change are authorized or required.
|
||||
|
||||
The Grok routine is still a **transport-only qualification** routine. Explicitly replace those test instructions before actual assigned/proactive work; retain Grok’s own authorized native tools. No real client work, Linux managed Runner artifact or deployed candidate has been qualified yet.
|
||||
|
||||
## Review and environment changes — 2026-10-13
|
||||
|
||||
The app and supporting Cloud ingress candidates are open as draft PRs. The first app review found task-summary privacy, deferred-feedback durability, settled-delivery reconciliation and stale Stop targeting gaps. The corrections use the existing task privacy predicate, keep comment admission independent of routine egress, close notification obligations only on canonical successful finish, and pass the displayed run to Stop. Recovery records distinguish dispatch uncertainty from revoked work; a late reply cannot clear a work-session hold or restore task authority. OpenAPI setup contracts and feature-card ordering are synchronized.
|
||||
|
||||
The guided story now uses Night Shift's production task thread, question form and document card with synthetic local callbacks. Incorrect credentials can be replaced from saved setup. This proves UI behavior only.
|
||||
|
||||
Cloud master has introduced a separate Test environment for branch experiments. Staging now admits only master-derived app and schema revisions; the previous pre-merge staging harness override is retired. The selected staging tenant remains the authorized destination, but a pre-merge candidate cannot be forced onto it. Candidate testing must either use an explicitly authorized Test workspace/harness or wait for reviewed master changes. Do not change the staging guard or fleet default to bypass this constraint.
|
||||
|
||||
|
||||
## Candidate checks and review corrections
|
||||
|
||||
The candidate passes repository typecheck, production build, token gates and the Storybook build. The saved-setup retry and pre-assignment Stop regressions pass. The shared provider descriptor schema accepts Grok and rejects invented native process identity. Copilot's versioned source declaration advances to v18 because that shared schema changes; v17 remains immutable, all pinned source hashes are checked, and Copilot qualification remains pending. Current-head full CI and review remain release gates.
|
||||
|
||||
The supporting Cloud ingress candidate passes typecheck, routing smoke and its full suite (3,339 passing, 93 optional skips). Current-head review is 5/5 with no open findings. Neither candidate has been deployed. The actual-client evidence remains one routine HTTP acceptance and matching chat acknowledgement; candidate-side authenticated acknowledgement and the six bounded principal journeys are outstanding.
|
||||
|
||||
|
||||
The next review corrections apply the native deliverable catalog's current work-mode policy to upload-ticket issuance and use. Confirmed rejected deliveries have an explicit operator retry; each retry retains the prior receipt, reserves one idempotent new intent and rechecks binding, grant, routine revision and work-session authority. Unknown or accepted delivery remains single-send. Thread Stop uses the selected run and its agent. Recovery locks all of the agent's current and retired bindings before routine rows. Historical Copilot v17 inputs remain decodable while new launch admission stays pinned to v18.
|
||||
|
||||
The focused rendered UI regressions pass (144 tests), as do OpenAPI route coverage (21 tests) and Runner contract/integrity/history checks (82 tests). The current server fixture attempt could not initialize PostgreSQL because the host exhausted its shared-memory slots; no server-test pass is inferred from that attempt. Clean-worker CI must execute the file-policy, rejected-feedback and lock-order regressions before hand-off.
|
||||
@@ -90,10 +90,18 @@ Only a board operator can record this repair. It preserves the current task
|
||||
owner, conversation generation, status, and execution locks. It does not queue a
|
||||
continuation or replay the stopped run; after repair, any new work still follows
|
||||
the normal admission rules.
|
||||
The board currently shows repair guidance and an Inspect run link, not a repair
|
||||
form. Recording the decision is an operator API/runbook workflow; the recovery
|
||||
API response contains the exact action and lease IDs. Provider access may require
|
||||
an instance administrator or Cloud support. This change does not add a repair UI.
|
||||
The original legacy run may be `succeeded` while required files still need
|
||||
repair. Verified file-only repair accepts that terminal result and keeps it
|
||||
unchanged. It still requires the retained-source record, exact remote stop
|
||||
receipts, and recovered-file evidence. A successful result does not permit a
|
||||
normal execution retry, and a queued or running source cannot be repaired this
|
||||
way. Later saved messages can continue through ordinary admission after the
|
||||
file hold clears.
|
||||
The board shows repair guidance, an Inspect run link, and a Record file recovery
|
||||
form for operators with runtime access. The form requires recovered-file evidence
|
||||
and confirmation; the API verifies the original stop receipts. The operator API
|
||||
also supports this workflow and returns the exact action and lease IDs. Provider
|
||||
access may require an instance administrator or Cloud support.
|
||||
Then use the provider's operator console to remove that exact retained
|
||||
allocation when its files are no longer needed. Verify its immutable provider
|
||||
allocation ID against the recorded lease before removal. The ordinary reusable
|
||||
|
||||
@@ -214,3 +214,19 @@ precedence in the UI; inspect the cleared state after permitted leave/terminatio
|
||||
Gaps: No agent mutation path for another person's preference. Live human/agent
|
||||
access is unverified; UI fixture callbacks do not establish persistence or task
|
||||
and Chat default resolution.
|
||||
|
||||
## `grok-bot`
|
||||
|
||||
Review: `baseline`.
|
||||
|
||||
API: `present` — `POST /api/companies/{companyId}/grok-invitations`, operator-owned Grok invitations and agent-scoped `/grok-binding` setup, routine, verify, stop, recover and disconnect endpoints. Direct HTTPS `/mcp/runner` uses fixed named-agent OAuth. Grok tools expose durable assignment operations and task-bound upload/download grants.
|
||||
|
||||
Agent access: `available` — `paperclip_grok_*` through the dedicated agent grant. First-party Paperclip operations execute through the shared Rust Runner. The Bot retains its own apps; Paperclip workspace and third-party app tools are unavailable.
|
||||
|
||||
Policy: company/agent/client/resource/authorizer binding; current manage-agent permission for consent and subsequent calls; normal task ownership, controller lease, governance, pause, revocation and recorded budget checks. Execution and file transfers require current task-bound sessions. Secret credentials remain in encrypted secret storage and are redacted from HTTP logs.
|
||||
|
||||
Evidence: `source-only` — [routes](../../server/src/routes/grok-bot.ts), [shared authority](../../server/src/services/external-bot-authority.ts), [routine receipts](../../server/src/services/grok-bot/routine.ts) and [Runner broker](../../server/src/services/dot-runner-broker.ts). Temporary actual-client OAuth and an outbound routine wake are qualified separately; they do not prove this candidate’s acceptance.
|
||||
|
||||
Verification: `not-run` — deployed acceptance pending; focused protocol/authorization and actual Rust backend tests; run fresh/saved setup, assigned and proactive tasks, duplicate calls, stale sessions, restart, ambiguous delivery, stop and recovery through the real client on the serving managed candidate.
|
||||
|
||||
Gaps: deployed two-way work and unattended first-time onboarding remain unqualified; remote termination, provider transcript streaming and usage accounting are unavailable.
|
||||
@@ -152,12 +152,17 @@ API: `present` — representative operation IDs:
|
||||
|
||||
- `POST /api/issues/{id}/recovery-actions/retry-workspace-export`.
|
||||
- `POST /api/issues/{id}/recovery-actions/resume-workspace-finalization`.
|
||||
- `POST /api/issues/{id}/recovery-actions/resolve` for retained-source file repair.
|
||||
- `POST /api/execution-workspaces/{id}/reconcile-branch`.
|
||||
|
||||
Agent access: `restricted` — Board/runtime management APIs; agent reads diagnosis and
|
||||
performs permitted workspace work.
|
||||
|
||||
Policy: Export retry and branch reconciliation require board runtime management.
|
||||
Retained legacy file-only repair requires the board, exact stopped-source receipts,
|
||||
and recovered-file evidence. It can record repair for a successful legacy turn
|
||||
without changing its result or authorizing replay. Queued and running sources
|
||||
remain denied. Source-only reconciliation regressions cover this boundary.
|
||||
Physical-owner stop confirmation uses the separate `runtime:confirm_owner_stop`
|
||||
action. It requires active company owner/admin membership, trusted local board
|
||||
access, or instance administration. Agents and ordinary runtime managers are denied.
|
||||
|
||||
@@ -14,6 +14,7 @@ Agent access: [companion](../agent-access-map/agents.md).
|
||||
|
||||
- `hire`: choose identity, harness, model, and required account settings; edit
|
||||
reporting relationships through the agent configuration afterward.
|
||||
- `grok-bot`: experimental existing-Bot agent setup through remote OAuth, routine credentials, and an authenticated two-way challenge; current contact, stop and explicit recovery.
|
||||
- `instructions`: edit agent configuration and instruction files with durable revisions.
|
||||
- `organization`: inspect and change reporting relationships through supported controls.
|
||||
- `primary-agent`: choose a personal primary agent for the company, confirm replacement, and inspect its roster/sidebar and task/chat defaults.
|
||||
@@ -112,6 +113,13 @@ over the primary fallback. Opening Chat must not start execution.
|
||||
- A personal primary agent is a per-user/company preference. It does not change
|
||||
reporting lines, stars, permissions, or another person's primary agent.
|
||||
|
||||
### `connect-grok-bot`
|
||||
|
||||
Choose **Invite an external agent → Grok Bot**, identify the Bot by its desktop name, connect its remote MCP plugin, configure a webhook-only routine, and verify delivery plus authenticated reply. Save & exit resumes the same agent. Keep pairing codes in browser authorization and routine credentials in Paperclip’s fields. A working Bot web URL is not required.
|
||||
|
||||
Prerequisites: authenticated public HTTPS, Assistant connections (MCP), Paperclip Runner and Grok Bot experimental settings. Cloud uses its normal managed Runner environment. Provider usage is unknown. Routine uncertainty blocks new assignments until explicit inspection and recovery; stopping work revokes Paperclip authority without confirming remote termination.
|
||||
|
||||
Evidence: source implementation and focused protocol tests; real provider assigned/proactive workflows and deployed managed acceptance remain release gates.
|
||||
### Documentation scope: Agents, organization, and runtime setup
|
||||
|
||||
Per-harness/model/account capabilities, every instruction-file error, and complete built-in-agent lifecycle variants remain separate qualification matrices.
|
||||
@@ -148,6 +148,13 @@ cover evidence/confirmation, read-only access, stale or missing stop proof,
|
||||
source-only repair, and preserved task status. These tests use fixtures; separately
|
||||
verify original files and their attached artifact before recording a real repair.
|
||||
|
||||
Repeat source-only repair with a `succeeded` legacy run that still has a retained
|
||||
file obligation. Its result must remain successful, its current task owner and
|
||||
status must stay unchanged, and later saved input must become eligible for normal
|
||||
admission. A running or queued source and a normal retry of a successful turn
|
||||
remain denied. The [reconciliation regressions](../../server/src/services/workspace-restore-reconciliation.test.ts)
|
||||
cover these cases and stop-receipt validation without claiming live provider proof.
|
||||
|
||||
For an abandoned native copyback owner, the harness verifies the exact stopped
|
||||
deployment and the reconciler restores saved work automatically. Task users see
|
||||
no process details or shutdown form. The documented operator API handles older
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
|
||||
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
|
||||
"test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
|
||||
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
|
||||
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
|
||||
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type PaperclipRunnerProvider =
|
||||
"codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot";
|
||||
"codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot";
|
||||
|
||||
export type CodexPermissionMode = "never" | "on-request" | "untrusted";
|
||||
export type OpenCodePermissionMode = "allow" | "ask" | "deny";
|
||||
@@ -103,6 +103,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = {
|
||||
description:
|
||||
"AWS AgentCore runs non-interactively under its qualified harness profile and Paperclip policy.",
|
||||
},
|
||||
grok_bot: { configurable: false, defaultMode: "provider-managed", options: [], description: "Grok Bot uses first-party Paperclip tools and retains its native apps. Grok manages its model; spending, live steering, provider transcript streaming and remote interruption are unavailable." },
|
||||
openai_dot: { configurable: false, defaultMode: "provider-managed", options: [], description: "Dot uses the admitted Paperclip tool catalog. OpenAI manages its model and other tools; provider usage and global interruption are unavailable." },
|
||||
acpx: {
|
||||
configurable: true,
|
||||
@@ -148,7 +149,7 @@ export function isPaperclipRunnerProvider(
|
||||
value === "opencode" ||
|
||||
value === "claude_managed" ||
|
||||
value === "aws_agentcore" ||
|
||||
value === "acpx" || value === "openai_dot"
|
||||
value === "acpx" || value === "openai_dot" || value === "grok_bot"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1335,9 +1335,6 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
);
|
||||
const workspaceRestoreExclude = mergeExcludes(restoreExclude, repositories.map((repo) => repo.path));
|
||||
// The baseline "before" snapshot: a recursive walk of the whole workspace that
|
||||
// `lstat`s every entry and SHA-256-hashes every file's bytes. This is the
|
||||
// dominant cost in the pre-`pack` window — it reads the content of every
|
||||
// non-excluded file, serially — so it earns its own span.
|
||||
baselineSnapshot = syncWorkspace
|
||||
? (input.workspaceBaseline ??
|
||||
(await runStepSpan("snapshot.baseline", () =>
|
||||
|
||||
@@ -147,21 +147,37 @@ async function hashFile(filePath: string): Promise<string> {
|
||||
async function* walkDirectory(
|
||||
root: string, exclude: readonly string[], ignored: ReturnType<typeof workspacePathMatcher>, relative = "",
|
||||
): AsyncGenerator<[string, SnapshotEntry]> {
|
||||
const readEntry = async (nextRelative: string): Promise<[string, SnapshotEntry | null]> => {
|
||||
const fullPath = path.join(root, nextRelative);
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isDirectory()) return [nextRelative, { kind: "dir" }];
|
||||
if (stats.isSymbolicLink()) return [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
||||
if (stats.isFile()) return [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
||||
return [nextRelative, null];
|
||||
};
|
||||
const visitBatch = async function* (paths: string[]): AsyncGenerator<[string, SnapshotEntry]> {
|
||||
const results = await Promise.allSettled(paths.map(readEntry));
|
||||
for (const result of results) if (result.status === "rejected") throw result.reason;
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const [nextRelative, entry] = result.value;
|
||||
if (!entry) continue;
|
||||
yield [nextRelative, entry];
|
||||
if (entry.kind === "dir") yield* walkDirectory(root, exclude, ignored, nextRelative);
|
||||
}
|
||||
};
|
||||
const current = relative ? path.join(root, relative) : root;
|
||||
let batch: string[] = [];
|
||||
for await (const entry of await fs.opendir(current)) {
|
||||
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
||||
if (shouldExcludePath(nextRelative, exclude) || ignored.matches(nextRelative)) continue;
|
||||
const fullPath = path.join(root, nextRelative);
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
yield [nextRelative, { kind: "dir" }];
|
||||
yield* walkDirectory(root, exclude, ignored, nextRelative);
|
||||
} else if (stats.isSymbolicLink()) {
|
||||
yield [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
||||
} else if (stats.isFile()) {
|
||||
yield [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
||||
batch.push(nextRelative);
|
||||
if (batch.length === 16) {
|
||||
yield* visitBatch(batch);
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
if (batch.length) yield* visitBatch(batch);
|
||||
}
|
||||
|
||||
async function readSnapshotEntry(root: string, relative: string): Promise<SnapshotEntry | null> {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
captureDirectorySnapshot,
|
||||
disposeDirectorySnapshot,
|
||||
type SnapshotEntry,
|
||||
} from "./workspace-restore-merge.js";
|
||||
|
||||
vi.mock("node:fs", async importOriginal => {
|
||||
const original = await importOriginal<typeof import("node:fs")>();
|
||||
return { ...original, createReadStream: vi.fn(original.createReadStream) };
|
||||
});
|
||||
|
||||
const originalFs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const roots: string[] = [];
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-snapshot-concurrency-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(createReadStream).mockImplementation(originalFs.createReadStream);
|
||||
await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("bounded workspace snapshot reads", () => {
|
||||
it("preserves file hashes, modes, nested paths, exclusions, and symlink entries across batches", async () => {
|
||||
const root = await fixture();
|
||||
const outside = await fixture();
|
||||
const expected = new Map<string, SnapshotEntry>();
|
||||
for (const directory of ["nested", "nested/deeper"]) {
|
||||
await fs.mkdir(path.join(root, directory));
|
||||
expected.set(directory, { kind: "dir" });
|
||||
}
|
||||
for (let index = 0; index < 73; index++) {
|
||||
const relative = `${index < 35 ? "" : "nested/deeper/"}${index % 3 === 0 ? "line\nbreak" : "-space name"}-${index}`;
|
||||
const content = Buffer.from(`snapshot ${index}\0bytes`);
|
||||
const filePath = path.join(root, relative);
|
||||
await fs.writeFile(filePath, content);
|
||||
await fs.chmod(filePath, index % 2 === 0 ? 0o600 : 0o444);
|
||||
expected.set(relative, {
|
||||
kind: "file", mode: (await fs.lstat(filePath)).mode,
|
||||
hash: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}
|
||||
await fs.writeFile(path.join(outside, "private"), "outside bytes");
|
||||
await fs.symlink(outside, path.join(root, "outside-link"));
|
||||
expected.set("outside-link", { kind: "symlink", target: outside });
|
||||
for (const ignored of ["excluded", "ignored"]) {
|
||||
await fs.mkdir(path.join(root, ignored));
|
||||
await fs.writeFile(path.join(root, ignored, "private"), "omitted bytes");
|
||||
}
|
||||
for (const diskBacked of [false, true]) {
|
||||
const snapshot = await captureDirectorySnapshot(root, {
|
||||
exclude: ["excluded"], ignoredPaths: ["ignored"], diskBacked,
|
||||
});
|
||||
try {
|
||||
expect(new Map(snapshot.entries)).toEqual(expected);
|
||||
} finally {
|
||||
await disposeDirectorySnapshot(snapshot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("overlaps reads within a fixed bound even across nested directories", async () => {
|
||||
const root = await fixture();
|
||||
for (const directory of ["", "nested", "nested/deeper"]) {
|
||||
await fs.mkdir(path.join(root, directory), { recursive: true });
|
||||
await Promise.all(Array.from({ length: 35 }, (_, index) =>
|
||||
fs.writeFile(path.join(root, directory, `file-${index}`), String(index))));
|
||||
}
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
vi.mocked(createReadStream).mockImplementation((...args) => {
|
||||
const stream = originalFs.createReadStream(...args);
|
||||
stream.pause();
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
const settled = () => { active--; };
|
||||
stream.once("end", settled);
|
||||
stream.once("error", settled);
|
||||
setTimeout(() => stream.resume(), 10);
|
||||
return stream;
|
||||
});
|
||||
const snapshot = await captureDirectorySnapshot(root, { diskBacked: true });
|
||||
try {
|
||||
expect(snapshot.entries.size).toBe(107);
|
||||
expect(peak).toBeGreaterThan(1);
|
||||
expect(peak).toBeLessThanOrEqual(16);
|
||||
expect(active).toBe(0);
|
||||
} finally {
|
||||
await disposeDirectorySnapshot(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles started metadata reads before propagating a peer failure", async () => {
|
||||
const root = await fixture();
|
||||
await fs.writeFile(path.join(root, "denied"), "denied");
|
||||
await fs.writeFile(path.join(root, "pending"), "pending");
|
||||
const lstat = fs.lstat.bind(fs);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
let pending = false;
|
||||
let active = 0;
|
||||
vi.spyOn(fs, "lstat").mockImplementation(async (...args: Parameters<typeof fs.lstat>) => {
|
||||
if (String(args[0]) === path.join(root, "denied")) throw Object.assign(new Error("denied"), { code: "EACCES" });
|
||||
active++;
|
||||
pending = true;
|
||||
try {
|
||||
await gate;
|
||||
return await lstat(...args);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
});
|
||||
let settled = false;
|
||||
const result = captureDirectorySnapshot(root, { diskBacked: true }).then(
|
||||
() => { settled = true; return null; },
|
||||
error => { settled = true; return error; },
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => expect(pending).toBe(true));
|
||||
expect(settled).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
expect(await result).toMatchObject({ code: "EACCES" });
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
|
||||
it("settles delayed peer content reads before returning a content failure", async () => {
|
||||
const root = await fixture();
|
||||
await fs.writeFile(path.join(root, "failed"), "failed");
|
||||
await fs.writeFile(path.join(root, "pending"), "pending bytes");
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
let pending = false;
|
||||
let failed = false;
|
||||
let active = 0;
|
||||
vi.mocked(createReadStream).mockImplementation((...args) => {
|
||||
const stream = originalFs.createReadStream(...args);
|
||||
stream.pause();
|
||||
active++;
|
||||
stream.once("end", () => { active--; });
|
||||
stream.once("error", () => { active--; failed = true; });
|
||||
if (String(args[0]) === path.join(root, "failed")) {
|
||||
void setImmediate().then(() => stream.destroy(Object.assign(new Error("read failed"), { code: "EIO" })));
|
||||
} else {
|
||||
pending = true;
|
||||
void gate.then(() => stream.resume());
|
||||
}
|
||||
return stream;
|
||||
});
|
||||
let settled = false;
|
||||
const result = captureDirectorySnapshot(root, { diskBacked: true }).then(
|
||||
() => { settled = true; return null; },
|
||||
error => { settled = true; return error; },
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => expect(failed && pending).toBe(true));
|
||||
expect(settled).toBe(false);
|
||||
expect(active).toBe(1);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
expect(await result).toMatchObject({ code: "EIO" });
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE "grok_bot_dispatches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"routine_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"grant_id" uuid NOT NULL,
|
||||
"routine_revision" integer NOT NULL,
|
||||
"assignment_id" uuid,
|
||||
"source_event_id" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"challenge_hash" text,
|
||||
"status" text DEFAULT 'prepared' NOT NULL,
|
||||
"http_status" integer,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"sent_at" timestamp with time zone,
|
||||
"acknowledged_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "grok_bot_routines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"binding_id" uuid NOT NULL,
|
||||
"binding_generation" integer NOT NULL,
|
||||
"bot_name" text NOT NULL,
|
||||
"secret_id" uuid NOT NULL,
|
||||
"secret_version" integer NOT NULL,
|
||||
"revision" integer DEFAULT 1 NOT NULL,
|
||||
"verified_grant_id" uuid,
|
||||
"verified_at" timestamp with time zone,
|
||||
"last_contact_at" timestamp with time zone,
|
||||
"recovery_required_at" timestamp with time zone,
|
||||
"recovery_reason" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "dot_agent_bindings" ADD COLUMN "provider_kind" text DEFAULT 'openai_dot' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "dot_runner_operations" ADD COLUMN "pending_returned_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "dot_runner_operations" ADD COLUMN "observed_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_attachment_uploads" ADD COLUMN "grant_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_attachment_uploads" ADD COLUMN "work_session_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "mcp_file_tickets" ADD COLUMN "work_session_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_dispatches" ADD CONSTRAINT "grok_bot_dispatches_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_dispatches" ADD CONSTRAINT "grok_bot_dispatches_routine_id_grok_bot_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."grok_bot_routines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_dispatches" ADD CONSTRAINT "grok_bot_dispatches_binding_id_dot_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."dot_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_dispatches" ADD CONSTRAINT "grok_bot_dispatches_grant_id_mcp_oauth_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."mcp_oauth_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_dispatches" ADD CONSTRAINT "grok_bot_dispatches_assignment_id_dot_runner_assignments_id_fk" FOREIGN KEY ("assignment_id") REFERENCES "public"."dot_runner_assignments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_routines" ADD CONSTRAINT "grok_bot_routines_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_routines" ADD CONSTRAINT "grok_bot_routines_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_routines" ADD CONSTRAINT "grok_bot_routines_binding_id_dot_agent_bindings_id_fk" FOREIGN KEY ("binding_id") REFERENCES "public"."dot_agent_bindings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_routines" ADD CONSTRAINT "grok_bot_routines_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "grok_bot_routines" ADD CONSTRAINT "grok_bot_routines_verified_grant_id_mcp_oauth_grants_id_fk" FOREIGN KEY ("verified_grant_id") REFERENCES "public"."mcp_oauth_grants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "grok_dispatch_event_uq" ON "grok_bot_dispatches" USING btree ("binding_id","binding_generation","routine_revision","source_event_id");--> statement-breakpoint
|
||||
CREATE INDEX "grok_dispatch_company_status_idx" ON "grok_bot_dispatches" USING btree ("company_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "grok_routines_binding_uq" ON "grok_bot_routines" USING btree ("binding_id");--> statement-breakpoint
|
||||
CREATE INDEX "grok_routines_company_agent_idx" ON "grok_bot_routines" USING btree ("company_id","agent_id");--> statement-breakpoint
|
||||
ALTER TABLE "mcp_attachment_uploads" ADD CONSTRAINT "mcp_attachment_uploads_grant_id_mcp_oauth_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."mcp_oauth_grants"("id") ON DELETE cascade ON UPDATE no action;
|
||||
+935
-8
File diff suppressed because it is too large.
Load diff
+7
@@ -2395,6 +2395,13 @@
|
||||
"when": 1791926375117,
|
||||
"tag": "0343_new_mentor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 344,
|
||||
"version": "7",
|
||||
"when": 1791930226792,
|
||||
"tag": "0344_wide_blue_marvel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const dotAgentBindings = pgTable("dot_agent_bindings", {
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
operatorId: text("operator_id").notNull(),
|
||||
grantId: uuid("grant_id").references(() => mcpOauthGrants.id, { onDelete: "set null" }),
|
||||
providerKind: text("provider_kind").$type<"openai_dot" | "grok_bot">().notNull().default("openai_dot"),
|
||||
generation: integer("generation").notNull().default(1),
|
||||
status: text("status").$type<"pairing" | "connected" | "ready" | "revoked">().notNull().default("pairing"),
|
||||
pairingCodeHash: text("pairing_code_hash"),
|
||||
@@ -63,6 +64,8 @@ export const dotRunnerOperations = pgTable("dot_runner_operations", {
|
||||
command: jsonb("command").$type<Record<string, unknown>>().notNull(),
|
||||
status: text("status").$type<"reserved" | "admitted" | "completed" | "rejected" | "unknown">().notNull().default("reserved"),
|
||||
outcome: jsonb("outcome").$type<Record<string, unknown>>(),
|
||||
pendingReturnedAt: timestamp("pending_returned_at", { withTimezone: true }),
|
||||
observedAt: timestamp("observed_at", { withTimezone: true }),
|
||||
sourceEventId: text("source_event_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { companySecrets } from "./company_secrets.js";
|
||||
import { dotAgentBindings, dotRunnerAssignments } from "./dot_runner.js";
|
||||
import { mcpOauthGrants } from "./public_mcp.js";
|
||||
|
||||
// Keep the shared binding/assignment tables and their unique ownership constraints.
|
||||
// Routine material is one encrypted secret version, never an agent config field.
|
||||
export const grokBotRoutines = pgTable("grok_bot_routines", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => dotAgentBindings.id, { onDelete: "cascade" }),
|
||||
bindingGeneration: integer("binding_generation").notNull(),
|
||||
botName: text("bot_name").notNull(),
|
||||
secretId: uuid("secret_id").notNull().references(() => companySecrets.id),
|
||||
secretVersion: integer("secret_version").notNull(),
|
||||
revision: integer("revision").notNull().default(1),
|
||||
verifiedGrantId: uuid("verified_grant_id").references(() => mcpOauthGrants.id, { onDelete: "set null" }),
|
||||
verifiedAt: timestamp("verified_at", { withTimezone: true }),
|
||||
lastContactAt: timestamp("last_contact_at", { withTimezone: true }),
|
||||
recoveryRequiredAt: timestamp("recovery_required_at", { withTimezone: true }),
|
||||
recoveryReason: text("recovery_reason").$type<"dispatch" | "work_session">(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, t => [uniqueIndex("grok_routines_binding_uq").on(t.bindingId), index("grok_routines_company_agent_idx").on(t.companyId, t.agentId)]);
|
||||
|
||||
export const grokBotDispatches = pgTable("grok_bot_dispatches", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
routineId: uuid("routine_id").notNull().references(() => grokBotRoutines.id, { onDelete: "cascade" }),
|
||||
bindingId: uuid("binding_id").notNull().references(() => dotAgentBindings.id, { onDelete: "cascade" }),
|
||||
bindingGeneration: integer("binding_generation").notNull(),
|
||||
grantId: uuid("grant_id").notNull().references(() => mcpOauthGrants.id, { onDelete: "cascade" }),
|
||||
routineRevision: integer("routine_revision").notNull(),
|
||||
assignmentId: uuid("assignment_id").references(() => dotRunnerAssignments.id, { onDelete: "cascade" }),
|
||||
sourceEventId: text("source_event_id").notNull(),
|
||||
kind: text("kind").$type<"verify" | "mailbox_updated" | "authority_revoked">().notNull(),
|
||||
// Closed references only. Challenge possession alone carries no authority.
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
challengeHash: text("challenge_hash"),
|
||||
status: text("status").$type<"prepared" | "sending" | "accepted" | "rejected" | "unknown" | "acknowledged" | "cancelled">().notNull().default("prepared"),
|
||||
httpStatus: integer("http_status"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, t => [
|
||||
uniqueIndex("grok_dispatch_event_uq").on(t.bindingId, t.bindingGeneration, t.routineRevision, t.sourceEventId),
|
||||
index("grok_dispatch_company_status_idx").on(t.companyId, t.status),
|
||||
]);
|
||||
@@ -227,6 +227,7 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows
|
||||
export * from "./company_skill_sources.js";
|
||||
export * from "./public_mcp.js";
|
||||
export * from "./dot_runner.js";
|
||||
export * from "./grok_bot.js";
|
||||
|
||||
|
||||
export * from "./accounting.js";
|
||||
|
||||
@@ -170,6 +170,8 @@ export const mcpEventDeliveries = pgTable("mcp_event_deliveries", {
|
||||
|
||||
// Upload identities outlive their short-lived tickets so retries cannot duplicate files.
|
||||
export const mcpAttachmentUploads = pgTable("mcp_attachment_uploads", {
|
||||
grantId: uuid("grant_id").references(() => mcpOauthGrants.id, { onDelete: "cascade" }),
|
||||
workSessionId: uuid("work_session_id"),
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
@@ -193,6 +195,7 @@ export const mcpAttachmentUploads = pgTable("mcp_attachment_uploads", {
|
||||
]);
|
||||
|
||||
export const mcpFileTickets = pgTable("mcp_file_tickets", {
|
||||
workSessionId: uuid("work_session_id"),
|
||||
tokenHash: text("token_hash").primaryKey(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
grantId: uuid("grant_id").notNull().references(() => mcpOauthGrants.id, { onDelete: "cascade" }),
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 17,
|
||||
"agentProfileVersion": 18,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f",
|
||||
"commandDigest": "sha256:2ec904f44218f51058fa83d7437b26c4551e1c65e6f2c55f5a6386fa4061e96d",
|
||||
"requiresProviderPolicy": true,
|
||||
"qualificationStatus": "pending"
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
{
|
||||
"path": "schemas/provider-descriptor.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
|
||||
"sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519"
|
||||
"sha256": "5ce1b2ad05777b33c71d1a3c05b29496b661421f9b232a551d8d584c5dd07ee3"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"claude_managed",
|
||||
"aws_agentcore",
|
||||
"acpx",
|
||||
"openai_dot"
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
]
|
||||
},
|
||||
"driver": {
|
||||
@@ -28,7 +29,8 @@
|
||||
"claude_managed_agents_api",
|
||||
"aws_agentcore_harness_api",
|
||||
"acpx_runtime",
|
||||
"openai_dot_mcp"
|
||||
"openai_dot_mcp",
|
||||
"grok_bot_mcp"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
@@ -53,7 +55,8 @@
|
||||
"enum": [
|
||||
"anthropic_managed_agents",
|
||||
"aws_bedrock_agentcore_harness",
|
||||
"openai_dot"
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
]
|
||||
},
|
||||
"providerSessionId": {
|
||||
@@ -255,6 +258,19 @@
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "grok_bot"
|
||||
},
|
||||
"driver": {
|
||||
"const": "grok_bot_mcp"
|
||||
},
|
||||
"executionKind": {
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -393,6 +409,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "grok_bot"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"service",
|
||||
"providerSessionId"
|
||||
],
|
||||
"properties": {
|
||||
"service": {
|
||||
"const": "grok_bot"
|
||||
},
|
||||
"model": {
|
||||
"const": null
|
||||
},
|
||||
"providerSessionId": {
|
||||
"const": null
|
||||
},
|
||||
"agentProcessId": {
|
||||
"const": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
|
||||
@@ -120,7 +120,7 @@ fn build_metadata() -> serde_json::Value {
|
||||
"packageVersion": env!("CARGO_PKG_VERSION"),
|
||||
"binaryContractVersion": 2,
|
||||
"durableSessionCapabilities": ["unlimited_runtime", "connection_lease_renewal"],
|
||||
"externalProviderCapabilities": ["openai_dot_mcp"],
|
||||
"externalProviderCapabilities": ["openai_dot_mcp", "grok_bot_mcp"],
|
||||
"nativeExecutionVersion": 1,
|
||||
"harnessDriverVersion": 1,
|
||||
"prp": {
|
||||
@@ -448,7 +448,7 @@ mod tests {
|
||||
assert_eq!(metadata["binaryContractVersion"], 2);
|
||||
assert_eq!(
|
||||
metadata["externalProviderCapabilities"],
|
||||
json!(["openai_dot_mcp"])
|
||||
json!(["openai_dot_mcp", "grok_bot_mcp"])
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["durableSessionCapabilities"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Dot is an external service. This executor owns the durable turn and tool
|
||||
//! Dot and Grok Bot are external services. This executor owns the durable turn and tool
|
||||
//! receipts; the authenticated controller owns OAuth, the mailbox and policy.
|
||||
//! No network credential or callback address enters this provider descriptor.
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
@@ -99,6 +99,22 @@ fn bounded_id(value: &str) -> bool {
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._:-".contains(&c))
|
||||
}
|
||||
|
||||
fn external_driver(kind: &str) -> &'static str {
|
||||
if kind == "grok_bot" {
|
||||
"grok_bot_mcp"
|
||||
} else {
|
||||
"openai_dot_mcp"
|
||||
}
|
||||
}
|
||||
|
||||
fn external_revision(kind: &str) -> &'static str {
|
||||
if kind == "grok_bot" {
|
||||
"grok-bot-mcp-v1"
|
||||
} else {
|
||||
"dot-mcp-v1"
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn validate(&self, config: &DurableRunnerConfig) -> Result<(), DurableRunnerError> {
|
||||
let d = &self.descriptor;
|
||||
@@ -106,7 +122,7 @@ impl State {
|
||||
|| self.run_id != config.run_id
|
||||
|| self.session_id != config.normalized_session_id
|
||||
|| self.turn_id != config.turn_id
|
||||
|| d.kind != "openai_dot"
|
||||
|| !matches!(d.kind.as_str(), "openai_dot" | "grok_bot")
|
||||
|| !bounded_id(&d.company_id)
|
||||
|| !bounded_id(&d.agent_id)
|
||||
|| !bounded_id(&d.binding_id)
|
||||
@@ -343,7 +359,13 @@ impl DotCommandExecutor {
|
||||
next_event: 1,
|
||||
});
|
||||
}
|
||||
Ok(json!({"status":"prepared","provider":"openai_dot","driver":"openai_dot_mcp"}))
|
||||
let s = self
|
||||
.state
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid("External bot is not prepared"))?;
|
||||
Ok(
|
||||
json!({"status":"prepared","provider":s.descriptor.kind,"driver":external_driver(&s.descriptor.kind)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn operation(&mut self, value: &Value) -> Result<Value, DurableRunnerError> {
|
||||
@@ -384,7 +406,7 @@ impl DotCommandExecutor {
|
||||
return Err(invalid("Dot assignment is unavailable for acceptance"));
|
||||
}
|
||||
state.lifecycle = "running".to_owned();
|
||||
state.push("turn.started", json!({"provider":"openai_dot","providerTurnId":state.turn_id,"turnId":state.turn_id}))?;
|
||||
state.push("turn.started", json!({"provider":state.descriptor.kind,"providerTurnId":state.turn_id,"turnId":state.turn_id}))?;
|
||||
json!({"status":"accepted"})
|
||||
}
|
||||
"tool" => {
|
||||
@@ -432,7 +454,7 @@ impl DotCommandExecutor {
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty() && s.len() <= 12000)
|
||||
.ok_or_else(|| invalid("Dot progress must be bounded text"))?;
|
||||
state.push("item.completed", json!({"provider":"openai_dot","item":{"id":id,"type":"agentMessage","text":text,"phase":"commentary"}}))?;
|
||||
state.push("item.completed", json!({"provider":state.descriptor.kind,"item":{"id":id,"type":"agentMessage","text":text,"phase":"commentary"}}))?;
|
||||
json!({"status":"completed"})
|
||||
}
|
||||
"finish" => {
|
||||
@@ -455,8 +477,8 @@ impl DotCommandExecutor {
|
||||
.settle_turn("completed")
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
state.push("run.result.proposed", result.clone())?;
|
||||
state.push("item.completed", json!({"provider":"openai_dot","item":{"id":id,"type":"agentMessage","text":result["summary"],"phase":"final_answer"}}))?;
|
||||
state.push("turn.completed", json!({"provider":"openai_dot","providerTurnId":state.turn_id,"status":"completed"}))?;
|
||||
state.push("item.completed", json!({"provider":state.descriptor.kind,"item":{"id":id,"type":"agentMessage","text":result["summary"],"phase":"final_answer"}}))?;
|
||||
state.push("turn.completed", json!({"provider":state.descriptor.kind,"providerTurnId":state.turn_id,"status":"completed"}))?;
|
||||
state.push("run.terminal", json!({"schema":"paperclip.prp.terminal.v1","turnTerminalState":"completed","runTerminalState":"succeeded","reportedWorkDisposition":result["reportedWorkDisposition"]}))?;
|
||||
json!({"status":"completed"})
|
||||
}
|
||||
@@ -566,9 +588,9 @@ impl DotCommandExecutor {
|
||||
.ok_or_else(|| invalid("Dot is not prepared"))?;
|
||||
if s.lifecycle == "prepared" {
|
||||
s.lifecycle = "open".to_owned();
|
||||
s.push("session.started", json!({"provider":"openai_dot","driver":"openai_dot_mcp","providerVersion":"dot-mcp-v1",
|
||||
s.push("session.started", json!({"provider":s.descriptor.kind,"driver":external_driver(&s.descriptor.kind),"providerVersion":external_revision(&s.descriptor.kind),
|
||||
"driverSessionId":s.session_id,"providerSessionId":null,"sessionId":s.session_id,"model":null,
|
||||
"providerDescriptor":{"provider":"openai_dot","driver":"openai_dot_mcp","executionKind":"remote_service","service":"openai_dot","model":null,"providerVersion":"unknown","providerSessionId":null,"processId":null}}))?;
|
||||
"providerDescriptor":{"provider":s.descriptor.kind,"driver":external_driver(&s.descriptor.kind),"executionKind":"remote_service","service":s.descriptor.kind,"model":null,"providerVersion":"unknown","providerSessionId":null,"processId":null}}))?;
|
||||
}
|
||||
Ok(
|
||||
json!({"status":"started","driverSessionId":s.session_id,"providerSessionId":null}),
|
||||
@@ -604,7 +626,7 @@ impl DotCommandExecutor {
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid("Dot is not prepared"))?;
|
||||
Ok(
|
||||
json!({"status":s.lifecycle,"provider":"openai_dot","driver":"openai_dot_mcp","driverSessionId":s.session_id,
|
||||
json!({"status":s.lifecycle,"provider":s.descriptor.kind,"driver":external_driver(&s.descriptor.kind),"driverSessionId":s.session_id,
|
||||
"providerSessionId":null,"activeProviderTurnId":if matches!(s.lifecycle.as_str(), "offered" | "running") {Some(&s.turn_id)} else {None},
|
||||
"operationCount":s.operations.len(),"pendingTools":s.tools.pending_calls().count(),"binding":s.binding()}),
|
||||
)
|
||||
@@ -620,7 +642,7 @@ impl DotCommandExecutor {
|
||||
.settle_turn("authority_revoked")
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
s.push("external_provider.dispatch_requested", json!({"binding":s.binding(),"kind":"authority_revoked","externalStopConfirmed":false}))?;
|
||||
s.push("turn.cancelled", json!({"provider":"openai_dot","providerTurnId":s.turn_id,"externalStopConfirmed":false}))?;
|
||||
s.push("turn.cancelled", json!({"provider":s.descriptor.kind,"providerTurnId":s.turn_id,"externalStopConfirmed":false}))?;
|
||||
s.push("run.terminal", json!({"schema":"paperclip.prp.terminal.v1","turnTerminalState":"cancelled","runTerminalState":"cancelled","reportedWorkDisposition":"needs_review"}))?;
|
||||
}
|
||||
Ok(
|
||||
@@ -680,7 +702,7 @@ impl CommandExecutor for DotCommandExecutor {
|
||||
let expiry = (|| -> Result<(), DurableRunnerError> {
|
||||
s.tools.settle_turn("authority_expired").map_err(|e| invalid(e.to_string()))?;
|
||||
s.push("external_provider.dispatch_requested", json!({"binding":s.binding(),"kind":"authority_revoked","reason":"authority_expired","externalStopConfirmed":false}))?;
|
||||
s.push("turn.failed", json!({"provider":"openai_dot","providerTurnId":s.turn_id,"error":{"code":"dot_assignment_expired","message":"Dot assignment acceptance or execution authority expired."}}))?;
|
||||
s.push("turn.failed", json!({"provider":s.descriptor.kind,"providerTurnId":s.turn_id,"error":{"code":"dot_assignment_expired","message":"Dot assignment acceptance or execution authority expired."}}))?;
|
||||
s.push("run.terminal", json!({"schema":"paperclip.prp.terminal.v1","turnTerminalState":"failed","runTerminalState":"failed","reportedWorkDisposition":"needs_review"}))?;
|
||||
Ok(())
|
||||
})().and_then(|_| self.save());
|
||||
@@ -791,6 +813,10 @@ mod tests {
|
||||
"digest":semantic_value_digest(&json!({"action":action,"input":input})),"action":action,"input":input})
|
||||
}
|
||||
fn prepared(path: &std::path::Path) -> DotCommandExecutor {
|
||||
prepared_for(path, "openai_dot")
|
||||
}
|
||||
|
||||
fn prepared_for(path: &std::path::Path, provider: &str) -> DotCommandExecutor {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -817,7 +843,7 @@ mod tests {
|
||||
call(
|
||||
&mut executor,
|
||||
"run.prepare",
|
||||
json!({"provider":{"kind":"openai_dot","companyId":"company-1","agentId":"agent-1",
|
||||
json!({"provider":{"kind":provider,"companyId":"company-1","agentId":"agent-1",
|
||||
"bindingId":"binding-1","bindingGeneration":1,"acceptByUnixMs":now+600000,"expiresAtUnixMs":now+7200000,"instructions":"test"},
|
||||
"completionContract":{"revision":"contract-1","criterionIds":["criterion-1"]},"authorizedTools":catalog}),
|
||||
);
|
||||
@@ -832,6 +858,61 @@ mod tests {
|
||||
operation("accept-1", "accept", json!({})),
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn grok_identity_survives_recovery_and_revocation_without_claiming_remote_termination() {
|
||||
let dir = TestDirectory::new();
|
||||
let mut executor = prepared_for(dir.path(), "grok_bot");
|
||||
let events = executor.poll_events().unwrap();
|
||||
assert!(events.iter().any(|e| e.event_type == "session.started"
|
||||
&& e.payload["provider"] == "grok_bot"
|
||||
&& e.payload["driver"] == "grok_bot_mcp"));
|
||||
accept(&mut executor);
|
||||
let progress = operation(
|
||||
"progress-1",
|
||||
"progress",
|
||||
json!({"text":"Synthetic Grok progress"}),
|
||||
);
|
||||
let result = call(
|
||||
&mut executor,
|
||||
"external_provider.operation",
|
||||
progress.clone(),
|
||||
);
|
||||
drop(executor);
|
||||
let mut recovered = DotCommandExecutor::with_runner_config(dir.path(), &config(dir.path()));
|
||||
assert_eq!(
|
||||
call(&mut recovered, "external_provider.operation", progress),
|
||||
result
|
||||
);
|
||||
let snapshot = call(&mut recovered, "session.snapshot", json!({}));
|
||||
assert_eq!(snapshot["provider"], "grok_bot");
|
||||
assert_eq!(snapshot["driver"], "grok_bot_mcp");
|
||||
let state = recovered.state.as_ref().unwrap();
|
||||
let mut descriptor = serde_json::to_value(&state.descriptor).unwrap();
|
||||
descriptor["kind"] = json!("openai_dot");
|
||||
let tools = state.tools.authorized_tools().cloned().collect::<Vec<_>>();
|
||||
let catalog = AuthorizedToolSet {
|
||||
schema: "paperclip.runner.authorized-tools.v1".into(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&tools).unwrap(),
|
||||
operations: tools,
|
||||
};
|
||||
let changed = json!({"provider":descriptor,"completionContract":state.completion_contract,"authorizedTools":catalog});
|
||||
let error = recovered
|
||||
.execute(&command("run.prepare", changed))
|
||||
.unwrap_err();
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("immutable provider authority changed"));
|
||||
let revoked = call(&mut recovered, "run.cancel", json!({}));
|
||||
assert_eq!(revoked["externalStopConfirmed"], false);
|
||||
assert!(recovered
|
||||
.execute(&command(
|
||||
"external_provider.operation",
|
||||
operation("late", "progress", json!({"text":"late"}))
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renewal_is_durable_bounded_and_cannot_revive_fenced_authority() {
|
||||
let dir = TestDirectory::new();
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f",
|
||||
"sha256:2ec904f44218f51058fa83d7437b26c4551e1c65e6f2c55f5a6386fa4061e96d",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
|
||||
@@ -175,10 +175,9 @@ impl NativeProviderCommandExecutor {
|
||||
)
|
||||
})?;
|
||||
self.selected = Some(match kind {
|
||||
"openai_dot" => SelectedExecutor::Dot(DotCommandExecutor::with_runner_config(
|
||||
&self.state_dir,
|
||||
&self.config,
|
||||
)),
|
||||
"openai_dot" | "grok_bot" => SelectedExecutor::Dot(
|
||||
DotCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
),
|
||||
"codex" | "opencode" => SelectedExecutor::LocalFacade(
|
||||
CodexCommandExecutor::with_runner_config(&self.state_dir, &self.config),
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-identity.json"]]) {
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v18-identity.json"]]) {
|
||||
const identity = await readJson(path);
|
||||
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
|
||||
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import type { CodexAppServerTransport } from "../drivers/codex/app-server-transport.js";
|
||||
import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js";
|
||||
import type { CodexWorkingDirectoryAuthority } from "../drivers/codex/codex-boundaries.js";
|
||||
import { describeRunnerdDotDriver } from "../drivers/dot/runnerd-dot-driver.js";
|
||||
import { describeRunnerdExternalBotDriver } from "../drivers/dot/runnerd-dot-driver.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
import {
|
||||
nativeSystemInstructions,
|
||||
@@ -209,8 +209,8 @@ export function describeRunnerdNativeSessionBackend(
|
||||
input: NativeExecutionInput,
|
||||
): Promise<NativeSessionBackendDescriptor> {
|
||||
if (input.schema === "paperclip.native-execution-input.v6") {
|
||||
// Dot uses its dedicated Rust bridge, rather than the JSON-RPC facade.
|
||||
const descriptor = describeRunnerdDotDriver();
|
||||
// External bots use the shared Rust bridge and retain their provider identity.
|
||||
const descriptor = describeRunnerdExternalBotDriver(input.provider.kind);
|
||||
return Promise.resolve({
|
||||
kind: "runner",
|
||||
name: descriptor.kind,
|
||||
|
||||
@@ -377,11 +377,12 @@ function assertProviderSessionIdentity(
|
||||
stage: "session.open" | "session.recover",
|
||||
): void {
|
||||
const ids = session.ids();
|
||||
const external = provider === "openai_dot_mcp" || provider === "grok_bot_mcp";
|
||||
if (
|
||||
typeof ids.driverSessionId !== "string" ||
|
||||
ids.driverSessionId.trim().length === 0 ||
|
||||
(provider !== "openai_dot_mcp" && (typeof ids.providerSessionId !== "string" || ids.providerSessionId.trim().length === 0))
|
||||
|| (provider === "openai_dot_mcp" && ids.providerSessionId !== null)
|
||||
(!external && (typeof ids.providerSessionId !== "string" || ids.providerSessionId.trim().length === 0))
|
||||
|| (external && ids.providerSessionId !== null)
|
||||
) {
|
||||
throw new Error(
|
||||
`provider_initialize_protocol_error: provider=${provider} stage=${stage} missing durable provider session identity`,
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createNativeSessionBackend(
|
||||
options: NativeBackendFactoryOptions = {},
|
||||
): NativeSessionBackend {
|
||||
if (input.schema === "paperclip.native-execution-input.v6") {
|
||||
if (!options.dotRunnerOptions) throw new Error("Dot requires an admitted broker port and Rust Runner authority");
|
||||
if (!options.dotRunnerOptions) throw new Error("External bots require an admitted broker port and Rust Runner authority");
|
||||
return new HarnessDriverBackend(new RunnerdDotDriver({ ...options.dotRunnerOptions, execution: input,
|
||||
dynamicTools: options.dynamicTools, dynamicToolHandler: options.dynamicToolHandler,
|
||||
completionFeedback: options.completionFeedback, onSpawn: options.onSpawn }));
|
||||
|
||||
@@ -27,11 +27,12 @@ export function nativeSystemInstructions(input: NativeExecutionInput): string {
|
||||
throw new Error("native_runtime_context_entry_outside_bundle");
|
||||
}
|
||||
const entry = readFileSync(entryPath, "utf8");
|
||||
if (input.provider.kind === "openai_dot") {
|
||||
if (input.schema === "paperclip.native-execution-input.v6") {
|
||||
const grok = input.provider.kind === "grok_bot";
|
||||
return [input.runtimeContext.prompt.text, entry.trim(),
|
||||
"This provider has no mounted workspace. Use Paperclip semantic tools for task coordination and write_document for durable text deliverables. Read pinned skills with list_assigned_skills and read_assigned_skill. Assigned app tools run through the Paperclip MCP gateway with normal permissions and approvals. If workspace tools are advertised, use them for files and sandboxed commands, then register_deliverable for requested downloadable files. Use get_identity and list_people to discover the responsible person and assignees. Read the current catalog before concluding a capability is unavailable.",
|
||||
grok ? "Grok Bot has no Paperclip filesystem or shell access. Use first-party Paperclip tools for authoritative task context, progress, questions, documents and deliverables. Use your own native apps and tools under their existing permissions. Provider memory persists; retrieve current task instructions for each work session." : "This provider has no mounted workspace. Use Paperclip semantic tools for task coordination and write_document for durable text deliverables. Read pinned skills with list_assigned_skills and read_assigned_skill. Assigned app tools run through the Paperclip MCP gateway with normal permissions and approvals. If workspace tools are advertised, use them for files and sandboxed commands, then register_deliverable for requested downloadable files. Use get_identity and list_people to discover the responsible person and assignees. Read the current catalog before concluding a capability is unavailable.",
|
||||
// Keep tool-use guidance aligned with shared external-agent onboarding; the standalone Runner has no shared-package dependency.
|
||||
"When you receive a Paperclip assignment or message, use your own available tools, apps, plugins, skills, and capabilities to complete the requested work, then return the result to Paperclip. For example, use your existing Slack connection when the task calls for Slack. Paperclip's app catalog describes what this connection exposes; it is not a list of everything you can use in your own runtime. A missing Paperclip app connection does not make your own connection unavailable. Respect each tool's existing permissions, approvals, and the user's current instructions. Keep Paperclip data and actions within this connection's authorized company, agent, and task. Never use another Paperclip connection or identity to bypass a denial. Setup or test restrictions apply only to their stated scope; do not carry them into later requests unless the user explicitly says they continue.\n\nUse the assignment catalog through paperclip_dot_tool for Paperclip operations. Use your native tools directly for the rest of the work; they do not need to appear in that catalog. Record useful results and deliverables on the task and follow the Paperclip completion instructions.",
|
||||
"When you receive a Paperclip assignment or message, use your own available tools, apps, plugins, skills, and capabilities to complete the requested work, then return the result to Paperclip. For example, use your existing Slack connection when the task calls for Slack. Paperclip's app catalog describes what this connection exposes; it is not a list of everything you can use in your own runtime. A missing Paperclip app connection does not make your own connection unavailable. Respect each tool's existing permissions, approvals, and the user's current instructions. Keep Paperclip data and actions within this connection's authorized company, agent, and task. Never use another Paperclip connection or identity to bypass a denial. Setup or test restrictions apply only to their stated scope; do not carry them into later requests unless the user explicitly says they continue.\n\nUse the assignment catalog through paperclip_dot_tool for Paperclip operations. Use your native tools directly for the rest of the work; they do not need to appear in that catalog. Record useful results and deliverables on the task and follow the Paperclip completion instructions.".replaceAll("paperclip_dot_", grok ? "paperclip_grok_" : "paperclip_dot_"),
|
||||
`Assigned Paperclip skills: ${input.runtimeContext.skills.map(skill => skill.runtimeName).join(", ") || "none"}.`].join("\n\n");
|
||||
}
|
||||
return composeNativeSystemInstructions(input.runtimeContext, entry);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
|
||||
|
||||
export type ExternalBotProviderKind = "openai_dot" | "grok_bot";
|
||||
export const GROK_BRIDGE_REVISION = "grok-bot-mcp-v1" as const;
|
||||
export const DOT_BRIDGE_REVISION = "dot-mcp-v1" as const;
|
||||
export interface DotBindingSnapshot {
|
||||
bindingId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -407,6 +408,16 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("does not match");
|
||||
});
|
||||
|
||||
it("decodes saved Copilot v17 input after the current source declaration advances", () => {
|
||||
const historical = JSON.parse(readFileSync(new URL("../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
const { permissionPolicy, qualificationStatus: _status, ...currentProfile } = QUALIFIED_ACPX_PROFILES.copilot;
|
||||
const provider = { kind: "acpx", agent: "copilot", model: "explicit-provider-model", permissionPolicy,
|
||||
profile: { ...currentProfile, agentProfileVersion: 17, commandDigest: historical.commandDigest } };
|
||||
const parsed = parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, provider });
|
||||
expect(parsed.provider).toEqual(provider);
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it.each(Object.values(QUALIFIED_ACPX_PROFILES))(
|
||||
"admits the current $agent profile through the native execution boundary",
|
||||
(declaration) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DotBindingSnapshot } from "./external-provider.js";
|
||||
import type { DotBindingSnapshot, ExternalBotProviderKind } from "./external-provider.js";
|
||||
import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js";
|
||||
import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js";
|
||||
import { isProviderMode } from "./provider-mode.js";
|
||||
@@ -240,9 +240,9 @@ export interface NativeExecutionInputV5 extends Omit<NativeExecutionInputV4, "sc
|
||||
|
||||
export interface NativeExecutionInputV6 extends Omit<NativeExecutionInputV5, "schema" | "provider" | "workspace" | "session"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V6;
|
||||
provider: { kind: "openai_dot"; model: null; binding: DotBindingSnapshot };
|
||||
provider: { kind: ExternalBotProviderKind; model: null; binding: DotBindingSnapshot };
|
||||
workspace: { access: "none"; cwd: null; repoUrl: null; repoRef: null; branchName: null };
|
||||
session: Omit<NativeExecutionInputV5["session"], "driverKind"> & { driverKind: "openai_dot_mcp" };
|
||||
session: Omit<NativeExecutionInputV5["session"], "driverKind"> & { driverKind: "openai_dot_mcp" | "grok_bot_mcp" };
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
@@ -334,7 +334,7 @@ function nullableText(value: unknown, path: string): string | null {
|
||||
* intentionally not an extensible metadata bag: new fields require a contract
|
||||
* revision and an explicit security review.
|
||||
*/
|
||||
function parseDotNativeExecutionInput(input: Record<string, unknown>): NativeExecutionInputV6 {
|
||||
function parseExternalNativeExecutionInput(input: Record<string, unknown>): NativeExecutionInputV6 {
|
||||
const provider = record(input.provider, "input.provider");
|
||||
exactKeys(provider, ["kind", "model", "binding"], "input.provider");
|
||||
const b = record(provider.binding, "input.provider.binding");
|
||||
@@ -343,16 +343,17 @@ function parseDotNativeExecutionInput(input: Record<string, unknown>): NativeExe
|
||||
const workspace = record(input.workspace, "input.workspace");
|
||||
exactKeys(workspace, ["access", "cwd", "repoUrl", "repoRef", "branchName"], "input.workspace");
|
||||
const session = record(input.session, "input.session");
|
||||
if (provider.kind !== "openai_dot" || provider.model !== null || workspace.access !== "none"
|
||||
const driverKind = provider.kind === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp";
|
||||
if (!["openai_dot", "grok_bot"].includes(String(provider.kind)) || provider.model !== null || workspace.access !== "none"
|
||||
|| [workspace.cwd, workspace.repoUrl, workspace.repoRef, workspace.branchName].some(v => v !== null)
|
||||
|| session.driverKind !== "openai_dot_mcp" || record(session.lifecyclePolicy, "input.session.lifecyclePolicy").mode !== "per_turn"
|
||||
|| session.driverKind !== driverKind || record(session.lifecyclePolicy, "input.session.lifecyclePolicy").mode !== "per_turn"
|
||||
|| binding.companyId !== b.companyId || binding.agentId !== b.agentId
|
||||
|| !Number.isSafeInteger(b.bindingGeneration) || Number(b.bindingGeneration) < 1
|
||||
|| !Number.isSafeInteger(b.acceptByUnixMs) || !Number.isSafeInteger(b.expiresAtUnixMs)
|
||||
|| Number(b.acceptByUnixMs) < 1 || Number(b.acceptByUnixMs) >= Number(b.expiresAtUnixMs)
|
||||
|| Number(b.expiresAtUnixMs) - Number(b.acceptByUnixMs) > 86_400_000
|
||||
|| !Array.isArray(input.credentialBindings) || input.credentialBindings.length !== 0) {
|
||||
throw new NativeExecutionInputError("Dot v6 requires a bound per-turn remote service with no filesystem or credential access");
|
||||
throw new NativeExecutionInputError("External v6 requires a bound per-turn remote service with no filesystem or credential access");
|
||||
}
|
||||
// Reuse the preceding closed contract's task/context validation. The local
|
||||
// validation value never leaves this function or becomes an execution path.
|
||||
@@ -361,17 +362,17 @@ function parseDotNativeExecutionInput(input: Record<string, unknown>): NativeExe
|
||||
session: { ...session, driverKind: "codex_app_server" },
|
||||
workspace: { cwd: "/", repoUrl: null, repoRef: null, branchName: null } }) as NativeExecutionInputV5;
|
||||
return { ...common, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V6,
|
||||
provider: { kind: "openai_dot", model: null, binding: {
|
||||
provider: { kind: provider.kind as ExternalBotProviderKind, model: null, binding: {
|
||||
bindingId: text(b.bindingId, "input.provider.binding.bindingId"), bindingGeneration: Number(b.bindingGeneration),
|
||||
companyId: text(b.companyId, "input.provider.binding.companyId"), agentId: text(b.agentId, "input.provider.binding.agentId"),
|
||||
acceptByUnixMs: Number(b.acceptByUnixMs), expiresAtUnixMs: Number(b.expiresAtUnixMs),
|
||||
} }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...common.session, driverKind: "openai_dot_mcp" } };
|
||||
session: { ...common.session, driverKind } };
|
||||
}
|
||||
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
|
||||
const input = record(value, "input");
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseDotNativeExecutionInput(input);
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseExternalNativeExecutionInput(input);
|
||||
const isV5 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV4 = isV5 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V4;
|
||||
const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3;
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface NativeSessionCapabilities {
|
||||
queuedFollowUp?: boolean;
|
||||
interruption: boolean;
|
||||
structuredResult: boolean;
|
||||
/** Defaults to existing provider terminal guarantees when omitted. */
|
||||
completionBoundary?: "provider_termination" | "paperclip_work_session";
|
||||
/** External work-session completion does not prove a remote process exited. */
|
||||
providerTermination?: "confirmed" | "unavailable";
|
||||
providerTranscriptStreaming?: boolean;
|
||||
read?: boolean;
|
||||
reconciliation?: boolean;
|
||||
usage?: boolean;
|
||||
|
||||
@@ -87,8 +87,16 @@ describe("Copilot build-owned installation", () => {
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("admits the current v17 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
it("preserves v17 history while binding the shared Grok descriptor schema", () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSchemaBundleSourceSha256"]);
|
||||
expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort());
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
it("admits the current v18 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
@@ -119,7 +127,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
@@ -160,7 +168,7 @@ describe("Copilot build-owned installation", () => {
|
||||
});
|
||||
it("rejects changed profile identities before native file access", async () => {
|
||||
vi.mocked(verifyNativeAcpxInstallation).mockClear();
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }]) {
|
||||
for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }, { agentProfileVersion: 9 }, { agentProfileVersion: 10 }, { agentProfileVersion: 17 }]) {
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned");
|
||||
}
|
||||
expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled();
|
||||
|
||||
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 17,
|
||||
"agentProfileVersion": 18,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f",
|
||||
"commandDigest": "sha256:2ec904f44218f51058fa83d7437b26c4551e1c65e6f2c55f5a6386fa4061e96d",
|
||||
"qualificationStatus": "pending",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isSupportedAcpxProfileVersion } from "./profile-compatibility.js";
|
||||
|
||||
describe("historical ACPX profile decoding", () => {
|
||||
it("retains each provider's existing revision boundary", () => {
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 17)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("pi", 17)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("pi", 18)).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("cursor", 15)).toBe(true);
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfi
|
||||
|
||||
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
|
||||
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecuti
|
||||
import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
||||
nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest } from "../../contracts/runtime-context.js";
|
||||
|
||||
function execution(root: string): NativeExecutionInputV6 {
|
||||
function execution(root: string, provider: "openai_dot" | "grok_bot" = "openai_dot"): NativeExecutionInputV6 {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
const digest = "0".repeat(64);
|
||||
const context = {
|
||||
@@ -25,10 +25,10 @@ function execution(root: string): NativeExecutionInputV6 {
|
||||
schema: "paperclip.native-execution-input.v6",
|
||||
binding: { companyId, agentId, runId: randomUUID(), issueId: randomUUID(), executionWorkspaceId: randomUUID() },
|
||||
task: { identifier: "DOT-1", title: "Synthetic bridge recovery", description: null, prompt: "Read the synthetic counter.", workMode: "standard" },
|
||||
provider: { kind: "openai_dot", model: null, binding: { companyId, agentId, bindingId: randomUUID(), bindingGeneration: 1,
|
||||
provider: { kind: provider, model: null, binding: { companyId, agentId, bindingId: randomUUID(), bindingGeneration: 1,
|
||||
acceptByUnixMs: Date.now() + 600_000, expiresAtUnixMs: Date.now() + 7_200_000 } },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: randomUUID(), driverKind: "openai_dot_mcp", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
||||
session: { normalizedSessionId: randomUUID(), driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
||||
executionMode: "default", planningContext: null,
|
||||
completionContract: { id: randomUUID(), sha256: "sha256:" + digest, schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: { revision: "1", objective: "Read the counter", criteria: [{ id: "objective", requirement: "Read the counter" }] } },
|
||||
@@ -36,8 +36,8 @@ function execution(root: string): NativeExecutionInputV6 {
|
||||
}) as NativeExecutionInputV6;
|
||||
}
|
||||
|
||||
it("v6 closes Dot identity, workspace, model and credential fields", () => {
|
||||
const input = execution("/synthetic/pinned-instructions");
|
||||
it.each(["openai_dot", "grok_bot"] as const)("v6 closes %s identity, workspace, model and credential fields", provider => {
|
||||
const input = execution("/synthetic/pinned-instructions", provider);
|
||||
expect(parseNativeExecutionInput(input)).toEqual(input);
|
||||
expect(buildNativeModelEnvelope(input).workspace).toBeNull();
|
||||
expect(JSON.stringify(buildNativeModelEnvelope(input))).not.toContain("/synthetic");
|
||||
@@ -51,8 +51,8 @@ it("v6 closes Dot identity, workspace, model and credential fields", () => {
|
||||
]) expect(() => parseNativeExecutionInput(changed)).toThrow();
|
||||
});
|
||||
|
||||
it("admits the heartbeat descriptor with the real Dot capabilities and no broker or process startup", async () => {
|
||||
const input = execution("/synthetic/pinned-instructions");
|
||||
it.each(["openai_dot", "grok_bot"] as const)("admits the %s descriptor without a broker or process startup", async provider => {
|
||||
const input = execution("/synthetic/pinned-instructions", provider);
|
||||
const spawnSpy = vi.spyOn(controlPlane, "spawnRunner");
|
||||
try {
|
||||
const descriptor = await describeRunnerdNativeSessionBackend(input);
|
||||
@@ -70,8 +70,8 @@ it("admits the heartbeat descriptor with the real Dot capabilities and no broker
|
||||
});
|
||||
expect(descriptor).toEqual(await backend.descriptor());
|
||||
expect(descriptor).toMatchObject({
|
||||
kind: "runner", name: "openai_dot_mcp", version: "dot-mcp-v1",
|
||||
capabilities: { resume: false, steering: false, interruption: false, usage: false, dynamicTools: true },
|
||||
kind: "runner", name: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", version: provider === "grok_bot" ? "grok-bot-mcp-v1" : "dot-mcp-v1",
|
||||
capabilities: { resume: false, steering: false, interruption: false, usage: false, dynamicTools: true, completionBoundary: "paperclip_work_session", providerTermination: "unavailable", providerTranscriptStreaming: false },
|
||||
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" },
|
||||
});
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
@@ -131,10 +131,10 @@ it.each([
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it.each(["local", "custom local directory", "external launcher"])("reattaches the %s Rust bridge without duplicate operations and refuses lost checkpoints", async launch => {
|
||||
it.each(["local", "custom local directory", "external launcher", "grok external launcher"])("reattaches the %s Rust bridge without duplicate operations and refuses lost checkpoints", async launch => {
|
||||
const root = await mkdtemp(join(tmpdir(), "dot-driver-recovery-"));
|
||||
await writeFile(join(root, "AGENTS.md"), "Use only the synthetic counter.");
|
||||
const input = execution(root);
|
||||
const input = execution(root, launch === "grok external launcher" ? "grok_bot" : "openai_dot");
|
||||
let send: ((op: ExternalProviderOperation) => Promise<void>) | undefined;
|
||||
let spawned: { pid: number; processGroupId: number | null; startedAt: string } | undefined;
|
||||
let calls = 0;
|
||||
@@ -159,7 +159,7 @@ it.each(["local", "custom local directory", "external launcher"])("reattaches th
|
||||
};
|
||||
const runnerState = launch === "local" ? join(root, "state/runner") : join(root, "external-runtime/runner");
|
||||
if (launch === "custom local directory") options.runnerStateDirectory = runnerState;
|
||||
if (launch === "external launcher") {
|
||||
if (["external launcher", "grok external launcher"].includes(launch)) {
|
||||
await mkdir(runnerState, { recursive: true });
|
||||
options.runnerStateDirectory = runnerState;
|
||||
options.readProviderState = async () => {
|
||||
@@ -190,6 +190,11 @@ it.each(["local", "custom local directory", "external launcher"])("reattaches th
|
||||
const call = operation("tool", { name: "synthetic_counter", arguments: {} });
|
||||
await send!(call);
|
||||
await vi.waitFor(() => expect(settlements.get(call.requestId)).toMatchObject({ status: "completed", isError: false }), { timeout: 10000 });
|
||||
if (input.provider.kind === "grok_bot") {
|
||||
const renewal = operation("renew", { expiresAtUnixMs: Date.now() + 3600000 });
|
||||
await send!(renewal);
|
||||
await vi.waitFor(() => expect(settlements.get(renewal.requestId)).toMatchObject({ status: "renewed" }), { timeout: 10000 });
|
||||
}
|
||||
const snapshot = await session.snapshot();
|
||||
expect(snapshot).toMatchObject({ providerSessionId: null, activeTurnId: options.identity.turnId, providerRecoveryPolicy: "same_session_only" });
|
||||
await session.detachControllerForRestart!();
|
||||
@@ -210,3 +215,9 @@ it.each(["local", "custom local directory", "external launcher"])("reattaches th
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it("rejects a mismatched Grok driver before any provider state is attached", async () => {
|
||||
const input = execution("/synthetic/pinned-instructions", "grok_bot");
|
||||
expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "openai_dot_mcp" } })).toThrow();
|
||||
expect(() => parseNativeExecutionInput({ ...input, provider: { ...input.provider, kind: "unsupported_bot" } })).toThrow();
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import type { HarnessDriver, HarnessDriverDescriptor, HarnessSession, OpenHarnessSessionInput, PersistedHarnessSession } from "../../contracts/harness-driver.js";
|
||||
import type { NativeExecutionInputV6 } from "../../contracts/native-execution.js";
|
||||
import type { ExternalProviderPort } from "../../contracts/external-provider.js";
|
||||
import type { ExternalProviderPort, ExternalBotProviderKind } from "../../contracts/external-provider.js";
|
||||
import type { PrpEvent } from "../../protocol/replay-contract.js";
|
||||
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { DurablePrpControlPlane, spawnRunner, type RunnerProcessHandle } from "../../control-plane/durable-prp-control-plane.js";
|
||||
@@ -37,17 +37,22 @@ const capabilities = {
|
||||
dynamicTools: true, unsupported: ["resume", "steering", "interruption", "usage", "goals", "runtimeRequestResolution", "threadLineage"],
|
||||
};
|
||||
|
||||
/** The admission path can inspect Dot without allocating a broker port or Runner. */
|
||||
/** Inspect external capabilities without allocating a broker port or Runner. */
|
||||
export function describeRunnerdExternalBotDriver(provider: ExternalBotProviderKind): HarnessDriverDescriptor {
|
||||
const grok = provider === "grok_bot";
|
||||
return { kind: grok ? "grok_bot_mcp" : "openai_dot_mcp", displayName: grok ? "Grok Bot" : "OpenAI Dot", version: grok ? "grok-bot-mcp-v1" : "dot-mcp-v1", protocolVersion: "prp.v3",
|
||||
capabilities: { ...structuredClone(capabilities), completionBoundary: "paperclip_work_session", providerTermination: "unavailable", providerTranscriptStreaming: false }, runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } };
|
||||
}
|
||||
|
||||
export function describeRunnerdDotDriver(): HarnessDriverDescriptor {
|
||||
return { kind: "openai_dot_mcp", displayName: "OpenAI Dot", version: "dot-mcp-v1", protocolVersion: "prp.v3",
|
||||
capabilities: structuredClone(capabilities), runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } };
|
||||
return describeRunnerdExternalBotDriver("openai_dot");
|
||||
}
|
||||
|
||||
/** Thin SDK projection. Rust owns every lifecycle decision and durable receipt. */
|
||||
export class RunnerdDotDriver implements HarnessDriver {
|
||||
constructor(readonly options: RunnerdDotDriverOptions) {}
|
||||
async descriptor() {
|
||||
return describeRunnerdDotDriver();
|
||||
return describeRunnerdExternalBotDriver(this.options.execution.provider.kind);
|
||||
}
|
||||
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
|
||||
if (input.runId !== this.options.identity.runId || input.normalizedSessionId !== this.options.identity.normalizedSessionId) {
|
||||
@@ -59,14 +64,18 @@ export class RunnerdDotDriver implements HarnessDriver {
|
||||
catch (error) { await session.detachControllerForRestart().catch(() => {}); throw error; }
|
||||
}
|
||||
async recoverSession(snapshot: PersistedHarnessSession, options: { signal: AbortSignal }) {
|
||||
if (snapshot.driverKind !== "openai_dot_mcp" || snapshot.runId !== this.options.identity.runId
|
||||
if (snapshot.driverKind !== describeRunnerdExternalBotDriver(this.options.execution.provider.kind).kind || snapshot.runId !== this.options.identity.runId
|
||||
|| snapshot.driverSessionId !== this.options.identity.normalizedSessionId || snapshot.providerSessionId != null) {
|
||||
return { recovered: false, reason: "Dot bridge checkpoint identity mismatch" };
|
||||
}
|
||||
let saved: Record<string, unknown> | null;
|
||||
try { saved = await readProviderCheckpoint(this.options); }
|
||||
catch { return { recovered: false, reason: "Dot bridge checkpoint invalid; external work requires reconciliation" }; }
|
||||
if (!saved) return { recovered: false, reason: "Dot bridge checkpoint missing; external work requires reconciliation" };
|
||||
if (!saved) return { recovered: false, reason: "External bridge checkpoint missing; external work requires reconciliation" };
|
||||
const descriptor = saved.descriptor as Record<string, unknown> | undefined;
|
||||
if (this.options.execution.provider.kind === "grok_bot" && descriptor?.kind !== "grok_bot") {
|
||||
return { recovered: false, reason: "External bridge checkpoint provider mismatch" };
|
||||
}
|
||||
if (saved.schema !== "paperclip.runner.dot-provider-state.v1" || saved.runId !== snapshot.runId || saved.sessionId !== snapshot.driverSessionId || saved.turnId !== this.options.identity.turnId) {
|
||||
return { recovered: false, reason: "Dot bridge checkpoint authority mismatch" };
|
||||
}
|
||||
@@ -93,7 +102,7 @@ class RunnerdDotSession implements HarnessSession {
|
||||
#closed = false;
|
||||
#failure: Error | null = null;
|
||||
constructor(readonly options: RunnerdDotDriverOptions) {}
|
||||
ids() { return { driverSessionId: this.options.identity.normalizedSessionId, providerSessionId: null, displayId: "OpenAI Dot" }; }
|
||||
ids() { return { driverSessionId: this.options.identity.normalizedSessionId, providerSessionId: null, displayId: describeRunnerdExternalBotDriver(this.options.execution.provider.kind).displayName }; }
|
||||
|
||||
async open(signal?: AbortSignal) {
|
||||
const o = this.options;
|
||||
@@ -179,12 +188,16 @@ class RunnerdDotSession implements HarnessSession {
|
||||
if (checkpoint && (checkpoint.schema !== "paperclip.runner.dot-provider-state.v1"
|
||||
|| checkpoint.runId !== o.identity.runId || checkpoint.sessionId !== o.identity.normalizedSessionId
|
||||
|| checkpoint.turnId !== o.identity.turnId)) throw new Error("dot_runner_checkpoint_authority_mismatch");
|
||||
if (checkpoint && o.execution.provider.kind === "grok_bot"
|
||||
&& (checkpoint.descriptor as Record<string, unknown> | undefined)?.kind !== "grok_bot") {
|
||||
throw new Error("external_provider_checkpoint_provider_mismatch");
|
||||
}
|
||||
this.#detachPort = await o.port.attach(async operation => {
|
||||
await this.#command("external_provider.operation", { ...operation }, `dot_operation_${operation.requestId}`);
|
||||
}, () => this.interrupt(), !!checkpoint);
|
||||
await this.#command("run.prepare", {
|
||||
provider: { kind: "openai_dot", ...o.execution.provider.binding,
|
||||
instructions: `${nativeSystemInstructions(o.execution)}\n\nAccept the assignment before calling tools. Invoke paperclip_finish or paperclip_block, then submit the same structured result with paperclip_dot_finish. Keep request IDs stable across retries.` },
|
||||
provider: { kind: o.execution.provider.kind, ...o.execution.provider.binding,
|
||||
instructions: `${nativeSystemInstructions(o.execution)}\n\nAccept the assignment before calling tools. Invoke paperclip_finish or paperclip_block, then submit the same structured result with ${o.execution.provider.kind === "grok_bot" ? "paperclip_grok_finish" : "paperclip_dot_finish"}. Keep request IDs stable across retries.` },
|
||||
authorizedTools: authorizedToolSetForProvider(undefined, [...o.dynamicTools ?? [], ...codexSemanticToolSpecs()]),
|
||||
completionContract: { revision: o.execution.completionContract.contract.revision,
|
||||
criterionIds: o.execution.completionContract.contract.criteria.map(c => c.id) },
|
||||
@@ -238,7 +251,7 @@ class RunnerdDotSession implements HarnessSession {
|
||||
const proposed = this.#events.findLast(event => event.eventType === "run.result.proposed");
|
||||
const result = proposed ? validatePrpStructuredRunResult(proposed.payload) : null;
|
||||
const terminal = this.#events.findLast(event => event.eventType === "run.terminal");
|
||||
return { driverKind: "openai_dot_mcp", driverSessionId: this.ids().driverSessionId,
|
||||
return { driverKind: describeRunnerdExternalBotDriver(this.options.execution.provider.kind).kind, driverSessionId: this.ids().driverSessionId,
|
||||
providerSessionId: null, runId: this.options.identity.runId,
|
||||
normalizedSessionId: this.options.identity.normalizedSessionId,
|
||||
activeTurnId: typeof state.activeProviderTurnId === "string" ? state.activeProviderTurnId : null,
|
||||
|
||||
@@ -1043,7 +1043,8 @@ export const providerDescriptorSchema = {
|
||||
"claude_managed",
|
||||
"aws_agentcore",
|
||||
"acpx",
|
||||
"openai_dot"
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
]
|
||||
},
|
||||
"driver": {
|
||||
@@ -1053,7 +1054,8 @@ export const providerDescriptorSchema = {
|
||||
"claude_managed_agents_api",
|
||||
"aws_agentcore_harness_api",
|
||||
"acpx_runtime",
|
||||
"openai_dot_mcp"
|
||||
"openai_dot_mcp",
|
||||
"grok_bot_mcp"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
@@ -1078,7 +1080,8 @@ export const providerDescriptorSchema = {
|
||||
"enum": [
|
||||
"anthropic_managed_agents",
|
||||
"aws_bedrock_agentcore_harness",
|
||||
"openai_dot"
|
||||
"openai_dot",
|
||||
"grok_bot"
|
||||
]
|
||||
},
|
||||
"providerSessionId": {
|
||||
@@ -1280,6 +1283,19 @@ export const providerDescriptorSchema = {
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "grok_bot"
|
||||
},
|
||||
"driver": {
|
||||
"const": "grok_bot_mcp"
|
||||
},
|
||||
"executionKind": {
|
||||
"const": "remote_service"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1418,6 +1434,38 @@ export const providerDescriptorSchema = {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "grok_bot"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"service",
|
||||
"providerSessionId"
|
||||
],
|
||||
"properties": {
|
||||
"service": {
|
||||
"const": "grok_bot"
|
||||
},
|
||||
"model": {
|
||||
"const": null
|
||||
},
|
||||
"providerSessionId": {
|
||||
"const": null
|
||||
},
|
||||
"agentProcessId": {
|
||||
"const": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
|
||||
@@ -30,6 +30,17 @@ describe("provider runtime descriptor", () => {
|
||||
expect(validatePrpEvent(event).ok).toBe(true);
|
||||
expect(validatePrpEvent({ ...event, payload: { ...event.payload, turnControls: { steering: "true" } } }).ok).toBe(false);
|
||||
});
|
||||
it.each(["openai_dot", "grok_bot"] as const)("accepts the real %s external-session descriptor and rejects native-process claims", provider => {
|
||||
const descriptor = { provider, driver: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", service: provider,
|
||||
executionKind: "remote_service", model: null, providerSessionId: null, processId: null, providerVersion: "unknown" };
|
||||
expect(validate(descriptor), JSON.stringify(validate.errors)).toBe(true);
|
||||
for (const changed of [
|
||||
{ ...descriptor, service: "anthropic_managed_agents" }, { ...descriptor, driver: "codex_app_server" },
|
||||
{ ...descriptor, executionKind: "local_process" }, { ...descriptor, model: "an-unreported-model" },
|
||||
{ ...descriptor, providerSessionId: "invented-session" }, { ...descriptor, processId: 42 }, { ...descriptor, agentProcessId: 42 },
|
||||
]) expect(validate(changed)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts ACPX sidecar and child process identities separately", () => {
|
||||
expect(validate({
|
||||
provider: "acpx",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"commandDigest": "sha256:2ec904f44218f51058fa83d7437b26c4551e1c65e6f2c55f5a6386fa4061e96d",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 18,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
|
||||
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06",
|
||||
"semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e",
|
||||
"semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594",
|
||||
"semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5",
|
||||
"semanticSchemaBundleSourceSha256": "969c1c47bc538422210e3789bd53249ebf0f6be335a38c7997d201f0783dfd6b",
|
||||
"semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1"
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export const EXTERNAL_AGENT_TOOL_GUIDANCE = `When you receive a Paperclip assign
|
||||
|
||||
/** The assignment catalog constrains the Paperclip bridge, not the external runtime. */
|
||||
export const DOT_AGENT_TOOL_GUIDANCE = `${EXTERNAL_AGENT_TOOL_GUIDANCE}\n\nUse the assignment catalog through paperclip_dot_tool for Paperclip operations. Use your native tools directly for the rest of the work; they do not need to appear in that catalog. Record useful results and deliverables on the task and follow the Paperclip completion instructions.`;
|
||||
|
||||
export const GROK_BOT_AGENT_TOOL_GUIDANCE = `${EXTERNAL_AGENT_TOOL_GUIDANCE}\n\nUse the current assignment catalog through paperclip_grok_tool for first-party Paperclip operations. Grok Bot retains its own connected apps and persistent memory. Retrieve current task context and instructions for each Paperclip work session; do not assume provider memory is isolated or reset. Acknowledge routine wakes using paperclip_grok_ack_wake. Accept admitted assignments before task operations and keep request IDs stable across retries. Report progress, questions and deliverables in Paperclip, then follow its canonical completion instructions. Routine acceptance, Paperclip session completion and remote process termination are distinct. Grok usage and spending are not reported.`;
|
||||
@@ -134,6 +134,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableGrokBot: {
|
||||
title: "Grok Bot",
|
||||
description: "Connect an existing Grok Bot as an experimental Paperclip agent. Requires Assistant connections (MCP), Paperclip Runner, and an authenticated public HTTPS instance. Provider spending is not reported.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableChatConnectors: {
|
||||
title: "Chat connectors",
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
import { AGENT_ROLES } from "./constants.js";
|
||||
import type { DotBinding, DotConnection, DotInvitation } from "./dot-invitations.js";
|
||||
|
||||
export const createGrokInvitationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
botName: z.string().trim().min(1).max(100),
|
||||
role: z.enum(AGENT_ROLES).default("general"),
|
||||
reportsTo: z.uuid().nullable().optional(),
|
||||
}).strict();
|
||||
export const configureGrokRoutineSchema = z.object({
|
||||
bindingId: z.uuid(), expectedRevision: z.number().int().nonnegative(),
|
||||
botName: z.string().trim().min(1).max(100),
|
||||
webhookUrl: z.string().min(1).max(2048), webhookKey: z.string().min(1).max(16384),
|
||||
}).strict();
|
||||
export const verifyGrokRoutineSchema = z.object({ bindingId: z.uuid(), requestId: z.uuid() }).strict();
|
||||
export const retryGrokDeliverySchema = z.object({ correlationId: z.uuid() }).strict();
|
||||
export const pairGrokBindingSchema = z.object({ replaceBindingId: z.uuid().optional() }).strict();
|
||||
export const stopGrokWorkSchema = z.object({ expectedRunId: z.uuid().optional() }).strict();
|
||||
export const recoverGrokRoutineSchema = z.object({ inspectedRemoteWork: z.literal(true) }).strict();
|
||||
export type CreateGrokInvitation = z.input<typeof createGrokInvitationSchema>;
|
||||
export type ConfigureGrokRoutine = z.infer<typeof configureGrokRoutineSchema>;
|
||||
export interface GrokRoutineState {
|
||||
bindingId: string; botName: string; revision: number; configured: boolean;
|
||||
verified: boolean; verifiedAt: string | null; lastContactAt: string | null; recoveryNeeded: boolean;
|
||||
lastExchange: { id: string; kind: "verify" | "mailbox_updated" | "authority_revoked";
|
||||
status: "prepared" | "sending" | "accepted" | "rejected" | "unknown" | "acknowledged" | "cancelled";
|
||||
httpStatus: number | null; sentAt: string | null; acknowledgedAt: string | null; expiresAt: string } | null;
|
||||
}
|
||||
export interface GrokBinding extends DotBinding {
|
||||
providerKind: "grok_bot"; wakeVerified: boolean; routine: GrokRoutineState | null;
|
||||
assignment?: { id: string; runId: string; status: string; lastActivityAt: string; attentionRequired: boolean } | null;
|
||||
}
|
||||
export interface GrokInvitation extends Omit<DotInvitation, "agent" | "binding"> {
|
||||
agent: DotInvitation["agent"] & { role: typeof AGENT_ROLES[number]; reportsTo: string | null; botName: string };
|
||||
binding: GrokBinding | null;
|
||||
}
|
||||
export interface GrokConnection extends Omit<DotConnection, "binding"> { binding: GrokBinding | null; recoveryNeeded: boolean; queuedTaskCount?: number; currentTask?: { id: string; identifier: string; title: string } | null }
|
||||
@@ -2869,7 +2869,7 @@ export * from "./agent-avatar-upload.js";
|
||||
|
||||
export type { DotBinding, DotInvitation, DotConnection, DotPairing } from "./dot-invitations.js";
|
||||
export { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, paperclipRunnerSupportsPlatform, PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION, type AgentRunnerChoice, type AgentRunner, type AgentRunnerAvailability } from "./agent-runner.js";
|
||||
export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js";
|
||||
export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE, GROK_BOT_AGENT_TOOL_GUIDANCE } from "./external-agent-guidance.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js";
|
||||
|
||||
@@ -2883,3 +2883,5 @@ export { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository } fro
|
||||
export * from "./fast-response.js";
|
||||
|
||||
export { HUMAN_TASK_ORIGIN_KINDS, isAgentCreatedIssue, type IssueCreationSourceFields } from "./issue-creation-source.js";
|
||||
|
||||
export * from "./grok-bot.js";
|
||||
@@ -35,6 +35,8 @@ export interface McpConnectionSetup {
|
||||
}
|
||||
|
||||
export interface McpConnection {
|
||||
companyPrefix?: string;
|
||||
agent?: { id: string; name: string; provider: string } | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
clientName: string;
|
||||
@@ -48,6 +50,8 @@ export interface McpConnection {
|
||||
|
||||
/** Code-scoped details shown before activating an operator-approved Dot agent. */
|
||||
export interface McpDotPairingPreview {
|
||||
providerKind?: "openai_dot" | "grok_bot";
|
||||
authorizingPerson?: string;
|
||||
company: { id: string; name: string };
|
||||
agent: { id: string; name: string };
|
||||
permissions: string;
|
||||
|
||||
@@ -154,6 +154,8 @@ export interface GitWorktreeBranchIncoherenceEvidence {
|
||||
}
|
||||
|
||||
export interface HeartbeatRun {
|
||||
/** Persisted native driver identity; older servers may omit this field. */
|
||||
driverKind?: string | null;
|
||||
execution?: import("./execution-projection.js").ExecutionProjection | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface InstanceExperimentalSettings {
|
||||
enablePublicMcp: boolean;
|
||||
/** Enable the standalone Dot choice and dedicated agent MCP connection independently of the general Runner rollout. */
|
||||
enableOpenAiDot: boolean;
|
||||
/** Enable experimental Grok Bot agents; requires MCP and the managed/native Runner. */
|
||||
enableGrokBot: boolean;
|
||||
/** @deprecated Compatibility key only. MCP aggregators are always enabled. */
|
||||
enableMcpAggregators: boolean;
|
||||
/** Show experimental memory connection setup. Existing connections remain usable. */
|
||||
|
||||
@@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableApps: z.boolean().default(true),
|
||||
enablePublicMcp: z.boolean().default(false),
|
||||
enableOpenAiDot: z.boolean().default(false),
|
||||
enableGrokBot: z.boolean().default(false),
|
||||
enableChatConnectors: z.boolean().default(false),
|
||||
enableGitHubReviewBots: z.boolean().default(false),
|
||||
// Compatibility only: old stored and managed values must still parse.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from '../grok-public-install-sandbox.mjs';
|
||||
import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs';
|
||||
import { publicInstallClosure } from '../public-install-closure.mjs';
|
||||
import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
@@ -209,7 +210,7 @@ test('the image and Grok downloads start before staging and join without weakeni
|
||||
assert.ok(source.includes("const stagedGrok = join(prefetched, 'grok')"));
|
||||
assert.doesNotMatch(source, /provision-grok\.mjs'\), prerequisite\]/);
|
||||
// Both downloads start before any package is staged and before the first sandbox runs.
|
||||
const staging = position("visit('@paperclipai/server')");
|
||||
const staging = position('publicInstallClosure(repo)');
|
||||
const firstSandbox = position("isolated(['npm', 'install', '--ignore-scripts', '--omit=dev'");
|
||||
assert.ok(imagePull < staging && grokProvision < staging);
|
||||
assert.ok(position("await awaited('image', imageReady)") < firstSandbox);
|
||||
@@ -284,17 +285,8 @@ test('the verifier stages only workspace packages, so it skips the standalone bu
|
||||
const retention = readFileSync(new URL('../retain-runner-qualification-packages.mjs', import.meta.url), 'utf8');
|
||||
assert.ok(retention.includes("const pluginRelative = 'packages/plugins/sandbox-providers/daytona';") && retention.includes("cpSync(join(repo, pluginRelative, 'dist'), join(plugin, 'dist'), { recursive: true })"), 'retention still packs the Daytona plugin from its dist, which is why the gated build exists');
|
||||
const repo = new URL('../../', import.meta.url);
|
||||
const listing = spawnSync(process.execPath, [fileURLToPath(new URL('scripts/release-package-map.mjs', repo)), 'list'], { cwd: fileURLToPath(repo), encoding: 'utf8' });
|
||||
assert.equal(listing.status, 0, listing.stderr);
|
||||
const packages = new Map(listing.stdout.trim().split('\n').map(line => line.split('\t')).map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(new URL(`${dir}/package.json`, repo), 'utf8')) }]));
|
||||
const needed = new Set();
|
||||
const visit = name => {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `missing public workspace dependency ${name}`); needed.add(name);
|
||||
for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) if (spec.startsWith('workspace:')) visit(dep);
|
||||
};
|
||||
assert.ok(source.includes("visit('@paperclipai/server');\n visit('paperclipai');"));
|
||||
visit('@paperclipai/server'); visit('paperclipai');
|
||||
assert.ok(source.includes('const { packages, needed } = publicInstallClosure(repo);'));
|
||||
const { packages, needed } = publicInstallClosure(fileURLToPath(repo));
|
||||
const workspace = parseWorkspaceEntries(readFileSync(new URL('pnpm-workspace.yaml', repo), 'utf8'));
|
||||
const standalone = [...packages.values()].filter(({ dir }) => !isWorkspacePackage(dir, workspace)).map(({ dir }) => dir);
|
||||
assert.ok(standalone.length > 0, 'the public package map must still carry standalone packages for this check to mean anything');
|
||||
@@ -307,7 +299,7 @@ test('the verifier stages only workspace packages, so it skips the standalone bu
|
||||
assert.deepEqual(JSON.parse(readFileSync(new URL('server/package.json', repo), 'utf8')).files, ['dist', 'ui-dist', 'skills']);
|
||||
assert.ok(source.includes("PAPERCLIP_RELEASE_REUSE_UI_DIST: '1'"));
|
||||
const uiDist = position("run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo)");
|
||||
assert.ok(position("visit('paperclipai')") < gate && gate < uiDist && uiDist < position('timing.stage_ui_dist_ms =') && uiDist < position('const stageSource = '));
|
||||
assert.ok(position('publicInstallClosure(repo)') < gate && gate < uiDist && uiDist < position('timing.stage_ui_dist_ms =') && uiDist < position('const stageSource = '));
|
||||
});
|
||||
|
||||
test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PUBLIC_INSTALL_ROOTS, publicInstallClosure, publicRegistryDependencies } from '../public-install-closure.mjs';
|
||||
|
||||
const repo = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
test('the closure starts at the server and the CLI and follows only workspace specs', () => {
|
||||
assert.deepEqual(PUBLIC_INSTALL_ROOTS, ['@paperclipai/server', 'paperclipai']);
|
||||
const { packages, needed } = publicInstallClosure(repo);
|
||||
const order = [...needed];
|
||||
assert.equal(order[0], '@paperclipai/server');
|
||||
assert.ok(needed.has('paperclipai'));
|
||||
for (const name of needed) {
|
||||
const { manifest } = packages.get(name);
|
||||
for (const [dep, spec] of Object.entries({ ...manifest.dependencies, ...manifest.optionalDependencies })) {
|
||||
assert.equal(needed.has(dep), spec.startsWith('workspace:'), `${name} -> ${dep}@${spec}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the warm-up graph is the closure\'s registry dependencies, first spec wins', () => {
|
||||
const pkg = (dependencies, optionalDependencies) => ({ dir: 'x', manifest: { dependencies, optionalDependencies } });
|
||||
const packages = new Map([
|
||||
['a', pkg({ b: 'workspace:*', left: '^1.0.0', shared: '^2.0.0', local: 'file:../local', linked: 'link:../linked' }, { native: '1.2.3' })],
|
||||
['b', pkg({ shared: '^3.0.0', right: '~4.0.0' }, { left: '^9.0.0' })],
|
||||
['unvisited', pkg({ never: '1.0.0' })],
|
||||
]);
|
||||
assert.deepEqual(publicRegistryDependencies({ packages, needed: new Set(['a', 'b']) }), {
|
||||
dependencies: { left: '^1.0.0', shared: '^2.0.0', right: '~4.0.0' },
|
||||
optionalDependencies: { native: '1.2.3' },
|
||||
});
|
||||
const real = publicRegistryDependencies(publicInstallClosure(repo));
|
||||
const names = [...Object.keys(real.dependencies), ...Object.keys(real.optionalDependencies)];
|
||||
assert.ok(names.length > 0);
|
||||
for (const spec of [...Object.values(real.dependencies), ...Object.values(real.optionalDependencies)]) {
|
||||
assert.doesNotMatch(spec, /^(workspace|file|link):/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the warm-up refuses to run without the cache the verifier will read', () => {
|
||||
const env = { ...process.env };
|
||||
delete env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE;
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../warm-public-install-npm-cache.mjs', import.meta.url))], { env, encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE/);
|
||||
});
|
||||
|
||||
test('the Build lane warms the cache before pnpm build and hands the same directory to the verifier', () => {
|
||||
const workflow = readFileSync(new URL('../../.github/workflows/pr-trusted.yml', import.meta.url), 'utf8');
|
||||
const build = workflow.slice(workflow.indexOf('\n build:\n'), workflow.indexOf('\n verify_serialized_server:\n'));
|
||||
const position = fragment => { const index = build.indexOf(fragment); assert.notEqual(index, -1, `Build job must contain ${fragment}`); return index; };
|
||||
const warm = position('PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE="$RUNNER_TEMP/public-install-npm-cache" node scripts/warm-public-install-npm-cache.mjs');
|
||||
assert.ok(position('- name: Install dependencies') < warm && warm < position('- name: Build\n'), 'the warm-up overlaps the Rust restore and pnpm build');
|
||||
assert.ok(build.includes('set +e\n'), 'a failed warm-up still records its status, so the verifier never waits it out');
|
||||
const verify = position('export PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE="$RUNNER_TEMP/public-install-npm-cache"');
|
||||
assert.ok(verify < position('node scripts/verify-grok-npm-install.mjs'));
|
||||
const verifier = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.ok(verifier.includes("const cache = process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? resolve(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE) : join(root, 'cache');"));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// The public packages the public-install verifier stages: `@paperclipai/server`
|
||||
// and `paperclipai` plus every public workspace package they reach through
|
||||
// `workspace:` specs, in the depth-first order the verifier packs them. The
|
||||
// npm cache warm-up reads the same closure from source manifests before
|
||||
// `pnpm build` has produced anything, so both share this one definition.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const PUBLIC_INSTALL_ROOTS = ['@paperclipai/server', 'paperclipai'];
|
||||
|
||||
// Returns `{ packages, needed }`: `packages` maps every public workspace
|
||||
// package name to `{ dir, manifest }`, and `needed` is the closure in visit
|
||||
// order.
|
||||
export function publicInstallClosure(repo, roots = PUBLIC_INSTALL_ROOTS) {
|
||||
const listing = execFileSync(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], { cwd: repo, stdio: 'pipe' })
|
||||
.toString().trim().split('\n').map(line => line.split('\t'));
|
||||
const packages = new Map(listing.map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json'), 'utf8')) }]));
|
||||
const needed = new Set();
|
||||
function visit(name) {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `Missing public workspace dependency ${name}`); needed.add(name);
|
||||
for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) {
|
||||
if (spec.startsWith('workspace:')) visit(dep);
|
||||
}
|
||||
}
|
||||
for (const root of roots) visit(root);
|
||||
return { packages, needed };
|
||||
}
|
||||
|
||||
// The registry dependencies the closure's packages declare, as one consumer
|
||||
// manifest. A name two packages declare with different specs keeps the first
|
||||
// spec: the warm-up only fills a cache, and the real install fetches whatever
|
||||
// it still lacks.
|
||||
export function publicRegistryDependencies({ packages, needed }) {
|
||||
const dependencies = {};
|
||||
const optionalDependencies = {};
|
||||
for (const name of needed) {
|
||||
const { manifest } = packages.get(name);
|
||||
for (const [section, target] of [['dependencies', dependencies], ['optionalDependencies', optionalDependencies]]) {
|
||||
for (const [dep, spec] of Object.entries(manifest[section] ?? {})) {
|
||||
if (needed.has(dep) || /^(workspace|file|link):/.test(spec) || dep in dependencies || dep in optionalDependencies) continue;
|
||||
target[dep] = spec;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { dependencies, optionalDependencies };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
|
||||
import { materializePublishManifest } from './prepare-bundled-package.mjs';
|
||||
import { publicInstallClosure } from './public-install-closure.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
|
||||
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
|
||||
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
|
||||
@@ -229,18 +230,10 @@ try {
|
||||
// Provision as the unprivileged verification user, never into the host's /opt.
|
||||
// Only the positive probe sees this file at the canonical sandbox path.
|
||||
const grokReady = prefetch('grok', process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), stagedGrok]);
|
||||
const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t'));
|
||||
const packages = new Map(listing.map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json'), 'utf8')) }]));
|
||||
const needed = new Set();
|
||||
function visit(name) {
|
||||
if (needed.has(name)) return;
|
||||
const entry = packages.get(name); assert.ok(entry, `Missing public workspace dependency ${name}`); needed.add(name);
|
||||
for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) {
|
||||
if (spec.startsWith('workspace:')) visit(dep);
|
||||
}
|
||||
}
|
||||
visit('@paperclipai/server');
|
||||
visit('paperclipai');
|
||||
// The server, the CLI and every public workspace package they reach through
|
||||
// `workspace:` specs. The npm cache warm-up reads the same closure, so both
|
||||
// take it from one module.
|
||||
const { packages, needed } = publicInstallClosure(repo);
|
||||
// release.sh also builds the standalone public packages (the sandbox-provider
|
||||
// plugins outside the pnpm workspace) before it stages anything, because it
|
||||
// publishes them. Nothing staged here reaches them: `needed` is closed under
|
||||
@@ -340,7 +333,13 @@ try {
|
||||
}
|
||||
const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 });
|
||||
const consumer = join(root, 'consumer'); mkdirSync(consumer);
|
||||
const cache = join(root, 'cache'); mkdirSync(cache);
|
||||
// CI can point this at a cache that scripts/warm-public-install-npm-cache.mjs
|
||||
// filled with the same public registry graph while `pnpm build` ran. npm
|
||||
// checks every cached tarball against the integrity its own resolution
|
||||
// records, so a warm cache changes where bytes come from, never what is
|
||||
// installed; the consumer directory and its lock still start empty.
|
||||
const cache = process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? resolve(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE) : join(root, 'cache');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
for (const tarball of tarballs) {
|
||||
const destination = join(assets, basename(tarball)); cpSync(tarball, destination); chmodSync(destination, 0o644);
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
// Fill an npm cache with the public registry graph of the packages the
|
||||
// public-install verifier stages, while `pnpm build` still runs. The verifier
|
||||
// starts after the build with an empty cache, and its consumer install then
|
||||
// downloads the whole graph (106-127s in runs 38241687458 to 38250487646,
|
||||
// 2026-10-12). The graph is fixed by source manifests, so it can be fetched
|
||||
// before any tarball exists.
|
||||
//
|
||||
// This only downloads. Scripts are disabled, nothing here is verified, and the
|
||||
// scratch consumer is removed afterwards. npm checks every cached tarball
|
||||
// against the integrity its own resolution records, so a warm cache changes
|
||||
// where bytes come from, never what the verifier installs. Any failure leaves
|
||||
// a partial cache that the verifier completes from the registry as before.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { publicInstallClosure, publicRegistryDependencies } from './public-install-closure.mjs';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const cache = process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE;
|
||||
assert.ok(cache, 'Set PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE to the cache directory the verifier will use');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const started = performance.now();
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'paperclip-public-install-warm-'));
|
||||
try {
|
||||
const { dependencies, optionalDependencies } = publicRegistryDependencies(publicInstallClosure(repo));
|
||||
writeFileSync(join(scratch, 'package.json'), JSON.stringify({ private: true, dependencies, optionalDependencies }));
|
||||
execFileSync('npm', ['install', '--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--no-package-lock'], {
|
||||
cwd: scratch, stdio: 'inherit', env: { ...process.env, npm_config_cache: cache, npm_config_update_notifier: 'false' },
|
||||
});
|
||||
console.log(JSON.stringify({ schema: 'paperclip.public-npm-install.cache-warm.v1', dependencyCount: Object.keys(dependencies).length + Object.keys(optionalDependencies).length, warm_ms: Math.round(performance.now() - started) }));
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
@@ -133,6 +133,7 @@ it("rejects URLs, SVG, corrupt and oversized images, unsupported fields and inac
|
||||
|
||||
it("lets a paired Dot upload without an assignment, with feature and current grant authorization", async () => {
|
||||
const f = await fixture(); const userId = randomUUID(); const clientId = randomUUID();
|
||||
await db.update(agents).set({ adapterConfig: { provider: "openai_dot" } }).where(eq(agents.id, f.agent.id));
|
||||
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await db.insert(mcpOauthClients).values({ id: clientId, name: "Dot", redirectUris: [] });
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { createGrokRoutineDispatcher } from "../services/grok-bot/dispatcher.js";
|
||||
import { grokRoutineService } from "../services/grok-bot/routine.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { Readable } from "node:stream";
|
||||
import { createPublicMcpTransfers } from "../services/public-mcp/file-transfers.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -5,11 +11,11 @@ import { fileURLToPath } from "node:url";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import express, { type Request } from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { grokBotRoutines, grokBotDispatches, agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, dotMailboxItems, dotRunnerAssignments, dotRunnerOperations,
|
||||
heartbeatRuns, agentWakeupRequests, issueComments, issues, nativeRunResults, nativeRunFinalizations, completionContracts, mcpEventDeliveries, mcpEventSubscriptions, workspaceOperations, workAssessments, statusDecisions, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { createPublicMcpOAuth, DEVICE_GRANT } from "../services/public-mcp/oauth.js";
|
||||
@@ -50,6 +56,11 @@ describe("durable Dot Runner integration", () => {
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false });
|
||||
}, 360000);
|
||||
afterEach(async () => {
|
||||
const { heartbeatService } = await import("../services/heartbeat.js");
|
||||
dotRunnerBroker(db, { heartbeat: heartbeatService(db) });
|
||||
await instanceSettingsService(db).updateExperimental({ enableGrokBot: false, enableNativeRunner: false });
|
||||
});
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await temporary?.cleanup();
|
||||
@@ -97,12 +108,15 @@ describe("durable Dot Runner integration", () => {
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } });
|
||||
});
|
||||
|
||||
async function fixture(options: { cloudOrigin?: string } = {}) {
|
||||
async function fixture(options: { cloudOrigin?: string; provider?: "openai_dot" | "grok_bot" } = {}) {
|
||||
const provider = options.provider ?? "openai_dot";
|
||||
if (provider === "grok_bot") await instanceSettingsService(db).updateExperimental({ enableGrokBot: true, enableNativeRunner: true });
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Dot operator", email: userId + "@example.test", createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Dot test", issuePrefix: "DT" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Dot", adapterType: "paperclip_runner", status: "active", adapterConfig: { provider: "openai_dot", allowUnmeteredProvider: true } }).returning();
|
||||
await accessService(db).setPrincipalPermission(company!.id, "user", userId, "agents:create", true, userId);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: provider === "grok_bot" ? "Grok Bot" : "Dot", adapterType: "paperclip_runner", status: "active", adapterConfig: { provider, allowUnmeteredProvider: true } }).returning();
|
||||
const config = { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/runner" };
|
||||
const oauth = createPublicMcpOAuth(db, config);
|
||||
const client = await oauth.register({ client_name: "Dedicated Dot", redirect_uris: ["https://chatgpt.com/connector_platform/oauth/callback"] }, randomUUID());
|
||||
@@ -116,9 +130,9 @@ describe("durable Dot Runner integration", () => {
|
||||
const unpaired = await oauth.authenticate(tokens.access_token);
|
||||
expect(unpaired.actor.type).toBe("none");
|
||||
const broker = dotRunnerBroker(db);
|
||||
const pairing = await broker.createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId });
|
||||
const pairing = await broker.createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId, provider });
|
||||
await broker.pair(unpaired, pairing.pairingCode);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig.dotBindingId).toBe(pairing.bindingId);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, agent!.id)))[0]!.adapterConfig[provider === "grok_bot" ? "externalBindingId" : "dotBindingId"]).toBe(pairing.bindingId);
|
||||
await expect(broker.pair(unpaired, pairing.pairingCode)).rejects.toThrow();
|
||||
const principal = await oauth.authenticate(tokens.access_token);
|
||||
expect(principal.actor).toMatchObject({ type: "agent", agentId: agent!.id, companyId: company!.id });
|
||||
@@ -137,6 +151,17 @@ describe("durable Dot Runner integration", () => {
|
||||
};
|
||||
const events = createPublicMcpEvents(db, oauth, async () => { throw new Error("personal API dispatch forbidden"); }, { enableDotRunner: true, fetch: fetcher, ...options });
|
||||
const subscription = { name: "paperclip.dot.mailbox_updated", arguments: { companyId: company!.id, bindingId: pairing.bindingId }, delivery: { mode: "webhook", url: "https://example.com/dot-hook", secret } };
|
||||
if (provider === "grok_bot") {
|
||||
const routines = grokRoutineService(db, async () => ({ delivery: "accepted", httpStatus: 200 }));
|
||||
await routines.configure({ companyId: company!.id, agentId: agent!.id, bindingId: pairing.bindingId, operatorId: userId,
|
||||
expectedRevision: 0, botName: "Synthetic Grok", webhookUrl: "https://bot.example.test/routine", webhookKey: "synthetic-key" });
|
||||
const wake = await routines.reserve({ companyId: company!.id, agentId: agent!.id, bindingId: pairing.bindingId, sourceEventId: randomUUID(), kind: "verify" });
|
||||
await routines.send(company!.id, agent!.id, wake.id);
|
||||
await routines.acknowledge(principal, { correlationId: wake.id, bindingId: wake.bindingId, bindingGeneration: wake.bindingGeneration, challenge: String(wake.payload.challenge) });
|
||||
const snapshot = await broker.snapshot(company!.id, agent!.id, pairing.bindingId);
|
||||
const [fresh] = await db.select().from(agents).where(eq(agents.id, agent!.id));
|
||||
return { company: company!, agent: fresh!, userId, broker, principal, oauth, events, subscription, received, snapshot, tokens, client, verifications: () => verifications };
|
||||
}
|
||||
await events.subscribe(principal, subscription);
|
||||
await events.tick();
|
||||
if (!received.length) throw new Error("Dot readiness delivery missing: " + JSON.stringify(await db.select({ outcome: mcpEventDeliveries.outcome, event: mcpEventDeliveries.event }).from(mcpEventDeliveries)));
|
||||
@@ -208,8 +233,8 @@ describe("durable Dot Runner integration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("admits one durable idle intake without requiring a preassigned task", async () => {
|
||||
const f = await fixture();
|
||||
it.each(["openai_dot", "grok_bot"] as const)("admits one durable %s idle intake without requiring a preassigned task", async provider => {
|
||||
const f = await fixture({ provider });
|
||||
await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.company.id));
|
||||
const holding = await offeredWork(f);
|
||||
await db.update(heartbeatRuns).set({ status: "running", startedAt: new Date() }).where(eq(heartbeatRuns.id, holding.run.id));
|
||||
@@ -220,13 +245,13 @@ describe("durable Dot Runner integration", () => {
|
||||
const intake = await db.select().from(issues).where(eq(issues.id, requests[0]!.issueId));
|
||||
expect(intake).toHaveLength(1);
|
||||
expect(intake[0]).toMatchObject({ assigneeAgentId: f.agent.id, createdByAgentId: f.agent.id, responsibleUserId: f.userId });
|
||||
expect(await f.broker.capabilities(f.principal)).toMatchObject({ agentId: f.agent.id, responsibleUser: { id: f.userId }, idle: { start: "paperclip_dot_request_turn" } });
|
||||
expect(await f.broker.capabilities(f.principal)).toMatchObject({ agentId: f.agent.id, responsibleUser: { id: f.userId }, idle: { start: provider === "grok_bot" ? "paperclip_grok_request_turn" : "paperclip_dot_request_turn" } });
|
||||
await expect(f.broker.requestTurn(f.principal, "Different request", requestId)).rejects.toThrow("reused");
|
||||
await db.update(companies).set({ budgetMonthlyCents: 1, spentMonthlyCents: 1 }).where(eq(companies.id, f.company.id));
|
||||
await expect(f.broker.requestTurn(f.principal, "Spend more", randomUUID())).rejects.toThrow("budget");
|
||||
await db.update(companies).set({ budgetMonthlyCents: 0, spentMonthlyCents: 0 }).where(eq(companies.id, f.company.id));
|
||||
await f.broker.revoke(f.company.id, f.agent.id, f.userId);
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig).not.toHaveProperty("dotBindingId");
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.agent.id)))[0]!.adapterConfig).not.toHaveProperty(provider === "grok_bot" ? "externalBindingId" : "dotBindingId");
|
||||
await expect(f.broker.requestTurn(f.principal, "After revoke", randomUUID())).rejects.toThrow();
|
||||
} finally { await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, holding.run.id)); }
|
||||
});
|
||||
@@ -426,6 +451,68 @@ describe("durable Dot Runner integration", () => {
|
||||
} finally { await f.events.stop(); }
|
||||
}, 30000);
|
||||
|
||||
it.each(["queued", "running"] as const)("stops a %s Grok run before an external assignment exists", async status => {
|
||||
const f = await fixture({ provider: "grok_bot" });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status, invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const cancelRun = vi.fn().mockResolvedValue(null);
|
||||
const broker = dotRunnerBroker(db, { heartbeat: { wakeup: vi.fn(), cancelRun } });
|
||||
expect(await broker.stopGrokWork(f.company.id, f.agent.id, f.userId, run!.id)).toMatchObject({ stopped: true, remoteTerminationConfirmed: false });
|
||||
expect(cancelRun).toHaveBeenCalledWith(run!.id, "Paperclip work stopped; Grok execution unconfirmed");
|
||||
expect(await grokRoutineService(db).recoveryNeeded(f.company.id, f.agent.id)).toBe(status === "running");
|
||||
expect(await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id))).toHaveLength(0);
|
||||
const other = await fixture({ provider: "grok_bot" });
|
||||
await expect(broker.stopGrokWork(other.company.id, other.agent.id, other.userId, run!.id)).rejects.toThrow("changed");
|
||||
expect(cancelRun).toHaveBeenCalledTimes(1);
|
||||
await f.events.stop(); await other.events.stop();
|
||||
});
|
||||
|
||||
it("cancels queued Grok work without fencing another active assignment", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" }), active = await offeredWork(f);
|
||||
const [queued] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "queued", invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const cancelRun = vi.fn().mockResolvedValue(null);
|
||||
const broker = dotRunnerBroker(db, { heartbeat: { wakeup: vi.fn(), cancelRun } });
|
||||
await broker.stopGrokWork(f.company.id, f.agent.id, f.userId, queued!.id);
|
||||
expect(cancelRun).toHaveBeenCalledTimes(1);
|
||||
expect(cancelRun).toHaveBeenCalledWith(queued!.id, "Paperclip work stopped; Grok execution unconfirmed");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, active.assignment.id)))[0]!.status).toBe("offered");
|
||||
expect(await grokRoutineService(db).recoveryNeeded(f.company.id, f.agent.id)).toBe(false);
|
||||
await f.events.stop();
|
||||
});
|
||||
|
||||
it("rejects a stale Grok Stop click without fencing the newly displayed work", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" });
|
||||
const old = await offeredWork(f);
|
||||
await db.update(dotRunnerAssignments).set({ status: "settled" }).where(eq(dotRunnerAssignments.id, old.assignment.id));
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, old.run.id));
|
||||
const current = await offeredWork(f);
|
||||
await expect(f.broker.stopGrokWork(f.company.id, f.agent.id, f.userId, old.run.id)).rejects.toThrow("changed");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, current.assignment.id)))[0]!.status).toBe("offered");
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, current.run.id)))[0]!.status).toBe("queued");
|
||||
await f.events.stop();
|
||||
});
|
||||
|
||||
it("preserves Grok feedback and its deferred wake while routine recovery blocks notification", async () => {
|
||||
const f = await fixture({ provider: "grok_bot" }), { run, assignment } = await offeredWork(f);
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Grok feedback", assigneeAgentId: f.agent.id }).returning();
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: task!.id, status: "running", runtimeMode: "native", nativeSessionId: assignment.normalizedSessionId }).where(eq(heartbeatRuns.id, run.id));
|
||||
await db.update(dotRunnerAssignments).set({ status: "accepted" }).where(eq(dotRunnerAssignments.id, assignment.id));
|
||||
await db.update(grokBotRoutines).set({ recoveryRequiredAt: new Date(), recoveryReason: "dispatch" }).where(eq(grokBotRoutines.bindingId, f.snapshot.bindingId));
|
||||
const [comment] = await db.insert(issueComments).values({ companyId: f.company.id, issueId: task!.id, authorUserId: f.userId, body: "Please include the source dates." }).returning();
|
||||
await db.transaction(async tx => {
|
||||
expect(await publishActiveDotComment(tx as unknown as typeof db, { companyId: f.company.id, agentId: f.agent.id,
|
||||
bindingId: f.snapshot.bindingId, runId: run.id, issueId: task!.id, commentId: comment!.id })).toMatchObject({ consumed: false });
|
||||
await tx.insert(agentWakeupRequests).values({ companyId: f.company.id, agentId: f.agent.id, source: "assignment", triggerDetail: "system",
|
||||
reason: "issue_comment", status: "deferred_issue_execution", payload: { issueId: task!.id, commentId: comment!.id } });
|
||||
});
|
||||
const delivery = vi.fn().mockResolvedValue({ delivery: "accepted", httpStatus: 200 });
|
||||
const dispatcher = createGrokRoutineDispatcher(db, () => true, delivery);
|
||||
await dispatcher.tick();
|
||||
expect(delivery).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(dotMailboxItems).where(and(eq(dotMailboxItems.assignmentId, assignment.id), eq(dotMailboxItems.kind, "follow_up")))).toHaveLength(1);
|
||||
expect(await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.agentId, f.agent.id), eq(agentWakeupRequests.status, "deferred_issue_execution")))).toHaveLength(1);
|
||||
await f.events.stop();
|
||||
});
|
||||
|
||||
it("serializes tool-result writes and cursor reads with the binding mailbox lock", async () => {
|
||||
const f = await fixture();
|
||||
const { run, assignment } = await offeredWork(f);
|
||||
@@ -665,12 +752,13 @@ describe("durable Dot Runner integration", () => {
|
||||
}, 30000);
|
||||
|
||||
it.each([
|
||||
{ name: "local Rust", managed: false },
|
||||
...(process.env.PAPERCLIP_DOT_DAYTONA_LIVE === "1" ? [{ name: "private Daytona Rust", managed: true }] : []),
|
||||
])("signed readiness, native tools and finalization through $name", async ({ managed }) => {
|
||||
{ name: "Dot local Rust", managed: false, provider: "openai_dot" as const },
|
||||
{ name: "Grok local Rust", managed: false, provider: "grok_bot" as const },
|
||||
...(process.env.PAPERCLIP_DOT_DAYTONA_LIVE === "1" ? [{ name: "private Daytona Rust", managed: true, provider: "openai_dot" as const }] : []),
|
||||
])("signed readiness, native tools and finalization through $name", async ({ managed, provider }) => {
|
||||
const remote = managed ? await (await import("./helpers/dot-daytona.js")).startDotDaytona(root) : null;
|
||||
try {
|
||||
const f = await fixture();
|
||||
const f = await fixture({ provider });
|
||||
const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Save the arithmetic report", description: "Save 17 + 25 = 42 as a task document.",
|
||||
status: "in_progress", workMode: "standard", assigneeAgentId: f.agent.id }).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "running", invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: issue!.id } }).returning();
|
||||
@@ -684,7 +772,7 @@ describe("durable Dot Runner integration", () => {
|
||||
runtimeContext.instructions.bundle.rootPath = root;
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const execution = buildNativeExecutionInput({ companyId: f.company.id, agentId: f.agent.id, runId: run!.id, issue: issue!,
|
||||
taskPrompt: issue!.description!, normalizedSessionId: prepared.normalizedSessionId, provider: "openai_dot", dotBinding: f.snapshot,
|
||||
taskPrompt: issue!.description!, normalizedSessionId: prepared.normalizedSessionId, provider, dotBinding: f.snapshot,
|
||||
workspace: { id: randomUUID(), cwd: root, repoUrl: null, repoRef: null, branchName: null }, runtimeContext,
|
||||
completionContract: { id: contract!.id, sha256: contract!.canonicalSha256,
|
||||
schemaVersion: contract!.schemaVersion, contract: contract!.contractJson as unknown as StrictCompletionContractInput } });
|
||||
@@ -696,7 +784,7 @@ describe("durable Dot Runner integration", () => {
|
||||
const appCall = vi.fn().mockResolvedValue({ status: "completed", result: { content: [{ type: "text", text: "synthetic gateway result" }] } });
|
||||
const gatewayTools = [{ name: "fixture:read_status", displayName: "Read fixture status", description: "Read a synthetic app", parametersSchema: { type: "object", properties: {}, additionalProperties: false }, risk: "read" }];
|
||||
registerAssignedMcpGateway(db, { listToolsForNamedGateway: vi.fn().mockResolvedValue(gatewayTools), executeTool: appCall } as unknown as ToolGatewayService);
|
||||
await db.update(agents).set({ adapterConfig: { ...f.agent.adapterConfig, dotWorkspaceAccess: true, dotAttachmentAccess: true } }).where(eq(agents.id, f.agent.id));
|
||||
if (provider === "openai_dot") await db.update(agents).set({ adapterConfig: { ...f.agent.adapterConfig, dotBindingId: f.snapshot.bindingId, dotWorkspaceAccess: true, dotAttachmentAccess: true } }).where(eq(agents.id, f.agent.id));
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-cloud");
|
||||
try {
|
||||
for (const useRunnerd of [false, true]) {
|
||||
@@ -719,18 +807,53 @@ describe("durable Dot Runner integration", () => {
|
||||
try {
|
||||
await vi.waitFor(async () => { if (startupError) throw startupError; expect(await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id))).toHaveLength(1); }, { timeout: 30000, interval: 50 });
|
||||
const [assignment] = await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.runId, run!.id));
|
||||
await f.events.tick();
|
||||
if (provider === "openai_dot") await f.events.tick();
|
||||
expect(JSON.stringify(f.received)).not.toContain("17 + 25");
|
||||
const work = await f.broker.read(f.principal, assignment!.id);
|
||||
expect(work.accounting).toEqual({ usage: null, cost: null });
|
||||
expect(work.status).toBe("offered");
|
||||
expect(observed.filter(event => event.eventType === "turn.started")).toHaveLength(0);
|
||||
expect(JSON.stringify(work)).not.toContain(root);
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId === "register_deliverable")).toBe(true);
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId === "register_deliverable")).toBe(provider === "openai_dot");
|
||||
expect(await f.broker.operation(f.principal, assignment!.id, randomUUID(), "tool", { name: "write_document", arguments: {} })).toMatchObject({ status: "rejected" });
|
||||
await f.broker.operation(f.principal, assignment!.id, randomUUID(), "accept", {});
|
||||
expect(JSON.stringify(work)).not.toContain("fixture-gateway-secret");
|
||||
if (provider === "grok_bot") {
|
||||
const files = new Map<string, Buffer>();
|
||||
const transfers = createPublicMcpTransfers(db, f.oauth, async () => { throw new Error("Personal API dispatch forbidden"); }, {
|
||||
provider: "local_disk", putFile: async input => {
|
||||
if (!Buffer.isBuffer(input.body)) throw new Error("Expected bounded bytes");
|
||||
const objectKey = input.objectKey!; files.set(objectKey, input.body);
|
||||
return { provider: "local_disk", objectKey, contentType: input.contentType, byteSize: input.body.length,
|
||||
sha256: createHash("sha256").update(input.body).digest("hex"), originalFilename: input.originalFilename };
|
||||
}, getObject: async (_company, key) => ({ stream: Readable.from(files.get(key)!) }),
|
||||
headObject: async () => ({ exists: true }), deleteObject: async (_company, key) => { files.delete(key); },
|
||||
}, { pathPrefix: "/mcp/runner/files", agentSession: { authorize: (principal, sessionId, taskId, write, database) => dotRunnerBroker(database ?? db).authorizeFileSession(principal, sessionId, taskId, write) } });
|
||||
const api = express(); api.use(transfers.router);
|
||||
const body = Buffer.from("Grok arithmetic file: 42\n"), uploadId = randomUUID();
|
||||
const args = { companyId: f.company.id, taskId: issue!.id, requestId: uploadId, filename: "report.txt", contentType: "text/plain", byteSize: body.length, sha256: createHash("sha256").update(body).digest("hex") };
|
||||
const grant = await transfers.getUploadUrl(f.principal, args, assignment!.id);
|
||||
const url = new URL(String(grant.url));
|
||||
const uploaded = await request(api).put(url.pathname + url.search).set("Content-Type", "text/plain").send(body);
|
||||
expect(uploaded.status).toBe(200);
|
||||
const duplicate = await request(api).put(url.pathname + url.search).set("Content-Type", "text/plain").send(body);
|
||||
expect(duplicate.body.attachment.id).toBe(uploaded.body.attachment.id);
|
||||
expect(await issueService(db).getAttachmentById(uploaded.body.attachment.id)).toMatchObject({ createdByAgentId: f.agent.id, originatingRunId: run!.id, createdByUserId: null });
|
||||
await expect(transfers.getUploadUrl(f.principal, { ...args, taskId: randomUUID() }, assignment!.id)).rejects.toThrow();
|
||||
const download = new URL(String((await transfers.getDownloadUrl(f.principal, uploaded.body.attachment.id, assignment!.id)).url));
|
||||
expect((await request(api).get(download.pathname + download.search)).status).toBe(200);
|
||||
const pendingUpload = new URL(String((await transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id)).url));
|
||||
await db.update(issues).set({ workMode: "ask" }).where(eq(issues.id, issue!.id));
|
||||
await expect(transfers.getUploadUrl(f.principal, { ...args, requestId: randomUUID() }, assignment!.id)).rejects.toThrow("work mode");
|
||||
expect((await request(api).put(pendingUpload.pathname + pendingUpload.search).set("Content-Type", "text/plain").send(body)).status).toBeGreaterThanOrEqual(400);
|
||||
await db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, issue!.id));
|
||||
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
expect((await request(api).get(download.pathname + download.search)).status).toBeGreaterThanOrEqual(400);
|
||||
await db.update(agents).set({ status: "active" }).where(eq(agents.id, f.agent.id));
|
||||
}
|
||||
|
||||
const app = (work.tools as Array<{ operationId: string }>).find(tool => tool.operationId.startsWith("app_"))!;
|
||||
if (provider === "openai_dot") {
|
||||
expect(app).toBeTruthy();
|
||||
if (remote) {
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => tool.operationId.startsWith("workspace_"))).toBe(false);
|
||||
@@ -748,6 +871,10 @@ describe("durable Dot Runner integration", () => {
|
||||
await f.broker.operation(f.principal, assignment!.id, appRequestId, "tool", { name: app.operationId, arguments: {} });
|
||||
await vi.waitFor(async () => expect(await f.broker.operationStatus(f.principal, assignment!.id, appRequestId)).toMatchObject({ status: "completed", isError: false }), { timeout: 10000 });
|
||||
expect(appCall).toHaveBeenCalledWith(expect.objectContaining({ gatewayPublicId: "dot-fixture", sessionToken: "fixture-gateway-secret", tool: "fixture:read_status" }));
|
||||
} else {
|
||||
expect(app).toBeUndefined();
|
||||
expect((work.tools as Array<{ operationId: string }>).some(tool => ["get_workspace", "prepare_repository", "workspace_read"].includes(tool.operationId))).toBe(false);
|
||||
}
|
||||
await db.update(agents).set({ budgetMonthlyCents: 100, spentMonthlyCents: 100 }).where(eq(agents.id, f.agent.id));
|
||||
await expect(f.broker.read(f.principal, assignment!.id)).rejects.toThrow("execution authority");
|
||||
await db.update(agents).set({ budgetMonthlyCents: 0, spentMonthlyCents: 0, status: "paused" }).where(eq(agents.id, f.agent.id));
|
||||
@@ -775,7 +902,14 @@ describe("durable Dot Runner integration", () => {
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", artifacts: [], attentionRequests: [],
|
||||
verification: [{ commandOrCheck: "17 + 25", status: "passed" }],
|
||||
} } });
|
||||
if (provider === "grok_bot") await db.update(grokBotDispatches).set({ status: "accepted", sentAt: new Date() })
|
||||
.where(and(eq(grokBotDispatches.assignmentId, assignment!.id), eq(grokBotDispatches.kind, "mailbox_updated")));
|
||||
await f.broker.operation(f.principal, assignment!.id, randomUUID(), "finish", { result });
|
||||
if (provider === "grok_bot") {
|
||||
await vi.waitFor(async () => expect(await db.select().from(grokBotDispatches).where(and(eq(grokBotDispatches.assignmentId, assignment!.id),
|
||||
eq(grokBotDispatches.kind, "mailbox_updated"), inArray(grokBotDispatches.status, ["prepared", "sending", "accepted", "unknown"])))).toHaveLength(0));
|
||||
expect(await grokRoutineService(db).recoveryNeeded(f.company.id, f.agent.id)).toBe(false);
|
||||
}
|
||||
await resultPromise;
|
||||
// The heartbeat records its controller workspace settlement before the
|
||||
// ordinary status finalizer commits the task disposition.
|
||||
@@ -786,14 +920,14 @@ describe("durable Dot Runner integration", () => {
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, run!.id))).toHaveLength(1);
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issue!.id)))[0]?.status).toBe("done");
|
||||
expect(await db.select().from(dotRunnerOperations).where(eq(dotRunnerOperations.requestId, writeId))).toHaveLength(1);
|
||||
expect(startupOutcome).toMatchObject({ exitCode: 0, model: null, nativeFinalization: { providerSessionId: null, driverKind: "openai_dot_mcp" } });
|
||||
expect(startupOutcome).toMatchObject({ exitCode: 0, model: null, nativeFinalization: { providerSessionId: null, driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp" } });
|
||||
expect(observed.filter(event => event.eventType === "turn.started")).toHaveLength(1);
|
||||
} catch (error) {
|
||||
if (startupError) throw startupError;
|
||||
await Promise.race([resultPromise.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]);
|
||||
if (startupError) throw startupError;
|
||||
throw error;
|
||||
} finally { await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop(); server.closeAllConnections(); await new Promise<void>(done => server.close(() => done())); }
|
||||
} finally { if (provider === "openai_dot") await f.events.unsubscribe(f.principal, f.subscription); await f.events.stop(); server.closeAllConnections(); await new Promise<void>(done => server.close(() => done())); }
|
||||
} finally { await remote?.close(); }
|
||||
}, 360000);
|
||||
|
||||
|
||||
@@ -83,6 +83,67 @@ describe("errorHandler", () => {
|
||||
expect(telemetryMocks.trackErrorHandlerCrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function endedPoolQueryError() {
|
||||
// Drizzle wraps the driver error; postgres.js uses CONNECTION_ENDED for a
|
||||
// query issued after this process called `sql.end()`.
|
||||
const driverError = Object.assign(new Error("write CONNECTION_ENDED db.example:5432"), {
|
||||
code: "CONNECTION_ENDED",
|
||||
});
|
||||
return new Error('Failed query: select "id" from "issues"', { cause: driverError });
|
||||
}
|
||||
|
||||
it("answers 503 without reporting a query that outlived the database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: { paperclipDatabaseClosing: true } } } as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
|
||||
errorHandler(endedPoolQueryError(), req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: "Server is shutting down" });
|
||||
expect(res.err).toBeUndefined();
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(telemetryMocks.trackErrorHandlerCrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only ends the response when headers were already sent during database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: { paperclipDatabaseClosing: true } } } as unknown as Request;
|
||||
const res = { ...makeRes(), end: vi.fn(), headersSent: true } as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
|
||||
errorHandler(endedPoolQueryError(), req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reports an ended-pool query error outside database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: {} } } as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = endedPoolQueryError();
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(captureExceptionMock).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it("still reports other connection failures during database shutdown", () => {
|
||||
const req = { ...makeReq(), app: { locals: { paperclipDatabaseClosing: true } } } as unknown as Request;
|
||||
const res = makeRes() as any;
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const err = new Error("Failed query", {
|
||||
cause: Object.assign(new Error("write CONNECTION_CLOSED"), { code: "CONNECTION_CLOSED" }),
|
||||
});
|
||||
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(captureExceptionMock).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it("exposes raw 500 messages for trusted Cloud tenant imports", () => {
|
||||
const req = {
|
||||
...makeReq(),
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { grokBotRoutes } from "../routes/grok-bot.js";
|
||||
import { publicMcpIngressRoutes } from "../routes/public-mcp.js";
|
||||
import { createPublicMcpExecutor } from "../services/public-mcp/capabilities.js";
|
||||
import { createDotRunnerMcpTools } from "../services/dot-runner-broker.js";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, dotAgentBindings, grokBotDispatches,
|
||||
grokBotRoutines, mcpOauthGrants, activityLog, issues, heartbeatRuns, dotRunnerAssignments, dotMailboxItems, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { createPublicMcpOAuth } from "../services/public-mcp/oauth.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { createGrokRoutineDispatcher } from "../services/grok-bot/dispatcher.js";
|
||||
import { grokRoutineService } from "../services/grok-bot/routine.js";
|
||||
import { type GrokRoutineWake } from "../services/grok-bot/webhook.js";
|
||||
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-grok-routine-");
|
||||
db = createDb(temporary.connectionString);
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableNativeRunner: true, enableGrokBot: true, enableOpenAiDot: true });
|
||||
}, 120000);
|
||||
afterAll(async () => { try { await temporary?.cleanup(); } finally { vi.unstubAllEnvs(); } });
|
||||
|
||||
async function fixture() {
|
||||
const userId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Grok operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
const [company] = await db.insert(companies).values({ name: "Grok test", issuePrefix: "GK" + randomBytes(3).toString("hex") }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company!.id, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
|
||||
await accessService(db).setPrincipalPermission(company!.id, "user", userId, "agents:create", true, userId);
|
||||
const [agent] = await db.insert(agents).values({ companyId: company!.id, name: "Grok test", adapterType: "paperclip_runner", status: "active", adapterConfig: { provider: "grok_bot", allowUnmeteredProvider: true, lifecycleMode: "per_turn" } }).returning();
|
||||
const oauth = createPublicMcpOAuth(db, { origin: "https://paperclip.example", resource: "https://paperclip.example/mcp/runner" });
|
||||
const client = await oauth.register({ client_name: "Synthetic Grok client", redirect_uris: ["https://client.example/callback"] }, randomUUID());
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const input = { client_id: client.client_id, redirect_uri: "https://client.example/callback", response_type: "code", resource: oauth.config.resource,
|
||||
scope: "paperclip:agent offline_access", code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256" };
|
||||
const requestId = (await oauth.authorize(input)).split("/").at(-1)!;
|
||||
const pairing = await dotRunnerBroker(db).createPairing({ companyId: company!.id, agentId: agent!.id, operatorId: userId, provider: "grok_bot" });
|
||||
const consent = await oauth.consentDotPairing(requestId, pairing.pairingCode);
|
||||
const tokens = await oauth.token({ grant_type: "authorization_code", client_id: client.client_id, redirect_uri: input.redirect_uri,
|
||||
resource: oauth.config.resource, code: new URL(consent.redirectUrl).searchParams.get("code"), code_verifier: verifier });
|
||||
const principal = await oauth.authenticate(tokens.access_token);
|
||||
const delivery = vi.fn().mockResolvedValue({ delivery: "accepted", httpStatus: 200 });
|
||||
const service = grokRoutineService(db, delivery);
|
||||
const config = { companyId: company!.id, agentId: agent!.id, bindingId: pairing.bindingId, operatorId: userId,
|
||||
expectedRevision: 0, botName: "Synthetic Bot", webhookUrl: "https://bot.example.test/routines/synthetic", webhookKey: "synthetic-sender-key" };
|
||||
await service.configure(config);
|
||||
return { company: company!, agent: agent!, principal, pairing, service, delivery, config, oauth, tokens };
|
||||
}
|
||||
async function verification(f: Awaited<ReturnType<typeof fixture>>, sourceEventId = randomUUID()) {
|
||||
return f.service.reserve({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId, sourceEventId, kind: "verify" });
|
||||
}
|
||||
function acknowledgement(row: Awaited<ReturnType<typeof verification>>) {
|
||||
const wake = row.payload as GrokRoutineWake & { challenge: string };
|
||||
return { correlationId: row.id, bindingId: row.bindingId, bindingGeneration: row.bindingGeneration, challenge: wake.challenge };
|
||||
}
|
||||
|
||||
describe("durable Grok routine qualification", () => {
|
||||
it("receives an authenticated Grok acknowledgement through the actual MCP transport without exposing personal or Dot event tools", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
const app = express(); app.use(express.json());
|
||||
const dispatch = async () => { throw new Error("Personal dispatch forbidden"); };
|
||||
app.use(publicMcpIngressRoutes(f.oauth, createPublicMcpExecutor(db, f.oauth, dispatch), undefined, createDotRunnerMcpTools(db)));
|
||||
const invoke = (method: string, params: unknown) => request(app).post("/mcp/runner")
|
||||
.set("Authorization", `Bearer ${f.tokens.access_token}`).set("Accept", "application/json, text/event-stream")
|
||||
.send({ jsonrpc: "2.0", id: 1, method, params });
|
||||
const list = await invoke("tools/list", {});
|
||||
expect(list.status).toBe(200);
|
||||
const names = list.body.result.tools.map((tool: {name: string}) => tool.name);
|
||||
expect(names).toContain("paperclip_grok_ack_wake");
|
||||
expect(names).not.toContain("paperclip_dot_confirm_event");
|
||||
expect(names).not.toContain("paperclip_call_api");
|
||||
const reply = await invoke("tools/call", { name: "paperclip_grok_ack_wake", arguments: acknowledgement(row) });
|
||||
expect(reply.status).toBe(200);
|
||||
expect(reply.body.result.structuredContent).toMatchObject({ outcome: "completed", result: { status: "acknowledged" } });
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true });
|
||||
});
|
||||
|
||||
it("rejects acknowledgement after the authorizing person loses permission to manage the agent", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await accessService(db).setPrincipalPermission(f.company.id, "user", f.principal.grant.userId, "agents:create", false, f.principal.grant.userId);
|
||||
await expect(f.service.acknowledge(f.principal, acknowledgement(row))).rejects.toThrow("permission");
|
||||
});
|
||||
|
||||
it("filters private current and queued tasks from the connection overview", async () => {
|
||||
const f = await fixture();
|
||||
const reader = randomUUID();
|
||||
await db.insert(authUsers).values({ id: reader, name: "Reader", email: `${reader}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: reader, membershipRole: "member", status: "active" });
|
||||
const [privateTask] = await db.insert(issues).values({ companyId: f.company.id, title: "Private strategy", visibility: "private", createdByUserId: f.principal.grant.userId, assigneeAgentId: f.agent.id, status: "todo" }).returning();
|
||||
await db.insert(issues).values({ companyId: f.company.id, title: "Visible task", assigneeAgentId: f.agent.id, status: "todo" });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "running", invocationSource: "assignment", triggerDetail: "system", nativeIssueId: privateTask!.id }).returning();
|
||||
await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId, bindingGeneration: 1,
|
||||
runId: run!.id, normalizedSessionId: randomUUID(), turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "fixture", projection: {},
|
||||
status: "accepted", acceptBy: new Date(Date.now() + 60000), expiresAt: new Date(Date.now() + 60000) });
|
||||
const app = express(); app.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId: reader, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, membershipRole: "member", status: "active" }] }; next(); });
|
||||
app.use("/api", grokBotRoutes(db, f.oauth.config.resource));
|
||||
const path = `/api/companies/${f.company.id}/agents/${f.agent.id}/grok-binding`;
|
||||
const hidden = await request(app).get(path).expect(200);
|
||||
expect(hidden.body).toMatchObject({ currentTask: null, queuedTaskCount: 1 });
|
||||
expect(JSON.stringify(hidden.body)).not.toContain("Private strategy");
|
||||
await db.update(issues).set({ responsibleUserId: reader }).where(eq(issues.id, privateTask!.id));
|
||||
const visible = await request(app).get(path).expect(200);
|
||||
expect(visible.body).toMatchObject({ currentTask: { id: privateTask!.id, title: "Private strategy" }, queuedTaskCount: 2 });
|
||||
});
|
||||
|
||||
it.each(["dispatch", "work_session"] as const)("closes settled wake obligations without clearing a %s hold incorrectly", async reason => {
|
||||
const f = await fixture(); const verified = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, verified.id); await f.service.acknowledge(f.principal, acknowledgement(verified));
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "succeeded", invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const [work] = await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId, bindingGeneration: 1,
|
||||
runId: run!.id, normalizedSessionId: randomUUID(), turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "fixture", projection: {},
|
||||
status: "settled", acceptBy: new Date(), expiresAt: new Date(Date.now() - 1) }).returning();
|
||||
const wake = await f.service.reserve({ companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId,
|
||||
assignmentId: work!.id, kind: "mailbox_updated", sourceEventId: randomUUID() });
|
||||
await db.update(grokBotDispatches).set({ status: "unknown", sentAt: new Date() }).where(eq(grokBotDispatches.id, wake.id));
|
||||
await db.update(grokBotRoutines).set({ recoveryRequiredAt: new Date(), recoveryReason: reason }).where(eq(grokBotRoutines.bindingId, f.pairing.bindingId));
|
||||
await db.transaction(tx => grokRoutineService(tx as unknown as typeof db).settleWorkNotifications(f.company.id, f.pairing.bindingId, work!.id));
|
||||
expect((await db.select().from(grokBotDispatches).where(eq(grokBotDispatches.id, wake.id)))[0]!.status).toBe("cancelled");
|
||||
const ack = { correlationId: wake.id, bindingId: wake.bindingId, bindingGeneration: wake.bindingGeneration };
|
||||
expect(await f.service.acknowledge(f.principal, ack)).toMatchObject({ status: "acknowledged", remoteTerminationConfirmed: false });
|
||||
expect(await f.service.recoveryNeeded(f.company.id, f.agent.id)).toBe(reason === "work_session");
|
||||
await expect(dotRunnerBroker(db).operation(f.principal, work!.id, randomUUID(), "progress", { text: "late effect" })).rejects.toThrow("expired");
|
||||
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, work!.id)))[0]!.status).toBe("settled");
|
||||
await db.update(dotRunnerAssignments).set({ status: "fenced" }).where(eq(dotRunnerAssignments.id, work!.id));
|
||||
await expect(f.service.acknowledge(f.principal, ack)).rejects.toThrow("revoked");
|
||||
});
|
||||
|
||||
it("keeps HTTP delivery and authenticated reply separate, with auditable agent and authorizer", async () => {
|
||||
const f = await fixture();
|
||||
const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: false, lastExchange: { status: "accepted", httpStatus: 200, acknowledgedAt: null } });
|
||||
expect((await db.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, row.bindingId)))[0]!.status).toBe("connected");
|
||||
await f.service.acknowledge(f.principal, acknowledgement(row));
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true, recoveryNeeded: false, lastExchange: { status: "acknowledged" } });
|
||||
const audit = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.company.id), eq(activityLog.action, "grok.wake_verified")));
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0]).toMatchObject({ actorType: "agent", actorId: f.agent.id, details: { authorizingPersonId: f.principal.grant.userId, correlationId: row.id } });
|
||||
expect(JSON.stringify(await f.service.state(f.company.id, f.agent.id, row.bindingId))).not.toContain("synthetic-sender-key");
|
||||
});
|
||||
|
||||
it("reserves once across concurrent attempts and never resends accepted work after a new controller starts", async () => {
|
||||
const f = await fixture(), source = randomUUID();
|
||||
const rows = await Promise.all([1, 2, 3].map(() => verification(f, source)));
|
||||
expect(new Set(rows.map(r => r.id)).size).toBe(1);
|
||||
await Promise.all(rows.map(r => f.service.send(f.company.id, f.agent.id, r.id)));
|
||||
const restarted = grokRoutineService(db, f.delivery);
|
||||
await restarted.send(f.company.id, f.agent.id, rows[0]!.id);
|
||||
expect(f.delivery).toHaveBeenCalledTimes(1);
|
||||
await expect(verification(f)).rejects.toThrow("outstanding delivery");
|
||||
});
|
||||
|
||||
it("reconciles a lost HTTP response with the same authenticated challenge without retrying", async () => {
|
||||
const f = await fixture(); f.delivery.mockResolvedValue({ delivery: "unknown", httpStatus: null });
|
||||
const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: true, verified: false });
|
||||
await f.service.acknowledge(f.principal, acknowledgement(row));
|
||||
await f.service.acknowledge(f.principal, acknowledgement(row));
|
||||
expect(f.delivery).toHaveBeenCalledTimes(1);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: false, verified: true });
|
||||
});
|
||||
|
||||
it("does not downgrade an authenticated acknowledgement when an ambiguous transport response arrives later", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockImplementation(async () => { await f.service.acknowledge(f.principal, acknowledgement(row)); return { delivery: "unknown", httpStatus: 504 }; });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: false, verified: true, lastExchange: { status: "acknowledged" } });
|
||||
});
|
||||
|
||||
it("keeps crashed sending intents in recovery and prevents credential replacement while delivery is uncertain", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
await db.update(grokBotDispatches).set({ status: "sending", sentAt: new Date(Date.now() - 60000), updatedAt: new Date(Date.now() - 60000) }).where(eq(grokBotDispatches.id, row.id));
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
expect(f.delivery).not.toHaveBeenCalled();
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ recoveryNeeded: true });
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1 })).rejects.toThrow("recover");
|
||||
});
|
||||
|
||||
it.each(["wrong nonce", "wrong binding", "wrong generation", "expired", "revoked", "personal grant", "other company"])("rejects %s without readiness or accepted late results", async condition => {
|
||||
const f = await fixture(); const other = await fixture(); const row = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
const ack = acknowledgement(row);
|
||||
let principal = f.principal;
|
||||
if (condition === "wrong nonce") ack.challenge = "a-different-nonce-long-enough";
|
||||
if (condition === "wrong binding") ack.bindingId = other.pairing.bindingId;
|
||||
if (condition === "wrong generation") ack.bindingGeneration++;
|
||||
if (condition === "expired") await db.update(grokBotDispatches).set({ expiresAt: new Date(0) }).where(eq(grokBotDispatches.id, row.id));
|
||||
if (condition === "revoked") await db.update(mcpOauthGrants).set({ revokedAt: new Date() }).where(eq(mcpOauthGrants.id, f.principal.grant.id));
|
||||
if (condition === "personal grant") principal = { ...f.principal, grant: { ...f.principal.grant, purpose: "personal" } };
|
||||
if (condition === "other company") principal = other.principal;
|
||||
await expect(f.service.acknowledge(principal, ack)).rejects.toThrow();
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: false });
|
||||
});
|
||||
|
||||
it("stores URL and key together encrypted and rejects stale, foreign or unauthorized configuration", async () => {
|
||||
const f = await fixture();
|
||||
const [stored] = await db.select().from(grokBotRoutines).where(eq(grokBotRoutines.bindingId, f.pairing.bindingId));
|
||||
expect(JSON.stringify(stored)).not.toContain(f.config.webhookKey);
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 0 })).rejects.toThrow("changed");
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1, operatorId: randomUUID() })).rejects.toThrow("permission");
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1, companyId: randomUUID() })).rejects.toThrow();
|
||||
await expect(f.service.configure({ ...f.config, expectedRevision: 1, webhookUrl: "https://user:password@bot.example.test/routine" })).rejects.toThrow("Invalid");
|
||||
});
|
||||
it("retries only confirmed rejection, retaining receipts and deduplicating concurrent operator retries", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValueOnce({ delivery: "rejected", httpStatus: 429 });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, randomUUID(), row.id)).rejects.toThrow("permission");
|
||||
await expect(f.service.retryRejected(randomUUID(), f.agent.id, f.principal.grant.userId, row.id)).rejects.toThrow();
|
||||
await Promise.all([1, 2].map(() => f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, row.id)));
|
||||
const receipts = await db.select().from(grokBotDispatches).where(eq(grokBotDispatches.bindingId, f.pairing.bindingId));
|
||||
expect(receipts).toHaveLength(2);
|
||||
expect(receipts.find(receipt => receipt.id === row.id)).toMatchObject({ status: "rejected", httpStatus: 429 });
|
||||
const retry = receipts.find(receipt => receipt.id !== row.id)!;
|
||||
expect(retry).toMatchObject({ status: "accepted", sourceEventId: `retry:${row.id}` });
|
||||
expect(f.delivery).toHaveBeenCalledTimes(2);
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, retry.id)).rejects.toThrow("confirmed rejected");
|
||||
await f.service.acknowledge(f.principal, acknowledgement(retry));
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true });
|
||||
});
|
||||
|
||||
it("keeps rejected unread feedback available for an explicit retry without automatic resend", async () => {
|
||||
const f = await fixture(); const verified = await verification(f);
|
||||
await f.service.send(f.company.id, f.agent.id, verified.id);
|
||||
await f.service.acknowledge(f.principal, acknowledgement(verified));
|
||||
const sessionId = randomUUID();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id,
|
||||
status: "running", runtimeMode: "native", nativeSessionId: sessionId, invocationSource: "assignment", triggerDetail: "system" }).returning();
|
||||
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Feedback retry",
|
||||
assigneeAgentId: f.agent.id, executionRunId: run!.id, checkoutRunId: run!.id, status: "in_progress" }).returning();
|
||||
await db.update(heartbeatRuns).set({ nativeIssueId: task!.id }).where(eq(heartbeatRuns.id, run!.id));
|
||||
await db.insert(nativeRunFinalizations).values({ runId: run!.id, companyId: f.company.id, issueId: task!.id,
|
||||
phase: "observed", controllerGeneration: 1, leaseExpiresAt: new Date(Date.now() + 60_000) });
|
||||
const [work] = await db.insert(dotRunnerAssignments).values({ companyId: f.company.id, agentId: f.agent.id,
|
||||
bindingId: f.pairing.bindingId, bindingGeneration: 1, runId: run!.id, normalizedSessionId: sessionId, turnId: randomUUID(),
|
||||
controllerGeneration: 1, catalogDigest: "test", status: "accepted", projection: {},
|
||||
acceptBy: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000) }).returning();
|
||||
const sourceEventId = randomUUID();
|
||||
await db.insert(dotMailboxItems).values({ companyId: f.company.id, bindingId: f.pairing.bindingId, bindingGeneration: 1,
|
||||
assignmentId: work!.id, kind: "follow_up", sourceEventId, references: { consumed: false } });
|
||||
f.delivery.mockClear(); f.delivery.mockResolvedValueOnce({ delivery: "rejected", httpStatus: 429 });
|
||||
const dispatcher = createGrokRoutineDispatcher(db, () => true, f.delivery);
|
||||
await dispatcher.tick(); await dispatcher.tick();
|
||||
expect(f.delivery).toHaveBeenCalledTimes(1);
|
||||
const [rejected] = await db.select().from(grokBotDispatches).where(and(eq(grokBotDispatches.assignmentId, work!.id), eq(grokBotDispatches.sourceEventId, sourceEventId)));
|
||||
expect(rejected).toMatchObject({ status: "rejected" });
|
||||
await f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, rejected!.id);
|
||||
await dispatcher.tick();
|
||||
expect(f.delivery).toHaveBeenCalledTimes(2);
|
||||
const [input] = await db.select().from(dotMailboxItems).where(eq(dotMailboxItems.sourceEventId, sourceEventId));
|
||||
expect(input?.references).toMatchObject({ consumed: false });
|
||||
});
|
||||
|
||||
it("never retries uncertain delivery or a rejection from replaced credentials", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValueOnce({ delivery: "unknown", httpStatus: 504 });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, row.id)).rejects.toThrow("confirmed rejected");
|
||||
await f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
const rejected = await verification(f);
|
||||
f.delivery.mockResolvedValueOnce({ delivery: "rejected", httpStatus: 401 });
|
||||
await f.service.send(f.company.id, f.agent.id, rejected.id);
|
||||
await f.service.configure({ ...f.config, expectedRevision: 1 });
|
||||
await expect(f.service.retryRejected(f.company.id, f.agent.id, f.principal.grant.userId, rejected.id)).rejects.toThrow("authority changed");
|
||||
expect(f.delivery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for the binding before acquiring routine recovery locks", async () => {
|
||||
const f = await fixture();
|
||||
let recovery: Promise<unknown> | undefined;
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await tx.select().from(dotAgentBindings).where(eq(dotAgentBindings.id, f.pairing.bindingId)).for("update");
|
||||
recovery = f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
void recovery.catch(() => {});
|
||||
await vi.waitFor(async () => {
|
||||
const result = await db.execute(sql`select exists (select 1 from pg_stat_activity where datname = current_database()
|
||||
and pid <> pg_backend_pid() and wait_event_type = 'Lock' and query like '%dot_agent_bindings%') as waiting`);
|
||||
const row = Array.isArray(result) ? result[0] : result.rows[0];
|
||||
expect(row?.waiting).toBe(true);
|
||||
}, { timeout: 5000 });
|
||||
await tx.execute(sql`set local lock_timeout = '2s'`);
|
||||
// A reverse-order recovery holds this row while waiting for our binding.
|
||||
await tx.select().from(grokBotRoutines).where(eq(grokBotRoutines.bindingId, f.pairing.bindingId)).for("update");
|
||||
});
|
||||
} finally { await recovery; }
|
||||
});
|
||||
|
||||
it("holds uncertainty across disconnect and requires inspection plus a fresh verification", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValue({ delivery: "unknown", httpStatus: 504 });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: false, recoveryNeeded: false });
|
||||
await expect(f.service.acknowledge(f.principal, acknowledgement(row))).rejects.toThrow("cancelled");
|
||||
const next = await verification(f);
|
||||
expect(next.id).not.toBe(row.id);
|
||||
f.delivery.mockResolvedValue({ delivery: "accepted", httpStatus: 200 });
|
||||
await f.service.send(f.company.id, f.agent.id, next.id);
|
||||
await f.service.acknowledge(f.principal, acknowledgement(next));
|
||||
expect(await f.service.state(f.company.id, f.agent.id, row.bindingId)).toMatchObject({ verified: true });
|
||||
});
|
||||
it("keeps a revoked binding's unknown activity from being bypassed by reconnecting", async () => {
|
||||
const f = await fixture(); const row = await verification(f);
|
||||
f.delivery.mockResolvedValue({ delivery: "unknown", httpStatus: null });
|
||||
await f.service.send(f.company.id, f.agent.id, row.id);
|
||||
await dotRunnerBroker(db).revoke(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
expect(await f.service.recoveryNeeded(f.company.id, f.agent.id)).toBe(true);
|
||||
await f.service.recover(f.company.id, f.agent.id, f.principal.grant.userId);
|
||||
expect(await f.service.recoveryNeeded(f.company.id, f.agent.id)).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
agentWakeupRequests,
|
||||
companies,
|
||||
createDb,
|
||||
environmentLeases,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRunWatchdogDecisions,
|
||||
heartbeatRuns,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { truncateTablesWithDeadlockRetry } from "./helpers/truncate-with-deadlock-retry.js";
|
||||
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
|
||||
import { createPostgresWatchdogAdapter } from "../modules/active-run-watchdog/adapters/postgres.js";
|
||||
import {
|
||||
ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS,
|
||||
ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS,
|
||||
@@ -446,6 +449,75 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["remote_lease", "workspace_finalize", "failed_finalize", "native_owner", "restore_failed"])(
|
||||
"leaves a terminal task's run with its lifecycle owner while %s is unsettled",
|
||||
async pending => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({ now, ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceStatus: "done", sameRunTerminalEvidence: true });
|
||||
if (pending === "remote_lease") {
|
||||
await db.insert(environmentLeases).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, status: "active", provider: "daytona", providerLeaseId: "sandbox-with-unsaved-files" });
|
||||
} else if (pending === "workspace_finalize" || pending === "failed_finalize") {
|
||||
await db.insert(workspaceOperations).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, phase: "workspace_finalize", status: pending === "failed_finalize" ? "failed" : "running" });
|
||||
} else if (pending === "native_owner") {
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native" }).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
} else {
|
||||
await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreFailure: "restore_failed" } })
|
||||
.where(eq(heartbeatRuns.id, seeded.runId));
|
||||
}
|
||||
const { recovery, enqueueWakeup } = createRecovery();
|
||||
expect(await recovery.scanSilentActiveRuns({ now, companyId: seeded.companyId }))
|
||||
.toMatchObject({ folded: 0, created: 0, skipped: 1 });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
const [agent] = await db.select().from(agents).where(eq(agents.id, seeded.coderId));
|
||||
expect(run.status).toBe("running");
|
||||
expect(task).toMatchObject({ status: "done", executionRunId: seeded.runId });
|
||||
expect(agent.status).toBe("running");
|
||||
expect(enqueueWakeup).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRunWatchdogDecisions).where(eq(heartbeatRunWatchdogDecisions.runId, seeded.runId)))
|
||||
.toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("folds stale bookkeeping after remote release and successful workspace finalization", async () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({ now, ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceStatus: "done", sameRunTerminalEvidence: true });
|
||||
await db.insert(environmentLeases).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, status: "released", releasedAt: now, cleanupStatus: "success",
|
||||
provider: "daytona", providerLeaseId: "finished-sandbox" });
|
||||
await db.insert(workspaceOperations).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, phase: "workspace_finalize", status: "succeeded", finishedAt: now });
|
||||
expect(await createRecovery().recovery.scanSilentActiveRuns({ now, companyId: seeded.companyId }))
|
||||
.toMatchObject({ folded: 1, created: 0 });
|
||||
});
|
||||
|
||||
it("rechecks remote ownership when a lease appears after the initial fold check", async () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({ now, ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
|
||||
sourceStatus: "done", sameRunTerminalEvidence: true });
|
||||
const adapter = createPostgresWatchdogAdapter(db);
|
||||
const run = await adapter.findRunForCompany(seeded.companyId, seeded.runId);
|
||||
expect(await adapter.isRuntimeSettlementPending(seeded.companyId, seeded.runId)).toBe(false);
|
||||
await db.insert(environmentLeases).values({ companyId: seeded.companyId, heartbeatRunId: seeded.runId,
|
||||
issueId: seeded.issueId, status: "active", provider: "daytona", providerLeaseId: "late-owned-sandbox" });
|
||||
expect(await adapter.foldSourceResolvedRun(seeded.companyId, {
|
||||
run: run!, sourceIssue: { id: seeded.issueId, identifier: `${seeded.issuePrefix}-1`, status: "done",
|
||||
originKind: "manual", isRecoveryOriginKind: false },
|
||||
evidence: { kind: "activity", id: randomUUID(), action: "issue.updated", createdAt: now },
|
||||
existingEvaluation: null, silenceStartedAt: seeded.startedAt, silenceAgeMs: 16 * 60 * 1000,
|
||||
cleanup: { attempted: false, outcome: "no_process_metadata", adapterType: "grok_local" }, now,
|
||||
})).toEqual({ kind: "stale" });
|
||||
const [persisted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
expect(persisted.status).toBe("running");
|
||||
expect(persisted.resultJson).toBeNull();
|
||||
expect(await db.select().from(heartbeatRunWatchdogDecisions).where(eq(heartbeatRunWatchdogDecisions.runId, seeded.runId)))
|
||||
.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("folds existing legacy evaluation and recovery rows idempotently", async () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
const seeded = await seedRunningRun({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { activityRoutes } from "../routes/activity.js";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { authorizationService } from "../services/authorization.js";
|
||||
import { getHeartbeatRunReadDecisions } from "../services/heartbeat-run-privacy.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -43,6 +45,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
|
||||
}, 120_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(activityLog);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
@@ -424,4 +427,103 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}
|
||||
});
|
||||
it.each(["owner", "outsider"])("checks concurrent duplicate issue runs once for the %s", async (viewer) => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const decide = vi.spyOn(access, "decide");
|
||||
const actor = {
|
||||
type: "agent" as const,
|
||||
companyId: fixture.companyId,
|
||||
agentId: viewer === "owner" ? fixture.ownerAgentId : fixture.otherAgentId,
|
||||
};
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
const decisions = await getHeartbeatRunReadDecisions(db, access, actor, Array.from({ length: 200 }, () => ({ ...binding })));
|
||||
expect(decisions).toEqual(Array(200).fill(viewer === "owner"));
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
decide.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps company scope, issue scope, and company identity separate", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding, { ...binding, companyId: randomUUID() }])).toEqual([true, false]);
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, { ...actor, agentId: fixture.otherAgentId }, [
|
||||
{ ...binding, scopeKind: "company" }, binding, { ...binding, scopeKind: null },
|
||||
{ companyId: fixture.companyId, scopeKind: "issue" },
|
||||
])).toEqual([true, false, false, false]);
|
||||
});
|
||||
|
||||
it("uses the captured binding if a caller changes its input while checks are pending", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const decide = vi.spyOn(access, "decide");
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
const pending = getHeartbeatRunReadDecisions(db, access, actor, [binding, binding]);
|
||||
binding.issueId = randomUUID();
|
||||
expect(await pending).toEqual([true, true]);
|
||||
expect(decide).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
resource: expect.objectContaining({ companyId: fixture.companyId, issueId: fixture.issueId }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not share decisions between actors", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, { ...actor, agentId: fixture.otherAgentId }, [binding])).toEqual([false]);
|
||||
});
|
||||
|
||||
it("rechecks a revoked grant on the next history request", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const duplicateId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: duplicateId, companyId: fixture.companyId, agentId: fixture.ownerAgentId,
|
||||
scopeKind: "issue", issueId: fixture.issueId, invocationSource: "on_demand", status: "succeeded",
|
||||
resultJson: { summary: "PRIVATE_HISTORY_CANARY" },
|
||||
});
|
||||
const app = createApp(fixture.companyId, fixture.grantedAgentId);
|
||||
const path = `/api/companies/${fixture.companyId}/heartbeat-runs`;
|
||||
const permitted = await request(app).get(path);
|
||||
expect(permitted.status).toBe(200);
|
||||
expect(permitted.body.find((run: { id: string }) => run.id === duplicateId).resultJson.summary).toBe("PRIVATE_HISTORY_CANARY");
|
||||
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.subjectId, fixture.grantedAgentId));
|
||||
for (const suffix of ["", "?summary=true"]) {
|
||||
const denied = await request(app).get(path + suffix);
|
||||
expect(denied.status).toBe(200);
|
||||
for (const id of [fixture.privateRunId, duplicateId]) {
|
||||
expect(denied.body.find((run: { id: string }) => run.id === id)).toMatchObject({ id, redacted: true });
|
||||
}
|
||||
expect(JSON.stringify(denied.body)).not.toContain("PRIVATE_HISTORY_CANARY");
|
||||
expect(JSON.stringify(denied.body)).not.toContain("private@example.test");
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when a shared decision rejects and can recover in a new request", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const error = new Error("authorization unavailable");
|
||||
const decide = vi.spyOn(access, "decide").mockRejectedValue(error);
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
await expect(getHeartbeatRunReadDecisions(db, access, actor, Array(20).fill(binding))).rejects.toBe(error);
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
decide.mockRestore();
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
|
||||
});
|
||||
|
||||
it("rejects an issue deleted before the next request", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const access = authorizationService(db);
|
||||
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
|
||||
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
|
||||
await db.delete(issues).where(eq(issues.id, fixture.issueId));
|
||||
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([false]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -60,6 +60,7 @@ describe("instance settings service", () => {
|
||||
enableAgentChat: false,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableGrokBot: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
|
||||
@@ -45,6 +45,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"costs.ts": "/api",
|
||||
"dashboard.ts": "/api",
|
||||
"dot-runner.ts": "/api",
|
||||
"grok-bot.ts": "/api",
|
||||
"decision-queues.ts": "/api",
|
||||
"decisions.ts": "/api",
|
||||
"decision-training.ts": "/api",
|
||||
@@ -247,11 +248,11 @@ function loadActualRoutes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (file === "dot-runner.ts") {
|
||||
for (const name of ["path", "invitePath"]) {
|
||||
if (file === "dot-runner.ts" || file === "grok-bot.ts") {
|
||||
for (const name of file === "dot-runner.ts" ? ["path", "invitePath"] : ["path"]) {
|
||||
const basePath = new RegExp(`const ${name} = "([^"]+)"`).exec(source)?.[1];
|
||||
if (!basePath) throw new Error(`Dot ${name} route prefix is missing`);
|
||||
const methods = new RegExp(`router\\.(get|post|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g");
|
||||
const methods = new RegExp(`router\\.(get|post|put|delete)\\(${name}(?: \\+ "([^"]+)")?`, "g");
|
||||
for (const match of source.matchAll(methods)) {
|
||||
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import express from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMcpApiDispatch } from "../services/public-mcp/capabilities.js";
|
||||
import type { McpPrincipal } from "../services/public-mcp/oauth.js";
|
||||
|
||||
const captureExceptionMock = vi.hoisted(() => vi.fn());
|
||||
const trackErrorHandlerCrashMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../sentry.js", () => ({ captureException: captureExceptionMock }));
|
||||
vi.mock("../telemetry.js", () => ({ getTelemetryClient: () => ({}) }));
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackErrorHandlerCrash: trackErrorHandlerCrashMock,
|
||||
}));
|
||||
|
||||
const principal = { actor: { type: "board", userId: "user-1" } } as unknown as McpPrincipal;
|
||||
|
||||
// The internal dispatch runs each public MCP call in its own Express app, so
|
||||
// the shutdown flag set on the main app must reach that app's error handler.
|
||||
function endedPoolApi() {
|
||||
const api = express.Router();
|
||||
api.get("/tasks", () => {
|
||||
throw new Error('Failed query: select "id" from "issues"', {
|
||||
cause: Object.assign(new Error("write CONNECTION_ENDED db.example:5432"), { code: "CONNECTION_ENDED" }),
|
||||
});
|
||||
});
|
||||
return api;
|
||||
}
|
||||
|
||||
describe("public MCP dispatch during database shutdown", () => {
|
||||
beforeEach(() => {
|
||||
captureExceptionMock.mockReset();
|
||||
trackErrorHandlerCrashMock.mockReset();
|
||||
});
|
||||
|
||||
it("does not report a call that outlived the database pool", async () => {
|
||||
const parentLocals: Record<string, unknown> = {};
|
||||
const dispatch = createMcpApiDispatch(endedPoolApi(), parentLocals);
|
||||
parentLocals.paperclipDatabaseClosing = true;
|
||||
|
||||
await expect(dispatch(principal, "GET", "/tasks", undefined)).rejects.toMatchObject({ status: 503 });
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(trackErrorHandlerCrashMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reports an ended-pool error outside database shutdown", async () => {
|
||||
const dispatch = createMcpApiDispatch(endedPoolApi(), {});
|
||||
|
||||
await expect(dispatch(principal, "GET", "/tasks", undefined)).rejects.toMatchObject({ status: 500 });
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -237,3 +237,10 @@ describe("stripSecretBearingUrlParts", () => {
|
||||
).toBe("/api/tools/oauth/callback");
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts Grok routine URL and key in setup failure logs", () => {
|
||||
const payload = { webhookUrl: "https://bot.example/routine/url-canary", webhookKey: "key-canary" };
|
||||
expect(JSON.stringify(redactSensitive(payload))).not.toContain("canary");
|
||||
expect(collectSensitiveStringValues(payload)).toContain(payload.webhookUrl);
|
||||
expect(collectSensitiveStringValues(payload)).toContain(payload.webhookKey);
|
||||
});
|
||||
@@ -117,8 +117,12 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect(await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.id, [f.sourceRunId, f.currentRunId]))).toHaveLength(2);
|
||||
}
|
||||
|
||||
it.each(["reassigned", "conversation_reset", "unassigned"])("records board repair after %s without restarting or changing the current task", async scenario => {
|
||||
it.each(["reassigned", "conversation_reset", "unassigned", "successful_source"])("records board repair after %s without restarting or changing the current task", async scenario => {
|
||||
const f = await seed({ conversationReset: scenario === "conversation_reset", noCurrentOwner: scenario === "unassigned" });
|
||||
if (scenario === "successful_source") {
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
}
|
||||
const [sourceBefore] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const { app, wake } = appFor();
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
|
||||
await request(app).post(`/api/issues/${f.issueId}/recovery-actions/resolve`).send(f.body).expect(200);
|
||||
@@ -137,6 +141,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId))).toEqual(auditBefore);
|
||||
await assertSourceUnchanged(f);
|
||||
expect(wake).not.toHaveBeenCalled();
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)))[0]).toEqual(sourceBefore);
|
||||
});
|
||||
|
||||
it.each(["blocked", "done", "cancelled", "backlog"])("records repair without changing the current %s task", async status => {
|
||||
|
||||
+13
-5
@@ -19,8 +19,10 @@ import { registerBrowserUseCleanup } from "./services/browser-use-work.js";
|
||||
import { browserUseService } from "./services/browser-use.js";
|
||||
import { slackToolRoutes } from "./routes/slack-tools.js";
|
||||
import { createPublicMcpOAuth, publicMcpConfig } from "./services/public-mcp/oauth.js";
|
||||
import { createDotRunnerMcpTools } from "./services/dot-runner-broker.js";
|
||||
import { createDotRunnerMcpTools, dotRunnerBroker } from "./services/dot-runner-broker.js";
|
||||
import { agentProfileAvatarRoutes } from "./routes/agent-profile-avatar.js";
|
||||
import { grokBotRoutes } from "./routes/grok-bot.js";
|
||||
import { createGrokRoutineDispatcher } from "./services/grok-bot/dispatcher.js";
|
||||
import { dotRunnerRoutes } from "./routes/dot-runner.js";
|
||||
import { createPublicMcpTransfers } from "./services/public-mcp/file-transfers.js";
|
||||
import { createMcpApiDispatch, createPublicMcpExecutor } from "./services/public-mcp/capabilities.js";
|
||||
@@ -585,7 +587,7 @@ export async function createApp(
|
||||
);
|
||||
const jsonBodyParser = express.json({ limit: DEFAULT_JSON_BODY_LIMIT, verify: captureRawBody });
|
||||
// File tickets authenticate before parsing bytes; JSON attachments must remain bytes too.
|
||||
app.use((req, res, next) => req.path === "/mcp/files/upload" ? next() : jsonBodyParser(req, res, next));
|
||||
app.use((req, res, next) => ["/mcp/files/upload", "/mcp/runner/files/upload"].includes(req.path) ? next() : jsonBodyParser(req, res, next));
|
||||
app.use("/api", apiCompression());
|
||||
app.use(httpLogger);
|
||||
const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({
|
||||
@@ -1061,9 +1063,10 @@ export async function createApp(
|
||||
}),
|
||||
);
|
||||
let publicMcpEvents: PublicMcpEvents | null = null;
|
||||
const grokDispatcher = createGrokRoutineDispatcher(db, () => !isWarmStandby() && !isIdleTaskDrainActive());
|
||||
let dotMcpEvents: PublicMcpEvents | null = null;
|
||||
if (publicMcpOAuth) {
|
||||
const dispatch = createMcpApiDispatch(api);
|
||||
const dispatch = createMcpApiDispatch(api, app.locals);
|
||||
publicMcpEvents = createPublicMcpEvents(db, publicMcpOAuth, dispatch, {
|
||||
isBackgroundWorkEnabled: () => !isWarmStandby() && !isIdleTaskDrainActive(),
|
||||
});
|
||||
@@ -1074,9 +1077,14 @@ export async function createApp(
|
||||
const dotOAuth = createPublicMcpOAuth(db, { ...publicMcpOAuth.config, resource: publicMcpOAuth.config.origin + "/mcp/runner" });
|
||||
dotMcpEvents = createPublicMcpEvents(db, dotOAuth, dispatch, { enableDotRunner: true, isBackgroundWorkEnabled: () => !isWarmStandby() && !isIdleTaskDrainActive() });
|
||||
dotMcpEvents.start();
|
||||
publicMcpIngress.use(publicMcpIngressRoutes(dotOAuth, createPublicMcpExecutor(db, dotOAuth, dispatch), dotMcpEvents, createDotRunnerMcpTools(db, connectionIntentHeartbeat)));
|
||||
const grokTransfers = createPublicMcpTransfers(db, dotOAuth, dispatch, opts.storageService, { pathPrefix: "/mcp/runner/files",
|
||||
agentSession: { authorize: (principal, sessionId, taskId, write, database) => dotRunnerBroker(database ?? db).authorizeFileSession(principal, sessionId, taskId, write) } });
|
||||
publicMcpIngress.use(grokTransfers.router);
|
||||
publicMcpIngress.use(publicMcpIngressRoutes(dotOAuth, createPublicMcpExecutor(db, dotOAuth, dispatch), dotMcpEvents, createDotRunnerMcpTools(db, connectionIntentHeartbeat, grokTransfers)));
|
||||
api.use(publicMcpManagementRoutes(publicMcpOAuth, dotOAuth));
|
||||
api.use(dotRunnerRoutes(db, publicMcpOAuth.config.origin + "/mcp/runner"));
|
||||
api.use(grokBotRoutes(db, publicMcpOAuth.config.origin + "/mcp/runner"));
|
||||
grokDispatcher.start();
|
||||
}
|
||||
|
||||
app.use("/api", api);
|
||||
@@ -1428,7 +1436,7 @@ export async function createApp(
|
||||
scheduler.stop();
|
||||
await agentLifecycle.stop();
|
||||
await publicMcpEvents?.stop();
|
||||
await dotMcpEvents?.stop();
|
||||
await dotMcpEvents?.stop(); grokDispatcher.stop();
|
||||
jobCoordinator.stop();
|
||||
resourceLifecycleDelivery.dispose();
|
||||
await deliveryWork.stop();
|
||||
|
||||
@@ -6,14 +6,27 @@ const transientDbConnectionCodes = new Set([
|
||||
"CONNECTION_DESTROYED",
|
||||
]);
|
||||
|
||||
export function isTransientDbConnectionError(error: unknown): boolean {
|
||||
function hasDbConnectionErrorCode(error: unknown, matches: (code: string) => boolean): boolean {
|
||||
for (let current: unknown = error; current instanceof Error; current = current.cause) {
|
||||
const code = (current as { code?: unknown }).code;
|
||||
if (typeof code === "string" && transientDbConnectionCodes.has(code)) return true;
|
||||
if (typeof code === "string" && matches(code)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isTransientDbConnectionError(error: unknown): boolean {
|
||||
return hasDbConnectionErrorCode(error, (code) => transientDbConnectionCodes.has(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* postgres.js rejects every query issued after this process called
|
||||
* `sql.end()` with `CONNECTION_ENDED`. Unlike a dropped socket, it never comes
|
||||
* from the database or the network: the pool was ended locally.
|
||||
*/
|
||||
export function isEndedDbPoolError(error: unknown): boolean {
|
||||
return hasDbConnectionErrorCode(error, (code) => code === "CONNECTION_ENDED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry only an explicitly idempotent operation, such as a known read-only
|
||||
* lookup or receipt-protected synchronization. Neither a SQL prefix nor a
|
||||
|
||||
+6
-1
@@ -2062,7 +2062,12 @@ async function startServerWithDatabaseTeardown(
|
||||
closeHttpListener: () =>
|
||||
closeHttpListenerForShutdown({ server, signal, log: logger }),
|
||||
drainPendingRunFailureReports: waitForPendingRunFailureReports,
|
||||
closeDatabase: closeDatabaseClients,
|
||||
// Mark the pool as ending before `sql.end()` rejects the first query,
|
||||
// so the error handler recognizes a request that outlived the drain.
|
||||
closeDatabase: () => {
|
||||
(app.locals as { paperclipDatabaseClosing?: boolean }).paperclipDatabaseClosing = true;
|
||||
return closeDatabaseClients();
|
||||
},
|
||||
stopEmbeddedPostgres,
|
||||
shutdownInstrumentation,
|
||||
shutdownSentry,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./redact-sensitive.js";
|
||||
import { recordResponsibleUserDenialOnActiveRun } from "../services/responsible-user-denial-run-outcomes.js";
|
||||
import { isExpectedMcpConnectionFailure } from "../services/mcp-connection-failure.js";
|
||||
import { isEndedDbPoolError } from "../database-retry.js";
|
||||
|
||||
export interface ErrorContext {
|
||||
error: {
|
||||
@@ -93,6 +94,12 @@ function reportCrash(error: Error, reportToSentry = true): void {
|
||||
if (reportToSentry) captureException(error);
|
||||
}
|
||||
|
||||
/** Set by the orderly shutdown just before it ends the database pools. */
|
||||
function isDatabaseClosing(req: Request): boolean {
|
||||
const locals = req.app?.locals as { paperclipDatabaseClosing?: boolean } | undefined;
|
||||
return locals?.paperclipDatabaseClosing === true;
|
||||
}
|
||||
|
||||
function getPaperclipDb(req: Request): Db | null {
|
||||
const locals = req.app?.locals as { paperclipDb?: Db; db?: Db } | undefined;
|
||||
return locals?.paperclipDb ?? locals?.db ?? null;
|
||||
@@ -266,6 +273,21 @@ export function errorHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
// Shutdown drains the HTTP listener before it ends the pool, but a handler
|
||||
// whose client already disconnected (or whose socket the drain timeout
|
||||
// closed) keeps running. Its next query then fails with CONNECTION_ENDED
|
||||
// against the pool this process deliberately ended. That is shutdown, not a
|
||||
// server fault, so skip the error sinks. Any other error during shutdown is
|
||||
// still reported.
|
||||
if (isDatabaseClosing(req) && isEndedDbPoolError(rootError)) {
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: "Server is shutting down" });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reportableError = sanitizeSecretSensitiveError(req, rootError);
|
||||
attachErrorContext(
|
||||
req,
|
||||
|
||||
@@ -76,6 +76,8 @@ const SENSITIVE_KEYS = new Set<string>([
|
||||
"signingsecret",
|
||||
"webhook_secret",
|
||||
"webhooksecret",
|
||||
"webhookkey",
|
||||
"webhookurl",
|
||||
"paperclip_capability",
|
||||
// The Claude setup-token login fields. `browserCode` carries the one-time
|
||||
// sign-in code and `authorization_code` carries the OAuth code; neither may
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
activityLog,
|
||||
heartbeatRunWatchdogDecisions,
|
||||
heartbeatRuns,
|
||||
environmentLeases,
|
||||
issues,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import { parseObject } from "../../../adapters/utils.js";
|
||||
import { visibleIssueCondition } from "../../../services/issue-visibility.js";
|
||||
@@ -65,6 +67,27 @@ function toRunSnapshot(row: typeof heartbeatRuns.$inferSelect): RunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSettlementPending(companyId: string, runId: string) {
|
||||
return sql<boolean>`(
|
||||
${heartbeatRuns.runtimeMode} = 'native'
|
||||
or coalesce(${heartbeatRuns.resultJson}->>'workspaceRestoreFailure', '') <> ''
|
||||
or exists (
|
||||
select 1 from ${environmentLeases}
|
||||
where ${environmentLeases.companyId} = ${companyId}
|
||||
and ${environmentLeases.heartbeatRunId} = ${runId}
|
||||
and ${environmentLeases.providerLeaseId} is not null
|
||||
and ${environmentLeases.status} in ('active', 'acquiring')
|
||||
)
|
||||
or exists (
|
||||
select 1 from ${workspaceOperations}
|
||||
where ${workspaceOperations.companyId} = ${companyId}
|
||||
and ${workspaceOperations.heartbeatRunId} = ${runId}
|
||||
and ${workspaceOperations.phase} = 'workspace_finalize'
|
||||
and ${workspaceOperations.status} in ('running', 'failed')
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & WatchdogWriter {
|
||||
const issuesSvc = issueService(db);
|
||||
const recoveryActionsSvc = issueRecoveryActionService(db);
|
||||
@@ -119,6 +142,13 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
return row ? toRunSnapshot(row) : null;
|
||||
}
|
||||
|
||||
async function isRuntimeSettlementPending(companyId: string, runId: string): Promise<boolean> {
|
||||
const [row] = await db.select({
|
||||
pending: sql<boolean>`${heartbeatRuns.status} <> 'running' or ${runtimeSettlementPending(companyId, runId)}`,
|
||||
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
||||
return row?.pending ?? true;
|
||||
}
|
||||
|
||||
async function findLatestDecision(companyId: string, runId: string, now: Date) {
|
||||
const [quietUntilRows, dismissedRows] = await Promise.all([
|
||||
db
|
||||
@@ -321,6 +351,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
eq(heartbeatRuns.id, input.run.id),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.status, "running"),
|
||||
sql`not ${runtimeSettlementPending(companyId, input.run.id)}`,
|
||||
))
|
||||
.returning();
|
||||
if (!updatedRun) return null;
|
||||
@@ -477,6 +508,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
|
||||
return {
|
||||
findCandidateSilentRuns,
|
||||
findRunForCompany,
|
||||
isRuntimeSettlementPending,
|
||||
findLatestDecision,
|
||||
findOpenStaleRunEvaluation,
|
||||
findEvaluationIssueById,
|
||||
|
||||
@@ -53,6 +53,7 @@ export type RunningAgentSnapshot = {
|
||||
export interface WatchdogRunReader {
|
||||
findCandidateSilentRuns(input: FindCandidateSilentRunsInput): Promise<RunSnapshot[]>;
|
||||
findRunForCompany(companyId: string, runId: string): Promise<RunSnapshot | null>;
|
||||
isRuntimeSettlementPending(companyId: string, runId: string): Promise<boolean>;
|
||||
findLatestDecision(companyId: string, runId: string, now: Date): Promise<FindLatestDecisionResult>;
|
||||
findOpenStaleRunEvaluation(companyId: string, runId: string): Promise<EvaluationIssueSnapshot | null>;
|
||||
findLatestSameRunTerminalEvidence(
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("application", () => {
|
||||
return {
|
||||
findCandidateSilentRuns: async () => [],
|
||||
findRunForCompany: async () => null,
|
||||
isRuntimeSettlementPending: async () => false,
|
||||
findLatestDecision: async () => ({ quietUntilDecision: null, dismissedFalsePositive: false }),
|
||||
findOpenStaleRunEvaluation: async () => null,
|
||||
findLatestSameRunTerminalEvidence: async () => null,
|
||||
@@ -134,6 +135,27 @@ describe("application", () => {
|
||||
};
|
||||
|
||||
describe("createFoldSourceResolvedRun", () => {
|
||||
it("does not terminate a process or settle the run while its runtime owns finalization", async () => {
|
||||
const calls: string[] = [];
|
||||
const fold = createFoldSourceResolvedRun({
|
||||
reader: makeReader({ isRuntimeSettlementPending: async (companyId, runId) => {
|
||||
expect([companyId, runId]).toEqual(["company-1", "run-1"]);
|
||||
return true;
|
||||
} }),
|
||||
processController: { cleanupRunProcess: async () => {
|
||||
calls.push("cleanup");
|
||||
return { attempted: false, outcome: "no_process_metadata", adapterType: "grok_local" };
|
||||
} },
|
||||
writer: { recordDecision: async () => { throw new Error("unexpected decision"); },
|
||||
foldSourceResolvedRun: async () => { calls.push("fold"); return { kind: "folded", evaluationIssueId: null }; } },
|
||||
});
|
||||
expect(await fold({ run: makeRun({ processPid: 4242 }), runningAgentAdapterType: "grok_local",
|
||||
sourceIssue: foldSourceIssue, evidence: foldEvidence, existingEvaluation: null,
|
||||
silenceStartedAt: null, silenceAgeMs: null, now: new Date("2026-01-01T05:00:00Z") }))
|
||||
.toEqual({ kind: "stale" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans up the process before the folding persistence call", async () => {
|
||||
const calls: string[] = [];
|
||||
const processController: RunProcessController = {
|
||||
@@ -154,7 +176,7 @@ describe("application", () => {
|
||||
return { kind: "folded", evaluationIssueId: null };
|
||||
},
|
||||
};
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ writer, processController });
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ reader: makeReader(), writer, processController });
|
||||
|
||||
const outcome = await foldSourceResolvedRun({
|
||||
run: makeRun({ processPid: 4242 }),
|
||||
@@ -187,7 +209,7 @@ describe("application", () => {
|
||||
},
|
||||
foldSourceResolvedRun: async () => ({ kind: "stale" }),
|
||||
};
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ writer, processController });
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({ reader: makeReader(), writer, processController });
|
||||
|
||||
const outcome = await foldSourceResolvedRun({
|
||||
run: makeRun({ processPid: 4242 }),
|
||||
@@ -396,7 +418,7 @@ describe("application", () => {
|
||||
foldSourceResolvedRun: async () => ({ kind: "folded", evaluationIssueId: null }),
|
||||
...writerOverrides,
|
||||
};
|
||||
return createFoldSourceResolvedRun({ writer, processController });
|
||||
return createFoldSourceResolvedRun({ reader: makeReader(), writer, processController });
|
||||
}
|
||||
|
||||
describe("createScanSilentActiveRuns", () => {
|
||||
|
||||
@@ -72,6 +72,7 @@ export function createBuildRunOutputSilence(deps: BuildRunOutputSilenceDeps) {
|
||||
}
|
||||
|
||||
export type FoldSourceResolvedRunDeps = {
|
||||
reader: Pick<WatchdogRunReader, "isRuntimeSettlementPending">;
|
||||
writer: WatchdogWriter;
|
||||
processController: RunProcessController;
|
||||
};
|
||||
@@ -89,6 +90,9 @@ export type FoldSourceResolvedRunUseCaseInput = {
|
||||
|
||||
export function createFoldSourceResolvedRun(deps: FoldSourceResolvedRunDeps) {
|
||||
return async function foldSourceResolvedRun(input: FoldSourceResolvedRunUseCaseInput): Promise<FoldOutcome> {
|
||||
if (await deps.reader.isRuntimeSettlementPending(input.run.companyId, input.run.id)) {
|
||||
return { kind: "stale" };
|
||||
}
|
||||
const cleanup = await deps.processController.cleanupRunProcess({
|
||||
runId: input.run.id,
|
||||
adapterType: input.runningAgentAdapterType,
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createActiveRunWatchdog(db: Db, config: ActiveRunWatchdogConfig)
|
||||
const processController = createProcessAdapter();
|
||||
|
||||
const foldSourceResolvedRun = createFoldSourceResolvedRun({
|
||||
reader: postgresAdapter,
|
||||
writer: postgresAdapter,
|
||||
processController,
|
||||
});
|
||||
|
||||
+14
-10
@@ -275,6 +275,7 @@ import {
|
||||
} from "../services/change-consent-gate.js";
|
||||
import {
|
||||
canActorReadHeartbeatRun,
|
||||
getHeartbeatRunReadDecisions,
|
||||
canActorReadWorkspaceOperation,
|
||||
redactHeartbeatRunListRow,
|
||||
} from "../services/heartbeat-run-privacy.js";
|
||||
@@ -282,6 +283,7 @@ import {
|
||||
PaperclipRunnerProviderProfileError,
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
validatePaperclipRunnerDotConfig,
|
||||
validatePaperclipRunnerGrokConfig,
|
||||
} from "../services/native-runtime/provider-profile.js";
|
||||
import { managedAgentProfileService } from "../services/managed-agent-profiles.js";
|
||||
import { remoteAgentProfileService } from "../services/remote-agent-profiles.js";
|
||||
@@ -802,12 +804,6 @@ export function agentRoutes(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function serializeRunListRow<
|
||||
T extends Record<string, unknown> & RunReadBinding & { id: string },
|
||||
>(req: Request, run: T) {
|
||||
return await actorCanReadRun(req, run) ? run : redactHeartbeatRunListRow(run);
|
||||
}
|
||||
|
||||
// The company-scoped adapter login-session service. It runs the device-login
|
||||
// flow in a fresh trusted sandbox and holds the one-time prompt in memory. The
|
||||
// process owns one instance, so the in-memory prompt and the cancellation
|
||||
@@ -1848,6 +1844,8 @@ export function agentRoutes(
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) {
|
||||
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
|
||||
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
@@ -1875,6 +1873,10 @@ export function agentRoutes(
|
||||
if (adapterType !== "paperclip_runner") return;
|
||||
let profile;
|
||||
try {
|
||||
if (adapterConfig.provider === "grok_bot") {
|
||||
validatePaperclipRunnerGrokConfig(adapterConfig, false);
|
||||
return;
|
||||
}
|
||||
if (adapterConfig.provider === "openai_dot") {
|
||||
validatePaperclipRunnerDotConfig(adapterConfig, false);
|
||||
return;
|
||||
@@ -2563,8 +2565,8 @@ export function agentRoutes(
|
||||
adapterConfig: Record<string, unknown>,
|
||||
path = "adapterConfig",
|
||||
) {
|
||||
if (req.actor.type === "agent" && ["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId"].some(key => hasOwn(adapterConfig, key)))
|
||||
throw forbidden("Only an operator can configure Dot attachment access, workspace access, or pairing.");
|
||||
if (req.actor.type === "agent" && ["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId", "externalBindingId"].some(key => hasOwn(adapterConfig, key)))
|
||||
throw forbidden("Only an operator can configure external-agent pairing, attachment access, or workspace access.");
|
||||
assertNoAgentInstructionsConfigMutation(req, adapterConfig, path);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
@@ -2680,7 +2682,7 @@ export function agentRoutes(
|
||||
boardOnboardingFirstAgent = false,
|
||||
adapterConfig?: Record<string, unknown>,
|
||||
): AgentDesiredSkillEntry[] | undefined {
|
||||
if (adapterType === "paperclip_runner" && adapterConfig?.provider === "openai_dot") return undefined;
|
||||
if (adapterType === "paperclip_runner" && ["openai_dot", "grok_bot"].includes(String(adapterConfig?.provider))) return undefined;
|
||||
if (role !== "ceo" && !boardOnboardingFirstAgent) return undefined;
|
||||
const adapter = findActiveServerAdapter(adapterType);
|
||||
if (!adapter?.listSkills && !adapter?.syncSkills) return undefined;
|
||||
@@ -6666,7 +6668,9 @@ export function agentRoutes(
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
|
||||
const summary = req.query.summary === "true" || req.query.summary === "1";
|
||||
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
|
||||
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(runs.map(run => serializeRunListRow(req, run))),
|
||||
const readDecisions = await getHeartbeatRunReadDecisions(db, access, req.actor, runs);
|
||||
res.json(await runRedactions.redactForRuns(companyId, runs.map((run, index) =>
|
||||
readDecisions[index] ? run : redactHeartbeatRunListRow(run)),
|
||||
req.actor.type === "board" ? getActorInfo(req).actorId : null));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { z } from "zod";
|
||||
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { createGrokInvitationSchema, configureGrokRoutineSchema, verifyGrokRoutineSchema, recoverGrokRoutineSchema, pairGrokBindingSchema, stopGrokWorkSchema, retryGrokDeliverySchema } from "@paperclipai/shared";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { forbidden, HttpError, notFound } from "../errors.js";
|
||||
import { issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
import { authorizeExternalBotOperator } from "../services/external-bot-authority.js";
|
||||
import { dotRunnerBroker } from "../services/dot-runner-broker.js";
|
||||
import { dotInvitationService } from "../services/dot-invitations.js";
|
||||
import { grokRoutineService } from "../services/grok-bot/routine.js";
|
||||
import { McpOAuthError } from "../services/public-mcp/oauth.js";
|
||||
import { canConfigureAgentConnection } from "../modules/agent-lifecycle/index.js";
|
||||
|
||||
export function grokBotRoutes(db: Db, resourceUrl: string) {
|
||||
const router = Router(), broker = dotRunnerBroker(db), routines = grokRoutineService(db);
|
||||
const invitations = dotInvitationService(db, "grok_bot");
|
||||
const path = "/companies/:companyId/agents/:agentId/grok-binding";
|
||||
async function companyScope(req: Request, write = false) {
|
||||
const companyId = z.uuid().parse(req.params.companyId);
|
||||
assertBoard(req); assertCompanyAccess(req, companyId);
|
||||
const operatorId = req.actor.userId;
|
||||
if (!operatorId || write && !["session", "cloud_tenant"].includes(req.actor.source ?? "")) throw forbidden("Sign in to manage this Grok agent.");
|
||||
if (write) await authorizeExternalBotOperator(db, companyId, operatorId);
|
||||
return { companyId, operatorId };
|
||||
}
|
||||
async function scope(req: Request, write = false) {
|
||||
const input = { ...await companyScope(req, write), agentId: z.uuid().parse(req.params.agentId) };
|
||||
const agent = await agentService(db).getById(input.agentId);
|
||||
if (!agent || agent.companyId !== input.companyId || agent.adapterType !== "paperclip_runner" || agent.adapterConfig.provider !== "grok_bot") throw notFound("Grok agent not found");
|
||||
return { ...input, agent };
|
||||
}
|
||||
router.get("/companies/:companyId/grok-invitations", async (req, res) => {
|
||||
const { companyId, operatorId } = await companyScope(req, true);
|
||||
res.set("Cache-Control", "no-store").json(await invitations.resume(companyId, operatorId));
|
||||
});
|
||||
router.post("/companies/:companyId/grok-invitations", async (req, res) => {
|
||||
const { companyId, operatorId } = await companyScope(req, true);
|
||||
const details = createGrokInvitationSchema.parse(req.body);
|
||||
if (details.reportsTo) { const manager = await agentService(db).getById(details.reportsTo); if (!manager || manager.companyId !== companyId || manager.status === "terminated") throw notFound("Manager not found"); }
|
||||
res.set("Cache-Control", "no-store").status(201).json(await invitations.create(companyId, operatorId, details));
|
||||
});
|
||||
router.get(path, async (req, res) => {
|
||||
const { companyId, agentId, agent } = await scope(req);
|
||||
const binding = await broker.bindingForAgent(companyId, agentId);
|
||||
const readable = await issueReadSqlCondition(db, req.actor);
|
||||
const [queued] = await db.select({ count: sql<number>`count(*)::int` }).from(issues).where(and(eq(issues.companyId, companyId), eq(issues.assigneeAgentId, agentId), eq(issues.status, "todo"), readable));
|
||||
const [currentTask] = binding?.assignment ? await db.select({ id: issues.id, identifier: issues.identifier, title: issues.title }).from(heartbeatRuns)
|
||||
.innerJoin(issues, and(eq(issues.id, heartbeatRuns.nativeIssueId), eq(issues.companyId, companyId)))
|
||||
.where(and(eq(heartbeatRuns.id, binding.assignment.runId), eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.companyId, companyId), readable)) : [];
|
||||
res.set("Cache-Control", "no-store").json({ queuedTaskCount: queued?.count ?? 0, currentTask: currentTask ?? null, enabled: resourceUrl.startsWith("https://") && await broker.enabled("grok_bot"), resourceUrl,
|
||||
agentStatus: agent.status, agentLifecycleState: agent.lifecycleState, recoveryNeeded: await routines.recoveryNeeded(companyId, agentId), canConfigureConnection: canConfigureAgentConnection(agent),
|
||||
binding });
|
||||
});
|
||||
router.post(path, async (req, res) => {
|
||||
const { companyId, agentId, operatorId } = await scope(req, true);
|
||||
if (!resourceUrl.startsWith("https://")) throw forbidden("Grok Bot requires an authenticated public HTTPS instance.");
|
||||
const body = pairGrokBindingSchema.parse(req.body ?? {});
|
||||
res.set("Cache-Control", "no-store").status(201).json(await broker.createPairing({ companyId, agentId, operatorId, provider: "grok_bot", ...body }));
|
||||
});
|
||||
router.put(path + "/routine", async (req, res) => {
|
||||
const { companyId, agentId, operatorId } = await scope(req, true);
|
||||
const body = configureGrokRoutineSchema.parse(req.body);
|
||||
res.set("Cache-Control", "no-store").json(await routines.configure({ companyId, agentId, operatorId, ...body }));
|
||||
});
|
||||
router.post(path + "/verify", async (req, res) => {
|
||||
const { companyId, agentId } = await scope(req, true);
|
||||
const { bindingId, requestId } = verifyGrokRoutineSchema.parse(req.body);
|
||||
const intent = await routines.reserve({ companyId, agentId, bindingId, sourceEventId: `verify:${requestId}`, kind: "verify" });
|
||||
res.set("Cache-Control", "no-store").json(await routines.send(companyId, agentId, intent.id));
|
||||
});
|
||||
router.post(path + "/retry", async (req, res) => {
|
||||
const { companyId, agentId, operatorId } = await scope(req, true);
|
||||
const { correlationId } = retryGrokDeliverySchema.parse(req.body);
|
||||
res.set("Cache-Control", "no-store").json(await routines.retryRejected(companyId, agentId, operatorId, correlationId));
|
||||
});
|
||||
router.post(path + "/stop", async (req, res) => {
|
||||
const { companyId, agentId, operatorId } = await scope(req, true);
|
||||
const { expectedRunId } = stopGrokWorkSchema.parse(req.body ?? {});
|
||||
res.set("Cache-Control", "no-store").json(await broker.stopGrokWork(companyId, agentId, operatorId, expectedRunId));
|
||||
});
|
||||
router.post(path + "/recover", async (req, res) => {
|
||||
const { companyId, agentId, operatorId } = await scope(req, true);
|
||||
recoverGrokRoutineSchema.parse(req.body);
|
||||
res.set("Cache-Control", "no-store").json(await routines.recover(companyId, agentId, operatorId));
|
||||
});
|
||||
router.delete(path, async (req, res) => {
|
||||
const { companyId, agentId, operatorId } = await scope(req, true);
|
||||
await broker.revoke(companyId, agentId, operatorId); res.status(204).end();
|
||||
});
|
||||
router.use((error: unknown, _req: Request, _res: unknown, next: (error: unknown) => void) => next(error instanceof McpOAuthError ? new HttpError(error.status, error.message) : error));
|
||||
return router;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createGrokInvitationSchema, configureGrokRoutineSchema, verifyGrokRoutineSchema, recoverGrokRoutineSchema, pairGrokBindingSchema, stopGrokWorkSchema, retryGrokDeliverySchema } from "@paperclipai/shared";
|
||||
import { updateFastResponseSchema } from "@paperclipai/shared";
|
||||
import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared";
|
||||
import { experimentalApiMetadata } from "./experimental-api-metadata.js";
|
||||
@@ -1748,7 +1749,8 @@ function resolveOperationAuthLevel(
|
||||
|| key === "POST /api/dot-mcp/requests/{id}/dot-pairing/preview") return "public";
|
||||
if (path === "/api/mcp/setup" || path === "/api/mcp/device/consent" || path.startsWith("/api/mcp/requests/") || path.startsWith("/api/mcp/connections")) return "board";
|
||||
if (/^\/api\/companies\/\{companyId\}\/agents\/\{agentId\}\/dot-binding(?:\/event-test)?$/.test(path)) return "board";
|
||||
if (path === "/api/companies/{companyId}/dot-invitations") return "board";
|
||||
if (path === "/api/companies/{companyId}/dot-invitations" || path === "/api/companies/{companyId}/grok-invitations"
|
||||
|| /^\/api\/companies\/\{companyId\}\/agents\/\{agentId\}\/grok-binding(?:\/(?:routine|verify|retry|stop|recover))?$/.test(path)) return "board";
|
||||
if (PUBLIC_OPERATIONS.has(key)) return "public";
|
||||
if (key === "POST /api/companies/{companyId}/agent-commentary") return "agent_heartbeat";
|
||||
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run";
|
||||
@@ -12041,6 +12043,74 @@ registerCurrentRoute({
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/companies/{companyId}/grok-invitations", tags: ["agents"],
|
||||
summary: "Resume this operator's unfinished Grok Bot setup",
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/grok-invitations", tags: ["agents"],
|
||||
summary: "Create Grok Bot agent setup under normal hire approval gates",
|
||||
body: createGrokInvitationSchema,
|
||||
responses: { 201: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding", tags: ["agents"],
|
||||
summary: "Read Grok connection health and visible task activity",
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding", tags: ["agents"],
|
||||
summary: "Create a one-use Grok agent browser pairing code",
|
||||
body: pairGrokBindingSchema,
|
||||
responses: { 201: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "put", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding/routine", tags: ["agents"],
|
||||
summary: "Store encrypted Grok routine credentials under the current configuration revision",
|
||||
body: configureGrokRoutineSchema,
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding/verify", tags: ["agents"],
|
||||
summary: "Verify routine delivery and authenticated agent reply",
|
||||
body: verifyGrokRoutineSchema,
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding/retry", tags: ["agents"],
|
||||
summary: "Explicitly retry a confirmed rejected routine delivery",
|
||||
body: retryGrokDeliverySchema,
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding/stop", tags: ["agents"],
|
||||
summary: "Revoke Paperclip work-session authority without confirming remote termination",
|
||||
body: stopGrokWorkSchema,
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding/recover", tags: ["agents"],
|
||||
summary: "Record remote inspection and require fresh verification",
|
||||
body: recoverGrokRoutineSchema,
|
||||
responses: { 200: r.ok(),
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "delete", path: "/api/companies/{companyId}/agents/{agentId}/grok-binding", tags: ["agents"],
|
||||
summary: "Disconnect the Grok agent and fence its work sessions",
|
||||
responses: { 204: { description: "Grok agent authority revoked; remote execution is unconfirmed" },
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/mcp/setup", tags: ["tool-gateway"],
|
||||
summary: "Read assistant connection setup using a human browser session",
|
||||
|
||||
@@ -72,7 +72,7 @@ export function publicMcpIngressRoutes(oauth: PublicMcpOAuth, execute: ReturnTyp
|
||||
});
|
||||
router.get(agentConnection ? ["/.well-known/oauth-protected-resource" + endpoint] : ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource" + endpoint], (_req, res) => res.json({
|
||||
resource, authorization_servers: [issuer], scopes_supported: agentConnection ? DOT_RUNNER_MCP_SCOPES : ["paperclip:read", "paperclip:write", "paperclip:configure"], bearer_methods_supported: ["header"],
|
||||
resource_name: agentConnection ? "Paperclip Dot agent" : "Paperclip",
|
||||
resource_name: agentConnection ? "Paperclip agent" : "Paperclip",
|
||||
}));
|
||||
router.get(metadataPath, (_req, res) => res.json({
|
||||
issuer, authorization_endpoint: (oauth.config.authorizationOrigin ?? origin) + oauthPath + "/authorize", token_endpoint: prefix + "/token",
|
||||
|
||||
@@ -29,9 +29,9 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
|
||||
async function verify(snapshot: LifecycleAgent): Promise<"complete" | "pending"> {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, snapshot.id), eq(agents.lifecycleVersion, snapshot.lifecycleVersion)));
|
||||
if (!agent) return "pending";
|
||||
if (agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "openai_dot") {
|
||||
if (agent.adapterType === "paperclip_runner" && ["openai_dot", "grok_bot"].includes(String(agent.adapterConfig.provider))) {
|
||||
const binding = await dotRunnerBroker(db).bindingForAgent(agent.companyId, agent.id);
|
||||
return binding?.status === "ready" && binding.subscriptionVerified && binding.id === agent.adapterConfig.dotBindingId ? "complete" : "pending";
|
||||
return binding?.status === "ready" && binding.wakeVerified && binding.id === agent.adapterConfig[agent.adapterConfig.provider === "grok_bot" ? "externalBindingId" : "dotBindingId"] ? "complete" : "pending";
|
||||
}
|
||||
const [company] = await db.select().from(companies).where(eq(companies.id, agent.companyId));
|
||||
const responsibleUserId = agent.lifecycleOperation?.responsibleUserId ?? company.defaultResponsibleUserId;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { initializeGitHubWebhookRuntime } from "./chat-channels/github/webhook-runtime.js";
|
||||
import { githubInboundTaskTitle, githubInboundCommentText, githubAssignedAgentRequiresLowTrust, retainGitHubDeliveryAuthority, retainGitHubReviewReceipt } from "./chat-channels/github/task-preparation.js";
|
||||
import { applyChatTaskReviewContainment } from "./chat-channels/inbound/task-trust.js";
|
||||
import { photonControlTargetsConversation, photonReplyMetadata, recordPhotonInboundActivity } from "./chat-channels/imessage-photon/messages.js";
|
||||
import { deferTeamsSetupReply } from "./chat-channels/microsoft-teams/setup-replies.js";
|
||||
import { slackOrphanFollowUp, stripSlackTitleMentions } from "./chat-channels/slack/messages.js";
|
||||
import { createChatPublicationResolution } from "./chat-channels/publications/resolution.js";
|
||||
import { createChatPublicationRequests } from "./chat-channels/publications/requests.js";
|
||||
import { createTeamsBoardSendPreparation } from "./chat-channels/microsoft-teams/board-send-preparation.js";
|
||||
@@ -18,7 +24,7 @@ import { createMessageResourceAdmission } from "./chat-channels/inbound/resource
|
||||
import type { DiscordGatewayOwnership } from "./chat-channels/runtime-types.js";
|
||||
import { createInboundAdmission } from "./chat-channels/inbound/admission.js";
|
||||
import { createInboundEnvelope } from "./chat-channels/inbound/envelope.js";
|
||||
import { sameTeamsThreadIdentity, durableExternalThreadIdentity, teamsThreadRootMessageId, canonicalProviderResourceId, nonDirectDestinationAllowed, linearControlCommand, actionThreadMatchesConversation } from "./chat-channels/inbound/surfaces.js";
|
||||
import { sameTeamsThreadIdentity, durableExternalThreadIdentity, canonicalProviderResourceId, nonDirectDestinationAllowed, linearControlCommand, actionThreadMatchesConversation } from "./chat-channels/inbound/surfaces.js";
|
||||
import { createChatInboundAuthority } from "./chat-channels/inbound/authorization.js";
|
||||
import { createTeamsRoutes } from "./chat-channels/microsoft-teams/routes.js";
|
||||
import type { LiveInboundMessage } from "./chat-channels/runtime-types.js";
|
||||
@@ -49,7 +55,7 @@ import { createSlackTaskAuthorityRegistration } from "./chat-channels/slack/task
|
||||
import { createGitHubPublicationPolicy } from "./chat-channels/github/publication-policy.js";
|
||||
import { createTelegramAttachmentFailure } from "./chat-channels/telegram/attachment-failure.js";
|
||||
import { createSlackCommands } from "./chat-channels/slack/commands.js";
|
||||
import { createTelegramCommands, telegramGuidanceCommand } from "./chat-channels/telegram/commands.js";
|
||||
import { createTelegramCommands, telegramGuidanceCommand, telegramGuidanceResponse, telegramForumControlResponse } from "./chat-channels/telegram/commands.js";
|
||||
import { createTeamsFileDelivery } from "./chat-channels/microsoft-teams/file-delivery.js";
|
||||
import { createGitHubSetupSecret } from "./chat-channels/github/setup-secret.js";
|
||||
import { createSlackInventory } from "./chat-channels/slack/inventory.js";
|
||||
@@ -91,20 +97,19 @@ import { createChatPublicationReplacement } from "./chat-channels/publication-re
|
||||
import { createChatIdentityLinks } from "./chat-channels/identity-links.js";
|
||||
import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js";
|
||||
|
||||
import { slackResourceLabelIsFallback } from "./chat-channels/slack/resources.js";
|
||||
import { scheduleSlackLifecycleResourceHydration } from "./chat-channels/slack/resources.js";
|
||||
|
||||
import {
|
||||
MAX_INBOUND_TEXT,
|
||||
stableExternalPrincipalId,
|
||||
} from "./chat-channels/message-lifecycle.js";
|
||||
import { telegramLifecycleEventFromPayload, isTelegramEditedMessageRaw, isTelegramMigrationRaw, telegramZeroMessageId } from "./chat-channels/telegram/messages.js";
|
||||
import { telegramLifecycleEventFromPayload, telegramIgnoresInboundMessage } from "./chat-channels/telegram/messages.js";
|
||||
|
||||
import {
|
||||
microsoftTeamsLifecycleEventFromPayload,
|
||||
microsoftTeamsLifecycleEventFromPayload, teamsOrphanFollowUp, teamsAttachmentReferenceText,
|
||||
} from "./chat-channels/microsoft-teams/messages.js";
|
||||
import {
|
||||
type GitHubLifecycleEvent,
|
||||
githubLifecycleEventFromRequest,
|
||||
githubSupplementalLifecycleFromRequest,
|
||||
} from "./chat-channels/github/messages.js";
|
||||
import { nextChatActionAt } from "./chat-action-work.js";
|
||||
import type { ScheduledChatAction } from "./chat-work-notifications.js";
|
||||
@@ -126,12 +131,9 @@ import { canPublishIssueToChatAudience } from "./authorization.js";
|
||||
|
||||
import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js";
|
||||
import { assertChatTaskCreationAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js";
|
||||
function githubPolicyRecord(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
|
||||
import { githubReviewCheckService } from "./chat-github-checks.js";
|
||||
import { githubAutomaticAdmission } from "./chat-github-events.js";
|
||||
import { githubManualMessagePrompt } from "./chat-github-review-policy.js";
|
||||
import { chatGitHubReviews } from "@paperclipai/db";
|
||||
import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubReviewPolicy } from "@paperclipai/shared";
|
||||
import { githubChatReviewService } from "./chat-github-reviews.js";
|
||||
import { githubResponseCommentService } from "./chat-github-response-comments.js";
|
||||
@@ -173,8 +175,6 @@ import type { ChatEventKind, ChatEndpointSetupState, ChatProvider, ExternalChann
|
||||
import {
|
||||
isUuidLike,
|
||||
LOW_TRUST_REVIEW_PRESET,
|
||||
LOW_TRUST_REVIEW_PRESET_VERSION,
|
||||
LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
@@ -217,9 +217,6 @@ import {
|
||||
readRegisteredDiscordCommandRegistration,
|
||||
} from "./chat-discord-command-registration-store.js";
|
||||
|
||||
import {
|
||||
parseSlackSessionStop,
|
||||
} from "./chat-slack-sessions.js";
|
||||
|
||||
import {
|
||||
parseChatProviderLifecycle,
|
||||
@@ -461,8 +458,7 @@ export interface ChatChannelServiceOptions {
|
||||
}
|
||||
|
||||
function safeTitle(text: string, fallback: string): string {
|
||||
const line = text
|
||||
.replace(/<@[A-Z0-9]+>/gi, "")
|
||||
const line = stripSlackTitleMentions(text)
|
||||
.replace(/@[\w.-]+(?:\[bot\])?/gi, "")
|
||||
.trim()
|
||||
.split(/\r?\n/)[0];
|
||||
@@ -890,7 +886,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
});
|
||||
const { handleTelegramSlashCommand } = createTelegramCommands({ runtime, runtimeFor, handleSdkMessage });
|
||||
const { stageGitHubWebhookIngress, githubIngressAcceptedResponse, processGitHubWebhookIngress, processFailedGitHubWebhookDeliveries, processPendingGitHubWebhookIngress } = createGitHubWebhookIngress(db, { endpointRecord, runtimeContextForRecord, resolveCredentialRefs, runtimeGeneration, credentialFingerprint, getWebhookPublicBaseUrl, redactError, handleWebhook, runtimeCallbackEndpoint, fetchImpl, persistence, withCredentialMutationLease, isShuttingDown: () => shuttingDown, get githubWebhookReplayBarrier() { return options.githubWebhookReplayBarrier; }, supportedWebhookEvents: SUPPORTED_GITHUB_WEBHOOK_EVENTS });
|
||||
const { stageSlackSessionStop, processSlackSessionStop, processPendingSlackSessionStops } = createSlackSessionStop(db, { runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, stageAuthorizedTaskControlPublication, getPublicBaseUrl, get heartbeat() { return options.heartbeat; }, redactError });
|
||||
const { stageSlackSessionStop, processSlackSessionStop, processPendingSlackSessionStops, handleSlackSessionStopPayload } = createSlackSessionStop(db, { scheduleMessageProcessing, get deferWebhookProcessing() { return options.deferWebhookProcessing; }, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, stageAuthorizedTaskControlPublication, getPublicBaseUrl, get heartbeat() { return options.heartbeat; }, redactError });
|
||||
const { stageTelegramMaintenance, processTelegramMaintenance, processPendingTelegramMaintenance, processPendingTelegramActions, processTelegramSubscriptionDiscovery, scheduleTelegramSubscriptionDiscovery, hasPendingTelegramSubscriptionDiscovery, hasTelegramStopSubscription, recordTelegramStopSubscription } = createTelegramMaintenance(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, resolveCredentials, clearCredentials, runtimeCallbackEndpoint, getWebhookPublicBaseUrl, fetchImpl, runtimeGeneration, credentialFingerprint, redactError, credentialCheckTimeoutMs: PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS });
|
||||
const { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability } = createChatResourceReconciliation(db, { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT });
|
||||
const { applyGitHubInstallationLifecycle } = createGitHubInstallationLifecycle(db, {
|
||||
@@ -1432,25 +1428,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
suppressSetupDestinationActivation = false,
|
||||
slackSlashControl = false,
|
||||
) {
|
||||
if (
|
||||
endpoint.provider === "telegram" &&
|
||||
(telegramZeroMessageId(message.id) ||
|
||||
(message.raw !== null &&
|
||||
typeof message.raw === "object" &&
|
||||
(message.raw as { message_id?: unknown }).message_id === 0))
|
||||
)
|
||||
return;
|
||||
// The Telegram adapter currently emits edited_message through the normal
|
||||
// message callback with the original message id. Paperclip records that
|
||||
// verified payload through its supplemental message_updated lifecycle
|
||||
// ledger instead; letting it reach normal dedupe would falsely report the
|
||||
// edit as a duplicate provider delivery.
|
||||
if (
|
||||
endpoint.provider === "telegram" &&
|
||||
(isTelegramEditedMessageRaw(message.raw) ||
|
||||
isTelegramMigrationRaw(message.raw))
|
||||
)
|
||||
return;
|
||||
if (endpoint.provider === "telegram" && telegramIgnoresInboundMessage(message)) return;
|
||||
if (
|
||||
message.author.isMe ||
|
||||
message.author.isBot === true ||
|
||||
@@ -1619,35 +1597,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
|
||||
try {
|
||||
if (admission.provisionalTeamsSetupReply) {
|
||||
const resolutionAt = new Date();
|
||||
const waitForSetupRoot =
|
||||
activeDelivery.attempts <= ORPHAN_FOLLOW_UP_MAX_ATTEMPTS;
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
return tx
|
||||
.update(chatDeliveries)
|
||||
.set(
|
||||
waitForSetupRoot
|
||||
? {
|
||||
state: "retry",
|
||||
nextAttemptAt: new Date(
|
||||
resolutionAt.getTime() + ORPHAN_FOLLOW_UP_GRACE_MS,
|
||||
),
|
||||
redactedError: "Waiting briefly for an earlier root mention",
|
||||
updatedAt: resolutionAt,
|
||||
}
|
||||
: {
|
||||
state: "filtered",
|
||||
normalizedEvent: redactedDestinationNormalized,
|
||||
principalId: null,
|
||||
nextAttemptAt: null,
|
||||
processedAt: resolutionAt,
|
||||
redactedError: "Destination is not enabled in Paperclip",
|
||||
updatedAt: resolutionAt,
|
||||
},
|
||||
)
|
||||
.where(eq(chatDeliveries.id, activeDelivery.id));
|
||||
});
|
||||
await deferTeamsSetupReply(db, activeDelivery, redactedDestinationNormalized, ORPHAN_FOLLOW_UP_MAX_ATTEMPTS, ORPHAN_FOLLOW_UP_GRACE_MS);
|
||||
return;
|
||||
}
|
||||
// A committed inbound message link proves that a prior attempt completed
|
||||
@@ -1890,23 +1840,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
destinationAllowed &&
|
||||
principalAllowed &&
|
||||
activationAllowed;
|
||||
const slackRootMessageId =
|
||||
endpoint.provider === "slack"
|
||||
? /^slack:[^:]+:(.+)$/.exec(thread.id)?.[1]
|
||||
: null;
|
||||
const teamsRootMessageId =
|
||||
endpoint.provider === "microsoft-teams"
|
||||
? teamsThreadRootMessageId(thread.id)
|
||||
: null;
|
||||
const isPlausibleOrphanFollowUp =
|
||||
endpoint.provider === "github" ||
|
||||
endpoint.provider === "discord" ||
|
||||
(endpoint.provider === "slack" &&
|
||||
Boolean(slackRootMessageId) &&
|
||||
slackRootMessageId !== message.id) ||
|
||||
(endpoint.provider === "microsoft-teams" &&
|
||||
Boolean(teamsRootMessageId) &&
|
||||
teamsRootMessageId !== message.id);
|
||||
endpoint.provider === "github" || endpoint.provider === "discord" ||
|
||||
(endpoint.provider === "slack" && slackOrphanFollowUp(thread.id, message.id)) ||
|
||||
(endpoint.provider === "microsoft-teams" && teamsOrphanFollowUp(thread.id, message.id));
|
||||
const setupDestinationCanBeEnabledByEarlierMention =
|
||||
(endpoint.provider === "github" ||
|
||||
endpoint.provider === "discord" ||
|
||||
@@ -2022,19 +1959,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
? photonReplyReference(message.raw)
|
||||
: null;
|
||||
if (controlCommand && photonQuote) {
|
||||
const source = await db
|
||||
.select({ conversationId: chatMessageLinks.conversationId })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.providerMessageId, photonQuote.guid),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
if (!source || source.conversationId !== existingConversation?.id) {
|
||||
if (!(await photonControlTargetsConversation(db, endpoint, photonQuote.guid, existingConversation?.id))) {
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
return tx
|
||||
@@ -2067,12 +1992,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
)
|
||||
.then((rows) => rows[0]?.name ?? "this agent")
|
||||
: null;
|
||||
const responseText =
|
||||
guidanceCommand === "start"
|
||||
? `Send a direct message to start work with ${assignedAgentName}. In a group, use /task@${endpoint.botUsername ?? "your_bot"} followed by your request. Use /status, /new, or /close to manage the active task in this chat.`
|
||||
: guidanceCommand === "task"
|
||||
? `Please include a request after /task@${endpoint.botUsername ?? "your_bot"}.`
|
||||
: `Available commands: /task@${endpoint.botUsername ?? "your_bot"} followed by your request, /status, /new, and /close.`;
|
||||
const responseText = telegramGuidanceResponse(guidanceCommand, endpoint.botUsername, assignedAgentName);
|
||||
const publicationBinding =
|
||||
existingConversation && existingIssue
|
||||
? { conversation: existingConversation, issue: existingIssue }
|
||||
@@ -2169,13 +2089,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
: taskLabel
|
||||
: controlCommand === "new"
|
||||
? isTelegramForumTopic
|
||||
? existingIssue
|
||||
? `${taskLabel} stays bound to this forum topic. Open a new Telegram forum topic to start a new Paperclip task.`
|
||||
: "Open a new Telegram forum topic to start a new Paperclip task."
|
||||
? telegramForumControlResponse("new", taskLabel, Boolean(existingIssue), Boolean(existingConversation))
|
||||
: "Send your request to start a new Paperclip task."
|
||||
: existingConversation
|
||||
? isTelegramForumTopic
|
||||
? "This chat conversation is closed. A later message here will continue the same Paperclip task."
|
||||
? telegramForumControlResponse("close", taskLabel, Boolean(existingIssue), true)
|
||||
: "This chat conversation is closed. Send another message to start a new task."
|
||||
: "No task is active. Send a message to start one.";
|
||||
const publicationBinding =
|
||||
@@ -2296,9 +2214,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
taskEndpoint.companyId,
|
||||
{
|
||||
title: safeTitle(
|
||||
githubAutomatic
|
||||
? `PR #${githubAutomatic.context.pullNumber}: ${githubAutomatic.context.title}`
|
||||
: githubIssue ? `GitHub issue #${githubIssue.context.issueNumber}: ${githubIssue.context.title}` : message.text,
|
||||
githubInboundTaskTitle(message.text, githubAutomatic, githubIssue),
|
||||
`${PROVIDER_LABELS[taskEndpoint.provider]} conversation`,
|
||||
),
|
||||
description: `Started from ${PROVIDER_LABELS[taskEndpoint.provider]}: ${resource.label}`,
|
||||
@@ -2364,54 +2280,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
.where(eq(issues.id, conversation.issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) throw notFound("Bound task not found");
|
||||
const [assignedAgentTrust] = taskEndpoint.provider === "github" ? await taskTx.select({ permissions: agents.permissions }).from(agents).where(and(eq(agents.companyId, taskEndpoint.companyId), eq(agents.id, taskEndpoint.assignedAgentId))) : [];
|
||||
if (!taskUserId || ((githubAutomatic || githubIssue) && !principalResolution.userId) || assignedAgentTrust?.permissions?.trustPreset === LOW_TRUST_REVIEW_PRESET) {
|
||||
const reviewPreset = {
|
||||
id: LOW_TRUST_REVIEW_PRESET,
|
||||
version: LOW_TRUST_REVIEW_PRESET_VERSION,
|
||||
rawOutputDisposition: LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
|
||||
} as const;
|
||||
await taskTx
|
||||
.update(issues)
|
||||
.set({
|
||||
sourceTrust: {
|
||||
preset: LOW_TRUST_REVIEW_PRESET,
|
||||
disposition: "quarantined",
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
...issue.executionPolicy,
|
||||
reviewPreset,
|
||||
authorizationPolicy: {
|
||||
...githubPolicyRecord(issue.executionPolicy?.authorizationPolicy),
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
reviewPreset,
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
companyId: endpoint.companyId,
|
||||
rootIssueId: issue.id,
|
||||
issueIds: [issue.id],
|
||||
allowedAgentIds: [endpoint.assignedAgentId],
|
||||
allowedToolClasses: [
|
||||
"git.read",
|
||||
"github.pr.read",
|
||||
"tests.local",
|
||||
],
|
||||
...githubPolicyRecord(githubPolicyRecord(issue.executionPolicy?.authorizationPolicy)?.trustBoundary),
|
||||
},
|
||||
},
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, issue.id),
|
||||
eq(issues.companyId, endpoint.companyId),
|
||||
),
|
||||
);
|
||||
const githubAgentRequiresLowTrust = taskEndpoint.provider === "github" && await githubAssignedAgentRequiresLowTrust(taskTx, taskEndpoint);
|
||||
if (!taskUserId || ((githubAutomatic || githubIssue) && !principalResolution.userId) || githubAgentRequiresLowTrust) {
|
||||
await applyChatTaskReviewContainment(taskTx, endpoint, issue);
|
||||
}
|
||||
|
||||
if (issue.status === "done" || issue.status === "cancelled") {
|
||||
@@ -2425,19 +2296,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
);
|
||||
}
|
||||
const body =
|
||||
(githubManual ? githubManualMessagePrompt({
|
||||
event: githubManual.event,
|
||||
policy: githubManual.policy,
|
||||
repository: resource.providerResourceId,
|
||||
thread: thread.id,
|
||||
sender: { id: principalResolution.principal.externalId, login: principalResolution.principal.handle },
|
||||
message: message.text,
|
||||
}) : message.text.trim()) ||
|
||||
githubInboundCommentText({ manual: githubManual, repository: resource.providerResourceId, thread: thread.id, sender: { id: principalResolution.principal.externalId, login: principalResolution.principal.handle }, message: message.text }) ||
|
||||
(message.attachments.length > 0
|
||||
? taskEndpoint.provider === "microsoft-teams" &&
|
||||
!thread.isDM &&
|
||||
nativeInboundAttachments.length === 0
|
||||
? `Shared ${message.attachments.length} Microsoft Teams file reference${message.attachments.length === 1 ? "" : "s"}.${providerUrl ? ` Open in Microsoft Teams: ${providerUrl}` : ""}`
|
||||
? teamsAttachmentReferenceText(message.attachments.length, providerUrl)
|
||||
: `Shared ${message.attachments.length} file${message.attachments.length === 1 ? "" : "s"}.`
|
||||
: taskEndpoint.provider === "slack" && (trigger === "mention" || message.isMention === true)
|
||||
? "Started a conversation by mentioning the agent."
|
||||
@@ -2475,26 +2339,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
{
|
||||
title: `${PROVIDER_LABELS[endpoint.provider]} sender`,
|
||||
rows: [
|
||||
...(endpoint.provider === "imessage-photon" &&
|
||||
photonReplyReference(message.raw)
|
||||
? [
|
||||
{
|
||||
type: "key_value" as const,
|
||||
label: "Reply to message",
|
||||
value: photonReplyReference(message.raw)!.guid,
|
||||
},
|
||||
...(photonReplyReference(message.raw)!.part
|
||||
? [
|
||||
{
|
||||
type: "key_value" as const,
|
||||
label: "Reply part",
|
||||
value: photonReplyReference(message.raw)!
|
||||
.part!,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
...(endpoint.provider === "imessage-photon" ? photonReplyMetadata(message.raw) : []),
|
||||
{
|
||||
type: "key_value",
|
||||
label: "Name",
|
||||
@@ -2573,17 +2418,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
})
|
||||
.where(eq(toolConnections.id, taskEndpoint.connectionId));
|
||||
if (endpoint.provider === "github") {
|
||||
const guest = !principalResolution.userId;
|
||||
await taskTx.update(chatDeliveries).set({ normalizedEvent: sql`${chatDeliveries.normalizedEvent} || ${JSON.stringify({ githubAuthority: { guest, responsibleUserId: taskUserId, sponsorUserId: guest ? taskEndpoint.sponsorUserId : null } })}::jsonb` }).where(eq(chatDeliveries.id, activeDelivery.id));
|
||||
await retainGitHubDeliveryAuthority(taskTx, taskEndpoint, activeDelivery.id, principalResolution.userId, taskUserId);
|
||||
}
|
||||
if (githubAutomatic) {
|
||||
await taskTx.insert(chatGitHubReviews).values({
|
||||
companyId: endpoint.companyId, endpointId: endpoint.id, issueId: issue.id,
|
||||
repositoryId: githubAutomatic.context.repositoryId, repository: githubAutomatic.context.repository,
|
||||
pullNumber: githubAutomatic.context.pullNumber, headSha: githubAutomatic.context.headSha,
|
||||
deliveryId: activeDelivery.id, configurationRevision: githubAutomatic.revision,
|
||||
policySnapshot: githubAutomatic.policy, event: githubAutomatic.context, state: "queued",
|
||||
}).onConflictDoNothing();
|
||||
await retainGitHubReviewReceipt(taskTx, endpoint, issue.id, activeDelivery.id, githubAutomatic);
|
||||
}
|
||||
await stageInboundWakeup(taskTx, {
|
||||
endpoint: taskEndpoint,
|
||||
@@ -2594,24 +2432,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
actorUserId: taskUserId,
|
||||
});
|
||||
if (taskEndpoint.provider === "imessage-photon") {
|
||||
await logActivity(
|
||||
taskTx as Db,
|
||||
{
|
||||
companyId: taskEndpoint.companyId,
|
||||
actorType: taskUserId ? "user" : "system",
|
||||
actorId: taskUserId ?? "chat:imessage-photon",
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
commentId: comment.id,
|
||||
issueIdentifier: issue.identifier,
|
||||
source: "chat:imessage-photon",
|
||||
endpointId: taskEndpoint.id,
|
||||
},
|
||||
},
|
||||
inboundActivityPublications,
|
||||
);
|
||||
await recordPhotonInboundActivity(taskTx, taskEndpoint, issue, comment.id, taskUserId, inboundActivityPublications);
|
||||
}
|
||||
return { actorUserId: taskUserId, comment, conversation, issue };
|
||||
};
|
||||
@@ -3155,46 +2976,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
effect,
|
||||
runtimeContext,
|
||||
);
|
||||
if (
|
||||
applied &&
|
||||
endpoint.provider === "slack" &&
|
||||
effect.kind === "resource" &&
|
||||
effect.availability === "available" &&
|
||||
slackResourceLabelIsFallback(effect.providerResourceId, effect.label)
|
||||
) {
|
||||
const stillAvailable = await db
|
||||
.select({ id: chatEndpointResources.id })
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "channel"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
effect.providerResourceId,
|
||||
),
|
||||
eq(chatEndpointResources.availability, "available"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
if (stillAvailable) {
|
||||
scheduleMessageProcessing(async () => {
|
||||
await hydrateSlackResourceLabel(
|
||||
endpoint.id,
|
||||
effect.providerResourceId,
|
||||
).catch((error) => {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: endpoint.id,
|
||||
providerResourceId: effect.providerResourceId,
|
||||
error: redactError(error),
|
||||
},
|
||||
"could not hydrate a newly available Slack channel label",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (applied && endpoint.provider === "slack") {
|
||||
await scheduleSlackLifecycleResourceHydration(db, endpoint, effect, { scheduleMessageProcessing, hydrateSlackResourceLabel, redactError });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3261,36 +3044,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
const githubInspection = provider === "github" ? request.clone() : null;
|
||||
recordChatWebhookStage("runtime_requested");
|
||||
const runtimePromise = runtimeFor(endpoint);
|
||||
let endpointRuntime: ChatEndpointRuntime;
|
||||
if (githubResponseDeadlineAt !== null) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
runtimePromise.then((value) => ({ completed: true as const, value })),
|
||||
new Promise<{ completed: false; value?: never }>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve({ completed: false }),
|
||||
Math.max(0, githubResponseDeadlineAt - Date.now()),
|
||||
);
|
||||
timeout.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (!outcome.completed) {
|
||||
return retryableGitHubWebhookResponse();
|
||||
}
|
||||
endpointRuntime = outcome.value;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ endpointId: endpoint.id, error: redactError(error) },
|
||||
"GitHub runtime initialization failed before durable webhook receipt",
|
||||
);
|
||||
return retryableGitHubWebhookResponse();
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
} else {
|
||||
endpointRuntime = await runtimePromise;
|
||||
}
|
||||
const initialized = await initializeGitHubWebhookRuntime(endpoint, runtimePromise, githubResponseDeadlineAt, redactError);
|
||||
if (initialized.response) return initialized.response;
|
||||
const endpointRuntime = initialized.runtime;
|
||||
recordChatWebhookStage("runtime_ready");
|
||||
const runtimeContext = runtimeContexts.get(endpointRuntime as object);
|
||||
if (!runtimeContext) {
|
||||
@@ -3331,26 +3087,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
endpointRuntime.acceptsProviderScope(lifecyclePayload)
|
||||
) {
|
||||
if (endpoint.provider === "slack") {
|
||||
const stop = parseSlackSessionStop(
|
||||
lifecyclePayload,
|
||||
endpoint.providerAccountId,
|
||||
);
|
||||
if (stop) {
|
||||
const actionId = await stageSlackSessionStop(
|
||||
endpoint,
|
||||
stop,
|
||||
runtimeContext,
|
||||
);
|
||||
if (actionId) {
|
||||
if (options.deferWebhookProcessing === true) {
|
||||
scheduleMessageProcessing(() =>
|
||||
processSlackSessionStop(actionId),
|
||||
);
|
||||
} else {
|
||||
await processSlackSessionStop(actionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
await handleSlackSessionStopPayload(endpoint, lifecyclePayload, runtimeContext);
|
||||
}
|
||||
const effects = parseChatProviderLifecycle({
|
||||
provider: endpoint.provider,
|
||||
@@ -3367,13 +3104,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
await recordSlackWebhookCallback(endpoint, request, slackCallbackInspection, runtimeContext);
|
||||
}
|
||||
if (githubInspection && response.ok) {
|
||||
let lifecycle: GitHubLifecycleEvent | null = null;
|
||||
try {
|
||||
lifecycle = await githubLifecycleEventFromRequest(githubInspection);
|
||||
} catch {
|
||||
// Ignore only malformed or unsupported supplemental payloads. Once an
|
||||
// event is recognized, its durable persistence failure must escape.
|
||||
}
|
||||
const lifecycle = await githubSupplementalLifecycleFromRequest(githubInspection);
|
||||
if (lifecycle) {
|
||||
await recordLifecycleDelivery(
|
||||
{
|
||||
|
||||
@@ -130,3 +130,14 @@ export async function githubLifecycleEventFromRequest(
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function githubSupplementalLifecycleFromRequest(githubInspection: Request): Promise<GitHubLifecycleEvent | null> {
|
||||
let lifecycle: GitHubLifecycleEvent | null = null;
|
||||
try {
|
||||
lifecycle = await githubLifecycleEventFromRequest(githubInspection);
|
||||
} catch {
|
||||
// Ignore only malformed or unsupported supplemental payloads. Once an
|
||||
// event is recognized, its durable persistence failure must escape.
|
||||
}
|
||||
return lifecycle;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { agents, chatDeliveries, chatGitHubReviews } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { LOW_TRUST_REVIEW_PRESET, type GitHubReviewEventContext, type GitHubIssueEventContext, type GitHubReviewPolicy } from "@paperclipai/shared";
|
||||
import type { DbOrTransaction, EndpointRow } from "../runtime-types.js";
|
||||
import { githubManualMessagePrompt } from "../../chat-github-review-policy.js";
|
||||
|
||||
type AutomaticRequest = { context: GitHubReviewEventContext; revision: number; policy: GitHubReviewPolicy };
|
||||
type IssueRequest = { context: GitHubIssueEventContext; revision: number; policy: GitHubReviewPolicy };
|
||||
type ManualRequest = { policy: GitHubReviewPolicy; revision: number; event: "mention" | "comment" };
|
||||
|
||||
export function githubInboundTaskTitle(message: string, automatic: AutomaticRequest | undefined, issue: IssueRequest | undefined): string {
|
||||
return automatic ? `PR #${automatic.context.pullNumber}: ${automatic.context.title}`
|
||||
: issue ? `GitHub issue #${issue.context.issueNumber}: ${issue.context.title}` : message;
|
||||
}
|
||||
|
||||
export function githubInboundCommentText(input: {
|
||||
manual: ManualRequest | undefined;
|
||||
repository: string;
|
||||
thread: string;
|
||||
sender: { id: string; login: string | null };
|
||||
message: string;
|
||||
}): string {
|
||||
return input.manual ? githubManualMessagePrompt({
|
||||
event: input.manual.event, policy: input.manual.policy, repository: input.repository,
|
||||
thread: input.thread, sender: input.sender, message: input.message,
|
||||
}) : input.message.trim();
|
||||
}
|
||||
|
||||
export async function githubAssignedAgentRequiresLowTrust(taskTx: DbOrTransaction, taskEndpoint: EndpointRow): Promise<boolean> {
|
||||
const [assignedAgentTrust] = await taskTx.select({ permissions: agents.permissions }).from(agents)
|
||||
.where(and(eq(agents.companyId, taskEndpoint.companyId), eq(agents.id, taskEndpoint.assignedAgentId)));
|
||||
return assignedAgentTrust?.permissions?.trustPreset === LOW_TRUST_REVIEW_PRESET;
|
||||
}
|
||||
|
||||
export async function retainGitHubDeliveryAuthority(
|
||||
taskTx: DbOrTransaction, taskEndpoint: EndpointRow, deliveryId: string,
|
||||
linkedUserId: string | null, taskUserId: string | null,
|
||||
): Promise<void> {
|
||||
const guest = !linkedUserId;
|
||||
await taskTx.update(chatDeliveries).set({ normalizedEvent: sql`${chatDeliveries.normalizedEvent} || ${JSON.stringify({ githubAuthority: { guest, responsibleUserId: taskUserId, sponsorUserId: guest ? taskEndpoint.sponsorUserId : null } })}::jsonb` }).where(eq(chatDeliveries.id, deliveryId));
|
||||
}
|
||||
|
||||
export async function retainGitHubReviewReceipt(taskTx: DbOrTransaction, endpoint: EndpointRow, issueId: string, deliveryId: string, githubAutomatic: AutomaticRequest): Promise<void> {
|
||||
await taskTx.insert(chatGitHubReviews).values({
|
||||
companyId: endpoint.companyId, endpointId: endpoint.id, issueId: issueId,
|
||||
repositoryId: githubAutomatic.context.repositoryId, repository: githubAutomatic.context.repository,
|
||||
pullNumber: githubAutomatic.context.pullNumber, headSha: githubAutomatic.context.headSha,
|
||||
deliveryId: deliveryId, configurationRevision: githubAutomatic.revision,
|
||||
policySnapshot: githubAutomatic.policy, event: githubAutomatic.context, state: "queued",
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
import type { EndpointRow } from "../runtime-types.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
import { retryableGitHubWebhookResponse } from "./webhook-authentication.js";
|
||||
|
||||
export async function initializeGitHubWebhookRuntime(
|
||||
endpoint: EndpointRow,
|
||||
runtimePromise: Promise<ChatEndpointRuntime>,
|
||||
githubResponseDeadlineAt: number | null,
|
||||
redactError: (error: unknown) => string,
|
||||
): Promise<{ response: Response; runtime?: never } | { response?: never; runtime: ChatEndpointRuntime }> {
|
||||
let endpointRuntime: ChatEndpointRuntime;
|
||||
if (githubResponseDeadlineAt !== null) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
runtimePromise.then((value) => ({ completed: true as const, value })),
|
||||
new Promise<{ completed: false; value?: never }>((resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve({ completed: false }),
|
||||
Math.max(0, githubResponseDeadlineAt - Date.now()),
|
||||
);
|
||||
timeout.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (!outcome.completed) {
|
||||
return { response: retryableGitHubWebhookResponse(), runtime: undefined };
|
||||
}
|
||||
endpointRuntime = outcome.value;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ endpointId: endpoint.id, error: redactError(error) },
|
||||
"GitHub runtime initialization failed before durable webhook receipt",
|
||||
);
|
||||
return { response: retryableGitHubWebhookResponse(), runtime: undefined };
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
} else {
|
||||
endpointRuntime = await runtimePromise;
|
||||
}
|
||||
return { runtime: endpointRuntime };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { chatMessageLinks, type Db, type issues } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { photonReplyReference } from "../../photon/adapter.js";
|
||||
import { logActivity, type ActivityPublication } from "../../activity-log.js";
|
||||
import type { DbOrTransaction, EndpointRow } from "../runtime-types.js";
|
||||
|
||||
export async function photonControlTargetsConversation(db: Db, endpoint: EndpointRow, guid: string, conversationId: string | undefined): Promise<boolean> {
|
||||
const source = await db
|
||||
.select({ conversationId: chatMessageLinks.conversationId })
|
||||
.from(chatMessageLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, endpoint.companyId),
|
||||
eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.providerMessageId, guid),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
return Boolean(source && source.conversationId === conversationId);
|
||||
}
|
||||
|
||||
export function photonReplyMetadata(raw: unknown) {
|
||||
const reference = photonReplyReference(raw);
|
||||
return reference ? [
|
||||
{ type: "key_value" as const, label: "Reply to message", value: reference.guid },
|
||||
...(reference.part ? [{ type: "key_value" as const, label: "Reply part", value: reference.part }] : []),
|
||||
] : [];
|
||||
}
|
||||
|
||||
export async function recordPhotonInboundActivity(
|
||||
taskTx: DbOrTransaction,
|
||||
taskEndpoint: EndpointRow,
|
||||
issue: Pick<typeof issues.$inferSelect, "id" | "identifier">,
|
||||
commentId: string,
|
||||
taskUserId: string | null,
|
||||
inboundActivityPublications: ActivityPublication[],
|
||||
): Promise<void> {
|
||||
await logActivity(
|
||||
taskTx as Db,
|
||||
{
|
||||
companyId: taskEndpoint.companyId,
|
||||
actorType: taskUserId ? "user" : "system",
|
||||
actorId: taskUserId ?? "chat:imessage-photon",
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
commentId: commentId,
|
||||
issueIdentifier: issue.identifier,
|
||||
source: "chat:imessage-photon",
|
||||
endpointId: taskEndpoint.id,
|
||||
},
|
||||
},
|
||||
inboundActivityPublications,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { issues } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { LOW_TRUST_REVIEW_PRESET, LOW_TRUST_REVIEW_PRESET_VERSION, LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION } from "@paperclipai/shared";
|
||||
import type { DbOrTransaction, EndpointRow } from "../runtime-types.js";
|
||||
|
||||
function policyRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
export async function applyChatTaskReviewContainment(taskTx: DbOrTransaction, endpoint: EndpointRow, issue: typeof issues.$inferSelect): Promise<void> {
|
||||
const reviewPreset = {
|
||||
id: LOW_TRUST_REVIEW_PRESET,
|
||||
version: LOW_TRUST_REVIEW_PRESET_VERSION,
|
||||
rawOutputDisposition: LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
|
||||
} as const;
|
||||
await taskTx
|
||||
.update(issues)
|
||||
.set({
|
||||
sourceTrust: {
|
||||
preset: LOW_TRUST_REVIEW_PRESET,
|
||||
disposition: "quarantined",
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
...issue.executionPolicy,
|
||||
reviewPreset,
|
||||
authorizationPolicy: {
|
||||
...policyRecord(issue.executionPolicy?.authorizationPolicy),
|
||||
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
||||
reviewPreset,
|
||||
trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET,
|
||||
companyId: endpoint.companyId,
|
||||
rootIssueId: issue.id,
|
||||
issueIds: [issue.id],
|
||||
allowedAgentIds: [endpoint.assignedAgentId],
|
||||
allowedToolClasses: [
|
||||
"git.read",
|
||||
"github.pr.read",
|
||||
"tests.local",
|
||||
],
|
||||
...policyRecord(policyRecord(issue.executionPolicy?.authorizationPolicy)?.trustBoundary),
|
||||
},
|
||||
},
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, issue.id),
|
||||
eq(issues.companyId, endpoint.companyId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { teamsThreadRootMessageId } from "../inbound/surfaces.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
import { MAX_INBOUND_TEXT, lifecycleActorFromAuthor, type LifecycleActor } from "../message-lifecycle.js";
|
||||
@@ -97,3 +98,12 @@ export function teamsActivitySentAt(raw: unknown): Date | null {
|
||||
const sentAt = new Date(value);
|
||||
return Number.isFinite(sentAt.getTime()) ? sentAt : null;
|
||||
}
|
||||
|
||||
export function teamsOrphanFollowUp(threadId: string, messageId: string): boolean {
|
||||
const rootMessageId = teamsThreadRootMessageId(threadId);
|
||||
return Boolean(rootMessageId) && rootMessageId !== messageId;
|
||||
}
|
||||
|
||||
export function teamsAttachmentReferenceText(count: number, providerUrl: string | null): string {
|
||||
return `Shared ${count} Microsoft Teams file reference${count === 1 ? "" : "s"}.${providerUrl ? ` Open in Microsoft Teams: ${providerUrl}` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { chatDeliveries, type Db } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notifyChatDeliveryWork } from "../../chat-work-notifications.js";
|
||||
|
||||
export async function deferTeamsSetupReply(
|
||||
db: Db,
|
||||
activeDelivery: Pick<typeof chatDeliveries.$inferSelect, "id" | "attempts">,
|
||||
redactedDestinationNormalized: typeof chatDeliveries.$inferSelect["normalizedEvent"],
|
||||
ORPHAN_FOLLOW_UP_MAX_ATTEMPTS: number,
|
||||
ORPHAN_FOLLOW_UP_GRACE_MS: number,
|
||||
): Promise<void> {
|
||||
const resolutionAt = new Date();
|
||||
const waitForSetupRoot =
|
||||
activeDelivery.attempts <= ORPHAN_FOLLOW_UP_MAX_ATTEMPTS;
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
return tx
|
||||
.update(chatDeliveries)
|
||||
.set(
|
||||
waitForSetupRoot
|
||||
? {
|
||||
state: "retry",
|
||||
nextAttemptAt: new Date(
|
||||
resolutionAt.getTime() + ORPHAN_FOLLOW_UP_GRACE_MS,
|
||||
),
|
||||
redactedError: "Waiting briefly for an earlier root mention",
|
||||
updatedAt: resolutionAt,
|
||||
}
|
||||
: {
|
||||
state: "filtered",
|
||||
normalizedEvent: redactedDestinationNormalized,
|
||||
principalId: null,
|
||||
nextAttemptAt: null,
|
||||
processedAt: resolutionAt,
|
||||
redactedError: "Destination is not enabled in Paperclip",
|
||||
updatedAt: resolutionAt,
|
||||
},
|
||||
)
|
||||
.where(eq(chatDeliveries.id, activeDelivery.id));
|
||||
});
|
||||
}
|
||||
@@ -87,3 +87,12 @@ export function slackThreadChannelId(threadId: string): string | null {
|
||||
separator === -1 ? channelAndThread : channelAndThread.slice(0, separator);
|
||||
return channelId || null;
|
||||
}
|
||||
|
||||
export function slackOrphanFollowUp(threadId: string, messageId: string): boolean {
|
||||
const rootMessageId = /^slack:[^:]+:(.+)$/.exec(threadId)?.[1];
|
||||
return Boolean(rootMessageId) && rootMessageId !== messageId;
|
||||
}
|
||||
|
||||
export function stripSlackTitleMentions(text: string): string {
|
||||
return text.replace(/<@[A-Z0-9]+>/gi, "");
|
||||
}
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user