mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
fix(runner): include Grok support in public installs with sandbox prerequisites (#14024)
## Thinking Path > - Paperclip manages agents through a shared native runner. > - Built-in harness support should ship with Paperclip's public distribution. > - Grok already speaks ACP; it does not require a new public bridge package. > - Sandbox provisioning owns the native executable and its pinned version. > - The runner must verify that prerequisite without downloading it during npm installation. > - This change separates built-in launcher identity from external runtime identity. > - Clean npm installation and live staging checks verify the distribution boundary. ## Linked Issues or Issue Description Refs #13882, #13973, #13977, #13979. This follow-up now targets master after #13882 was squash-merged. It replaces the private `@paperclipai/grok-acp` workspace package with runner-owned assets. Current master is included so the branch also contains the merged scheduler, complete-event capture, and durable cleanup fixes. ## What Changed - Ship Grok launcher and qualification metadata inside the runner's compiled output and the public server's vendored runner tree. - Remove the separate Grok npm package and all package-manager install hooks for this runtime. - Require the checksum-verified Grok Build 1.0.13 binary at `/opt/paperclip/providers/grok/1.0.13/grok` in the selected execution environment. Provision it explicitly in the Daytona image and CI setup. - Keep native binaries outside the provider pack. Bind the built-in launcher into the pack manifest. - Preserve executable leases, descriptor-backed startup, credential fences, permissions, and exact ACP model admission. - Use `builtin:grok-acp` and `native:grok` as profile identities. Historical package-profile sessions fail closed on resume rather than being silently reinterpreted. - Resolve built-in assets from the authenticated sidecar location, including public server npm layouts. Keep the controller path out of provider environments. - Add clean npm tarball installation verification to the existing trusted canary CI job and the admitted manual EC2 verification path. It stages a unified release version and runs npm lifecycle scripts, then verifies missing-prerequisite rejection and admission after separate provisioning without credentials or inference. - Include the controller-owned provider pack in stamped Cloud images. Unstamped local images omit the pack and remain usable; remote ACPX requires full source provenance. - Correct CLI approval-page metadata for an already authenticated Cloud board user; approval authorization remains unchanged. - Honor explicit native-runner enablement in the Cloud agent picker and direct setup page, keeping the flag disabled by default. - Allow selecting the execution environment before connecting credentials. Include Grok in the existing authenticated hello-probe flow, targeting its pinned native prerequisite for runner setup. - Recover an existing subscription sign-in conflict through an explicit cancel-and-retry action, serialized after cancellation succeeds. - Preserve the selected ACPX harness before normalizing config fields, so new Grok agents use the Grok default model. - Keep the credential-free Cloud provider pack root-owned and readable after runtime UID remapping; verify manifest and referenced asset access under an unrelated unprivileged UID during image builds. - Archive prior failover backups alongside explicitly replaced harness state, preserving evidence while preventing stale backups from blocking a fresh replacement. - Update Daytona image content inputs and contract tests for the built-in assets and explicit provisioner. - Document and regression-test the shared `approve-all` default for Grok setup, saved configuration, and native execution. Explicitly saved restrictions remain unchanged. ## Verification Current merge-repair head `df09eb3e1a619430ad8419a0ee9aedd486689b05` incorporates master `f1a394bd30cb56fb9e479f98b9f50176fe921858` after the base PR was squash-merged. All 12 conflicts came from incoming files identical to the tested pre-squash base. The final tree exactly matches a three-way merge using that original base, preserving built-in Grok distribution and removal of the obsolete private package. All 252 focused runner/UI tests, six npm-isolation tests, and token gates pass. Fresh exact-head Greptile review is 5/5 with no outstanding findings; security scans and EC2 native compilation pass. All current-head CI is green: 56 successful checks/statuses and four intentional skips ([run 36468768035](https://github.com/paperclipai/paperclip/actions/runs/36468768035)). The repository owner explicitly authorized bypassing code-owner approval after all checks passed; no CI checks or repository protection settings are bypassed or changed. The only remaining PR was removed from the completed stack metadata to permit native auto-merge. Earlier integration head `78cb306ecc41b5c96577c26c1d89153b0ef865a1` includes master `3447609d2247e75e55d91493dda91a608364f672` (2026-09-28). Two master advances during verification overlapped the eval catalog; the final merge preserves Grok qualification, completion updates, and bounded API-response reading in all 348 cells. All 77 focused catalog/eval/workflow tests pass. Both native stack layers (#14397) are mergeable, and both exact-head Greptile reviews are 5/5 with successful security scans and no unresolved review threads. All current-head CI is green: 56 successful checks/statuses and four intentional skips ([CI attempts](https://github.com/paperclipai/paperclip/actions/runs/36447124691)). The initial attempt lost two EC2 runners to shutdown signals and stalled a third shard during dependency preparation; all three passed the same-commit failed-job-only retry. Trunk code-owner requirements remain enforced. The review summary’s non-blocking saved-asset offset classification note concerns code already merged in #14301; those runtime files are identical to master and outside this stack’s diff. Historical live evidence below retains its original source revisions. [Final public npm verification](https://github.com/paperclipai/paperclip/actions/runs/36445542764) passed on `76ea70cd4d13786a042af9df82f0fd7a8c85ae30`: 17 public packages, an executed offline lifecycle sentinel, unchanged consumer lock, built-in launcher, missing-prerequisite rejection, and verified separately provisioned binary/command lease. Provisioning and cleanup require no host privilege elevation; only the positive probe mounts the temporary native binary read-only. The verifier is unchanged by the final master merge. All six isolation tests and an offline npm smoke test pass. The prior head had 56 green CI checks and a 5/5 review after two unchanged tests timed out and passed a failed-job-only retry ([CI attempts](https://github.com/paperclipai/paperclip/actions/runs/36444597313)). All 56 recovery-display/lineage tests pass; re-review cleared the already-covered missed-retry concern. Earlier EC2 failures remain retained: [npm lockfile rejection](https://github.com/paperclipai/paperclip/actions/runs/36436311203), [missing compiler in the slim image](https://github.com/paperclipai/paperclip/actions/runs/36440210984), and the aggregate 15-minute test timeouts in those broad runs. Both broad attempts passed typecheck, token gates, Product E2E type/unit checks and build. The focused EC2 lane preserves the existing trusted-actor and immutable-source gates. Earlier documentation/test checkpoint `ff244c4fd78a7ede5a3e00efe09f475f133ef33e` leaves runtime behavior unchanged. 154 focused tests pass across configuration building, native provider resolution, permission policy, credentials, UI configuration, and new-agent setup (including both Grok auth modes); token gates pass. All fresh CI is green for this head: 56 successful checks/statuses and two intentional skips ([run 36367065119](https://github.com/paperclipai/paperclip/actions/runs/36367065119)). Greptile is 5/5 with no new findings. Grok already inherits the shared `approve-all` default, so unattended setup requires no manual permission change. Runtime head `bb5a9307991f1ac567b781970ef11b39d518e19b` fixes a final staging continuation failure before provider startup: explicit replacement archived the old harness but left its failover backups active, which caused `runner_harness_state_mismatch`. The regression fails before the fix and passes after it; all eight adjacent recovery-safety cases also pass. Old backups remain inspectable inside the continuity archive. All fresh CI is green at this head ([run 36360839248](https://github.com/paperclipai/paperclip/actions/runs/36360839248)), with a 5/5 review. One unrelated Cursor test timed out in the initial server shard; the same-commit failed-job rerun passed, and both attempts are retained. Staging deployment is confirmed healthy on this revision. The controller image is `ghcr.io/paperclipai/paperclip@sha256:6ad91c487910ccd2596ff7aed0a3a3ea5233d12b51b83cd6e1402237749b9673`. The final browser-created staging task passed on this exact revision with API authentication: context read → structured human question → controller restart → answer submission → same native provider session resumed → document saved → task Done. The two turns took approximately 119s and 77s. The actual write receipt was applied, and the saved document has exactly one revision containing the selected answer and requested marker. Usage and cost were not reported. [Controller image build](https://github.com/paperclipai/paperclip/actions/runs/36360889243). - Previous integration head `a44f7dbb6b6f77cd9ed893756ca453307f281e5f`: all CI green (53 successful checks/statuses, two intentional skips), including repository typecheck/build/tests, native Runner tests, browser shards, and canary installation checks. [CI run 36358672529](https://github.com/paperclipai/paperclip/actions/runs/36358672529). Greptile is 5/5 with no unresolved findings. - Focused checks cover Grok credentials, executable admission, launcher assets, provider-pack paths/permissions, workflow contracts, setup defaults, CLI authorization, and subscription conflict recovery. All 39 protocol definitions validate. Final integration checks pass 124 catalog/evidence/cache tests and nine project-form tests; token gates pass. Some local dependency checks could not load the stale installed dependency tree; the corresponding fresh EC2 checks pass. - Clean public npm installation passed on EC2 at `8b172ebcf8e02e30662d830c00f3961e3bd459ec` ([run 36164964900](https://github.com/paperclipai/paperclip/actions/runs/36164964900)): 17 unified-version packages, lifecycle scripts enabled, built-in launcher present, no separate Grok package or npm-downloaded binary, missing prerequisite rejected, separately provisioned native executable and command lease verified. No credentials or inference were used. Subsequent changes preserve this npm asset layout. - The immutable Daytona prerequisite image is `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:98957d5be0ac774d086b6402b5849e8e6356fec70fb8c09fca6eb4ed6de918e0`, built from `5a2db471f3ddabe77f9f80e76ed27f996cb97fba`. The previous Cloud controller image was `ghcr.io/paperclipai/paperclip@sha256:fd914e1ab1e45f741e8e078ff452d16f082d7ac05f9b4b3506d3a3c64150d204`, built from `a44f7dbb6b6f77cd9ed893756ca453307f281e5f`; it is superseded by the latest image above. Its EC2 build verified provider-pack access under an unrelated unprivileged UID. - Browser staging at `40f898bc4cba73c1dff4e6344a3983ba0fb247ef` passed full Grok onboarding with the correct `grok-4.7` model, saved credential delivery, and pinned Daytona execution. A browser-created task read context and asked the structured human question. After a controller restart, answering the persisted question resumed the same native provider session, saved the requested document, and completed the task. Actual tool outcomes and durable state agree: one question and one document revision. The two successful turns took 42.7s and 63.1s; usage and cost were not reported. - Restricted policy returned the expected `approval_required` outcome. Functional staging tests explicitly selected `approve-all`; controller authorization and governed approvals remain enforced. Temporary board CLI access was revoked and verified rejected (HTTP 401), and the disposable onboarding agent was paused. Failures remain retained: the pre-fix continuation failure (its task remains blocked; the passing final task is fresh), the original Cloud provider-pack permission failure, the expected restricted-policy denial, the superseded npm staging failure, and an earlier monolithic CI infrastructure timeout. Browser CI exposed a project alias/form race; the final stack uses master's stronger draft-preservation fix and all browser shards pass. Historical full subscription/API protocol and Product rosters retain their original source revisions and do not qualify this packaging revision. No local Docker or Rust build was used. ## Risks The branch includes master’s draft-preservation fix for project URL aliases. It keeps the same project’s edit form mounted and clears prior data when the project or company changes. Custom sandboxes and local execution hosts must provision the pinned binary before Grok starts. Missing, changed, unsupported-platform, and symlinked executables fail admission. The new builtin profile cannot resume sessions created with the former private-package profile. Existing Claude/Codex npm bridge profiles retain their package pins. Grok restricted modes preserve the selected policy but cannot automatically admit Paperclip calls: ACP permission metadata does not independently bind tool authority, so those calls stop with `approval_required`. New Grok configurations default to `approve-all`, including API configurations that omit the mode. Existing explicitly restricted configurations remain restricted; controller authorization and governed approvals remain enforced. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact serving model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1158,6 +1158,10 @@ jobs:
|
||||
commit --no-verify -m "ci(canary): stage regenerated lockfile"
|
||||
fi
|
||||
./scripts/release.sh canary --skip-verify --dry-run
|
||||
- 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
|
||||
|
||||
|
||||
e2e_shards:
|
||||
name: e2e shard (${{ matrix.shard_label }})
|
||||
|
||||
@@ -892,7 +892,7 @@ jobs:
|
||||
|
||||
- name: Install checksum-verified Grok executable
|
||||
if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-acpx-grok' || matrix.profileId == 'runner-acpx-grok-subscription')
|
||||
run: node packages/grok-acp/install.mjs
|
||||
run: sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok
|
||||
|
||||
- name: Download immutable campaign outputs
|
||||
if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth'
|
||||
|
||||
@@ -356,8 +356,8 @@ jobs:
|
||||
|
||||
- name: Materialize the pinned Grok executable when the target includes it
|
||||
run: |
|
||||
if [ -f packages/grok-acp/install.mjs ]; then
|
||||
node packages/grok-acp/install.mjs
|
||||
if [ -f packages/paperclip-runner/scripts/provision-grok.mjs ]; then
|
||||
sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok
|
||||
fi
|
||||
|
||||
- name: Build runner CLI, daemon, and canonical attempt viewer
|
||||
|
||||
+22
-1
@@ -22,7 +22,6 @@ COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-server/
|
||||
COPY packages/grok-acp/package.json packages/grok-acp/
|
||||
COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/paperclip-eval-kernel/package.json packages/paperclip-eval-kernel/
|
||||
@@ -294,7 +293,29 @@ RUN set -eu; \
|
||||
test -n "$specifiers" || { echo "ERROR: CLOUD_BUNDLED_SERVER_DEPS names no package" >&2; exit 1; }; \
|
||||
pnpm add --ignore-workspace --no-lockfile $specifiers
|
||||
|
||||
# ACPX remote runs require a controller-owned provider pack to verify the
|
||||
# sandbox installation or stage matching assets. Grok's native executable stays
|
||||
# an external sandbox prerequisite; this pack contains only its launcher.
|
||||
FROM build AS cloud-provider-pack
|
||||
# Unstamped local builds remain usable, but cannot qualify a remote pack.
|
||||
# Never invent a source revision to make an unqualified pack look verified.
|
||||
RUN mkdir -p /provider-pack \
|
||||
&& if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \
|
||||
PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack; \
|
||||
else \
|
||||
echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote ACPX execution"; \
|
||||
fi
|
||||
|
||||
FROM production AS cloud
|
||||
COPY --from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack
|
||||
# Cloud remaps node's UID at startup. This immutable pack contains public code
|
||||
# and integrity metadata, never credentials; it must remain readable afterward.
|
||||
# Keep it root-owned and verify access as an unrelated unprivileged UID.
|
||||
RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \
|
||||
&& if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \
|
||||
gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \
|
||||
fi
|
||||
ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack
|
||||
COPY --chown=node:node --from=cloud-plugins /app/packages/plugins/sandbox-providers /app/packages/plugins/sandbox-providers
|
||||
# Land the isolated install inside the server's own `node_modules`, the
|
||||
# directory Node's module resolution walks up to from `/app/server` for
|
||||
|
||||
@@ -3,23 +3,54 @@
|
||||
Select **Grok Build** in the native runner provider selector. The stored contract is
|
||||
`adapterType: "paperclip_runner"` with `provider: "acpx"`, `acpxAgent: "grok"`,
|
||||
and `model: "grok-4.7"`. Existing `grok_local` agents keep their legacy adapter.
|
||||
New Grok runner agents default to **Full auto (approve all)**
|
||||
(`acpxPermissionMode: "approve-all"`) in setup and the configuration form.
|
||||
API configurations that omit the permission mode use the same default. No
|
||||
additional permission setting is needed for unattended execution. Explicitly
|
||||
saved restrictions remain unchanged.
|
||||
On Cloud, an operator must enable `enableNativeRunner` for the instance before
|
||||
the new-agent picker or direct setup page offers the native runner.
|
||||
|
||||
Grok Build speaks [ACP over stdio](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/15-agent-mode.md).
|
||||
The runner owns `grok agent --no-leader stdio` through ACPX, including session
|
||||
identity, cancellation, recovery and the authenticated Paperclip MCP bridge.
|
||||
It does not add `--always-approve`. Restricted operations use the selected ACPX
|
||||
permission policy and return the existing approval-required outcome. Isolated ask
|
||||
ACP permission requests are approved by the runner under the default full-auto
|
||||
policy. It does not add `--always-approve`: permission decisions remain under
|
||||
the selected ACPX policy. Grok's ACP metadata cannot independently establish
|
||||
Paperclip tool authority, so explicitly selecting `approve-paperclip` or
|
||||
`approve-reads` returns the approval-required outcome; `deny-all` rejects requests.
|
||||
Full auto does not bypass Paperclip's company permissions, governed approvals,
|
||||
or execution-environment boundaries. Isolated ask
|
||||
rules override project allow rules, and compatible always-approve settings are
|
||||
locked off. Compatible hook/MCP discovery and shell login capture are disabled.
|
||||
|
||||
## Installation and identity
|
||||
|
||||
Run `pnpm --filter @paperclipai/paperclip-runner install:grok` after installing
|
||||
workspace dependencies. This explicitly downloads Grok Build 1.0.13, verifies
|
||||
the native executable digest in `packages/grok-acp/platforms.json`, and installs
|
||||
it privately. Only macOS arm64 and Linux x64 are admitted. Provider packs install
|
||||
the same verified binary. No ambient `grok` from PATH is used by the native runner.
|
||||
ACP must report the requested exact model; absent or mismatched identities fail.
|
||||
Grok support ships inside the native runner and the public Paperclip server npm
|
||||
artifact. There is no separate Grok npm package, binary payload, or npm lifecycle
|
||||
download. The built-in launcher is identified as `builtin:grok-acp` version 1;
|
||||
its native runtime identity is `native:grok` version 1.0.13. The historical
|
||||
`agentServerPackage`/`agentRuntimePackage` wire fields carry these identities,
|
||||
not npm dependencies. Existing npm-backed ACP bridges retain their package pins.
|
||||
|
||||
Provision Grok Build 1.0.13 at
|
||||
`/opt/paperclip/providers/grok/1.0.13/grok` in the selected execution environment.
|
||||
The sandbox provisioning helper is explicit and is never run by npm:
|
||||
|
||||
```sh
|
||||
sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok
|
||||
```
|
||||
|
||||
The standard Daytona image provisions it separately from the provider pack.
|
||||
Custom images and local execution hosts must provide the same prerequisite.
|
||||
The runner verifies the native executable checksum before credential refresh or
|
||||
ACP startup; a missing prerequisite reports the required path and version.
|
||||
Only macOS arm64 and Linux x64 are qualified. No ambient `grok` from PATH is used.
|
||||
ACP must report the requested exact model; mismatches fail closed.
|
||||
|
||||
The distribution identity change deliberately rejects resume bindings from the
|
||||
former private-package profile. Start a fresh session after upgrading that
|
||||
unreleased profile; do not silently reinterpret its saved identity.
|
||||
|
||||
Instructions use Grok ACP session rules. Assigned skills live in the isolated
|
||||
Grok home. Steering and goals are unsupported. Token and cost values remain
|
||||
@@ -83,3 +114,25 @@ Do not run Docker on a developer laptop when using remote verification. The
|
||||
builds and broad source checks without provider credentials. It records the
|
||||
source revision, resolved lock digest and immutable image reference. Paid
|
||||
Product E2E remains behind the protected default-branch workflow and environment.
|
||||
|
||||
The public npm consumer check uses a digest-pinned, unprivileged container with
|
||||
no checkout or credentials mounted. It downloads dependencies with lifecycle
|
||||
scripts disabled and freezes the resulting consumer lockfile. It completes the
|
||||
clean install with offline `npm rebuild`, running the deferred lifecycle hooks
|
||||
without re-resolving bundled optional dependencies. Networking stays disabled,
|
||||
the lockfile must remain unchanged, and a sentinel proves scripts actually ran.
|
||||
The pinned image includes native build tools and local Node headers so dependency
|
||||
hooks can compile without network access. Both executable admission probes run
|
||||
in the same isolation. The verification user provisions Grok inside the disposable
|
||||
test directory without privilege elevation or host `/opt` changes. Only the
|
||||
positive probe mounts that binary read-only at the canonical sandbox path.
|
||||
|
||||
|
||||
The Cloud application image also carries the controller-owned provider pack and
|
||||
sets `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH`. Remote ACPX execution verifies
|
||||
the sandbox against that pack before using it, or stages the matching pack when
|
||||
needed. The Cloud controller image does not install the native Grok executable;
|
||||
the selected sandbox image must provide the prerequisite above.
|
||||
Cloud builds must supply the full source SHA through `PAPERCLIP_BUILD_COMMIT`
|
||||
to produce that verified pack. Unstamped local Cloud builds still work for other
|
||||
features, but omit the pack and cannot start remote ACPX sessions.
|
||||
|
||||
@@ -93,6 +93,10 @@ RUN set -eu; \
|
||||
install "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh; \
|
||||
rm -rf /tmp/gh.tgz /tmp/gh.sha256 "/tmp/gh_${GH_VERSION}_linux_amd64"
|
||||
|
||||
COPY packages/paperclip-runner/scripts/provision-grok.mjs /tmp/grok-provision/scripts/provision-grok.mjs
|
||||
COPY packages/paperclip-runner/src/providers/grok/platforms.json /tmp/grok-provision/src/providers/grok/platforms.json
|
||||
RUN node /tmp/grok-provision/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok && rm -rf /tmp/grok-provision
|
||||
|
||||
COPY --from=runnerd-build /workspace/packages/paperclip-runner/runner/target/release/paperclip-runnerd /usr/local/bin/paperclip-runnerd
|
||||
COPY --from=provider-pack-build /provider-pack /opt/paperclip-runner/provider-pack
|
||||
RUN set -eu; for cli in codex claude opencode; do \
|
||||
|
||||
@@ -108,5 +108,9 @@ lock against this value before installation. The fixed Dockerfile default is
|
||||
for standalone builds; it must not replace a campaign's verified lock digest.
|
||||
Refresh exact runtime versions and qualification digests together; never download
|
||||
dependencies when a task starts. Grok's additive native ACP profile keeps its
|
||||
qualified 1.0.13 executable at a verified package path. It does not replace the
|
||||
qualified 1.0.13 executable at the verified sandbox prerequisite path. It does not replace the
|
||||
legacy adapter's `grok` command on PATH.
|
||||
|
||||
Native Grok is an image prerequisite at `/opt/paperclip/providers/grok/1.0.13/grok`.
|
||||
Its checksum-verified provisioning is separate from the provider pack, which ships
|
||||
only the built-in launcher. Public npm installation never downloads this binary.
|
||||
|
||||
@@ -36,6 +36,37 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigVa
|
||||
}
|
||||
|
||||
describe("buildCodexLocalConfig", () => {
|
||||
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
|
||||
"defaults Grok to full auto while preserving an explicit %s permission mode",
|
||||
(acpxPermissionMode) => {
|
||||
const config = buildPaperclipRunnerConfig(makeValues({
|
||||
adapterType: "paperclip_runner",
|
||||
model: "",
|
||||
adapterSchemaValues: { provider: "acpx", acpxAgent: "grok", acpxPermissionMode },
|
||||
}));
|
||||
expect(config).toMatchObject({
|
||||
provider: "acpx",
|
||||
acpxAgent: "grok",
|
||||
model: "grok-4.7",
|
||||
acpxPermissionMode: acpxPermissionMode ?? "approve-all",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["", "grok-4.7-custom"])("retains the Grok harness and its model when normalizing runner fields (%s)", (model) => {
|
||||
const values = makeValues({
|
||||
model,
|
||||
adapterSchemaValues: { provider: "acpx", acpxAgent: "grok", acpxPermissionMode: "approve-paperclip" },
|
||||
});
|
||||
expect(buildPaperclipRunnerConfig(values)).toMatchObject({
|
||||
provider: "acpx",
|
||||
acpxAgent: "grok",
|
||||
model: model || "grok-4.7",
|
||||
acpxPermissionMode: "approve-paperclip",
|
||||
});
|
||||
expect(values.adapterSchemaValues?.acpxAgent).toBe("grok");
|
||||
});
|
||||
|
||||
it("omits engine for the auto default so runtime fallback remains available", () => {
|
||||
const config = buildCodexLocalConfig(makeValues({ codexEngine: "auto" }));
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
const provider = isPaperclipRunnerProvider(providerCandidate)
|
||||
? providerCandidate
|
||||
: "codex";
|
||||
const acpxAgent = schemaValues.acpxAgent === "grok" ? "grok" : "claude";
|
||||
|
||||
const schemaModel = typeof schemaValues.model === "string"
|
||||
? schemaValues.model.trim()
|
||||
@@ -233,8 +234,8 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
: {}),
|
||||
...(provider === "acpx"
|
||||
? {
|
||||
acpxAgent: schemaValues.acpxAgent === "grok" ? "grok" : "claude",
|
||||
model: configuredModel || schemaModel || (schemaValues.acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
|
||||
acpxAgent,
|
||||
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
|
||||
}
|
||||
: {}),
|
||||
...(provider === "claude_managed"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/bin/
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@paperclipai/grok-acp",
|
||||
"version": "1.0.13",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"paperclip-grok-acp": "launcher.cjs"
|
||||
},
|
||||
"files": [
|
||||
"launcher.cjs",
|
||||
"bin",
|
||||
"install.mjs",
|
||||
"platforms.json"
|
||||
],
|
||||
"scripts": {
|
||||
"install:binary": "node install.mjs"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,13 @@ do not change workspace isolation or grant credentials or connection access.
|
||||
task tools; `approve-reads` allows assigned reads; `deny-all` rejects requests.
|
||||
None of these restrictive modes is the default.
|
||||
|
||||
Automatic Paperclip/read allowances currently require the Claude SDK dispatch
|
||||
boundary. Grok preserves these restricted settings, but its ACP requests lack
|
||||
independently bound tool authority. They therefore stop with
|
||||
`approval_required`, including Paperclip tool requests. Use an explicitly
|
||||
selected `approve-all` policy for unattended Grok work in an assigned sandbox;
|
||||
Paperclip authorization and governed approvals still apply.
|
||||
|
||||
This runtime has no interactive permission handler. An operation that still
|
||||
requires approval stops the turn with `approval_required`. The server marks the
|
||||
task blocked, exposes the permission action to the operator, and disables
|
||||
|
||||
@@ -5,13 +5,39 @@ the provider contract. A driver is not complete until its permission modes,
|
||||
maximum non-interactive default, durable request translation, recovery
|
||||
identity, isolation behavior, and conformance coverage are defined.
|
||||
|
||||
## Distribution
|
||||
|
||||
First-party harness glue belongs in the runner, not in a new npm package for
|
||||
each provider. Keep built-in launchers and metadata under
|
||||
`src/providers/<provider>/`; include them in the compiled runner and the public
|
||||
server's vendored runtime. Grok is the reference implementation. This keeps a
|
||||
public Paperclip npm install self-contained as more harnesses are added.
|
||||
|
||||
Native provider binaries remain execution-environment prerequisites. Provision
|
||||
them explicitly in sandbox images or on local execution hosts, independently
|
||||
of npm installation and the controller provider pack. Admission must verify
|
||||
the qualified version and executable checksum before credentials are delivered.
|
||||
Missing prerequisites should produce an actionable error, never a silent
|
||||
download or an unverified fallback to PATH.
|
||||
|
||||
Use a distinct built-in launcher identity and native runtime identity in the
|
||||
qualified profile, recovery binding, provider-pack manifest, and eval config.
|
||||
Existing upstream npm-backed ACP bridges retain their package pins; this does
|
||||
not require replacing those bridges or republishing them under Paperclip names.
|
||||
|
||||
Verify public release tarballs in a clean consumer outside the checkout with
|
||||
npm lifecycle scripts enabled. Prove that the launcher ships, npm does not
|
||||
install the native prerequisite, missing prerequisites fail closed, and
|
||||
explicit provisioning permits verified execution. See
|
||||
`scripts/verify-grok-npm-install.mjs` at the repository root.
|
||||
|
||||
## Current permission catalog
|
||||
|
||||
| Provider | Agent configuration key | Supported values | Default |
|
||||
|---|---|---|---|
|
||||
| Codex | `codexPermissionMode` | `never`, `on-request`, `untrusted` | `never` |
|
||||
| OpenCode | `opencodePermissionMode` | `allow`, `ask`, `deny` | `allow` |
|
||||
| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-all` |
|
||||
| ACPX (Claude, Codex, Grok) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-all` |
|
||||
|
||||
The browser-safe source of truth for labels, defaults, and configuration
|
||||
validation is `PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES` in
|
||||
|
||||
@@ -160,7 +160,6 @@
|
||||
"browser:preview": "vite preview --config vite.config.ts",
|
||||
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet",
|
||||
"verify:rootless": "bash scripts/verify-rootless-linux.sh",
|
||||
"install:grok": "pnpm --filter @paperclipai/grok-acp install:binary",
|
||||
"test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -173,8 +172,7 @@
|
||||
"opencode-ai": "1.18.32",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"smol-toml": "^1.4.2",
|
||||
"@paperclipai/grok-acp": "workspace:*"
|
||||
"smol-toml": "^1.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
|
||||
@@ -164,9 +164,9 @@ impl AcpxProviderDescriptor {
|
||||
),
|
||||
"grok" => (
|
||||
"grok-4.7",
|
||||
"@paperclipai/grok-acp",
|
||||
"1.0.13",
|
||||
Some("@paperclipai/grok-acp"),
|
||||
"builtin:grok-acp",
|
||||
"1",
|
||||
Some("native:grok"),
|
||||
Some("1.0.13"),
|
||||
"sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
),
|
||||
|
||||
@@ -124,6 +124,7 @@ impl AcpxSidecarTransport {
|
||||
// The qualified sidecar configures the runner-owned gateway. Keep
|
||||
// its credential with the name/URL; unrelated secrets stay excluded.
|
||||
"PAPERCLIP_NATIVE_MCP_TOKEN",
|
||||
"PAPERCLIP_ACPX_BUILTIN_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
|
||||
];
|
||||
|
||||
@@ -188,12 +188,6 @@ try {
|
||||
);
|
||||
writePortableExecutableShim("node", "node/bin/node");
|
||||
writePortableExecutableShim("opencode", "opencode-ai/bin/opencode.exe");
|
||||
const grokPackage = dirname(packRequire.resolve("@paperclipai/grok-acp/package.json"));
|
||||
const grokInstall = spawnSync(process.execPath, [join(grokPackage, "install.mjs")], { stdio: "inherit" });
|
||||
if (grokInstall.status !== 0) throw new Error("Pinned Grok installation failed");
|
||||
// Native ACP launches the verified absolute package path. Do not expose a
|
||||
// generic grok shim: the legacy adapter owns that command and its version.
|
||||
writePortableNodeShim("paperclip-grok-acp", "@paperclipai/grok-acp/launcher.cjs");
|
||||
writePortableNodeShim("acpx", "acpx/dist/cli.js");
|
||||
writePortableNodeShim(
|
||||
"claude-agent-acp",
|
||||
@@ -321,9 +315,9 @@ try {
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
},
|
||||
artifacts: {
|
||||
grokExecutable: {
|
||||
path: "node_modules/@paperclipai/grok-acp/bin/grok",
|
||||
sha256: sha256File(join(grokPackage, "bin/grok")),
|
||||
grokLauncher: {
|
||||
path: "dist/providers/grok/launcher.cjs",
|
||||
sha256: sha256File(join(temporaryRoot, "dist/providers/grok/launcher.cjs")),
|
||||
},
|
||||
nodeCommand: {
|
||||
path: nodeCommand,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod } from "node:fs/promises";
|
||||
import { chmod, cp, mkdir } from "node:fs/promises";
|
||||
import { builtinModules } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -50,6 +50,10 @@ function assertSelfContainedBundle(entrypoint, result) {
|
||||
}
|
||||
|
||||
export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
|
||||
if (write) {
|
||||
await mkdir(resolve(packageRoot, "dist/providers"), { recursive: true });
|
||||
await cp(resolve(packageRoot, "src/providers"), resolve(packageRoot, "dist/providers"), { recursive: true });
|
||||
}
|
||||
const results = [];
|
||||
for (const entrypoint of verifiedProviderEntrypoints) {
|
||||
const buildBundle = async (outfile, format) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ if (values.auth === 'api') {
|
||||
environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET = await readFile(values['auth-file'], 'utf8');
|
||||
}
|
||||
const report = { schema: 'paperclip.grok-native-smoke.v1', auth: values.auth, sourceRevision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), sourceDirty: execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).length > 0, profile: QUALIFIED_ACPX_PROFILES.grok, platform: `${process.platform}-${process.arch}`, startedAt: new Date().toISOString(), attempts: [] };
|
||||
const binary = await readFile(new URL('../../grok-acp/bin/grok', import.meta.url));
|
||||
const binary = await readFile('/opt/paperclip/providers/grok/1.0.13/grok');
|
||||
report.binaryDigest = `sha256:${createHash('sha256').update(binary).digest('hex')}`;
|
||||
async function persist() { await writeFile(resolve(values.output), JSON.stringify(report, null, 2)+'\n', { mode: 0o600 }); }
|
||||
await persist();
|
||||
|
||||
+7
-6
@@ -1,11 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, isAbsolute } from "node:path";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
const manifest = JSON.parse(await readFile(new URL("./platforms.json", import.meta.url), "utf8"));
|
||||
const manifest = JSON.parse(await readFile(new URL("../src/providers/grok/platforms.json", import.meta.url), "utf8"));
|
||||
const platform = manifest.platforms[`${process.platform}-${process.arch}`];
|
||||
if (!platform) throw new Error(`Unqualified Grok platform: ${process.platform}-${process.arch}`);
|
||||
const destination = fileURLToPath(new URL("./bin/grok", import.meta.url));
|
||||
const destination = process.argv[2];
|
||||
if (!destination || !isAbsolute(destination)) throw new Error("Provisioning requires an explicit absolute destination; this script is never an npm lifecycle hook");
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const installed = await readFile(destination).catch(() => null);
|
||||
if (!installed || digest(installed) !== platform.sha256) {
|
||||
@@ -14,12 +15,12 @@ if (!installed || digest(installed) !== platform.sha256) {
|
||||
if (!response.ok) throw new Error(`Grok download failed: HTTP ${response.status}`);
|
||||
const bytes = gunzipSync(Buffer.from(await response.arrayBuffer()), { maxOutputLength: 384 * 1024 * 1024 });
|
||||
if (digest(bytes) !== platform.sha256) throw new Error("Grok binary digest mismatch");
|
||||
await mkdir(new URL("./bin/", import.meta.url), { recursive: true });
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
const temporary = `${destination}.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, bytes, { flag: "wx", mode: 0o700 });
|
||||
await writeFile(temporary, bytes, { flag: "wx", mode: 0o755 });
|
||||
await rename(temporary, destination);
|
||||
} finally { await rm(temporary, { force: true }); }
|
||||
}
|
||||
await chmod(destination, 0o700);
|
||||
await chmod(destination, 0o755);
|
||||
console.log(`Verified Grok ${manifest.version} (${process.platform}-${process.arch})`);
|
||||
@@ -3303,6 +3303,7 @@ const runnerExplicitProviderEnvironmentKeys = [
|
||||
"PAPERCLIP_NATIVE_MCP_TOKEN",
|
||||
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
|
||||
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
|
||||
"PAPERCLIP_ACPX_BUILTIN_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
|
||||
"PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY",
|
||||
|
||||
@@ -24,6 +24,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import {
|
||||
verifyProvisionedGrokExecutable,
|
||||
builtinGrokLauncherPath,
|
||||
awaitVerifiedAcpxProviderExit,
|
||||
awaitVerifiedAcpxProviderOwnership,
|
||||
createAcpxPackageJsonResolver,
|
||||
@@ -607,18 +609,38 @@ describe("ACPX installation integrity", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves the shipped builtin launcher without an npm package", async () => {
|
||||
const launcher = builtinGrokLauncherPath();
|
||||
expect(launcher).toContain("/providers/grok/launcher.cjs");
|
||||
expect(`sha256:${createHash("sha256").update(await readFile(launcher)).digest("hex")}`)
|
||||
.toBe(resolveQualifiedAcpxProfile("grok", "grok-4.7").commandDigest);
|
||||
expect(resolveQualifiedAcpxProfile("grok", "grok-4.7").agentServerPackage).toBe("builtin:grok-acp");
|
||||
});
|
||||
|
||||
it("reports a missing environment prerequisite before launching Grok", async () => {
|
||||
const fixture = await installationFixture();
|
||||
await expect(verifyProvisionedGrokExecutable(join(fixture.commandDirectory, "missing")))
|
||||
.rejects.toThrow(/Grok Build 1.0.13 prerequisite missing/);
|
||||
});
|
||||
|
||||
it("resolves a controller-owned builtin root for descriptor-loaded sidecars and rejects relative roots", () => {
|
||||
vi.stubEnv("PAPERCLIP_ACPX_BUILTIN_ROOT", "/verified/dist/providers");
|
||||
try { expect(builtinGrokLauncherPath("file:///proc/self/fd/9")).toBe("/verified/dist/providers/grok/launcher.cjs"); }
|
||||
finally { vi.unstubAllEnvs(); }
|
||||
vi.stubEnv("PAPERCLIP_ACPX_BUILTIN_ROOT", "../untrusted");
|
||||
try { expect(() => builtinGrokLauncherPath()).toThrow("Invalid builtin provider root"); }
|
||||
finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
|
||||
it("rejects a tampered native Grok executable and symlink substitution", async () => {
|
||||
const fixture = await installationFixture();
|
||||
const base = resolveQualifiedAcpxProfile("grok", "grok-4.7");
|
||||
const profile = { ...base, commandDigest: fixture.profile.commandDigest };
|
||||
await writeFile(fixture.serverPackageJsonPath, JSON.stringify({ version: "1.0.13", bin: "bin/server.js" }));
|
||||
|
||||
const native = join(fixture.commandDirectory, "grok");
|
||||
await writeFile(native, "tampered native binary", { mode: 0o700 });
|
||||
const resolvePackage = () => fixture.serverPackageJsonPath;
|
||||
await expect(verifyQualifiedAcpxInstallation(profile, resolvePackage)).rejects.toThrow(/digest mismatch/);
|
||||
await expect(verifyProvisionedGrokExecutable(native)).rejects.toThrow(/digest mismatch/);
|
||||
await rm(native);
|
||||
await symlink(fixture.commandPath, native);
|
||||
await expect(verifyQualifiedAcpxInstallation(profile, resolvePackage)).rejects.toThrow(/regular file|symlink|no-follow/);
|
||||
await expect(verifyProvisionedGrokExecutable(native)).rejects.toThrow(/regular file|symlink|no-follow/);
|
||||
});
|
||||
|
||||
it("rejects package version and executable digest drift", async () => {
|
||||
@@ -2119,7 +2141,7 @@ async function installationFixture() {
|
||||
|
||||
describe("Grok launcher subscription refresh", () => {
|
||||
it("keeps the Grok launcher digest synchronized across TypeScript, Rust, server, and provider pack", async () => {
|
||||
const launcher = await readFile(new URL("../../../../grok-acp/launcher.cjs", import.meta.url));
|
||||
const launcher = await readFile(new URL("../../providers/grok/launcher.cjs", import.meta.url));
|
||||
const digest = `sha256:${createHash("sha256").update(launcher).digest("hex")}`;
|
||||
expect(resolveQualifiedAcpxProfile("grok", "grok-4.7").commandDigest).toBe(digest);
|
||||
const [server, pack, rust] = await Promise.all([
|
||||
@@ -2137,7 +2159,7 @@ describe("Grok launcher subscription refresh", () => {
|
||||
refresh?: { status: number | null; signal?: string | null; error?: Error };
|
||||
mode?: number; fileError?: string; growingFile?: boolean;
|
||||
} = {}) {
|
||||
const script = await readFile(new URL("../../../../grok-acp/launcher.cjs", import.meta.url), "utf8");
|
||||
const script = await readFile(new URL("../../providers/grok/launcher.cjs", import.meta.url), "utf8");
|
||||
const payload = Buffer.from(JSON.stringify({ account: {
|
||||
key: "PRIVATE-CREDENTIAL-SENTINEL", refresh_token: "PRIVATE-REFRESH-SENTINEL",
|
||||
expires_at: "rawExpiry" in input ? input.rawExpiry : new Date(input.expiry ?? Date.now() - 60_000).toISOString(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ChildProcess,
|
||||
type SpawnOptionsWithoutStdio,
|
||||
} from "node:child_process";
|
||||
import { constants, realpathSync } from "node:fs";
|
||||
import { constants, existsSync, realpathSync } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
@@ -342,13 +343,7 @@ export function createAcpxPackageJsonResolver(
|
||||
const packageJsonPath = realpathSync(
|
||||
resolvePackageJsonFromIssuer(packageName, canonicalIssuer),
|
||||
);
|
||||
// The native Grok launcher is a source-owned workspace package. Admit
|
||||
// only its exact source location when the issuer is the source Runner;
|
||||
// installed packs still resolve exclusively below their node_modules.
|
||||
const sourceGrokPackage = packageName === "@paperclipai/grok-acp" &&
|
||||
canonicalManifest === resolve(canonicalRoot, "packages/paperclip-runner/package.json") &&
|
||||
packageJsonPath === resolve(canonicalRoot, "packages/grok-acp/package.json");
|
||||
if (!pathIsInside(canonicalNodeModules, packageJsonPath) && !sourceGrokPackage) {
|
||||
if (!pathIsInside(canonicalNodeModules, packageJsonPath)) {
|
||||
throw new Error(
|
||||
`ACPX provider package ${packageName} resolves outside the selected provider root`,
|
||||
);
|
||||
@@ -407,7 +402,7 @@ function pathIsInside(root: string, candidate: string): boolean {
|
||||
|
||||
export interface VerifiedAcpxInstallation {
|
||||
readonly commandDigest: string;
|
||||
readonly agentServerPackageJsonPath: string;
|
||||
readonly agentServerPackageJsonPath: string | null;
|
||||
readonly agentRuntimePackageJsonPath: string | null;
|
||||
openCommand(): Promise<VerifiedAcpxCommandLease>;
|
||||
}
|
||||
@@ -529,13 +524,14 @@ export async function verifyQualifiedAcpxInstallation(
|
||||
profile: QualifiedAcpxProfile,
|
||||
resolvePackageJson: AcpxPackageJsonResolver = defaultPackageJsonResolver,
|
||||
): Promise<VerifiedAcpxInstallation> {
|
||||
const serverPackageJsonPath = await realpath(
|
||||
resolvePackageJson(profile.agentServerPackage),
|
||||
);
|
||||
const serverPackage = await readPackageJson(
|
||||
serverPackageJsonPath,
|
||||
profile.agentServerPackage,
|
||||
);
|
||||
const builtin = profile.agent === "grok";
|
||||
if (builtin && (profile.agentServerPackage !== "builtin:grok-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:grok" || profile.agentRuntimeVersion !== "1.0.13")) {
|
||||
throw new Error("Grok builtin profile identity mismatch");
|
||||
}
|
||||
const serverPackageJsonPath = builtin ? null : await realpath(resolvePackageJson(profile.agentServerPackage));
|
||||
const serverPackage: AcpxPackageMetadata = builtin
|
||||
? { version: "1", bin: "launcher.cjs", type: "commonjs" }
|
||||
: await readPackageJson(serverPackageJsonPath!, profile.agentServerPackage);
|
||||
if (serverPackage.version !== profile.agentServerVersion) {
|
||||
throw new Error(
|
||||
`ACPX ${profile.agent} package version mismatch: expected ${profile.agentServerVersion}, received ${serverPackage.version ?? "unknown"}`,
|
||||
@@ -548,7 +544,7 @@ export async function verifyQualifiedAcpxInstallation(
|
||||
profile.agent,
|
||||
);
|
||||
const serverPackageFormat = packageModuleFormat(serverPackage.type);
|
||||
const packageDirectory = dirname(serverPackageJsonPath);
|
||||
const packageDirectory = builtin ? await realpath(dirname(builtinGrokLauncherPath())) : dirname(serverPackageJsonPath!);
|
||||
const unresolvedCommandPath = resolve(packageDirectory, relativeCommand);
|
||||
if (!isInside(packageDirectory, unresolvedCommandPath)) {
|
||||
throw new Error(`ACPX ${profile.agent} executable escapes its package`);
|
||||
@@ -577,12 +573,14 @@ export async function verifyQualifiedAcpxInstallation(
|
||||
let runtimePackageFormat: AcpxCommandFormat | null = null;
|
||||
let runtimePackage: AcpxPackageMetadata | null = null;
|
||||
let runtimeExecutable: VerifiedAcpxRuntimeExecutable | null = null;
|
||||
if (profile.agentRuntimePackage !== null) {
|
||||
if (builtin) {
|
||||
runtimeExecutable = await verifyProvisionedGrokExecutable();
|
||||
} else if (profile.agentRuntimePackage !== null) {
|
||||
if (profile.agentRuntimeVersion === null) {
|
||||
throw new Error("Qualified ACPX runtime package omitted its version");
|
||||
}
|
||||
runtimePackageJsonPath = await realpath(
|
||||
resolvePackageJson(profile.agentRuntimePackage, serverPackageJsonPath),
|
||||
resolvePackageJson(profile.agentRuntimePackage, serverPackageJsonPath!),
|
||||
);
|
||||
runtimePackage = await readPackageJson(
|
||||
runtimePackageJsonPath,
|
||||
@@ -628,7 +626,7 @@ export async function verifyQualifiedAcpxInstallation(
|
||||
);
|
||||
}
|
||||
const dependencyPackageJsonPath = await realpath(
|
||||
resolvePackageJson(expected.packageName, serverPackageJsonPath),
|
||||
resolvePackageJson(expected.packageName, serverPackageJsonPath!),
|
||||
);
|
||||
const dependencyPackage = await readPackageJson(
|
||||
dependencyPackageJsonPath,
|
||||
@@ -838,25 +836,44 @@ async function readPackageJson(
|
||||
return value as AcpxPackageMetadata;
|
||||
}
|
||||
|
||||
// Same layout in source, compiled modules, bundled sidecar, and vendored npm output.
|
||||
export function builtinGrokLauncherPath(moduleUrl: string = import.meta.url): string {
|
||||
// Only the controller supplies this path to the descriptor-loaded sidecar;
|
||||
// createSanitizedAcpxSpawnInput excludes it from provider environments.
|
||||
const root = process.env.PAPERCLIP_ACPX_BUILTIN_ROOT;
|
||||
if (root !== undefined) {
|
||||
if (!isAbsolute(root) || root.includes("\0") || resolve(root) !== root) throw new Error("Invalid builtin provider root");
|
||||
return resolve(root, "grok/launcher.cjs");
|
||||
}
|
||||
for (const relativePath of ["../../providers/grok/launcher.cjs", "../providers/grok/launcher.cjs"]) {
|
||||
const candidate = fileURLToPath(new URL(relativePath, moduleUrl));
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("Grok builtin launcher is missing from the Paperclip installation");
|
||||
}
|
||||
|
||||
export const GROK_PREREQUISITE_PATH = "/opt/paperclip/providers/grok/1.0.13/grok";
|
||||
|
||||
export async function verifyProvisionedGrokExecutable(executablePath = GROK_PREREQUISITE_PATH): Promise<VerifiedAcpxRuntimeExecutable> {
|
||||
const digests: Record<string, string> = {
|
||||
"darwin-arm64": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80",
|
||||
"linux-x64": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1",
|
||||
};
|
||||
const digest = digests[`${process.platform}-${process.arch}`];
|
||||
if (!digest) throw new Error(`Grok prerequisite unavailable for ${process.platform}-${process.arch}`);
|
||||
if (!existsSync(executablePath)) throw new Error(`Grok Build 1.0.13 prerequisite missing: provision ${GROK_PREREQUISITE_PATH} in the execution environment`);
|
||||
const verified = await openVerifiedRuntimeExecutable(executablePath, `sha256:${digest}`, "grok");
|
||||
await verified.handle.close();
|
||||
return { path: executablePath, digest: `sha256:${digest}`, identity: verified.identity,
|
||||
environmentVariable: "PAPERCLIP_GROK_VERIFIED_EXECUTABLE" };
|
||||
}
|
||||
|
||||
async function verifyQualifiedRuntimeExecutable(input: {
|
||||
profile: QualifiedAcpxProfile;
|
||||
runtimePackage: AcpxPackageMetadata;
|
||||
runtimePackageJsonPath: string;
|
||||
resolvePackageJson: AcpxPackageJsonResolver;
|
||||
}): Promise<VerifiedAcpxRuntimeExecutable | null> {
|
||||
if (input.profile.agent === "grok") {
|
||||
const digests: Record<string, string> = {
|
||||
"darwin-arm64": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80",
|
||||
"linux-x64": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1",
|
||||
};
|
||||
const digest = digests[`${process.platform}-${process.arch}`];
|
||||
if (!digest) throw new Error(`Grok verified runtime unavailable for ${process.platform}-${process.arch}`);
|
||||
const executablePath = resolve(dirname(input.runtimePackageJsonPath), "bin/grok");
|
||||
const verified = await openVerifiedRuntimeExecutable(executablePath, `sha256:${digest}`, "grok");
|
||||
await verified.handle.close();
|
||||
return { path: executablePath, digest: `sha256:${digest}`, identity: verified.identity,
|
||||
environmentVariable: "PAPERCLIP_GROK_VERIFIED_EXECUTABLE" };
|
||||
}
|
||||
const qualification =
|
||||
input.profile.agent === "claude"
|
||||
? process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64")
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface QualifiedAcpxProfile {
|
||||
readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION;
|
||||
readonly agent: QualifiedAcpxAgent;
|
||||
readonly agentProfileVersion: 1;
|
||||
/** Wire identity: an npm package name or a runner-owned builtin: identifier. */
|
||||
readonly agentServerPackage: string;
|
||||
readonly agentServerVersion: string;
|
||||
readonly agentRuntimePackage: string | null;
|
||||
@@ -34,8 +35,8 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
grok: {
|
||||
driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION, agent: "grok", agentProfileVersion: 1,
|
||||
agentServerPackage: "@paperclipai/grok-acp", agentServerVersion: "1.0.13",
|
||||
agentRuntimePackage: "@paperclipai/grok-acp", agentRuntimeVersion: "1.0.13",
|
||||
agentServerPackage: "builtin:grok-acp", agentServerVersion: "1",
|
||||
agentRuntimePackage: "native:grok", agentRuntimeVersion: "1.0.13",
|
||||
commandDigest: "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55",
|
||||
qualificationModel: "grok-4.7", reportedModelId: "grok-4.7", permissionPolicy: "interactive",
|
||||
},
|
||||
|
||||
@@ -1370,6 +1370,15 @@ it("derives the ACPX package authority only from the verified dist/cli layout",
|
||||
).toThrow("ACPX sidecar must use the provider package dist/cli layout");
|
||||
});
|
||||
|
||||
it("uses the public server npm package as the authority for vendored sidecars", () => {
|
||||
expect(runnerdLaunchProfileInternals.acpxProviderPackageAuthority(
|
||||
"/clean/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs",
|
||||
)).toEqual({
|
||||
root: "/clean/node_modules/@paperclipai/server",
|
||||
manifest: "/clean/node_modules/@paperclipai/server/package.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a self-rooted pnpm deployment inside its dependency authority", async () => {
|
||||
const deploymentRoot = await mkdtemp(
|
||||
join(tmpdir(), "paperclip-deployed-provider-root-"),
|
||||
@@ -1722,6 +1731,7 @@ it.each([
|
||||
environment: {
|
||||
PATH: "/bin",
|
||||
...credentialEnvironment,
|
||||
PAPERCLIP_ACPX_BUILTIN_ROOT: "/attacker/builtin",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
|
||||
"/attacker/package-root/package.json",
|
||||
@@ -1750,6 +1760,7 @@ it.each([
|
||||
PAPERCLIP_RUN_ID: "run-1",
|
||||
PAPERCLIP_NORMALIZED_SESSION_ID: "session-1",
|
||||
PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json",
|
||||
PAPERCLIP_ACPX_BUILTIN_ROOT: "/verified/provider-pack/dist/providers",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
|
||||
"/verified/provider-pack/package.json",
|
||||
|
||||
@@ -2874,6 +2874,15 @@ function acpxProviderPackageAuthority(
|
||||
manifest: string;
|
||||
} {
|
||||
const cliDirectory = dirname(sidecarScript);
|
||||
// Public server packages vendor runner dist directly, without a nested dist
|
||||
// directory or a separately published runner package.
|
||||
if (basename(sidecarScript) === "acpx-runtime-sidecar.cjs" &&
|
||||
basename(cliDirectory) === "cli" && basename(dirname(cliDirectory)) === "paperclip-runner" &&
|
||||
basename(resolve(cliDirectory, "../..")) === "vendor" &&
|
||||
basename(resolve(cliDirectory, "../../..")) === "dist") {
|
||||
const serverRoot = resolve(cliDirectory, "../../../..");
|
||||
return { root: serverRoot, manifest: resolve(serverRoot, "package.json") };
|
||||
}
|
||||
if (
|
||||
basename(sidecarScript) !== "acpx-runtime-sidecar.cjs" ||
|
||||
basename(cliDirectory) !== "cli" ||
|
||||
@@ -3144,6 +3153,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
||||
// The verified sidecar bundle cannot use import.meta.url while Node
|
||||
// executes it through /proc/self/fd. Anchor its closed provider package
|
||||
// lookups at the package that owns the already-authenticated bundle.
|
||||
PAPERCLIP_ACPX_BUILTIN_ROOT: resolve(dirname(sidecarPath), "../providers"),
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: providerPackageAuthority.root,
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST:
|
||||
providerPackageAuthority.manifest,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from '../grok-public-install-sandbox.mjs';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []);
|
||||
|
||||
test('lifecycle execution has no network, host credentials, checkout, or elevated privileges', () => {
|
||||
const args = grokConsumerDockerArgs({ ...paths, command: GROK_PUBLIC_INSTALL_LIFECYCLE });
|
||||
assert.deepEqual(values(args, '--network'), ['none']);
|
||||
assert.deepEqual(values(args, '--user'), ['1001:1001']);
|
||||
assert.ok(args.includes('--read-only'));
|
||||
assert.deepEqual(values(args, '--cap-drop'), ['ALL']);
|
||||
assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']);
|
||||
assert.deepEqual(values(args, '--mount'), [
|
||||
'type=bind,src=/private/staging/assets,dst=/packages,readonly',
|
||||
'type=bind,src=/private/staging/consumer,dst=/consumer',
|
||||
'type=bind,src=/private/staging/cache,dst=/cache',
|
||||
]);
|
||||
assert.deepEqual(values(args, '--env'), ['HOME=/tmp', 'npm_config_cache=/cache', 'npm_config_nodedir=/usr/local', 'npm_config_audit=false', 'npm_config_fund=false', 'npm_config_ignore_scripts=false']);
|
||||
assert.match(GROK_PUBLIC_INSTALL_IMAGE, /@sha256:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('deferred lifecycle execution rebuilds the installed graph without dependency resolution', () => {
|
||||
assert.deepEqual(GROK_PUBLIC_INSTALL_LIFECYCLE, ['npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts']);
|
||||
});
|
||||
|
||||
test('a root or malformed host identity cannot run lifecycle scripts', () => {
|
||||
for (const uid of [0, -1, undefined, '1001']) {
|
||||
assert.throws(() => grokConsumerDockerArgs({ ...paths, uid, command: ['npm', 'ci'] }), /unprivileged/);
|
||||
}
|
||||
});
|
||||
|
||||
test('only the scripts-disabled dependency download gets network access', () => {
|
||||
const args = grokConsumerDockerArgs({ ...paths, download: true, command: ['npm', 'install', '--ignore-scripts'] });
|
||||
assert.deepEqual(values(args, '--network'), ['bridge']);
|
||||
assert.ok(values(args, '--env').includes('npm_config_ignore_scripts=true'));
|
||||
});
|
||||
|
||||
test('the separately provisioned executable is exposed read-only to the offline probe', () => {
|
||||
const prerequisite = '/private/staging/native/grok';
|
||||
const args = grokConsumerDockerArgs({ ...paths, prerequisite, command: ['node', '/packages/probe.mjs', 'present'] });
|
||||
assert.deepEqual(values(args, '--network'), ['none']);
|
||||
assert.equal(values(args, '--mount').at(-1), `type=bind,src=${prerequisite},dst=/opt/paperclip/providers/grok/1.0.13/grok,readonly`);
|
||||
});
|
||||
|
||||
test('verification never elevates PR-controlled provisioning or cleanup on the host', () => {
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /\bsudo\b/);
|
||||
assert.ok(source.includes("const prerequisite = join(root, 'native/grok')"));
|
||||
});
|
||||
@@ -436,8 +436,8 @@ test("Runner eval workflows pin actions and gate paid live execution", () => {
|
||||
test("direct Grok qualification installs the pinned binary and scopes the selected credential", () => {
|
||||
const workflow = readWorkflow("runner-protocol-live-evals.yml");
|
||||
assert.ok(workflow.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}"));
|
||||
assert.ok(workflow.includes("if [ -f packages/grok-acp/install.mjs ]; then"));
|
||||
assert.ok(workflow.indexOf("node packages/grok-acp/install.mjs") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod"));
|
||||
assert.ok(workflow.includes("if [ -f packages/paperclip-runner/scripts/provision-grok.mjs ]; then"));
|
||||
assert.ok(workflow.indexOf("sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod"));
|
||||
assert.ok(workflow.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}"));
|
||||
assert.equal((workflow.match(/secrets\.GROK_AUTH_JSON/gu) ?? []).length, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Keep public-package lifecycle code off the verification host. Resolve and
|
||||
// cache the public npm graph without scripts, then execute it offline.
|
||||
export const GROK_PUBLIC_INSTALL_IMAGE =
|
||||
'node:24-trixie@sha256:be40f6a87b9b22215ddb20da0a2320a5c6d583fe3ee3b0024d9fa4f05b40c8fd';
|
||||
// Complete the scripts-disabled install without resolving the graph again.
|
||||
export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
|
||||
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
|
||||
];
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite }) {
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
}
|
||||
return [
|
||||
'run', '--rm', '--platform', 'linux/amd64',
|
||||
'--user', `${uid}:${gid}`, '--read-only',
|
||||
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--pids-limit', '256', '--memory', '3g',
|
||||
'--network', download ? 'bridge' : 'none',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,size=256m,mode=1777',
|
||||
'--env', 'HOME=/tmp', '--env', 'npm_config_cache=/cache',
|
||||
'--env', 'npm_config_nodedir=/usr/local',
|
||||
'--env', 'npm_config_audit=false', '--env', 'npm_config_fund=false',
|
||||
'--env', `npm_config_ignore_scripts=${download ? 'true' : 'false'}`,
|
||||
'--mount', `type=bind,src=${assets},dst=/packages,readonly`,
|
||||
'--mount', `type=bind,src=${consumer},dst=/consumer`,
|
||||
'--mount', `type=bind,src=${cache},dst=/cache`,
|
||||
...(prerequisite ? ['--mount', `type=bind,src=${prerequisite},dst=/opt/paperclip/providers/grok/1.0.13/grok,readonly`] : []),
|
||||
'--workdir', '/consumer', GROK_PUBLIC_INSTALL_IMAGE, ...command,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
// Run on a disposable Linux verification host after pnpm build. No publication,
|
||||
// credentials, inference, or changes to release versions. Native provisioning is
|
||||
// explicit and separate from npm installation, and is removed in finally.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host');
|
||||
const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-'));
|
||||
const prerequisite = join(root, 'native/grok');
|
||||
const env = { ...process.env, NODE_PATH: '', PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' };
|
||||
const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 });
|
||||
const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim();
|
||||
const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`;
|
||||
try {
|
||||
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');
|
||||
// Match release.sh's unified versioning in temporary staging directories.
|
||||
// Source manifests remain untouched, including independently versioned SDKs.
|
||||
run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo);
|
||||
run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo);
|
||||
const tarballs = [];
|
||||
for (const [index, name] of [...needed].entries()) {
|
||||
const { dir, manifest } = packages.get(name);
|
||||
const target = join(root, `package-${index}`); mkdirSync(target);
|
||||
const stagedSource = join(root, `source-${index}`); mkdirSync(stagedSource);
|
||||
for (const file of manifest.files ?? ['dist']) {
|
||||
// release.sh stages runtime skills into these public packages before pack.
|
||||
const releaseSkills = file === 'skills' && ['server', 'packages/adapters/claude-local', 'packages/adapters/codex-local'].includes(dir);
|
||||
// Other adapters declare an optional skills directory that npm pack omits
|
||||
// when absent. Do not fabricate extra release payloads for those adapters.
|
||||
if (file === 'skills' && !releaseSkills && !existsSync(join(repo, dir, file))) continue;
|
||||
cpSync(releaseSkills ? join(repo, 'skills') : join(repo, dir, file), join(stagedSource, file), { recursive: true });
|
||||
}
|
||||
const releaseManifest = { ...manifest, version: releaseVersion };
|
||||
writeFileSync(join(stagedSource, 'package.json'), JSON.stringify(releaseManifest));
|
||||
if ((manifest.bundleDependencies ?? manifest.bundledDependencies ?? []).length) {
|
||||
prepareBundledPackage(stagedSource, target);
|
||||
} else {
|
||||
cpSync(stagedSource, target, { recursive: true });
|
||||
writeFileSync(join(target, 'package.json'), JSON.stringify(materializePublishManifest(releaseManifest)));
|
||||
}
|
||||
run('npm', ['pack', '--ignore-scripts', '--pack-destination', root], target);
|
||||
const packed = readdirSync(root).filter(f => f.endsWith('.tgz') && !tarballs.includes(join(root, f)));
|
||||
assert.equal(packed.length, 1); tarballs.push(join(root, packed[0]));
|
||||
}
|
||||
const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 });
|
||||
const consumer = join(root, 'consumer'); mkdirSync(consumer);
|
||||
const cache = join(root, 'cache'); mkdirSync(cache);
|
||||
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);
|
||||
}
|
||||
// Prove lifecycle execution is real even if npm changes its script defaults.
|
||||
const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource);
|
||||
writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({
|
||||
name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0', private: true,
|
||||
scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' },
|
||||
}));
|
||||
run('npm', ['pack', '--ignore-scripts', '--pack-destination', assets], sentinelSource);
|
||||
const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran');
|
||||
const consumerUid = process.getuid();
|
||||
const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache, uid: consumerUid, gid: process.getgid(), command, ...options }));
|
||||
// npm resolution is intentionally the public consumer graph, not the pnpm
|
||||
// workspace graph. Freeze that result before any lifecycle script can run.
|
||||
isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { download: true });
|
||||
assert.equal(existsSync(sentinel), false, 'Dependency download must not run lifecycle scripts');
|
||||
const consumerLock = readFileSync(join(consumer, 'package-lock.json'), 'utf8');
|
||||
// npm ci rejects bundled optional platform dependencies absent from its own
|
||||
// generated lock. Rebuild runs the deferred install hooks on the installed
|
||||
// graph without re-resolving it; network isolation and lock checks still hold.
|
||||
isolated(GROK_PUBLIC_INSTALL_LIFECYCLE);
|
||||
assert.equal(readFileSync(sentinel, 'utf8'), 'ok', 'Offline lifecycle scripts must actually execute');
|
||||
assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), consumerLock, 'Lifecycle execution must preserve the resolved consumer lock');
|
||||
for (const name of needed) {
|
||||
const installedManifest = JSON.parse(readFileSync(join(consumer, 'node_modules', name, 'package.json'), 'utf8'));
|
||||
assert.equal(installedManifest.version, releaseVersion, `Installed release version for ${name}`);
|
||||
}
|
||||
assert.equal(existsSync(prerequisite), false, 'npm must not provision Grok');
|
||||
const server = join(consumer, 'node_modules/@paperclipai/server');
|
||||
const installed = join(server, 'dist/vendor/paperclip-runner');
|
||||
assert.ok(existsSync(join(installed, 'providers/grok/launcher.cjs')));
|
||||
assert.equal(existsSync(join(consumer, 'node_modules/@paperclipai/grok-acp')), false);
|
||||
assert.equal(existsSync(join(installed, 'providers/grok/bin')), false);
|
||||
// Use real installed compiled code and its actual npm dependency graph. A
|
||||
// separate process prevents module resolution from borrowing this checkout.
|
||||
const probe = `
|
||||
import assert from 'node:assert/strict';
|
||||
import { verifyQualifiedAcpxInstallation } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/installation-integrity.js';
|
||||
import { resolveQualifiedAcpxProfile } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js';
|
||||
const profile = resolveQualifiedAcpxProfile('grok', 'grok-4.7');
|
||||
const inspect = () => verifyQualifiedAcpxInstallation(profile, () => { throw new Error('Grok must not resolve an npm package'); });
|
||||
if (process.argv[2] === 'missing') await assert.rejects(inspect, /prerequisite missing/);
|
||||
else { const installation = await inspect(); assert.equal(installation.agentServerPackageJsonPath, null); assert.equal(installation.agentRuntimePackageJsonPath, null); await (await installation.openCommand()).close(); }
|
||||
`;
|
||||
writeFileSync(join(assets, 'probe.mjs'), probe, { mode: 0o644 });
|
||||
isolated(['node', '/packages/probe.mjs', 'missing']);
|
||||
// Provision as the unprivileged verification user, never into the host's /opt.
|
||||
// Only the positive probe sees this file at the canonical sandbox path.
|
||||
run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]);
|
||||
isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite });
|
||||
console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 }));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -197,6 +197,21 @@ describe.sequential("cli auth routes", () => {
|
||||
expect(res.body.canApprove).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cloud_tenant", "board", false, false, true],
|
||||
["cloud_tenant", "instance_admin_required", false, false, false],
|
||||
["session", "board", false, false, true],
|
||||
["board_api_key", "board", false, true, false],
|
||||
])("reports CLI approval for %s requesting %s", async (source, requestedAccess, isInstanceAdmin, requiresSignIn, canApprove) => {
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
||||
id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess,
|
||||
});
|
||||
const app = await createApp({ type: "board", source, userId: "user-1", isInstanceAdmin });
|
||||
const res = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ requiresSignIn, canApprove });
|
||||
});
|
||||
|
||||
it.each(["PASTE_ID_HERE", "not-a-uuid"])("rejects malformed challenge ID %s before calling the service", async (id) => {
|
||||
const app = await createApp({ type: "board", userId: "user-1", source: "session" });
|
||||
const responses = [
|
||||
|
||||
@@ -29,7 +29,7 @@ const previewWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows",
|
||||
*/
|
||||
function stageBody(source: string, stageName: string): string {
|
||||
const froms = [...source.matchAll(/^FROM .*$/gm)];
|
||||
const startIdx = froms.findIndex((m) => new RegExp(`\\bAS ${stageName}\\b`).test(m[0]));
|
||||
const startIdx = froms.findIndex((m) => new RegExp(`\\bAS ${stageName}\\s*$`).test(m[0]));
|
||||
expect(startIdx, `Dockerfile must declare a '${stageName}' stage`).toBeGreaterThanOrEqual(0);
|
||||
const start = froms[startIdx].index ?? 0;
|
||||
const end = froms[startIdx + 1]?.index ?? source.length;
|
||||
@@ -104,3 +104,23 @@ describe("Docker Rust dependency cache", () => {
|
||||
expect(stageBody(dockerfile, "build")).toContain("FROM runner-build AS build");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Cloud remote provider pack", () => {
|
||||
it("ships a build-owned pack without provisioning Grok on the controller", () => {
|
||||
const pack = stageBody(dockerfile, "cloud-provider-pack");
|
||||
const cloud = stageBody(dockerfile, "cloud");
|
||||
expect(pack).toContain('PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}"');
|
||||
expect(pack).toContain("build-provider-pack.mjs /provider-pack");
|
||||
expect(pack).toContain('if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then');
|
||||
expect(pack).toContain("mkdir -p /provider-pack");
|
||||
expect(pack).toContain("Skipping remote provider pack");
|
||||
expect(cloud).toContain("--from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack");
|
||||
expect(cloud).not.toContain("--chown=node:node --from=cloud-provider-pack");
|
||||
expect(cloud).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack");
|
||||
expect(cloud).toContain("gosu 65534:65534 node");
|
||||
expect(cloud).toContain("Object.values(manifest.payload.artifacts)");
|
||||
expect(cloud).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack");
|
||||
expect(dockerfile).not.toContain("provision-grok.mjs");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2798,7 +2798,7 @@ export function accessRoutes(
|
||||
|
||||
const isSignedInBoardUser =
|
||||
req.actor.type === "board" &&
|
||||
(req.actor.source === "session" || isLocalImplicit(req)) &&
|
||||
(req.actor.source === "session" || req.actor.source === "cloud_tenant" || isLocalImplicit(req)) &&
|
||||
Boolean(req.actor.userId);
|
||||
const canApprove =
|
||||
isSignedInBoardUser &&
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from "./native-runtime/provider-profile.js";
|
||||
|
||||
describe("Paperclip Runner native provider configuration", () => {
|
||||
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
|
||||
"passes Grok's full-auto default or explicit %s policy to the native runner",
|
||||
(acpxPermissionMode) => {
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({
|
||||
backend: "acpx_runtime",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", acpxPermissionMode },
|
||||
})).toEqual({
|
||||
provider: "acpx",
|
||||
acpxAgent: "grok",
|
||||
model: "grok-4.7",
|
||||
acpxPermissionMode: acpxPermissionMode ?? "approve-all",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("qualifies native Codex only in never-ask mode", () => {
|
||||
expect(
|
||||
resolvePaperclipRunnerNativeProviderInput({
|
||||
|
||||
@@ -1041,9 +1041,9 @@ describe("remote provider pack manifest", () => {
|
||||
const lockfile = "lockfileVersion: '9.0'\n";
|
||||
const opencodeCommand = "#!/bin/sh\n";
|
||||
const opencodeExecutable = "opencode-binary\n";
|
||||
const grokExecutable = "grok-binary\n";
|
||||
await mkdir(join(root, "node_modules/@paperclipai/grok-acp/bin"), { recursive: true });
|
||||
await writeFile(join(root, "node_modules/@paperclipai/grok-acp/bin/grok"), grokExecutable);
|
||||
const grokLauncher = "grok-binary\n";
|
||||
await mkdir(join(root, "dist/providers/grok"), { recursive: true });
|
||||
await writeFile(join(root, "dist/providers/grok/launcher.cjs"), grokLauncher);
|
||||
await writeFile(
|
||||
join(root, "dist", "cli", "opencode-app-server-proxy.cjs"),
|
||||
proxy,
|
||||
@@ -1088,7 +1088,7 @@ describe("remote provider pack manifest", () => {
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
},
|
||||
artifacts: {
|
||||
grokExecutable: { path: "node_modules/@paperclipai/grok-acp/bin/grok", sha256: digest(grokExecutable) },
|
||||
grokLauncher: { path: "dist/providers/grok/launcher.cjs", sha256: digest(grokLauncher) },
|
||||
nodeCommand: {
|
||||
path: "node_modules/node/bin/node",
|
||||
sha256: digest(node),
|
||||
@@ -10642,6 +10642,69 @@ describe("runnerd provider runtime wiring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("archives failover evidence with an explicitly replaced provider session", async () => {
|
||||
const remoteCwd = join(isolatedStateDirectory, "remote");
|
||||
const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => {
|
||||
if (command.args?.[0] === "--build-metadata") return {
|
||||
exitCode: 0, timedOut: false, stdout: JSON.stringify({
|
||||
schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd",
|
||||
packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2,
|
||||
durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"],
|
||||
prpTransportModes: ["listen_ws"],
|
||||
}), stderr: "",
|
||||
};
|
||||
if (command.args?.[0] === "--version") return {
|
||||
exitCode: 0, timedOut: false, stdout: "codex-cli 0.156.0", stderr: "",
|
||||
};
|
||||
if (command.args?.[1]?.includes("base64")) return {
|
||||
exitCode: 1, timedOut: false, stdout: "", stderr: "",
|
||||
};
|
||||
return { exitCode: 0, timedOut: false, stdout: "", stderr: "" };
|
||||
});
|
||||
let prepareReplacement!: () => Promise<void>;
|
||||
const replacement = { close: vi.fn(async () => undefined) };
|
||||
const openSession = vi.fn(async () => {
|
||||
await prepareReplacement();
|
||||
return replacement;
|
||||
});
|
||||
state.createBackend.mockReturnValueOnce({ kind: "test", openSession } as never);
|
||||
const backend = await createRunnerdBackend({
|
||||
db: leaseDb(execution), execution, runnerInstanceId: "runner-replacement",
|
||||
runnerIngressAuthorized: true,
|
||||
runnerExecutionTarget: {
|
||||
kind: "remote", transport: "sandbox", remoteCwd, environmentId: "environment",
|
||||
leaseId: "lease-created", providerKey: "daytona", reusableLeaseConfigured: true,
|
||||
effectiveCapabilities: { runnerWebSocketIngress: true },
|
||||
sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "sandbox-created" },
|
||||
runner: { execute: remoteExecute, syncIn: vi.fn(async () => undefined) },
|
||||
} as never,
|
||||
});
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const options = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
|
||||
prepareExternalRunnerState: () => Promise<void>;
|
||||
};
|
||||
prepareReplacement = options.prepareExternalRunnerState;
|
||||
const root = options.stateDirectory!;
|
||||
for (const name of ["current", "previous"]) {
|
||||
await mkdir(join(root, "failover-backups", name), { recursive: true });
|
||||
await writeFile(join(root, "failover-backups", name, "manifest.json"), JSON.stringify({ priorSession: name }));
|
||||
}
|
||||
// Ambiguous ordinary recovery must still fail closed. Only the runtime's
|
||||
// explicitly admitted replacement may retire these prior-session backups.
|
||||
await expect(prepareReplacement()).rejects.toThrow("runner_harness_state_mismatch");
|
||||
await expect(backend.openReplacementSession!({
|
||||
identity: { runId: execution.binding.runId }, workingDirectory: execution.workspace.cwd,
|
||||
} as never, {} as never)).resolves.toBe(replacement);
|
||||
expect(openSession).toHaveBeenCalledOnce();
|
||||
await expect(access(join(root, "failover-backups"))).rejects.toThrow();
|
||||
const archives = await readdir(join(root, "continuity-breaks"));
|
||||
expect(archives).toHaveLength(1);
|
||||
for (const name of ["current", "previous"]) {
|
||||
expect(JSON.parse(await readFile(join(root, "continuity-breaks", archives[0]!, "failover-backups", name, "manifest.json"), "utf8")))
|
||||
.toEqual({ priorSession: name });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["fresh", "existing_state", "symlink_parent", "wrong_identity", "connected", "pending_turn", "remote_probe_failed", "backup_present"])(
|
||||
"bootstraps only an untouched provider session in a resumed workspace lease: %s", async (scenario) => {
|
||||
const remoteCwd = join(isolatedStateDirectory, "remote");
|
||||
|
||||
@@ -9275,7 +9275,7 @@ const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = {
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
} as const;
|
||||
const REMOTE_PROVIDER_PACK_ARTIFACT_PATHS = {
|
||||
grokExecutable: "node_modules/@paperclipai/grok-acp/bin/grok",
|
||||
grokLauncher: "dist/providers/grok/launcher.cjs",
|
||||
nodeCommand: "node_modules/node/bin/node",
|
||||
productionLock: "pnpm-lock.yaml",
|
||||
opencodeCommand: "node_modules/.bin/opencode",
|
||||
@@ -9295,7 +9295,7 @@ type RemoteProviderPackManifest = {
|
||||
bridgeDigest: string;
|
||||
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
|
||||
artifacts: {
|
||||
grokExecutable: { path: string; sha256: string };
|
||||
grokLauncher: { path: string; sha256: string };
|
||||
nodeCommand: { path: string; sha256: string };
|
||||
productionLock: { path: string; sha256: string };
|
||||
opencodeCommand: { path: string; sha256: string };
|
||||
@@ -9394,7 +9394,7 @@ export function readRemoteProviderPackManifest(
|
||||
);
|
||||
}
|
||||
const artifactEntries = [
|
||||
["Grok executable", payload.artifacts?.grokExecutable, REMOTE_PROVIDER_PACK_ARTIFACT_PATHS.grokExecutable],
|
||||
["Grok builtin launcher", payload.artifacts?.grokLauncher, REMOTE_PROVIDER_PACK_ARTIFACT_PATHS.grokLauncher],
|
||||
[
|
||||
"provider Node",
|
||||
payload.artifacts?.nodeCommand,
|
||||
@@ -10854,14 +10854,14 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
"if(canonical(manifest)!==expected)throw new Error('manifest mismatch')",
|
||||
"const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')",
|
||||
"const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}",
|
||||
"for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokExecutable']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
|
||||
"for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
|
||||
"if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
|
||||
"const version=process.versions.node.split('.').map(Number)",
|
||||
"const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
|
||||
"if(version[0]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')",
|
||||
"if(process.platform!==manifest.payload.target.platform||process.arch!==manifest.payload.target.architecture)throw new Error('provider pack target mismatch')",
|
||||
"const packageVersion=(pkg)=>JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode,'@paperclipai/grok-acp':manifest.payload.pins.grok}",
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
|
||||
"for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
|
||||
].join(";");
|
||||
const verified = await remoteCommandRunner.execute({
|
||||
@@ -12203,6 +12203,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
"codex-home",
|
||||
"opencode",
|
||||
"acpx",
|
||||
// Backups belong to the retired provider session. Leaving them active
|
||||
// makes the fresh replacement look like ambiguous lost harness state.
|
||||
// Keep their evidence inside the same continuity-break archive.
|
||||
"failover-backups",
|
||||
]) {
|
||||
const source = resolve(root, name);
|
||||
if (existsSync(source)) renameSync(source, resolve(archiveRoot, name));
|
||||
|
||||
@@ -27,10 +27,7 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [
|
||||
"packages/paperclip-eval-kernel/package.json",
|
||||
"packages/paperclip-eval-kernel/src",
|
||||
"packages/paperclip-eval-kernel/tsconfig.json",
|
||||
"packages/grok-acp/package.json",
|
||||
"packages/grok-acp/launcher.cjs",
|
||||
"packages/grok-acp/install.mjs",
|
||||
"packages/grok-acp/platforms.json",
|
||||
"packages/paperclip-runner/scripts/provision-grok.mjs",
|
||||
"packages/paperclip-runner/package.json",
|
||||
"packages/paperclip-runner/protocol",
|
||||
"packages/paperclip-runner/runner/Cargo.lock",
|
||||
|
||||
@@ -14,16 +14,20 @@ const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
describe("runner E2E Daytona image contract", () => {
|
||||
it("keeps the qualified native Grok binary separate from the legacy command", async () => {
|
||||
const [dockerfile, packBuilder, nativePackage] = await Promise.all([
|
||||
const [dockerfile, packBuilder, runnerPackage] = await Promise.all([
|
||||
readFile(path.join(repositoryRoot, "docker/daytona-runner/Dockerfile"), "utf8"),
|
||||
readFile(path.join(repositoryRoot, "packages/paperclip-runner/scripts/build-provider-pack.mjs"), "utf8"),
|
||||
readFile(path.join(repositoryRoot, "packages/grok-acp/package.json"), "utf8"),
|
||||
readFile(path.join(repositoryRoot, "packages/paperclip-runner/package.json"), "utf8"),
|
||||
]);
|
||||
expect(dockerfile).toMatch(/@xai-official\/grok@\d+\.\d+\.\d+/);
|
||||
expect(dockerfile).not.toMatch(/for cli in[^;]*\bgrok\b/);
|
||||
expect(packBuilder).not.toMatch(/writePortable\w+Shim\("grok"/);
|
||||
expect(JSON.parse(nativePackage).bin).not.toHaveProperty("grok");
|
||||
expect(packBuilder).toContain('path: "node_modules/@paperclipai/grok-acp/bin/grok"');
|
||||
expect(JSON.parse(runnerPackage).dependencies).not.toHaveProperty("@paperclipai/grok-acp");
|
||||
expect(packBuilder).toContain('path: "dist/providers/grok/launcher.cjs"');
|
||||
expect(dockerfile).toContain("scripts/provision-grok.mjs");
|
||||
expect(dockerfile).toContain("/opt/paperclip/providers/grok/1.0.13/grok");
|
||||
expect(DAYTONA_IMAGE_INPUT_PATHS).toContain("packages/paperclip-runner/scripts/provision-grok.mjs");
|
||||
expect(DAYTONA_IMAGE_INPUT_PATHS).not.toContain("packages/grok-acp/package.json");
|
||||
});
|
||||
|
||||
it("builds runnerd and the provider pack and verifies every required transport", async () => {
|
||||
|
||||
@@ -321,7 +321,7 @@ describe("public repository paid workflow security", () => {
|
||||
expect(grokPreparation).toBeGreaterThan(paidInstall);
|
||||
expect(paidExecution).toBeGreaterThan(grokPreparation);
|
||||
expect(paidJob).toContain("if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-acpx-grok' || matrix.profileId == 'runner-acpx-grok-subscription')");
|
||||
expect(paidJob).toContain("run: node packages/grok-acp/install.mjs");
|
||||
expect(paidJob).toContain("run: sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok");
|
||||
|
||||
const everydayOracleStep = paidJob.slice(
|
||||
everydayOraclePreparation,
|
||||
|
||||
@@ -25,6 +25,18 @@ function renderRunner(config: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
describe("Paperclip Runner Codex configuration", () => {
|
||||
it.each([
|
||||
[undefined, "Full auto (approve all)"],
|
||||
["approve-paperclip", "Automatic Paperclip actions"],
|
||||
["approve-reads", "Allow Paperclip reads"],
|
||||
["deny-all", "Deny all"],
|
||||
])("displays Grok's default or saved permission mode %s", (acpxPermissionMode, label) => {
|
||||
const html = renderRunner({ provider: "acpx", acpxAgent: "grok", acpxPermissionMode });
|
||||
expect(html).toContain('<option value="grok" selected="">Grok Build</option>');
|
||||
expect(html).toContain('aria-label="Permission mode"');
|
||||
expect(html).toContain(label);
|
||||
});
|
||||
|
||||
it("exposes all qualified provider choices", () => {
|
||||
const html = renderRunner({ provider: "codex" });
|
||||
|
||||
|
||||
@@ -1723,6 +1723,42 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers a previous-environment sign-in only after explicit successful cancellation", async () => {
|
||||
const intent = { provider: "xai", method: "subscription", name: "My Grok subscription", ownership: "personal", agentIds: [], allAgents: true } as const;
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValueOnce({
|
||||
sessionId: "previous-login", environmentId: "previous-sandbox", aiConnection: intent,
|
||||
status: "waiting_for_user", prompt: null,
|
||||
}).mockImplementation(noActiveSession);
|
||||
mockAgentsApi.cancelAdapterAuthLogin.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container); roots.push(root);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider>
|
||||
<AdapterLoginPanel companyId="company-1" adapterType="grok_local" environmentId="new-sandbox"
|
||||
aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart />
|
||||
</TooltipProvider></ToastProvider></QueryClientProvider>);
|
||||
});
|
||||
await flushUntil(() => Boolean(findButton(container, "Cancel previous sign-in and retry")));
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
await act(async () => findButton(container, "Cancel previous sign-in and retry")!.click());
|
||||
await flushUntil(() => container.textContent?.includes("Could not cancel") ?? false);
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
let release!: () => void;
|
||||
mockAgentsApi.cancelAdapterAuthLogin.mockImplementationOnce(() => new Promise<void>((resolve) => { release = resolve; }));
|
||||
await act(async () => findButton(container, "Cancel previous sign-in and retry")!.click());
|
||||
await flushReact();
|
||||
expect(findButton(container, "Cancel previous sign-in and retry")!.disabled).toBe(true);
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
await act(async () => release());
|
||||
await flushUntil(() => mockAgentsApi.startAdapterAuthLogin.mock.calls.length === 1);
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenLastCalledWith("company-1", "grok_local", "previous-login");
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "grok_local", { environmentId: "new-sandbox", aiConnection: intent });
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("offers no Cancel in the onboarding chrome", async () => {
|
||||
// The card carried a Cancel beside its instruction, directly above the
|
||||
// step's own Back. Two ways out of one screen is one too many, so the
|
||||
|
||||
@@ -2341,6 +2341,12 @@ export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
|
||||
return <DisplayedCodeLoginPanel {...props} />;
|
||||
}
|
||||
|
||||
class AdapterLoginConflictError extends Error {
|
||||
constructor(readonly sessionId: string) {
|
||||
super("Another sign-in attempt is active. Finish or cancel that attempt before starting a new sign-in.");
|
||||
}
|
||||
}
|
||||
|
||||
function DisplayedCodeLoginPanel({
|
||||
companyId,
|
||||
adapterType,
|
||||
@@ -2415,7 +2421,7 @@ function DisplayedCodeLoginPanel({
|
||||
try {
|
||||
const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
|
||||
if (!active) return null;
|
||||
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
|
||||
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId);
|
||||
return active;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
@@ -2521,6 +2527,20 @@ function DisplayedCodeLoginPanel({
|
||||
// before the session id lands and start a second login the server would
|
||||
// count against the per-owner cap.
|
||||
const autoStartedRef = useRef(false);
|
||||
const cancelConflictingLogin = useMutation({
|
||||
mutationFn: async () => {
|
||||
const conflict = activeSessionQuery.error;
|
||||
if (!(conflict instanceof AdapterLoginConflictError)) return;
|
||||
await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, conflict.sessionId);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
autoStartedRef.current = false;
|
||||
resumeAttemptedRef.current = false;
|
||||
setStartError(null);
|
||||
await activeSessionQuery.refetch();
|
||||
},
|
||||
onError: () => setStartError("Could not cancel the previous sign-in. Retry before starting a new one."),
|
||||
});
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
@@ -2617,9 +2637,15 @@ function DisplayedCodeLoginPanel({
|
||||
mode="displayed_code"
|
||||
>
|
||||
{startError ? (
|
||||
<p role="alert" className="pl-2 text-xs text-destructive">
|
||||
{startError}
|
||||
</p>
|
||||
<div>
|
||||
<p role="alert" className="pl-2 text-xs text-destructive">{startError}</p>
|
||||
{activeSessionQuery.error instanceof AdapterLoginConflictError && (
|
||||
<Button type="button" variant="outline" disabled={cancelConflictingLogin.isPending}
|
||||
onClick={() => cancelConflictingLogin.mutate()}>
|
||||
Cancel previous sign-in and retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : failed ? (
|
||||
<p role="alert" className="pl-2 text-xs text-destructive">
|
||||
{status === "timed_out"
|
||||
|
||||
@@ -132,8 +132,9 @@ it.each([false, undefined])(
|
||||
},
|
||||
);
|
||||
|
||||
it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enabled", async () => {
|
||||
it.each([true, false, undefined])("gates the Cloud native runner on explicit enablement (%s)", async (enableNativeRunner) => {
|
||||
await act(async () => {
|
||||
cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner });
|
||||
cache.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
cloud: { managed: true },
|
||||
@@ -160,8 +161,7 @@ it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enab
|
||||
[...document.querySelectorAll<HTMLInputElement>('input[type="radio"]')].map(
|
||||
(input) => input.value,
|
||||
),
|
||||
).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local"]);
|
||||
expect(document.body.textContent).not.toContain("CLI harness");
|
||||
).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local", ...(enableNativeRunner ? ["paperclip_runner"] : [])]);
|
||||
await act(async () =>
|
||||
document.querySelector<HTMLInputElement>('input[value="grok_local"]')!.click(),
|
||||
);
|
||||
|
||||
@@ -719,6 +719,26 @@ function Setup({
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<Field label="Environment">
|
||||
<select
|
||||
aria-label="Environment"
|
||||
className={controlClass}
|
||||
value={environmentOverride}
|
||||
disabled={busy || forced.forced}
|
||||
onChange={(event) => {
|
||||
setEnvironmentOverride(event.target.value);
|
||||
setConnection(null);
|
||||
resetTest();
|
||||
}}
|
||||
>
|
||||
<option value="">Default: {environmentLabel}</option>
|
||||
{(envs.data ?? []).filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local")).map((env) => (
|
||||
<option key={env.id} value={env.id}>{environmentDisplayLabel(env)}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<AgentProviderConnection
|
||||
key={environmentId ?? "local"}
|
||||
companyId={companyId}
|
||||
@@ -1108,7 +1128,7 @@ function Setup({
|
||||
aria-label="Environment"
|
||||
className={controlClass}
|
||||
value={environmentOverride}
|
||||
disabled={forced.forced || managedOnly}
|
||||
disabled={forced.forced}
|
||||
onChange={(event) => {
|
||||
setEnvironmentOverride(event.target.value);
|
||||
setConnection(null);
|
||||
@@ -1120,7 +1140,7 @@ function Setup({
|
||||
Default: {environmentLabel}
|
||||
</option>
|
||||
{(envs.data ?? [])
|
||||
.filter((env) => env.status === "active")
|
||||
.filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local"))
|
||||
.map((env) => (
|
||||
<option key={env.id} value={env.id}>
|
||||
{environmentDisplayLabel(env)}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function isNewAgentAdapterAllowed(
|
||||
nativeRunnerEnabled,
|
||||
}: { cloud: boolean; nativeRunnerEnabled: boolean },
|
||||
) {
|
||||
if (type === "paperclip_runner") return nativeRunnerEnabled;
|
||||
if (cloud) return CLOUD_ADAPTERS.has(type);
|
||||
return type !== "paperclip_runner" || nativeRunnerEnabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,19 @@ it("does not report a connection when runtime readiness passes but provider auth
|
||||
"claude_hello_probe_failed",
|
||||
]);
|
||||
});
|
||||
it("probes native Grok credentials with the pinned prerequisite in the selected sandbox", async () => {
|
||||
testEnvironment.mockResolvedValueOnce(ready).mockResolvedValueOnce({ ...ready,
|
||||
checks: [{ code: "grok_hello_probe_passed", level: "info", message: "Hello" }],
|
||||
});
|
||||
const result = await testAgentSetup({ ...input, providerAdapter: "grok_local",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-4.7" },
|
||||
});
|
||||
expect(testEnvironment).toHaveBeenLastCalledWith("company-1", "grok_local", {
|
||||
agentId: "agent-1", environmentId: "sandbox-1",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-4.7", engine: "cli", command: "/opt/paperclip/providers/grok/1.0.13/grok" },
|
||||
});
|
||||
expect(result.checks.some((check) => check.code === "grok_hello_probe_passed")).toBe(true);
|
||||
});
|
||||
it("does not repeat a completed model probe", async () => {
|
||||
testEnvironment.mockResolvedValue({
|
||||
...ready,
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function testAgentSetup(input: {
|
||||
(check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
) ||
|
||||
runtime.checks.some((check) => check.code.includes("hello_probe")) ||
|
||||
!["claude_local", "codex_local"].includes(input.providerAdapter)
|
||||
!["claude_local", "codex_local", "grok_local"].includes(input.providerAdapter)
|
||||
)
|
||||
return runtime;
|
||||
const provider = await agentsApi.testEnvironment(
|
||||
@@ -40,7 +40,13 @@ export async function testAgentSetup(input: {
|
||||
input.providerAdapter,
|
||||
{
|
||||
...payload,
|
||||
adapterConfig: { ...input.adapterConfig, engine: "cli" },
|
||||
adapterConfig: {
|
||||
...input.adapterConfig,
|
||||
engine: "cli",
|
||||
...(input.adapterType === "paperclip_runner" && input.providerAdapter === "grok_local"
|
||||
? { command: "/opt/paperclip/providers/grok/1.0.13/grok" }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
const checks = [
|
||||
|
||||
@@ -198,7 +198,33 @@ afterEach(async () => {
|
||||
container.remove();
|
||||
});
|
||||
describe("New agent setup", () => {
|
||||
it("blocks direct runner setup links when the experiment is disabled", async () => {
|
||||
it.each([false, true])("selects the Grok sandbox before connecting (managed-only=%s)", async (managedOnly) => {
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableManagedSandboxOnly: managedOnly });
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Local", status: "active", driver: "local", config: {} },
|
||||
{ id: "grok-sandbox", name: "Grok sandbox", status: "active", driver: "sandbox", config: { provider: "daytona" } },
|
||||
]);
|
||||
secrets.listMyUserSecrets.mockResolvedValue([{
|
||||
definition: { id: "grok-key", companyId: "company-1", key: "XAI_API_KEY", name: "Grok key", status: "active" },
|
||||
secret: { companyId: "company-1", status: "active" },
|
||||
}]);
|
||||
await render("paperclip_runner", "grok");
|
||||
const select = container.querySelector('select[aria-label="Environment"]') as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(false);
|
||||
expect([...select.options].some((option) => option.value === "local-1")).toBe(!managedOnly);
|
||||
await act(async () => {
|
||||
select.value = "grok-sandbox";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
await click("GrokAPI");
|
||||
await click("Use saved API key");
|
||||
expect(api.testEnvironment).toHaveBeenCalledWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: "grok-sandbox" }));
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");
|
||||
});
|
||||
it.each([false, true])("blocks direct runner setup links when the experiment is disabled (cloud=%s)", async (cloud) => {
|
||||
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } });
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
await render("paperclip_runner");
|
||||
expect(container.textContent).toContain("This adapter is unavailable");
|
||||
@@ -213,7 +239,10 @@ describe("New agent setup", () => {
|
||||
expect(container.textContent).toContain("This adapter is unavailable");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["subscription", "api_key"])("configures Grok on Cloud with an xAI %s connection", async (method) => {
|
||||
it.each([
|
||||
["grok_local", "subscription"], ["grok_local", "api_key"],
|
||||
["paperclip_runner", "subscription"], ["paperclip_runner", "api_key"],
|
||||
])("configures %s Grok on Cloud with an xAI %s connection", async (adapterType, method) => {
|
||||
cache.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
cloud: { managed: true },
|
||||
@@ -225,10 +254,10 @@ describe("New agent setup", () => {
|
||||
sandboxProviders: { daytona: { supportsLoginPty: true } },
|
||||
});
|
||||
settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
|
||||
settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
|
||||
settings.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true, enableNativeRunner: true });
|
||||
api.getAdapterAuthSignal.mockResolvedValue({ status: "missing" });
|
||||
api.testEnvironment.mockResolvedValue({ ...pass, adapterType: "grok_local" });
|
||||
await render("grok_local");
|
||||
api.testEnvironment.mockResolvedValue({ ...pass, adapterType });
|
||||
await render(adapterType, "grok");
|
||||
expect(container.textContent).toContain("Connect Atlas to Grok");
|
||||
if (method === "subscription") {
|
||||
await click("GrokSubscription");
|
||||
@@ -242,21 +271,22 @@ describe("New agent setup", () => {
|
||||
provider: "xai", method: "api_key", apiKey: "example-test-secret",
|
||||
}));
|
||||
}
|
||||
await fill("Model", "grok-code-fast-1");
|
||||
const model = adapterType === "paperclip_runner" ? "grok-4.7" : "grok-code-fast-1";
|
||||
await fill("Model", model);
|
||||
await click("Run test");
|
||||
const binding = { provider: "xai", method, mode: "responsible_user" };
|
||||
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", "grok_local", expect.objectContaining({
|
||||
expect(api.testEnvironment).toHaveBeenLastCalledWith("company-1", adapterType, expect.objectContaining({
|
||||
environmentId: "sandbox-1",
|
||||
adapterConfig: expect.objectContaining({ model: "grok-code-fast-1" }),
|
||||
adapterConfig: expect.objectContaining({ model, ...(adapterType === "paperclip_runner" ? { provider: "acpx", acpxAgent: "grok", acpxPermissionMode: "approve-all" } : {}) }),
|
||||
aiConnection: binding,
|
||||
testCredentials: {},
|
||||
}));
|
||||
await click("Finish setup");
|
||||
expect(api.hire).toHaveBeenCalledTimes(1);
|
||||
expect(api.hire.mock.calls[0][1]).toMatchObject({
|
||||
adapterType: "grok_local",
|
||||
adapterType,
|
||||
defaultEnvironmentId: "sandbox-1",
|
||||
adapterConfig: { model: "grok-code-fast-1" },
|
||||
adapterConfig: { model, ...(adapterType === "paperclip_runner" ? { provider: "acpx", acpxAgent: "grok", acpxPermissionMode: "approve-all" } : {}) },
|
||||
runtimeConfig: { aiConnection: binding, heartbeat: { enabled: false } },
|
||||
});
|
||||
expect(JSON.stringify(api.testEnvironment.mock.calls)).not.toContain("example-test-secret");
|
||||
|
||||
Reference in New Issue
Block a user