mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 16:53:02 +08:00
Compare commits
1 Commits
codex/nati
...
fix/codeql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aaa9a2e5c |
@@ -41,11 +41,9 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
recommended replacement can shift as plugin ownership, externalization, and
|
||||
config footprint move, so do not blindly copy stale replacement annotations
|
||||
into release notes.
|
||||
- Do not delete or rewrite beta tags after their matching npm package has been
|
||||
published. If a pushed beta tag fails preflight before npm publish, delete and
|
||||
recreate the tag and prerelease at the fixed commit so npm prerelease versions
|
||||
stay contiguous. If a published beta needs a fix, commit the fix on the
|
||||
release branch and increment to the next `-beta.N`.
|
||||
- Do not delete or rewrite beta tags after they leave the machine. If a
|
||||
published or pushed beta needs a fix, commit the fix on the release branch and
|
||||
increment to the next `-beta.N`.
|
||||
- For a beta release train, run the fast local preflight first, publish the
|
||||
beta to npm `beta`, then run the expensive published-package roster focused
|
||||
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
|
||||
@@ -369,10 +367,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- Any fix after preflight means a new commit. Delete and recreate the tag and
|
||||
matching GitHub release from the fixed commit, then rerun preflight from
|
||||
scratch before publishing.
|
||||
Exception: never delete or recreate a beta tag whose matching npm package has
|
||||
already been published; increment to the next beta number instead. If only the
|
||||
pushed tag/prerelease exists and npm publish has not happened, recreate that
|
||||
same beta tag at the fixed commit.
|
||||
Exception: never delete or recreate a beta tag that has already been pushed or
|
||||
published; increment to the next beta number instead.
|
||||
- For stable mac releases, generate the signed `appcast.xml` before uploading
|
||||
public release assets so the updater feed cannot lag the published binaries.
|
||||
- Serialize stable appcast-producing runs across tags so two releases do not
|
||||
@@ -565,9 +561,6 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
commit, and rerun all relevant preflights from scratch before continuing.
|
||||
Never reuse old preflight results after the commit changes. For pushed or
|
||||
published beta tags, do not delete/recreate; increment to the next beta tag.
|
||||
For preflight-only failures where npm did not publish the beta version,
|
||||
delete/recreate the same beta tag and prerelease at the fixed commit instead
|
||||
of skipping a prerelease number.
|
||||
20. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
|
||||
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
|
||||
`latest` only when you intentionally want direct stable publish), keep it
|
||||
@@ -580,9 +573,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
important low-risk fixes before starting expensive lanes, or increment to
|
||||
the next beta if the fix must change the already-published package. If any
|
||||
lane fails after the beta package is published, fix, commit/push/pull,
|
||||
increment to the next beta tag, and rerun the affected beta evidence. Once
|
||||
the beta is live, start remote/manual rosters where they
|
||||
lane fails after the beta tag/package is pushed or published, fix,
|
||||
commit/push/pull, increment to the next beta tag, and rerun the affected
|
||||
beta evidence. Once the beta is live, start remote/manual rosters where they
|
||||
can overlap safely, but keep local Docker and Parallels load controlled.
|
||||
Ensure the full expensive roster has passed at least once before
|
||||
stable/latest promotion. The roster includes the manual Actions >
|
||||
|
||||
7
.github/workflows/opengrep-precise-full.yml
vendored
7
.github/workflows/opengrep-precise-full.yml
vendored
@@ -11,9 +11,6 @@ concurrency:
|
||||
group: opengrep-full-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
@@ -25,7 +22,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,7 +50,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
34
.github/workflows/opengrep-precise.yml
vendored
34
.github/workflows/opengrep-precise.yml
vendored
@@ -9,26 +9,11 @@ name: OpenGrep — PR Diff
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- ".github/actions/ensure-base-commit/**"
|
||||
- ".github/workflows/opengrep-precise.yml"
|
||||
- ".github/workflows/opengrep-precise-full.yml"
|
||||
- ".semgrepignore"
|
||||
- "apps/**"
|
||||
- "extensions/**"
|
||||
- "packages/**"
|
||||
- "scripts/**"
|
||||
- "security/opengrep/**"
|
||||
- "src/**"
|
||||
|
||||
concurrency:
|
||||
group: opengrep-pr-diff-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
@@ -36,24 +21,15 @@ permissions:
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan changed paths (precise)
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure PR base commit
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event.pull_request.base.ref }}
|
||||
# `scripts/run-opengrep.sh --changed` diffs base...HEAD.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install opengrep
|
||||
env:
|
||||
@@ -83,7 +59,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --changed --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
4
.github/workflows/package-acceptance.yml
vendored
4
.github/workflows/package-acceptance.yml
vendored
@@ -354,10 +354,10 @@ jobs:
|
||||
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
|
||||
;;
|
||||
package)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor bundled-channel-deps-compat plugins-offline plugin-update"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps-compat plugins-offline plugin-update"
|
||||
;;
|
||||
product)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor bundled-channel-deps-compat plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch bundled-channel-deps-compat plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
include_openwebui=true
|
||||
;;
|
||||
full)
|
||||
|
||||
293
.github/workflows/stale.yml
vendored
293
.github/workflows/stale.yml
vendored
@@ -4,32 +4,6 @@ on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backfill_stale_closures:
|
||||
description: "Close currently stale-eligible issues and PRs with the Barnacle app"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: "List matching stale-eligible items without closing them"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
include_issues:
|
||||
description: "Include stale-eligible issues in the backfill"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
include_prs:
|
||||
description: "Include stale-eligible pull requests in the backfill"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
max_closures:
|
||||
description: "Maximum items to close when dry_run is false"
|
||||
required: false
|
||||
type: number
|
||||
default: 50
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -38,7 +12,6 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }}
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
@@ -62,10 +35,10 @@ jobs:
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 7
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
@@ -122,7 +95,7 @@ jobs:
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 27
|
||||
days-before-pr-close: 7
|
||||
days-before-pr-close: 3
|
||||
stale-pr-label: stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
@@ -166,10 +139,10 @@ jobs:
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 7
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
@@ -224,7 +197,7 @@ jobs:
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 27
|
||||
days-before-pr-close: 7
|
||||
days-before-pr-close: 3
|
||||
stale-pr-label: stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
@@ -240,253 +213,7 @@ jobs:
|
||||
If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
|
||||
backfill-stale-closures:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_stale_closures == true }}
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill stale closures
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
INCLUDE_ISSUES: ${{ inputs.include_issues }}
|
||||
INCLUDE_PRS: ${{ inputs.include_prs }}
|
||||
MAX_CLOSURES: ${{ inputs.max_closures }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const dryRun = process.env.DRY_RUN !== "false";
|
||||
const includeIssues = process.env.INCLUDE_ISSUES !== "false";
|
||||
const includePrs = process.env.INCLUDE_PRS !== "false";
|
||||
const maxClosures = Math.max(0, Number(process.env.MAX_CLOSURES || "50"));
|
||||
const nowMs = Date.now();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const issueExemptLabels = new Set([
|
||||
"enhancement",
|
||||
"maintainer",
|
||||
"pinned",
|
||||
"security",
|
||||
"no-stale",
|
||||
"bad-barnacle",
|
||||
]);
|
||||
const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]);
|
||||
const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
const maintainerLogins = new Set([
|
||||
"altaywtf",
|
||||
"BunsDev",
|
||||
"cpojer",
|
||||
"gumadeiras",
|
||||
"hydro13",
|
||||
"hxy91819",
|
||||
"jalehman",
|
||||
"joshavant",
|
||||
"joshp123",
|
||||
"mbelinky",
|
||||
"mukhtharcm",
|
||||
"ngutman",
|
||||
"obviyus",
|
||||
"odysseus0",
|
||||
"onutc",
|
||||
"osolmaz",
|
||||
"sebslight",
|
||||
"sliverp",
|
||||
"steipete",
|
||||
"thewilloftheshadow",
|
||||
"tyler6204",
|
||||
"velvet-shark",
|
||||
"vignesh07",
|
||||
"vincentkoc",
|
||||
"visionik",
|
||||
].map(login => login.toLowerCase()));
|
||||
|
||||
const issueCloseMessage = [
|
||||
"Closing due to inactivity.",
|
||||
"If this is still an issue, please retry on the latest OpenClaw release and share updated details.",
|
||||
"If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.",
|
||||
].join("\n");
|
||||
const prCloseMessage = [
|
||||
"Closing due to inactivity.",
|
||||
"If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.",
|
||||
"That channel is the escape hatch for high-quality PRs that get auto-closed.",
|
||||
].join("\n");
|
||||
|
||||
const hasAny = (labels, exemptLabels) => {
|
||||
for (const label of labels) {
|
||||
if (exemptLabels.has(label)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isOlderThan = (dateString, days) => {
|
||||
const timestamp = Date.parse(dateString);
|
||||
return Number.isFinite(timestamp) && timestamp < nowMs - days * dayMs;
|
||||
};
|
||||
|
||||
const candidates = [];
|
||||
const skipped = {
|
||||
missingStale: 0,
|
||||
exemptLabel: 0,
|
||||
maintainerAuthor: 0,
|
||||
maintainerAssignee: 0,
|
||||
notOldEnough: 0,
|
||||
disabledType: 0,
|
||||
};
|
||||
|
||||
for await (const response of github.paginate.iterator(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
sort: "updated",
|
||||
direction: "asc",
|
||||
per_page: 100,
|
||||
})) {
|
||||
for (const item of response.data) {
|
||||
const isPr = Boolean(item.pull_request);
|
||||
if ((isPr && !includePrs) || (!isPr && !includeIssues)) {
|
||||
skipped.disabledType += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const labels = new Set((item.labels || []).map(label => label.name));
|
||||
if (!labels.has("stale")) {
|
||||
skipped.missingStale += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const exemptLabels = isPr ? prExemptLabels : issueExemptLabels;
|
||||
if (hasAny(labels, exemptLabels)) {
|
||||
skipped.exemptLabel += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (maintainerAssociations.has(item.author_association)) {
|
||||
skipped.maintainerAuthor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assigned = (item.assignees || []).length > 0;
|
||||
const assignedToMaintainer = (item.assignees || []).some(assignee =>
|
||||
maintainerLogins.has(assignee.login.toLowerCase()),
|
||||
);
|
||||
if (assignedToMaintainer) {
|
||||
skipped.maintainerAssignee += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let eligible = false;
|
||||
let lane = "";
|
||||
if (isPr && assigned) {
|
||||
lane = "assigned-pr";
|
||||
eligible = isOlderThan(item.created_at, 34) && isOlderThan(item.updated_at, 7);
|
||||
} else if (isPr) {
|
||||
lane = "unassigned-pr";
|
||||
eligible = isOlderThan(item.updated_at, 7);
|
||||
} else if (assigned) {
|
||||
lane = "assigned-issue";
|
||||
eligible = isOlderThan(item.updated_at, 10);
|
||||
} else {
|
||||
lane = "unassigned-issue";
|
||||
eligible = isOlderThan(item.updated_at, 7);
|
||||
}
|
||||
|
||||
if (!eligible) {
|
||||
skipped.notOldEnough += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
lane,
|
||||
isPr,
|
||||
assigned,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
authorAssociation: item.author_association,
|
||||
url: item.html_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const countsByLane = candidates.reduce((counts, candidate) => {
|
||||
counts[candidate.lane] = (counts[candidate.lane] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
const selected = candidates.slice(0, maxClosures);
|
||||
|
||||
core.info(`Dry run: ${dryRun}`);
|
||||
core.info(`Candidates: ${candidates.length}`);
|
||||
core.info(`Selected: ${selected.length}`);
|
||||
core.info(`Counts by lane: ${JSON.stringify(countsByLane)}`);
|
||||
core.info(`Skipped: ${JSON.stringify(skipped)}`);
|
||||
for (const candidate of selected) {
|
||||
core.info(`${dryRun ? "Would close" : "Closing"} ${candidate.lane} #${candidate.number}: ${candidate.title} (${candidate.url})`);
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Stale Closure Backfill")
|
||||
.addRaw(`Dry run: ${dryRun}\n\n`)
|
||||
.addRaw(`Candidates: ${candidates.length}\n\n`)
|
||||
.addRaw(`Selected: ${selected.length}\n\n`)
|
||||
.addCodeBlock(JSON.stringify({ countsByLane, skipped }, null, 2), "json")
|
||||
.addTable([
|
||||
[
|
||||
{ data: "Lane", header: true },
|
||||
{ data: "Number", header: true },
|
||||
{ data: "Title", header: true },
|
||||
{ data: "URL", header: true },
|
||||
],
|
||||
...selected.map(candidate => [
|
||||
candidate.lane,
|
||||
String(candidate.number),
|
||||
candidate.title,
|
||||
candidate.url,
|
||||
]),
|
||||
])
|
||||
.write();
|
||||
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of selected) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: candidate.number,
|
||||
body: candidate.isPr ? prCloseMessage : issueCloseMessage,
|
||||
});
|
||||
|
||||
if (candidate.isPr) {
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: candidate.number,
|
||||
state: "closed",
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: candidate.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lock-closed-issues:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }}
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -2,44 +2,6 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box.
|
||||
- Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.
|
||||
- Codex/macOS: route Computer Use through OpenClaw.app's native node-hosted MCP host, with Gateway loopback proxying and managed backend packaging, so Telegram/Discord Codex agents can use macOS permissions under the OpenClaw app identity instead of Codex owning the local helper process. (#74716) Thanks @pashpashpash.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent.
|
||||
- Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24.
|
||||
- Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt.
|
||||
- Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener.
|
||||
- Agents/Codex: isolate local Codex app-server `CODEX_HOME` and `HOME` per agent and add a deliberate Codex migration path with selectable skill copies, so personal Codex CLI skills, plugins, config, and hooks no longer leak into OpenClaw agents unless the operator migrates them into the workspace. Thanks @pashpashpash.
|
||||
- Plugins/runtime-deps: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi.
|
||||
- Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the `message` tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent.
|
||||
- Browser/gateway: share one browser control runtime across the HTTP control server and `browser.request`, and refresh browser profile config from the source snapshot, so CLI status/start honors configured `browser.executablePath`, `headless`, and `noSandbox` instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon.
|
||||
- Agents/subagents: bound automatic orphan recovery with persisted recovery attempts and a wedged-session tombstone, and teach task maintenance/doctor to reconcile those sessions so restart loops no longer require manual `sessions.json` surgery. Fixes #74864. Thanks @solosage1.
|
||||
- Plugins/runtime-deps: keep bundled provider policy config loading from staging plugin runtime dependencies, so config reads no longer fail on locked-down `/var/lib/openclaw/plugin-runtime-deps` directories. Fixes #74971. Thanks @eurojojo.
|
||||
- Memory/runtime-deps: retain the native `node-llama-cpp` runtime only when local memory search is configured, so packaged installs can repair local embeddings without relying on unreachable global npm installs. Fixes #74777. Thanks @LLagoon3.
|
||||
- Gateway/startup: skip pre-bind web-fetch provider discovery for credential-free `tools.web.fetch` config, so Docker/Kubernetes gateways bind even when optional fetch limits are present. Fixes #74896. Thanks @KoykL.
|
||||
- Signal: match group allowlists against inbound Signal group ids as well as sender ids, and process explicitly configured Signal groups without requiring mentions unless `requireMention` is set. Fixes #53308. Thanks @minupla and @juan-flores077.
|
||||
- Signal: bound `signal-cli` installer release and archive downloads with explicit timeouts, declared and streamed size checks, and partial-file cleanup. Fixes #54153. Thanks @jinduwang1001-max and @juan-flores077.
|
||||
- Slack: require bot-authored room messages with `allowBots=true` to come from an explicitly channel-allowlisted bot or from a room where an explicit Slack owner is present, so broad bot relays cannot run unattended. Fixes #59284. Thanks @andrewhong-translucent.
|
||||
- Signal: derive `getAttachment` HTTP response caps from `channels.signal.mediaMaxMb` with base64 headroom, so inbound photos and videos no longer drop behind the 1 MiB RPC default. Fixes #73564. Thanks @heyhudson.
|
||||
- Signal: keep the long-lived receive SSE monitor open while idle instead of applying the 10s RPC/check deadline, so `signal-cli` 0.14.3 event streams no longer reconnect before inbound messages arrive. Fixes #74741. Thanks @fgabelmannjr and @k7n4n5t3w4rt.
|
||||
- CLI/progress: suppress nested progress spinners and line clears while TUI input owns raw stdin, so Crestodian `/status` no longer disturbs the active input row. (#75003) Thanks @velvet-shark.
|
||||
- Models/OpenAI Codex: restore `openai-codex/gpt-5.4-mini` for ChatGPT/Codex OAuth PI runs after live OAuth proof, and align the manifest, forward-compat metadata, docs, and regression tests so stale cron and heartbeat configs resolve again. Fixes #74451. Thanks @0xCyda, @hclsys, and @Marvae.
|
||||
- Plugins/runtime-deps: always write a dependency map in generated runtime-deps install manifests, so npm does not crash or prune staged bundled-plugin packages when the plan is empty. Fixes #74949. Thanks @hclsys.
|
||||
- Telegram: use durable message edits for streaming previews instead of native draft state, so generated replies no longer flicker through draft-to-message transitions that look like duplicates. (#75073) Thanks @obviyus.
|
||||
- Telegram: echo preflighted DM voice-note transcripts back to the originating chat, including Telegram DM topic thread metadata, instead of only echoing later media-understanding transcripts. Fixes #75084. Thanks @M-Lietz.
|
||||
- Telegram: clamp low long-polling client timeouts so configured `timeoutSeconds` values below the `getUpdates` poll window no longer force a fresh HTTPS connection every few seconds. Fixes #75114. Thanks @hpinho77.
|
||||
- Web search: describe `web_search` as using the configured provider instead of hard-coding Brave when DuckDuckGo or another provider is active. Fixes #75088. Thanks @sun-rongyang.
|
||||
- Infra/tmp: tolerate concurrent temp-dir permission repairs by rechecking directories that another process already tightened, so parallel ACP subprocess startup no longer throws `Unsafe fallback OpenClaw temp dir`. Fixes #66867. Thanks @Kane808-AI and @jarvisz8.
|
||||
- Agents/compaction: add an opt-in `agents.defaults.compaction.midTurnPrecheck` mid-turn precheck that detects tool-loop context pressure and triggers compaction before the next tool call instead of waiting for end-of-turn. (#73499) Thanks @marchpure and @haoxingjun.
|
||||
- Gateway/approvals: let loopback token/password-backed native approval clients resolve exec approvals without attaching stale paired Gateway identities, while remote and unauthenticated approval clients keep normal device identity behavior. (#74472)
|
||||
|
||||
## 2026.4.29
|
||||
|
||||
### Highlights
|
||||
@@ -53,7 +15,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Security/tools: configured tool sections (`tools.exec`, `tools.fs`) no longer implicitly widen restrictive profiles (`messaging`, `minimal`). Users who need those tools under a restricted profile must add explicit `alsoAllow` entries; a startup warning identifies affected configs. Fixes #47487. Thanks @amknight.
|
||||
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
|
||||
- Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc.
|
||||
- Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc.
|
||||
@@ -73,24 +34,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/diagnostics: emit an opt-in startup diagnostics timeline that records gateway lifecycle and plugin-load phases behind a config flag, so slow-start diagnosis no longer requires bespoke instrumentation. Thanks @shakkernerd.
|
||||
- Control UI/i18n: extend the locale registry with new Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), and Thai (th) entries and ship `fa`, `nl`, `vi`, and `zh-TW` docs glossaries, so the docs translation pipeline and the Control UI language picker stay aligned across surfaces. Thanks @vincentkoc.
|
||||
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
|
||||
- Channels/Yuanbao: update plugin GitHub location to YuanbaoTeam/yuanbao-openclaw-plugin and add "yuanbao" alias to channel catalog. (#74253) Thanks @loongfay.
|
||||
- Docker setup: add `OPENCLAW_SKIP_ONBOARDING` so automated Docker installs can skip the interactive onboarding step while still applying gateway defaults. (#55518) Thanks @jinjimz.
|
||||
- Security policy: classify media/base64 decode and format-conversion overhead after configured acceptance limits as performance-only for GHSA triage unless a report demonstrates a limit bypass, crash, exhaustion, data exposure, or another boundary bypass. (#74311)
|
||||
- Security/OpenGrep: add a precise OpenGrep rulepack, source-rule compiler, provenance metadata check, and PR/full scan workflows that validate first-party code and rulepack-only changes while uploading SARIF to GitHub Code Scanning. (#69483) Thanks @jesse-merhi.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/outbound: strip re-formed HTML tags during plain-text sanitization so nested tag fragments cannot leave a CodeQL-detected `<script>` sequence behind. Thanks @vincentkoc.
|
||||
- Security/secrets: compare credential bytes with padded timing-safe buffers instead of hashing candidate passwords before equality checks. Thanks @vincentkoc.
|
||||
- Security/QQBot: sanitize debug log arguments before writing to `console.*`, so gateway payload fields cannot forge extra log lines when debug logging is enabled. Thanks @vincentkoc.
|
||||
- QQBot: unify slash command auth and c2cOnly gating in the command registry, pass `allowQQBotDataDownloads` when sending slash command file attachments, align clear-storage with actual downloads directory, and add `/bot-me` to display sender user ID. (#73616) Thanks @cxyhhhhh.
|
||||
- CLI/agents/status: keep `openclaw agents`, text `agents list`, and plain text `status` on read-only metadata paths so human output no longer preloads plugin runtimes or live channel scans before printing. Fixes #74195. Thanks @NianJiuZst.
|
||||
- Agents/local models: derive context-window guard thresholds from the effective model window with 4k/8k safety floors, so small local models are no longer rejected by fixed 16k/32k preflight cutoffs. Fixes #42999. Thanks @chengjialu8888.
|
||||
- PDF extraction: resolve PDF.js standard fonts from the installed package root and pass a filesystem path to the Node fallback extractor, so built-in font PDFs render without `file://` URL lookup failures. Fixes #51455; carries forward #70936, #54447, and #62175. Thanks @anyech, @JuanRdBO, and @solomonneas.
|
||||
- Media: treat legacy Word/OLE attachments with `application/msword` or `application/x-cfb` MIME as binary so printable-looking `.doc` files are not embedded into prompts as text. Fixes #54176; carries forward #54380. Thanks @andyliu.
|
||||
- Config: accept documented `browser.tabCleanup` keys in strict root config validation, so configured tab cleanup no longer fails before runtime reads it. Fixes #74577. Thanks @lonexreb and @ezdlp.
|
||||
- Cron: validate disabled job schedule edits before persisting updates, so invalid cron changes no longer partially mutate stored jobs. Fixes #74459. Thanks @yfge.
|
||||
- CLI/cron: warn when `openclaw cron add --message` omits a nonblank `--agent`, including blank agent values and session-key jobs, so scheduled agent-turn jobs make default-agent fallback explicit while system events stay quiet. Fixes #42196; carries forward #42245. Thanks @ethanclaw.
|
||||
- Channels/status: keep Telegram, Slack, and Google Chat read-only allowlist/default-target accessors on config-only paths, so status and channel summaries do not resolve SecretRef-backed runtime credentials. Thanks @eusine.
|
||||
- Active Memory: clarify the deprecated `modelFallbackPolicy` warning and config help so `modelFallback` is described as a chain-resolution last resort, not runtime failover. (#74602) Thanks @jeffrey701.
|
||||
- Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine.
|
||||
@@ -101,7 +54,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu: skip empty-text messages (e.g. `{"text":""}`) that carry no media, so no blank user turn is written to the session and downstream LLM providers cannot reject the request with "messages must not be empty". (#74634) Thanks @xdengli and @hclsys.
|
||||
- Feishu/Bitable: clean up newly created placeholder rows whose fields contain only default empty values while preserving meaningful link, attachment, user, number, boolean, and location values during create-app cleanup. (#73920) Carries forward #40602. Thanks @boat2moon.
|
||||
- macOS app: keep attach-only mode and the Debug Settings launchd toggle marker-only, so launching with `--attach-only`/`--no-launchd` no longer uninstalls the Gateway LaunchAgent or drops active sessions. (#72174) Thanks @DolencLuka.
|
||||
- macOS Canvas: stop auto-reloading the current A2UI host during push/eval/snapshot flows, so pushed A2UI content remains visible instead of returning to the empty Canvas shell. Fixes #73337. Thanks @Gr4via.
|
||||
- Plugin SDK: restore the deprecated `plugin-sdk/zalouser` command-auth facade so published Lark/Zalo plugins that import it load on current hosts. Fixes #74702. Thanks @Goron01.
|
||||
- Plugins/runtime-deps: include bundled provider plugins when `models.providers`, auth profiles, agent defaults, or subagent model refs configure that provider, while keeping inactive default-enabled provider plugins out of doctor repair. Refs #74307. Thanks @Skeptomenos.
|
||||
- Plugins/runtime: resolve relative plugin `api.resolvePath` inputs against the plugin root instead of the host working directory, while keeping absolute and home paths user-resolved. Fixes #74718. Thanks @jimdawdy-hub.
|
||||
@@ -121,7 +73,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/models: serve the last successful model catalog while stale reloads refresh in the background, so Gateway control-plane and OpenAI-compatible requests no longer block behind model-provider rediscovery after model config changes. Refs #74135, #74630, and #74633. Thanks @DerFlash, @moltar-bot, and @Saboor711.
|
||||
- CLI/status: resolve read-only channel setup runtime fallback from the packaged OpenClaw dist root, so `status --all`, `status --deep`, channel, and doctor paths do not crash when an external channel plugin needs setup metadata. Fixes #74693. Thanks @giangthb.
|
||||
- SDK/events: keep per-run SDK event streams from surfacing duplicate raw chat projection frames, while normalizing chat-only projection frames and preserving raw access through `rawEvents`. Refs #74704. Thanks @BunsDev.
|
||||
- SDK: report Gateway terminal `agent.wait` timeout snapshots with lifecycle metadata as `timed_out` while keeping bare wait deadlines non-terminal. Thanks @clawsweeper.
|
||||
- Google Meet: block managed Chrome intro/test speech until browser health proves the participant is in-call, and expose `speechReady` diagnostics so login, admission, permission, and audio-bridge blockers no longer look like successful speech. Refs #72478. Thanks @DougButdorf.
|
||||
- Slack/commands: keep native command argument menus on select controls for encoded choice values up to Slack's option limit and truncate fallback button labels to Slack's button-text limit, so long valid choices no longer render invalid Slack blocks. Thanks @slackapi.
|
||||
- Agents/Codex: flush accepted debounced steering messages before normal app-server turn cleanup, so inbound follow-ups acknowledged as queued are not dropped when the turn completes before the debounce fires. Thanks @vincentkoc.
|
||||
@@ -148,7 +99,6 @@ Docs: https://docs.openclaw.ai
|
||||
- ACP/resolver: fall through to thread-bound session resolution when an explicit `--session` token cannot be resolved while preserving the bad-token diagnostic when no thread binding exists, so Discord slash commands that auto-fill the current thread ID as the positional ACP target no longer return "Unable to resolve session target" errors. Fixes #66299. Thanks @hclsys, @kindomLee, and @martingarramon.
|
||||
- Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79.
|
||||
- Gateway/diagnostics: include stuck-session reason hints and recovery skip causes in warnings, so operators can tell whether a lane is waiting on active work, queued work, or stale bookkeeping. Thanks @vincentkoc.
|
||||
- Providers/DeepSeek: expose native DeepSeek V4 `xhigh` and `max` thinking levels through the provider `resolveThinkingProfile` hook so `/think xhigh|max` applies the intended effort instead of falling back to base levels. (#73008) Thanks @ai-hpc.
|
||||
- Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc.
|
||||
- Heartbeat/exec: consume successful metadata-only async exec completions silently so Telegram and other chat surfaces no longer ask users for missing command logs after `No session found`. Fixes #74595. Thanks @gkoch02.
|
||||
- Web fetch: add a documented `tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` opt-in and thread it through cache keys and DNS/IP checks so trusted fake-IP proxy stacks using `fc00::/7` can work without broad private-network access. Fixes #74351. Thanks @jeffrey701.
|
||||
@@ -159,8 +109,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Sandbox/Docker: tolerate Docker daemon unavailability when sandbox mode is off, so doctor and preflight checks no longer fail on installs that do not run the Docker daemon. Fixes #73671. Thanks @kaseonedge.
|
||||
- Control UI/mobile: persist mobile chat settings through Lit-managed state and route mobile navigation through the same view-state path so chat panel toggles survive transitions on small viewports. Thanks @BunsDev.
|
||||
- Control UI/exports: align sidebar trigger affordances across the resizable divider, mobile layout, and exported-HTML transcript template so the sidebar toggle and exported transcript sidebar render with consistent hit areas and styling. Thanks @BunsDev.
|
||||
- Control UI/chat: disable the page refresh affordance while a chat run is active so accidental refreshes do not abort an in-flight reply. Thanks @Angfr95 and @BunsDev.
|
||||
- Memory/LanceDB: return real memory records from `openclaw ltm list` (with optional `--limit` and createdAt ordering) instead of an empty placeholder, so the CLI surface matches the documented LTM listing contract. (#67952) Thanks @zhangyue19921010.
|
||||
- Control UI/chat: disable the page refresh affordance while a chat run is active so accidental refreshes do not abort an in-flight reply. Thanks @BunsDev.
|
||||
- Media: include redacted per-attempt resize failures and resolved model input capabilities in vision-pipeline errors so ARM64 image failures are diagnosable without closing the remaining routing investigation. Refs #74552. Thanks @1yihui.
|
||||
- Control UI/i18n: route zh-CN agent, debug, channel-refresh, and exec-approval copy through the locale source while preserving the English `Cron Jobs` agent tab label and the security-audit command styling. Carries forward #39692 repair context. Thanks @hepeng154833488 and @vincentkoc.
|
||||
- Auto-reply: honor explicit `silentReply.direct: "allow"` for clean empty or reasoning-only direct chat turns while keeping the default direct-chat empty-response guard conservative. Fixes #74409. Thanks @jesuskannolis.
|
||||
@@ -199,7 +148,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie.
|
||||
- Channels/Discord/Slack: share one DM policy/allowlist resolver across runtime, setup, allowlist editing, and doctor repair, so legacy `dm.policy` / `dm.allowFrom` compatibility migrates to canonical `dmPolicy` / `allowFrom` without divergent access checks. Thanks @Squirbie.
|
||||
- Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev.
|
||||
- Control UI/chat: wire the slash-command autocomplete menu to the composer with stable ARIA relationships so screen readers announce the active command or argument option. Thanks @BunsDev.
|
||||
- Agents/usage: keep PI embedded-run telemetry attributed to the resolved model provider instead of the PI harness label, so OpenRouter and other provider-backed turns report the right provider in session usage and traces. Thanks @vincentkoc.
|
||||
- Agents/attribution: send OpenClaw attribution headers on native OpenAI and Codex traffic, including SDK transports, realtime voice and TTS, device-code auth, WHAM usage, and remote embeddings, so PI-origin defaults no longer leak into provider requests. Thanks @vincentkoc.
|
||||
- Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest.
|
||||
@@ -370,7 +318,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/GitHub Copilot: support the GUI/RPC wizard device-code auth flow so onboarding from non-TTY clients (gateway RPC bridge, GUI wizards) completes instead of returning empty profiles. Dangerous-state handling now distinguishes `access_denied` and `expired_token` from transport errors. (#73290) Thanks @indierawk2k2.
|
||||
- Installer/Linux: warn before switching an unwritable npm global prefix to `~/.npm-global`, then tell users to run future global updates with `npm i -g openclaw@latest` without `sudo` so npm keeps using the redirected user prefix. Fixes #44365; carries forward #50479. Thanks @Sayeem3051.
|
||||
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
|
||||
- macOS app: detect stale Gateway TLS certificate pins, automatically repair trusted Tailscale Serve rotations, and surface paired-but-disconnected Mac companion nodes so partial Gateway connections no longer look healthy. Thanks @guti.
|
||||
|
||||
## 2026.4.27
|
||||
|
||||
@@ -561,7 +508,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev.
|
||||
- CLI/status: show skipped fast-path memory checks as `not checked` and report active custom memory plugin runtime status from `status --json --all` without requiring built-in `agents.defaults.memorySearch`, so plugins such as memory-lancedb-pro and memory-cms no longer look unavailable when their own runtime is healthy. Fixes #56968. Thanks @Tony-ooo and @aderius.
|
||||
- Gateway/channels: record and log unexpected clean channel monitor exits so channels that return without throwing no longer appear stopped with no error. Fixes #73099. Thanks @balaji1968-kingler.
|
||||
- Group/channel chats (all channels): keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
|
||||
- Discord/group chats: keep group/channel replies private by default unless the agent explicitly uses the message tool, so always-on rooms can lurk without leaking automatic final, block, preview, or status-reaction output; `messages.groupChat.visibleReplies: "automatic"` restores legacy auto-posting. (#73046) Thanks @scoootscooob.
|
||||
- Plugins/package: force nested bundled-plugin runtime dependency installs out of inherited npm dry-run mode during prepack and package smoke checks, so packed installs materialize required plugin modules instead of reporting missing bundled files. Refs #73128. Thanks @Adam-Researchh.
|
||||
- Discord: skip reaction events before REST channel fetch when notifications are off, guild reactions are disabled, or allowlist mode cannot match without channel overrides, reducing reconnect bursts that caused slow listener warnings. Fixes #73133. Thanks @isaacsummers.
|
||||
- Channels/Telegram: centralize polling update tracking so accepted offsets remain durable across restarts, same-process handler failures can still retry, and slow offset writes cannot overwrite newer accepted watermarks. Refs #73115. Thanks @vdruts.
|
||||
|
||||
@@ -184,9 +184,7 @@ final class CanvasManager {
|
||||
|
||||
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
|
||||
guard let a2uiUrl else { return }
|
||||
let shouldNavigate = controller.shouldAutoNavigateToA2UI(
|
||||
lastAutoTarget: self.lastAutoA2UIUrl,
|
||||
candidateTarget: a2uiUrl)
|
||||
let shouldNavigate = controller.shouldAutoNavigateToA2UI(lastAutoTarget: self.lastAutoA2UIUrl)
|
||||
guard shouldNavigate else {
|
||||
Self.logger.debug("canvas auto-nav skipped; target unchanged")
|
||||
return
|
||||
|
||||
@@ -319,14 +319,12 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
self.sessionDir.path
|
||||
}
|
||||
|
||||
func shouldAutoNavigateToA2UI(lastAutoTarget: String?, candidateTarget: String) -> Bool {
|
||||
let current = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidate = candidateTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if current.isEmpty || current == "/" { return true }
|
||||
if !candidate.isEmpty, current == candidate { return false }
|
||||
func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool {
|
||||
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "/" { return true }
|
||||
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!lastAuto.isEmpty,
|
||||
current == lastAuto
|
||||
trimmed == lastAuto
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import AppKit
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
/// Menu contents for the OpenClaw menu bar extra.
|
||||
@@ -15,7 +14,6 @@ struct MenuContent: View {
|
||||
private let heartbeatStore = HeartbeatStore.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let nodesStore = NodesStore.shared
|
||||
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
||||
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@@ -46,9 +44,6 @@ struct MenuContent: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.connectionLabel)
|
||||
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
|
||||
if let macNodeStatus = self.macNodeStatus {
|
||||
self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color)
|
||||
}
|
||||
if self.pairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.pairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
@@ -356,31 +351,6 @@ struct MenuContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var macNodeStatus: (label: String, color: Color)? {
|
||||
guard self.state.connectionMode != .unconfigured else { return nil }
|
||||
guard case .connected = self.controlChannel.state else { return nil }
|
||||
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
|
||||
guard entry.isConnected else {
|
||||
return ("Mac capabilities offline", .orange)
|
||||
}
|
||||
let commands = Set(entry.commands ?? [])
|
||||
let missingRequiredCommands = [
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
].filter { !commands.contains($0) }
|
||||
if !missingRequiredCommands.isEmpty {
|
||||
return ("Mac capabilities incomplete", .orange)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
|
||||
return ("Mac capabilities offline", .orange)
|
||||
}
|
||||
|
||||
private var healthStatus: (label: String, color: Color) {
|
||||
if let activity = self.activityStore.current {
|
||||
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||
|
||||
@@ -1156,7 +1156,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [NodeInfo] {
|
||||
let entries = self.nodesStore.nodes.filter { $0.isConnected || $0.isPaired }
|
||||
let entries = self.nodesStore.nodes.filter(\.isConnected)
|
||||
return entries.sorted { lhs, rhs in
|
||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||
@@ -1239,9 +1239,5 @@ extension MenuSessionsInjector {
|
||||
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findNodesInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func testingSortedNodeEntries() -> [NodeInfo] {
|
||||
self.sortedNodeEntries()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,883 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
private let computerUseServerId = "computer-use"
|
||||
private let computerUseRequiredPermissions = [Capability.accessibility.rawValue, Capability.screenRecording.rawValue]
|
||||
private let computerUseEnvCommandKey = "OPENCLAW_COMPUTER_USE_MCP_COMMAND"
|
||||
private let computerUseEnvArgsKey = "OPENCLAW_COMPUTER_USE_MCP_ARGS"
|
||||
private let computerUseEnvPackageDirKey = "OPENCLAW_COMPUTER_USE_MCP_PACKAGE_DIR"
|
||||
private let computerUseEnvInstallDirKey = "OPENCLAW_COMPUTER_USE_MCP_INSTALL_DIR"
|
||||
private let computerUseAppSupportDirName = "CodexComputerUseMCP"
|
||||
private let computerUsePackageDirName = "computer-use"
|
||||
private let computerUseBundledResourcePath = "CodexComputerUseMCP/computer-use"
|
||||
private let computerUseManagedMetadataFileName = ".openclaw-computer-use-source.json"
|
||||
private let computerUsePackageInstallBeginCommand = "mcp.package.install.begin"
|
||||
private let computerUsePackageInstallChunkCommand = "mcp.package.install.chunk"
|
||||
private let computerUsePackageInstallFinishCommand = "mcp.package.install.finish"
|
||||
private let computerUsePackageInstallCancelCommand = "mcp.package.install.cancel"
|
||||
|
||||
struct MacMcpLaunchConfig {
|
||||
var command: URL
|
||||
var args: [String]
|
||||
var cwd: URL?
|
||||
var source: String
|
||||
}
|
||||
|
||||
private struct MacMcpPackageSource {
|
||||
var directory: URL
|
||||
var source: String
|
||||
}
|
||||
|
||||
private struct MacMcpPackageFingerprint: Codable, Equatable {
|
||||
var fileCount: Int
|
||||
var totalSize: UInt64
|
||||
var latestModifiedAt: TimeInterval
|
||||
}
|
||||
|
||||
private struct MacMcpManagedPackageMetadata: Codable, Equatable {
|
||||
var source: String
|
||||
var sourcePath: String
|
||||
var sourceFingerprint: MacMcpPackageFingerprint
|
||||
}
|
||||
|
||||
private struct MacMcpPackageInstallBeginParams: Decodable {
|
||||
var transferId: String
|
||||
var nodeId: String
|
||||
var serverId: String
|
||||
var packageName: String?
|
||||
var sourcePath: String?
|
||||
var fileCount: Int?
|
||||
var totalBytes: UInt64?
|
||||
}
|
||||
|
||||
private struct MacMcpPackageInstallChunkParams: Decodable {
|
||||
var transferId: String
|
||||
var relativePath: String
|
||||
var dataBase64: String
|
||||
var executable: Bool?
|
||||
}
|
||||
|
||||
private struct MacMcpPackageInstallFinishParams: Decodable {
|
||||
var transferId: String
|
||||
}
|
||||
|
||||
private struct MacMcpPackageInstallCancelParams: Decodable {
|
||||
var transferId: String
|
||||
}
|
||||
|
||||
private struct CodexMcpManifest: Decodable {
|
||||
struct Server: Decodable {
|
||||
var command: String
|
||||
var args: [String]?
|
||||
var cwd: String?
|
||||
}
|
||||
|
||||
var mcpServers: [String: Server]
|
||||
}
|
||||
|
||||
private struct MacMcpPackageInstallPayload: Encodable {
|
||||
var ok: Bool
|
||||
var transferId: String
|
||||
var serverId: String?
|
||||
var fileCount: Int?
|
||||
var totalBytes: UInt64?
|
||||
}
|
||||
|
||||
private final class ActiveMacMcpSession: @unchecked Sendable {
|
||||
let sessionId: String
|
||||
let nodeId: String
|
||||
let process: Process
|
||||
let input: Pipe
|
||||
var nextSeq = 0
|
||||
var closeRequested = false
|
||||
|
||||
init(sessionId: String, nodeId: String, process: Process, input: Pipe) {
|
||||
self.sessionId = sessionId
|
||||
self.nodeId = nodeId
|
||||
self.process = process
|
||||
self.input = input
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActiveMacMcpPackageInstall {
|
||||
var transferId: String
|
||||
var nodeId: String
|
||||
var serverId: String
|
||||
var sourcePath: String
|
||||
var expectedFileCount: Int?
|
||||
var expectedTotalBytes: UInt64?
|
||||
var directory: URL
|
||||
var files: Set<String> = []
|
||||
var totalBytes: UInt64 = 0
|
||||
}
|
||||
|
||||
actor MacComputerUseMcpHost {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "mac-mcp")
|
||||
private let appSupportRoot: URL?
|
||||
private var sessions: [String: ActiveMacMcpSession] = [:]
|
||||
private var activeInstall: ActiveMacMcpPackageInstall?
|
||||
|
||||
init(appSupportRoot: URL? = nil) {
|
||||
self.appSupportRoot = appSupportRoot
|
||||
}
|
||||
|
||||
nonisolated static var packageInstallCommands: [String] {
|
||||
[
|
||||
computerUsePackageInstallBeginCommand,
|
||||
computerUsePackageInstallChunkCommand,
|
||||
computerUsePackageInstallFinishCommand,
|
||||
computerUsePackageInstallCancelCommand,
|
||||
]
|
||||
}
|
||||
|
||||
nonisolated static func computerUseDescriptor(permissions: [String: Bool]) -> NodeMcpServerDescriptor {
|
||||
let hasRequiredPermissions = computerUseRequiredPermissions.allSatisfy { permissions[$0] == true }
|
||||
let launch = Self.resolveComputerUseLaunchConfig()
|
||||
let status = if !hasRequiredPermissions {
|
||||
"missing_permissions"
|
||||
} else if launch == nil {
|
||||
"missing_backend"
|
||||
} else {
|
||||
"ready"
|
||||
}
|
||||
var metadata: [String: AnyCodable] = [:]
|
||||
if let launch {
|
||||
metadata["source"] = AnyCodable(launch.source)
|
||||
metadata["command"] = AnyCodable(launch.command.lastPathComponent)
|
||||
}
|
||||
return NodeMcpServerDescriptor(
|
||||
id: computerUseServerId,
|
||||
displayname: "Computer Use",
|
||||
provider: "codex",
|
||||
transport: "stdio",
|
||||
source: launch?.source ?? "codex-bundled",
|
||||
status: status,
|
||||
requiredpermissions: computerUseRequiredPermissions,
|
||||
metadata: metadata.isEmpty ? nil : metadata)
|
||||
}
|
||||
|
||||
func handleInvoke(
|
||||
_ req: BridgeInvokeRequest,
|
||||
permissions: [String: Bool],
|
||||
sendMcpServersUpdate: (@Sendable (String, [NodeMcpServerDescriptor]) async -> Void)? = nil) async
|
||||
-> BridgeInvokeResponse?
|
||||
{
|
||||
do {
|
||||
switch req.command {
|
||||
case computerUsePackageInstallBeginCommand:
|
||||
return try self.handlePackageInstallBegin(req)
|
||||
case computerUsePackageInstallChunkCommand:
|
||||
return try self.handlePackageInstallChunk(req)
|
||||
case computerUsePackageInstallFinishCommand:
|
||||
let nodeId = self.activeInstall?.nodeId
|
||||
let response = try self.handlePackageInstallFinish(req)
|
||||
if response.ok, let nodeId {
|
||||
await sendMcpServersUpdate?(
|
||||
nodeId,
|
||||
[Self.computerUseDescriptor(permissions: permissions)])
|
||||
}
|
||||
return response
|
||||
case computerUsePackageInstallCancelCommand:
|
||||
return try self.handlePackageInstallCancel(req)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
} catch {
|
||||
return Self.errorResponse(req, code: .unavailable, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ event: NodeMcpSessionOpenEvent, gateway: GatewayNodeSession) async {
|
||||
guard event.serverid == computerUseServerId else {
|
||||
await gateway.sendMcpSessionOpenResult(Self.openResult(
|
||||
event: event,
|
||||
ok: false,
|
||||
errorCode: "UNKNOWN_SERVER",
|
||||
message: "unknown MCP server"))
|
||||
return
|
||||
}
|
||||
guard let launch = Self.resolveComputerUseLaunchConfig() else {
|
||||
await gateway.sendMcpSessionOpenResult(Self.openResult(
|
||||
event: event,
|
||||
ok: false,
|
||||
errorCode: "MISSING_BACKEND",
|
||||
message: "Codex Computer Use MCP backend is not installed"))
|
||||
return
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = launch.command
|
||||
process.arguments = launch.args
|
||||
process.currentDirectoryURL = launch.cwd
|
||||
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
process.standardInput = stdin
|
||||
process.standardOutput = stdout
|
||||
process.standardError = stderr
|
||||
|
||||
let active = ActiveMacMcpSession(
|
||||
sessionId: event.sessionid,
|
||||
nodeId: event.nodeid,
|
||||
process: process,
|
||||
input: stdin)
|
||||
self.sessions[event.sessionid] = active
|
||||
|
||||
stdout.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in
|
||||
let data = fileHandle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
Task { await self?.emitOutput(sessionId: event.sessionid, stream: "stdout", data: data, gateway: gateway) }
|
||||
}
|
||||
stderr.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in
|
||||
let data = fileHandle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
Task { await self?.emitOutput(sessionId: event.sessionid, stream: "stderr", data: data, gateway: gateway) }
|
||||
}
|
||||
process.terminationHandler = { [weak self] process in
|
||||
Task { await self?.handleTermination(sessionId: event.sessionid, process: process, gateway: gateway) }
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
stdout.fileHandleForReading.readabilityHandler = nil
|
||||
stderr.fileHandleForReading.readabilityHandler = nil
|
||||
self.sessions[event.sessionid] = nil
|
||||
await gateway.sendMcpSessionOpenResult(Self.openResult(
|
||||
event: event,
|
||||
ok: false,
|
||||
errorCode: "SPAWN_FAILED",
|
||||
message: error.localizedDescription))
|
||||
return
|
||||
}
|
||||
|
||||
await gateway.sendMcpSessionOpenResult(NodeMcpSessionOpenResultParams(
|
||||
sessionid: event.sessionid,
|
||||
nodeid: event.nodeid,
|
||||
serverid: event.serverid,
|
||||
ok: true,
|
||||
pid: Int(process.processIdentifier),
|
||||
error: nil))
|
||||
self.logger.info("computer-use MCP session opened pid=\(process.processIdentifier, privacy: .public)")
|
||||
}
|
||||
|
||||
func input(_ event: NodeMcpSessionInputEvent) async {
|
||||
guard let active = self.sessions[event.sessionid], active.nodeId == event.nodeid else {
|
||||
return
|
||||
}
|
||||
guard let data = Data(base64Encoded: event.database64) else {
|
||||
return
|
||||
}
|
||||
active.input.fileHandleForWriting.write(data)
|
||||
}
|
||||
|
||||
func close(_ event: NodeMcpSessionCloseEvent) async {
|
||||
guard let active = self.sessions[event.sessionid], active.nodeId == event.nodeid else {
|
||||
return
|
||||
}
|
||||
active.closeRequested = true
|
||||
try? active.input.fileHandleForWriting.close()
|
||||
if active.process.isRunning {
|
||||
active.process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func emitOutput(sessionId: String, stream: String, data: Data, gateway: GatewayNodeSession) async {
|
||||
guard let active = self.sessions[sessionId] else { return }
|
||||
let seq = active.nextSeq
|
||||
active.nextSeq += 1
|
||||
await gateway.sendMcpSessionOutput(NodeMcpSessionOutputParams(
|
||||
sessionid: active.sessionId,
|
||||
nodeid: active.nodeId,
|
||||
seq: seq,
|
||||
stream: stream,
|
||||
database64: data.base64EncodedString()))
|
||||
}
|
||||
|
||||
private func handleTermination(sessionId: String, process: Process, gateway: GatewayNodeSession) async {
|
||||
guard let active = self.sessions.removeValue(forKey: sessionId) else { return }
|
||||
let ok = active.closeRequested || process.terminationStatus == 0
|
||||
let signal = Self.signalName(
|
||||
for: process.terminationStatus,
|
||||
reason: process.terminationReason)
|
||||
await gateway.sendMcpSessionClosed(NodeMcpSessionClosedParams(
|
||||
sessionid: active.sessionId,
|
||||
nodeid: active.nodeId,
|
||||
ok: ok,
|
||||
exitcode: AnyCodable(Int(process.terminationStatus)),
|
||||
signal: signal.map { AnyCodable($0) },
|
||||
error: ok
|
||||
? nil
|
||||
: [
|
||||
"code": AnyCodable("PROCESS_EXITED"),
|
||||
"message": AnyCodable("MCP backend exited with status \(process.terminationStatus)"),
|
||||
]))
|
||||
}
|
||||
|
||||
private static func signalName(for status: Int32, reason: Process.TerminationReason) -> String? {
|
||||
guard reason == .uncaughtSignal else { return nil }
|
||||
switch Int(status) {
|
||||
case 1: return "SIGHUP"
|
||||
case 2: return "SIGINT"
|
||||
case 3: return "SIGQUIT"
|
||||
case 4: return "SIGILL"
|
||||
case 5: return "SIGTRAP"
|
||||
case 6: return "SIGABRT"
|
||||
case 7: return "SIGEMT"
|
||||
case 8: return "SIGFPE"
|
||||
case 9: return "SIGKILL"
|
||||
case 10: return "SIGBUS"
|
||||
case 11: return "SIGSEGV"
|
||||
case 12: return "SIGSYS"
|
||||
case 13: return "SIGPIPE"
|
||||
case 14: return "SIGALRM"
|
||||
case 15: return "SIGTERM"
|
||||
case 16: return "SIGURG"
|
||||
case 17: return "SIGSTOP"
|
||||
case 18: return "SIGTSTP"
|
||||
case 19: return "SIGCONT"
|
||||
case 20: return "SIGCHLD"
|
||||
case 21: return "SIGTTIN"
|
||||
case 22: return "SIGTTOU"
|
||||
case 23: return "SIGIO"
|
||||
case 24: return "SIGXCPU"
|
||||
case 25: return "SIGXFSZ"
|
||||
case 26: return "SIGVTALRM"
|
||||
case 27: return "SIGPROF"
|
||||
case 28: return "SIGWINCH"
|
||||
case 29: return "SIGINFO"
|
||||
case 30: return "SIGUSR1"
|
||||
case 31: return "SIGUSR2"
|
||||
default: return "SIG\(status)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func openResult(
|
||||
event: NodeMcpSessionOpenEvent,
|
||||
ok: Bool,
|
||||
errorCode: String,
|
||||
message: String) -> NodeMcpSessionOpenResultParams
|
||||
{
|
||||
NodeMcpSessionOpenResultParams(
|
||||
sessionid: event.sessionid,
|
||||
nodeid: event.nodeid,
|
||||
serverid: event.serverid,
|
||||
ok: ok,
|
||||
pid: nil,
|
||||
error: [
|
||||
"code": AnyCodable(errorCode),
|
||||
"message": AnyCodable(message),
|
||||
])
|
||||
}
|
||||
|
||||
private func handlePackageInstallBegin(_ req: BridgeInvokeRequest) throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeInvokeParams(MacMcpPackageInstallBeginParams.self, from: req)
|
||||
guard params.serverId == computerUseServerId else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: unsupported MCP server")
|
||||
}
|
||||
|
||||
if let activeInstall {
|
||||
try? FileManager.default.removeItem(at: activeInstall.directory)
|
||||
}
|
||||
|
||||
let destination = Self.managedPackageDirectory(
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
fileManager: .default,
|
||||
appSupportRoot: self.appSupportRoot)
|
||||
let parent = destination.deletingLastPathComponent()
|
||||
let transferDir = parent.appendingPathComponent(
|
||||
".\(computerUsePackageDirName).\(params.transferId).transfer",
|
||||
isDirectory: true)
|
||||
if FileManager.default.fileExists(atPath: transferDir.path) {
|
||||
try FileManager.default.removeItem(at: transferDir)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: transferDir, withIntermediateDirectories: true)
|
||||
|
||||
self.activeInstall = ActiveMacMcpPackageInstall(
|
||||
transferId: params.transferId,
|
||||
nodeId: params.nodeId,
|
||||
serverId: params.serverId,
|
||||
sourcePath: params.sourcePath ?? "gateway-transfer",
|
||||
expectedFileCount: params.fileCount,
|
||||
expectedTotalBytes: params.totalBytes,
|
||||
directory: transferDir)
|
||||
return try Self.payloadResponse(
|
||||
req,
|
||||
MacMcpPackageInstallPayload(
|
||||
ok: true,
|
||||
transferId: params.transferId,
|
||||
serverId: params.serverId,
|
||||
fileCount: params.fileCount,
|
||||
totalBytes: params.totalBytes))
|
||||
}
|
||||
|
||||
private func handlePackageInstallChunk(_ req: BridgeInvokeRequest) throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeInvokeParams(MacMcpPackageInstallChunkParams.self, from: req)
|
||||
guard var activeInstall, activeInstall.transferId == params.transferId else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: no active package transfer")
|
||||
}
|
||||
guard let relativePath = Self.safePackageRelativePath(params.relativePath) else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: unsafe package path")
|
||||
}
|
||||
guard let data = Data(base64Encoded: params.dataBase64) else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: package chunk is not base64")
|
||||
}
|
||||
|
||||
let destination = Self.packageFileURL(base: activeInstall.directory, relativePath: relativePath)
|
||||
try FileManager.default.createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager.default.fileExists(atPath: destination.path) {
|
||||
_ = FileManager.default.createFile(atPath: destination.path, contents: nil)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: destination)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: data)
|
||||
if params.executable == true {
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path)
|
||||
}
|
||||
|
||||
activeInstall.files.insert(relativePath)
|
||||
activeInstall.totalBytes += UInt64(data.count)
|
||||
self.activeInstall = activeInstall
|
||||
return try Self.payloadResponse(
|
||||
req,
|
||||
MacMcpPackageInstallPayload(
|
||||
ok: true,
|
||||
transferId: params.transferId,
|
||||
serverId: activeInstall.serverId,
|
||||
fileCount: activeInstall.files.count,
|
||||
totalBytes: activeInstall.totalBytes))
|
||||
}
|
||||
|
||||
private func handlePackageInstallFinish(_ req: BridgeInvokeRequest) throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeInvokeParams(MacMcpPackageInstallFinishParams.self, from: req)
|
||||
guard let activeInstall, activeInstall.transferId == params.transferId else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: no active package transfer")
|
||||
}
|
||||
if let expectedFileCount = activeInstall.expectedFileCount,
|
||||
activeInstall.files.count != expectedFileCount
|
||||
{
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: incomplete package transfer")
|
||||
}
|
||||
if let expectedTotalBytes = activeInstall.expectedTotalBytes,
|
||||
activeInstall.totalBytes != expectedTotalBytes
|
||||
{
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: package transfer byte count mismatch")
|
||||
}
|
||||
guard
|
||||
Self.resolvePackageLaunchConfig(
|
||||
packageDir: activeInstall.directory,
|
||||
source: "gateway-transfer",
|
||||
fileManager: .default) != nil
|
||||
else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: transferred package does not expose computer-use MCP")
|
||||
}
|
||||
guard let fingerprint = Self.packageFingerprint(
|
||||
packageDir: activeInstall.directory,
|
||||
fileManager: .default)
|
||||
else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: transferred package is empty")
|
||||
}
|
||||
|
||||
let destination = Self.managedPackageDirectory(
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
fileManager: .default,
|
||||
appSupportRoot: self.appSupportRoot)
|
||||
let metadata = MacMcpManagedPackageMetadata(
|
||||
source: "gateway-transfer",
|
||||
sourcePath: activeInstall.sourcePath,
|
||||
sourceFingerprint: fingerprint)
|
||||
let metadataData = try JSONEncoder().encode(metadata)
|
||||
try metadataData.write(
|
||||
to: activeInstall.directory.appendingPathComponent(computerUseManagedMetadataFileName),
|
||||
options: [.atomic])
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try FileManager.default.moveItem(at: activeInstall.directory, to: destination)
|
||||
self.activeInstall = nil
|
||||
|
||||
return try Self.payloadResponse(
|
||||
req,
|
||||
MacMcpPackageInstallPayload(
|
||||
ok: true,
|
||||
transferId: params.transferId,
|
||||
serverId: activeInstall.serverId,
|
||||
fileCount: activeInstall.files.count,
|
||||
totalBytes: activeInstall.totalBytes))
|
||||
}
|
||||
|
||||
private func handlePackageInstallCancel(_ req: BridgeInvokeRequest) throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeInvokeParams(MacMcpPackageInstallCancelParams.self, from: req)
|
||||
guard let activeInstall, activeInstall.transferId == params.transferId else {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: no active package transfer")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: activeInstall.directory)
|
||||
self.activeInstall = nil
|
||||
return try Self.payloadResponse(
|
||||
req,
|
||||
MacMcpPackageInstallPayload(
|
||||
ok: true,
|
||||
transferId: params.transferId,
|
||||
serverId: activeInstall.serverId,
|
||||
fileCount: activeInstall.files.count,
|
||||
totalBytes: activeInstall.totalBytes))
|
||||
}
|
||||
|
||||
nonisolated static func resolveComputerUseLaunchConfig(
|
||||
env: [String: String] = ProcessInfo.processInfo.environment,
|
||||
fileManager: FileManager = .default,
|
||||
resourceURL: URL? = Bundle.main.resourceURL,
|
||||
codexPluginDir: URL = URL(
|
||||
fileURLWithPath: "/Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use"),
|
||||
appSupportRoot: URL? = nil) -> MacMcpLaunchConfig?
|
||||
{
|
||||
if let rawCommand = env[computerUseEnvCommandKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawCommand.isEmpty
|
||||
{
|
||||
let command = URL(fileURLWithPath: NSString(string: rawCommand).expandingTildeInPath)
|
||||
return MacMcpLaunchConfig(
|
||||
command: command,
|
||||
args: Self.parseEnvArgs(env[computerUseEnvArgsKey]) ?? ["mcp"],
|
||||
cwd: nil,
|
||||
source: "env-command")
|
||||
}
|
||||
|
||||
if let rawPackageDir = env[computerUseEnvPackageDirKey]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawPackageDir.isEmpty
|
||||
{
|
||||
let packageDir = URL(fileURLWithPath: NSString(string: rawPackageDir).expandingTildeInPath)
|
||||
if let launch = Self.resolvePackageLaunchConfig(
|
||||
packageDir: packageDir,
|
||||
source: "env-package",
|
||||
fileManager: fileManager)
|
||||
{
|
||||
return launch
|
||||
}
|
||||
}
|
||||
|
||||
let managedDir = Self.managedPackageDirectory(
|
||||
env: env,
|
||||
fileManager: fileManager,
|
||||
appSupportRoot: appSupportRoot)
|
||||
let managedLaunch = Self.resolvePackageLaunchConfig(
|
||||
packageDir: managedDir,
|
||||
source: "openclaw-managed",
|
||||
fileManager: fileManager)
|
||||
let source = Self.approvedPackageSources(
|
||||
resourceURL: resourceURL,
|
||||
codexPluginDir: codexPluginDir,
|
||||
fileManager: fileManager).first
|
||||
|
||||
if let managedLaunch {
|
||||
guard
|
||||
let source,
|
||||
Self.managedPackageNeedsRefresh(
|
||||
managedDir: managedDir,
|
||||
source: source,
|
||||
fileManager: fileManager)
|
||||
else {
|
||||
return managedLaunch
|
||||
}
|
||||
}
|
||||
|
||||
if let source,
|
||||
Self.installManagedPackage(from: source, to: managedDir, fileManager: fileManager),
|
||||
let launch = Self.resolvePackageLaunchConfig(
|
||||
packageDir: managedDir,
|
||||
source: "openclaw-managed:\(source.source)",
|
||||
fileManager: fileManager)
|
||||
{
|
||||
return launch
|
||||
}
|
||||
|
||||
return managedLaunch
|
||||
}
|
||||
|
||||
private nonisolated static func approvedPackageSources(
|
||||
resourceURL: URL?,
|
||||
codexPluginDir: URL,
|
||||
fileManager: FileManager) -> [MacMcpPackageSource]
|
||||
{
|
||||
var sources: [MacMcpPackageSource] = []
|
||||
if let resourceURL {
|
||||
sources.append(MacMcpPackageSource(
|
||||
directory: resourceURL.appendingPathComponent(computerUseBundledResourcePath, isDirectory: true),
|
||||
source: "openclaw-bundled"))
|
||||
}
|
||||
sources.append(MacMcpPackageSource(directory: codexPluginDir, source: "codex-bundled"))
|
||||
return sources.filter {
|
||||
Self.resolvePackageLaunchConfig(
|
||||
packageDir: $0.directory,
|
||||
source: $0.source,
|
||||
fileManager: fileManager) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func resolvePackageLaunchConfig(
|
||||
packageDir: URL,
|
||||
source: String,
|
||||
fileManager: FileManager) -> MacMcpLaunchConfig?
|
||||
{
|
||||
let manifestURL = packageDir.appendingPathComponent(".mcp.json", isDirectory: false)
|
||||
guard
|
||||
let data = try? Data(contentsOf: manifestURL),
|
||||
let manifest = try? JSONDecoder().decode(CodexMcpManifest.self, from: data),
|
||||
let server = manifest.mcpServers[computerUseServerId]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let cwd = Self.resolvePath(server.cwd ?? ".", relativeTo: packageDir)
|
||||
let command = Self.resolvePath(server.command, relativeTo: cwd)
|
||||
guard fileManager.isExecutableFile(atPath: command.path) else {
|
||||
return nil
|
||||
}
|
||||
return MacMcpLaunchConfig(
|
||||
command: command,
|
||||
args: server.args ?? [],
|
||||
cwd: cwd,
|
||||
source: source)
|
||||
}
|
||||
|
||||
private nonisolated static func managedPackageDirectory(
|
||||
env: [String: String],
|
||||
fileManager: FileManager,
|
||||
appSupportRoot: URL?) -> URL
|
||||
{
|
||||
if let rawInstallDir = env[computerUseEnvInstallDirKey]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!rawInstallDir.isEmpty
|
||||
{
|
||||
return URL(fileURLWithPath: NSString(string: rawInstallDir).expandingTildeInPath)
|
||||
}
|
||||
let base = if let appSupportRoot {
|
||||
appSupportRoot
|
||||
} else if let applicationSupportRoot = fileManager
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
||||
.first
|
||||
{
|
||||
applicationSupportRoot.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
} else {
|
||||
fileManager.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Application Support", isDirectory: true)
|
||||
.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
return base
|
||||
.appendingPathComponent(computerUseAppSupportDirName, isDirectory: true)
|
||||
.appendingPathComponent(computerUsePackageDirName, isDirectory: true)
|
||||
}
|
||||
|
||||
private nonisolated static func managedPackageNeedsRefresh(
|
||||
managedDir: URL,
|
||||
source: MacMcpPackageSource,
|
||||
fileManager: FileManager) -> Bool
|
||||
{
|
||||
guard let sourceFingerprint = packageFingerprint(
|
||||
packageDir: source.directory,
|
||||
fileManager: fileManager)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let metadataURL = managedDir.appendingPathComponent(
|
||||
computerUseManagedMetadataFileName,
|
||||
isDirectory: false)
|
||||
guard
|
||||
let data = try? Data(contentsOf: metadataURL),
|
||||
let metadata = try? JSONDecoder().decode(MacMcpManagedPackageMetadata.self, from: data)
|
||||
else {
|
||||
return true
|
||||
}
|
||||
if metadata.source == "gateway-transfer" {
|
||||
return false
|
||||
}
|
||||
return metadata != MacMcpManagedPackageMetadata(
|
||||
source: source.source,
|
||||
sourcePath: source.directory.path,
|
||||
sourceFingerprint: sourceFingerprint)
|
||||
}
|
||||
|
||||
private nonisolated static func installManagedPackage(
|
||||
from source: MacMcpPackageSource,
|
||||
to destination: URL,
|
||||
fileManager: FileManager) -> Bool
|
||||
{
|
||||
guard let sourceFingerprint = packageFingerprint(
|
||||
packageDir: source.directory,
|
||||
fileManager: fileManager)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let parent = destination.deletingLastPathComponent()
|
||||
let temp = parent.appendingPathComponent(
|
||||
".\(destination.lastPathComponent).\(UUID().uuidString).tmp",
|
||||
isDirectory: true)
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
if fileManager.fileExists(atPath: temp.path) {
|
||||
try fileManager.removeItem(at: temp)
|
||||
}
|
||||
try fileManager.copyItem(at: source.directory, to: temp)
|
||||
let metadata = MacMcpManagedPackageMetadata(
|
||||
source: source.source,
|
||||
sourcePath: source.directory.path,
|
||||
sourceFingerprint: sourceFingerprint)
|
||||
let metadataData = try JSONEncoder().encode(metadata)
|
||||
try metadataData.write(
|
||||
to: temp.appendingPathComponent(computerUseManagedMetadataFileName, isDirectory: false),
|
||||
options: [.atomic])
|
||||
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
try fileManager.moveItem(at: temp, to: destination)
|
||||
return true
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: temp)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func packageFingerprint(
|
||||
packageDir: URL,
|
||||
fileManager: FileManager) -> MacMcpPackageFingerprint?
|
||||
{
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: packageDir,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],
|
||||
options: [],
|
||||
errorHandler: nil)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
var fileCount = 0
|
||||
var totalSize: UInt64 = 0
|
||||
var latestModifiedAt: TimeInterval = 0
|
||||
for case let url as URL in enumerator {
|
||||
guard let values = try? url.resourceValues(forKeys: [
|
||||
.isRegularFileKey,
|
||||
.fileSizeKey,
|
||||
.contentModificationDateKey,
|
||||
]), values.isRegularFile == true
|
||||
else {
|
||||
continue
|
||||
}
|
||||
fileCount += 1
|
||||
totalSize += UInt64(values.fileSize ?? 0)
|
||||
latestModifiedAt = max(
|
||||
latestModifiedAt,
|
||||
values.contentModificationDate?.timeIntervalSince1970 ?? 0)
|
||||
}
|
||||
guard fileCount > 0 else { return nil }
|
||||
return MacMcpPackageFingerprint(
|
||||
fileCount: fileCount,
|
||||
totalSize: totalSize,
|
||||
latestModifiedAt: latestModifiedAt)
|
||||
}
|
||||
|
||||
private nonisolated static func decodeInvokeParams<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
from req: BridgeInvokeRequest) throws -> T
|
||||
{
|
||||
guard let paramsJSON = req.paramsJSON, let data = paramsJSON.data(using: .utf8) else {
|
||||
throw NSError(domain: "MacComputerUseMcpHost", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: missing params",
|
||||
])
|
||||
}
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private nonisolated static func payloadResponse(
|
||||
_ req: BridgeInvokeRequest,
|
||||
_ payload: some Encodable) throws -> BridgeInvokeResponse
|
||||
{
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: true,
|
||||
payloadJSON: String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
private nonisolated static func errorResponse(
|
||||
_ req: BridgeInvokeRequest,
|
||||
code: OpenClawNodeErrorCode,
|
||||
message: String) -> BridgeInvokeResponse
|
||||
{
|
||||
BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: code, message: message))
|
||||
}
|
||||
|
||||
private nonisolated static func safePackageRelativePath(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, !trimmed.hasPrefix("/") else { return nil }
|
||||
let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
guard !parts.isEmpty else { return nil }
|
||||
guard !parts.contains(where: { $0 == "." || $0 == ".." }) else { return nil }
|
||||
guard !parts.contains(computerUseManagedMetadataFileName) else { return nil }
|
||||
return parts.joined(separator: "/")
|
||||
}
|
||||
|
||||
private nonisolated static func packageFileURL(base: URL, relativePath: String) -> URL {
|
||||
relativePath
|
||||
.split(separator: "/", omittingEmptySubsequences: true)
|
||||
.reduce(base) { partial, component in
|
||||
partial.appendingPathComponent(String(component), isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func parseEnvArgs(_ raw: String?) -> [String]? {
|
||||
guard let raw, let data = raw.data(using: .utf8) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String]
|
||||
}
|
||||
|
||||
private nonisolated static func resolvePath(_ raw: String, relativeTo base: URL) -> URL {
|
||||
let expanded = NSString(string: raw).expandingTildeInPath
|
||||
if expanded.hasPrefix("/") {
|
||||
return URL(fileURLWithPath: expanded)
|
||||
}
|
||||
return base.appendingPathComponent(expanded)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@@ -11,8 +10,6 @@ final class MacNodeModeCoordinator {
|
||||
private var task: Task<Void, Never>?
|
||||
private let runtime = MacNodeRuntime()
|
||||
private let session = GatewayNodeSession()
|
||||
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
|
||||
private let mcpHost = MacComputerUseMcpHost()
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
@@ -61,23 +58,17 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
var attemptedURL: URL?
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
attemptedURL = config.url
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
let mcpServers = Self.resolvedMcpServers(permissions: permissions)
|
||||
let mcpHost = self.mcpHost
|
||||
let nodeSession = self.session
|
||||
let connectOptions = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: caps,
|
||||
commands: commands,
|
||||
permissions: permissions,
|
||||
mcpServers: mcpServers,
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
@@ -112,35 +103,12 @@ final class MacNodeModeCoordinator {
|
||||
ok: false,
|
||||
error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
|
||||
}
|
||||
let permissions = await self.currentPermissions()
|
||||
if let response = await mcpHost.handleInvoke(
|
||||
req,
|
||||
permissions: permissions,
|
||||
sendMcpServersUpdate: { nodeId, mcpServers in
|
||||
await nodeSession.sendMcpServersUpdate(nodeId: nodeId, mcpServers: mcpServers)
|
||||
})
|
||||
{
|
||||
return response
|
||||
}
|
||||
return await self.runtime.handleInvoke(req)
|
||||
},
|
||||
onMcpSessionOpen: { event in
|
||||
await mcpHost.open(event, gateway: nodeSession)
|
||||
},
|
||||
onMcpSessionInput: { event in
|
||||
await mcpHost.input(event)
|
||||
},
|
||||
onMcpSessionClose: { event in
|
||||
await mcpHost.close(event)
|
||||
})
|
||||
|
||||
retryDelay = 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
|
||||
retryDelay = 1_000_000_000
|
||||
continue
|
||||
}
|
||||
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
@@ -154,11 +122,7 @@ final class MacNodeModeCoordinator {
|
||||
locationMode: OpenClawLocationMode,
|
||||
connectionMode: AppState.ConnectionMode) -> [String]
|
||||
{
|
||||
var caps: [String] = [
|
||||
OpenClawCapability.canvas.rawValue,
|
||||
OpenClawCapability.screen.rawValue,
|
||||
OpenClawCapability.mcpHost.rawValue,
|
||||
]
|
||||
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
|
||||
if browserControlEnabled, connectionMode == .local {
|
||||
caps.append(OpenClawCapability.browser.rawValue)
|
||||
}
|
||||
@@ -185,10 +149,6 @@ final class MacNodeModeCoordinator {
|
||||
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
|
||||
}
|
||||
|
||||
nonisolated static func resolvedMcpServers(permissions: [String: Bool]) -> [NodeMcpServerDescriptor] {
|
||||
[MacComputerUseMcpHost.computerUseDescriptor(permissions: permissions)]
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
|
||||
var commands: [String] = [
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
@@ -209,9 +169,6 @@ final class MacNodeModeCoordinator {
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(OpenClawCapability.mcpHost.rawValue) {
|
||||
commands.append(contentsOf: MacComputerUseMcpHost.packageInstallCommands)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.browser.rawValue) {
|
||||
commands.append(OpenClawBrowserCommand.proxy.rawValue)
|
||||
}
|
||||
@@ -231,49 +188,11 @@ final class MacNodeModeCoordinator {
|
||||
Self.resolvedCommands(caps: caps)
|
||||
}
|
||||
|
||||
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
nonisolated static func shouldAutoRepairStaleTLSPin(url: URL, failure: GatewayTLSValidationFailure) -> Bool {
|
||||
guard failure.kind == .pinMismatch else { return false }
|
||||
guard url.scheme?.lowercased() == "wss" else { return false }
|
||||
guard failure.storeKey == nil || failure.storeKey == self.tlsPinStoreKey(for: url) else { return false }
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty
|
||||
else { return false }
|
||||
|
||||
if LoopbackHost.isLoopback(host) {
|
||||
return failure.systemTrustOk
|
||||
}
|
||||
|
||||
// Tailscale Serve uses publicly trusted, rotating certificates for *.ts.net names.
|
||||
// A stale legacy leaf pin should not leave the companion app half-connected forever.
|
||||
if host == "ts.net" || host.hasSuffix(".ts.net") {
|
||||
return failure.systemTrustOk
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func autoRepairStaleTLSPinIfNeeded(error: Error, url: URL?) async -> Bool {
|
||||
guard let tlsError = error as? GatewayTLSValidationError, let url else { return false }
|
||||
guard Self.shouldAutoRepairStaleTLSPin(url: url, failure: tlsError.failure) else { return false }
|
||||
let storeKey = tlsError.failure.storeKey ?? Self.tlsPinStoreKey(for: url)
|
||||
guard let observedFingerprint = tlsError.failure.observedFingerprint else { return false }
|
||||
guard self.autoRepairedTLSFingerprintsByStoreKey[storeKey] != observedFingerprint else { return false }
|
||||
|
||||
guard GatewayTLSStore.replaceFingerprint(observedFingerprint, stableID: storeKey) else { return false }
|
||||
self.autoRepairedTLSFingerprintsByStoreKey[storeKey] = observedFingerprint
|
||||
self.logger.info("replaced stale gateway TLS pin storeKey=\(storeKey, privacy: .public)")
|
||||
await self.session.disconnect()
|
||||
return true
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let host = url.host ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
let stableID = "\(host):\(port)"
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
|
||||
@@ -44,12 +44,10 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func roleText(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
return entry.isConnected ? "connected" : "disconnected"
|
||||
}
|
||||
let pairing = entry.isPaired ? "paired" : "unpaired"
|
||||
let connection = entry.isConnected ? "connected" : "disconnected"
|
||||
return "\(pairing) · \(connection)"
|
||||
if entry.isConnected { return "connected" }
|
||||
if self.isGateway(entry) { return "disconnected" }
|
||||
if entry.isPaired { return "paired" }
|
||||
return "unpaired"
|
||||
}
|
||||
|
||||
static func detailLeft(_ entry: NodeInfo) -> String {
|
||||
|
||||
@@ -29,7 +29,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let mcpservers: [NodeMcpServerDescriptor]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
@@ -45,7 +44,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
mcpservers: [NodeMcpServerDescriptor]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
@@ -60,7 +58,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.mcpservers = mcpservers
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
@@ -77,7 +74,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case mcpservers = "mcpServers"
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
@@ -837,7 +833,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
public let modelidentifier: String?
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let mcpservers: [NodeMcpServerDescriptor]?
|
||||
public let remoteip: String?
|
||||
public let silent: Bool?
|
||||
|
||||
@@ -852,7 +847,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
modelidentifier: String?,
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
mcpservers: [NodeMcpServerDescriptor]?,
|
||||
remoteip: String?,
|
||||
silent: Bool?)
|
||||
{
|
||||
@@ -866,7 +860,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
self.modelidentifier = modelidentifier
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.mcpservers = mcpservers
|
||||
self.remoteip = remoteip
|
||||
self.silent = silent
|
||||
}
|
||||
@@ -882,7 +875,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
case modelidentifier = "modelIdentifier"
|
||||
case caps
|
||||
case commands
|
||||
case mcpservers = "mcpServers"
|
||||
case remoteip = "remoteIp"
|
||||
case silent
|
||||
}
|
||||
@@ -1110,238 +1102,6 @@ public struct NodeEventResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpServerDescriptor: Codable, Sendable {
|
||||
public let id: String
|
||||
public let displayname: String?
|
||||
public let provider: String?
|
||||
public let transport: String?
|
||||
public let source: String?
|
||||
public let status: String?
|
||||
public let requiredpermissions: [String]?
|
||||
public let metadata: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
displayname: String?,
|
||||
provider: String?,
|
||||
transport: String?,
|
||||
source: String?,
|
||||
status: String?,
|
||||
requiredpermissions: [String]?,
|
||||
metadata: [String: AnyCodable]?)
|
||||
{
|
||||
self.id = id
|
||||
self.displayname = displayname
|
||||
self.provider = provider
|
||||
self.transport = transport
|
||||
self.source = source
|
||||
self.status = status
|
||||
self.requiredpermissions = requiredpermissions
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case displayname = "displayName"
|
||||
case provider
|
||||
case transport
|
||||
case source
|
||||
case status
|
||||
case requiredpermissions = "requiredPermissions"
|
||||
case metadata
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpServersUpdateParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let mcpservers: [NodeMcpServerDescriptor]
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
mcpservers: [NodeMcpServerDescriptor])
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.mcpservers = mcpservers
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case mcpservers = "mcpServers"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionOpenEvent: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let serverid: String
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
serverid: String,
|
||||
timeoutms: Int?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.serverid = serverid
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case serverid = "serverId"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionOpenResultParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let serverid: String
|
||||
public let ok: Bool
|
||||
public let pid: Int?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
serverid: String,
|
||||
ok: Bool,
|
||||
pid: Int?,
|
||||
error: [String: AnyCodable]?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.serverid = serverid
|
||||
self.ok = ok
|
||||
self.pid = pid
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case serverid = "serverId"
|
||||
case ok
|
||||
case pid
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionInputEvent: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let seq: Int
|
||||
public let database64: String
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
seq: Int,
|
||||
database64: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.seq = seq
|
||||
self.database64 = database64
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case seq
|
||||
case database64 = "dataBase64"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionOutputParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let seq: Int
|
||||
public let stream: String
|
||||
public let database64: String
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
seq: Int,
|
||||
stream: String,
|
||||
database64: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.seq = seq
|
||||
self.stream = stream
|
||||
self.database64 = database64
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case seq
|
||||
case stream
|
||||
case database64 = "dataBase64"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionCloseEvent: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let reason: String?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
reason: String?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case reason
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionClosedParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let ok: Bool
|
||||
public let exitcode: AnyCodable?
|
||||
public let signal: AnyCodable?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
ok: Bool,
|
||||
exitcode: AnyCodable?,
|
||||
signal: AnyCodable?,
|
||||
error: [String: AnyCodable]?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.ok = ok
|
||||
self.exitcode = exitcode
|
||||
self.signal = signal
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case ok
|
||||
case exitcode = "exitCode"
|
||||
case signal
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePresenceAlivePayload: Codable, Sendable {
|
||||
public let trigger: NodePresenceAliveReason
|
||||
public let sentatms: Int?
|
||||
|
||||
@@ -46,37 +46,4 @@ struct CanvasWindowSmokeTests {
|
||||
controller.hideCanvas()
|
||||
controller.close()
|
||||
}
|
||||
|
||||
@Test func `A2UI auto navigation is idempotent for current host target`() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: "main",
|
||||
root: root,
|
||||
presentation: .window)
|
||||
defer { controller.close() }
|
||||
|
||||
let oldTarget = "http://127.0.0.1:18789/__openclaw__/a2ui/?platform=macos"
|
||||
let currentTarget = "http://127.0.0.1:18790/__openclaw__/a2ui/?platform=macos"
|
||||
let userTarget = "https://github.com/openclaw/openclaw"
|
||||
|
||||
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: nil, candidateTarget: currentTarget) == true)
|
||||
|
||||
controller.load(target: "/")
|
||||
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: nil, candidateTarget: currentTarget) == true)
|
||||
|
||||
controller.load(target: currentTarget)
|
||||
#expect(controller
|
||||
.shouldAutoNavigateToA2UI(lastAutoTarget: currentTarget, candidateTarget: currentTarget) == false)
|
||||
|
||||
controller.load(target: oldTarget)
|
||||
#expect(controller.shouldAutoNavigateToA2UI(lastAutoTarget: oldTarget, candidateTarget: currentTarget) == true)
|
||||
|
||||
controller.load(target: userTarget)
|
||||
#expect(controller
|
||||
.shouldAutoNavigateToA2UI(lastAutoTarget: currentTarget, candidateTarget: currentTarget) == false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,6 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct GatewayChannelConnectTests {
|
||||
private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
|
||||
private var failure: GatewayTLSValidationFailure?
|
||||
|
||||
init(failure: GatewayTLSValidationFailure) {
|
||||
self.failure = failure
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
let task = GatewayTestWebSocketTask(receiveHook: { _, receiveIndex in
|
||||
if receiveIndex == 0 {
|
||||
return .data(GatewayWebSocketTestSupport.connectChallengeData())
|
||||
}
|
||||
throw URLError(.userCancelledAuthentication)
|
||||
})
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
|
||||
defer { self.failure = nil }
|
||||
return self.failure
|
||||
}
|
||||
}
|
||||
|
||||
private enum FakeResponse {
|
||||
case helloOk(delayMs: Int)
|
||||
case invalid(delayMs: Int)
|
||||
@@ -133,28 +109,4 @@ struct GatewayChannelConnectTests {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `connect maps user cancelled authentication with cached TLS failure`() async throws {
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.ts.net",
|
||||
storeKey: "gateway.example.ts.net:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
let session = TLSFailureSession(failure: failure)
|
||||
let channel = try GatewayChannelActor(
|
||||
url: #require(URL(string: "wss://gateway.example.ts.net")),
|
||||
token: nil,
|
||||
session: WebSocketSessionBox(session: session))
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
Issue.record("expected GatewayTLSValidationError")
|
||||
} catch let error as GatewayTLSValidationError {
|
||||
#expect(error.failure == failure)
|
||||
} catch {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct MacComputerUseMcpHostTests {
|
||||
@Test func `env package dir resolves directly without managed install`() throws {
|
||||
let fixture = try Self.makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
let package = fixture.root.appendingPathComponent("direct-package", isDirectory: true)
|
||||
let executable = try Self.writeComputerUsePackage(at: package)
|
||||
|
||||
let launch = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: ["OPENCLAW_COMPUTER_USE_MCP_PACKAGE_DIR": package.path],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: fixture.root.appendingPathComponent("missing-codex", isDirectory: true),
|
||||
appSupportRoot: fixture.appSupport))
|
||||
|
||||
#expect(launch.source == "env-package")
|
||||
#expect(launch.command.path == executable.path)
|
||||
#expect(!FileManager.default.fileExists(atPath: fixture.managedPackage.path))
|
||||
}
|
||||
|
||||
@Test func `codex bundled package is copied into openclaw managed storage`() throws {
|
||||
let fixture = try Self.makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
let codexPackage = fixture.root.appendingPathComponent("Codex.app-computer-use", isDirectory: true)
|
||||
try Self.writeComputerUsePackage(at: codexPackage)
|
||||
|
||||
let launch = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: [:],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: codexPackage,
|
||||
appSupportRoot: fixture.appSupport))
|
||||
|
||||
let managedExecutable = fixture.managedPackage
|
||||
.appendingPathComponent("bin", isDirectory: true)
|
||||
.appendingPathComponent("computer-use-test", isDirectory: false)
|
||||
#expect(launch.source == "openclaw-managed:codex-bundled")
|
||||
#expect(launch.command.path == managedExecutable.path)
|
||||
#expect(FileManager.default.fileExists(atPath: managedExecutable.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: fixture.managedPackage.appendingPathComponent(".mcp.json").path))
|
||||
}
|
||||
|
||||
@Test func `existing managed package works without codex app source`() throws {
|
||||
let fixture = try Self.makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
try Self.writeComputerUsePackage(at: fixture.managedPackage)
|
||||
|
||||
let launch = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: [:],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: fixture.root.appendingPathComponent("missing-codex", isDirectory: true),
|
||||
appSupportRoot: fixture.appSupport))
|
||||
|
||||
#expect(launch.source == "openclaw-managed")
|
||||
#expect(launch.cwd?.path == fixture.managedPackage.path)
|
||||
}
|
||||
|
||||
@Test func `managed package refreshes when codex source changes`() throws {
|
||||
let fixture = try Self.makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
let codexPackage = fixture.root.appendingPathComponent("Codex.app-computer-use", isDirectory: true)
|
||||
let sourceExecutable = try Self.writeComputerUsePackage(at: codexPackage, script: "#!/bin/sh\necho one\n")
|
||||
|
||||
_ = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: [:],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: codexPackage,
|
||||
appSupportRoot: fixture.appSupport))
|
||||
|
||||
try "#!/bin/sh\necho two\n".write(to: sourceExecutable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[
|
||||
.posixPermissions: 0o755,
|
||||
.modificationDate: Date(timeIntervalSinceNow: 60),
|
||||
],
|
||||
ofItemAtPath: sourceExecutable.path)
|
||||
|
||||
_ = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: [:],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: codexPackage,
|
||||
appSupportRoot: fixture.appSupport))
|
||||
|
||||
let copiedExecutable = fixture.managedPackage
|
||||
.appendingPathComponent("bin", isDirectory: true)
|
||||
.appendingPathComponent("computer-use-test", isDirectory: false)
|
||||
let copiedScript = try String(contentsOf: copiedExecutable, encoding: .utf8)
|
||||
#expect(copiedScript.contains("echo two"))
|
||||
}
|
||||
|
||||
@Test func `gateway package transfer installs openclaw managed backend`() async throws {
|
||||
let fixture = try Self.makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
let sourcePackage = fixture.root.appendingPathComponent("source-package", isDirectory: true)
|
||||
let sourceExecutable = try Self.writeComputerUsePackage(at: sourcePackage)
|
||||
let manifestData = try Data(contentsOf: sourcePackage.appendingPathComponent(".mcp.json"))
|
||||
let executableData = try Data(contentsOf: sourceExecutable)
|
||||
let host = MacComputerUseMcpHost(appSupportRoot: fixture.appSupport)
|
||||
|
||||
let begin = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "begin",
|
||||
command: "mcp.package.install.begin",
|
||||
paramsJSON: Self.json([
|
||||
"transferId": "transfer-1",
|
||||
"nodeId": "mac-node",
|
||||
"serverId": "computer-use",
|
||||
"packageName": "computer-use",
|
||||
"sourcePath": sourcePackage.path,
|
||||
"fileCount": 2,
|
||||
"totalBytes": manifestData.count + executableData.count,
|
||||
])),
|
||||
permissions: [:]))
|
||||
#expect(begin.ok)
|
||||
|
||||
let manifestChunk = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "manifest",
|
||||
command: "mcp.package.install.chunk",
|
||||
paramsJSON: Self.json([
|
||||
"transferId": "transfer-1",
|
||||
"relativePath": ".mcp.json",
|
||||
"dataBase64": manifestData.base64EncodedString(),
|
||||
])),
|
||||
permissions: [:]))
|
||||
#expect(manifestChunk.ok)
|
||||
|
||||
let executableChunk = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "executable",
|
||||
command: "mcp.package.install.chunk",
|
||||
paramsJSON: Self.json([
|
||||
"transferId": "transfer-1",
|
||||
"relativePath": "bin/computer-use-test",
|
||||
"dataBase64": executableData.base64EncodedString(),
|
||||
"executable": true,
|
||||
])),
|
||||
permissions: [:]))
|
||||
#expect(executableChunk.ok)
|
||||
|
||||
let finish = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "finish",
|
||||
command: "mcp.package.install.finish",
|
||||
paramsJSON: Self.json(["transferId": "transfer-1"])),
|
||||
permissions: [
|
||||
"accessibility": true,
|
||||
"screenRecording": true,
|
||||
]))
|
||||
#expect(finish.ok)
|
||||
|
||||
let launch = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: [:],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: fixture.root.appendingPathComponent("missing-codex", isDirectory: true),
|
||||
appSupportRoot: fixture.appSupport))
|
||||
#expect(launch.source == "openclaw-managed")
|
||||
#expect(launch.command.path == fixture.managedPackage.appendingPathComponent("bin/computer-use-test").path)
|
||||
|
||||
let codexPackage = fixture.root.appendingPathComponent("Codex.app-computer-use", isDirectory: true)
|
||||
try Self.writeComputerUsePackage(at: codexPackage, script: "#!/bin/sh\necho codex\n")
|
||||
let launchWithCodexFallback = try #require(MacComputerUseMcpHost.resolveComputerUseLaunchConfig(
|
||||
env: [:],
|
||||
resourceURL: nil,
|
||||
codexPluginDir: codexPackage,
|
||||
appSupportRoot: fixture.appSupport))
|
||||
#expect(launchWithCodexFallback.source == "openclaw-managed")
|
||||
}
|
||||
|
||||
@Test func `gateway package transfer rejects incomplete package`() async throws {
|
||||
let fixture = try Self.makeFixture()
|
||||
defer { try? FileManager.default.removeItem(at: fixture.root) }
|
||||
let sourcePackage = fixture.root.appendingPathComponent("source-package", isDirectory: true)
|
||||
_ = try Self.writeComputerUsePackage(at: sourcePackage)
|
||||
let host = MacComputerUseMcpHost(appSupportRoot: fixture.appSupport)
|
||||
|
||||
let begin = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "begin",
|
||||
command: "mcp.package.install.begin",
|
||||
paramsJSON: Self.json([
|
||||
"transferId": "transfer-1",
|
||||
"nodeId": "mac-node",
|
||||
"serverId": "computer-use",
|
||||
"fileCount": 2,
|
||||
"totalBytes": 100,
|
||||
])),
|
||||
permissions: [:]))
|
||||
#expect(begin.ok)
|
||||
|
||||
let manifestData = try Data(contentsOf: sourcePackage.appendingPathComponent(".mcp.json"))
|
||||
let manifestChunk = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "manifest",
|
||||
command: "mcp.package.install.chunk",
|
||||
paramsJSON: Self.json([
|
||||
"transferId": "transfer-1",
|
||||
"relativePath": ".mcp.json",
|
||||
"dataBase64": manifestData.base64EncodedString(),
|
||||
])),
|
||||
permissions: [:]))
|
||||
#expect(manifestChunk.ok)
|
||||
|
||||
let finish = try #require(await host.handleInvoke(
|
||||
BridgeInvokeRequest(
|
||||
id: "finish",
|
||||
command: "mcp.package.install.finish",
|
||||
paramsJSON: Self.json(["transferId": "transfer-1"])),
|
||||
permissions: [:]))
|
||||
#expect(!finish.ok)
|
||||
#expect(!FileManager.default.fileExists(atPath: fixture.managedPackage.path))
|
||||
}
|
||||
|
||||
private static func makeFixture() throws -> (
|
||||
root: URL,
|
||||
appSupport: URL,
|
||||
managedPackage: URL
|
||||
) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openclaw-mac-mcp-\(UUID().uuidString)", isDirectory: true)
|
||||
let appSupport = root.appendingPathComponent("ApplicationSupport", isDirectory: true)
|
||||
let managedPackage = appSupport
|
||||
.appendingPathComponent("CodexComputerUseMCP", isDirectory: true)
|
||||
.appendingPathComponent("computer-use", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
return (root, appSupport, managedPackage)
|
||||
}
|
||||
|
||||
private static func json(_ value: [String: Any]) throws -> String {
|
||||
let data = try JSONSerialization.data(withJSONObject: value)
|
||||
return try #require(String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func writeComputerUsePackage(
|
||||
at package: URL,
|
||||
script: String = "#!/bin/sh\n") throws -> URL
|
||||
{
|
||||
let bin = package.appendingPathComponent("bin", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
|
||||
let executable = bin.appendingPathComponent("computer-use-test", isDirectory: false)
|
||||
try script.write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
let manifest = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"computer-use": {
|
||||
"command": "./bin/computer-use-test",
|
||||
"args": ["mcp"],
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try manifest.write(
|
||||
to: package.appendingPathComponent(".mcp.json", isDirectory: false),
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
return executable
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,7 @@ struct MacNodeModeCoordinatorTests {
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(!caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.mcpHost.rawValue))
|
||||
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
#expect(commands.contains("mcp.package.install.begin"))
|
||||
#expect(commands.contains(OpenClawCanvasCommand.present.rawValue))
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
}
|
||||
@@ -29,76 +27,6 @@ struct MacNodeModeCoordinatorTests {
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(caps.contains(OpenClawCapability.mcpHost.rawValue))
|
||||
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
}
|
||||
|
||||
@Test func `tls pin store key uses default wss port`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
|
||||
#expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443")
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted tailscale serve pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.ts.net",
|
||||
storeKey: "gateway.example.ts.net:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `does not auto repair untrusted remote pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.com",
|
||||
storeKey: "gateway.example.com:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted loopback pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "127.0.0.1",
|
||||
storeKey: "127.0.0.1:18789",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true)
|
||||
|
||||
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `does not auto repair untrusted loopback pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "127.0.0.1",
|
||||
storeKey: "127.0.0.1:18789",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: false)
|
||||
|
||||
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
|
||||
}
|
||||
|
||||
@Test func `computer use mcp descriptor reports missing permissions`() {
|
||||
let descriptors = MacNodeModeCoordinator.resolvedMcpServers(permissions: [
|
||||
"accessibility": true,
|
||||
"screenRecording": false,
|
||||
])
|
||||
|
||||
#expect(descriptors.count == 1)
|
||||
#expect(descriptors.first?.id == "computer-use")
|
||||
#expect(descriptors.first?.status == "missing_permissions")
|
||||
#expect(descriptors.first?.requiredpermissions == ["accessibility", "screenRecording"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,50 +165,4 @@ struct MenuSessionsInjectorTests {
|
||||
#expect(usageCostItem?.submenu != nil)
|
||||
#expect(usageCostItem?.submenu?.delegate == nil)
|
||||
}
|
||||
|
||||
@Test func `node status text distinguishes paired disconnected nodes`() {
|
||||
let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false)
|
||||
let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false)
|
||||
let connected = Self.node(id: "connected", paired: true, connected: true)
|
||||
|
||||
#expect(NodeMenuEntryFormatter.roleText(pairedDisconnected) == "paired · disconnected")
|
||||
#expect(NodeMenuEntryFormatter.roleText(unpairedDisconnected) == "unpaired · disconnected")
|
||||
#expect(NodeMenuEntryFormatter.roleText(connected) == "paired · connected")
|
||||
}
|
||||
|
||||
@Test func `sorted node entries include paired disconnected nodes`() {
|
||||
let injector = MenuSessionsInjector()
|
||||
defer { NodesStore.shared.nodes = [] }
|
||||
NodesStore.shared.nodes = [
|
||||
Self.node(id: "ignored", paired: false, connected: false, displayName: "Ignored"),
|
||||
Self.node(id: "paired", paired: true, connected: false, displayName: "MacBook"),
|
||||
Self.node(id: "connected", paired: true, connected: true, displayName: "iPhone"),
|
||||
]
|
||||
|
||||
let entries = injector.testingSortedNodeEntries()
|
||||
#expect(entries.map(\.nodeId) == ["connected", "paired"])
|
||||
}
|
||||
|
||||
private static func node(
|
||||
id: String,
|
||||
paired: Bool,
|
||||
connected: Bool,
|
||||
displayName: String? = nil) -> NodeInfo
|
||||
{
|
||||
NodeInfo(
|
||||
nodeId: id,
|
||||
displayName: displayName ?? id,
|
||||
platform: "macOS 26.3.1",
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
uiVersion: nil,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: nil,
|
||||
remoteIp: nil,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: paired,
|
||||
connected: connected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ public enum OpenClawCapability: String, Codable, Sendable {
|
||||
case browser
|
||||
case camera
|
||||
case screen
|
||||
case mcpHost
|
||||
case voiceWake
|
||||
case location
|
||||
case device
|
||||
|
||||
@@ -82,7 +82,6 @@ public struct GatewayConnectOptions: Sendable {
|
||||
public var caps: [String]
|
||||
public var commands: [String]
|
||||
public var permissions: [String: Bool]
|
||||
public var mcpServers: [NodeMcpServerDescriptor]
|
||||
public var clientId: String
|
||||
public var clientMode: String
|
||||
public var clientDisplayName: String?
|
||||
@@ -97,7 +96,6 @@ public struct GatewayConnectOptions: Sendable {
|
||||
caps: [String],
|
||||
commands: [String],
|
||||
permissions: [String: Bool],
|
||||
mcpServers: [NodeMcpServerDescriptor] = [],
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
clientDisplayName: String?,
|
||||
@@ -108,7 +106,6 @@ public struct GatewayConnectOptions: Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.mcpServers = mcpServers
|
||||
self.clientId = clientId
|
||||
self.clientMode = clientMode
|
||||
self.clientDisplayName = clientDisplayName
|
||||
@@ -423,9 +420,6 @@ public actor GatewayChannelActor {
|
||||
if !options.permissions.isEmpty {
|
||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||
}
|
||||
if !options.mcpServers.isEmpty {
|
||||
params["mcpServers"] = ProtoAnyCodable(options.mcpServers.map(Self.encodeMcpServerDescriptor))
|
||||
}
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity
|
||||
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
|
||||
let selectedAuth = self.selectConnectAuth(
|
||||
@@ -505,34 +499,6 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
static func encodeMcpServerDescriptor(_ descriptor: NodeMcpServerDescriptor) -> [String: Any] {
|
||||
var encoded: [String: Any] = [
|
||||
"id": descriptor.id,
|
||||
]
|
||||
if let displayname = descriptor.displayname {
|
||||
encoded["displayName"] = displayname
|
||||
}
|
||||
if let provider = descriptor.provider {
|
||||
encoded["provider"] = provider
|
||||
}
|
||||
if let transport = descriptor.transport {
|
||||
encoded["transport"] = transport
|
||||
}
|
||||
if let source = descriptor.source {
|
||||
encoded["source"] = source
|
||||
}
|
||||
if let status = descriptor.status {
|
||||
encoded["status"] = status
|
||||
}
|
||||
if let requiredpermissions = descriptor.requiredpermissions {
|
||||
encoded["requiredPermissions"] = requiredpermissions
|
||||
}
|
||||
if let metadata = descriptor.metadata {
|
||||
encoded["metadata"] = metadata
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
private func selectConnectAuth(
|
||||
role: String,
|
||||
includeDeviceIdentity: Bool,
|
||||
@@ -1044,13 +1010,10 @@ public actor GatewayChannelActor {
|
||||
|
||||
/// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError || error is GatewayTLSValidationError {
|
||||
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
|
||||
return error
|
||||
}
|
||||
if let urlError = error as? URLError {
|
||||
if let failure = (self.session as? GatewayTLSFailureProviding)?.consumeLastTLSFailure() {
|
||||
return GatewayTLSValidationError(failure: failure, context: context)
|
||||
}
|
||||
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
||||
return NSError(
|
||||
domain: URLError.errorDomain,
|
||||
|
||||
@@ -30,9 +30,6 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
|
||||
case connectionRefused
|
||||
case reachabilityFailed
|
||||
case websocketCancelled
|
||||
case tlsPinMismatch
|
||||
case tlsCertificateUntrusted
|
||||
case tlsCertificateUnavailable
|
||||
case unknown
|
||||
}
|
||||
|
||||
@@ -173,9 +170,6 @@ public enum GatewayConnectionProblemMapper {
|
||||
if let responseError = error as? GatewayResponseError {
|
||||
return self.map(responseError)
|
||||
}
|
||||
if let tlsError = error as? GatewayTLSValidationError {
|
||||
return self.map(tlsError)
|
||||
}
|
||||
return self.mapTransportError(error)
|
||||
}
|
||||
|
||||
@@ -524,51 +518,6 @@ public enum GatewayConnectionProblemMapper {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func map(_ tlsError: GatewayTLSValidationError) -> GatewayConnectionProblem {
|
||||
let failure = tlsError.failure
|
||||
switch failure.kind {
|
||||
case .pinMismatch:
|
||||
let trustedSuffix = failure.systemTrustOk
|
||||
? " The new certificate is trusted by this device; this is commonly caused by certificate rotation."
|
||||
: " This device could not verify the new certificate."
|
||||
return GatewayConnectionProblem(
|
||||
kind: .tlsPinMismatch,
|
||||
owner: failure.systemTrustOk ? .network : .unknown,
|
||||
title: "Gateway certificate changed",
|
||||
message: "The saved TLS certificate pin for \(failure.host) no longer matches the gateway certificate.\(trustedSuffix)",
|
||||
actionLabel: "Review certificate",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
technicalDetails: tlsError.localizedDescription)
|
||||
case .certificateUnavailable:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .tlsCertificateUnavailable,
|
||||
owner: .network,
|
||||
title: "Gateway certificate unavailable",
|
||||
message: "OpenClaw could not read the gateway certificate for \(failure.host).",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: tlsError.localizedDescription)
|
||||
case .untrustedCertificate:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .tlsCertificateUntrusted,
|
||||
owner: .network,
|
||||
title: "Gateway certificate is not trusted",
|
||||
message: "This device does not trust the TLS certificate presented by \(failure.host).",
|
||||
actionLabel: "Check certificate",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
technicalDetails: tlsError.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
|
||||
let nsError = error as NSError
|
||||
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
|
||||
|
||||
@@ -70,9 +70,6 @@ public actor GatewayNodeSession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
private var onMcpSessionOpen: (@Sendable (NodeMcpSessionOpenEvent) async -> Void)?
|
||||
private var onMcpSessionInput: (@Sendable (NodeMcpSessionInputEvent) async -> Void)?
|
||||
private var onMcpSessionClose: (@Sendable (NodeMcpSessionCloseEvent) async -> Void)?
|
||||
private var hasEverConnected = false
|
||||
private var hasNotifiedConnected = false
|
||||
private var snapshotReceived = false
|
||||
@@ -170,20 +167,6 @@ public actor GatewayNodeSession {
|
||||
let scopes = sorted(options.scopes)
|
||||
let caps = sorted(options.caps)
|
||||
let commands = sorted(options.commands)
|
||||
let mcpServers = options.mcpServers
|
||||
.map { descriptor in
|
||||
[
|
||||
descriptor.id,
|
||||
descriptor.displayname ?? "",
|
||||
descriptor.provider ?? "",
|
||||
descriptor.transport ?? "",
|
||||
descriptor.source ?? "",
|
||||
descriptor.status ?? "",
|
||||
(descriptor.requiredpermissions ?? []).sorted().joined(separator: ","),
|
||||
].joined(separator: ":")
|
||||
}
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -201,7 +184,6 @@ public actor GatewayNodeSession {
|
||||
scopes,
|
||||
caps,
|
||||
commands,
|
||||
mcpServers,
|
||||
clientId,
|
||||
clientMode,
|
||||
clientDisplayName,
|
||||
@@ -219,10 +201,7 @@ public actor GatewayNodeSession {
|
||||
sessionBox: WebSocketSessionBox?,
|
||||
onConnected: @escaping @Sendable () async -> Void,
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
|
||||
onMcpSessionOpen: (@Sendable (NodeMcpSessionOpenEvent) async -> Void)? = nil,
|
||||
onMcpSessionInput: (@Sendable (NodeMcpSessionInputEvent) async -> Void)? = nil,
|
||||
onMcpSessionClose: (@Sendable (NodeMcpSessionCloseEvent) async -> Void)? = nil) async throws
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||
let shouldReconnect = self.activeURL != url ||
|
||||
@@ -236,9 +215,6 @@ public actor GatewayNodeSession {
|
||||
self.onConnected = onConnected
|
||||
self.onDisconnected = onDisconnected
|
||||
self.onInvoke = onInvoke
|
||||
self.onMcpSessionOpen = onMcpSessionOpen
|
||||
self.onMcpSessionInput = onMcpSessionInput
|
||||
self.onMcpSessionClose = onMcpSessionClose
|
||||
|
||||
if shouldReconnect {
|
||||
self.resetConnectionState()
|
||||
@@ -462,25 +438,11 @@ public actor GatewayNodeSession {
|
||||
|
||||
private func handleEvent(_ evt: EventFrame) async {
|
||||
self.broadcastServerEvent(evt)
|
||||
switch evt.event {
|
||||
case "node.invoke.request":
|
||||
await self.handleInvokeEvent(evt)
|
||||
case "node.mcp.session.open":
|
||||
await self.handleMcpSessionOpenEvent(evt)
|
||||
case "node.mcp.session.input":
|
||||
await self.handleMcpSessionInputEvent(evt)
|
||||
case "node.mcp.session.close":
|
||||
await self.handleMcpSessionCloseEvent(evt)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func handleInvokeEvent(_ evt: EventFrame) async {
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
self.logger.info("node invoke request received")
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let request = try self.decodePayload(NodeInvokeRequestPayload.self, from: payload)
|
||||
let request = try self.decodeInvokeRequest(from: payload)
|
||||
let timeoutLabel = request.timeoutMs.map(String.init) ?? "none"
|
||||
self.logger.info(
|
||||
"node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)")
|
||||
@@ -502,43 +464,13 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMcpSessionOpenEvent(_ evt: EventFrame) async {
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let event = try self.decodePayload(NodeMcpSessionOpenEvent.self, from: payload)
|
||||
await self.onMcpSessionOpen?(event)
|
||||
} catch {
|
||||
self.logger.error("node MCP open decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMcpSessionInputEvent(_ evt: EventFrame) async {
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let event = try self.decodePayload(NodeMcpSessionInputEvent.self, from: payload)
|
||||
await self.onMcpSessionInput?(event)
|
||||
} catch {
|
||||
self.logger.error("node MCP input decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMcpSessionCloseEvent(_ evt: EventFrame) async {
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let event = try self.decodePayload(NodeMcpSessionCloseEvent.self, from: payload)
|
||||
await self.onMcpSessionClose?(event)
|
||||
} catch {
|
||||
self.logger.error("node MCP close decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func decodePayload<T: Decodable>(_ type: T.Type, from payload: OpenClawProtocol.AnyCodable) throws -> T {
|
||||
private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload {
|
||||
do {
|
||||
let data = try self.encoder.encode(payload)
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
} catch {
|
||||
if let raw = payload.value as? String, let data = raw.data(using: .utf8) {
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -570,107 +502,6 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
}
|
||||
|
||||
public func sendMcpSessionOpenResult(_ result: NodeMcpSessionOpenResultParams) async {
|
||||
guard let channel = self.channel else { return }
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(result.sessionid),
|
||||
"nodeId": AnyCodable(result.nodeid),
|
||||
"serverId": AnyCodable(result.serverid),
|
||||
"ok": AnyCodable(result.ok),
|
||||
]
|
||||
if let pid = result.pid {
|
||||
params["pid"] = AnyCodable(pid)
|
||||
}
|
||||
if let error = result.error {
|
||||
params["error"] = AnyCodable(error)
|
||||
}
|
||||
do {
|
||||
try await channel.send(method: "node.mcp.session.open.result", params: params)
|
||||
} catch {
|
||||
self.logger.error("node MCP open result failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func sendMcpSessionOutput(_ output: NodeMcpSessionOutputParams) async {
|
||||
guard let channel = self.channel else { return }
|
||||
let params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(output.sessionid),
|
||||
"nodeId": AnyCodable(output.nodeid),
|
||||
"seq": AnyCodable(output.seq),
|
||||
"stream": AnyCodable(output.stream),
|
||||
"dataBase64": AnyCodable(output.database64),
|
||||
]
|
||||
do {
|
||||
try await channel.send(method: "node.mcp.session.output", params: params)
|
||||
} catch {
|
||||
self.logger.error("node MCP output failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func sendMcpSessionClosed(_ closed: NodeMcpSessionClosedParams) async {
|
||||
guard let channel = self.channel else { return }
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(closed.sessionid),
|
||||
"nodeId": AnyCodable(closed.nodeid),
|
||||
"ok": AnyCodable(closed.ok),
|
||||
]
|
||||
if let exitcode = closed.exitcode {
|
||||
params["exitCode"] = exitcode
|
||||
}
|
||||
if let signal = closed.signal {
|
||||
params["signal"] = signal
|
||||
}
|
||||
if let error = closed.error {
|
||||
params["error"] = AnyCodable(error)
|
||||
}
|
||||
do {
|
||||
try await channel.send(method: "node.mcp.session.closed", params: params)
|
||||
} catch {
|
||||
self.logger.error("node MCP closed failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func sendMcpServersUpdate(nodeId: String, mcpServers: [NodeMcpServerDescriptor]) async {
|
||||
guard let channel = self.channel else { return }
|
||||
let params: [String: AnyCodable] = [
|
||||
"nodeId": AnyCodable(nodeId),
|
||||
"mcpServers": AnyCodable(mcpServers.map(Self.encodeMcpServerDescriptor)),
|
||||
]
|
||||
do {
|
||||
try await channel.send(method: "node.mcp.servers.update", params: params)
|
||||
} catch {
|
||||
self.logger.error("node MCP server update failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func encodeMcpServerDescriptor(_ descriptor: NodeMcpServerDescriptor) -> [String: Any] {
|
||||
var encoded: [String: Any] = [
|
||||
"id": descriptor.id,
|
||||
]
|
||||
if let displayname = descriptor.displayname {
|
||||
encoded["displayName"] = displayname
|
||||
}
|
||||
if let provider = descriptor.provider {
|
||||
encoded["provider"] = provider
|
||||
}
|
||||
if let transport = descriptor.transport {
|
||||
encoded["transport"] = transport
|
||||
}
|
||||
if let source = descriptor.source {
|
||||
encoded["source"] = source
|
||||
}
|
||||
if let status = descriptor.status {
|
||||
encoded["status"] = status
|
||||
}
|
||||
if let requiredpermissions = descriptor.requiredpermissions {
|
||||
encoded["requiredPermissions"] = requiredpermissions
|
||||
}
|
||||
if let metadata = descriptor.metadata {
|
||||
encoded["metadata"] = metadata
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
private func decodeParamsJSON(
|
||||
_ paramsJSON: String?) throws -> [String: AnyCodable]?
|
||||
{
|
||||
|
||||
@@ -16,65 +16,6 @@ public struct GatewayTLSParams: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayTLSValidationFailureKind: String, Sendable {
|
||||
case pinMismatch
|
||||
case certificateUnavailable
|
||||
case untrustedCertificate
|
||||
}
|
||||
|
||||
public struct GatewayTLSValidationFailure: Equatable, Sendable {
|
||||
public let kind: GatewayTLSValidationFailureKind
|
||||
public let host: String
|
||||
public let storeKey: String?
|
||||
public let expectedFingerprint: String?
|
||||
public let observedFingerprint: String?
|
||||
public let systemTrustOk: Bool
|
||||
|
||||
public init(
|
||||
kind: GatewayTLSValidationFailureKind,
|
||||
host: String,
|
||||
storeKey: String?,
|
||||
expectedFingerprint: String?,
|
||||
observedFingerprint: String?,
|
||||
systemTrustOk: Bool)
|
||||
{
|
||||
self.kind = kind
|
||||
self.host = host
|
||||
self.storeKey = storeKey
|
||||
self.expectedFingerprint = expectedFingerprint
|
||||
self.observedFingerprint = observedFingerprint
|
||||
self.systemTrustOk = systemTrustOk
|
||||
}
|
||||
}
|
||||
|
||||
public struct GatewayTLSValidationError: LocalizedError, Sendable {
|
||||
public let failure: GatewayTLSValidationFailure
|
||||
public let context: String
|
||||
|
||||
public init(failure: GatewayTLSValidationFailure, context: String) {
|
||||
self.failure = failure
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
let prefix = self.context.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch self.failure.kind {
|
||||
case .pinMismatch:
|
||||
let expected = self.failure.expectedFingerprint ?? "unknown"
|
||||
let observed = self.failure.observedFingerprint ?? "unknown"
|
||||
return "\(prefix): TLS certificate pin mismatch for \(self.failure.host) (expected \(expected), observed \(observed))"
|
||||
case .certificateUnavailable:
|
||||
return "\(prefix): TLS certificate unavailable for \(self.failure.host)"
|
||||
case .untrustedCertificate:
|
||||
return "\(prefix): TLS certificate is not trusted for \(self.failure.host)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol GatewayTLSFailureProviding: AnyObject {
|
||||
func consumeLastTLSFailure() -> GatewayTLSValidationFailure?
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
private static let keychainService = "ai.openclaw.tls-pinning"
|
||||
|
||||
@@ -94,15 +35,6 @@ public enum GatewayTLSStore {
|
||||
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func replaceFingerprint(_ value: String, stableID: String) -> Bool {
|
||||
guard GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID) else {
|
||||
return false
|
||||
}
|
||||
self.clearLegacyFingerprint(stableID: stableID)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func clearFingerprint(stableID: String) -> Bool {
|
||||
let removedKeychain = GenericPasswordKeychainStore.delete(
|
||||
@@ -155,10 +87,8 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, @unchecked Sendable {
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||
private let params: GatewayTLSParams
|
||||
private let failureLock = NSLock()
|
||||
private var lastTLSFailure: GatewayTLSValidationFailure?
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.waitsForConnectivity = true
|
||||
@@ -170,26 +100,6 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
|
||||
self.failureLock.lock()
|
||||
defer { self.failureLock.unlock() }
|
||||
let failure = self.lastTLSFailure
|
||||
self.lastTLSFailure = nil
|
||||
return failure
|
||||
}
|
||||
|
||||
private func recordTLSFailure(_ failure: GatewayTLSValidationFailure) {
|
||||
self.failureLock.lock()
|
||||
self.lastTLSFailure = failure
|
||||
self.failureLock.unlock()
|
||||
}
|
||||
|
||||
private func clearTLSFailure() {
|
||||
self.failureLock.lock()
|
||||
self.lastTLSFailure = nil
|
||||
self.failureLock.unlock()
|
||||
}
|
||||
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
@@ -208,23 +118,12 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
return
|
||||
}
|
||||
|
||||
let host = challenge.protectionSpace.host
|
||||
let systemTrustOk = SecTrustEvaluateWithError(trust, nil)
|
||||
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
|
||||
let fingerprint = certificateFingerprint(trust)
|
||||
if let fingerprint {
|
||||
if let fingerprint = certificateFingerprint(trust) {
|
||||
if let expected {
|
||||
if fingerprint == expected {
|
||||
self.clearTLSFailure()
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
self.recordTLSFailure(GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: host,
|
||||
storeKey: self.params.storeKey,
|
||||
expectedFingerprint: expected,
|
||||
observedFingerprint: fingerprint,
|
||||
systemTrustOk: systemTrustOk))
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
return
|
||||
@@ -233,23 +132,15 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
self.clearTLSFailure()
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if systemTrustOk || !self.params.required {
|
||||
self.clearTLSFailure()
|
||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||
if ok || !self.params.required {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
self.recordTLSFailure(GatewayTLSValidationFailure(
|
||||
kind: fingerprint == nil ? .certificateUnavailable : .untrustedCertificate,
|
||||
host: host,
|
||||
storeKey: self.params.storeKey,
|
||||
expectedFingerprint: expected,
|
||||
observedFingerprint: fingerprint,
|
||||
systemTrustOk: false))
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let mcpservers: [NodeMcpServerDescriptor]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
@@ -45,7 +44,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
mcpservers: [NodeMcpServerDescriptor]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
@@ -60,7 +58,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.mcpservers = mcpservers
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
@@ -77,7 +74,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case mcpservers = "mcpServers"
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
@@ -837,7 +833,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
public let modelidentifier: String?
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let mcpservers: [NodeMcpServerDescriptor]?
|
||||
public let remoteip: String?
|
||||
public let silent: Bool?
|
||||
|
||||
@@ -852,7 +847,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
modelidentifier: String?,
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
mcpservers: [NodeMcpServerDescriptor]?,
|
||||
remoteip: String?,
|
||||
silent: Bool?)
|
||||
{
|
||||
@@ -866,7 +860,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
self.modelidentifier = modelidentifier
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.mcpservers = mcpservers
|
||||
self.remoteip = remoteip
|
||||
self.silent = silent
|
||||
}
|
||||
@@ -882,7 +875,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
case modelidentifier = "modelIdentifier"
|
||||
case caps
|
||||
case commands
|
||||
case mcpservers = "mcpServers"
|
||||
case remoteip = "remoteIp"
|
||||
case silent
|
||||
}
|
||||
@@ -1110,238 +1102,6 @@ public struct NodeEventResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpServerDescriptor: Codable, Sendable {
|
||||
public let id: String
|
||||
public let displayname: String?
|
||||
public let provider: String?
|
||||
public let transport: String?
|
||||
public let source: String?
|
||||
public let status: String?
|
||||
public let requiredpermissions: [String]?
|
||||
public let metadata: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
displayname: String?,
|
||||
provider: String?,
|
||||
transport: String?,
|
||||
source: String?,
|
||||
status: String?,
|
||||
requiredpermissions: [String]?,
|
||||
metadata: [String: AnyCodable]?)
|
||||
{
|
||||
self.id = id
|
||||
self.displayname = displayname
|
||||
self.provider = provider
|
||||
self.transport = transport
|
||||
self.source = source
|
||||
self.status = status
|
||||
self.requiredpermissions = requiredpermissions
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case displayname = "displayName"
|
||||
case provider
|
||||
case transport
|
||||
case source
|
||||
case status
|
||||
case requiredpermissions = "requiredPermissions"
|
||||
case metadata
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpServersUpdateParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let mcpservers: [NodeMcpServerDescriptor]
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
mcpservers: [NodeMcpServerDescriptor])
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.mcpservers = mcpservers
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case mcpservers = "mcpServers"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionOpenEvent: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let serverid: String
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
serverid: String,
|
||||
timeoutms: Int?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.serverid = serverid
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case serverid = "serverId"
|
||||
case timeoutms = "timeoutMs"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionOpenResultParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let serverid: String
|
||||
public let ok: Bool
|
||||
public let pid: Int?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
serverid: String,
|
||||
ok: Bool,
|
||||
pid: Int?,
|
||||
error: [String: AnyCodable]?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.serverid = serverid
|
||||
self.ok = ok
|
||||
self.pid = pid
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case serverid = "serverId"
|
||||
case ok
|
||||
case pid
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionInputEvent: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let seq: Int
|
||||
public let database64: String
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
seq: Int,
|
||||
database64: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.seq = seq
|
||||
self.database64 = database64
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case seq
|
||||
case database64 = "dataBase64"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionOutputParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let seq: Int
|
||||
public let stream: String
|
||||
public let database64: String
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
seq: Int,
|
||||
stream: String,
|
||||
database64: String)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.seq = seq
|
||||
self.stream = stream
|
||||
self.database64 = database64
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case seq
|
||||
case stream
|
||||
case database64 = "dataBase64"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionCloseEvent: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let reason: String?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
reason: String?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case reason
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeMcpSessionClosedParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let nodeid: String
|
||||
public let ok: Bool
|
||||
public let exitcode: AnyCodable?
|
||||
public let signal: AnyCodable?
|
||||
public let error: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
nodeid: String,
|
||||
ok: Bool,
|
||||
exitcode: AnyCodable?,
|
||||
signal: AnyCodable?,
|
||||
error: [String: AnyCodable]?)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.nodeid = nodeid
|
||||
self.ok = ok
|
||||
self.exitcode = exitcode
|
||||
self.signal = signal
|
||||
self.error = error
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionid = "sessionId"
|
||||
case nodeid = "nodeId"
|
||||
case ok
|
||||
case exitcode = "exitCode"
|
||||
case signal
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePresenceAlivePayload: Codable, Sendable {
|
||||
public let trigger: NodePresenceAliveReason
|
||||
public let sentatms: Int?
|
||||
|
||||
@@ -89,41 +89,4 @@ import Testing
|
||||
|
||||
#expect(mapped == nil)
|
||||
}
|
||||
|
||||
@Test func tlsPinMismatchMapsToActionableProblem() {
|
||||
let error = GatewayTLSValidationError(
|
||||
failure: GatewayTLSValidationFailure(
|
||||
kind: .pinMismatch,
|
||||
host: "gateway.example.ts.net",
|
||||
storeKey: "gateway.example.ts.net:443",
|
||||
expectedFingerprint: "old",
|
||||
observedFingerprint: "new",
|
||||
systemTrustOk: true),
|
||||
context: "connect to gateway")
|
||||
|
||||
let problem = GatewayConnectionProblemMapper.map(error: error)
|
||||
|
||||
#expect(problem?.kind == .tlsPinMismatch)
|
||||
#expect(problem?.retryable == false)
|
||||
#expect(problem?.pauseReconnect == true)
|
||||
#expect(problem?.actionLabel == "Review certificate")
|
||||
}
|
||||
|
||||
@Test func untrustedTLSCertificatePausesReconnect() {
|
||||
let error = GatewayTLSValidationError(
|
||||
failure: GatewayTLSValidationFailure(
|
||||
kind: .untrustedCertificate,
|
||||
host: "gateway.example.com",
|
||||
storeKey: "gateway.example.com:443",
|
||||
expectedFingerprint: nil,
|
||||
observedFingerprint: nil,
|
||||
systemTrustOk: false),
|
||||
context: "connect to gateway")
|
||||
|
||||
let problem = GatewayConnectionProblemMapper.map(error: error)
|
||||
|
||||
#expect(problem?.kind == .tlsCertificateUntrusted)
|
||||
#expect(problem?.retryable == false)
|
||||
#expect(problem?.pauseReconnect == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
f2f5dc47ab9572fa5f80eb01b5a176edb04ca91c7a25bea3b9ea8e19dd21904b config-baseline.json
|
||||
d81f9cadab9762a4b542795ed1f01f27e374f9811cf176f08cbbb7a20b044c15 config-baseline.core.json
|
||||
c3bcb3a3da46bbbe15a7798869911cab109df950ee51c79fd86c96bb809dfdf1 config-baseline.json
|
||||
8f573caa7f4cf01ae9d4805d3d14e1ba6772f651f6da182baaf2b469592749a4 config-baseline.core.json
|
||||
92712871defa92eeda8161b516db85574681f2b70678b940508a808b987aeae2 config-baseline.channel.json
|
||||
6005cf9f6e8c9f25ef97207b5eee29ae0e506cf910cdeca77fc9894ad1755b1f config-baseline.plugin.json
|
||||
aca3215b7382af82b5060d73c631a7f82661c6e99193fa5eb1c5b4b499fb657b config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
6cea9af695cff6b8fc785d35275ca2902d08f4788d459fb58a4c5bebb1dca591 plugin-sdk-api-baseline.json
|
||||
6710ee09800d8e1ec37b7c8335b77e2b1c318561c99bd417b929f61663c49128 plugin-sdk-api-baseline.jsonl
|
||||
dd840b7c222ca003aa5336aabff8a126e3e254474941ddab93165e0e44944ffa plugin-sdk-api-baseline.json
|
||||
443878722940029e4ae5220f3c23ffc321559b73848f6a7a3f4cab98c076924e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -247,7 +247,6 @@ openclaw tasks notify <lookup> state_changes
|
||||
Reconciliation is runtime-aware:
|
||||
|
||||
- ACP/subagent tasks check their backing child session.
|
||||
- Subagent tasks whose child session has a restart-recovery tombstone are marked lost instead of being treated as recoverable backing sessions.
|
||||
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
|
||||
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.
|
||||
|
||||
|
||||
@@ -61,9 +61,6 @@ To restore legacy automatic final replies for group/channel rooms:
|
||||
}
|
||||
```
|
||||
|
||||
The gateway hot-reloads `messages` config after the file is saved. Restart only
|
||||
when file watching or config reload is disabled in the deployment.
|
||||
|
||||
To require visible output to go through the message tool for every source chat:
|
||||
|
||||
```json5
|
||||
@@ -257,7 +254,6 @@ Control how group/room messages are handled per channel:
|
||||
<Accordion title="Per-channel notes">
|
||||
- `groupPolicy` is separate from mention-gating (which requires @mentions).
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Signal: `groupAllowFrom` can match either the inbound Signal group id or the sender phone/UUID.
|
||||
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `channels.slack.channels`.
|
||||
@@ -332,7 +328,6 @@ Replying to a bot message counts as an implicit mention when the channel support
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
- Allowlisting a group or sender does not disable mention gating; set that group's `requireMention` to `false` when all messages should trigger.
|
||||
- Group chat prompt context carries the resolved silent-reply instruction every turn; workspace files should not duplicate `NO_REPLY` mechanics.
|
||||
- Groups where silent replies are allowed treat clean empty or reasoning-only model turns as silent, equivalent to `NO_REPLY`. Direct chats do the same only when direct silent replies are explicitly allowed; otherwise empty replies remain failed agent turns.
|
||||
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
|
||||
|
||||
@@ -239,15 +239,12 @@ Built-in commands intercepted before the AI queue:
|
||||
| `/bot-ping` | Latency test |
|
||||
| `/bot-version` | Show the OpenClaw framework version |
|
||||
| `/bot-help` | List all commands |
|
||||
| `/bot-me` | Show the sender's QQ user ID (openid) for `allowFrom`/`groupAllowFrom` setup |
|
||||
| `/bot-upgrade` | Show the QQBot upgrade guide link |
|
||||
| `/bot-logs` | Export recent gateway logs as a file |
|
||||
| `/bot-approve` | Approve a pending QQ Bot action (for example, confirming a C2C or group upload) through the native flow. |
|
||||
|
||||
Append `?` to any command for usage help (for example `/bot-upgrade ?`).
|
||||
|
||||
Admin commands (`/bot-me`, `/bot-upgrade`, `/bot-logs`, `/bot-clear-storage`, `/bot-streaming`, `/bot-approve`) are direct-message-only and require the sender's openid in an explicit non-wildcard `allowFrom` list. A wildcard `allowFrom: ["*"]` permits chat but does not grant admin command access. Group messages match against `groupAllowFrom` first and fall back to `allowFrom`. Running an admin command in a group returns a hint rather than silently dropping.
|
||||
|
||||
## Engine architecture
|
||||
|
||||
QQ Bot ships as a self-contained engine inside the plugin:
|
||||
|
||||
@@ -194,10 +194,9 @@ DMs:
|
||||
Groups:
|
||||
|
||||
- `channels.signal.groupPolicy = open | allowlist | disabled`.
|
||||
- `channels.signal.groupAllowFrom` controls which groups or senders can trigger group replies when `allowlist` is set; entries can be Signal group IDs (raw, `group:<id>`, or `signal:group:<id>`), sender phone numbers, `uuid:<id>` values, or `*`.
|
||||
- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
|
||||
- `channels.signal.groups["<group-id>" | "*"]` can override group behavior with `requireMention`, `tools`, and `toolsBySender`.
|
||||
- Use `channels.signal.accounts.<id>.groups` for per-account overrides in multi-account setups.
|
||||
- Allowlisting a Signal group through `groupAllowFrom` does not disable mention gating by itself. A specifically configured `channels.signal.groups["<group-id>"]` entry processes every group message unless `requireMention=true` is set.
|
||||
- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
## How it works (behavior)
|
||||
@@ -315,7 +314,7 @@ Provider options:
|
||||
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group allowlist; accepts Signal group IDs (raw, `group:<id>`, or `signal:group:<id>`), sender E.164 numbers, or `uuid:<id>` values.
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.groups`: per-group overrides keyed by Signal group id (or `"*"`). Supported fields: `requireMention`, `tools`, `toolsBySender`.
|
||||
- `channels.signal.accounts.<id>.groups`: per-account version of `channels.signal.groups` for multi-account setups.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -582,8 +582,6 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
- `toolsBySender` key format: `id:`, `e164:`, `username:`, `name:`, or `"*"` wildcard
|
||||
(legacy unprefixed keys still map to `id:` only)
|
||||
|
||||
`allowBots` is conservative for channels and private channels: bot-authored room messages are accepted only when the sending bot is explicitly listed in that room's `users` allowlist, or when at least one explicit Slack owner ID from `channels.slack.allowFrom` is currently a room member. Wildcards and display-name owner entries do not satisfy owner presence. Owner presence uses Slack `conversations.members`; make sure the app has the matching read scope for the room type (`channels:read` for public channels, `groups:read` for private channels). If the member lookup fails, OpenClaw drops the bot-authored room message.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -310,6 +310,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
|
||||
|
||||
If native draft transport is unavailable/rejected, OpenClaw automatically falls back to `sendMessage` + `editMessageText`.
|
||||
|
||||
Telegram-only reasoning stream:
|
||||
|
||||
- `/reasoning stream` sends reasoning to the live preview while generating
|
||||
@@ -724,7 +726,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `channels.telegram.textChunkLimit` default is 4000.
|
||||
- `channels.telegram.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting.
|
||||
- `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound Telegram media size.
|
||||
- `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Long-polling bot clients clamp configured values below the 45-second `getUpdates` request guard so idle polls are not aborted before the 30-second poll window completes.
|
||||
- `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies).
|
||||
- `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts.
|
||||
- group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables.
|
||||
- reply/quote/forward supplemental context is currently passed as received.
|
||||
@@ -864,7 +866,6 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
|
||||
- Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch.
|
||||
- Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures.
|
||||
- If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors.
|
||||
- If Telegram sockets recycle on a short fixed cadence, check for a low `channels.telegram.timeoutSeconds`; long-polling bot clients clamp configured values below the `getUpdates` request guard, but older releases could abort every poll when this was set below the long-poll timeout.
|
||||
- If logs include `Polling stall detected`, OpenClaw restarts polling and rebuilds the Telegram transport after 120 seconds without completed long-poll liveness by default.
|
||||
- `openclaw channels status --probe` and `openclaw doctor` warn when a running polling account has not completed `getUpdates` after startup grace, when a running webhook account has not completed `setWebhook` after startup grace, or when the last successful polling transport activity is stale.
|
||||
- Increase `channels.telegram.pollingStallThresholdMs` only when long-running `getUpdates` calls are healthy but your host still reports false polling-stall restarts. Persistent stalls usually point to proxy, DNS, IPv6, or TLS egress issues between the host and `api.telegram.org`.
|
||||
|
||||
@@ -169,7 +169,7 @@ Keep `workflow_ref` and `package_ref` separate. `workflow_ref` is the trusted wo
|
||||
### Suite profiles
|
||||
|
||||
- `smoke` — `npm-onboard-channel-agent`, `gateway-network`, `config-reload`
|
||||
- `package` — `npm-onboard-channel-agent`, `doctor-switch`, `update-channel-switch`, `upgrade-survivor`, `bundled-channel-deps-compat`, `plugins-offline`, `plugin-update`
|
||||
- `package` — `npm-onboard-channel-agent`, `doctor-switch`, `update-channel-switch`, `bundled-channel-deps-compat`, `plugins-offline`, `plugin-update`
|
||||
- `product` — `package` plus `mcp-channels`, `cron-mcp-cleanup`, `openai-web-search-minimal`, `openwebui`
|
||||
- `full` — full Docker release-path chunks with OpenWebUI
|
||||
- `custom` — exact `docker_lanes`; required when `suite_profile=custom`
|
||||
|
||||
@@ -226,8 +226,6 @@ openclaw cron edit <job-id> --session current
|
||||
openclaw cron edit <job-id> --session "session:daily-brief"
|
||||
```
|
||||
|
||||
`openclaw cron add` warns when `--agent` is omitted on agent-turn jobs and falls back to the default agent (`main`). Pass `--agent <id>` at create time to pin a specific agent.
|
||||
|
||||
Delivery tweaks:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -52,7 +52,6 @@ Notes:
|
||||
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.
|
||||
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
|
||||
- Doctor warns when no command owner is configured. The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions. DM pairing only lets someone talk to the bot; if you approved a sender before first-owner bootstrap existed, set `commands.ownerAllowFrom` explicitly.
|
||||
- Doctor warns when Codex-mode agents are configured and personal Codex CLI assets exist in the operator's Codex home. Local Codex app-server launches use isolated per-agent homes, so use `openclaw migrate codex --dry-run` to inventory assets that should be promoted deliberately.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
|
||||
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "Migrate"
|
||||
|
||||
# `openclaw migrate`
|
||||
|
||||
Import state from another agent system through a plugin-owned migration provider. Bundled providers cover Codex CLI state, [Claude](/install/migrating-claude), and [Hermes](/install/migrating-hermes); third-party plugins can register additional providers.
|
||||
Import state from another agent system through a plugin-owned migration provider. Bundled providers cover [Claude](/install/migrating-claude) and [Hermes](/install/migrating-hermes); third-party plugins can register additional providers.
|
||||
|
||||
<Tip>
|
||||
For user-facing walkthroughs, see [Migrating from Claude](/install/migrating-claude) and [Migrating from Hermes](/install/migrating-hermes). The [migration hub](/install/migrating) lists all paths.
|
||||
@@ -19,12 +19,8 @@ For user-facing walkthroughs, see [Migrating from Claude](/install/migrating-cla
|
||||
```bash
|
||||
openclaw migrate list
|
||||
openclaw migrate claude --dry-run
|
||||
openclaw migrate codex --dry-run
|
||||
openclaw migrate codex --skill gog-vault77-google-workspace
|
||||
openclaw migrate hermes --dry-run
|
||||
openclaw migrate hermes
|
||||
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
|
||||
openclaw migrate apply codex --yes
|
||||
openclaw migrate apply claude --yes
|
||||
openclaw migrate apply hermes --yes
|
||||
openclaw migrate apply hermes --include-secrets --yes
|
||||
@@ -51,9 +47,6 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
|
||||
<ParamField path="--yes" type="boolean">
|
||||
Skip the confirmation prompt. Required in non-interactive mode.
|
||||
</ParamField>
|
||||
<ParamField path="--skill <name>" type="string">
|
||||
Select one skill copy item by skill name or item id. Repeat the flag to migrate multiple skills. When omitted, interactive Codex migrations show a checkbox selector and non-interactive migrations keep all planned skills.
|
||||
</ParamField>
|
||||
<ParamField path="--no-backup" type="boolean">
|
||||
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
|
||||
</ParamField>
|
||||
@@ -106,43 +99,6 @@ For a user-facing walkthrough, see [Migrating from Claude](/install/migrating-cl
|
||||
|
||||
Claude hooks, permissions, environment defaults, local memory, path-scoped rules, subagents, caches, plans, and project history are preserved in the migration report or reported as manual-review items. OpenClaw does not execute hooks, copy broad allowlists, or import OAuth/Desktop credential state automatically.
|
||||
|
||||
## Codex provider
|
||||
|
||||
The bundled Codex provider detects Codex CLI state at `~/.codex` by default, or
|
||||
at `CODEX_HOME` when that environment variable is set. Use `--from <path>` to
|
||||
inventory a specific Codex home.
|
||||
|
||||
Use this provider when moving to the OpenClaw Codex harness and you want to
|
||||
promote useful personal Codex CLI assets deliberately. Local Codex app-server
|
||||
launches use per-agent `CODEX_HOME` and `HOME` directories, so they do not read
|
||||
your personal Codex CLI state by default.
|
||||
|
||||
Running `openclaw migrate codex` in an interactive terminal previews the full
|
||||
plan, then opens a checkbox selector for skill copy items before the final
|
||||
apply confirmation. All skills start selected; uncheck any skill you do not want
|
||||
copied into this agent. For scripted or exact runs, pass `--skill <name>` once
|
||||
per skill, for example:
|
||||
|
||||
```bash
|
||||
openclaw migrate codex --dry-run --skill gog-vault77-google-workspace
|
||||
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
|
||||
```
|
||||
|
||||
### What Codex imports
|
||||
|
||||
- Codex CLI skill directories under `$CODEX_HOME/skills`, excluding Codex's
|
||||
`.system` cache.
|
||||
- Personal AgentSkills under `$HOME/.agents/skills`, copied into the current
|
||||
OpenClaw agent workspace when you want per-agent ownership.
|
||||
|
||||
### Manual-review Codex state
|
||||
|
||||
Codex native plugins, `config.toml`, and native `hooks/hooks.json` are not
|
||||
activated automatically. Plugins may expose MCP servers, apps, hooks, or other
|
||||
executable behavior, so the provider reports them for review instead of loading
|
||||
them into OpenClaw. Config and hook files are copied into the migration report
|
||||
for manual review.
|
||||
|
||||
## Hermes provider
|
||||
|
||||
The bundled Hermes provider detects state at `~/.hermes` by default. Use `--from <path>` when Hermes lives elsewhere.
|
||||
|
||||
@@ -32,7 +32,6 @@ wired end-to-end.
|
||||
- resolves model + auth profile and builds the pi session
|
||||
- subscribes to pi events and streams assistant/tool deltas
|
||||
- enforces timeout -> aborts run if exceeded
|
||||
- for Codex app-server turns, aborts an accepted turn that stops producing app-server progress before a terminal event
|
||||
- returns payloads + usage metadata
|
||||
4. `subscribeEmbeddedPiSession` bridges pi-agent-core events to OpenClaw `agent` stream:
|
||||
- tool events => `stream: "tool"`
|
||||
|
||||
@@ -108,7 +108,6 @@ These live under `~/.openclaw/` and should NOT be committed to the workspace rep
|
||||
|
||||
- `~/.openclaw/openclaw.json` (config)
|
||||
- `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (model auth profiles: OAuth + API keys)
|
||||
- `~/.openclaw/agents/<agentId>/agent/codex-home/` (per-agent Codex runtime account, config, skills, plugins, and native thread state)
|
||||
- `~/.openclaw/credentials/` (channel/provider state plus legacy OAuth import data)
|
||||
- `~/.openclaw/agents/<agentId>/sessions/` (session transcripts + metadata)
|
||||
- `~/.openclaw/skills/` (managed skills)
|
||||
|
||||
@@ -33,9 +33,8 @@ For multi-endpoint setups, `provider` can also be a custom
|
||||
`models.providers.<id>` entry, such as `ollama-5080`, when that provider sets
|
||||
`api: "ollama"` or another embedding adapter owner.
|
||||
|
||||
For local embeddings with no API key, set `provider: "local"`. Packaged
|
||||
installs retain the native `node-llama-cpp` runtime in OpenClaw's managed plugin
|
||||
runtime-deps tree; run `openclaw doctor --fix` if that tree needs repair.
|
||||
For local embeddings with no API key, install the optional `node-llama-cpp`
|
||||
runtime package next to OpenClaw and use `provider: "local"`.
|
||||
|
||||
Some OpenAI-compatible embedding endpoints require asymmetric labels such as
|
||||
`input_type: "query"` for searches and `input_type: "document"` or `"passage"`
|
||||
|
||||
@@ -93,11 +93,8 @@ OpenClaw keeps that boundary explicit:
|
||||
|
||||
OpenClaw separates the **prompt body** from the **command body**:
|
||||
|
||||
- `BodyForAgent`: primary model-facing text for the current message. Channel
|
||||
plugins should keep this focused on the sender's current prompt-bearing text.
|
||||
- `Body`: legacy prompt fallback. This may include channel envelopes and
|
||||
optional history wrappers, but current channels should not rely on it as the
|
||||
primary model input when `BodyForAgent` is available.
|
||||
- `Body`: prompt text sent to the agent. This may include channel envelopes and
|
||||
optional history wrappers.
|
||||
- `CommandBody`: raw user text for directive/command parsing.
|
||||
- `RawBody`: legacy alias for `CommandBody` (kept for compatibility).
|
||||
|
||||
@@ -117,8 +114,6 @@ already in the session transcript.
|
||||
Directive stripping only applies to the **current message** section so history
|
||||
remains intact. Channels that wrap history should set `CommandBody` (or
|
||||
`RawBody`) to the original message text and keep `Body` as the combined prompt.
|
||||
Structured history, reply, forwarded, and channel metadata are rendered as
|
||||
user-role untrusted context blocks during prompt assembly.
|
||||
History buffers are configurable via `messages.groupChat.historyLimit` (global
|
||||
default) and per-channel overrides like `channels.slack.historyLimit` or
|
||||
`channels.telegram.accounts.<id>.historyLimit` (set `0` to disable).
|
||||
|
||||
@@ -114,7 +114,6 @@ keys.
|
||||
|
||||
- If commands seem stuck, enable verbose logs and look for “queued for …ms” lines to confirm the queue is draining.
|
||||
- If you need queue depth, enable verbose logs and watch for queue timing lines.
|
||||
- Codex app-server runs that accept a turn and then stop emitting progress are interrupted by the Codex adapter so the active session lane can release instead of waiting for the outer run timeout.
|
||||
- When diagnostics are enabled, sessions that remain in `processing` past `diagnostics.stuckSessionWarnMs` log a stuck-session warning. Active embedded runs, active reply operations, and active lane tasks remain warning-only by default; stale startup bookkeeping with no active session work can release the affected session lane so queued work drains.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -560,7 +560,6 @@ Periodic heartbeat runs.
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
qualityGuard: { enabled: true, maxRetries: 1 },
|
||||
midTurnPrecheck: { enabled: false }, // optional Pi tool-loop pressure check
|
||||
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
|
||||
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
|
||||
truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction
|
||||
@@ -586,7 +585,6 @@ Periodic heartbeat runs.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
|
||||
- `midTurnPrecheck`: optional Pi tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
|
||||
|
||||
@@ -772,8 +772,6 @@ Group messages default to **require mention** (metadata mention or safe regex pa
|
||||
|
||||
Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. To apply the same tool-only visible-reply behavior to direct chats too, set `messages.visibleReplies: "message_tool"`.
|
||||
|
||||
The gateway hot-reloads `messages` config after the file is saved. Restart only when file watching or config reload is disabled in the deployment.
|
||||
|
||||
**Mention types:**
|
||||
|
||||
- **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
|
||||
|
||||
@@ -93,7 +93,6 @@ cat ~/.openclaw/openclaw.json
|
||||
<Accordion title="State and integrity">
|
||||
- Session lock file inspection and stale lock cleanup.
|
||||
- Session transcript repair for duplicated prompt-rewrite branches created by affected 2026.4.24 builds.
|
||||
- Wedged subagent restart-recovery tombstone detection, with `--fix` support for clearing stale aborted recovery flags so startup does not keep treating the child as restart-aborted.
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- Config file permission checks (chmod 600) when running locally.
|
||||
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
|
||||
|
||||
@@ -28,7 +28,7 @@ title: "Gateway lock"
|
||||
## Operational notes
|
||||
|
||||
- If the port is occupied by _another_ process, the error is the same; free the port or choose another with `openclaw gateway --port <port>`.
|
||||
- Under a service supervisor, a new gateway process that sees an existing healthy `/healthz` responder leaves that process in control. On systemd, the duplicate starter exits with code 78 so the default `RestartPreventExitStatus=78` stops `Restart=always` from looping on a lock or `EADDRINUSE` conflict. If the existing process never becomes healthy, retries are bounded and startup fails with a clear lock error instead of looping forever.
|
||||
- Under a service supervisor, a new gateway process that sees an existing healthy `/healthz` responder exits successfully and leaves that process in control. If the existing process never becomes healthy, retries are bounded and startup fails with a clear lock error instead of looping forever.
|
||||
- The macOS app still maintains its own lightweight PID guard before spawning the gateway; the runtime lock is enforced by the lock file plus HTTP/WebSocket bind.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -319,7 +319,7 @@ Compatibility notes for stricter OpenAI-compatible backends:
|
||||
OpenClaw process RSS/heap snapshot in diagnostics. For LM Studio/Ollama
|
||||
memory pressure, match that timestamp against the server log or macOS crash /
|
||||
jetsam log to confirm whether the model server was killed.
|
||||
- OpenClaw derives context-window preflight thresholds from the detected model window, or from the uncapped model window when `agents.defaults.contextTokens` lowers the effective window. It warns below 20% with an **8k** floor. Hard blocks use the 10% threshold with a **4k** floor, capped to the effective context window so oversized model metadata cannot reject an otherwise valid user cap. If you hit that preflight, raise the server/model context limit or choose a larger model.
|
||||
- OpenClaw warns when the detected context window is below **32k** and blocks below **16k**. If you hit that preflight, raise the server/model context limit or choose a larger model.
|
||||
- Context errors? Lower `contextWindow` or raise your server limit.
|
||||
- OpenAI-compatible server returns `messages[].content ... expected a string`?
|
||||
Add `compat.requiresStringContent: true` on that model entry.
|
||||
|
||||
@@ -252,20 +252,9 @@ Nodes declare capability claims at connect time:
|
||||
|
||||
- `caps`: high-level capability categories.
|
||||
- `commands`: command allowlist for invoke.
|
||||
- `mcpServers`: named node-hosted MCP servers the node is willing to run.
|
||||
- `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
|
||||
|
||||
The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
Nodes that advertise MCP servers should also include the `mcpHost` cap. Adding
|
||||
a new node-hosted MCP server is treated as a privileged surface and requires
|
||||
node pairing approval.
|
||||
|
||||
Node-hosted MCP sessions use a long-lived stream instead of `node.invoke`.
|
||||
The Gateway opens a named server with `node.mcp.session.open`, sends MCP stdio
|
||||
bytes with `node.mcp.session.input`, receives stdout/stderr chunks through
|
||||
`node.mcp.session.output`, and closes with `node.mcp.session.close` /
|
||||
`node.mcp.session.closed`. The Gateway only sends the named `serverId`; the node
|
||||
owns the executable path, bundle checks, permissions, and process lifecycle.
|
||||
|
||||
## Presence
|
||||
|
||||
@@ -435,7 +424,6 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `node.rename` updates a paired node label.
|
||||
- `node.invoke` forwards a command to a connected node.
|
||||
- `node.invoke.result` returns the result for an invoke request.
|
||||
- `node.mcp.session.open`, `node.mcp.session.input`, `node.mcp.session.close`, `node.mcp.session.open.result`, `node.mcp.session.output`, and `node.mcp.session.closed` carry node-hosted MCP stdio streams.
|
||||
- `node.event` carries node-originated events back into the gateway.
|
||||
- `node.canvas.capability.refresh` refreshes scoped canvas-capability tokens.
|
||||
- `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs.
|
||||
|
||||
@@ -236,7 +236,6 @@ Use this when auditing access or deciding what to back up:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **Codex runtime state**: `~/.openclaw/agents/<agentId>/agent/codex-home/`
|
||||
- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
|
||||
@@ -966,7 +965,6 @@ Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain sec
|
||||
- `openclaw.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
|
||||
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
|
||||
- `agents/<agentId>/agent/auth-profiles.json`: API keys, token profiles, OAuth tokens, and optional `keyRef`/`tokenRef`.
|
||||
- `agents/<agentId>/agent/codex-home/**`: per-agent Codex app-server account, config, skills, plugins, native thread state, and diagnostics.
|
||||
- `secrets.json` (optional): file-backed secret payload used by `file` SecretRef providers (`secrets.providers`).
|
||||
- `agents/<agentId>/agent/auth.json`: legacy compatibility file. Static `api_key` entries are scrubbed when discovered.
|
||||
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.
|
||||
|
||||
@@ -600,10 +600,10 @@ These Docker runners split into two buckets:
|
||||
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you
|
||||
explicitly want the larger exhaustive scan.
|
||||
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials.
|
||||
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract plus the keyless upgrade-survivor fixture and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs a custom package delta (`bundled-channel-deps-compat plugins-offline`) plus Telegram package QA because the release-path Docker chunks already cover the overlapping package/update/plugin lanes. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images.
|
||||
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs a custom package delta (`bundled-channel-deps-compat plugins-offline`) plus Telegram package QA because the release-path Docker chunks already cover the overlapping package/update/plugin lanes. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images.
|
||||
- Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command.
|
||||
- Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures.
|
||||
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths.
|
||||
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths.
|
||||
|
||||
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
|
||||
@@ -617,7 +617,6 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
|
||||
- Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`)
|
||||
- Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_CURRENT_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`.
|
||||
- Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status.
|
||||
- Upgrade survivor smoke: `pnpm test:docker:upgrade-survivor` installs the packed OpenClaw tarball over a dirty old-user fixture with agents, channel config, plugin allowlists, stale plugin runtime-deps state, and existing workspace/session files. It runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks config/state preservation plus startup/status budgets.
|
||||
- Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches.
|
||||
- Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`.
|
||||
- Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Override with `OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE=2026.4.22` locally, or with the Install Smoke workflow's `update_baseline_version` input on GitHub. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns.
|
||||
|
||||
@@ -83,7 +83,6 @@ node.
|
||||
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`openclaw status --json`).
|
||||
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
|
||||
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
|
||||
- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, review the certificate or switch to **Remote over SSH**.
|
||||
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
|
||||
|
||||
## Notification sounds
|
||||
|
||||
@@ -58,19 +58,6 @@ The macOS app presents itself as a node. Common commands:
|
||||
|
||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||
|
||||
For permission-sensitive MCP tools such as Computer Use, the intended model is
|
||||
for the Mac app to advertise an `mcpHost` capability with named `mcpServers`.
|
||||
The Gateway may open one of those named servers and proxy MCP stdio bytes over
|
||||
the node WebSocket, but the Mac app owns the executable mapping, signing checks,
|
||||
TCC prompts, and child-process lifetime. This keeps CLI-hatched and remote
|
||||
Gateways working while preserving the app as the macOS permission boundary.
|
||||
For Codex Computer Use, OpenClaw.app launches an approved package from its own
|
||||
managed Application Support location. `/codex computer-use install` installs the
|
||||
package through Codex app-server, streams it to the paired Mac node over the
|
||||
Gateway, and updates the node's `mcpServers` descriptor when the host is ready.
|
||||
The app can also bootstrap from bundled app resources or the standard Codex
|
||||
desktop bundle when needed.
|
||||
|
||||
Node service + app IPC:
|
||||
|
||||
- When the headless node host service is running (remote mode), it connects to the Gateway WS as a node.
|
||||
|
||||
@@ -11,14 +11,10 @@ read_when:
|
||||
|
||||
Computer Use is a Codex-native MCP plugin for local desktop control. OpenClaw
|
||||
does not vendor the desktop app, execute desktop actions itself, or bypass
|
||||
Codex permissions. OpenClaw prepares the Codex app-server plugin, then prefers a
|
||||
native OpenClaw.app node host for the permission-sensitive MCP process on macOS.
|
||||
|
||||
In the happy path, Codex app-server still runs inside the Gateway, but the
|
||||
`computer-use` MCP server is launched by OpenClaw.app. Codex sees a normal HTTP
|
||||
MCP server for the thread, while the Gateway routes those MCP frames over the
|
||||
paired node connection to the Mac app. That keeps the permission-sensitive
|
||||
process under a GUI app lineage instead of a `launchd` Gateway lineage.
|
||||
Codex permissions. The bundled `codex` plugin only prepares Codex app-server:
|
||||
it enables Codex plugin support, finds or installs the configured Codex
|
||||
Computer Use plugin, checks that the `computer-use` MCP server is available, and
|
||||
then lets Codex own the native MCP tool calls during Codex-mode turns.
|
||||
|
||||
Use this page when OpenClaw is already using the native Codex harness. For the
|
||||
runtime setup itself, see [Codex harness](/plugins/codex-harness).
|
||||
@@ -28,14 +24,8 @@ runtime setup itself, see [Codex harness](/plugins/codex-harness).
|
||||
OpenClaw.app's Peekaboo integration is separate from Codex Computer Use. The
|
||||
macOS app can host a PeekabooBridge socket so the `peekaboo` CLI can reuse the
|
||||
app's local Accessibility and Screen Recording grants for Peekaboo's own
|
||||
automation tools. Codex Computer Use does not call through the PeekabooBridge
|
||||
socket.
|
||||
|
||||
For Codex Computer Use, OpenClaw.app connects to the Gateway as a native node
|
||||
and advertises an MCP host capability. When that node reports a `computer-use`
|
||||
server, owner-initiated Codex turns get a per-thread MCP override that points to
|
||||
the Gateway's authenticated loopback proxy. The proxy opens a node-hosted MCP
|
||||
session, and the Mac app starts the configured Computer Use stdio server.
|
||||
automation tools. That bridge does not install or proxy Codex Computer Use, and
|
||||
Codex Computer Use does not call through the PeekabooBridge socket.
|
||||
|
||||
Use [Peekaboo bridge](/platforms/mac/peekaboo) when you want OpenClaw.app to be
|
||||
a permission-aware host for Peekaboo CLI automation. Use this page when a
|
||||
@@ -125,12 +115,6 @@ register the bundled Codex marketplace from
|
||||
fails. If setup still cannot make the MCP server available, the turn fails
|
||||
before the thread starts.
|
||||
|
||||
On macOS, also keep OpenClaw.app running and paired with the Gateway as a node.
|
||||
That is the path that gives Computer Use a native GUI host for Accessibility and
|
||||
Screen Recording. If OpenClaw.app is not connected, Codex may still see the
|
||||
app-server's own MCP configuration, but macOS can deny desktop automation from a
|
||||
headless `launchd` Gateway process.
|
||||
|
||||
Existing sessions keep their runtime and Codex thread binding. After changing
|
||||
`agentRuntime` or Computer Use config, use `/new` or `/reset` in the affected
|
||||
chat before testing.
|
||||
@@ -154,8 +138,7 @@ enable Codex plugin support.
|
||||
|
||||
`install` enables Codex app-server plugin support, optionally adds a configured
|
||||
marketplace source, installs or re-enables the configured plugin through Codex
|
||||
app-server, reloads MCP servers, verifies that the MCP server exposes tools, and
|
||||
copies the installed package into OpenClaw.app through the paired native node.
|
||||
app-server, reloads MCP servers, and verifies that the MCP server exposes tools.
|
||||
|
||||
## Marketplace choices
|
||||
|
||||
@@ -203,36 +186,6 @@ If you use a nonstandard Codex app path, set `computerUse.marketplacePath` to a
|
||||
local marketplace file path or run `/codex computer-use install --source
|
||||
<marketplace-source>` once.
|
||||
|
||||
The native Mac node host does not launch the MCP backend directly from the
|
||||
Gateway. OpenClaw.app owns the permission-sensitive child process. During
|
||||
`/codex computer-use install`, OpenClaw asks Codex app-server to install the
|
||||
`computer-use` plugin, resolves the plugin's local package directory, streams
|
||||
the package over the Gateway to OpenClaw.app, and stores it in OpenClaw-managed
|
||||
Application Support storage. OpenClaw.app then advertises the MCP server as
|
||||
ready without requiring a Gateway restart.
|
||||
|
||||
At launch time, OpenClaw.app resolves the backend in this order:
|
||||
|
||||
1. `OPENCLAW_COMPUTER_USE_MCP_COMMAND`, with optional
|
||||
`OPENCLAW_COMPUTER_USE_MCP_ARGS` as a JSON array.
|
||||
2. `OPENCLAW_COMPUTER_USE_MCP_PACKAGE_DIR`, pointing at a package directory
|
||||
that contains `.mcp.json`.
|
||||
3. The OpenClaw-managed package under
|
||||
`~/Library/Application Support/OpenClaw/CodexComputerUseMCP/computer-use`.
|
||||
4. An approved Computer Use package bundled with OpenClaw.app resources at
|
||||
`CodexComputerUseMCP/computer-use`.
|
||||
5. The standard Codex desktop bundle at
|
||||
`/Applications/Codex.app/Contents/Resources/plugins/openai-bundled/plugins/computer-use`.
|
||||
|
||||
When OpenClaw.app receives or finds an approved package, it copies the whole
|
||||
`computer-use` directory into OpenClaw-managed Application Support storage and
|
||||
launches the stdio server from that managed copy. Copying the package preserves
|
||||
relative resources and nested app helpers declared by `.mcp.json`; copying only
|
||||
the executable is not enough. OpenClaw refreshes the managed copy when the
|
||||
approved source package changes. Set `OPENCLAW_COMPUTER_USE_MCP_INSTALL_DIR`
|
||||
only for development or diagnostics when you need to override the managed
|
||||
install directory.
|
||||
|
||||
## Remote catalog limit
|
||||
|
||||
Codex app-server can list and read remote-only catalog entries, but it does not
|
||||
@@ -281,9 +234,6 @@ status for chat:
|
||||
| `plugin_disabled` | Plugin is installed but disabled in Codex config. | Run install to re-enable it. |
|
||||
| `remote_install_unsupported` | Selected marketplace is remote-only. | Use `marketplaceSource` or `marketplacePath`. |
|
||||
| `mcp_missing` | Plugin is enabled, but the MCP server is unavailable. | Check Codex Computer Use and OS permissions. |
|
||||
| `package_missing` | Codex did not expose a local package path. | Use a local marketplace path or source. |
|
||||
| `native_host_missing` | No paired OpenClaw.app node can host the package. | Open and pair OpenClaw.app, then retry. |
|
||||
| `native_install_failed` | Package transfer or native install failed. | Check Gateway and OpenClaw.app logs. |
|
||||
| `ready` | Plugin and MCP tools are available. | Start the Codex-mode turn. |
|
||||
| `check_failed` | A Codex app-server request failed during status check. | Check app-server connectivity and logs. |
|
||||
| `auto_install_blocked` | Turn-start setup would need to add a new source. | Run explicit install first. |
|
||||
@@ -295,23 +245,16 @@ when available, and the specific message for the failing setup step.
|
||||
|
||||
Computer Use is macOS-specific. The Codex-owned MCP server may need local OS
|
||||
permissions before it can inspect or control apps. If OpenClaw says Computer Use
|
||||
is installed but the MCP server is unavailable, verify both layers:
|
||||
is installed but the MCP server is unavailable, verify the Codex-side Computer
|
||||
Use setup first:
|
||||
|
||||
- Codex app-server is running on the same host where desktop control should
|
||||
happen.
|
||||
- The Computer Use plugin is enabled in Codex config.
|
||||
- The `computer-use` MCP server appears in Codex app-server MCP status.
|
||||
- OpenClaw.app is running, connected to the Gateway, and visible in
|
||||
`openclaw nodes status`.
|
||||
- macOS has granted the required permissions for the desktop-control app and
|
||||
the native host.
|
||||
- macOS has granted the required permissions for the desktop-control app.
|
||||
- The current host session can access the desktop being controlled.
|
||||
|
||||
The Mac node can advertise `computer-use` before permissions are fully granted.
|
||||
The Gateway still routes owner-requested MCP startup through that node so the
|
||||
native host can surface the permission flow or return a specific MCP startup
|
||||
error from the right process lineage.
|
||||
|
||||
OpenClaw intentionally fails closed when `computerUse.enabled` is true. A
|
||||
Codex-mode turn should not silently proceed without the native desktop tools
|
||||
that the config required.
|
||||
|
||||
@@ -180,10 +180,7 @@ Codex after changing config.
|
||||
Codex app-server binary by default, so local `codex` commands on `PATH` do
|
||||
not affect normal harness startup.
|
||||
- Codex auth available to the app-server process or to OpenClaw's Codex auth
|
||||
bridge. Local app-server launches use an OpenClaw-managed Codex home for each
|
||||
agent and an isolated child `HOME`, so they do not read your personal
|
||||
`~/.codex` account, skills, plugins, config, thread state, or native
|
||||
`$HOME/.agents/skills` by default.
|
||||
bridge.
|
||||
|
||||
The plugin blocks older or unversioned app-server handshakes. That keeps
|
||||
OpenClaw on the protocol surface it has been tested against.
|
||||
@@ -514,33 +511,11 @@ For an already-running app-server, use WebSocket transport:
|
||||
```
|
||||
|
||||
Stdio app-server launches inherit OpenClaw's process environment by default,
|
||||
but OpenClaw owns the Codex app-server account bridge and sets both
|
||||
`CODEX_HOME` and `HOME` to per-agent directories under that agent's OpenClaw
|
||||
state. Codex's own skill loader reads `$CODEX_HOME/skills` and
|
||||
`$HOME/.agents/skills`, so both values are isolated for local app-server
|
||||
launches. That keeps Codex-native skills, plugins, config, accounts, and thread
|
||||
state scoped to the OpenClaw agent instead of leaking in from the operator's
|
||||
personal Codex CLI home.
|
||||
|
||||
OpenClaw plugins and OpenClaw skill snapshots still flow through OpenClaw's own
|
||||
plugin registry and skill loader. Personal Codex CLI assets do not. If you have
|
||||
useful Codex CLI skills or plugins that should become part of an OpenClaw agent,
|
||||
inventory them explicitly:
|
||||
|
||||
```bash
|
||||
openclaw migrate codex --dry-run
|
||||
openclaw migrate apply codex --yes
|
||||
```
|
||||
|
||||
The Codex migration provider copies skills into the current OpenClaw agent
|
||||
workspace. Codex native plugins, hooks, and config files are reported or archived
|
||||
for manual review instead of being activated automatically, because they can
|
||||
execute commands, expose MCP servers, or carry credentials.
|
||||
|
||||
Auth is selected in this order:
|
||||
but OpenClaw owns the Codex app-server account bridge. Auth is selected in this
|
||||
order:
|
||||
|
||||
1. An explicit OpenClaw Codex auth profile for the agent.
|
||||
2. The app-server's existing account in that agent's Codex home.
|
||||
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
|
||||
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
|
||||
`OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is
|
||||
still required.
|
||||
@@ -578,21 +553,21 @@ If a deployment needs additional environment isolation, add those variables to
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. `CODEX_HOME` and `HOME` are reserved for OpenClaw's per-agent Codex isolation on local launches. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
|
||||
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
|
||||
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
|
||||
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
|
||||
| Field | Default | Meaning |
|
||||
| ------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
|
||||
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
|
||||
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `mode` | `"yolo"` | Preset for YOLO or guardian-reviewed execution. |
|
||||
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
|
||||
| `sandbox` | `"danger-full-access"` | Native Codex sandbox mode sent to thread start/resume. |
|
||||
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: each Codex `item/tool/call` request must receive
|
||||
|
||||
@@ -139,7 +139,7 @@ streaming replies, proactive messaging, image/file/audio/video processing,
|
||||
Markdown formatting, built-in access control, and slash-command menus.
|
||||
|
||||
- **npm:** `openclaw-plugin-yuanbao`
|
||||
- **repo:** [github.com/YuanbaoTeam/yuanbao-openclaw-plugin](https://github.com/YuanbaoTeam/yuanbao-openclaw-plugin)
|
||||
- **repo:** [github.com/yb-claw/openclaw-plugin-yuanbao](https://github.com/yb-claw/openclaw-plugin-yuanbao)
|
||||
|
||||
```bash
|
||||
openclaw plugins install openclaw-plugin-yuanbao
|
||||
|
||||
@@ -79,8 +79,6 @@ is available to that process (for example, in `~/.openclaw/.env` or via
|
||||
V4 models support DeepSeek's `thinking` control. OpenClaw also replays
|
||||
DeepSeek `reasoning_content` on follow-up turns so thinking sessions with tool
|
||||
calls can continue.
|
||||
Use `/think xhigh` or `/think max` with DeepSeek V4 models to request DeepSeek's
|
||||
maximum `reasoning_effort`.
|
||||
</Tip>
|
||||
|
||||
## Thinking and tools
|
||||
|
||||
@@ -208,7 +208,6 @@ Choose your preferred auth method and follow the setup steps.
|
||||
| Model ref | Runtime config | Route | Auth |
|
||||
|-----------|----------------|-------|------|
|
||||
| `openai-codex/gpt-5.5` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in |
|
||||
| `openai-codex/gpt-5.4-mini` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in |
|
||||
| `openai-codex/gpt-5.5` | `runtime: "auto"` | Still PI unless a plugin explicitly claims `openai-codex` | Codex sign-in |
|
||||
| `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server auth |
|
||||
|
||||
@@ -218,6 +217,12 @@ Choose your preferred auth method and follow the setup steps.
|
||||
It does not select or auto-enable the bundled Codex app-server harness.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
`openai-codex/gpt-5.4-mini` is not a supported Codex OAuth route. Use
|
||||
`openai/gpt-5.4-mini` with an OpenAI API key, or use
|
||||
`openai-codex/gpt-5.5` with Codex OAuth.
|
||||
</Warning>
|
||||
|
||||
### Config example
|
||||
|
||||
```json5
|
||||
|
||||
@@ -284,7 +284,7 @@ For custom OpenAI-compatible endpoints or overriding provider defaults:
|
||||
| `local.modelCacheDir` | `string` | node-llama-cpp default | Cache dir for downloaded models |
|
||||
| `local.contextSize` | `number \| "auto"` | `4096` | Context window size for the embedding context. 4096 covers typical chunks (128–512 tokens) while bounding non-weight VRAM. Lower to 1024–2048 on constrained hosts. `"auto"` uses the model's trained maximum — not recommended for 8B+ models (Qwen3-Embedding-8B: 40 960 tokens → ~32 GB VRAM vs ~8.8 GB at 4096). |
|
||||
|
||||
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Packaged installs repair the native `node-llama-cpp` runtime through managed plugin runtime deps when `provider: "local"` is configured. Source checkouts still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
|
||||
Default model: `embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB, auto-downloaded). Requires native build: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`.
|
||||
|
||||
Use the standalone CLI to verify the same provider path the Gateway uses:
|
||||
|
||||
|
||||
@@ -272,20 +272,6 @@ reopen cost, not raw archival: OpenClaw still runs normal semantic compaction,
|
||||
and it requires `truncateAfterCompaction` so the compacted summary can become a
|
||||
new successor transcript.
|
||||
|
||||
For embedded Pi runs, `agents.defaults.compaction.midTurnPrecheck.enabled: true`
|
||||
adds an opt-in tool-loop guard. After a tool result is appended and before the
|
||||
next model call, OpenClaw estimates the prompt pressure using the same preflight
|
||||
budget logic used at turn start. If the context no longer fits, the guard does
|
||||
not compact inside Pi's `transformContext` hook. It raises a structured
|
||||
mid-turn precheck signal, stops the current prompt submission, and lets the
|
||||
outer run loop use the existing recovery path: truncate oversized tool results
|
||||
when that is enough, or trigger the configured compaction mode and retry. The
|
||||
option is disabled by default and works with both `default` and `safeguard`
|
||||
compaction modes, including provider-backed safeguard compaction.
|
||||
This is independent of `maxActiveTranscriptBytes`: the byte-size guard runs
|
||||
before a turn opens, while mid-turn precheck runs later in the embedded Pi tool
|
||||
loop after new tool results have been appended.
|
||||
|
||||
---
|
||||
|
||||
## Compaction settings (`reserveTokens`, `keepRecentTokens`)
|
||||
@@ -312,11 +298,6 @@ OpenClaw also enforces a safety floor for embedded runs:
|
||||
and keeps Pi's recent-tail cut point. Without an explicit keep budget,
|
||||
manual compaction remains a hard checkpoint and rebuilt context starts from
|
||||
the new summary.
|
||||
- Set `agents.defaults.compaction.midTurnPrecheck.enabled: true` to run the
|
||||
optional tool-loop precheck after new tool results and before the next model
|
||||
call. This is a trigger only; summary generation still uses the configured
|
||||
compaction path. It is independent of `maxActiveTranscriptBytes`, which is a
|
||||
turn-start active-transcript byte-size guard.
|
||||
- Set `agents.defaults.compaction.maxActiveTranscriptBytes` to a byte value or
|
||||
string such as `"20mb"` to run local compaction before a turn when the active
|
||||
transcript gets large. This guard is active only when
|
||||
|
||||
@@ -42,7 +42,6 @@ title: "Tests"
|
||||
- CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:codex`, `pnpm test:docker:live-cli-backend:codex:resume`, or `pnpm test:docker:live-cli-backend:codex:mcp`. Claude and Gemini have matching `:resume` and `:mcp` aliases.
|
||||
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
|
||||
- `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits.
|
||||
- `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale plugin runtime-deps state, startup, and RPC status survive.
|
||||
|
||||
## Local PR gate
|
||||
|
||||
|
||||
@@ -159,10 +159,6 @@ sessions and logged-in profiles, so add it explicitly with
|
||||
`tools.alsoAllow: ["browser"]` or a per-agent
|
||||
`agents.list[].tools.alsoAllow: ["browser"]`.
|
||||
|
||||
<Note>
|
||||
Configuring `tools.exec` or `tools.fs` under a restrictive profile (`messaging`, `minimal`) does not implicitly widen the profile's allowlist. Add explicit `tools.alsoAllow` entries (for example `["exec", "process"]` for exec, or `["read", "write", "edit"]` for fs) when you want a restrictive profile to use those configured sections. OpenClaw logs a startup warning when a config section is present without a matching `alsoAllow` grant.
|
||||
</Note>
|
||||
|
||||
The `coding` and `messaging` profiles also allow configured bundle MCP tools
|
||||
under the plugin key `bundle-mcp`. Add `tools.deny: ["bundle-mcp"]` when you
|
||||
want a profile to keep its normal built-ins but hide all configured MCP tools.
|
||||
|
||||
@@ -29,14 +29,6 @@ OpenClaw loads skills from these sources, **highest precedence first**:
|
||||
|
||||
If a skill name conflicts, the highest source wins.
|
||||
|
||||
Codex CLI's native `$CODEX_HOME/skills` directory is not one of these OpenClaw
|
||||
skill roots. In Codex harness mode, local app-server launches use isolated
|
||||
per-agent Codex homes, so personal Codex CLI skills are not loaded implicitly.
|
||||
Use `openclaw migrate codex --dry-run` to inventory them and
|
||||
`openclaw migrate codex` to choose skill directories with an interactive
|
||||
checkbox prompt before copying them into the current OpenClaw agent workspace.
|
||||
For non-interactive runs, repeat `--skill <name>` for the exact skills to copy.
|
||||
|
||||
## Per-agent vs shared skills
|
||||
|
||||
In **multi-agent** setups each agent has its own workspace:
|
||||
|
||||
@@ -512,14 +512,6 @@ restart-aborted child sessions remain recoverable through the sub-agent
|
||||
orphan recovery flow, which sends a synthetic resume message before
|
||||
clearing the aborted marker.
|
||||
|
||||
Automatic restart recovery is bounded per child session. If the same
|
||||
sub-agent child is accepted for orphan recovery repeatedly inside the
|
||||
rapid re-wedge window, OpenClaw persists a recovery tombstone on that
|
||||
session and stops auto-resuming it on later restarts. Run
|
||||
`openclaw tasks maintenance --apply` to reconcile the task record, or
|
||||
`openclaw doctor --fix` to clear stale aborted recovery flags on
|
||||
tombstoned sessions.
|
||||
|
||||
<Note>
|
||||
If a sub-agent spawn fails with Gateway `PAIRING_REQUIRED` /
|
||||
`scope-upgrade`, check the RPC caller before editing pairing state.
|
||||
|
||||
@@ -26,7 +26,6 @@ title: "Thinking levels"
|
||||
- Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level.
|
||||
- Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting.
|
||||
- Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path.
|
||||
- DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`.
|
||||
- Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings.
|
||||
- OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value.
|
||||
- Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers.<provider>.models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior.
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { Server } from "node:http";
|
||||
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
|
||||
|
||||
type BrowserControlOwner = "server" | "service";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
let owner: BrowserControlOwner | null = null;
|
||||
|
||||
export function getBrowserControlState(): BrowserServerState | null {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function createBrowserControlContext() {
|
||||
return createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
refreshConfigFromDisk: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureBrowserControlRuntime(params: {
|
||||
server?: Server | null;
|
||||
port: number;
|
||||
resolved: BrowserServerState["resolved"];
|
||||
owner: BrowserControlOwner;
|
||||
onWarn: (message: string) => void;
|
||||
}): Promise<BrowserServerState> {
|
||||
if (state) {
|
||||
if (params.server) {
|
||||
state.server = params.server;
|
||||
state.port = params.port;
|
||||
state.resolved = { ...params.resolved, controlPort: params.port };
|
||||
owner = "server";
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
state = await createBrowserRuntimeState({
|
||||
server: params.server ?? null,
|
||||
port: params.port,
|
||||
resolved: params.resolved,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
owner = params.owner;
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function stopBrowserControlRuntime(params: {
|
||||
requestedBy: BrowserControlOwner;
|
||||
closeServer?: boolean;
|
||||
onWarn: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const current = state;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (params.requestedBy === "service" && current.server && owner === "server") {
|
||||
return;
|
||||
}
|
||||
await stopBrowserRuntime({
|
||||
current,
|
||||
getState: () => state,
|
||||
clearState: () => {
|
||||
state = null;
|
||||
owner = null;
|
||||
},
|
||||
closeServer: params.closeServer,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
|
||||
|
||||
export function loadBrowserConfigForRuntimeRefresh(): OpenClawConfig {
|
||||
return getRuntimeConfigSourceSnapshot() ?? getRuntimeConfig();
|
||||
return getRuntimeConfig();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
replaceConfigFile,
|
||||
type BrowserConfig,
|
||||
type BrowserProfileConfig,
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
ensureBrowserControlRuntime,
|
||||
getBrowserControlState,
|
||||
stopBrowserControlRuntime,
|
||||
} from "./browser-control-state.js";
|
||||
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
|
||||
import { resolveBrowserConfig } from "./browser/config.js";
|
||||
import { ensureBrowserControlAuth } from "./browser/control-auth.js";
|
||||
import type { BrowserServerState } from "./browser/server-context.js";
|
||||
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
|
||||
import { getRuntimeConfig } from "./config/config.js";
|
||||
import { createSubsystemLogger } from "./logging/subsystem.js";
|
||||
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
const log = createSubsystemLogger("browser");
|
||||
const logService = log.child("service");
|
||||
|
||||
export function getBrowserControlState(): BrowserServerState | null {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function createBrowserControlContext() {
|
||||
return createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
refreshConfigFromDisk: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
|
||||
const current = getBrowserControlState();
|
||||
if (current) {
|
||||
return current;
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const browserCfg = loadBrowserConfigForRuntimeRefresh();
|
||||
if (!isDefaultBrowserPluginEnabled(browserCfg)) {
|
||||
if (!isDefaultBrowserPluginEnabled(cfg)) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) {
|
||||
return null;
|
||||
}
|
||||
@@ -39,11 +43,10 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||
}
|
||||
|
||||
const state = await ensureBrowserControlRuntime({
|
||||
state = await createBrowserRuntimeState({
|
||||
server: null,
|
||||
port: resolved.controlPort,
|
||||
resolved,
|
||||
owner: "service",
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
|
||||
@@ -54,10 +57,13 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
}
|
||||
|
||||
export async function stopBrowserControlService(): Promise<void> {
|
||||
await stopBrowserControlRuntime({
|
||||
requestedBy: "service",
|
||||
const current = state;
|
||||
await stopBrowserRuntime({
|
||||
current,
|
||||
getState: () => state,
|
||||
clearState: () => {
|
||||
state = null;
|
||||
},
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
}
|
||||
|
||||
export { createBrowserControlContext, getBrowserControlState };
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getFreePort } from "../browser/test-port.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runtimeConfig: {} as OpenClawConfig,
|
||||
runtimeSourceConfig: null as OpenClawConfig | null,
|
||||
ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })),
|
||||
resolveBrowserControlAuth: vi.fn(() => ({})),
|
||||
shouldAutoGenerateBrowserAuth: vi.fn(() => false),
|
||||
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
|
||||
stopKnownBrowserProfiles: vi.fn(async () => {}),
|
||||
isChromeReachable: vi.fn(async () => false),
|
||||
isChromeCdpReady: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeConfig: () => mocks.runtimeConfig,
|
||||
getRuntimeConfigSourceSnapshot: () => mocks.runtimeSourceConfig,
|
||||
loadConfig: () => mocks.runtimeConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../browser/control-auth.js", () => ({
|
||||
ensureBrowserControlAuth: mocks.ensureBrowserControlAuth,
|
||||
resolveBrowserControlAuth: mocks.resolveBrowserControlAuth,
|
||||
shouldAutoGenerateBrowserAuth: mocks.shouldAutoGenerateBrowserAuth,
|
||||
}));
|
||||
|
||||
vi.mock("../browser/server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles,
|
||||
stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles,
|
||||
}));
|
||||
|
||||
vi.mock("../browser/chrome.js", () => ({
|
||||
diagnoseChromeCdp: vi.fn(async () => ({ ok: false })),
|
||||
formatChromeCdpDiagnostic: vi.fn(() => "not reachable"),
|
||||
isChromeCdpReady: mocks.isChromeCdpReady,
|
||||
isChromeReachable: mocks.isChromeReachable,
|
||||
launchOpenClawChrome: vi.fn(async () => {
|
||||
throw new Error("launch should not be needed for status");
|
||||
}),
|
||||
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-browser"),
|
||||
stopOpenClawChrome: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../browser/pw-ai-state.js", () => ({
|
||||
isPwAiLoaded: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("../server.js");
|
||||
const { stopBrowserControlService } = await import("../control-service.js");
|
||||
const { browserHandlers } = await import("./browser-request.js");
|
||||
|
||||
function browserConfig(params: {
|
||||
gatewayPort: number;
|
||||
executablePath?: string;
|
||||
headless?: boolean;
|
||||
noSandbox?: boolean;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
gateway: {
|
||||
port: params.gatewayPort,
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "openclaw",
|
||||
...(params.executablePath ? { executablePath: params.executablePath } : {}),
|
||||
...(typeof params.headless === "boolean" ? { headless: params.headless } : {}),
|
||||
...(typeof params.noSandbox === "boolean" ? { noSandbox: params.noSandbox } : {}),
|
||||
profiles: {
|
||||
openclaw: {
|
||||
cdpPort: params.gatewayPort + 11,
|
||||
color: "#FF4500",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function browserRequestStatus(): Promise<unknown> {
|
||||
const respond = vi.fn();
|
||||
await browserHandlers["browser.request"]({
|
||||
params: {
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query: { profile: "openclaw" },
|
||||
},
|
||||
respond: respond as never,
|
||||
context: {
|
||||
nodeRegistry: {
|
||||
listConnected: () => [],
|
||||
},
|
||||
} as never,
|
||||
client: null,
|
||||
req: { type: "req", id: "req-1", method: "browser.request" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
const call = respond.mock.calls[0];
|
||||
expect(call?.[0]).toBe(true);
|
||||
return call?.[1];
|
||||
}
|
||||
|
||||
describe("browser.request local control state", () => {
|
||||
afterEach(async () => {
|
||||
await stopBrowserControlService();
|
||||
await stopBrowserControlServer();
|
||||
mocks.runtimeSourceConfig = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses the same resolved browser config as the HTTP control service", async () => {
|
||||
const controlPort = await getFreePort();
|
||||
const gatewayPort = controlPort - 2;
|
||||
|
||||
mocks.runtimeConfig = browserConfig({
|
||||
gatewayPort,
|
||||
executablePath: "/usr/bin/google-chrome",
|
||||
headless: true,
|
||||
noSandbox: true,
|
||||
});
|
||||
mocks.runtimeSourceConfig = mocks.runtimeConfig;
|
||||
const httpState = await startBrowserControlServerFromConfig();
|
||||
expect(httpState?.resolved.executablePath).toBe("/usr/bin/google-chrome");
|
||||
expect(httpState?.resolved.noSandbox).toBe(true);
|
||||
|
||||
// The runtime snapshot can lag behind source config after gateway startup;
|
||||
// browser.request must not fork a second stale control state from it.
|
||||
mocks.runtimeConfig = browserConfig({
|
||||
gatewayPort,
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
});
|
||||
|
||||
await expect(browserRequestStatus()).resolves.toMatchObject({
|
||||
executablePath: "/usr/bin/google-chrome",
|
||||
headless: true,
|
||||
noSandbox: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,19 +17,17 @@ const configMocks = vi.hoisted(() => ({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
||||
})),
|
||||
sourceConfig: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const browserConfigMocks = vi.hoisted(() => ({
|
||||
resolveBrowserConfig: vi.fn((browser?: { defaultProfile?: string }) => ({
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
enabled: true,
|
||||
defaultProfile: browser?.defaultProfile ?? "openclaw",
|
||||
defaultProfile: "openclaw",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../sdk-config.js", () => ({
|
||||
getRuntimeConfig: configMocks.loadConfig,
|
||||
getRuntimeConfigSourceSnapshot: () => configMocks.sourceConfig,
|
||||
loadConfig: configMocks.loadConfig,
|
||||
}));
|
||||
|
||||
@@ -152,7 +150,6 @@ describe("runBrowserProxyCommand", () => {
|
||||
}));
|
||||
controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true });
|
||||
controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true);
|
||||
configMocks.sourceConfig = null;
|
||||
configMocks.loadConfig.mockReset().mockReturnValue({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
||||
@@ -307,41 +304,6 @@ describe("runBrowserProxyCommand", () => {
|
||||
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the browser source snapshot for proxy default-profile decisions", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: { defaultProfile: "openclaw" },
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["work"] } },
|
||||
});
|
||||
configMocks.sourceConfig = {
|
||||
browser: { defaultProfile: "work" },
|
||||
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["work"] } },
|
||||
};
|
||||
browserConfigMocks.resolveBrowserConfig.mockImplementation(
|
||||
(browser?: { defaultProfile?: string }) => ({
|
||||
enabled: true,
|
||||
defaultProfile: browser?.defaultProfile ?? "openclaw",
|
||||
}),
|
||||
);
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 200,
|
||||
body: { ok: true },
|
||||
});
|
||||
|
||||
await runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/snapshot",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unauthorized body.profile when allowProfiles is configured", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fsPromises from "node:fs/promises";
|
||||
import { redactCdpUrl } from "../browser/cdp.helpers.js";
|
||||
import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
import {
|
||||
isPersistentBrowserProfileMutation,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
createBrowserControlContext,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "../control-service.js";
|
||||
import { getRuntimeConfig } from "../sdk-config.js";
|
||||
import { withTimeout } from "../sdk-node-runtime.js";
|
||||
import { detectMime } from "../sdk-setup-tools.js";
|
||||
|
||||
@@ -44,7 +44,7 @@ function normalizeProfileAllowlist(raw?: string[]): string[] {
|
||||
}
|
||||
|
||||
function resolveBrowserProxyConfig() {
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const cfg = getRuntimeConfig();
|
||||
const proxy = cfg.nodeHost?.browserProxy;
|
||||
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
|
||||
const enabled = proxy?.enabled !== false;
|
||||
@@ -64,7 +64,7 @@ async function ensureBrowserControlService(): Promise<void> {
|
||||
return browserControlReady;
|
||||
}
|
||||
browserControlReady = (async () => {
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) {
|
||||
throw new Error("browser control disabled");
|
||||
@@ -231,7 +231,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
await ensureBrowserControlService();
|
||||
const cfg = loadBrowserConfigForRuntimeRefresh();
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
||||
const path = normalizeBrowserRequestPath(pathValue);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti
|
||||
export {
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
export { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation";
|
||||
export {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import {
|
||||
createBrowserControlContext,
|
||||
ensureBrowserControlRuntime,
|
||||
getBrowserControlState,
|
||||
stopBrowserControlRuntime,
|
||||
} from "./browser-control-state.js";
|
||||
import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./browser/bridge-auth-registry.js";
|
||||
import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js";
|
||||
import { resolveBrowserConfig } from "./browser/config.js";
|
||||
import {
|
||||
ensureBrowserControlAuth,
|
||||
@@ -16,7 +9,8 @@ import {
|
||||
} from "./browser/control-auth.js";
|
||||
import { registerBrowserRoutes } from "./browser/routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./browser/routes/types.js";
|
||||
import type { BrowserServerState } from "./browser/server-context.js";
|
||||
import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js";
|
||||
import {
|
||||
installBrowserAuthMiddleware,
|
||||
installBrowserCommonMiddleware,
|
||||
@@ -25,21 +19,20 @@ import { getRuntimeConfig } from "./config/config.js";
|
||||
import { createSubsystemLogger } from "./logging/subsystem.js";
|
||||
import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
const log = createSubsystemLogger("browser");
|
||||
const logServer = log.child("server");
|
||||
|
||||
export async function startBrowserControlServerFromConfig(): Promise<BrowserServerState | null> {
|
||||
const current = getBrowserControlState();
|
||||
if (current?.server) {
|
||||
return current;
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const browserCfg = loadBrowserConfigForRuntimeRefresh();
|
||||
if (!isDefaultBrowserPluginEnabled(browserCfg)) {
|
||||
if (!isDefaultBrowserPluginEnabled(cfg)) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg);
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (!resolved.enabled) {
|
||||
return null;
|
||||
}
|
||||
@@ -77,7 +70,10 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
installBrowserCommonMiddleware(app);
|
||||
installBrowserAuthMiddleware(app, browserAuth);
|
||||
|
||||
const ctx = createBrowserControlContext();
|
||||
const ctx = createBrowserRouteContext({
|
||||
getState: () => state,
|
||||
refreshConfigFromDisk: true,
|
||||
});
|
||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
||||
|
||||
const port = resolved.controlPort;
|
||||
@@ -93,11 +89,10 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await ensureBrowserControlRuntime({
|
||||
state = await createBrowserRuntimeState({
|
||||
server,
|
||||
port,
|
||||
resolved,
|
||||
owner: "server",
|
||||
onWarn: (message) => logServer.warn(message),
|
||||
});
|
||||
setBridgeAuthForPort(port, browserAuth);
|
||||
@@ -108,12 +103,16 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
}
|
||||
|
||||
export async function stopBrowserControlServer(): Promise<void> {
|
||||
const current = getBrowserControlState();
|
||||
const current = state;
|
||||
if (current?.port) {
|
||||
deleteBridgeAuthForPort(current.port);
|
||||
}
|
||||
await stopBrowserControlRuntime({
|
||||
requestedBy: "server",
|
||||
await stopBrowserRuntime({
|
||||
current,
|
||||
getState: () => state,
|
||||
clearState: () => {
|
||||
state = null;
|
||||
},
|
||||
closeServer: true,
|
||||
onWarn: (message) => logServer.warn(message),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
CodexAppServerModel,
|
||||
CodexAppServerModelListResult,
|
||||
} from "./src/app-server/models.js";
|
||||
import type { NativeComputerUseInstaller } from "./src/app-server/native-computer-use-install.js";
|
||||
|
||||
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex"]);
|
||||
|
||||
@@ -15,7 +14,6 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
label?: string;
|
||||
providerIds?: Iterable<string>;
|
||||
pluginConfig?: unknown;
|
||||
nativeComputerUseInstaller?: NativeComputerUseInstaller;
|
||||
}): AgentHarness {
|
||||
const providerIds = new Set(
|
||||
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
|
||||
@@ -37,10 +35,7 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
},
|
||||
runAttempt: async (params) => {
|
||||
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
|
||||
return runCodexAppServerAttempt(params, {
|
||||
pluginConfig: options?.pluginConfig,
|
||||
nativeComputerUseInstaller: options?.nativeComputerUseInstaller,
|
||||
});
|
||||
return runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig });
|
||||
},
|
||||
compact: async (params) => {
|
||||
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("codex plugin", () => {
|
||||
const registerAgentHarness = vi.fn();
|
||||
const registerCommand = vi.fn();
|
||||
const registerMediaUnderstandingProvider = vi.fn();
|
||||
const registerMigrationProvider = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
@@ -33,7 +32,6 @@ describe("codex plugin", () => {
|
||||
registerAgentHarness,
|
||||
registerCommand,
|
||||
registerMediaUnderstandingProvider,
|
||||
registerMigrationProvider,
|
||||
registerProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
@@ -57,10 +55,6 @@ describe("codex plugin", () => {
|
||||
name: "codex",
|
||||
description: "Inspect and control the Codex app-server harness",
|
||||
});
|
||||
expect(registerMigrationProvider.mock.calls[0]?.[0]).toMatchObject({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
});
|
||||
expect(on).toHaveBeenCalledWith("inbound_claim", expect.any(Function));
|
||||
expect(onConversationBindingResolved).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -4,13 +4,11 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createCodexAppServerAgentHarness } from "./harness.js";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { buildCodexProvider } from "./provider.js";
|
||||
import { createNativeComputerUseInstaller } from "./src/app-server/native-computer-use-install.js";
|
||||
import { createCodexCommand } from "./src/commands.js";
|
||||
import {
|
||||
handleCodexConversationBindingResolved,
|
||||
handleCodexConversationInboundClaim,
|
||||
} from "./src/conversation-binding.js";
|
||||
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
@@ -25,24 +23,12 @@ export default definePluginEntry({
|
||||
"codex",
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
) ?? api.pluginConfig;
|
||||
const nativeComputerUseInstaller = createNativeComputerUseInstaller(api.runtime.nodes);
|
||||
api.registerAgentHarness(
|
||||
createCodexAppServerAgentHarness({
|
||||
pluginConfig: api.pluginConfig,
|
||||
nativeComputerUseInstaller,
|
||||
}),
|
||||
);
|
||||
api.registerAgentHarness(createCodexAppServerAgentHarness({ pluginConfig: api.pluginConfig }));
|
||||
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
);
|
||||
api.registerMigrationProvider(buildCodexMigrationProvider());
|
||||
api.registerCommand(
|
||||
createCodexCommand({
|
||||
pluginConfig: api.pluginConfig,
|
||||
nativeComputerUseInstaller,
|
||||
}),
|
||||
);
|
||||
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
"providers": ["codex"],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["codex"],
|
||||
"migrationProviders": ["codex"]
|
||||
"mediaUnderstandingProviders": ["codex"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"codex": {
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
applyCodexAppServerAuthProfile,
|
||||
bridgeCodexAppServerStartOptions,
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerNativeHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
|
||||
@@ -117,64 +115,6 @@ function createStartOptions(
|
||||
}
|
||||
|
||||
describe("bridgeCodexAppServerStartOptions", () => {
|
||||
it("sets agent-owned CODEX_HOME and HOME for local app-server launches", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions();
|
||||
try {
|
||||
const codexHome = resolveCodexAppServerHomeDir(agentDir);
|
||||
const nativeHome = resolveCodexAppServerNativeHomeDir(agentDir);
|
||||
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: nativeHome,
|
||||
},
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
expect(startOptions.env).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const codexHome = path.join(agentDir, "custom-codex-home");
|
||||
const nativeHome = path.join(agentDir, "custom-native-home");
|
||||
const startOptions = createStartOptions({
|
||||
env: { CODEX_HOME: codexHome, HOME: nativeHome, EXISTING: "1" },
|
||||
clearEnv: ["CODEX_HOME", "HOME", "FOO"],
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: nativeHome,
|
||||
EXISTING: "1",
|
||||
},
|
||||
clearEnv: ["FOO"],
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("clears inherited API-key env vars when the default Codex profile is subscription auth", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions({
|
||||
@@ -202,11 +142,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
EXISTING: "1",
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
expect(startOptions.clearEnv).toEqual(["FOO"]);
|
||||
@@ -243,10 +178,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
} finally {
|
||||
@@ -276,10 +207,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
clearEnv: ["FOO", "CODEX_API_KEY", "OPENAI_API_KEY"],
|
||||
});
|
||||
} finally {
|
||||
@@ -307,13 +234,7 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
agentDir,
|
||||
authProfileId: "openai-codex:work",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
...startOptions,
|
||||
env: {
|
||||
CODEX_HOME: resolveCodexAppServerHomeDir(agentDir),
|
||||
HOME: resolveCodexAppServerNativeHomeDir(agentDir),
|
||||
},
|
||||
});
|
||||
).resolves.toBe(startOptions);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
loadAuthProfileStoreForSecretsRuntime,
|
||||
@@ -19,14 +17,9 @@ import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js";
|
||||
|
||||
const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex";
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
const CODEX_HOME_ENV_VAR = "CODEX_HOME";
|
||||
const HOME_ENV_VAR = "HOME";
|
||||
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
|
||||
const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
|
||||
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
|
||||
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
|
||||
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
|
||||
const CODEX_APP_SERVER_ISOLATION_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR];
|
||||
|
||||
export async function bridgeCodexAppServerStartOptions(params: {
|
||||
startOptions: CodexAppServerStartOptions;
|
||||
@@ -36,64 +29,14 @@ export async function bridgeCodexAppServerStartOptions(params: {
|
||||
if (params.startOptions.transport !== "stdio") {
|
||||
return params.startOptions;
|
||||
}
|
||||
const isolatedStartOptions = await withAgentCodexHomeEnvironment(
|
||||
params.startOptions,
|
||||
params.agentDir,
|
||||
);
|
||||
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
|
||||
const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({
|
||||
store,
|
||||
authProfileId: params.authProfileId,
|
||||
});
|
||||
return shouldClearInheritedOpenAiApiKey
|
||||
? withClearedEnvironmentVariables(isolatedStartOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
|
||||
: isolatedStartOptions;
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerHomeDir(agentDir: string): string {
|
||||
return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME);
|
||||
}
|
||||
|
||||
export function resolveCodexAppServerNativeHomeDir(agentDir: string): string {
|
||||
return path.join(resolveCodexAppServerHomeDir(agentDir), CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
|
||||
}
|
||||
|
||||
async function withAgentCodexHomeEnvironment(
|
||||
startOptions: CodexAppServerStartOptions,
|
||||
agentDir: string,
|
||||
): Promise<CodexAppServerStartOptions> {
|
||||
const codexHome = startOptions.env?.[CODEX_HOME_ENV_VAR]?.trim()
|
||||
? startOptions.env[CODEX_HOME_ENV_VAR]
|
||||
: resolveCodexAppServerHomeDir(agentDir);
|
||||
const nativeHome = startOptions.env?.[HOME_ENV_VAR]?.trim()
|
||||
? startOptions.env[HOME_ENV_VAR]
|
||||
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.mkdir(nativeHome, { recursive: true });
|
||||
const nextStartOptions: CodexAppServerStartOptions = {
|
||||
...startOptions,
|
||||
env: {
|
||||
...startOptions.env,
|
||||
[CODEX_HOME_ENV_VAR]: codexHome,
|
||||
[HOME_ENV_VAR]: nativeHome,
|
||||
},
|
||||
};
|
||||
const clearEnv = withoutClearedCodexIsolationEnv(startOptions.clearEnv);
|
||||
if (clearEnv) {
|
||||
nextStartOptions.clearEnv = clearEnv;
|
||||
} else {
|
||||
delete nextStartOptions.clearEnv;
|
||||
}
|
||||
return nextStartOptions;
|
||||
}
|
||||
|
||||
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
|
||||
if (!clearEnv) {
|
||||
return undefined;
|
||||
}
|
||||
const reserved = new Set(CODEX_APP_SERVER_ISOLATION_ENV_VARS);
|
||||
const filtered = clearEnv.filter((envVar) => !reserved.has(envVar.trim().toUpperCase()));
|
||||
return filtered.length === clearEnv.length ? clearEnv : filtered;
|
||||
? withClearedEnvironmentVariables(params.startOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
|
||||
: params.startOptions;
|
||||
}
|
||||
|
||||
export async function applyCodexAppServerAuthProfile(params: {
|
||||
|
||||
@@ -49,7 +49,6 @@ describe("Codex Computer Use setup", () => {
|
||||
pluginEnabled: true,
|
||||
mcpServerAvailable: true,
|
||||
marketplaceName: "desktop-tools",
|
||||
packagePath: "/marketplaces/desktop-tools/plugins/computer-use",
|
||||
tools: ["list_apps"],
|
||||
message: "Computer Use is ready.",
|
||||
}),
|
||||
@@ -262,57 +261,6 @@ describe("Codex Computer Use setup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("copies the installed package into a native OpenClaw app host during install", async () => {
|
||||
const request = createComputerUseRequest({ installed: false });
|
||||
const nativeInstaller = vi.fn(async () => ({
|
||||
ready: true,
|
||||
nodeId: "mac-node",
|
||||
files: 2,
|
||||
bytes: 123,
|
||||
message: "Installed Computer Use package into OpenClaw.app on Mac.",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installCodexComputerUse({
|
||||
pluginConfig: { computerUse: { marketplaceName: "desktop-tools" } },
|
||||
request,
|
||||
nativeInstaller,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
nativeHost: expect.objectContaining({ ready: true, nodeId: "mac-node" }),
|
||||
message: "Computer Use is ready. Installed Computer Use package into OpenClaw.app on Mac.",
|
||||
}),
|
||||
);
|
||||
expect(nativeInstaller).toHaveBeenCalledWith({
|
||||
packagePath: "/marketplaces/desktop-tools/plugins/computer-use",
|
||||
pluginName: "computer-use",
|
||||
mcpServerName: "computer-use",
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when native OpenClaw app hosting fails during install", async () => {
|
||||
const request = createComputerUseRequest({ installed: true });
|
||||
|
||||
await expect(
|
||||
installCodexComputerUse({
|
||||
pluginConfig: { computerUse: { marketplaceName: "desktop-tools" } },
|
||||
request,
|
||||
nativeInstaller: vi.fn(async () => {
|
||||
throw new Error("No connected OpenClaw.app node can install native MCP packages.");
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: expect.objectContaining({
|
||||
ready: false,
|
||||
reason: "native_host_missing",
|
||||
nativeHost: expect.objectContaining({ ready: false }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-registers the bundled Codex app marketplace during auto-install", async () => {
|
||||
const bundledMarketplacePath = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "openclaw-codex-bundled-marketplace-"),
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
type CodexComputerUseConfig,
|
||||
type ResolvedCodexComputerUseConfig,
|
||||
} from "./config.js";
|
||||
import type {
|
||||
NativeComputerUseInstaller,
|
||||
NativeComputerUseInstallResult,
|
||||
} from "./native-computer-use-install.js";
|
||||
import type { v2 } from "./protocol-generated/typescript/index.js";
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
import { requestCodexAppServerJson } from "./request.js";
|
||||
@@ -27,9 +23,6 @@ export type CodexComputerUseStatusReason =
|
||||
| "plugin_disabled"
|
||||
| "remote_install_unsupported"
|
||||
| "mcp_missing"
|
||||
| "package_missing"
|
||||
| "native_host_missing"
|
||||
| "native_install_failed"
|
||||
| "ready"
|
||||
| "check_failed"
|
||||
| "auto_install_blocked";
|
||||
@@ -45,8 +38,6 @@ export type CodexComputerUseStatus = {
|
||||
mcpServerName: string;
|
||||
marketplaceName?: string;
|
||||
marketplacePath?: string;
|
||||
packagePath?: string;
|
||||
nativeHost?: NativeComputerUseInstallResult;
|
||||
tools: string[];
|
||||
message: string;
|
||||
};
|
||||
@@ -70,7 +61,6 @@ export type CodexComputerUseSetupParams = {
|
||||
signal?: AbortSignal;
|
||||
forceEnable?: boolean;
|
||||
defaultBundledMarketplacePath?: string;
|
||||
nativeInstaller?: NativeComputerUseInstaller;
|
||||
};
|
||||
|
||||
type MarketplaceRef =
|
||||
@@ -191,7 +181,6 @@ async function inspectCodexComputerUse(params: {
|
||||
config: ResolvedCodexComputerUseConfig;
|
||||
installPlugin: boolean;
|
||||
defaultBundledMarketplacePath?: string;
|
||||
nativeInstaller?: NativeComputerUseInstaller;
|
||||
}): Promise<CodexComputerUseStatus> {
|
||||
const request = createComputerUseRequest(params);
|
||||
if (params.installPlugin) {
|
||||
@@ -229,18 +218,12 @@ async function inspectCodexComputerUse(params: {
|
||||
return pluginInspection.status;
|
||||
}
|
||||
|
||||
const status = await readComputerUseTools({
|
||||
return await readComputerUseTools({
|
||||
request,
|
||||
config: params.config,
|
||||
plugin: pluginInspection.plugin,
|
||||
installPlugin: params.installPlugin,
|
||||
});
|
||||
return await maybeInstallNativeComputerUse({
|
||||
status,
|
||||
config: params.config,
|
||||
nativeInstaller: params.nativeInstaller,
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureComputerUsePlugin(params: {
|
||||
@@ -335,7 +318,6 @@ async function readComputerUseTools(params: {
|
||||
tools: Object.keys(server.tools).toSorted(),
|
||||
reason: "ready",
|
||||
message: "Computer Use is ready.",
|
||||
packagePath: resolveComputerUsePackagePath(params.plugin),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -602,97 +584,12 @@ function remoteInstallUnsupportedMessage(
|
||||
return `Computer Use is ${state} in remote Codex marketplace ${marketplaceName}, but Codex app-server does not support remote plugin install yet. Configure computerUse.marketplaceSource or computerUse.marketplacePath for a local marketplace, then run /codex computer-use install.`;
|
||||
}
|
||||
|
||||
async function maybeInstallNativeComputerUse(params: {
|
||||
status: CodexComputerUseStatus;
|
||||
config: ResolvedCodexComputerUseConfig;
|
||||
nativeInstaller?: NativeComputerUseInstaller;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CodexComputerUseStatus> {
|
||||
if (!params.status.ready || !params.nativeInstaller) {
|
||||
return params.status;
|
||||
}
|
||||
if (!params.status.packagePath) {
|
||||
return {
|
||||
...params.status,
|
||||
ready: false,
|
||||
reason: "package_missing",
|
||||
message:
|
||||
"Computer Use is installed in Codex, but Codex did not expose a local package path that OpenClaw.app can host.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const nativeHost = await params.nativeInstaller({
|
||||
packagePath: params.status.packagePath,
|
||||
pluginName: params.config.pluginName,
|
||||
mcpServerName: params.config.mcpServerName,
|
||||
signal: params.signal,
|
||||
});
|
||||
return {
|
||||
...params.status,
|
||||
nativeHost,
|
||||
message: `${params.status.message} ${nativeHost.message}`.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = describeControlFailure(error);
|
||||
const reason =
|
||||
message.includes("No connected OpenClaw.app node") || message.includes("OpenClaw.app")
|
||||
? "native_host_missing"
|
||||
: "native_install_failed";
|
||||
return {
|
||||
...params.status,
|
||||
ready: false,
|
||||
reason,
|
||||
nativeHost: {
|
||||
ready: false,
|
||||
message,
|
||||
},
|
||||
message: `Computer Use is installed in Codex, but OpenClaw.app could not host it: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComputerUsePackagePath(plugin: v2.PluginDetail): string | undefined {
|
||||
const source = plugin.summary.source;
|
||||
if (!source || source.type !== "local") {
|
||||
return undefined;
|
||||
}
|
||||
const rawPath = source.path.trim();
|
||||
if (!rawPath) {
|
||||
return undefined;
|
||||
}
|
||||
if (rawPath.startsWith("/")) {
|
||||
return rawPath;
|
||||
}
|
||||
const marketplacePath = plugin.marketplacePath?.trim();
|
||||
if (!marketplacePath) {
|
||||
return rawPath;
|
||||
}
|
||||
const marketplaceRoot = resolveMarketplaceRoot(marketplacePath);
|
||||
return pathJoin(marketplaceRoot, rawPath);
|
||||
}
|
||||
|
||||
function resolveMarketplaceRoot(marketplacePath: string): string {
|
||||
const normalized = marketplacePath.replaceAll("\\", "/");
|
||||
if (normalized.endsWith("/.agents/plugins/marketplace.json")) {
|
||||
return normalized.slice(0, -"/.agents/plugins/marketplace.json".length);
|
||||
}
|
||||
return normalized.slice(0, normalized.lastIndexOf("/")) || ".";
|
||||
}
|
||||
|
||||
function pathJoin(root: string, relativePath: string): string {
|
||||
const normalizedRoot = root.endsWith("/") ? root.slice(0, -1) : root;
|
||||
const normalizedRelative = relativePath.replace(/^\.\//, "");
|
||||
return `${normalizedRoot}/${normalizedRelative}`;
|
||||
}
|
||||
|
||||
function statusFromPlugin(params: {
|
||||
config: ResolvedCodexComputerUseConfig;
|
||||
plugin: v2.PluginDetail;
|
||||
tools: string[];
|
||||
reason: CodexComputerUseStatusReason;
|
||||
message: string;
|
||||
packagePath?: string;
|
||||
nativeHost?: NativeComputerUseInstallResult;
|
||||
}): CodexComputerUseStatus {
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -706,8 +603,6 @@ function statusFromPlugin(params: {
|
||||
mcpServerName: params.config.mcpServerName,
|
||||
marketplaceName: params.plugin.marketplaceName,
|
||||
...(params.plugin.marketplacePath ? { marketplacePath: params.plugin.marketplacePath } : {}),
|
||||
...(params.packagePath ? { packagePath: params.packagePath } : {}),
|
||||
...(params.nativeHost ? { nativeHost: params.nativeHost } : {}),
|
||||
tools: params.tools,
|
||||
message: params.message,
|
||||
};
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installNativeComputerUsePackage,
|
||||
type NativeComputerUseNodesRuntime,
|
||||
} from "./native-computer-use-install.js";
|
||||
|
||||
describe("native Computer Use package install", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanupPath of cleanupPaths.splice(0)) {
|
||||
fs.rmSync(cleanupPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("streams a Codex Computer Use package to the connected OpenClaw app node", async () => {
|
||||
const packagePath = makeComputerUsePackage();
|
||||
const invocations: Array<{ command: string; params: Record<string, unknown> }> = [];
|
||||
const nodes = createNodesRuntime(invocations);
|
||||
|
||||
await expect(
|
||||
installNativeComputerUsePackage(nodes, {
|
||||
packagePath,
|
||||
pluginName: "computer-use",
|
||||
mcpServerName: "computer-use",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
nodeId: "mac-node",
|
||||
files: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(invocations.map((call) => call.command)).toEqual([
|
||||
"mcp.package.install.begin",
|
||||
"mcp.package.install.chunk",
|
||||
"mcp.package.install.chunk",
|
||||
"mcp.package.install.finish",
|
||||
]);
|
||||
const begin = invocations[0]?.params;
|
||||
expect(begin).toEqual(
|
||||
expect.objectContaining({
|
||||
nodeId: "mac-node",
|
||||
serverId: "computer-use",
|
||||
packageName: "computer-use",
|
||||
sourcePath: packagePath,
|
||||
fileCount: 2,
|
||||
}),
|
||||
);
|
||||
expect(invocations.slice(1, 3).map((call) => call.params.relativePath)).toEqual([
|
||||
".mcp.json",
|
||||
"bin/sky-client",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not resend the package when the native node already advertises ready", async () => {
|
||||
const packagePath = makeComputerUsePackage();
|
||||
const invocations: Array<{ command: string; params: Record<string, unknown> }> = [];
|
||||
const nodes = createNodesRuntime(invocations, {
|
||||
mcpServers: [{ id: "computer-use", status: "ready" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
installNativeComputerUsePackage(nodes, {
|
||||
packagePath,
|
||||
pluginName: "computer-use",
|
||||
mcpServerName: "computer-use",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
nodeId: "mac-node",
|
||||
message: "OpenClaw.app already hosts Computer Use on Mac.",
|
||||
}),
|
||||
);
|
||||
expect(invocations).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels the native transfer if a chunk fails", async () => {
|
||||
const packagePath = makeComputerUsePackage();
|
||||
const invocations: Array<{ command: string; params: Record<string, unknown> }> = [];
|
||||
const nodes = createNodesRuntime(invocations, { failCommand: "mcp.package.install.chunk" });
|
||||
|
||||
await expect(
|
||||
installNativeComputerUsePackage(nodes, {
|
||||
packagePath,
|
||||
pluginName: "computer-use",
|
||||
mcpServerName: "computer-use",
|
||||
}),
|
||||
).rejects.toThrow("chunk failed");
|
||||
expect(invocations.at(-1)?.command).toBe("mcp.package.install.cancel");
|
||||
});
|
||||
|
||||
it("requires a connected native package host", async () => {
|
||||
const packagePath = makeComputerUsePackage();
|
||||
const nodes: NativeComputerUseNodesRuntime = {
|
||||
list: vi.fn(async () => ({ nodes: [] })),
|
||||
invoke: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
installNativeComputerUsePackage(nodes, {
|
||||
packagePath,
|
||||
pluginName: "computer-use",
|
||||
mcpServerName: "computer-use",
|
||||
}),
|
||||
).rejects.toThrow("No connected OpenClaw.app node");
|
||||
});
|
||||
|
||||
function makeComputerUsePackage(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-native-computer-use-"));
|
||||
cleanupPaths.push(root);
|
||||
const packagePath = path.join(root, "computer-use");
|
||||
fs.mkdirSync(path.join(packagePath, "bin"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packagePath, ".mcp.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
"computer-use": {
|
||||
command: "./bin/sky-client",
|
||||
args: ["mcp"],
|
||||
cwd: ".",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(packagePath, "bin", "sky-client"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(packagePath, "bin", "sky-client"), 0o755);
|
||||
return packagePath;
|
||||
}
|
||||
});
|
||||
|
||||
function createNodesRuntime(
|
||||
invocations: Array<{ command: string; params: Record<string, unknown> }>,
|
||||
options: {
|
||||
mcpServers?: Array<{ id: string; status?: string }>;
|
||||
failCommand?: string;
|
||||
} = {},
|
||||
): NativeComputerUseNodesRuntime {
|
||||
return {
|
||||
async list() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "mac-node",
|
||||
displayName: "Mac",
|
||||
connected: true,
|
||||
caps: ["mcpHost"],
|
||||
commands: [
|
||||
"mcp.package.install.begin",
|
||||
"mcp.package.install.chunk",
|
||||
"mcp.package.install.finish",
|
||||
"mcp.package.install.cancel",
|
||||
],
|
||||
mcpServers: options.mcpServers,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async invoke(params) {
|
||||
invocations.push({
|
||||
command: params.command,
|
||||
params: params.params as Record<string, unknown>,
|
||||
});
|
||||
if (params.command === options.failCommand) {
|
||||
throw new Error("chunk failed");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ ok: true }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type NativeComputerUseNodesRuntime = {
|
||||
list: (params?: { connected?: boolean }) => Promise<{
|
||||
nodes: Array<{
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
connected?: boolean;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
mcpServers?: Array<{ id?: string; status?: string }>;
|
||||
}>;
|
||||
}>;
|
||||
invoke: (params: {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
params?: unknown;
|
||||
timeoutMs?: number;
|
||||
idempotencyKey?: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type NativeComputerUseInstallResult = {
|
||||
ready: boolean;
|
||||
nodeId?: string;
|
||||
files?: number;
|
||||
bytes?: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type NativeComputerUseInstallParams = {
|
||||
packagePath: string;
|
||||
pluginName: string;
|
||||
mcpServerName: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type NativeComputerUseInstaller = (
|
||||
params: NativeComputerUseInstallParams,
|
||||
) => Promise<NativeComputerUseInstallResult>;
|
||||
|
||||
const COMPUTER_USE_MCP_SERVER_ID = "computer-use";
|
||||
const INSTALL_COMMANDS = [
|
||||
"mcp.package.install.begin",
|
||||
"mcp.package.install.chunk",
|
||||
"mcp.package.install.finish",
|
||||
"mcp.package.install.cancel",
|
||||
] as const;
|
||||
const PACKAGE_CHUNK_BYTES = 256 * 1024;
|
||||
|
||||
type PackageFile = {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
size: number;
|
||||
executable: boolean;
|
||||
};
|
||||
|
||||
class NativeComputerUseInstallError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NativeComputerUseInstallError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createNativeComputerUseInstaller(
|
||||
nodes: NativeComputerUseNodesRuntime,
|
||||
): NativeComputerUseInstaller {
|
||||
return async (params) => installNativeComputerUsePackage(nodes, params);
|
||||
}
|
||||
|
||||
export async function installNativeComputerUsePackage(
|
||||
nodes: NativeComputerUseNodesRuntime,
|
||||
params: NativeComputerUseInstallParams,
|
||||
): Promise<NativeComputerUseInstallResult> {
|
||||
const node = await findNativeMcpPackageHost(nodes);
|
||||
if (hasReadyMcpServer(node, params.mcpServerName || COMPUTER_USE_MCP_SERVER_ID)) {
|
||||
return {
|
||||
ready: true,
|
||||
nodeId: node.nodeId,
|
||||
message: `OpenClaw.app already hosts Computer Use on ${node.displayName ?? node.nodeId}.`,
|
||||
};
|
||||
}
|
||||
const packagePath = path.resolve(params.packagePath);
|
||||
const files = await collectPackageFiles(packagePath);
|
||||
if (!files.some((file) => file.relativePath === ".mcp.json")) {
|
||||
throw new NativeComputerUseInstallError(
|
||||
`Computer Use package at ${packagePath} is missing .mcp.json.`,
|
||||
);
|
||||
}
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const transferId = randomUUID();
|
||||
try {
|
||||
await invokePackageCommand(nodes, {
|
||||
nodeId: node.nodeId,
|
||||
command: "mcp.package.install.begin",
|
||||
params: {
|
||||
transferId,
|
||||
nodeId: node.nodeId,
|
||||
serverId: params.mcpServerName || COMPUTER_USE_MCP_SERVER_ID,
|
||||
packageName: params.pluginName,
|
||||
sourcePath: packagePath,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
},
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
params.signal?.throwIfAborted();
|
||||
await sendFileChunks(nodes, {
|
||||
nodeId: node.nodeId,
|
||||
transferId,
|
||||
file,
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
await invokePackageCommand(nodes, {
|
||||
nodeId: node.nodeId,
|
||||
command: "mcp.package.install.finish",
|
||||
params: { transferId },
|
||||
signal: params.signal,
|
||||
});
|
||||
return {
|
||||
ready: true,
|
||||
nodeId: node.nodeId,
|
||||
files: files.length,
|
||||
bytes: totalBytes,
|
||||
message: `Installed Computer Use package into OpenClaw.app on ${node.displayName ?? node.nodeId}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
await invokePackageCommand(nodes, {
|
||||
nodeId: node.nodeId,
|
||||
command: "mcp.package.install.cancel",
|
||||
params: { transferId },
|
||||
signal: undefined,
|
||||
}).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasReadyMcpServer(
|
||||
node: { mcpServers?: Array<{ id?: string; status?: string }> },
|
||||
serverId: string,
|
||||
): boolean {
|
||||
return (
|
||||
node.mcpServers?.some((server) => server.id === serverId && server.status === "ready") === true
|
||||
);
|
||||
}
|
||||
|
||||
async function findNativeMcpPackageHost(nodes: NativeComputerUseNodesRuntime) {
|
||||
const listed = await nodes.list({ connected: true });
|
||||
const candidates = listed.nodes
|
||||
.filter((node) => node.connected !== false)
|
||||
.filter((node) => node.caps?.includes("mcpHost"))
|
||||
.filter((node) =>
|
||||
INSTALL_COMMANDS.every((command) => node.commands?.includes(command) === true),
|
||||
)
|
||||
.toSorted((left, right) => {
|
||||
const labelDelta = (left.displayName ?? "").localeCompare(right.displayName ?? "");
|
||||
return labelDelta !== 0 ? labelDelta : left.nodeId.localeCompare(right.nodeId);
|
||||
});
|
||||
const node = candidates[0];
|
||||
if (!node) {
|
||||
throw new NativeComputerUseInstallError(
|
||||
"No connected OpenClaw.app node can install native MCP packages. Open OpenClaw.app on this Mac and pair it with the Gateway, then retry /codex computer-use install.",
|
||||
);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function collectPackageFiles(packagePath: string): Promise<PackageFile[]> {
|
||||
const rootStat = await fs.stat(packagePath);
|
||||
if (!rootStat.isDirectory()) {
|
||||
throw new NativeComputerUseInstallError(`${packagePath} is not a directory.`);
|
||||
}
|
||||
const files: PackageFile[] = [];
|
||||
await collectPackageFilesIn(packagePath, packagePath, files);
|
||||
return files.toSorted((left, right) => left.relativePath.localeCompare(right.relativePath));
|
||||
}
|
||||
|
||||
async function collectPackageFilesIn(
|
||||
root: string,
|
||||
current: string,
|
||||
files: PackageFile[],
|
||||
): Promise<void> {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(current, entry.name);
|
||||
const relativePath = toPackageRelativePath(root, absolutePath);
|
||||
if (entry.isDirectory()) {
|
||||
await collectPackageFilesIn(root, absolutePath, files);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
throw new NativeComputerUseInstallError(
|
||||
`Computer Use package contains unsupported entry ${relativePath}.`,
|
||||
);
|
||||
}
|
||||
if (relativePath === ".openclaw-computer-use-source.json") {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(absolutePath);
|
||||
files.push({
|
||||
absolutePath,
|
||||
relativePath,
|
||||
size: stat.size,
|
||||
executable: (stat.mode & 0o111) !== 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toPackageRelativePath(root: string, absolutePath: string): string {
|
||||
const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
|
||||
if (
|
||||
!relativePath ||
|
||||
relativePath.startsWith("../") ||
|
||||
relativePath === ".." ||
|
||||
path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new NativeComputerUseInstallError(`Unsafe package path ${relativePath}.`);
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
async function sendFileChunks(
|
||||
nodes: NativeComputerUseNodesRuntime,
|
||||
params: {
|
||||
nodeId: string;
|
||||
transferId: string;
|
||||
file: PackageFile;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<void> {
|
||||
const handle = await fs.open(params.file.absolutePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(PACKAGE_CHUNK_BYTES);
|
||||
let offset = 0;
|
||||
while (offset < params.file.size || (params.file.size === 0 && offset === 0)) {
|
||||
params.signal?.throwIfAborted();
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(PACKAGE_CHUNK_BYTES, Math.max(1, params.file.size - offset)),
|
||||
offset,
|
||||
);
|
||||
const chunk = bytesRead > 0 ? buffer.subarray(0, bytesRead) : Buffer.alloc(0);
|
||||
await invokePackageCommand(nodes, {
|
||||
nodeId: params.nodeId,
|
||||
command: "mcp.package.install.chunk",
|
||||
params: {
|
||||
transferId: params.transferId,
|
||||
relativePath: params.file.relativePath,
|
||||
dataBase64: chunk.toString("base64"),
|
||||
executable: params.file.executable,
|
||||
},
|
||||
signal: params.signal,
|
||||
});
|
||||
if (params.file.size === 0 || bytesRead === 0) {
|
||||
break;
|
||||
}
|
||||
offset += bytesRead;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function invokePackageCommand(
|
||||
nodes: NativeComputerUseNodesRuntime,
|
||||
params: {
|
||||
nodeId: string;
|
||||
command: (typeof INSTALL_COMMANDS)[number];
|
||||
params: unknown;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
params.signal?.throwIfAborted();
|
||||
const response = await nodes.invoke({
|
||||
nodeId: params.nodeId,
|
||||
command: params.command,
|
||||
params: params.params,
|
||||
timeoutMs: 60_000,
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
const payload = unwrapInvokePayload(response);
|
||||
if (payload.ok === false) {
|
||||
throw new NativeComputerUseInstallError(readErrorMessage(payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function unwrapInvokePayload(response: unknown): Record<string, unknown> {
|
||||
if (!isRecord(response)) {
|
||||
return {};
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw new NativeComputerUseInstallError(readErrorMessage(response));
|
||||
}
|
||||
const payloadJSON = typeof response.payloadJSON === "string" ? response.payloadJSON : undefined;
|
||||
if (payloadJSON) {
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJSON) as unknown;
|
||||
return isRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
throw new NativeComputerUseInstallError("Native Computer Use install returned invalid JSON.");
|
||||
}
|
||||
}
|
||||
return isRecord(response.payload) ? response.payload : response;
|
||||
}
|
||||
|
||||
function readErrorMessage(value: Record<string, unknown>): string {
|
||||
const error = isRecord(value.error) ? value.error : undefined;
|
||||
return (
|
||||
readString(error?.message) ??
|
||||
readString(value.message) ??
|
||||
readString(error?.code) ??
|
||||
"Native Computer Use install failed."
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
@@ -443,33 +443,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the session when Codex accepts a turn but never sends progress", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, { turnTerminalIdleTimeoutMs: 5 });
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
await expect(run).resolves.toMatchObject({
|
||||
aborted: true,
|
||||
timedOut: true,
|
||||
promptError: "codex app-server turn idle timed out waiting for turn/completed",
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(harness.request).toHaveBeenCalledWith("turn/interrupt", {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
}),
|
||||
{ interval: 1 },
|
||||
);
|
||||
expect(queueAgentHarnessMessage("session-1", "after silent turn")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies before_prompt_build to Codex developer instructions and turn input", async () => {
|
||||
const beforePromptBuild = vi.fn(async () => ({
|
||||
systemPrompt: "custom codex system",
|
||||
@@ -762,51 +735,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes node-hosted MCP servers through Codex thread config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.threadMcpServers = {
|
||||
"computer-use": {
|
||||
transport: "http",
|
||||
url: "http://127.0.0.1:49152/mcp/node/mac-node/computer-use",
|
||||
headers: { Authorization: "Bearer owner-token" },
|
||||
startupTimeoutSec: 30,
|
||||
toolTimeoutSec: 120,
|
||||
defaultToolsApprovalMode: "approve",
|
||||
},
|
||||
};
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
nativeHookRelay: {
|
||||
enabled: true,
|
||||
events: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
"features.codex_hooks": true,
|
||||
mcp_servers: {
|
||||
"computer-use": {
|
||||
url: "http://127.0.0.1:49152/mcp/node/mac-node/computer-use",
|
||||
http_headers: { Authorization: "Bearer owner-token" },
|
||||
startup_timeout_sec: 30,
|
||||
tool_timeout_sec: 120,
|
||||
default_tools_approval_mode: "approve",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the Codex native hook relay id across runs for the same session", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -49,7 +49,6 @@ import { projectContextEngineAssemblyForCodex } from "./context-engine-projectio
|
||||
import { createCodexDynamicToolBridge, type CodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
|
||||
import { CodexAppServerEventProjector } from "./event-projector.js";
|
||||
import type { NativeComputerUseInstaller } from "./native-computer-use-install.js";
|
||||
import {
|
||||
buildCodexNativeHookRelayDisabledConfig,
|
||||
buildCodexNativeHookRelayConfig,
|
||||
@@ -76,7 +75,6 @@ import {
|
||||
buildTurnStartParams,
|
||||
startOrResumeThread,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexThreadMcpConfig, mergeCodexThreadConfigs } from "./thread-mcp-config.js";
|
||||
import {
|
||||
createCodexTrajectoryRecorder,
|
||||
normalizeCodexTrajectoryError,
|
||||
@@ -89,7 +87,6 @@ import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
|
||||
const CODEX_DYNAMIC_TOOL_TIMEOUT_MS = 30_000;
|
||||
const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
|
||||
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
||||
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
|
||||
|
||||
type OpenClawCodingToolsOptions = NonNullable<
|
||||
@@ -229,8 +226,6 @@ export async function runCodexAppServerAttempt(
|
||||
hookTimeoutSec?: number;
|
||||
};
|
||||
turnCompletionIdleTimeoutMs?: number;
|
||||
turnTerminalIdleTimeoutMs?: number;
|
||||
nativeComputerUseInstaller?: NativeComputerUseInstaller;
|
||||
} = {},
|
||||
): Promise<EmbeddedRunAttemptResult> {
|
||||
const attemptStartedAt = Date.now();
|
||||
@@ -408,10 +403,6 @@ export async function runCodexAppServerAttempt(
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
? buildCodexNativeHookRelayDisabledConfig()
|
||||
: undefined;
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
buildCodexThreadMcpConfig(params.threadMcpServers),
|
||||
);
|
||||
({ client, thread } = await withCodexStartupTimeout({
|
||||
timeoutMs: params.timeoutMs,
|
||||
timeoutFloorMs: options.startupTimeoutFloorMs,
|
||||
@@ -423,7 +414,6 @@ export async function runCodexAppServerAttempt(
|
||||
pluginConfig: options.pluginConfig,
|
||||
timeoutMs: appServer.requestTimeoutMs,
|
||||
signal: runAbortController.signal,
|
||||
nativeInstaller: options.nativeComputerUseInstaller,
|
||||
});
|
||||
const startupThread = await startOrResumeThread({
|
||||
client: startupClient,
|
||||
@@ -432,7 +422,7 @@ export async function runCodexAppServerAttempt(
|
||||
dynamicTools: toolBridge.specs,
|
||||
appServer,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
config: threadConfig,
|
||||
config: nativeHookRelayConfig,
|
||||
});
|
||||
return { client: startupClient, thread: startupThread };
|
||||
},
|
||||
@@ -481,13 +471,8 @@ export async function runCodexAppServerAttempt(
|
||||
const turnCompletionIdleTimeoutMs = resolveCodexTurnCompletionIdleTimeoutMs(
|
||||
options.turnCompletionIdleTimeoutMs,
|
||||
);
|
||||
const turnTerminalIdleTimeoutMs = resolveCodexTurnTerminalIdleTimeoutMs(
|
||||
options.turnTerminalIdleTimeoutMs,
|
||||
);
|
||||
let turnCompletionIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let turnCompletionIdleWatchArmed = false;
|
||||
let turnTerminalIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let turnTerminalIdleWatchArmed = false;
|
||||
let turnCompletionLastActivityAt = Date.now();
|
||||
let turnCompletionLastActivityReason = "startup";
|
||||
let activeAppServerTurnRequests = 0;
|
||||
@@ -499,13 +484,6 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
};
|
||||
|
||||
const clearTurnTerminalIdleTimer = () => {
|
||||
if (turnTerminalIdleTimer) {
|
||||
clearTimeout(turnTerminalIdleTimer);
|
||||
turnTerminalIdleTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fireTurnCompletionIdleTimeout = () => {
|
||||
if (
|
||||
completed ||
|
||||
@@ -542,42 +520,6 @@ export async function runCodexAppServerAttempt(
|
||||
runAbortController.abort("turn_completion_idle_timeout");
|
||||
};
|
||||
|
||||
const fireTurnTerminalIdleTimeout = () => {
|
||||
if (
|
||||
completed ||
|
||||
runAbortController.signal.aborted ||
|
||||
!turnTerminalIdleWatchArmed ||
|
||||
activeAppServerTurnRequests > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const idleMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
|
||||
if (idleMs < turnTerminalIdleTimeoutMs) {
|
||||
scheduleTurnTerminalIdleWatch();
|
||||
return;
|
||||
}
|
||||
timedOut = true;
|
||||
turnCompletionIdleTimedOut = true;
|
||||
turnCompletionIdleTimeoutMessage =
|
||||
"codex app-server turn idle timed out waiting for turn/completed";
|
||||
projector?.markTimedOut();
|
||||
trajectoryRecorder?.recordEvent("turn.terminal_idle_timeout", {
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnTerminalIdleTimeoutMs,
|
||||
lastActivityReason: turnCompletionLastActivityReason,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnTerminalIdleTimeoutMs,
|
||||
lastActivityReason: turnCompletionLastActivityReason,
|
||||
});
|
||||
runAbortController.abort("turn_terminal_idle_timeout");
|
||||
};
|
||||
|
||||
function scheduleTurnCompletionIdleWatch() {
|
||||
clearTurnCompletionIdleTimer();
|
||||
if (
|
||||
@@ -594,22 +536,6 @@ export async function runCodexAppServerAttempt(
|
||||
turnCompletionIdleTimer.unref?.();
|
||||
}
|
||||
|
||||
function scheduleTurnTerminalIdleWatch() {
|
||||
clearTurnTerminalIdleTimer();
|
||||
if (
|
||||
completed ||
|
||||
runAbortController.signal.aborted ||
|
||||
!turnTerminalIdleWatchArmed ||
|
||||
activeAppServerTurnRequests > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Math.max(0, Date.now() - turnCompletionLastActivityAt);
|
||||
const delayMs = Math.max(1, turnTerminalIdleTimeoutMs - elapsedMs);
|
||||
turnTerminalIdleTimer = setTimeout(fireTurnTerminalIdleTimeout, delayMs);
|
||||
turnTerminalIdleTimer.unref?.();
|
||||
}
|
||||
|
||||
const touchTurnCompletionActivity = (reason: string, options?: { arm?: boolean }) => {
|
||||
turnCompletionLastActivityAt = Date.now();
|
||||
turnCompletionLastActivityReason = reason;
|
||||
@@ -617,7 +543,6 @@ export async function runCodexAppServerAttempt(
|
||||
turnCompletionIdleWatchArmed = true;
|
||||
}
|
||||
scheduleTurnCompletionIdleWatch();
|
||||
scheduleTurnTerminalIdleWatch();
|
||||
};
|
||||
|
||||
const emitLifecycleStart = () => {
|
||||
@@ -670,7 +595,6 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
completed = true;
|
||||
clearTurnCompletionIdleTimer();
|
||||
clearTurnTerminalIdleTimer();
|
||||
resolveCompletion?.();
|
||||
}
|
||||
}
|
||||
@@ -915,7 +839,6 @@ export async function runCodexAppServerAttempt(
|
||||
abort: () => runAbortController.abort("aborted"),
|
||||
};
|
||||
setActiveEmbeddedRun(params.sessionId, handle, params.sessionKey);
|
||||
turnTerminalIdleWatchArmed = true;
|
||||
touchTurnCompletionActivity("turn:start");
|
||||
|
||||
const timeout = setTimeout(
|
||||
@@ -1082,7 +1005,6 @@ export async function runCodexAppServerAttempt(
|
||||
userInputBridge?.cancelPending();
|
||||
clearTimeout(timeout);
|
||||
clearTurnCompletionIdleTimer();
|
||||
clearTurnTerminalIdleTimer();
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
nativeHookRelay?.unregister();
|
||||
@@ -1383,16 +1305,6 @@ function resolveCodexTurnCompletionIdleTimeoutMs(value: number | undefined): num
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function resolveCodexTurnTerminalIdleTimeoutMs(value: number | undefined): number {
|
||||
if (value === undefined) {
|
||||
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function readDynamicToolCallParams(
|
||||
value: JsonValue | undefined,
|
||||
): CodexDynamicToolCallParams | undefined {
|
||||
@@ -1505,7 +1417,6 @@ function handleApprovalRequest(params: {
|
||||
export const __testing = {
|
||||
CODEX_DYNAMIC_TOOL_TIMEOUT_MS,
|
||||
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
|
||||
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
|
||||
buildCodexNativeHookRelayId,
|
||||
filterToolsForVisionInputs,
|
||||
handleDynamicToolCallWithTimeout,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { AgentThreadMcpServers } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function buildCodexThreadMcpConfig(
|
||||
servers: AgentThreadMcpServers | undefined,
|
||||
): JsonObject | undefined {
|
||||
if (!servers || Object.keys(servers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const codexServers: JsonObject = {};
|
||||
for (const [id, server] of Object.entries(servers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (server.transport !== "http") {
|
||||
continue;
|
||||
}
|
||||
const entry: JsonObject = {
|
||||
url: server.url,
|
||||
};
|
||||
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||
entry.http_headers = server.headers;
|
||||
}
|
||||
if (server.startupTimeoutSec !== undefined) {
|
||||
entry.startup_timeout_sec = server.startupTimeoutSec;
|
||||
}
|
||||
if (server.toolTimeoutSec !== undefined) {
|
||||
entry.tool_timeout_sec = server.toolTimeoutSec;
|
||||
}
|
||||
if (server.defaultToolsApprovalMode !== undefined) {
|
||||
entry.default_tools_approval_mode = server.defaultToolsApprovalMode;
|
||||
}
|
||||
codexServers[id] = entry;
|
||||
}
|
||||
|
||||
if (Object.keys(codexServers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { mcp_servers: codexServers };
|
||||
}
|
||||
|
||||
export function mergeCodexThreadConfigs(
|
||||
...configs: Array<JsonObject | undefined>
|
||||
): JsonObject | undefined {
|
||||
const merged: JsonObject = {};
|
||||
for (const config of configs) {
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
const existingMcpServers = isJsonObject(merged.mcp_servers) ? merged.mcp_servers : undefined;
|
||||
const nextMcpServers = isJsonObject(config.mcp_servers) ? config.mcp_servers : undefined;
|
||||
Object.assign(merged, config);
|
||||
if (existingMcpServers || nextMcpServers) {
|
||||
merged.mcp_servers = {
|
||||
...existingMcpServers,
|
||||
...nextMcpServers,
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
@@ -103,16 +103,6 @@ export function formatComputerUseStatus(status: CodexComputerUseStatus): string
|
||||
if (status.marketplaceName) {
|
||||
lines.push(`Marketplace: ${status.marketplaceName}`);
|
||||
}
|
||||
if (status.packagePath) {
|
||||
lines.push(`Package: ${status.packagePath}`);
|
||||
}
|
||||
if (status.nativeHost) {
|
||||
lines.push(
|
||||
`OpenClaw.app host: ${status.nativeHost.ready ? "ready" : "not ready"}${
|
||||
status.nativeHost.nodeId ? ` (${status.nativeHost.nodeId})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (status.tools.length > 0) {
|
||||
lines.push(`Tools: ${status.tools.slice(0, 8).join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "./app-server/computer-use.js";
|
||||
import type { CodexComputerUseConfig } from "./app-server/config.js";
|
||||
import { listAllCodexAppServerModels } from "./app-server/models.js";
|
||||
import type { NativeComputerUseInstaller } from "./app-server/native-computer-use-install.js";
|
||||
import { isJsonObject, type JsonValue } from "./app-server/protocol.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
@@ -177,11 +176,7 @@ export function resetCodexDiagnosticsFeedbackStateForTests(): void {
|
||||
|
||||
export async function handleCodexSubcommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: {
|
||||
pluginConfig?: unknown;
|
||||
deps?: Partial<CodexCommandDeps>;
|
||||
nativeComputerUseInstaller?: NativeComputerUseInstaller;
|
||||
},
|
||||
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> },
|
||||
): Promise<PluginCommandResult> {
|
||||
const deps: CodexCommandDeps = { ...defaultCodexCommandDeps, ...options.deps };
|
||||
const [subcommand = "status", ...rest] = splitArgs(ctx.args);
|
||||
@@ -262,12 +257,7 @@ export async function handleCodexSubcommand(
|
||||
}
|
||||
if (normalized === "computer-use" || normalized === "computeruse") {
|
||||
return {
|
||||
text: await handleComputerUseCommand(
|
||||
deps,
|
||||
options.pluginConfig,
|
||||
rest,
|
||||
options.nativeComputerUseInstaller,
|
||||
),
|
||||
text: await handleComputerUseCommand(deps, options.pluginConfig, rest),
|
||||
};
|
||||
}
|
||||
if (normalized === "mcp") {
|
||||
@@ -308,7 +298,6 @@ async function handleComputerUseCommand(
|
||||
deps: CodexCommandDeps,
|
||||
pluginConfig: unknown,
|
||||
args: string[],
|
||||
nativeComputerUseInstaller?: NativeComputerUseInstaller,
|
||||
): Promise<string> {
|
||||
const parsed = parseComputerUseArgs(args);
|
||||
if (parsed.help) {
|
||||
@@ -321,9 +310,6 @@ async function handleComputerUseCommand(
|
||||
pluginConfig,
|
||||
forceEnable: parsed.action === "install" || parsed.hasOverrides,
|
||||
...(Object.keys(parsed.overrides).length > 0 ? { overrides: parsed.overrides } : {}),
|
||||
...(parsed.action === "install" && nativeComputerUseInstaller
|
||||
? { nativeInstaller: nativeComputerUseInstaller }
|
||||
: {}),
|
||||
};
|
||||
if (parsed.action === "install") {
|
||||
return formatComputerUseStatus(await deps.installCodexComputerUse(params));
|
||||
|
||||
@@ -3,13 +3,11 @@ import type {
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { NativeComputerUseInstaller } from "./app-server/native-computer-use-install.js";
|
||||
import type { CodexCommandDeps } from "./command-handlers.js";
|
||||
|
||||
export function createCodexCommand(options: {
|
||||
pluginConfig?: unknown;
|
||||
deps?: Partial<CodexCommandDeps>;
|
||||
nativeComputerUseInstaller?: NativeComputerUseInstaller;
|
||||
}): OpenClawPluginCommandDefinition {
|
||||
return {
|
||||
name: "codex",
|
||||
@@ -27,11 +25,7 @@ export function createCodexCommand(options: {
|
||||
|
||||
export async function handleCodexCommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: {
|
||||
pluginConfig?: unknown;
|
||||
deps?: Partial<CodexCommandDeps>;
|
||||
nativeComputerUseInstaller?: NativeComputerUseInstaller;
|
||||
} = {},
|
||||
options: { pluginConfig?: unknown; deps?: Partial<CodexCommandDeps> } = {},
|
||||
): Promise<PluginCommandResult> {
|
||||
const { handleCodexSubcommand } = await import("./command-handlers.js");
|
||||
return await handleCodexSubcommand(ctx, options);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
archiveMigrationItem,
|
||||
copyMigrationFileItem,
|
||||
writeMigrationReport,
|
||||
} from "openclaw/plugin-sdk/migration-runtime";
|
||||
import type {
|
||||
MigrationApplyResult,
|
||||
MigrationItem,
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
|
||||
export async function applyCodexMigrationPlan(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plan?: MigrationPlan;
|
||||
}): Promise<MigrationApplyResult> {
|
||||
const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx));
|
||||
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex");
|
||||
const items: MigrationItem[] = [];
|
||||
for (const item of plan.items) {
|
||||
if (item.status !== "planned") {
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.action === "archive") {
|
||||
items.push(await archiveMigrationItem(item, reportDir));
|
||||
} else {
|
||||
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
|
||||
}
|
||||
}
|
||||
const result: MigrationApplyResult = {
|
||||
...plan,
|
||||
items,
|
||||
summary: summarizeMigrationItems(items),
|
||||
backupPath: params.ctx.backupPath,
|
||||
reportDir,
|
||||
};
|
||||
await writeMigrationReport(result, { title: "Codex Migration Report" });
|
||||
return result;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDirectory(filePath: string | undefined): Promise<boolean> {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await fs.stat(filePath)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveUserHomeDir(): string {
|
||||
return process.env.HOME?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function resolveHomePath(value: string): string {
|
||||
if (value === "~") {
|
||||
return resolveUserHomeDir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(resolveUserHomeDir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
export function sanitizeName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._-]+/gu, "-")
|
||||
.replaceAll(/^-+|-+$/gu, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export async function readJsonObject(
|
||||
filePath: string | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
createMigrationItem,
|
||||
createMigrationManualItem,
|
||||
MIGRATION_REASON_TARGET_EXISTS,
|
||||
summarizeMigrationItems,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import type {
|
||||
MigrationItem,
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { exists, sanitizeName } from "./helpers.js";
|
||||
import { discoverCodexSource, hasCodexSource, type CodexSkillSource } from "./source.js";
|
||||
import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
if ((counts.get(base) ?? 0) <= 1) {
|
||||
return base;
|
||||
}
|
||||
const parent = sanitizeName(path.basename(path.dirname(skill.source)));
|
||||
return sanitizeName(["codex", parent, base].filter(Boolean).join("-")) || base;
|
||||
}
|
||||
|
||||
async function buildSkillItems(params: {
|
||||
skills: CodexSkillSource[];
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
}): Promise<MigrationItem[]> {
|
||||
const baseCounts = new Map<string, number>();
|
||||
for (const skill of params.skills) {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
|
||||
}
|
||||
const resolvedCounts = new Map<string, number>();
|
||||
const planned = params.skills.map((skill) => {
|
||||
const name = uniqueSkillName(skill, baseCounts);
|
||||
resolvedCounts.set(name, (resolvedCounts.get(name) ?? 0) + 1);
|
||||
return { skill, name, target: path.join(params.workspaceDir, "skills", name) };
|
||||
});
|
||||
const items: MigrationItem[] = [];
|
||||
for (const item of planned) {
|
||||
const collides = (resolvedCounts.get(item.name) ?? 0) > 1;
|
||||
const targetExists = await exists(item.target);
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: `skill:${item.name}`,
|
||||
kind: "skill",
|
||||
action: "copy",
|
||||
source: item.skill.source,
|
||||
target: item.target,
|
||||
status: collides ? "conflict" : targetExists && !params.overwrite ? "conflict" : "planned",
|
||||
reason: collides
|
||||
? `multiple Codex skills normalize to "${item.name}"`
|
||||
: targetExists && !params.overwrite
|
||||
? MIGRATION_REASON_TARGET_EXISTS
|
||||
: undefined,
|
||||
message: `Copy ${item.skill.sourceLabel} into this OpenClaw agent workspace.`,
|
||||
details: {
|
||||
skillName: item.name,
|
||||
sourceLabel: item.skill.sourceLabel,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function buildCodexMigrationPlan(
|
||||
ctx: MigrationProviderContext,
|
||||
): Promise<MigrationPlan> {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
if (!hasCodexSource(source)) {
|
||||
throw new Error(
|
||||
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
|
||||
);
|
||||
}
|
||||
const targets = resolveCodexMigrationTargets(ctx);
|
||||
const items: MigrationItem[] = [];
|
||||
items.push(
|
||||
...(await buildSkillItems({
|
||||
skills: source.skills,
|
||||
workspaceDir: targets.workspaceDir,
|
||||
overwrite: ctx.overwrite,
|
||||
})),
|
||||
);
|
||||
for (const [index, plugin] of source.plugins.entries()) {
|
||||
items.push(
|
||||
createMigrationManualItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${index + 1}`,
|
||||
source: plugin.source,
|
||||
message: `Codex native plugin "${plugin.name}" was found but not activated automatically.`,
|
||||
recommendation:
|
||||
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const archivePath of source.archivePaths) {
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: archivePath.id,
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
source: archivePath.path,
|
||||
message:
|
||||
archivePath.message ??
|
||||
"Archived in the migration report for manual review; not imported into live config.",
|
||||
details: { archiveRelativePath: archivePath.relativePath },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const warnings = [
|
||||
...(items.some((item) => item.status === "conflict")
|
||||
? [
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting skill targets after item-level backups.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.length > 0
|
||||
? [
|
||||
"Codex native plugins are reported for manual review only. OpenClaw does not auto-activate plugin bundles, hooks, MCP servers, or apps from another Codex home.",
|
||||
]
|
||||
: []),
|
||||
...(source.archivePaths.length > 0
|
||||
? [
|
||||
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
providerId: "codex",
|
||||
source: source.root,
|
||||
target: targets.workspaceDir,
|
||||
summary: summarizeMigrationItems(items),
|
||||
items,
|
||||
warnings,
|
||||
nextSteps: [
|
||||
"Run openclaw doctor after applying the migration.",
|
||||
"Review skipped Codex plugin/config/hook items before installing or recreating them in OpenClaw.",
|
||||
],
|
||||
metadata: {
|
||||
agentDir: targets.agentDir,
|
||||
codexHome: source.codexHome,
|
||||
codexSkillsDir: source.codexSkillsDir,
|
||||
personalAgentsSkillsDir: source.personalAgentsSkillsDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMigrationProvider } from "./provider.js";
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
|
||||
const logger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
async function makeTempRoot(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-migrate-codex-"));
|
||||
tempRoots.add(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function writeFile(filePath: string, content = ""): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
function makeContext(params: {
|
||||
source: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
reportDir?: string;
|
||||
}): MigrationProviderContext {
|
||||
return {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: params.workspaceDir,
|
||||
},
|
||||
},
|
||||
} as MigrationProviderContext["config"],
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
reportDir: params.reportDir,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
async function createCodexFixture(): Promise<{
|
||||
root: string;
|
||||
homeDir: string;
|
||||
codexHome: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
}> {
|
||||
const root = await makeTempRoot();
|
||||
const homeDir = path.join(root, "home");
|
||||
const codexHome = path.join(root, ".codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
vi.stubEnv("HOME", homeDir);
|
||||
await writeFile(path.join(codexHome, "skills", "tweet-helper", "SKILL.md"), "# Tweet helper\n");
|
||||
await writeFile(path.join(codexHome, "skills", ".system", "system-skill", "SKILL.md"));
|
||||
await writeFile(path.join(homeDir, ".agents", "skills", "personal-style", "SKILL.md"));
|
||||
await writeFile(
|
||||
path.join(
|
||||
codexHome,
|
||||
"plugins",
|
||||
"cache",
|
||||
"openai-primary-runtime",
|
||||
"documents",
|
||||
"1.0.0",
|
||||
".codex-plugin",
|
||||
"plugin.json",
|
||||
),
|
||||
JSON.stringify({ name: "documents" }),
|
||||
);
|
||||
await writeFile(path.join(codexHome, "config.toml"), 'model = "gpt-5.5"\n');
|
||||
await writeFile(path.join(codexHome, "hooks", "hooks.json"), "{}\n");
|
||||
return { root, homeDir, codexHome, stateDir, workspaceDir };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const root of tempRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
describe("buildCodexMigrationProvider", () => {
|
||||
it("plans Codex skills while keeping plugins and native config explicit", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.providerId).toBe("codex");
|
||||
expect(plan.source).toBe(fixture.codexHome);
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "skill:tweet-helper",
|
||||
kind: "skill",
|
||||
action: "copy",
|
||||
status: "planned",
|
||||
target: path.join(fixture.workspaceDir, "skills", "tweet-helper"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "skill:personal-style",
|
||||
kind: "skill",
|
||||
action: "copy",
|
||||
status: "planned",
|
||||
target: path.join(fixture.workspaceDir, "skills", "personal-style"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "plugin:documents:1",
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
status: "skipped",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "archive:config.toml",
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
status: "planned",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "archive:hooks/hooks.json",
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
status: "planned",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(plan.items).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "skill:system-skill" })]),
|
||||
);
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Codex native plugins are reported for manual review only"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies planned skills and archives native config during apply", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const reportDir = path.join(fixture.root, "report");
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
reportDir,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.access(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md")),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(fixture.workspaceDir, "skills", "personal-style", "SKILL.md")),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(reportDir, "archive", "config.toml")),
|
||||
).resolves.toBeUndefined();
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "plugin:documents:1", status: "skipped" }),
|
||||
expect.objectContaining({ id: "skill:tweet-helper", status: "migrated" }),
|
||||
expect.objectContaining({ id: "archive:config.toml", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
await expect(fs.access(path.join(reportDir, "report.json"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports existing skill targets as conflicts unless overwrite is set", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
await writeFile(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md"));
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
const overwritePlan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
overwrite: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "skill:tweet-helper", status: "conflict" }),
|
||||
]),
|
||||
);
|
||||
expect(overwritePlan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "skill:tweet-helper", status: "planned" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { MigrationPlan, MigrationProviderPlugin } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { applyCodexMigrationPlan } from "./apply.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
import { discoverCodexSource, hasCodexSource } from "./source.js";
|
||||
|
||||
export function buildCodexMigrationProvider(): MigrationProviderPlugin {
|
||||
return {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description:
|
||||
"Inventory and promote Codex CLI skills while keeping Codex native plugins and hooks explicit.",
|
||||
async detect(ctx) {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const found = hasCodexSource(source);
|
||||
return {
|
||||
found,
|
||||
source: source.root,
|
||||
label: "Codex",
|
||||
confidence: found ? source.confidence : "low",
|
||||
message: found ? "Codex state found." : "Codex state not found.",
|
||||
};
|
||||
},
|
||||
plan: buildCodexMigrationPlan,
|
||||
async apply(ctx, plan?: MigrationPlan) {
|
||||
return await applyCodexMigrationPlan({ ctx, plan });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
exists,
|
||||
isDirectory,
|
||||
readJsonObject,
|
||||
resolveHomePath,
|
||||
resolveUserHomeDir,
|
||||
} from "./helpers.js";
|
||||
|
||||
const SKILL_FILENAME = "SKILL.md";
|
||||
const MAX_SCAN_DEPTH = 6;
|
||||
const MAX_DISCOVERED_DIRS = 2000;
|
||||
|
||||
export type CodexSkillSource = {
|
||||
name: string;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export type CodexPluginSource = {
|
||||
name: string;
|
||||
source: string;
|
||||
manifestPath: string;
|
||||
};
|
||||
|
||||
export type CodexArchiveSource = {
|
||||
id: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type CodexSource = {
|
||||
root: string;
|
||||
confidence: "low" | "medium" | "high";
|
||||
codexHome: string;
|
||||
codexSkillsDir?: string;
|
||||
personalAgentsSkillsDir?: string;
|
||||
configPath?: string;
|
||||
hooksPath?: string;
|
||||
skills: CodexSkillSource[];
|
||||
plugins: CodexPluginSource[];
|
||||
archivePaths: CodexArchiveSource[];
|
||||
};
|
||||
|
||||
function defaultCodexHome(): string {
|
||||
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
|
||||
}
|
||||
|
||||
function personalAgentsSkillsDir(): string {
|
||||
return path.join(resolveUserHomeDir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
async function safeReadDir(dir: string): Promise<Dirent[]> {
|
||||
return await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
}
|
||||
|
||||
async function discoverSkillDirs(params: {
|
||||
root: string | undefined;
|
||||
sourceLabel: string;
|
||||
excludeSystem?: boolean;
|
||||
}): Promise<CodexSkillSource[]> {
|
||||
if (!params.root || !(await isDirectory(params.root))) {
|
||||
return [];
|
||||
}
|
||||
const discovered: CodexSkillSource[] = [];
|
||||
async function visit(dir: string, depth: number): Promise<void> {
|
||||
if (discovered.length >= MAX_DISCOVERED_DIRS || depth > MAX_SCAN_DEPTH) {
|
||||
return;
|
||||
}
|
||||
const name = path.basename(dir);
|
||||
if (params.excludeSystem && depth === 1 && name === ".system") {
|
||||
return;
|
||||
}
|
||||
if (await exists(path.join(dir, SKILL_FILENAME))) {
|
||||
discovered.push({ name, source: dir, sourceLabel: params.sourceLabel });
|
||||
return;
|
||||
}
|
||||
for (const entry of await safeReadDir(dir)) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
await visit(path.join(dir, entry.name), depth + 1);
|
||||
}
|
||||
}
|
||||
await visit(params.root, 0);
|
||||
return discovered;
|
||||
}
|
||||
|
||||
async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[]> {
|
||||
const root = path.join(codexHome, "plugins", "cache");
|
||||
if (!(await isDirectory(root))) {
|
||||
return [];
|
||||
}
|
||||
const discovered = new Map<string, CodexPluginSource>();
|
||||
async function visit(dir: string, depth: number): Promise<void> {
|
||||
if (discovered.size >= MAX_DISCOVERED_DIRS || depth > MAX_SCAN_DEPTH) {
|
||||
return;
|
||||
}
|
||||
const manifestPath = path.join(dir, ".codex-plugin", "plugin.json");
|
||||
if (await exists(manifestPath)) {
|
||||
const manifest = await readJsonObject(manifestPath);
|
||||
const manifestName = typeof manifest.name === "string" ? manifest.name.trim() : "";
|
||||
const name = manifestName || path.basename(dir);
|
||||
discovered.set(dir, { name, source: dir, manifestPath });
|
||||
return;
|
||||
}
|
||||
for (const entry of await safeReadDir(dir)) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
await visit(path.join(dir, entry.name), depth + 1);
|
||||
}
|
||||
}
|
||||
await visit(root, 0);
|
||||
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
|
||||
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
|
||||
const codexSkillsDir = path.join(codexHome, "skills");
|
||||
const agentsSkillsDir = personalAgentsSkillsDir();
|
||||
const configPath = path.join(codexHome, "config.toml");
|
||||
const hooksPath = path.join(codexHome, "hooks", "hooks.json");
|
||||
const codexSkills = await discoverSkillDirs({
|
||||
root: codexSkillsDir,
|
||||
sourceLabel: "Codex CLI skill",
|
||||
excludeSystem: true,
|
||||
});
|
||||
const personalAgentSkills = await discoverSkillDirs({
|
||||
root: agentsSkillsDir,
|
||||
sourceLabel: "personal AgentSkill",
|
||||
});
|
||||
const plugins = await discoverPluginDirs(codexHome);
|
||||
const archivePaths: CodexArchiveSource[] = [];
|
||||
if (await exists(configPath)) {
|
||||
archivePaths.push({
|
||||
id: "archive:config.toml",
|
||||
path: configPath,
|
||||
relativePath: "config.toml",
|
||||
message: "Codex config is archived for manual review; it is not activated automatically.",
|
||||
});
|
||||
}
|
||||
if (await exists(hooksPath)) {
|
||||
archivePaths.push({
|
||||
id: "archive:hooks/hooks.json",
|
||||
path: hooksPath,
|
||||
relativePath: "hooks/hooks.json",
|
||||
message:
|
||||
"Codex native hooks are archived for manual review because they can execute commands.",
|
||||
});
|
||||
}
|
||||
const skills = [...codexSkills, ...personalAgentSkills].toSorted((a, b) =>
|
||||
a.source.localeCompare(b.source),
|
||||
);
|
||||
const high = Boolean(codexSkills.length || plugins.length || archivePaths.length);
|
||||
const medium = personalAgentSkills.length > 0;
|
||||
return {
|
||||
root: codexHome,
|
||||
confidence: high ? "high" : medium ? "medium" : "low",
|
||||
codexHome,
|
||||
...((await isDirectory(codexSkillsDir)) ? { codexSkillsDir } : {}),
|
||||
...((await isDirectory(agentsSkillsDir)) ? { personalAgentsSkillsDir: agentsSkillsDir } : {}),
|
||||
...((await exists(configPath)) ? { configPath } : {}),
|
||||
...((await exists(hooksPath)) ? { hooksPath } : {}),
|
||||
skills,
|
||||
plugins,
|
||||
archivePaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasCodexSource(source: CodexSource): boolean {
|
||||
return source.confidence !== "low";
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveHomePath } from "./helpers.js";
|
||||
|
||||
export type CodexMigrationTargets = {
|
||||
workspaceDir: string;
|
||||
agentDir: string;
|
||||
};
|
||||
|
||||
export function resolveCodexMigrationTargets(ctx: MigrationProviderContext): CodexMigrationTargets {
|
||||
const cfg = ctx.config;
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
|
||||
const agentDir =
|
||||
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
|
||||
(configuredAgentDir ? resolveHomePath(configuredAgentDir) : undefined) ??
|
||||
path.join(ctx.stateDir, "agents", agentId, "agent");
|
||||
return { workspaceDir, agentDir };
|
||||
}
|
||||
@@ -110,37 +110,6 @@ describe("deepseek provider plugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises max thinking levels for DeepSeek V4 models only", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepseekPlugin);
|
||||
const resolveThinkingProfile = provider.resolveThinkingProfile!;
|
||||
const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
} as never)?.levels.map((level) => level.id),
|
||||
).toEqual(expectedV4Levels);
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-flash",
|
||||
} as never)?.defaultLevel,
|
||||
).toBe("high");
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-flash",
|
||||
} as never)?.levels.map((level) => level.id),
|
||||
).toEqual(expectedV4Levels);
|
||||
expect(
|
||||
resolveThinkingProfile({ provider: "deepseek", modelId: "deepseek-chat" } as never),
|
||||
).toBe(undefined);
|
||||
expect(
|
||||
resolveThinkingProfile({ provider: "deepseek", modelId: "deepseek-reasoner" } as never),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("maps thinking levels to DeepSeek V4 payload controls", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn = (
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { isDeepSeekV4ModelId } from "./models.js";
|
||||
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { buildDeepSeekProvider } from "./provider-catalog.js";
|
||||
import { createDeepSeekV4ThinkingWrapper } from "./stream.js";
|
||||
|
||||
const PROVIDER_ID = "deepseek";
|
||||
const V4_THINKING_LEVEL_IDS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
||||
|
||||
function buildDeepSeekV4ThinkingLevel(id: (typeof V4_THINKING_LEVEL_IDS)[number]) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
const DEEPSEEK_V4_THINKING_PROFILE = {
|
||||
levels: V4_THINKING_LEVEL_IDS.map(buildDeepSeekV4ThinkingLevel),
|
||||
defaultLevel: "high",
|
||||
} satisfies ProviderThinkingProfile;
|
||||
|
||||
function resolveDeepSeekV4ThinkingProfile(modelId: string): ProviderThinkingProfile | undefined {
|
||||
return isDeepSeekV4ModelId(modelId) ? DEEPSEEK_V4_THINKING_PROFILE : undefined;
|
||||
}
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
@@ -62,7 +46,9 @@ export default defineSingleProviderPluginEntry({
|
||||
/\bdeepseek\b.*(?:input.*too long|context.*exceed)/i.test(errorMessage),
|
||||
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
|
||||
wrapStreamFn: (ctx) => createDeepSeekV4ThinkingWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||
resolveThinkingProfile: ({ modelId }) => resolveDeepSeekV4ThinkingProfile(modelId),
|
||||
isModernModelRef: ({ modelId }) => Boolean(resolveDeepSeekV4ThinkingProfile(modelId)),
|
||||
isModernModelRef: ({ modelId }) => {
|
||||
const lower = modelId.toLowerCase();
|
||||
return lower === "deepseek-v4-flash" || lower === "deepseek-v4-pro";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,15 +19,3 @@ export function buildDeepSeekModelDefinition(
|
||||
api: "openai-completions",
|
||||
};
|
||||
}
|
||||
|
||||
const DEEPSEEK_V4_MODEL_IDS = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]);
|
||||
|
||||
export function isDeepSeekV4ModelId(modelId: string): boolean {
|
||||
return DEEPSEEK_V4_MODEL_IDS.has(modelId.toLowerCase());
|
||||
}
|
||||
|
||||
export function isDeepSeekV4ModelRef(model: { provider?: string; id?: unknown }): boolean {
|
||||
return (
|
||||
model.provider === "deepseek" && typeof model.id === "string" && isDeepSeekV4ModelId(model.id)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user