mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
Merge remote-tracking branch 'origin/main' into jesse/non-clawhub-untrusted-installs
This commit is contained in:
@@ -5,10 +5,12 @@ description: Use the Crabbox wrapper for OpenClaw remote validation across Linux
|
||||
|
||||
# Crabbox
|
||||
|
||||
OpenClaw agent sessions use the Crabbox wrapper by default for tests and
|
||||
computationally intensive work: builds, typechecks, lint fan-out, broad gates,
|
||||
CI-parity checks, secrets, hosted services, Docker/E2E/package lanes, warmed
|
||||
reusable boxes, sync timing, logs/results, cache inspection, and lease cleanup.
|
||||
OpenClaw agent sessions use the Crabbox wrapper for heavy proof: larger test
|
||||
suites, builds, typechecks, lint fan-out, broad gates, CI-parity checks,
|
||||
secrets, hosted services, Docker/E2E/package lanes, reusable boxes, sync
|
||||
timing, logs/results, cache inspection, and lease cleanup. For trusted source,
|
||||
one/few focused tests stay local when the existing dependency install is ready.
|
||||
Untrusted repository tooling never runs locally, regardless of proof size.
|
||||
|
||||
Crabbox is the transport/orchestration surface. The actual backend can be:
|
||||
|
||||
@@ -18,7 +20,7 @@ Crabbox is the transport/orchestration surface. The actual backend can be:
|
||||
`provider=blacksmith-testbox`, ids like `tbx_...`, `syncDelegated=true`
|
||||
|
||||
Blacksmith Testbox through the Crabbox wrapper is the default OpenClaw agent
|
||||
backend for trusted maintainer code and heavy `pnpm` gates. The configured
|
||||
backend for trusted maintainer heavy proof and heavy `pnpm` gates. The configured
|
||||
Blacksmith workflow hydrates provider and agent credentials, so never sync or
|
||||
run untrusted contributor/fork code there. Use secretless fork CI or
|
||||
sanitized direct AWS Crabbox for untrusted source. Do not describe
|
||||
@@ -53,8 +55,8 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
|
||||
If warmup drifts well past the minute-scale path, verify image promotion,
|
||||
region/AZ placement, and FSR state before blaming OpenClaw.
|
||||
- For trusted OpenClaw agent tests and computationally intensive work, use the
|
||||
repo wrapper with `--provider blacksmith-testbox` or the repo Testbox helpers.
|
||||
- For trusted OpenClaw computationally intensive work, use the repo wrapper
|
||||
with `--provider blacksmith-testbox` or the repo Testbox helpers.
|
||||
- Treat contributor/fork source as untrusted unless a maintainer explicitly
|
||||
approves credentialed execution after review. Run untrusted source only in
|
||||
secretless fork CI or sanitized direct AWS Crabbox. For every untrusted AWS
|
||||
@@ -79,17 +81,13 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
hydrated lease. If the broker cannot provide
|
||||
the no-role proof or no remote PR exists, use secretless fork CI. Never use
|
||||
`hydrate-github` or a credential-hydrated Testbox workflow for untrusted code.
|
||||
- Cold Testbox acquisition and hydration often take about a minute. At the
|
||||
start of any task likely to change code or need tests/heavy proof, immediately
|
||||
start, after confirming the source is trusted,
|
||||
`node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json`
|
||||
in a background command session while inspecting and editing. Poll later,
|
||||
reuse the returned `tbx_...` with
|
||||
`--provider blacksmith-testbox --id <tbx_id>`, and stop it before handoff.
|
||||
For untrusted source, switch to a clean trusted `main` checkout and pre-warm
|
||||
with the installed binary after the empty-instance-profile check below.
|
||||
Do not warm for read-only, docs-only, or clearly trivial work that will not
|
||||
run tests or heavy commands.
|
||||
- Cold Testbox acquisition and hydration often take about a minute. Do not
|
||||
pre-warm for anticipated work. After confirming source trust, acquire a box
|
||||
lazily when the first heavy command is ready. Reuse the returned `tbx_...`
|
||||
with `--provider blacksmith-testbox --id <tbx_id>` for later heavy commands,
|
||||
and stop it before handoff. For untrusted heavy proof, switch to a clean
|
||||
trusted `main` checkout and lazily warm with the installed binary after the
|
||||
empty-instance-profile check below.
|
||||
- Run untrusted source only with the sanitized form below. The explicit
|
||||
allowlist prevents locally exported `OPENCLAW_*` credentials from crossing
|
||||
the SSH boundary; `--no-hydrate` and temporary `HOME` prevent auth-profile
|
||||
@@ -146,9 +144,11 @@ env -u CRABBOX_AWS_INSTANCE_PROFILE \
|
||||
not leave it in remote shell history or logs. If no secret-safe injection path
|
||||
is available, say true live provider auth is blocked instead of silently using
|
||||
a fake key.
|
||||
- Agent-run tests, including targeted edit-loop tests, default to a pre-warmed
|
||||
remote box selected by source trust. Local test execution requires an
|
||||
explicit user request or a reported remote-provider blocker.
|
||||
- Run one/few targeted edit-loop tests locally with
|
||||
`node scripts/run-vitest.mjs` when the existing dependency install is ready.
|
||||
If the proof fans out, becomes expensive, lacks ready dependencies, or needs
|
||||
OS/package/Docker/service behavior, acquire a remote box selected by source
|
||||
trust.
|
||||
- Do not treat inherited shell env as operator intent. In particular,
|
||||
`OPENCLAW_LOCAL_CHECK_MODE=throttled` from the local shell is not permission
|
||||
to move broad `pnpm check:changed`, `pnpm test:changed`, full `pnpm test`, or
|
||||
@@ -444,8 +444,11 @@ Efficient flow:
|
||||
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
|
||||
when feasible. If the issue cannot be reproduced, capture the exact command
|
||||
and observed behavior instead.
|
||||
2. Patch locally and run narrow tests on the pre-warmed remote box.
|
||||
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
|
||||
2. For trusted source, patch locally and run narrow tests locally when
|
||||
dependencies are ready and the proof remains bounded. Never run untrusted
|
||||
repository tooling locally.
|
||||
3. Lazily acquire one Crabbox when heavy proof is needed, then run an E2E
|
||||
command that starts from the user-facing entrypoint:
|
||||
package install, Docker setup, onboarding, channel add, gateway start, or
|
||||
agent turn as appropriate.
|
||||
4. Record proof as: Testbox id, command, environment shape, redacted secret
|
||||
@@ -463,9 +466,9 @@ Keep it efficient:
|
||||
top of that PR.
|
||||
- Use `--full-resync` before replacing a warmed direct-provider lease when the
|
||||
remote workdir or sync fingerprint appears stale.
|
||||
- For agent code tasks, reuse the pre-warmed remote box across focused tests
|
||||
and heavy proof. Use a one-shot only when a single late proof is genuinely
|
||||
the task's only remote command.
|
||||
- After the first heavy proof acquires a remote box, reuse it across later
|
||||
heavy commands. Use a one-shot when a single late proof is the task's only
|
||||
remote command.
|
||||
- Prefer `OPENCLAW_CURRENT_PACKAGE_TGZ` with Docker/package lanes when testing a
|
||||
candidate tarball; prefer the repo's package helper instead of direct source
|
||||
execution when the bug might be packaging/install related.
|
||||
@@ -525,9 +528,9 @@ Interactive CLI/onboarding:
|
||||
|
||||
## Reuse And Keepalive
|
||||
|
||||
Agent code tasks should pre-warm and reuse one remote box selected by source
|
||||
trust for focused tests and heavy proof. One-shot runs remain appropriate for a
|
||||
single late proof when early warmup was not warranted.
|
||||
Agent code tasks should acquire one remote box lazily when the first heavy
|
||||
proof is ready, then reuse it for later heavy commands. One-shot runs remain
|
||||
appropriate for a single late proof.
|
||||
|
||||
Reuse the lease, not stale source. Each command must sync the current checkout;
|
||||
use `--no-sync` only to rerun an unchanged, already-synced tree intentionally.
|
||||
|
||||
@@ -434,10 +434,10 @@ function mergedByTarget(mergedAt, targetTimestamp) {
|
||||
return Number.isFinite(mergedTimestamp) && mergedTimestamp <= targetTimestamp;
|
||||
}
|
||||
|
||||
function sectionFor(changelog, version) {
|
||||
function optionalSectionFor(changelog, version) {
|
||||
const heading = new RegExp(`^## ${escapeRegExp(version)}\\r?$`, "m").exec(changelog);
|
||||
if (!heading || heading.index === undefined) {
|
||||
fail(`CHANGELOG.md does not contain ## ${version}`);
|
||||
return undefined;
|
||||
}
|
||||
const start = heading.index;
|
||||
const bodyStart = changelog.indexOf("\n", start) + 1;
|
||||
@@ -453,6 +453,14 @@ function sectionFor(changelog, version) {
|
||||
};
|
||||
}
|
||||
|
||||
function sectionFor(changelog, version) {
|
||||
const section = optionalSectionFor(changelog, version);
|
||||
if (!section) {
|
||||
fail(`CHANGELOG.md does not contain ## ${version}`);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function referencesIn(text) {
|
||||
const references = [];
|
||||
for (const match of text.matchAll(
|
||||
@@ -641,6 +649,22 @@ export function contributionRecordFor(section) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function contributionRecordTarget(section) {
|
||||
const recordStart = section.source.search(/\n### Complete contribution (?:ledger|record)\r?$/m);
|
||||
if (recordStart < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return section.source
|
||||
.slice(recordStart)
|
||||
.match(/^This audited record covers the complete \S+\.\.(?<target>[0-9a-f]{40}) history:/mu)
|
||||
?.groups?.target;
|
||||
}
|
||||
|
||||
export function pullRequestTitleFromCommitSubject(subject, number) {
|
||||
const match = subject.match(/^(?<title>\S(?:.*\S)?) \(#(?<number>[1-9]\d*)\)$/u);
|
||||
return match?.groups?.number === String(number) ? match.groups.title : undefined;
|
||||
}
|
||||
|
||||
function completeContributionRecord(section, label) {
|
||||
const recordStart = section.source.search(/\n### Complete contribution record\r?$/m);
|
||||
if (recordStart < 0) {
|
||||
@@ -993,7 +1017,7 @@ function sourceCommits(base, target, mainRef) {
|
||||
"log",
|
||||
"--first-parent",
|
||||
"--reverse",
|
||||
"--format=%H%x1f%s%x1f%an%x1f%ae%x1f%B%x1e",
|
||||
"--format=%H%x1f%s%x1f%an%x1f%ae%x1f%cI%x1f%B%x1e",
|
||||
`${mergeBase}..${targetCommit}`,
|
||||
]);
|
||||
const commits = new Map();
|
||||
@@ -1002,7 +1026,8 @@ function sourceCommits(base, target, mainRef) {
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
const [rawHash, subject, authorName, authorEmail, ...bodyParts] = record.split("\x1f");
|
||||
const [rawHash, subject, authorName, authorEmail, committedAt, ...bodyParts] =
|
||||
record.split("\x1f");
|
||||
const hash = rawHash.trim();
|
||||
const body = bodyParts.join("\x1f");
|
||||
const revertedHash = standardRevertedHash(body);
|
||||
@@ -1011,6 +1036,7 @@ function sourceCommits(base, target, mainRef) {
|
||||
authorEmail,
|
||||
authorName,
|
||||
body,
|
||||
committedAt,
|
||||
hash,
|
||||
isRevert,
|
||||
revertedHash,
|
||||
@@ -1085,6 +1111,7 @@ function sourceCommits(base, target, mainRef) {
|
||||
authorName: commit.authorName,
|
||||
body: commit.body,
|
||||
closingReferences: [],
|
||||
committedAt: commit.committedAt,
|
||||
coauthors: coauthorEmails.map(githubHandleFromNoreply).filter(isEligibleHandle),
|
||||
coauthorEmails,
|
||||
hash: commit.hash,
|
||||
@@ -1116,6 +1143,7 @@ function sourceCommits(base, target, mainRef) {
|
||||
authorName: commit.authorName,
|
||||
body: commit.body,
|
||||
closingReferences: closingReferencesIn(`${commit.subject}\n${commit.body}`),
|
||||
committedAt: commit.committedAt,
|
||||
coauthors,
|
||||
coauthorEmails,
|
||||
hash: commit.hash,
|
||||
@@ -1562,6 +1590,73 @@ function resolveReferences(numbers) {
|
||||
return resolveIssueRelationshipPages(nodes);
|
||||
}
|
||||
|
||||
// A vanished GitHub PR is recoverable only when a prior exact-SHA ledger
|
||||
// covered its exact merge-title commit; every other unresolved ref stays fatal.
|
||||
export function recoverUnavailablePullRequests({
|
||||
numbers,
|
||||
nodes,
|
||||
record,
|
||||
recordTarget,
|
||||
source,
|
||||
isAncestor = gitIsAncestor,
|
||||
}) {
|
||||
const recovered = new Map();
|
||||
if (!recordTarget || !isAncestor(recordTarget, source.target)) {
|
||||
return recovered;
|
||||
}
|
||||
for (const number of numbers) {
|
||||
if (nodes.has(number)) {
|
||||
continue;
|
||||
}
|
||||
const recorded = record.pullRequests.get(number);
|
||||
if (!recorded) {
|
||||
continue;
|
||||
}
|
||||
const commits = source.activeCommits
|
||||
.map((commit) => ({
|
||||
commit,
|
||||
title: pullRequestTitleFromCommitSubject(commit.subject, number),
|
||||
}))
|
||||
.filter(
|
||||
({ commit, title }) =>
|
||||
title &&
|
||||
commit.references.includes(number) &&
|
||||
Number.isFinite(Date.parse(commit.committedAt)),
|
||||
);
|
||||
if (commits.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
const { commit, title } = commits[0];
|
||||
if (!isAncestor(commit.hash, recordTarget)) {
|
||||
continue;
|
||||
}
|
||||
const authorHandle = commit.authorHandle ?? recorded.thanks[0];
|
||||
const node = {
|
||||
__typename: "PullRequest",
|
||||
number,
|
||||
title,
|
||||
baseRefName: "main",
|
||||
mergedAt: commit.committedAt,
|
||||
mergeCommit: { oid: commit.hash },
|
||||
...(authorHandle ? { author: { __typename: "User", login: authorHandle } } : {}),
|
||||
closingIssuesReferences: {
|
||||
nodes: commit.closingReferences.map((reference) => ({ number: reference })),
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
},
|
||||
};
|
||||
nodes.set(number, node);
|
||||
recovered.set(number, node);
|
||||
commit.pullRequests = [...new Set([...commit.pullRequests, number])];
|
||||
source.pullRequests.add(number);
|
||||
const credits = source.coauthorsByReference.get(number) ?? new Set();
|
||||
for (const handle of recorded.thanks) {
|
||||
credits.add(handle);
|
||||
}
|
||||
source.coauthorsByReference.set(number, credits);
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
export function validateReleaseProvenanceOverrides(
|
||||
provenanceOverrides,
|
||||
nodes,
|
||||
@@ -2279,6 +2374,16 @@ function main() {
|
||||
const changelog = readFileSync("CHANGELOG.md", "utf8");
|
||||
const section = sectionFor(changelog, options.version);
|
||||
const source = sourceCommits(options.base, options.target, options.mainRef ?? "origin/main");
|
||||
const committedSection = optionalSectionFor(
|
||||
git(["show", `${source.target}:CHANGELOG.md`]),
|
||||
options.version,
|
||||
);
|
||||
const committedRecord = committedSection
|
||||
? contributionRecordFor(committedSection)
|
||||
: { legacyIssues: new Map(), pullRequests: new Map() };
|
||||
const committedRecordTarget = committedSection
|
||||
? contributionRecordTarget(committedSection)
|
||||
: undefined;
|
||||
const shippedBaselineRecords = options.shippedRefs.map(shippedBaselineFor);
|
||||
const shippedExclusions = subtractShippedPullRequests(source, shippedBaselineRecords);
|
||||
source.shippedBaselines = shippedExclusions.baselines;
|
||||
@@ -2360,6 +2465,13 @@ function main() {
|
||||
);
|
||||
appendReferences(references, legacyIssuePullRequests);
|
||||
nodes = resolveReferences(references);
|
||||
const recoveredPullRequests = recoverUnavailablePullRequests({
|
||||
numbers: references,
|
||||
nodes,
|
||||
record: committedRecord,
|
||||
recordTarget: committedRecordTarget,
|
||||
source,
|
||||
});
|
||||
const unresolvedSourceReferences = references.filter((number) => !nodes.has(number));
|
||||
if (unresolvedSourceReferences.length > 0) {
|
||||
fail(
|
||||
@@ -2380,6 +2492,11 @@ function main() {
|
||||
appendReferences(resolvedReferences, titleReferenceNumbers);
|
||||
appendReferences(resolvedReferences, closingIssueNumbers);
|
||||
nodes = resolveReferences(resolvedReferences);
|
||||
for (const [number, node] of recoveredPullRequests) {
|
||||
if (!nodes.has(number)) {
|
||||
nodes.set(number, node);
|
||||
}
|
||||
}
|
||||
const invalidRecordedPullRequests = [...priorRecord.pullRequests.keys()].filter((number) => {
|
||||
const node = nodes.get(number);
|
||||
return (
|
||||
|
||||
@@ -18,25 +18,23 @@ or validating a change without wasting hours.
|
||||
|
||||
Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
|
||||
Agent sessions are remote-first for tests and computationally intensive work.
|
||||
Classify source trust before selecting a backend. Trusted maintainer code
|
||||
defaults to Blacksmith Testbox. Untrusted contributor or fork code must use
|
||||
secretless fork CI or sanitized direct AWS Crabbox; never sync or run it on the
|
||||
credential-hydrated Blacksmith workflow.
|
||||
Route by source trust first, then proof size. Only trusted source may run
|
||||
locally; never execute untrusted repository tooling locally, regardless of
|
||||
proof size. Run one/few focused tests and cheap static checks locally when the
|
||||
existing dependency install is ready. Use a
|
||||
remote backend for larger suites, changed gates with typecheck/lint fan-out,
|
||||
builds, Docker, packaging, E2E, live proof, and cross-platform work. Trusted
|
||||
maintainer heavy proof defaults to Blacksmith Testbox. Untrusted contributor
|
||||
or fork code must use secretless fork CI or sanitized direct AWS Crabbox;
|
||||
never sync or run it on the credential-hydrated Blacksmith workflow.
|
||||
|
||||
When trusted work is likely to change code or need tests, builds, typechecks,
|
||||
lint fan-out, Docker, packaging, E2E, or live proof, immediately start this in
|
||||
a background command session:
|
||||
Do not pre-warm for anticipated work. Acquire the backend lazily when the
|
||||
first heavy command is ready to run, save its id, reuse it for later heavy
|
||||
commands, and stop it before handoff. A single late heavy command can remain a
|
||||
one-shot.
|
||||
|
||||
```bash
|
||||
node scripts/crabbox-wrapper.mjs warmup \
|
||||
--provider blacksmith-testbox \
|
||||
--keep \
|
||||
--timing-json
|
||||
```
|
||||
|
||||
For untrusted code, switch to a clean trusted `main` checkout and pre-warm
|
||||
direct AWS with an installed trusted Crabbox binary. Do not execute the
|
||||
For untrusted heavy proof, switch to a clean trusted `main` checkout and lazily
|
||||
warm direct AWS with an installed trusted Crabbox binary. Do not execute the
|
||||
untrusted checkout's wrapper or config locally:
|
||||
|
||||
```bash
|
||||
@@ -91,22 +89,19 @@ env -u CRABBOX_AWS_INSTANCE_PROFILE \
|
||||
crabbox stop --provider aws <cbx_id>
|
||||
```
|
||||
|
||||
Continue inspection and editing while the remote box hydrates. Save the
|
||||
returned id, reuse it for the task's focused tests and heavy gates, sync the
|
||||
current checkout on every run, and stop it before handoff. Do not pre-warm for
|
||||
read-only, docs-only, or clearly trivial work that will not run tests or heavy
|
||||
commands.
|
||||
Once heavy proof starts, save the returned id, reuse it for later heavy gates,
|
||||
sync the current checkout on every run, and stop it before handoff.
|
||||
|
||||
1. Inspect the diff and classify the touched surface:
|
||||
- any agent-run test, focused or broad: run it on the pre-warmed safe remote
|
||||
backend; Blacksmith Testbox only for trusted maintainer code
|
||||
- trusted source, one/few focused tests with ready local dependencies:
|
||||
`node scripts/run-vitest.mjs <path-or-filter>`
|
||||
- if focused proof fans out, becomes expensive, or lacks ready dependencies:
|
||||
acquire the safe remote backend selected by source trust
|
||||
- changed gates, builds, typechecks, lint fan-out, Docker, package, E2E, or
|
||||
live work: run it remotely; these are never routine laptop work
|
||||
- normal source checkout, `pnpm check:changed`: it delegates to
|
||||
Crabbox/Testbox, but prefer the explicit kept-lease path when a Testbox was
|
||||
pre-warmed so the task reuses one lease
|
||||
- explicit local fallback requested by the user, one/few files:
|
||||
`node scripts/run-vitest.mjs <path-or-filter>`
|
||||
- `check:changed` classifies first; docs-only, no-change, and small metadata
|
||||
plans stay local when dependencies are ready, while heavy or dependency-
|
||||
missing plans delegate remotely
|
||||
- direct AWS Crabbox proof: pass `--provider aws`; untrusted code also
|
||||
requires the sanitized invocation above
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
@@ -119,7 +114,9 @@ commands.
|
||||
## Guardrails
|
||||
|
||||
- Do not kill unrelated processes or tests. If something is running elsewhere, treat it as owned by the user or another agent.
|
||||
- Do not run tests or computationally intensive commands locally unless the user explicitly asks for local proof. Remote-provider unavailability permits only a narrow reported fallback, not a silent local full gate.
|
||||
- Keep trusted-source local proof bounded to one/few focused tests and cheap
|
||||
static checks with ready dependencies. Untrusted repository tooling never
|
||||
runs locally. Full suites and computationally intensive commands run remotely.
|
||||
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
|
||||
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
|
||||
- If dependencies are missing on the selected remote box, run `pnpm install` there, retry
|
||||
@@ -127,9 +124,11 @@ commands.
|
||||
local Codex worktree merely to run validation.
|
||||
- In a Codex worktree or linked/sparse checkout, do not run direct local
|
||||
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer`. Use
|
||||
`node scripts/crabbox-wrapper.mjs` for remote proof, and `git commit --no-verify`
|
||||
only after the relevant remote proof is already clean. The direct
|
||||
`node scripts/run-vitest.mjs` path is an explicit local fallback only.
|
||||
`node scripts/crabbox-wrapper.mjs` for remote proof and
|
||||
`node scripts/check-changed.mjs` for classify-first changed checks. Use
|
||||
`node scripts/run-vitest.mjs` for bounded focused local proof when the
|
||||
dependency install is ready. Use `git commit --no-verify` only after the
|
||||
relevant proof is already clean.
|
||||
- For remote proof, use the Crabbox wrapper first, but name the actual backend.
|
||||
Direct AWS Crabbox uses `provider=aws` and `cbx_...` ids. Delegated
|
||||
Blacksmith Testbox through Crabbox uses `provider=blacksmith-testbox`,
|
||||
@@ -169,14 +168,14 @@ commands.
|
||||
current checkout. Use `--no-sync` only to rerun an unchanged, already-synced
|
||||
tree intentionally.
|
||||
|
||||
## Explicit Local Test Fallbacks
|
||||
## Local Focused Proof
|
||||
|
||||
These commands are for human workflows or an agent's explicit local fallback.
|
||||
They are not the default agent path.
|
||||
Use these commands only while the dependency install is ready and the proof
|
||||
remains bounded. If it fans out or becomes expensive, acquire a remote backend.
|
||||
|
||||
```bash
|
||||
pnpm changed:lanes --json
|
||||
pnpm check:changed # Crabbox/Testbox changed typecheck/lint/guards; no Vitest
|
||||
pnpm check:changed # local small plan or delegated heavy plan; no Vitest
|
||||
pnpm test:changed # cheap smart changed Vitest targets
|
||||
pnpm verify # full check, then full Vitest
|
||||
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
|
||||
|
||||
@@ -215,16 +215,15 @@ Stop watchers before ending the turn or switching strategy.
|
||||
failed child; never rebuild an immutable version that already published
|
||||
7. If a required PR CI run is capacity-stalled with queued jobs and no active
|
||||
jobs, do not cancel unrelated work or accept a generic manual dispatch.
|
||||
First check the target-owned workflow with `git show
|
||||
<full-pr-sha>:.github/workflows/ci.yml | rg -q '^ +pr_number:'`. When it
|
||||
declares `pr_number`, dispatch the explicit exact-SHA fallback:
|
||||
First verify the PR head carries the current fallback schema:
|
||||
`gh api 'repos/openclaw/openclaw/contents/.github/workflows/ci.yml?ref=<pr-head-branch>'
|
||||
--jq .content | base64 --decode | rg -q 'pull_request_number:'`. If absent,
|
||||
refresh the PR head from `main` and use the new head SHA; let normal CI run
|
||||
before considering another fallback.
|
||||
From the PR head branch, dispatch the explicit exact-SHA fallback:
|
||||
`gh workflow run ci.yml --repo openclaw/openclaw --ref <pr-head-branch> -f
|
||||
target_ref=<full-pr-sha> -f pr_number=<pr-number> -f include_android=true -f
|
||||
release_gate=true`.
|
||||
The workflow authenticates that PR's head/base, validates GitHub's current
|
||||
synthetic merge tree, and runs the LOC task from that tree. Older workflow
|
||||
schemas cannot provide equivalent LOC evidence; update the head to contain the current `pr_number` workflow, then
|
||||
restart exact-head proof on the new SHA instead of dispatching them.
|
||||
target_ref=<full-pr-sha> -f pull_request_number=<pr-number> -f
|
||||
include_android=true -f release_gate=true`.
|
||||
It runs on GitHub-hosted runners and is accepted only when its run title is
|
||||
`CI release gate <full-pr-sha>`. Record the stalled Blacksmith run and the
|
||||
fallback run in release evidence.
|
||||
|
||||
@@ -167,16 +167,14 @@ a workflow fix that the existing parent run cannot consume.
|
||||
gates on the newly pushed SHA, then run `prepare-run` again.
|
||||
- If an exact PR-head CI run has no active jobs because Blacksmith capacity is
|
||||
stalled, a maintainer may dispatch the explicit GitHub-hosted fallback from
|
||||
the PR head branch. Check the target-owned workflow with `git show
|
||||
<full-pr-sha>:.github/workflows/ci.yml | rg -q '^ +pr_number:'`. When it
|
||||
declares `pr_number`, run:
|
||||
the PR head branch. First verify its workflow carries the current schema with
|
||||
`gh api 'repos/openclaw/openclaw/contents/.github/workflows/ci.yml?ref=<pr-head-branch>'
|
||||
--jq .content | base64 --decode | rg -q 'pull_request_number:'`. If absent,
|
||||
refresh the PR head from `main`, use the new SHA, and let normal CI run before
|
||||
considering another fallback. Then dispatch:
|
||||
`gh workflow run ci.yml --repo openclaw/openclaw --ref <pr-head-branch> -f
|
||||
target_ref=<full-pr-sha> -f pr_number=<pr-number> -f include_android=true -f
|
||||
release_gate=true`.
|
||||
The workflow authenticates that PR's head/base, validates GitHub's current
|
||||
synthetic merge tree, and runs the LOC task from that tree. Older workflow
|
||||
schemas cannot provide equivalent LOC evidence; update the head to contain the current `pr_number` workflow, then
|
||||
restart exact-head proof on the new SHA instead of dispatching them.
|
||||
target_ref=<full-pr-sha> -f pull_request_number=<pr-number> -f
|
||||
include_android=true -f release_gate=true`.
|
||||
Use it only for an observed provider queue stall, never for failed CI or as a
|
||||
routine shortcut. The run must be named `CI release gate <full-pr-sha>` and
|
||||
pass on that exact SHA; the native hosted-gate verifier rejects generic manual
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
- "extensions/file-transfer/**"
|
||||
- "docs/nodes/index.md"
|
||||
- "docs/plugins/sdk-runtime.md"
|
||||
"plugin: linux-node":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/linux-node/**"
|
||||
- "docs/nodes/camera.md"
|
||||
- "docs/nodes/location-command.md"
|
||||
- "docs/platforms/linux.md"
|
||||
"plugin: pixverse":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
contents: read
|
||||
name: "check"
|
||||
# Pull requests only validate the hydration steps; real Testbox leases arrive via dispatch.
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'blacksmith-16vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '120') }}
|
||||
steps:
|
||||
# Testbox lifecycle actions require Blacksmith VM metadata; PRs validate our setup steps only.
|
||||
|
||||
+214
-133
@@ -8,16 +8,6 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
pr_number:
|
||||
description: Pull request number required by the exact-SHA release gate
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
loc_base_ref:
|
||||
description: Optional exact LOC comparison-base SHA for standalone manual runs
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_android:
|
||||
description: Run Android lanes for this manual CI dispatch.
|
||||
required: false
|
||||
@@ -28,6 +18,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request_number:
|
||||
description: Pull request number required by the exact-SHA release gate.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
dispatch_id:
|
||||
description: Optional parent workflow dispatch identifier
|
||||
required: false
|
||||
@@ -50,8 +45,6 @@ on:
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
paths-ignore:
|
||||
- "CHANGELOG.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -91,15 +84,12 @@ jobs:
|
||||
preflight:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
needs: [runner-admission]
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
|
||||
loc_base_sha: ${{ steps.release_gate_loc_tree.outputs.base_sha || inputs.loc_base_ref || '' }}
|
||||
loc_head_sha: ${{ steps.release_gate_loc_tree.outputs.head_sha || '' }}
|
||||
docs_only: ${{ steps.manifest.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
|
||||
run_node: ${{ steps.manifest.outputs.run_node }}
|
||||
@@ -143,7 +133,8 @@ jobs:
|
||||
- name: Validate release-gate dispatch
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }}
|
||||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||||
TARGET_REF: ${{ inputs.target_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -153,8 +144,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "release_gate requires pr_number to identify an open pull request" >&2
|
||||
if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "release_gate requires pull_request_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -163,14 +154,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate manual LOC base input
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.loc_base_ref != ''
|
||||
env:
|
||||
LOC_BASE_REF: ${{ inputs.loc_base_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$LOC_BASE_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "loc_base_ref must be a full commit SHA" >&2
|
||||
if [[ -n "$HISTORICAL_TARGET_TAG" ]]; then
|
||||
echo "release_gate cannot be combined with historical_target_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -257,67 +242,6 @@ jobs:
|
||||
id: checkout_ref
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate release-gate PR merge tree
|
||||
id: release_gate_loc_tree
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
TARGET_REF: ${{ inputs.target_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
merge_sha=""
|
||||
pr_base_sha=""
|
||||
for attempt in {1..12}; do
|
||||
pr_json="$(gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
|
||||
pr_state="$(jq -r '.state // ""' <<<"$pr_json")"
|
||||
pr_head_sha="$(jq -r '.head.sha // ""' <<<"$pr_json")"
|
||||
pr_base_sha="$(jq -r '.base.sha // ""' <<<"$pr_json")"
|
||||
pr_base_repo="$(jq -r '.base.repo.full_name // ""' <<<"$pr_json")"
|
||||
if [[ "$pr_state" != "open" || "$pr_base_repo" != "$GITHUB_REPOSITORY" ]]; then
|
||||
echo "release_gate pr_number must identify an open pull request in ${GITHUB_REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$pr_head_sha" != "$TARGET_REF" ]]; then
|
||||
echo "release_gate target_ref must match pull request ${PR_NUMBER} head ${pr_head_sha}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$pr_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve pull request ${PR_NUMBER} base SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
mergeable="$(jq -r 'if .mergeable == null then "pending" else (.mergeable | tostring) end' <<<"$pr_json")"
|
||||
if [[ "$mergeable" == "true" ]]; then
|
||||
merge_sha="$(jq -r '.merge_commit_sha // ""' <<<"$pr_json")"
|
||||
if [[ ! "$merge_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve pull request ${PR_NUMBER} test merge commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
break
|
||||
fi
|
||||
if [[ "$mergeable" == "false" ]]; then
|
||||
echo "Pull request ${PR_NUMBER} is not mergeable with its current base." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$attempt" == "12" ]]; then
|
||||
echo "Timed out waiting for pull request ${PR_NUMBER} mergeability." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
merge_ref="refs/remotes/origin/release-gate-merge"
|
||||
git fetch --no-tags --depth=2 origin \
|
||||
"+refs/pull/${PR_NUMBER}/merge:${merge_ref}"
|
||||
resolved_merge_sha="$(git rev-parse "$merge_ref")"
|
||||
merge_base_parent="$(git rev-parse "${merge_ref}^1")"
|
||||
merge_head_parent="$(git rev-parse "${merge_ref}^2")"
|
||||
if [[ "$resolved_merge_sha" != "$merge_sha" || "$merge_base_parent" != "$pr_base_sha" || "$merge_head_parent" != "$TARGET_REF" ]]; then
|
||||
echo "release_gate pull request merge ref does not match the current base and head" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "base_sha=${pr_base_sha}" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=${merge_sha}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate historical release target
|
||||
id: historical_target
|
||||
if: inputs.historical_target_tag != ''
|
||||
@@ -406,7 +330,6 @@ jobs:
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}
|
||||
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
|
||||
OPENCLAW_CI_RUN_TS_LOC: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ts_loc || 'false' }}
|
||||
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}
|
||||
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||||
OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }}
|
||||
@@ -553,7 +476,6 @@ jobs:
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) &&
|
||||
!docsOnly &&
|
||||
(!frozenTarget || supportsNativeI18n);
|
||||
const runTsLoc = parseBoolean(process.env.OPENCLAW_CI_RUN_TS_LOC) && !docsOnly;
|
||||
const targetWorkflow = existsSync(".github/workflows/ci.yml")
|
||||
? readFileSync(".github/workflows/ci.yml", "utf8")
|
||||
: "";
|
||||
@@ -562,8 +484,15 @@ jobs:
|
||||
const supportsFormatCheck =
|
||||
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
|
||||
const runFormatCheck = !frozenTarget || supportsFormatCheck;
|
||||
const checksFastCoreTasks = runTsLoc
|
||||
? [{ check_name: "checks-fast-loc-ratchet", runtime: "node", task: "loc-ratchet" }]
|
||||
const checksFastCoreTasks =
|
||||
runNode && !frozenTarget
|
||||
? [
|
||||
{
|
||||
check_name: "checks-fast-max-lines-ratchet",
|
||||
runtime: "node",
|
||||
task: "max-lines-ratchet",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
@@ -1235,6 +1164,7 @@ jobs:
|
||||
checks-fast-core:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
@@ -1246,26 +1176,56 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
||||
steps:
|
||||
- *linux_node_checkout_step
|
||||
- name: Checkout verified release-gate LOC merge tree
|
||||
if: matrix.task == 'loc-ratchet' && needs.preflight.outputs.loc_head_sha != ''
|
||||
- name: Prepare release-gate max-lines merge tree
|
||||
if: matrix.task == 'max-lines-ratchet' && github.event_name == 'workflow_dispatch' && inputs.release_gate
|
||||
env:
|
||||
LOC_HEAD_SHA: ${{ needs.preflight.outputs.loc_head_sha }}
|
||||
LOC_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||||
TARGET_SHA: ${{ inputs.target_ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$LOC_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "A release-gate LOC head requires a pull request number." >&2
|
||||
prepared=false
|
||||
for attempt in {1..6}; do
|
||||
pr_info="$(
|
||||
gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}" |
|
||||
jq -r --arg repo "$GITHUB_REPOSITORY" --arg target "$TARGET_SHA" '
|
||||
select(.state == "open" and .head.sha == $target and .base.repo.full_name == $repo)
|
||||
| [.base.sha, (if .mergeable == null then "unknown" else (.mergeable | tostring) end)]
|
||||
| @tsv
|
||||
'
|
||||
)"
|
||||
if [[ -z "$pr_info" ]]; then
|
||||
echo "release-gate pull request must be open and match the target head" >&2
|
||||
exit 1
|
||||
fi
|
||||
IFS=$'\t' read -r base_sha mergeable <<<"$pr_info"
|
||||
if [[ "$mergeable" == "false" ]]; then
|
||||
echo "release-gate pull request is not mergeable" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$mergeable" == "true" ]] &&
|
||||
git fetch --no-tags --depth=2 origin \
|
||||
"+refs/pull/${PULL_REQUEST_NUMBER}/merge:refs/remotes/origin/ci-max-lines-merge"; then
|
||||
merge_sha="$(git rev-parse refs/remotes/origin/ci-max-lines-merge)"
|
||||
read -r merge_base merge_head extra_parent <<<"$(git show -s --format=%P "$merge_sha")"
|
||||
if [[ "$merge_base" == "$base_sha" && "$merge_head" == "$TARGET_SHA" && -z "$extra_parent" ]]; then
|
||||
prepared=true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
if [[ "$attempt" != "6" ]]; then
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
if [[ "$prepared" != "true" ]]; then
|
||||
echo "release-gate merge tree did not refresh to the current pull request base and head" >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags --depth=2 origin \
|
||||
"+refs/pull/${LOC_PR_NUMBER}/merge:refs/remotes/origin/ci-head"
|
||||
resolved_loc_head="$(git rev-parse refs/remotes/origin/ci-head)"
|
||||
if [[ "$resolved_loc_head" != "$LOC_HEAD_SHA" ]]; then
|
||||
echo "Pull request ${LOC_PR_NUMBER} merge ref moved before the LOC check." >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach refs/remotes/origin/ci-head
|
||||
git checkout --detach "$merge_sha"
|
||||
echo "RATCHET_RELEASE_BASE_SHA=${base_sha}" >> "$GITHUB_ENV"
|
||||
echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
@@ -1273,10 +1233,12 @@ jobs:
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
env:
|
||||
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
||||
LOC_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'push' && github.event.before || needs.preflight.outputs.loc_base_sha || '' }}
|
||||
LOC_EXPECTED_PR_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || needs.preflight.outputs.loc_head_sha != '' && inputs.target_ref || '' }}
|
||||
GH_TOKEN: ${{ matrix.task == 'max-lines-ratchet' && github.token || '' }}
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: 3
|
||||
RATCHET_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
RATCHET_EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
|
||||
RATCHET_MANUAL_TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.checkout_revision || '' }}
|
||||
RATCHET_PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
|
||||
TASK: ${{ matrix.task }}
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -1299,33 +1261,61 @@ jobs:
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
loc-ratchet)
|
||||
# Frozen compatibility targets were governed by their original CI. Comparing
|
||||
# them with moving main would turn unrelated branch drift into release failures.
|
||||
if [[ "$HISTORICAL_TARGET" != "true" ]] && [ -n "$LOC_BASE_SHA" ] && [[ ! "$LOC_BASE_SHA" =~ ^0+$ ]]; then
|
||||
loc_base_ref="refs/remotes/origin/ci-base"
|
||||
if [ -n "$LOC_EXPECTED_PR_HEAD" ]; then
|
||||
# Pull-request merge refs can advance past the event base as main moves.
|
||||
# Compare with the base parent of the exact merge tree under test.
|
||||
if ! git rev-parse --verify HEAD^1 >/dev/null 2>&1 || ! git rev-parse --verify HEAD^2 >/dev/null 2>&1; then
|
||||
loc_merge_sha="$(git rev-parse HEAD)"
|
||||
git fetch --no-tags --depth=2 origin "+${loc_merge_sha}:refs/remotes/origin/ci-loc-merge"
|
||||
fi
|
||||
merge_head="$(git rev-parse HEAD^2)"
|
||||
if [[ "$merge_head" != "$LOC_EXPECTED_PR_HEAD" ]]; then
|
||||
echo "LOC merge tree head ${merge_head} does not match expected pull-request head ${LOC_EXPECTED_PR_HEAD}." >&2
|
||||
exit 1
|
||||
fi
|
||||
loc_base_ref="$(git rev-parse HEAD^1)"
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin "+${LOC_BASE_SHA}:${loc_base_ref}"
|
||||
fi
|
||||
if has_package_script "check:loc"; then
|
||||
pnpm check:loc --base "$loc_base_ref" --head HEAD
|
||||
else
|
||||
echo "Current CI targets must provide the check:loc package script." >&2
|
||||
max-lines-ratchet)
|
||||
if ! has_package_script "check:max-lines-ratchet"; then
|
||||
echo "Current CI targets must provide check:max-lines-ratchet." >&2
|
||||
exit 1
|
||||
fi
|
||||
base_sha="${RATCHET_EVENT_BASE_SHA:-${RATCHET_RELEASE_BASE_SHA:-}}"
|
||||
if [[ "$base_sha" == "0000000000000000000000000000000000000000" ]]; then
|
||||
base_sha=""
|
||||
fi
|
||||
base_ref=""
|
||||
if [[ -n "${RATCHET_PR_HEAD_SHA:-}" ]]; then
|
||||
mapfile -t merge_parents < <(git cat-file -p HEAD | sed -n 's/^parent //p')
|
||||
if [[ "${#merge_parents[@]}" != "2" || "${merge_parents[1]:-}" != "$RATCHET_PR_HEAD_SHA" ]]; then
|
||||
echo "Pull request checkout is not the expected two-parent merge tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
merge_base="${merge_parents[0]}"
|
||||
git fetch --no-tags --depth=1 origin \
|
||||
"+${merge_base}:refs/remotes/origin/ci-max-lines-base"
|
||||
base_ref="refs/remotes/origin/ci-max-lines-base"
|
||||
elif [[ -n "$base_sha" ]]; then
|
||||
git fetch --no-tags --depth=1 origin \
|
||||
"+${base_sha}:refs/remotes/origin/ci-max-lines-base"
|
||||
base_ref="refs/remotes/origin/ci-max-lines-base"
|
||||
elif [[ -n "${RATCHET_MANUAL_TARGET_SHA:-}" ]]; then
|
||||
default_branch="${RATCHET_DEFAULT_BRANCH:-main}"
|
||||
default_sha="$(
|
||||
git ls-remote origin "refs/heads/${default_branch}" | awk 'NR == 1 { print $1 }'
|
||||
)"
|
||||
if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve the default branch head for the max-lines ratchet." >&2
|
||||
exit 1
|
||||
fi
|
||||
merge_base_sha="$(
|
||||
gh api --method GET \
|
||||
"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${RATCHET_MANUAL_TARGET_SHA}" \
|
||||
--jq '.merge_base_commit.sha'
|
||||
)"
|
||||
if [[ ! "$merge_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve the manual target merge base for the max-lines ratchet." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch --no-tags --depth=1 origin \
|
||||
"+${merge_base_sha}:refs/remotes/origin/ci-max-lines-base"
|
||||
base_ref="refs/remotes/origin/ci-max-lines-base"
|
||||
else
|
||||
default_branch="${RATCHET_DEFAULT_BRANCH:-main}"
|
||||
git fetch --no-tags --depth=1 origin \
|
||||
"+refs/heads/${default_branch}:refs/remotes/origin/ci-max-lines-base"
|
||||
base_ref="refs/remotes/origin/ci-max-lines-base"
|
||||
fi
|
||||
unset GH_TOKEN
|
||||
pnpm check:max-lines-ratchet --base "$base_ref"
|
||||
if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then
|
||||
node scripts/run-oxlint.mjs src ui/src packages extensions
|
||||
fi
|
||||
;;
|
||||
bun-launcher)
|
||||
@@ -2777,6 +2767,97 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
ci-gate:
|
||||
permissions:
|
||||
contents: read
|
||||
name: openclaw/ci-gate
|
||||
needs:
|
||||
- runner-admission
|
||||
- preflight
|
||||
- security-fast
|
||||
- pnpm-store-warmup
|
||||
- build-artifacts
|
||||
- native-i18n
|
||||
- checks-ui
|
||||
- control-ui-i18n
|
||||
- checks-fast-core
|
||||
- qa-smoke-ci-profile
|
||||
- checks-fast-plugin-contracts-shard
|
||||
- checks-fast-channel-contracts-shard
|
||||
- checks-node-compat
|
||||
- checks-node-core-test-nondist-shard
|
||||
- check-shard
|
||||
- check-additional-shard
|
||||
- check-docs
|
||||
- skills-python
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- ios-build
|
||||
- android
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify selected CI lanes
|
||||
shell: bash
|
||||
env:
|
||||
REQUIRED_RESULTS: |
|
||||
runner-admission=${{ needs.runner-admission.result }}
|
||||
preflight=${{ needs.preflight.result }}
|
||||
security-fast=${{ needs.security-fast.result }}
|
||||
SELECTED_RESULTS: |
|
||||
pnpm-store-warmup=${{ needs.pnpm-store-warmup.result }}
|
||||
build-artifacts=${{ needs.build-artifacts.result }}
|
||||
native-i18n=${{ needs.native-i18n.result }}
|
||||
checks-ui=${{ needs.checks-ui.result }}
|
||||
control-ui-i18n=${{ needs.control-ui-i18n.result }}
|
||||
checks-fast-core=${{ needs.checks-fast-core.result }}
|
||||
qa-smoke-ci-profile=${{ needs.qa-smoke-ci-profile.result }}
|
||||
checks-fast-plugin-contracts-shard=${{ needs.checks-fast-plugin-contracts-shard.result }}
|
||||
checks-fast-channel-contracts-shard=${{ needs.checks-fast-channel-contracts-shard.result }}
|
||||
checks-node-compat=${{ needs.checks-node-compat.result }}
|
||||
checks-node-core-test-nondist-shard=${{ needs.checks-node-core-test-nondist-shard.result }}
|
||||
check-shard=${{ needs.check-shard.result }}
|
||||
check-additional-shard=${{ needs.check-additional-shard.result }}
|
||||
check-docs=${{ needs.check-docs.result }}
|
||||
skills-python=${{ needs.skills-python.result }}
|
||||
checks-windows=${{ needs.checks-windows.result }}
|
||||
macos-node=${{ needs.macos-node.result }}
|
||||
macos-swift=${{ needs.macos-swift.result }}
|
||||
ios-build=${{ needs.ios-build.result }}
|
||||
android=${{ needs.android.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
failures=0
|
||||
while IFS= read -r entry; do
|
||||
[[ -n "$entry" ]] || continue
|
||||
name="${entry%%=*}"
|
||||
result="${entry#*=}"
|
||||
echo "${name}: ${result}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "::error title=Required CI job did not succeed::${name} finished with ${result}"
|
||||
failures=1
|
||||
fi
|
||||
done <<< "$REQUIRED_RESULTS"
|
||||
|
||||
while IFS= read -r entry; do
|
||||
[[ -n "$entry" ]] || continue
|
||||
name="${entry%%=*}"
|
||||
result="${entry#*=}"
|
||||
echo "${name}: ${result}"
|
||||
case "$result" in
|
||||
success | skipped) ;;
|
||||
*)
|
||||
echo "::error title=Selected CI job did not succeed::${name} finished with ${result}"
|
||||
failures=1
|
||||
;;
|
||||
esac
|
||||
done <<< "$SELECTED_RESULTS"
|
||||
|
||||
exit "$failures"
|
||||
|
||||
ci-timings-summary:
|
||||
permissions:
|
||||
actions: read
|
||||
|
||||
@@ -11,6 +11,9 @@ on:
|
||||
- ui/src/i18n/lib/types.ts
|
||||
- ui/src/i18n/lib/registry.ts
|
||||
- scripts/control-ui-i18n.ts
|
||||
- scripts/control-ui-i18n-verify.ts
|
||||
- scripts/lib/control-ui-i18n-config.ts
|
||||
- scripts/lib/control-ui-i18n-raw-copy.ts
|
||||
- .github/actions/create-generated-pr-tokens/action.yml
|
||||
- .github/actions/publish-generated-pr/action.yml
|
||||
- .github/workflows/control-ui-locale-refresh.yml
|
||||
@@ -227,7 +230,7 @@ jobs:
|
||||
artifact_dir="${RUNNER_TEMP}/control-ui-locale-${LOCALE}"
|
||||
mkdir -p "${artifact_dir}"
|
||||
git add -A ui/src/i18n
|
||||
git diff --cached --binary --full-index -- ui/src/i18n > "${artifact_dir}/${LOCALE}.patch"
|
||||
git diff --cached --binary --full-index -- ui/src/i18n ':(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json' > "${artifact_dir}/${LOCALE}.patch"
|
||||
|
||||
- name: Upload locale artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
@@ -301,6 +304,9 @@ jobs:
|
||||
ui/src/i18n/lib/types.ts
|
||||
ui/src/i18n/lib/registry.ts
|
||||
scripts/control-ui-i18n.ts
|
||||
scripts/control-ui-i18n-verify.ts
|
||||
scripts/lib/control-ui-i18n-config.ts
|
||||
scripts/lib/control-ui-i18n-raw-copy.ts
|
||||
.github/actions/create-generated-pr-tokens/action.yml
|
||||
.github/actions/publish-generated-pr/action.yml
|
||||
.github/workflows/control-ui-locale-refresh.yml
|
||||
|
||||
@@ -343,6 +343,13 @@ jobs:
|
||||
AI_RUNTIME_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t AI_TARBALLS < <(find "$AI_RUNTIME_TARBALL_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | sort)
|
||||
if [[ "${#AI_TARBALLS[@]}" -ne 1 ]]; then
|
||||
echo "Expected exactly one prepared @openclaw/ai tarball, found ${#AI_TARBALLS[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
AI_TARBALL_PATH="${AI_TARBALLS[0]}"
|
||||
node --import tsx scripts/prepare-openclaw-npm-shrinkwrap.ts "$AI_TARBALL_PATH"
|
||||
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
|
||||
pnpm pack --json 2>&1 | tee "$PACK_OUTPUT"
|
||||
PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE'
|
||||
|
||||
@@ -50,7 +50,7 @@ on:
|
||||
kova_ref:
|
||||
description: openclaw/Kova Git ref to install
|
||||
required: false
|
||||
default: 678ff0b764b8786c2e436efbe4efac7d9aac10f8
|
||||
default: 8fc3ad27ae684f52eae758a913a0009f0d6dca22
|
||||
type: string
|
||||
dispatch_id:
|
||||
description: Optional parent workflow dispatch identifier
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
include_filters: "scenario:agent-cold-warm-message"
|
||||
expected_release_entries: "agent-cold-warm-message:mock-openai-provider"
|
||||
env:
|
||||
KOVA_REF: ${{ inputs.kova_ref || '678ff0b764b8786c2e436efbe4efac7d9aac10f8' }}
|
||||
KOVA_REF: ${{ inputs.kova_ref || '8fc3ad27ae684f52eae758a913a0009f0d6dca22' }}
|
||||
KOVA_HOME: ${{ github.workspace }}/.artifacts/kova/home/${{ matrix.lane }}
|
||||
PERFORMANCE_HELPER_DIR: ${{ github.workspace }}/.artifacts/performance-workflow
|
||||
REPORT_DIR: ${{ github.workspace }}/.artifacts/kova/reports/${{ matrix.lane }}
|
||||
|
||||
@@ -380,12 +380,13 @@ jobs:
|
||||
<<<"$signature_json"
|
||||
)"
|
||||
fi
|
||||
role_name="$(
|
||||
permission_json="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/collaborators/${permission_actor}/permission" \
|
||||
--jq '.role_name'
|
||||
"repos/${GITHUB_REPOSITORY}/collaborators/${permission_actor}/permission"
|
||||
)"
|
||||
if [[ "$role_name" != "admin" && "$role_name" != "maintain" ]]; then
|
||||
permission="$(jq -r '.permission // ""' <<<"$permission_json")"
|
||||
role_name="$(jq -r '.role_name // ""' <<<"$permission_json")"
|
||||
if [[ "$permission" != "admin" && "$role_name" != "maintain" ]]; then
|
||||
echo "Release candidate actor ${permission_actor} lacks maintain/admin access." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1073,12 +1074,13 @@ jobs:
|
||||
<<<"$signature_json"
|
||||
)"
|
||||
fi
|
||||
role_name="$(
|
||||
permission_json="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/collaborators/${permission_actor}/permission" \
|
||||
--jq '.role_name'
|
||||
"repos/${GITHUB_REPOSITORY}/collaborators/${permission_actor}/permission"
|
||||
)"
|
||||
[[ "$role_name" == "admin" || "$role_name" == "maintain" ]]
|
||||
permission="$(jq -r '.permission // ""' <<<"$permission_json")"
|
||||
role_name="$(jq -r '.role_name // ""' <<<"$permission_json")"
|
||||
[[ "$permission" == "admin" || "$role_name" == "maintain" ]]
|
||||
fi
|
||||
|
||||
- name: Create isolated Telegram SUT identity and launcher
|
||||
|
||||
@@ -271,6 +271,96 @@
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/**/*.{js,ts,mts,cts}",
|
||||
"ui/src/**/*.{js,ts,mts,cts}",
|
||||
"packages/**/*.{js,ts,mts,cts}",
|
||||
"extensions/**/*.{js,ts,mts,cts}"
|
||||
],
|
||||
"excludeFiles": [
|
||||
"**/*.test.*",
|
||||
"**/*.spec.*",
|
||||
"**/__generated__/**",
|
||||
"**/generated/**",
|
||||
"**/protocol-gen/**",
|
||||
"**/*.generated.*",
|
||||
"**/dist/**",
|
||||
"ui/src/i18n/locales/**",
|
||||
"src/wizard/i18n/locales/**"
|
||||
],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 700, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/**/*.{jsx,tsx}",
|
||||
"ui/src/**/*.{jsx,tsx}",
|
||||
"packages/**/*.{jsx,tsx}",
|
||||
"extensions/**/*.{jsx,tsx}"
|
||||
],
|
||||
"excludeFiles": [
|
||||
"**/*.test.*",
|
||||
"**/*.spec.*",
|
||||
"**/__generated__/**",
|
||||
"**/generated/**",
|
||||
"**/protocol-gen/**",
|
||||
"**/*.generated.*",
|
||||
"**/dist/**",
|
||||
"ui/src/i18n/locales/**",
|
||||
"src/wizard/i18n/locales/**"
|
||||
],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 700, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/**/*.{mjs,cjs}",
|
||||
"ui/src/**/*.{mjs,cjs}",
|
||||
"packages/**/*.{mjs,cjs}",
|
||||
"extensions/**/*.{mjs,cjs}"
|
||||
],
|
||||
"excludeFiles": [
|
||||
"**/*.test.*",
|
||||
"**/*.spec.*",
|
||||
"**/__generated__/**",
|
||||
"**/generated/**",
|
||||
"**/protocol-gen/**",
|
||||
"**/*.generated.*",
|
||||
"**/dist/**",
|
||||
"ui/src/i18n/locales/**",
|
||||
"src/wizard/i18n/locales/**"
|
||||
],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 800, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/**/*.test.*",
|
||||
"src/**/*.spec.*",
|
||||
"ui/src/**/*.test.*",
|
||||
"ui/src/**/*.spec.*",
|
||||
"packages/**/*.test.*",
|
||||
"packages/**/*.spec.*",
|
||||
"extensions/**/*.test.*",
|
||||
"extensions/**/*.spec.*"
|
||||
],
|
||||
"excludeFiles": [
|
||||
"**/__generated__/**",
|
||||
"**/generated/**",
|
||||
"**/protocol-gen/**",
|
||||
"**/*.generated.*",
|
||||
"**/dist/**",
|
||||
"ui/src/i18n/locales/**",
|
||||
"src/wizard/i18n/locales/**"
|
||||
],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 1000, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched). Agent dependency installation for tests/builds defaults to the selected remote box; do not reconcile a local Codex worktree just to run validation.
|
||||
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
|
||||
- Agent tests default remote through Crabbox, including focused tests. Trusted maintainer code defaults to Blacksmith Testbox. Contributor/fork code remains untrusted unless a maintainer explicitly approves credentialed execution after review; an explicit owner/maintainer instruction to land named, reviewed PRs is that approval, so do not ask twice. Otherwise use secretless fork CI or sanitized direct AWS Crabbox, never a credential-hydrated Testbox. Sanitized AWS must launch an installed trusted Crabbox binary from a clean trusted `main` checkout and fetch only the remote PR via `--fresh-pr`; never execute a wrapper, config, or command from the untrusted local checkout. Before warmup, unset `CRABBOX_AWS_INSTANCE_PROFILE` and all `CRABBOX_TAILSCALE*` overrides; fail closed unless resolved `aws.instanceProfile` is empty. Force `--network public --tailscale=false`, clear exit-node/LAN flags, and require `crabbox inspect` to report public networking with no Tailscale state before any script. Upload trusted `scripts/crabbox-untrusted-bootstrap.sh` from clean `main` alongside `--fresh-pr`; it proves the remote IMDSv2 IAM credentials endpoint returns 404, verifies the reviewed head SHA, unsets `NODE_OPTIONS`, installs pinned Node/pnpm, verifies the package-manager pin, isolates `HOME`, installs dependencies, then runs the requested test. Use a newly warmed lease bound to one reviewed head SHA, set `CRABBOX_ENV_ALLOW=CI`, and use `--no-hydrate`. Never reuse a trusted/previously hydrated lease or carry an untrusted lease across head revisions; stop and rewarm when the SHA changes. No repo `OPENCLAW_*` allowlist, existing auth profile, instance role, tailnet/LAN access, moving PR head, or ambient Node preload may reach untrusted execution. When a code task is likely to need tests, classify source trust, pre-warm the safe backend immediately, keep working while it hydrates, reuse trusted leases or same-SHA untrusted leases, then stop before handoff.
|
||||
- Test commands (run inside the selected remote box by default): `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- Agent proof routing: source trust first, proof size second. Only trusted source may run locally; then run one/few focused tests and cheap static checks locally when the existing checkout dependencies are ready. Use Crabbox only for larger suites, changed gates with typecheck/lint fan-out, builds, Docker, packaging, E2E, live proof, and cross-platform work. Trusted maintainer heavy proof defaults to Blacksmith Testbox. Contributor/fork code remains untrusted unless a maintainer explicitly approves credentialed execution after review; an explicit owner/maintainer instruction to land named, reviewed PRs is that approval, so do not ask twice. Otherwise use secretless fork CI or sanitized direct AWS Crabbox, never a credential-hydrated Testbox. Never run an untrusted checkout's scripts, tests, checks, wrappers, config, or package hooks locally, regardless of proof size. Sanitized AWS must launch an installed trusted Crabbox binary from a clean trusted `main` checkout and fetch only the remote PR via `--fresh-pr`; never execute a wrapper, config, or command from the untrusted local checkout. Before warmup, unset `CRABBOX_AWS_INSTANCE_PROFILE` and all `CRABBOX_TAILSCALE*` overrides; fail closed unless resolved `aws.instanceProfile` is empty. Force `--network public --tailscale=false`, clear exit-node/LAN flags, and require `crabbox inspect` to report public networking with no Tailscale state before any script. Upload trusted `scripts/crabbox-untrusted-bootstrap.sh` from clean `main` alongside `--fresh-pr`; it proves the remote IMDSv2 IAM credentials endpoint returns 404, verifies the reviewed head SHA, unsets `NODE_OPTIONS`, installs pinned Node/pnpm, verifies the package-manager pin, isolates `HOME`, installs dependencies, then runs the requested test. Use a newly warmed lease bound to one reviewed head SHA, set `CRABBOX_ENV_ALLOW=CI`, and use `--no-hydrate`. Never reuse a trusted/previously hydrated lease or carry an untrusted lease across head revisions; stop and rewarm when the SHA changes. No repo `OPENCLAW_*` allowlist, existing auth profile, instance role, tailnet/LAN access, moving PR head, or ambient Node preload may reach untrusted execution. Do not pre-warm at task start. Acquire the safe backend lazily for the first heavy proof, reuse that one lease, then stop it before handoff.
|
||||
- Test commands: trusted-source focused local proof uses `node scripts/run-vitest.mjs <path-or-filter>`; remote or normal-checkout proof may use `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, or `pnpm test:coverage`. Never raw `vitest`.
|
||||
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
|
||||
- Vitest repetition: no `--repeat`; use a bounded shell loop around the focused repo test command.
|
||||
- Local agent test execution is opt-in: only when the user explicitly requests local proof or Testbox is unavailable and the fallback is reported. In a Codex worktree or linked/sparse checkout, the narrow local fallback is `node scripts/run-vitest.mjs <path-or-filter>`; never direct local `pnpm test*`.
|
||||
- Checks/lint in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged/path-scoped: `pnpm check:changed --staged` or `pnpm check:changed -- <files...>`; full `pnpm check`/`pnpm lint` only when required.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside the selected remote box, not locally.
|
||||
- Local agent test execution is allowed only for trusted source and one/few focused files when the existing dependency install is ready. In a Codex worktree or linked/sparse checkout, use `node scripts/run-vitest.mjs <path-or-filter>`; never direct local `pnpm test*`, and never reconcile dependencies merely to keep proof local.
|
||||
- Checks/lint in a trusted normal source checkout: `pnpm check:changed` classifies first; docs-only, no-change, and small metadata plans stay local when dependencies are ready, while typecheck/lint fan-out delegates to Crabbox/Testbox. Never run this repository-controlled classifier locally for untrusted source. Inspect lanes with `pnpm changed:lanes --json`; staged/path-scoped forms are `pnpm check:changed --staged` and `pnpm check:changed -- <files...>`.
|
||||
- Checks in a trusted Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/check-changed.mjs [--staged|-- <files...>]`. It can classify without installed dependencies and delegates heavy or dependency-missing proof before loading package-backed helpers. For untrusted source, do not execute this repository-controlled wrapper locally.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `scripts/run-oxlint.mjs`; full `pnpm lint:*` only when scope requires).
|
||||
@@ -133,7 +133,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## Validation
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- At task start, if code changes, tests, builds, typechecks, lint fan-out, Docker, packaging, E2E, or live proof are likely, classify source trust and immediately pre-warm the safe Crabbox backend in a background command session. Trusted maintainer code defaults to Blacksmith Testbox; untrusted contributor/fork code uses secretless fork CI or sanitized direct AWS Crabbox under the rule above. Continue inspection/editing while it hydrates; sync the current checkout for every run, reuse the lease, then stop it before handoff.
|
||||
- Classify source trust before proof size. Do not pre-warm for anticipated work. Run focused proof locally only for trusted source; untrusted source uses secretless fork CI or sanitized direct AWS regardless of size. Lazily acquire the appropriate backend at the first remote command. Trusted maintainer heavy proof defaults to Blacksmith Testbox. Reuse one acquired lease for later heavy commands, sync the current checkout for every run, then stop it before handoff.
|
||||
- Warm Testbox from the task checkout; ownership is checkout-path scoped; `--reclaim` only for intentional transfer.
|
||||
- One Testbox lease, one active command; never sync/reclaim during a run.
|
||||
- Testbox `--reclaim` does not retarget the remote checkout; never cross repos.
|
||||
@@ -144,8 +144,10 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- PR review artifacts: keep template enum values; put evidence detail in summaries.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
|
||||
- Local agent work is limited to lightweight non-test checks such as `git diff --check`, targeted formatting, and cheap static probes. Tests and computationally intensive work default to the selected remote box.
|
||||
- Trusted-source local agent work includes one/few focused tests, `git diff --check`, targeted formatting, and cheap static probes when dependencies already exist. Untrusted source executes none of its repository-controlled tooling locally. Computationally intensive work uses the selected remote box.
|
||||
- In Codex or linked worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Direct Blacksmith lease: use `blacksmith testbox run`; Crabbox wrapper reuse needs a wrapper-created lease.
|
||||
- Dirty-sync generator proof: compare hashes before/after; `git diff` includes the synced patch.
|
||||
- Crabbox wrapper `stop` has no `--timing-json`; use `node scripts/crabbox-wrapper.mjs stop --provider <provider> --id <id>`.
|
||||
- Repo-native PR worktree may omit `node_modules`; prove remotely, then use `git commit --no-verify`, not `scripts/committer`.
|
||||
- Release-branch formatting: Testbox or existing binary; never local `pnpm exec` reconciliation.
|
||||
@@ -153,7 +155,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Testbox status: `blacksmith testbox status --id <tbx_id>`; no `--json` flag.
|
||||
- QA CLI `--output-dir` must be repo-relative.
|
||||
- Full suites, changed gates, builds, typechecks, lint fan-out, Docker/package/E2E/live/cross-OS proof, or anything computationally intensive: Crabbox/Testbox.
|
||||
- If an allowed local fallback fans out or becomes expensive, stop it and move the work to the pre-warmed remote box.
|
||||
- Testbox owns Chromium; never pass Crabbox `--browser` to `provider=blacksmith-testbox`.
|
||||
- Testbox warmup must print a lease id; silent success is unusable. Verify before reuse; fall back to one-shot `run`.
|
||||
- If local proof fans out or becomes expensive, stop it and lazily acquire the remote box.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: proof matches actual risk. Bounded behavior-neutral refactor: focused tests/checks enough; no issue proof or full/broad suite by default.
|
||||
- Release-branch full validation: freeze the product-complete **Code SHA**, then use `node scripts/full-release-validation-at-sha.mjs --sha <code-sha> --target-ref release/YYYY.M.PATCH`; no raw dispatch without `target_context_ref`.
|
||||
- Pre-land/pre-commit code changes: mandatory fresh `$autoreview` until no accepted/actionable findings remain. Do not land code on CI, ClawSweeper, prior review comments, or your own manual review alone unless user explicitly opts out or scope is truly trivial/docs-only. If findings want refactor, refactor; no ugly fixes.
|
||||
@@ -177,6 +181,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Sparse-sync temp checkout may claim kept Testbox; repo-path reuse needs `--reclaim`.
|
||||
- GitHub Actions: resolve workflow files from `.github/workflows` or API; never infer filenames from display names.
|
||||
- zsh: quote command globs; unmatched patterns abort before the tool runs.
|
||||
- Nested remote shell: avoid local `$()` expansion; use remote-safe validation.
|
||||
- zsh: don't use `path` as a variable; it rewrites `$PATH`.
|
||||
- `scripts/pr` artifacts: preserve template enum values; validate before prepare.
|
||||
- `scripts/pr` subcommands require a PR number; no subcommand `--help` placeholder.
|
||||
@@ -260,6 +265,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
- Split files around ~700 LOC when clarity/testability improves.
|
||||
- Never add a `max-lines` suppression. Existing suppressions are grandfathered TODOs; split the file and remove its suppression plus baseline entry.
|
||||
- Naming: **OpenClaw** product/docs; `openclaw` CLI/package/path/config.
|
||||
- English: American spelling.
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **TUI fuzzy selectors:** delegate list matching to pi-tui, adding slash-token and alpha-number matching while removing the local matcher fork.
|
||||
- **macOS paired-node terminals:** advertise duplex Codex and Claude terminal resume commands from the embedded node host and forward interactive input and cancellation through the native app bridge. (#107335)
|
||||
- **Control UI catalog terminals:** open eligible Codex and Claude Code sessions in the native CLI on their Gateway or paired-node host, with viewer-versus-terminal preferences, validated resume commands, and an interactive PTY relay. (#107086)
|
||||
- **Skill Workshop history review:** add a manual, newest-first session scan that progressively searches older substantial work for conservative skill ideas, stores only SQLite cursor metadata, and leaves up to three results as pending proposals even when autonomous self-learning is disabled. (#106182)
|
||||
- **SQLite snapshots:** add `openclaw backup sqlite create|list|verify|restore` for compact, verified global and per-agent database artifacts with fresh-target-only restore. (#94805) Thanks @giodl73-repo.
|
||||
- **GPT-5.6 Ultra and runtime switching:** support Sol, Terra, and Luna across OpenClaw and Codex engines; keep model, runtime, and thinking selection atomic through `/model` and fallback; and add live matrix coverage for both harnesses. (#98021) Thanks @anyech.
|
||||
@@ -34,6 +37,13 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Agent git package identities:** strip refs before hosted-repository parsing and reject traversal segments so GitLab branch refs resolve to the canonical managed install path.
|
||||
- **Tlon custom S3 uploads:** pass storage endpoints through the AWS SDK's native parser so custom S3-compatible uploads no longer fail before presigning.
|
||||
- **Signal active-run controls:** keep authorized stop, status, approval, and queue-read controls responsive during active turns while preserving ordinary and stateful turns in canonical session admission, and cancel every pending group sender lane on stop. (#107422) Thanks @arduano.
|
||||
- **Agent auth storage locks:** surface normal release failures while avoiding redundant release attempts after `proper-lockfile` reports a compromised lock.
|
||||
- **Paired-node session catalogs:** authorize bundled Anthropic and Codex catalog requests to invoke their read-only node commands from Control UI read flows, restoring remote Claude/Codex rows and terminal resume availability. Fixes #107406.
|
||||
- **Sandbox recreate confirmation:** treat Clack cancellation as a decline so Ctrl-C cannot proceed with container removal.
|
||||
- **Microsoft Teams HTML text:** decode HTML5 entities consistently in quoted and Graph-fetched messages while preserving literal escaped entity text.
|
||||
- **ClawHub plugin API ranges:** delegate each supported comparator to `semver` so tilde, partial-wildcard, and prerelease caret bounds are correct while preserving OpenClaw version normalization and the existing restricted range grammar. (#106877)
|
||||
- **Web Readability relative links:** seed parsed documents with the request URL so article links resolve correctly while removing the plugin's duplicate lazy-loader facade. (#106860)
|
||||
- **Browser auto-routing:** fall back to the Gateway host when an implicitly selected browser node reports that its control host is unreachable, while preserving explicit node pins and ambiguous action failures.
|
||||
|
||||
+1
-1
@@ -1750,7 +1750,7 @@ Shipped baseline exclusions: v2026.6.11 (10 PRs: #87298, #89949, #90811, #92020,
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.7.1/OpenClaw-2026.7.1.zip" length="59038286" type="application/octet-stream" sparkle:edSignature="smUCPNQSjvwkBN8kKC0c4Oq8ygySakUmjD9m/1L3j06UQ7QXWBZKnVtCfFMfAUb8cDv5wiPPm2mUNioWfZQcBQ=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.7.1/OpenClaw-2026.7.1.zip" length="59038535" type="application/octet-stream" sparkle:edSignature="a7K+wq4Fgg44Fg/Mt1XseUcICLiPShPz023twbvVHSlus+gQZjkE33aV4pwRKAOA2VlzZYkdRkEOX+3sE8h2BA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.11</title>
|
||||
|
||||
@@ -28635,7 +28635,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 144,
|
||||
"line": 148,
|
||||
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
|
||||
"source": "Dashboard reconnecting",
|
||||
"surface": "apple",
|
||||
@@ -28643,7 +28643,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 145,
|
||||
"line": 149,
|
||||
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
|
||||
"source": "The selected Gateway changed.",
|
||||
"surface": "apple",
|
||||
@@ -28651,7 +28651,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 146,
|
||||
"line": 150,
|
||||
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
|
||||
"source": "Waiting for a fresh authenticated connection.",
|
||||
"surface": "apple",
|
||||
@@ -28659,7 +28659,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 229,
|
||||
"line": 251,
|
||||
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
|
||||
"source": "Dashboard unavailable",
|
||||
"surface": "apple",
|
||||
@@ -28667,7 +28667,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 231,
|
||||
"line": 253,
|
||||
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
|
||||
"source": "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.",
|
||||
"surface": "apple",
|
||||
@@ -28675,7 +28675,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 763,
|
||||
"line": 811,
|
||||
"path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift",
|
||||
"source": "[\\(host)]",
|
||||
"surface": "apple",
|
||||
@@ -30557,13 +30557,21 @@
|
||||
"kind": "ui-call",
|
||||
"line": 103,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "New Session",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.75102378a6775180"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 109,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "Settings...",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.185a1b27c9859cc0"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 110,
|
||||
"line": 116,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "Back",
|
||||
"surface": "apple",
|
||||
@@ -30571,15 +30579,23 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 115,
|
||||
"line": 121,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "Forward",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.c19b0989064c0020"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 128,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "Command Palette…",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.ef646bce7927b2f0"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 133,
|
||||
"line": 146,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
"surface": "apple",
|
||||
@@ -30587,7 +30603,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 134,
|
||||
"line": 147,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "OpenClaw",
|
||||
"surface": "apple",
|
||||
@@ -30595,7 +30611,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 334,
|
||||
"line": 347,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "Close Canvas",
|
||||
"surface": "apple",
|
||||
@@ -30603,7 +30619,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 334,
|
||||
"line": 347,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
|
||||
"source": "Open Canvas",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "حرج"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "جلسة جديدة"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "تقديم"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "لوحة الأوامر…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Kritisch"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Neue Sitzung"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Vorwärts"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Befehlspalette…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Crítico"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Nueva sesión"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Adelante"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Paleta de comandos…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "بحرانی"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "جلسه جدید"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "جلو"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "پالت فرمان…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Critique"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Nouvelle session"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Suivant"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Palette de commandes…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "गंभीर"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "नया सत्र"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "आगे"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "कमांड पैलेट…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Kritis"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Sesi Baru"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Maju"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Palet Perintah…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Critico"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Nuova sessione"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Avanti"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Palette dei comandi…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Critical"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "新規セッション"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "進む"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "コマンドパレット…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"locale": "ko",
|
||||
"glossaryHash": "47cf80de6cebb6ba1ac7a0bfd60e96de12a8611785bae9ca4f82c14912db9bd1",
|
||||
"glossaryHash": "31510f6398458d1da1b082b45b39b144b16b17a091cc64c02124e9563fd035be",
|
||||
"entries": [
|
||||
{
|
||||
"id": "native.android.85cc8de945e3929c",
|
||||
@@ -8511,7 +8511,7 @@
|
||||
{
|
||||
"id": "native.apple.4dcf36018847481d",
|
||||
"source": "Save or discard edits before running, pausing, or enabling this automation.",
|
||||
"translated": "이 자동화를 실행, 일시 중지 또는 활성화하기 전에 편집 내용을 저장하거나 취소하세요."
|
||||
"translated": "이 자동화를 실행, 일시 중지 또는 활성화하기 전에 편집 내용을 저장하거나 버리세요."
|
||||
},
|
||||
{
|
||||
"id": "native.apple.9fb34a87dc5dd407",
|
||||
@@ -9186,7 +9186,7 @@
|
||||
{
|
||||
"id": "native.apple.2cb500a4a64c9a05",
|
||||
"source": "No diary prose for this day.",
|
||||
"translated": "No diary prose for this day."
|
||||
"translated": "이 날의 일기 본문이 없습니다."
|
||||
},
|
||||
{
|
||||
"id": "native.apple.d6d76334ab8bbf86",
|
||||
@@ -12411,7 +12411,7 @@
|
||||
{
|
||||
"id": "native.apple.32a85f247d3bc0af",
|
||||
"source": "Alerts Off",
|
||||
"translated": "Alerts Off"
|
||||
"translated": "알림 꺼짐"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.906553130aa591f1",
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "심각"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "새 세션"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "앞으로"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "명령 팔레트…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Kritiek"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Nieuwe sessie"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Vooruit"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Opdrachtenpalet…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Krytyczny"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Nowa sesja"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Dalej"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Paleta poleceń…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Crítico"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Nova sessão"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Avançar"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Paleta de comandos…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Критическая"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Новый сеанс"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Вперёд"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Палитра команд…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Kritisk"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Ny session"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Framåt"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Kommandopalett…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "ร้ายแรง"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "เซสชันใหม่"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "ไปข้างหน้า"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "ชุดคำสั่ง…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Kritik"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Yeni Oturum"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "İleri"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Komut Paleti…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Критично"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Новий сеанс"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Вперед"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Палітра команд…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "Nghiêm trọng"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "Phiên mới"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "Tiến tới"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "Bảng lệnh…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "严重"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "新建会话"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "前进"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "命令面板…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -19098,6 +19098,11 @@
|
||||
"source": "Critical",
|
||||
"translated": "嚴重"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.75102378a6775180",
|
||||
"source": "New Session",
|
||||
"translated": "新增工作階段"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.185a1b27c9859cc0",
|
||||
"source": "Settings...",
|
||||
@@ -19113,6 +19118,11 @@
|
||||
"source": "Forward",
|
||||
"translated": "前進"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.ef646bce7927b2f0",
|
||||
"source": "Command Palette…",
|
||||
"translated": "命令選擇區…"
|
||||
},
|
||||
{
|
||||
"id": "native.apple.13a7356f48278757",
|
||||
"source": "OpenClaw - Voice Wake live meter active",
|
||||
|
||||
@@ -401,6 +401,7 @@ enum class GatewayEvent(
|
||||
NodePairResolved("node.pair.resolved"),
|
||||
NodePresence("node.presence"),
|
||||
NodeInvokeCancel("node.invoke.cancel"),
|
||||
NodeInvokeInput("node.invoke.input"),
|
||||
NodeInvokeRequest("node.invoke.request"),
|
||||
DevicePairRequested("device.pair.requested"),
|
||||
DevicePairResolved("device.pair.resolved"),
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
# AGP 9.2 emits deprecated project-dependency notation on Gradle 9.5+.
|
||||
# Gradle 9.5+ exposes deprecated project-dependency notation inside AGP 9.2.1.
|
||||
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
|
||||
@@ -11879,8 +11879,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Alerts Off"
|
||||
"state": "translated",
|
||||
"value": "알림 꺼짐"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -18951,8 +18951,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Attachment"
|
||||
"state": "translated",
|
||||
"value": "첨부 파일"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -26975,8 +26975,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Chat transcript"
|
||||
"state": "translated",
|
||||
"value": "채팅 전사본"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -40303,8 +40303,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Copied"
|
||||
"state": "translated",
|
||||
"value": "복사됨"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -75119,8 +75119,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Image"
|
||||
"state": "translated",
|
||||
"value": "이미지"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -78791,8 +78791,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Just received your iOS share + request, working on it."
|
||||
"state": "translated",
|
||||
"value": "iOS 공유와 요청을 방금 받았습니다. 처리 중입니다."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -89943,8 +89943,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Message…"
|
||||
"state": "translated",
|
||||
"value": "메시지…"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -96743,8 +96743,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "No diary prose for this day."
|
||||
"state": "translated",
|
||||
"value": "이 날의 일기 본문이 없습니다."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -102727,8 +102727,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Not loaded"
|
||||
"state": "translated",
|
||||
"value": "로드되지 않음"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -110479,8 +110479,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Out-of-app approval alerts need notification permission."
|
||||
"state": "translated",
|
||||
"value": "앱 외부 승인 알림에는 알림 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -118367,8 +118367,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Reachable"
|
||||
"state": "translated",
|
||||
"value": "연결 가능"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -130743,8 +130743,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Review command below"
|
||||
"state": "translated",
|
||||
"value": "아래 명령어 검토"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -131967,8 +131967,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Routed on this phone"
|
||||
"state": "translated",
|
||||
"value": "이 휴대폰에서 라우팅됨"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -140399,8 +140399,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "See attached."
|
||||
"state": "translated",
|
||||
"value": "첨부 파일을 참조하세요."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -142167,8 +142167,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Sending decision..."
|
||||
"state": "translated",
|
||||
"value": "결정 전송 중..."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -156311,8 +156311,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Talk Config"
|
||||
"state": "translated",
|
||||
"value": "Talk 구성"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -156583,8 +156583,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Talk Voice"
|
||||
"state": "translated",
|
||||
"value": "Talk 음성"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -163655,8 +163655,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "This device needs gateway approval before Talk can use realtime voice. Audio will go directly from this device to the voice provider."
|
||||
"state": "translated",
|
||||
"value": "이 기기에서 Talk가 실시간 음성을 사용하려면 게이트웨이 승인이 필요합니다. 오디오는 이 기기에서 음성 제공자로 직접 전송됩니다."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -166783,8 +166783,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "Try again after the gateway reconnects."
|
||||
"state": "translated",
|
||||
"value": "Gateway가 다시 연결된 후 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -182151,8 +182151,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "gateway connect failed"
|
||||
"state": "translated",
|
||||
"value": "게이트웨이 연결 실패"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
@@ -188543,8 +188543,8 @@
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "unknown relay error"
|
||||
"state": "translated",
|
||||
"value": "알 수 없는 릴레이 오류"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
|
||||
@@ -1143,7 +1143,17 @@ def shell_join(parts)
|
||||
end
|
||||
|
||||
def xcodebuild_shell_join(parts)
|
||||
xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
|
||||
# Keep caller-provided pinned Swift tools ahead of Homebrew. Replacing PATH
|
||||
# here makes archive build phases bypass the versions verified by the caller.
|
||||
xcode_path = [
|
||||
*ENV.fetch("PATH", "").split(File::PATH_SEPARATOR),
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
].reject(&:empty?).uniq.join(File::PATH_SEPARATOR)
|
||||
shell_join(["env", "PATH=#{xcode_path}", *parts])
|
||||
end
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ final class DashboardManager {
|
||||
|
||||
private var controller: DashboardWindowController?
|
||||
private var endpointTask: Task<Void, Never>?
|
||||
private var pendingOpenCommands: [DashboardNativeCommand] = []
|
||||
private var openForCommandTask: Task<Void, Never>?
|
||||
private var updater: UpdaterProviding?
|
||||
private var displayedRouteRevision: UInt64?
|
||||
private let authTokenProvider: @Sendable (GatewayConnection.Config) async -> String?
|
||||
@@ -122,6 +124,7 @@ final class DashboardManager {
|
||||
auth: DashboardWindowAuth,
|
||||
mode: AppState.ConnectionMode)
|
||||
{
|
||||
current.releaseFrameAutosaveForReplacement()
|
||||
current.closeDashboard()
|
||||
let replacement = DashboardWindowController(
|
||||
url: url,
|
||||
@@ -133,6 +136,7 @@ final class DashboardManager {
|
||||
}
|
||||
|
||||
private func replaceWithRouteFailure(_ current: DashboardWindowController) {
|
||||
current.releaseFrameAutosaveForReplacement()
|
||||
current.closeDashboard()
|
||||
let replacement = DashboardWindowController(
|
||||
url: Self.failureURL,
|
||||
@@ -180,6 +184,24 @@ final class DashboardManager {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Preload failures stay invisible: navigation errors land in the
|
||||
/// controller's `showLoadFailure`, which never orders the window front, and
|
||||
/// preload skips `observeEndpointChanges()` so no observer path can call
|
||||
/// `showFailure`. The failure page is only seen on a later explicit show.
|
||||
func preloadIfConfigured() {
|
||||
guard self.controller == nil,
|
||||
AppStateStore.shared.onboardingSeen,
|
||||
let (mode, url, auth) = self.immediateWindowConfiguration()
|
||||
else { return }
|
||||
let controller = DashboardWindowController(
|
||||
url: url,
|
||||
auth: auth,
|
||||
updater: self.updater,
|
||||
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
|
||||
self.controller = controller
|
||||
controller.loadInBackground(url: url, auth: auth)
|
||||
}
|
||||
|
||||
func show() async throws {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
|
||||
@@ -249,6 +271,37 @@ final class DashboardManager {
|
||||
self.controller?.navigateForward()
|
||||
}
|
||||
|
||||
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let controller, controller.isWindowOpen, controller.canDeliverNativeCommands {
|
||||
controller.show()
|
||||
controller.dispatchNativeCommand(command)
|
||||
return
|
||||
}
|
||||
// One coalesced open drains the queue in press order; a Task per key
|
||||
// press would race window creation and reorder ⌘N/⌘K delivery.
|
||||
self.pendingOpenCommands.append(command)
|
||||
guard self.openForCommandTask == nil else { return }
|
||||
self.openForCommandTask = Task { @MainActor in
|
||||
defer { self.openForCommandTask = nil }
|
||||
if !self.showConfiguredWindowIfPossible() {
|
||||
do {
|
||||
try await self.show()
|
||||
} catch {
|
||||
// Commands are moment-bound; drop them with the failed open.
|
||||
self.pendingOpenCommands = []
|
||||
self.showFailure(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
let commands = self.pendingOpenCommands
|
||||
self.pendingOpenCommands = []
|
||||
for command in commands {
|
||||
self.controller?.dispatchNativeCommand(command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func websocketURLString(for dashboardURL: URL) -> String {
|
||||
guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else {
|
||||
return dashboardURL.absoluteString
|
||||
@@ -294,6 +347,23 @@ final class DashboardManager {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func immediateWindowConfiguration()
|
||||
-> (AppState.ConnectionMode, URL, DashboardWindowAuth)?
|
||||
{
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
guard let config = self.immediateDashboardConfig(mode: mode),
|
||||
let url = try? GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: mode,
|
||||
authToken: config.token)
|
||||
else { return nil }
|
||||
let auth = DashboardWindowAuth(
|
||||
gatewayUrl: Self.websocketURLString(for: url),
|
||||
token: config.token,
|
||||
password: (config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty))
|
||||
return auth.hasCredential ? (mode, url, auth) : nil
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
final class DashboardWebView: WKWebView {
|
||||
private static let hiddenContextMenuIdentifiers: Set<String> = [
|
||||
"WKMenuItemIdentifierReload",
|
||||
"WKMenuItemIdentifierOpenLinkInNewWindow",
|
||||
"WKMenuItemIdentifierOpenImageInNewWindow",
|
||||
"WKMenuItemIdentifierOpenMediaInNewWindow",
|
||||
"WKMenuItemIdentifierOpenFrameInNewWindow",
|
||||
"WKMenuItemIdentifierDownloadLinkedFile",
|
||||
"WKMenuItemIdentifierDownloadImage",
|
||||
"WKMenuItemIdentifierDownloadMedia",
|
||||
]
|
||||
|
||||
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
||||
super.willOpenMenu(menu, with: event)
|
||||
let items = Self.filteredContextMenuItems(menu.items)
|
||||
menu.removeAllItems()
|
||||
for item in items {
|
||||
menu.addItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
static func filteredContextMenuItems(_ items: [NSMenuItem]) -> [NSMenuItem] {
|
||||
var filtered: [NSMenuItem] = []
|
||||
for item in items where !Self.hiddenContextMenuIdentifiers.contains(item.identifier?.rawValue ?? "") {
|
||||
if item.isSeparatorItem, filtered.last?.isSeparatorItem != false {
|
||||
continue
|
||||
}
|
||||
filtered.append(item)
|
||||
}
|
||||
if filtered.last?.isSeparatorItem == true {
|
||||
filtered.removeLast()
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,24 @@ enum DashboardWindowLayout {
|
||||
static let linkBrowserPreferredFraction: CGFloat = 0.4
|
||||
static let linkBrowserTabBarHeight: CGFloat = 30
|
||||
static let linkBrowserSplitAutosaveName = "OpenClawDashboardLinkBrowserSplit"
|
||||
static let windowFrameAutosaveName = "OpenClawDashboardWindow"
|
||||
}
|
||||
|
||||
/// Raw values are window event names the Control UI handles. `newSession`
|
||||
/// reuses the shipped pre-web-chrome event; `commandPalette` gets a dedicated
|
||||
/// toggle event because the legacy `native-open-search` contract is open-only.
|
||||
enum DashboardNativeCommand: String {
|
||||
case newSession = "openclaw:native-new-session"
|
||||
case commandPalette = "openclaw:native-toggle-search"
|
||||
|
||||
/// Older gateway bundles lack the toggle listener; dispatch degrades to the
|
||||
/// open-only legacy event when the primary event goes unhandled.
|
||||
var legacyFallbackEventName: String? {
|
||||
switch self {
|
||||
case .newSession: nil
|
||||
case .commandPalette: "openclaw:native-open-search"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DashboardLinkTarget: String, Equatable {
|
||||
|
||||
@@ -65,7 +65,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
private static let windowDragMessageHandlerName = "openclawWindowDrag"
|
||||
private static let updateMessageHandlerName = "openclawUpdate"
|
||||
|
||||
private let webView: WKWebView
|
||||
private let webView: DashboardWebView
|
||||
private let linkBrowser: DashboardLinkBrowserView
|
||||
private let linkBrowserItem: NSSplitViewItem
|
||||
private let splitViewController: NSSplitViewController
|
||||
@@ -82,6 +82,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
private var browserProfileImportOfferIsArmed = false
|
||||
private var browserProfileImportOfferRequestIsInFlight = false
|
||||
private var browserProfileImportOfferRetryPending = false
|
||||
private var hasLiveContent = false
|
||||
private var isShowingFailurePage = false
|
||||
private var pendingNativeCommands: [DashboardNativeCommand] = []
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
@@ -121,10 +124,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
Self.installNativeChromeScript(into: config.userContentController)
|
||||
Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth)
|
||||
|
||||
self.webView = WKWebView(
|
||||
self.webView = DashboardWebView(
|
||||
frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
|
||||
configuration: config)
|
||||
self.webView.setValue(true, forKey: "drawsBackground")
|
||||
self.webView.underPageBackgroundColor = .windowBackgroundColor
|
||||
// The Control UI routes via pushState, so WKWebView's back-forward list
|
||||
// carries in-app navigation; the web titlebar buttons use this list.
|
||||
self.webView.allowsBackForwardNavigationGestures = true
|
||||
@@ -161,6 +165,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
|
||||
let window = Self.makeWindow(contentView: splitViewController.view)
|
||||
super.init(window: window)
|
||||
// NSWindowController adopts its own frame state during initialization;
|
||||
// keep it aligned with the autosave name installed by makeWindow, then
|
||||
// re-correct placement in case the assignment re-applied a stale frame.
|
||||
self.windowFrameAutosaveName = DashboardWindowLayout.windowFrameAutosaveName
|
||||
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
|
||||
|
||||
// Width is autosaved, while each new dashboard window starts with the
|
||||
// optional browser collapsed until a link explicitly opens it.
|
||||
@@ -304,19 +313,32 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
self.show()
|
||||
}
|
||||
|
||||
func loadInBackground(url: URL, auth: DashboardWindowAuth) {
|
||||
self.update(url: url, auth: auth)
|
||||
}
|
||||
|
||||
/// Swap the dashboard to a new gateway endpoint without reordering the window:
|
||||
/// re-injects the native auth script for the new origin and reloads. Used when
|
||||
/// the remote tunnel is recreated on a new local port while the window stays
|
||||
/// open; ordering the window front here would steal focus on background
|
||||
/// tunnel recreation.
|
||||
func update(url: URL, auth: DashboardWindowAuth, updateBridgeEnabled: Bool? = nil) {
|
||||
let shouldReload = Self.shouldReloadDashboard(
|
||||
currentURL: self.currentURL,
|
||||
newURL: url,
|
||||
currentAuth: self.auth,
|
||||
newAuth: auth,
|
||||
hasUsableDocument: self.hasLiveContent || self.webView.isLoading,
|
||||
isShowingFailurePage: self.isShowingFailurePage)
|
||||
self.currentURL = url
|
||||
self.auth = auth
|
||||
self.refreshNativeAuthScript(url: url, auth: auth)
|
||||
if let updateBridgeEnabled {
|
||||
self.setUpdateBridgeEnabled(updateBridgeEnabled)
|
||||
}
|
||||
self.load(url)
|
||||
if shouldReload {
|
||||
self.refreshNativeAuthScript(url: url, auth: auth)
|
||||
self.load(url)
|
||||
}
|
||||
self.requestBrowserProfileImportOfferIfNeeded()
|
||||
}
|
||||
|
||||
@@ -327,6 +349,14 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
return window.isVisible || window.isMiniaturized
|
||||
}
|
||||
|
||||
/// Commands are deliverable when a document is live or a load is in flight
|
||||
/// (the queue flushes at `didFinish`). A failure page, or a terminally
|
||||
/// cancelled load with no successor, needs a reload before dispatch —
|
||||
/// otherwise queued ⌘N/⌘K would wait on a `didFinish` that never comes.
|
||||
var canDeliverNativeCommands: Bool {
|
||||
!self.isShowingFailurePage && (self.hasLiveContent || self.webView.isLoading)
|
||||
}
|
||||
|
||||
func show() {
|
||||
if let window {
|
||||
let frame = window.frame
|
||||
@@ -347,7 +377,19 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
window?.performClose(nil)
|
||||
}
|
||||
|
||||
func releaseFrameAutosaveForReplacement() {
|
||||
// AppKit rejects duplicate autosave owners. Release only when the manager
|
||||
// replaces this controller so the successor can restore the saved frame.
|
||||
self.window?.saveFrame(usingName: DashboardWindowLayout.windowFrameAutosaveName)
|
||||
self.windowFrameAutosaveName = ""
|
||||
}
|
||||
|
||||
func showFailure(title: String, message: String, detail: String? = nil) {
|
||||
self.hasLiveContent = false
|
||||
self.isShowingFailurePage = true
|
||||
// Queued commands are moment-bound user intent; replaying them after a
|
||||
// later recovery reload would toggle or navigate unexpectedly.
|
||||
self.pendingNativeCommands = []
|
||||
self.currentURL = URL(string: "about:blank")!
|
||||
self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
|
||||
self.setUpdateBridgeEnabled(false)
|
||||
@@ -360,6 +402,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
}
|
||||
|
||||
private func load(_ url: URL) {
|
||||
// Endpoint swaps must queue commands for the replacement document.
|
||||
self.hasLiveContent = false
|
||||
self.isShowingFailurePage = false
|
||||
dashboardWindowLogger.debug("dashboard load \(dashboardLogString(for: url), privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
@@ -676,6 +721,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
window.contentViewController = viewController
|
||||
window.center()
|
||||
window.minSize = DashboardWindowLayout.windowMinSize
|
||||
// Autosave restore first, placement correction last: a frame saved on
|
||||
// a since-disconnected monitor must not leave the window off-screen.
|
||||
window.setFrameAutosaveName(DashboardWindowLayout.windowFrameAutosaveName)
|
||||
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
|
||||
return window
|
||||
}
|
||||
@@ -850,9 +898,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
|
||||
private func showLoadFailure(_ error: Error) {
|
||||
let nsError = error as NSError
|
||||
// A cancelled provisional navigation never commits, so the prior
|
||||
// document survives and stays command-capable; clearing live state
|
||||
// here would queue native commands forever with no reload to flush.
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
|
||||
return
|
||||
}
|
||||
self.hasLiveContent = false
|
||||
self.isShowingFailurePage = true
|
||||
// Same moment-bound rule as showFailure: a terminal load failure
|
||||
// invalidates commands queued for the document that never arrived.
|
||||
self.pendingNativeCommands = []
|
||||
dashboardWindowLogger.error(
|
||||
"""
|
||||
dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) \
|
||||
@@ -867,6 +923,64 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
}
|
||||
}
|
||||
|
||||
extension DashboardWindowController {
|
||||
static func shouldReloadDashboard(
|
||||
currentURL: URL,
|
||||
newURL: URL,
|
||||
currentAuth: DashboardWindowAuth,
|
||||
newAuth: DashboardWindowAuth,
|
||||
hasUsableDocument: Bool,
|
||||
isShowingFailurePage: Bool) -> Bool
|
||||
{
|
||||
// Token changes surface in the URL fragment, but password-only auth keeps
|
||||
// the URL identical; comparing auth prevents serving stale credentials.
|
||||
// An in-flight load counts as usable so opening mid-preload does not
|
||||
// cancel and restart it — unless the in-flight document is the failure
|
||||
// page, which must always be replaced.
|
||||
currentURL != newURL || currentAuth != newAuth || isShowingFailurePage || !hasUsableDocument
|
||||
}
|
||||
|
||||
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
|
||||
guard self.hasLiveContent else {
|
||||
// Ordered queue, duplicates included: two ⌘K presses while loading
|
||||
// must toggle twice, and ⌘N followed by ⌘K must deliver both.
|
||||
self.pendingNativeCommands.append(command)
|
||||
return
|
||||
}
|
||||
self.evaluateNativeCommand(command)
|
||||
}
|
||||
|
||||
private func evaluateNativeCommand(_ command: DashboardNativeCommand) {
|
||||
guard let fallback = command.legacyFallbackEventName else {
|
||||
self.webView.evaluateJavaScript(
|
||||
"window.dispatchEvent(new CustomEvent(\(Self.jsStringLiteral(command.rawValue))))")
|
||||
return
|
||||
}
|
||||
// Older gateway-served bundles predate the toggle event but handled ⌘K
|
||||
// via page keydown, which the menu item now intercepts. A handler that
|
||||
// knows the new event calls preventDefault; otherwise fall back to the
|
||||
// legacy open-only event so ⌘K keeps working against old bundles.
|
||||
self.webView.evaluateJavaScript(
|
||||
"""
|
||||
(() => {
|
||||
const handled = !window.dispatchEvent(
|
||||
new CustomEvent(\(Self.jsStringLiteral(command.rawValue)), {cancelable: true}));
|
||||
if (!handled) {
|
||||
window.dispatchEvent(new CustomEvent(\(Self.jsStringLiteral(fallback))));
|
||||
}
|
||||
})();
|
||||
""")
|
||||
}
|
||||
|
||||
private func flushPendingNativeCommands() {
|
||||
let commands = self.pendingNativeCommands
|
||||
self.pendingNativeCommands = []
|
||||
for command in commands {
|
||||
self.evaluateNativeCommand(command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WKNavigationDelegate policy lives in an extension to keep the class
|
||||
/// body inside the swiftlint type_body_length budget.
|
||||
extension DashboardWindowController {
|
||||
@@ -962,11 +1076,29 @@ extension DashboardWindowController {
|
||||
}
|
||||
}
|
||||
|
||||
/// The displayed document is replaced at commit, not at provisional start.
|
||||
/// Clearing here covers page/WebKit-initiated main-frame navigations that
|
||||
/// never pass through `load(_:)`, so commands queue for the new document.
|
||||
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
|
||||
guard webView === self.webView else { return }
|
||||
self.hasLiveContent = false
|
||||
// Swipe-back/⌘[ can leave the failure page through WKWebView history
|
||||
// without a `load(_:)`; a committed http(s) document is a real
|
||||
// dashboard again (the failure page itself commits as about:blank).
|
||||
if webView.url?.scheme?.lowercased().hasPrefix("http") == true {
|
||||
self.isShowingFailurePage = false
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
if self.linkBrowser.owns(webView) {
|
||||
self.linkBrowser.navigationDidFinish(navigation, for: webView)
|
||||
} else if webView === self.webView {
|
||||
guard !self.isShowingFailurePage else { return }
|
||||
self.hasLiveContent = true
|
||||
self.publishNativeHistoryState()
|
||||
// History state must reach the shell before a queued command can navigate it.
|
||||
self.flushPendingNativeCommands()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1126,6 +1258,10 @@ extension DashboardWindowController {
|
||||
self.webView.allowsBackForwardNavigationGestures
|
||||
}
|
||||
|
||||
var _testPendingNativeCommands: [DashboardNativeCommand] {
|
||||
self.pendingNativeCommands
|
||||
}
|
||||
|
||||
var _testNavigationWebViewIdentity: ObjectIdentifier {
|
||||
ObjectIdentifier(self.activeNavigationWebView)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,11 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
static func kickstart() async -> String? {
|
||||
await self.runDaemonCommand(["restart"], timeout: 20)
|
||||
if self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd restart skipped (disable marker set)")
|
||||
return nil
|
||||
}
|
||||
return await self.runDaemonCommand(["restart"], timeout: 20)
|
||||
}
|
||||
|
||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||
|
||||
@@ -99,6 +99,12 @@ struct OpenClawApp: App {
|
||||
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
.windowResizability(.contentSize)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Session") {
|
||||
DashboardManager.shared.dispatchNativeCommand(.newSession)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings...") {
|
||||
self.openWindow(id: SettingsWindowOpener.windowID)
|
||||
@@ -116,6 +122,13 @@ struct OpenClawApp: App {
|
||||
DashboardManager.shared.navigateForward()
|
||||
}
|
||||
.keyboardShortcut("]", modifiers: .command)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Command Palette…") {
|
||||
DashboardManager.shared.dispatchNativeCommand(.commandPalette)
|
||||
}
|
||||
.keyboardShortcut("k", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.isMenuPresented) { _, _ in
|
||||
@@ -449,6 +462,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
DashboardManager.shared.preloadIfConfigured()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Screenshot/demo helper: show the pairing panel with sample requests.
|
||||
|
||||
@@ -18,6 +18,8 @@ protocol MacNodeHostWorking: Sendable {
|
||||
func start(command: [String]) async throws -> MacNodeHostManifest
|
||||
func supports(_ command: String) async -> Bool
|
||||
func invoke(_ request: BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
func handleInput(invokeId: String, seq: Int, payloadJSON: String) async
|
||||
func cancel(invokeId: String) async
|
||||
func setRoute(_ route: GatewayNodeSessionRoute?, authorityGeneration: UInt64) async -> Bool
|
||||
func publishInventory(ifCurrentRoute route: GatewayNodeSessionRoute) async
|
||||
func stop() async
|
||||
@@ -28,6 +30,13 @@ protocol MacNodeHostWorking: Sendable {
|
||||
/// and keeps TCC-sensitive execution behind the native exec-host socket.
|
||||
final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
|
||||
nonisolated static let defaultStartupTimeout: TimeInterval = 300
|
||||
private static let maxPendingInvokeControlIDs = 32
|
||||
private static let maxPendingInvokeControlsPerID = 64
|
||||
|
||||
private enum PendingInvokeControl {
|
||||
case input(seq: Int, payloadJSON: String)
|
||||
case cancel
|
||||
}
|
||||
|
||||
enum WorkerError: LocalizedError {
|
||||
case unavailable(String)
|
||||
@@ -60,6 +69,8 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
|
||||
private var routeAuthorityGeneration: UInt64 = 0
|
||||
private var startContinuation: CheckedContinuation<MacNodeHostManifest, Error>?
|
||||
private var invokeContinuations: [String: CheckedContinuation<BridgeInvokeResponse, Never>] = [:]
|
||||
private var pendingInvokeControls: [String: [PendingInvokeControl]] = [:]
|
||||
private var pendingInvokeControlOrder: [String] = []
|
||||
private var startTimer: DispatchSourceTimer?
|
||||
private var eventDeliveryTask: Task<Void, Never>?
|
||||
private var inventoryPublicationTask: Task<Void, Never>?
|
||||
@@ -131,6 +142,9 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
|
||||
"type": "invoke",
|
||||
"request": workerRequest,
|
||||
])
|
||||
for control in self.takePendingInvokeControlsLocked(invokeId: request.id) {
|
||||
try self.enqueueInvokeControlLocked(control, invokeId: request.id)
|
||||
}
|
||||
} catch {
|
||||
self.invokeContinuations.removeValue(forKey: request.id)?.resume(returning:
|
||||
Self.unavailableResponse(request.id, "UNAVAILABLE: node-host worker write failed"))
|
||||
@@ -139,6 +153,83 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func handleInput(invokeId: String, seq: Int, payloadJSON: String) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
let control = PendingInvokeControl.input(seq: seq, payloadJSON: payloadJSON)
|
||||
if self.invokeContinuations[invokeId] != nil {
|
||||
try? self.enqueueInvokeControlLocked(control, invokeId: invokeId)
|
||||
} else if self.process?.isRunning == true, self.manifest != nil {
|
||||
self.bufferInvokeControlLocked(control, invokeId: invokeId)
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel(invokeId: String) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
let control = PendingInvokeControl.cancel
|
||||
if self.invokeContinuations[invokeId] != nil {
|
||||
try? self.enqueueInvokeControlLocked(control, invokeId: invokeId)
|
||||
} else if self.process?.isRunning == true, self.manifest != nil {
|
||||
self.bufferInvokeControlLocked(control, invokeId: invokeId)
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bufferInvokeControlLocked(_ control: PendingInvokeControl, invokeId: String) {
|
||||
// Gateway control events can overtake detached invoke dispatch. Keep the
|
||||
// short race window bounded, then flush controls after the invoke frame.
|
||||
if self.pendingInvokeControls[invokeId] == nil {
|
||||
if self.pendingInvokeControlOrder.count >= Self.maxPendingInvokeControlIDs,
|
||||
let oldest = self.pendingInvokeControlOrder.first
|
||||
{
|
||||
self.pendingInvokeControlOrder.removeFirst()
|
||||
self.pendingInvokeControls.removeValue(forKey: oldest)
|
||||
}
|
||||
self.pendingInvokeControlOrder.append(invokeId)
|
||||
self.pendingInvokeControls[invokeId] = []
|
||||
}
|
||||
var controls = self.pendingInvokeControls[invokeId] ?? []
|
||||
if controls.contains(where: {
|
||||
if case .cancel = $0 { return true }
|
||||
return false
|
||||
}) {
|
||||
return
|
||||
}
|
||||
if controls.count >= Self.maxPendingInvokeControlsPerID {
|
||||
controls.removeFirst()
|
||||
}
|
||||
controls.append(control)
|
||||
self.pendingInvokeControls[invokeId] = controls
|
||||
}
|
||||
|
||||
private func takePendingInvokeControlsLocked(invokeId: String) -> [PendingInvokeControl] {
|
||||
self.pendingInvokeControlOrder.removeAll { $0 == invokeId }
|
||||
return self.pendingInvokeControls.removeValue(forKey: invokeId) ?? []
|
||||
}
|
||||
|
||||
private func enqueueInvokeControlLocked(_ control: PendingInvokeControl, invokeId: String) throws {
|
||||
switch control {
|
||||
case let .input(seq, payloadJSON):
|
||||
try self.enqueueWriteLocked([
|
||||
"type": "invoke-input",
|
||||
"invokeId": invokeId,
|
||||
"seq": seq,
|
||||
"payloadJSON": payloadJSON,
|
||||
])
|
||||
case .cancel:
|
||||
try self.enqueueWriteLocked([
|
||||
"type": "invoke-cancel",
|
||||
"invokeId": invokeId,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func setRoute(_ route: GatewayNodeSessionRoute?, authorityGeneration: UInt64) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.queue.async {
|
||||
@@ -561,6 +652,8 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
|
||||
}
|
||||
let pending = self.invokeContinuations
|
||||
self.invokeContinuations.removeAll()
|
||||
self.pendingInvokeControls.removeAll()
|
||||
self.pendingInvokeControlOrder.removeAll()
|
||||
for (id, continuation) in pending {
|
||||
continuation.resume(returning: Self.unavailableResponse(id, "UNAVAILABLE: node-host worker stopped"))
|
||||
}
|
||||
|
||||
@@ -683,6 +683,21 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
}
|
||||
return await self.runtime.handleInvoke(req)
|
||||
},
|
||||
onInvokeInput: { [weak self] input in
|
||||
guard let self,
|
||||
await self.routeAuthorityAllowsInvoke(attempt.routeAuthorityGeneration)
|
||||
else { return }
|
||||
await self.nodeHostWorker?.handleInput(
|
||||
invokeId: input.id,
|
||||
seq: input.seq,
|
||||
payloadJSON: input.payloadjson)
|
||||
},
|
||||
onInvokeCancel: { [weak self] invokeId in
|
||||
guard let self,
|
||||
await self.routeAuthorityAllowsInvoke(attempt.routeAuthorityGeneration)
|
||||
else { return }
|
||||
await self.nodeHostWorker?.cancel(invokeId: invokeId)
|
||||
},
|
||||
onRouteInvalidated: { [weak self] in
|
||||
await self?.invalidateRuntimeRoute(authorityGeneration: attempt.routeAuthorityGeneration)
|
||||
})
|
||||
|
||||
@@ -68,9 +68,121 @@ struct DashboardWindowSmokeTests {
|
||||
#expect(controller.window?.toolbar?.isVisible == true)
|
||||
#expect((controller.window?.frame.width ?? 0) >= DashboardWindowLayout.windowMinSize.width)
|
||||
#expect((controller.window?.frame.height ?? 0) >= DashboardWindowLayout.windowMinSize.height)
|
||||
#expect(controller.window?.frameAutosaveName == DashboardWindowLayout.windowFrameAutosaveName)
|
||||
controller.closeDashboard()
|
||||
}
|
||||
|
||||
@Test func `dashboard context menu removes browser items and collapses separators`() {
|
||||
let hiddenIdentifiers = [
|
||||
"WKMenuItemIdentifierReload",
|
||||
"WKMenuItemIdentifierOpenLinkInNewWindow",
|
||||
"WKMenuItemIdentifierOpenImageInNewWindow",
|
||||
"WKMenuItemIdentifierOpenMediaInNewWindow",
|
||||
"WKMenuItemIdentifierOpenFrameInNewWindow",
|
||||
"WKMenuItemIdentifierDownloadLinkedFile",
|
||||
"WKMenuItemIdentifierDownloadImage",
|
||||
"WKMenuItemIdentifierDownloadMedia",
|
||||
]
|
||||
let hiddenItems = hiddenIdentifiers.map { identifier in
|
||||
let item = NSMenuItem(title: identifier, action: nil, keyEquivalent: "")
|
||||
item.identifier = NSUserInterfaceItemIdentifier(identifier)
|
||||
return item
|
||||
}
|
||||
let copy = NSMenuItem(title: "Copy", action: nil, keyEquivalent: "")
|
||||
let inspect = NSMenuItem(title: "Inspect Element", action: nil, keyEquivalent: "")
|
||||
let filtered = DashboardWebView.filteredContextMenuItems([
|
||||
.separator(),
|
||||
hiddenItems[0],
|
||||
.separator(),
|
||||
copy,
|
||||
.separator(),
|
||||
.separator(),
|
||||
hiddenItems[1],
|
||||
hiddenItems[2],
|
||||
hiddenItems[3],
|
||||
hiddenItems[4],
|
||||
hiddenItems[5],
|
||||
hiddenItems[6],
|
||||
hiddenItems[7],
|
||||
.separator(),
|
||||
inspect,
|
||||
.separator(),
|
||||
])
|
||||
|
||||
#expect(filtered.map(\.title) == ["Copy", "", "Inspect Element"])
|
||||
#expect(filtered[1].isSeparatorItem)
|
||||
#expect(!filtered.contains { hiddenIdentifiers.contains($0.identifier?.rawValue ?? "") })
|
||||
}
|
||||
|
||||
@Test func `dashboard reload decision preserves live same URL content`() throws {
|
||||
let current = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
let replacement = try #require(URL(string: "http://127.0.0.1:18790/control/"))
|
||||
let auth = DashboardWindowAuth(
|
||||
gatewayUrl: "ws://127.0.0.1:18789/control/",
|
||||
token: nil,
|
||||
password: "secret")
|
||||
let rotatedAuth = DashboardWindowAuth(
|
||||
gatewayUrl: "ws://127.0.0.1:18789/control/",
|
||||
token: nil,
|
||||
password: "rotated")
|
||||
|
||||
#expect(!DashboardWindowController.shouldReloadDashboard(
|
||||
currentURL: current,
|
||||
newURL: current,
|
||||
currentAuth: auth,
|
||||
newAuth: auth,
|
||||
hasUsableDocument: true,
|
||||
isShowingFailurePage: false))
|
||||
#expect(DashboardWindowController.shouldReloadDashboard(
|
||||
currentURL: current,
|
||||
newURL: current,
|
||||
currentAuth: auth,
|
||||
newAuth: auth,
|
||||
hasUsableDocument: false,
|
||||
isShowingFailurePage: false))
|
||||
#expect(DashboardWindowController.shouldReloadDashboard(
|
||||
currentURL: current,
|
||||
newURL: replacement,
|
||||
currentAuth: auth,
|
||||
newAuth: auth,
|
||||
hasUsableDocument: true,
|
||||
isShowingFailurePage: false))
|
||||
// Password-only auth keeps the URL identical; rotation must reload.
|
||||
#expect(DashboardWindowController.shouldReloadDashboard(
|
||||
currentURL: current,
|
||||
newURL: current,
|
||||
currentAuth: auth,
|
||||
newAuth: rotatedAuth,
|
||||
hasUsableDocument: true,
|
||||
isShowingFailurePage: false))
|
||||
// An in-flight failure page is never a usable document to keep.
|
||||
#expect(DashboardWindowController.shouldReloadDashboard(
|
||||
currentURL: current,
|
||||
newURL: current,
|
||||
currentAuth: auth,
|
||||
newAuth: auth,
|
||||
hasUsableDocument: true,
|
||||
isShowingFailurePage: true))
|
||||
}
|
||||
|
||||
@Test func `dashboard native command queues before page load`() throws {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
let controller = DashboardWindowController(
|
||||
url: url,
|
||||
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
|
||||
|
||||
controller.dispatchNativeCommand(.newSession)
|
||||
controller.dispatchNativeCommand(.commandPalette)
|
||||
controller.dispatchNativeCommand(.commandPalette)
|
||||
|
||||
#expect(controller._testPendingNativeCommands == [.newSession, .commandPalette, .commandPalette])
|
||||
|
||||
// A terminal failure drops moment-bound intent instead of replaying it
|
||||
// after a later recovery reload.
|
||||
controller.showFailure(title: "Dashboard unavailable", message: "offline")
|
||||
#expect(controller._testPendingNativeCommands.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `dashboard navigation stays on same endpoint`() throws {
|
||||
let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
let staleEndpoint = try #require(URL(string: "http://127.0.0.1:18790/control/chat"))
|
||||
@@ -686,6 +798,7 @@ struct DashboardWindowSmokeTests {
|
||||
detail: "Reset the remote tunnel and try again.")
|
||||
#expect(controller.window?.isVisible == true)
|
||||
#expect(controller.window?.styleMask.contains(.closable) == true)
|
||||
#expect(!controller.canDeliverNativeCommands)
|
||||
controller.closeDashboard()
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ struct GatewayLaunchAgentManagerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `attach only runtime override does not uninstall gateway launch agent`() throws {
|
||||
@Test func `attach only runtime override blocks gateway launch agent writes`() async throws {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-attach-only-\(UUID().uuidString)", isDirectory: true)
|
||||
let marker = dir.appendingPathComponent("disable-launchagent")
|
||||
@@ -45,8 +45,10 @@ struct GatewayLaunchAgentManagerTests {
|
||||
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
||||
|
||||
let error = GatewayLaunchAgentManager.applyAttachOnlyRuntimeOverride()
|
||||
let kickstartError = await GatewayLaunchAgentManager.kickstart()
|
||||
|
||||
#expect(error == nil)
|
||||
#expect(kickstartError == nil)
|
||||
#expect(FileManager().fileExists(atPath: marker.path))
|
||||
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ private actor StubMacNodeHostWorker: MacNodeHostWorking {
|
||||
return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: #"{"owner":"cli"}"#)
|
||||
}
|
||||
|
||||
func handleInput(invokeId _: String, seq _: Int, payloadJSON _: String) async {}
|
||||
func cancel(invokeId _: String) async {}
|
||||
|
||||
func setRoute(_: GatewayNodeSessionRoute?, authorityGeneration _: UInt64) async -> Bool { true }
|
||||
func publishInventory(ifCurrentRoute _: GatewayNodeSessionRoute) async {}
|
||||
func stop() async {}
|
||||
@@ -98,6 +101,34 @@ struct MacNodeHostWorkerTests {
|
||||
await worker.stop()
|
||||
}
|
||||
|
||||
@Test func `worker forwards terminal input and cancellation frames`() async throws {
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession())
|
||||
let script = """
|
||||
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["terminal"],"commands":["codex.terminal.resume.v1"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
IFS= read -r invoke
|
||||
IFS= read -r input
|
||||
IFS= read -r cancel
|
||||
printf '%s' "$invoke" | grep -q '"id":"terminal-1"' || exit 40
|
||||
printf '%s' "$input" | grep -q '"type":"invoke-input"' || exit 41
|
||||
printf '%s' "$input" | grep -q '"invokeId":"terminal-1"' || exit 42
|
||||
printf '%s' "$input" | grep -q '"seq":7' || exit 43
|
||||
printf '%s' "$cancel" | grep -q '"type":"invoke-cancel"' || exit 44
|
||||
printf '%s' "$cancel" | grep -q '"invokeId":"terminal-1"' || exit 45
|
||||
printf '%s\\n' '{"type":"invoke-result","result":{"id":"terminal-1","ok":true}}'
|
||||
while IFS= read -r line; do :; done
|
||||
"""
|
||||
|
||||
_ = try await worker.start(command: ["/bin/sh", "-c", script])
|
||||
await worker.handleInput(invokeId: "terminal-1", seq: 7, payloadJSON: #"{"data":"x"}"#)
|
||||
await worker.cancel(invokeId: "terminal-1")
|
||||
let response = await worker.invoke(BridgeInvokeRequest(
|
||||
id: "terminal-1",
|
||||
command: "codex.terminal.resume.v1"))
|
||||
|
||||
#expect(response.ok)
|
||||
await worker.stop()
|
||||
}
|
||||
|
||||
@Test func `ready worker exit notifies its route owner`() async throws {
|
||||
try await confirmation("unexpected worker exit") { confirmed in
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession()) {
|
||||
|
||||
@@ -111,6 +111,9 @@ private actor CoordinatorNodeHostWorkerProbe: MacNodeHostWorking {
|
||||
BridgeInvokeResponse(id: request.id, ok: false)
|
||||
}
|
||||
|
||||
func handleInput(invokeId _: String, seq _: Int, payloadJSON _: String) async {}
|
||||
func cancel(invokeId _: String) async {}
|
||||
|
||||
func setRoute(_: GatewayNodeSessionRoute?, authorityGeneration _: UInt64) async -> Bool { true }
|
||||
func publishInventory(ifCurrentRoute _: GatewayNodeSessionRoute) async {}
|
||||
func stop() async { self.stopCount += 1 }
|
||||
|
||||
@@ -12,6 +12,10 @@ private struct NodeInvokeRequestPayload: Codable {
|
||||
var idempotencyKey: String?
|
||||
}
|
||||
|
||||
private struct NodeInvokeCancelPayload: Codable {
|
||||
var invokeId: String
|
||||
}
|
||||
|
||||
func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
@@ -165,6 +169,8 @@ 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 onInvokeInput: (@Sendable (NodeInvokeInputEvent) async -> Void)?
|
||||
private var onInvokeCancel: (@Sendable (String) async -> Void)?
|
||||
private var onRouteInvalidated: (@Sendable () async -> Void)?
|
||||
private var hasEverConnected = false
|
||||
private var hasNotifiedConnected = false
|
||||
@@ -335,6 +341,8 @@ public actor GatewayNodeSession {
|
||||
onConnected: @escaping @Sendable () async -> Void,
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
|
||||
onInvokeInput: (@Sendable (NodeInvokeInputEvent) async -> Void)? = nil,
|
||||
onInvokeCancel: (@Sendable (String) async -> Void)? = nil,
|
||||
onRouteInvalidated: (@Sendable () async -> Void)? = nil) async throws
|
||||
{
|
||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||
@@ -404,6 +412,8 @@ public actor GatewayNodeSession {
|
||||
self.onConnected = onConnected
|
||||
self.onDisconnected = onDisconnected
|
||||
self.onInvoke = onInvoke
|
||||
self.onInvokeInput = onInvokeInput
|
||||
self.onInvokeCancel = onInvokeCancel
|
||||
self.onRouteInvalidated = onRouteInvalidated
|
||||
self.activeURL = url
|
||||
self.activeCredentials = credentials
|
||||
@@ -415,6 +425,8 @@ public actor GatewayNodeSession {
|
||||
self.onConnected = onConnected
|
||||
self.onDisconnected = onDisconnected
|
||||
self.onInvoke = onInvoke
|
||||
self.onInvokeInput = onInvokeInput
|
||||
self.onInvokeCancel = onInvokeCancel
|
||||
self.onRouteInvalidated = onRouteInvalidated
|
||||
}
|
||||
|
||||
@@ -458,6 +470,8 @@ public actor GatewayNodeSession {
|
||||
onConnected: @escaping @Sendable () async -> Void,
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse,
|
||||
onInvokeInput: (@Sendable (NodeInvokeInputEvent) async -> Void)? = nil,
|
||||
onInvokeCancel: (@Sendable (String) async -> Void)? = nil,
|
||||
onRouteInvalidated: (@Sendable () async -> Void)? = nil) async throws
|
||||
{
|
||||
try await self.connect(
|
||||
@@ -472,6 +486,8 @@ public actor GatewayNodeSession {
|
||||
onConnected: onConnected,
|
||||
onDisconnected: onDisconnected,
|
||||
onInvoke: onInvoke,
|
||||
onInvokeInput: onInvokeInput,
|
||||
onInvokeCancel: onInvokeCancel,
|
||||
onRouteInvalidated: onRouteInvalidated)
|
||||
}
|
||||
|
||||
@@ -507,6 +523,8 @@ public actor GatewayNodeSession {
|
||||
self.onConnected = nil
|
||||
self.onDisconnected = nil
|
||||
self.onInvoke = nil
|
||||
self.onInvokeInput = nil
|
||||
self.onInvokeCancel = nil
|
||||
self.onRouteInvalidated = nil
|
||||
self.activeSocketGeneration = nil
|
||||
self.lastRetiredSocketGeneration = nil
|
||||
@@ -992,6 +1010,26 @@ extension GatewayNodeSession {
|
||||
socketGeneration: UInt64) async
|
||||
{
|
||||
self.broadcastServerEvent(evt)
|
||||
if evt.event == "node.invoke.input" {
|
||||
guard let payload = evt.payload, let onInvokeInput else { return }
|
||||
do {
|
||||
let input: NodeInvokeInputEvent = try self.decodeEventPayload(from: payload)
|
||||
await onInvokeInput(input)
|
||||
} catch {
|
||||
self.logger.error("node invoke input decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
return
|
||||
}
|
||||
if evt.event == "node.invoke.cancel" {
|
||||
guard let payload = evt.payload, let onInvokeCancel else { return }
|
||||
do {
|
||||
let cancel: NodeInvokeCancelPayload = try self.decodeEventPayload(from: payload)
|
||||
await onInvokeCancel(cancel.invokeId)
|
||||
} catch {
|
||||
self.logger.error("node invoke cancel decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
self.logger.info("node invoke request received")
|
||||
guard let payload = evt.payload else { return }
|
||||
@@ -1442,12 +1480,16 @@ extension GatewayNodeSession {
|
||||
}
|
||||
|
||||
private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload {
|
||||
try self.decodeEventPayload(from: payload)
|
||||
}
|
||||
|
||||
private func decodeEventPayload<T: Decodable>(from payload: OpenClawProtocol.AnyCodable) throws -> T {
|
||||
do {
|
||||
let data = try encoder.encode(payload)
|
||||
return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
if let raw = payload.value as? String, let data = raw.data(using: .utf8) {
|
||||
return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -490,6 +490,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
public let health: AnyCodable
|
||||
public let stateversion: StateVersion
|
||||
public let uptimems: Int
|
||||
public let appliedconfighash: AnyCodable?
|
||||
public let configpath: String?
|
||||
public let statedir: String?
|
||||
public let sessiondefaults: [String: AnyCodable]?
|
||||
@@ -501,6 +502,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
health: AnyCodable,
|
||||
stateversion: StateVersion,
|
||||
uptimems: Int,
|
||||
appliedconfighash: AnyCodable? = nil,
|
||||
configpath: String? = nil,
|
||||
statedir: String? = nil,
|
||||
sessiondefaults: [String: AnyCodable]? = nil,
|
||||
@@ -511,6 +513,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
self.health = health
|
||||
self.stateversion = stateversion
|
||||
self.uptimems = uptimems
|
||||
self.appliedconfighash = appliedconfighash
|
||||
self.configpath = configpath
|
||||
self.statedir = statedir
|
||||
self.sessiondefaults = sessiondefaults
|
||||
@@ -523,6 +526,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
case health
|
||||
case stateversion = "stateVersion"
|
||||
case uptimems = "uptimeMs"
|
||||
case appliedconfighash = "appliedConfigHash"
|
||||
case configpath = "configPath"
|
||||
case statedir = "stateDir"
|
||||
case sessiondefaults = "sessionDefaults"
|
||||
@@ -2155,6 +2159,32 @@ public struct NodeInvokeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeInputEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
public let seq: Int
|
||||
public let payloadjson: String
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
nodeid: String,
|
||||
seq: Int,
|
||||
payloadjson: String)
|
||||
{
|
||||
self.id = id
|
||||
self.nodeid = nodeid
|
||||
self.seq = seq
|
||||
self.payloadjson = payloadjson
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case nodeid = "nodeId"
|
||||
case seq
|
||||
case payloadjson = "payloadJSON"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeProgressParams: Codable, Sendable {
|
||||
public let invokeid: String
|
||||
public let nodeid: String
|
||||
@@ -2215,6 +2245,40 @@ public struct NodeInvokeResultParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let paramsjson: String?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
nodeid: String,
|
||||
command: String,
|
||||
paramsjson: String? = nil,
|
||||
timeoutms: Int? = nil,
|
||||
idempotencykey: String? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.nodeid = nodeid
|
||||
self.command = command
|
||||
self.paramsjson = paramsjson
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case nodeid = "nodeId"
|
||||
case command
|
||||
case paramsjson = "paramsJSON"
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeEventParams: Codable, Sendable {
|
||||
public let event: String
|
||||
public let payload: AnyCodable?
|
||||
@@ -2419,40 +2483,6 @@ public struct NodePendingEnqueueResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let paramsjson: String?
|
||||
public let timeoutms: Int?
|
||||
public let idempotencykey: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
nodeid: String,
|
||||
command: String,
|
||||
paramsjson: String? = nil,
|
||||
timeoutms: Int? = nil,
|
||||
idempotencykey: String? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.nodeid = nodeid
|
||||
self.command = command
|
||||
self.paramsjson = paramsjson
|
||||
self.timeoutms = timeoutms
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case nodeid = "nodeId"
|
||||
case command
|
||||
case paramsjson = "paramsJSON"
|
||||
case timeoutms = "timeoutMs"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
public struct PushTestParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let title: String?
|
||||
@@ -2671,21 +2701,25 @@ public struct SessionCatalogCapabilities: Codable, Sendable {
|
||||
public let continuesession: Bool
|
||||
public let archive: Bool
|
||||
public let createsession: [String: AnyCodable]?
|
||||
public let openterminal: Bool?
|
||||
|
||||
public init(
|
||||
continuesession: Bool,
|
||||
archive: Bool,
|
||||
createsession: [String: AnyCodable]? = nil)
|
||||
createsession: [String: AnyCodable]? = nil,
|
||||
openterminal: Bool? = nil)
|
||||
{
|
||||
self.continuesession = continuesession
|
||||
self.archive = archive
|
||||
self.createsession = createsession
|
||||
self.openterminal = openterminal
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case continuesession = "continueSession"
|
||||
case archive
|
||||
case createsession = "createSession"
|
||||
case openterminal = "openTerminal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2727,6 +2761,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
public let openclawsessionkey: String?
|
||||
public let cancontinue: Bool
|
||||
public let canarchive: Bool
|
||||
public let canopenterminal: Bool?
|
||||
|
||||
public init(
|
||||
threadid: String,
|
||||
@@ -2743,7 +2778,8 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
archived: Bool,
|
||||
openclawsessionkey: String? = nil,
|
||||
cancontinue: Bool,
|
||||
canarchive: Bool)
|
||||
canarchive: Bool,
|
||||
canopenterminal: Bool? = nil)
|
||||
{
|
||||
self.threadid = threadid
|
||||
self.name = name
|
||||
@@ -2760,6 +2796,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
self.openclawsessionkey = openclawsessionkey
|
||||
self.cancontinue = cancontinue
|
||||
self.canarchive = canarchive
|
||||
self.canopenterminal = canopenterminal
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2778,6 +2815,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
case openclawsessionkey = "openClawSessionKey"
|
||||
case cancontinue = "canContinue"
|
||||
case canarchive = "canArchive"
|
||||
case canopenterminal = "canOpenTerminal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4490,6 +4528,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let emitcommandhooks: Bool?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let worktree: Bool?
|
||||
public let worktreebaseref: String?
|
||||
public let worktreename: String?
|
||||
@@ -4507,6 +4546,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
emitcommandhooks: Bool? = nil,
|
||||
task: String? = nil,
|
||||
message: String? = nil,
|
||||
attachments: [AnyCodable]? = nil,
|
||||
worktree: Bool? = nil,
|
||||
worktreebaseref: String? = nil,
|
||||
worktreename: String? = nil,
|
||||
@@ -4523,6 +4563,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
self.emitcommandhooks = emitcommandhooks
|
||||
self.task = task
|
||||
self.message = message
|
||||
self.attachments = attachments
|
||||
self.worktree = worktree
|
||||
self.worktreebaseref = worktreebaseref
|
||||
self.worktreename = worktreename
|
||||
@@ -4541,6 +4582,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
case emitcommandhooks = "emitCommandHooks"
|
||||
case task
|
||||
case message
|
||||
case attachments
|
||||
case worktree
|
||||
case worktreebaseref = "worktreeBaseRef"
|
||||
case worktreename = "worktreeName"
|
||||
@@ -10789,21 +10831,25 @@ public struct MigrationsMemoryApplyResult: Codable, Sendable {
|
||||
|
||||
public struct TerminalOpenParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
public let catalog: SessionsCatalogContinueParams?
|
||||
public let cols: Int
|
||||
public let rows: Int
|
||||
|
||||
public init(
|
||||
agentid: String? = nil,
|
||||
catalog: SessionsCatalogContinueParams? = nil,
|
||||
cols: Int,
|
||||
rows: Int)
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.catalog = catalog
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case catalog
|
||||
case cols
|
||||
case rows
|
||||
}
|
||||
@@ -10815,19 +10861,22 @@ public struct TerminalOpenResult: Codable, Sendable {
|
||||
public let shell: String
|
||||
public let cwd: String
|
||||
public let confined: Bool
|
||||
public let title: String?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
agentid: String,
|
||||
shell: String,
|
||||
cwd: String,
|
||||
confined: Bool)
|
||||
confined: Bool,
|
||||
title: String? = nil)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.agentid = agentid
|
||||
self.shell = shell
|
||||
self.cwd = cwd
|
||||
self.confined = confined
|
||||
self.title = title
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -10836,6 +10885,7 @@ public struct TerminalOpenResult: Codable, Sendable {
|
||||
case shell
|
||||
case cwd
|
||||
case confined
|
||||
case title
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10914,6 +10964,7 @@ public struct TerminalAttachResult: Codable, Sendable {
|
||||
public let cwd: String
|
||||
public let confined: Bool
|
||||
public let buffer: String
|
||||
public let seq: Int?
|
||||
|
||||
public init(
|
||||
sessionid: String,
|
||||
@@ -10921,7 +10972,8 @@ public struct TerminalAttachResult: Codable, Sendable {
|
||||
shell: String,
|
||||
cwd: String,
|
||||
confined: Bool,
|
||||
buffer: String)
|
||||
buffer: String,
|
||||
seq: Int? = nil)
|
||||
{
|
||||
self.sessionid = sessionid
|
||||
self.agentid = agentid
|
||||
@@ -10929,6 +10981,7 @@ public struct TerminalAttachResult: Codable, Sendable {
|
||||
self.cwd = cwd
|
||||
self.confined = confined
|
||||
self.buffer = buffer
|
||||
self.seq = seq
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -10938,6 +10991,7 @@ public struct TerminalAttachResult: Codable, Sendable {
|
||||
case cwd
|
||||
case confined
|
||||
case buffer
|
||||
case seq
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -571,6 +571,23 @@ private actor ComputerInvokeProbe {
|
||||
}
|
||||
}
|
||||
|
||||
private actor NodeInvokeControlProbe {
|
||||
private var inputs: [NodeInvokeInputEvent] = []
|
||||
private var cancellations: [String] = []
|
||||
|
||||
func recordInput(_ input: NodeInvokeInputEvent) {
|
||||
self.inputs.append(input)
|
||||
}
|
||||
|
||||
func recordCancellation(_ invokeId: String) {
|
||||
self.cancellations.append(invokeId)
|
||||
}
|
||||
|
||||
func values() -> ([String], [String]) {
|
||||
(self.inputs.map { "\($0.id):\($0.seq):\($0.payloadjson)" }, self.cancellations)
|
||||
}
|
||||
}
|
||||
|
||||
private func nodeInvokePush(id: String, command: String) -> GatewayPush {
|
||||
.event(EventFrame(
|
||||
type: "event",
|
||||
@@ -587,6 +604,59 @@ private func nodeInvokePush(id: String, command: String) -> GatewayPush {
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test func `node invoke input and cancellation reach route callbacks`() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let probe = NodeInvokeControlProbe()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["terminal"],
|
||||
commands: ["codex.terminal.resume.v1"],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "macOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
try await gateway.connect(
|
||||
url: #require(URL(string: "ws://gateway.example.invalid")),
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { request in BridgeInvokeResponse(id: request.id, ok: true) },
|
||||
onInvokeInput: { input in await probe.recordInput(input) },
|
||||
onInvokeCancel: { invokeId in await probe.recordCancellation(invokeId) })
|
||||
|
||||
await gateway._test_handlePush(
|
||||
.event(EventFrame(
|
||||
type: "event",
|
||||
event: "node.invoke.input",
|
||||
payload: AnyCodable([
|
||||
"id": AnyCodable("terminal-1"),
|
||||
"nodeId": AnyCodable("test-node"),
|
||||
"seq": AnyCodable(3),
|
||||
"payloadJSON": AnyCodable(#"{"data":"hello"}"#),
|
||||
]),
|
||||
seq: nil,
|
||||
stateversion: nil)),
|
||||
socketGeneration: 1)
|
||||
await gateway._test_handlePush(
|
||||
.event(EventFrame(
|
||||
type: "event",
|
||||
event: "node.invoke.cancel",
|
||||
payload: AnyCodable(["invokeId": AnyCodable("terminal-1")]),
|
||||
seq: nil,
|
||||
stateversion: nil)),
|
||||
socketGeneration: 1)
|
||||
|
||||
let values = await probe.values()
|
||||
#expect(values.0 == [#"terminal-1:3:{"data":"hello"}"#])
|
||||
#expect(values.1 == ["terminal-1"])
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test func `node connections use the node protocol floor`() {
|
||||
#expect(
|
||||
GatewayChannelActor.minimumProtocolVersion(role: "node", clientMode: "node") ==
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
6b509179fce1f5115037be7432b2dc21dda507351296ce5ca769e24741cece87 plugin-sdk-api-baseline.json
|
||||
d317f5ef0a767f557357a2f7699e27e0f436edd18298401ee7a982805bcd1f76 plugin-sdk-api-baseline.jsonl
|
||||
b54488723b29d94400cf65abba859bab80cd3b1e3bf9f7104956242152470c39 plugin-sdk-api-baseline.json
|
||||
d96f25f59d60b3171bc139da39eed20dd5b46d271db0875bf002a546699d2ccb plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -674,10 +674,9 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "auto",
|
||||
maxLines: 8,
|
||||
maxLineChars: 120,
|
||||
toolProgress: true,
|
||||
toolProgress: false,
|
||||
commentary: false,
|
||||
},
|
||||
},
|
||||
@@ -689,11 +688,11 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
|
||||
- `off` disables Discord preview edits.
|
||||
- `partial` edits a single preview message as tokens arrive.
|
||||
- `block` emits draft-sized chunks; tune size and breakpoints with `streaming.preview.chunk` (`minChars`, `maxChars`, `breakPreference`), clamped to `textChunkLimit`. When block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming.
|
||||
- `progress` keeps one editable status draft and updates it with tool progress until final delivery. Raw tool progress uses the shared starter label as a rolling line; narrated status shows only the narration unless a label is explicitly configured.
|
||||
- `progress` keeps one editable status draft until final delivery. By default it shows one line of the agent's latest preamble or narration, with no generated label, spacer, or tool rows.
|
||||
- Media, error, and explicit-reply finals cancel pending preview edits.
|
||||
- `streaming.preview.toolProgress` (default `true`) controls whether tool/progress updates reuse the preview message.
|
||||
- Tool/progress rows render as compact emoji + title + detail when available, for example `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`.
|
||||
- `streaming.progress.commentary` (default `false`) opts into assistant commentary/preamble text in the temporary progress draft. Commentary is cleaned before display, stays transient, and does not change final answer delivery.
|
||||
- `streaming.preview.toolProgress` defaults to `true` in `partial`/`block` mode. Discord progress mode defaults to no tool rows; set `streaming.progress.toolProgress: true` to opt in.
|
||||
- Set `streaming.progress.toolProgress: true` to add compact tool/progress rows such as `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`. For compatibility, an existing `progress.label` or `progress.labels` configuration retains the prior tool-row default; set `toolProgress: false` for a custom label without rows.
|
||||
- `streaming.progress.commentary` (default `false`) opts into raw assistant commentary in the temporary progress draft. The default preamble/narration status line is independent of this option. Commentary is cleaned before display, stays transient, and does not change final answer delivery.
|
||||
- `streaming.progress.maxLineChars` controls the per-line progress preview budget. Prose is shortened on word boundaries; command and path details keep useful suffixes.
|
||||
- `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `raw` (default) or `status` (tool label only).
|
||||
|
||||
@@ -864,6 +863,31 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Online presence events">
|
||||
Opt a guild into routed agent wakes when a human member transitions from offline to online:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
intents: { presence: true },
|
||||
guilds: {
|
||||
"111111111111111111": {
|
||||
presenceEvents: {
|
||||
channelId: "222222222222222222",
|
||||
users: ["333333333333333333"], // optional; omit for all humans
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`presenceEvents` requires an enabled heartbeat for the routed agent and the privileged **Presence Intent** on the application's Bot page in the Discord Developer Portal. OpenClaw seeds current online members from each complete `GUILD_CREATE` snapshot, routes observed offline-to-online transitions, and also treats a later first online signal for an unseen member as newly available. That member may have come online or joined after the snapshot, so the event does not assert an exact prior status. OpenClaw ignores bots and unchanged online states and persists an eight-hour per-user cooldown across Gateway restarts. Discord limits snapshots for guilds above 75,000 members; there, OpenClaw requires an explicit offline update before greeting. The system event carries immutable user, guild, and channel IDs without embedding mutable display names. The agent decides whether and how to greet.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Ack reactions">
|
||||
`ackReaction` sends an acknowledgement emoji while OpenClaw processes an inbound message.
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@ Group owners can toggle per-group activation with a standalone message:
|
||||
- `/activation mention`
|
||||
- `/activation always`
|
||||
|
||||
`/activation` is a core owner-gated command and only applies in group chats. Owner means the sender matches the channel's `allowFrom` / `commands.ownerAllowFrom` (when no allowlist is configured, the account's own id counts as owner). The stored mode overrides that group's `requireMention` on channels that consult it (Google Chat, QQBot, Telegram, WhatsApp), and the group system-prompt intro reflects the active mode everywhere.
|
||||
`/activation` is a core owner-gated command and only applies in group chats. Owner means the sender matches `commands.ownerAllowFrom`; channel `allowFrom` lists only control ordinary channel and command access. The stored mode overrides that group's `requireMention` on channels that consult it (Google Chat, QQBot, Telegram, WhatsApp), and the group system-prompt intro reflects the active mode everywhere.
|
||||
|
||||
## Context fields
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ Bare plugin specs try ClawHub first, then npm fallback. Force a source with `ope
|
||||
openclaw channels add
|
||||
```
|
||||
The wizard detects whether `signal-cli` is on `PATH` and, when missing, offers to install it: downloads the official native GraalVM build on Linux x86-64, or installs via Homebrew on macOS and other architectures. It then prompts for the bot number and `signal-cli` path.
|
||||
|
||||
For non-interactive setup, `openclaw channels add --channel signal` also accepts `--signal-number <e164>` for the bot phone number, plus `--http-host <host>` and `--http-port <port>` for the Signal daemon endpoint (default `127.0.0.1:8080`).
|
||||
|
||||
</Step>
|
||||
<Step title="Link or register the account">
|
||||
- **QR link (fastest):** `signal-cli link -n "OpenClaw"`, then scan with Signal. See [Path A](#setup-path-a-link-existing-signal-account-qr).
|
||||
|
||||
+23
-17
@@ -9,8 +9,8 @@ read_when:
|
||||
---
|
||||
|
||||
OpenClaw CI runs on pushes to `main` (Markdown and `docs/**` paths are ignored
|
||||
at the trigger), on non-draft pull requests (CHANGELOG-only diffs are ignored),
|
||||
and on manual dispatch. Canonical `main` pushes first pass through a 90-second
|
||||
at the trigger), on every non-draft pull request, and on manual dispatch.
|
||||
Canonical `main` pushes first pass through a 90-second
|
||||
hosted-runner admission window; the `CI` concurrency group cancels that waiting
|
||||
run when a newer commit lands, so sequential merges do not each register a full
|
||||
Blacksmith matrix. Pull requests and manual dispatches skip the wait. The
|
||||
@@ -33,7 +33,7 @@ dispatch.
|
||||
| `pnpm-store-warmup` | Warm the lockfile-pinned pnpm store cache without blocking Linux Node shards | Node or docs-check lanes selected |
|
||||
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, startup memory, and embedded built-artifact checks | Node-relevant changes |
|
||||
| `control-ui-i18n` | Verify generated Control UI locale bundles, metadata, and translation memory; advisory on automatic runs, blocking on manual release CI | Control UI i18n-relevant changes and manual CI |
|
||||
| `checks-fast-core` | Fast Linux correctness lanes: changed-file TypeScript LOC ratchet, bundled + protocol, Bun launcher, and the CI-routing fast task | Node-relevant or production TypeScript changes |
|
||||
| `checks-fast-core` | Fast Linux correctness lanes: suppression-baseline max-lines ratchet, bundled + protocol, Bun launcher, and the CI-routing fast task | Node-relevant changes |
|
||||
| `qa-smoke-ci-profile` | Two self-contained balanced parts of the bounded automatic QA Smoke representative set; full taxonomy coverage remains available through explicit QA profiles | Node-relevant changes |
|
||||
| `checks-fast-contracts-plugins-*` | Two weighted plugin contract shards | Node-relevant changes |
|
||||
| `checks-fast-contracts-channels-*` | Two weighted channel contract shards | Node-relevant changes |
|
||||
@@ -49,6 +49,7 @@ dispatch.
|
||||
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
|
||||
| `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes |
|
||||
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
|
||||
| `openclaw/ci-gate` | Final aggregate: requires admission, preflight, and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run |
|
||||
| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
|
||||
| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch |
|
||||
|
||||
@@ -61,6 +62,14 @@ Standalone Periphery workflows enforce zero dead-code findings for the iOS and m
|
||||
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
|
||||
4. `build-artifacts` and the advisory `control-ui-i18n` check overlap with the fast Linux lanes. Generated locale drift stays visible while the standalone refresh workflow repairs it in the background.
|
||||
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
|
||||
6. `openclaw/ci-gate` waits for every selected lane. Admission, preflight, and security must succeed; downstream jobs may skip only when the manifest did not select them. A failed or canceled selected lane fails the aggregate.
|
||||
|
||||
The merge coordinator may reuse an authenticated successful `openclaw/ci-gate`
|
||||
for the same pull-request head for up to 24 hours. This avoids rewriting a
|
||||
contributor branch after unrelated `main` changes. The reusable result does not
|
||||
replace the separate strict, App-owned test-merge check against current `main`.
|
||||
A later pending or failed rerun does not erase an earlier successful result for
|
||||
that unchanged head during the freshness window.
|
||||
|
||||
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs. The plugin-list startup-memory guard keeps a 350 MiB ceiling on self-hosted Blacksmith Linux and allows 425 MiB on GitHub-hosted Linux, whose RSS baseline is higher for the same built CLI.
|
||||
|
||||
@@ -139,7 +148,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
|
||||
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Control UI locale parity is advisory on automatic PR and `main` runs because the standalone refresh workflow repairs generated drift in the background; it is blocking on manual CI and therefore on Full Release Validation. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
|
||||
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref. The optional `loc_base_ref` supplies an exact comparison SHA for standalone manual runs. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pr_number` to identify the open pull request. The workflow authenticates that PR's current head and base, waits for GitHub to finish computing mergeability, pins the reported test merge commit, fetches GitHub's synthetic pull-request merge ref, verifies its SHA and both parents, then checks out that tree before installing dependencies and running the changed-file TypeScript LOC ratchet. This matches automatic PR CI's merged tree and policy implementation. Target-owned workflow revisions without `pr_number` cannot provide equivalent merge-tree evidence; update the PR head to the current workflow and restart exact-head proof instead of using the fallback.
|
||||
PR max-lines checks derive the baseline from the checked-out synthetic merge tree and verify its head parent against the event head. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; the max-lines baseline is compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated.
|
||||
|
||||
```bash
|
||||
gh workflow run ci.yml --ref release/YYYY.M.PATCH
|
||||
@@ -627,10 +636,11 @@ Local changed-test routing lives in `scripts/test-projects.test-support.mjs` and
|
||||
## Testbox validation
|
||||
|
||||
Crabbox is the repo-owned remote-box wrapper for maintainer Linux proof. Agent
|
||||
sessions use it by default for tests and computationally intensive work,
|
||||
including builds, typechecks, lint fan-out, Docker, package lanes, E2E, live
|
||||
proof, and CI parity. Trusted maintainer code defaults to
|
||||
`blacksmith-testbox`, and `.crabbox.yaml` now defaults to it. Its configured
|
||||
sessions keep one/few focused tests and cheap static checks local only for
|
||||
trusted source when the existing dependency install is ready. They use Crabbox for larger suites and
|
||||
computationally intensive work, including builds, typechecks, lint fan-out,
|
||||
Docker, package lanes, E2E, live proof, and CI parity. Trusted maintainer heavy
|
||||
proof defaults to `blacksmith-testbox`, and `.crabbox.yaml` now defaults to it. Its configured
|
||||
workflow hydrates provider and agent credentials, so untrusted contributor or
|
||||
fork code must use secretless fork CI or sanitized direct AWS Crabbox instead.
|
||||
Sanitized AWS runs set `CRABBOX_ENV_ALLOW=CI`, pass
|
||||
@@ -655,14 +665,9 @@ report public networking with no Tailscale state before uploading any script.
|
||||
Owned AWS/Hetzner capacity also remains the fallback for Blacksmith outages,
|
||||
quota issues, or explicit owned-capacity testing.
|
||||
|
||||
At the start of a trusted code task likely to need tests or heavy proof, agents
|
||||
should pre-warm immediately in a background command session, continue
|
||||
inspection and editing while hydration runs, reuse the returned `tbx_...` id,
|
||||
sync the current checkout on every run, and stop it before handoff:
|
||||
|
||||
```bash
|
||||
node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json
|
||||
```
|
||||
Agents do not pre-warm for anticipated work. Acquire a Testbox lazily when the
|
||||
first heavy command is ready, reuse the returned `tbx_...` id for later heavy
|
||||
commands, sync the current checkout on every run, and stop it before handoff.
|
||||
|
||||
Crabbox-backed Blacksmith runs warm, claim, sync, run, report, and clean up
|
||||
one-shot Testboxes. The built-in sync sanity check fails fast when
|
||||
@@ -709,7 +714,8 @@ pnpm crabbox:run -- --provider blacksmith-testbox \
|
||||
"corepack pnpm check:changed"
|
||||
```
|
||||
|
||||
Focused test rerun:
|
||||
Focused test rerun on Testbox when local dependencies are unavailable or the
|
||||
target fans out:
|
||||
|
||||
```bash
|
||||
pnpm crabbox:run -- --provider blacksmith-testbox \
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ Options: `--channel <channel>`, `--account <accountId>`, `--notify` (send a conf
|
||||
|
||||
If `commands.ownerAllowFrom` is empty when you approve a pairing code, OpenClaw also records the approved sender as the command owner, using a channel-scoped entry such as `telegram:123456789`. This only bootstraps the first owner - later pairing approvals never replace or expand `commands.ownerAllowFrom`.
|
||||
|
||||
The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions such as `/diagnostics`, `/export-trajectory`, `/config`, and exec approvals. Pairing only lets a sender talk to the agent; it does not by itself grant owner privileges beyond this one-time bootstrap.
|
||||
The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions such as `/diagnostics`, `/export-session`, `/export-trajectory`, `/config`, and exec approvals. Pairing only lets a sender talk to the agent; it does not by itself grant owner privileges beyond this one-time bootstrap.
|
||||
|
||||
If you approved a sender before this bootstrap existed, run `openclaw doctor`; it warns when no command owner is configured and shows the exact `openclaw config set commands.ownerAllowFrom ...` command to fix it.
|
||||
|
||||
|
||||
+13
-2
@@ -22,6 +22,7 @@ openclaw gateway restart
|
||||
openclaw workboard list [--board <id>] [--status <status>] [--include-archived] [--json]
|
||||
openclaw workboard create <title...> [--notes <text>] [--status <status>] [--priority <priority>] [--agent <id>] [--board <id>] [--labels <items>] [--json]
|
||||
openclaw workboard show <id> [--json]
|
||||
openclaw workboard move <id> --status <status> [--json]
|
||||
openclaw workboard dispatch [--board <id>] [--max-starts <count>] [--admin] [--url <url>] [--token <token>] [--timeout <ms>] [--json]
|
||||
```
|
||||
|
||||
@@ -82,6 +83,15 @@ openclaw workboard show 7f4a2c10 --json
|
||||
|
||||
Text output prints the compact card line and notes. JSON output returns the full card record, including execution metadata, attempts, comments, links, proof, artifacts, worker logs, protocol state, diagnostics, and automation metadata.
|
||||
|
||||
## `move`
|
||||
|
||||
```bash
|
||||
openclaw workboard move 7f4a2c10 --status review
|
||||
openclaw workboard move 7f4a2c10 --status done --json
|
||||
```
|
||||
|
||||
`move` changes the card's status using the same manual-operator path as dragging a card in the dashboard. It accepts a full card id or an unambiguous prefix. Active dependency and schedule holds still apply. Operators may move a claimed card without its agent claim token; claim tokens remain scoped to agent-tool mutations and are redacted from JSON output.
|
||||
|
||||
## `dispatch`
|
||||
|
||||
```bash
|
||||
@@ -134,18 +144,19 @@ Command-capable channels can use the matching slash command:
|
||||
/workboard list
|
||||
/workboard show 7f4a2c10
|
||||
/workboard create Fix stale worker heartbeat
|
||||
/workboard move 7f4a2c10 --status review
|
||||
/workboard dispatch
|
||||
```
|
||||
|
||||
Slash command dispatch also uses the Gateway subagent runtime, so it follows the same claim, worker-start, and failure behavior as the dashboard and CLI Gateway path.
|
||||
|
||||
`/workboard list` and `/workboard show` are read commands for authorized command senders. `/workboard create` and `/workboard dispatch` mutate board state and require owner status on chat surfaces or a Gateway client with `operator.write` or `operator.admin`.
|
||||
`/workboard list` and `/workboard show` are read commands for authorized command senders. `/workboard create`, `/workboard move`, and `/workboard dispatch` mutate board state and require owner status on chat surfaces or a Gateway client with `operator.write` or `operator.admin`.
|
||||
|
||||
## Permissions
|
||||
|
||||
The CLI dispatch path normally requests Gateway `operator.write` and `operator.read` scopes. Workspace-bound cards run directly in an exact configured agent workspace; a worktree request is narrowed to that directory instead of letting the host materialize repository-controlled code. The selected worker must have writable, non-shared Docker sandbox access to that exact workspace, a live container hash matching the requested mounts and policy, and no host escape capability. Pass `--admin` to explicitly request `operator.admin`, allow another host checkout, and use normal managed-worktree setup; the connection fails if that scope is not approved for the client. A read-only Gateway token can inspect Workboard data through read methods, but it cannot create cards or dispatch workers. Workspace limits do not otherwise change manual card movement for callers with Workboard mutation permission.
|
||||
|
||||
Local `list`, `create`, and `show` commands operate on the local OpenClaw state directory used by the current profile. Use `--dev` or `--profile <name>` on the top-level `openclaw` command when you need a different state root.
|
||||
Local `list`, `create`, `show`, and `move` commands operate on the local OpenClaw state directory used by the current profile. Use `--dev` or `--profile <name>` on the top-level `openclaw` command when you need a different state root.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ message once real work starts, edits it as the agent reads, plans, calls
|
||||
tools, or waits for approval, then turns it into the final answer.
|
||||
|
||||
```text
|
||||
Shelling...
|
||||
Working...
|
||||
📖 from docs/concepts/progress-drafts.md
|
||||
🔎 Web Search: for "discord edit message"
|
||||
🛠️ Bash: run tests
|
||||
@@ -58,7 +58,7 @@ migration, see [Streaming and chunking](/concepts/streaming).
|
||||
| Part | Purpose |
|
||||
| --------------- | --------------------------------------------------------------------------------- |
|
||||
| Status headline | On Discord and Telegram, the model preamble; Discord adds a utility filler. |
|
||||
| Label | Optional starter/status line such as `Working` or `Shelling`. |
|
||||
| Label | Optional starter/status line such as `Working`. |
|
||||
| Progress lines | Compact run updates using the same tool icons and detail formatter as `/verbose`. |
|
||||
|
||||
For raw tool progress, the label appears once the agent starts meaningful work
|
||||
@@ -94,14 +94,12 @@ block-reply delivery — use `streaming.block.enabled` for that.
|
||||
## Configure labels
|
||||
|
||||
Progress labels live under `channels.<channel>.streaming.progress`. The default
|
||||
raw tool-line label is `"auto"`, which picks from OpenClaw's built-in
|
||||
single-word label pool. A status headline hides that implicit label; set
|
||||
raw tool-line label is `"auto"`, which uses the plain built-in `Working`
|
||||
label. A status headline hides that implicit label; set
|
||||
`label: "auto"` explicitly if you want a label above it too:
|
||||
|
||||
```text
|
||||
Working, Shelling, Scuttling, Clawing, Pinching, Molting, Bubbling, Tiding,
|
||||
Reefing, Cracking, Sifting, Brining, Nautiling, Krilling, Barnacling,
|
||||
Lobstering, Tidepooling, Pearling, Snapping, Surfacing
|
||||
Working
|
||||
```
|
||||
|
||||
Use a fixed label:
|
||||
|
||||
@@ -23,11 +23,13 @@ OpenClaw appends a typed event to the shared state database (`session_state_even
|
||||
| Kind | Recorded when | Notifies watchers |
|
||||
| ---------------------- | -------------------------------------------------------- | ----------------- |
|
||||
| `human_direct_message` | A human sends a turn directly to a watched session | Yes |
|
||||
| `upstream_missing` | An adopted session's upstream source disappears | Yes |
|
||||
| `goal_changed` | The session's goal state is created, updated, or cleared | Yes |
|
||||
| `child_spawned` | A sub-agent or ACP child session is created | No (seeds cursor) |
|
||||
| `run_completed` | A child run ends successfully | No (log only) |
|
||||
| `run_failed` | A child run fails, times out, or is cancelled | No (log only) |
|
||||
| `compacted` | The session's history is compacted | No (log only) |
|
||||
| `adopted` | A catalog session is adopted into OpenClaw | No (log only) |
|
||||
|
||||
Each event names its actor (`human`, `agent`, or `system`). Cancelled and timed-out child runs are recorded as failures with the precise outcome (`cancelled`, `timeout`, or `error`) preserved in the event payload.
|
||||
|
||||
@@ -46,6 +48,10 @@ Watcher identity must be an agent-qualified session key. Under `session.scope="g
|
||||
|
||||
Watches clean themselves up: cursor rows expire with signal-log retention, are removed when the watcher session resets, and are deleted with either session. There is no unwatch verb in v1.
|
||||
|
||||
Watched sessions adopted from a session catalog are checked for direct upstream human activity on a fixed cadence. Detected activity enters the same signal log and watcher flow as other direct human turns.
|
||||
|
||||
If an adopted session's upstream source is deleted externally, three consecutive missing checks (about three monitor ticks) produce one `upstream_missing` signal for its watchers and remove the upstream link. Continuing the catalog session again creates a fresh link.
|
||||
|
||||
## Notices: one, not many
|
||||
|
||||
When a notify-eligible event lands and a watcher's cursor is behind, the watcher receives one system notice on its next turn:
|
||||
@@ -97,6 +103,13 @@ Current limits:
|
||||
- Notice delivery assumes one gateway process owns the shared state database. Multiple gateways share the durable log and `changesSince`, but v1 does not push notices across processes.
|
||||
- Compaction events cover the embedded runtime's compaction owners; native-harness-only compaction is not fully logged.
|
||||
- Cancelled-outcome payload detail is currently produced by ACP child runs; native sub-agent cancellations surface as generic failures.
|
||||
- Upstream self-echo detection compares normalized user text. An external prompt matching one of the session's 10 most recent OpenClaw-side user messages is treated as self-echo.
|
||||
- A single local Claude JSONL row larger than the 1 MiB per-cadence scan cap blocks that session's cursor in v1; unclassified bytes are never skipped.
|
||||
- Paired-node Claude checks classify the latest 50 transcript items per cadence. Larger bursts can fall outside the v1 scan window.
|
||||
- Paired-node Claude history reads do not expose a definitive thread-not-found result, so remote Claude deletions are not classified as `upstream_missing` in v1.
|
||||
- Catalog sessions that have not been adopted remain outside the awareness layer in v1.
|
||||
- Sessions adopted before this feature carry no upstream link; continue them from the catalog once to start upstream monitoring.
|
||||
- Upstream links assume each adopted session key maps to one owning agent (adoption uses the default store agent). Multi-agent adoption of the same external thread is not monitored in v1.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2164,6 +2164,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: list
|
||||
- H2: create
|
||||
- H2: show
|
||||
- H2: move
|
||||
- H2: dispatch
|
||||
- H2: Slash command parity
|
||||
- H2: Permissions
|
||||
@@ -4837,6 +4838,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: macOS app
|
||||
- H3: macOS user setting
|
||||
- H3: CLI helper (node invoke)
|
||||
- H2: Linux node host
|
||||
- H2: Safety + practical limits
|
||||
- H2: macOS screen video (OS-level)
|
||||
- H2: Related
|
||||
@@ -4912,6 +4914,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Permissions mapping (node.permissions)
|
||||
- H2: Command: location.get
|
||||
- H2: Background behavior
|
||||
- H2: Linux node host
|
||||
- H2: Model/tooling integration
|
||||
- H2: UX copy (suggested)
|
||||
- H2: Related
|
||||
@@ -5164,6 +5167,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- Headings:
|
||||
- H2: Desktop companion
|
||||
- H2: CLI and SSH alternative
|
||||
- H2: Node capabilities
|
||||
- H2: Install
|
||||
- H2: Gateway service (systemd)
|
||||
- H2: Memory pressure and OOM kills
|
||||
@@ -6509,6 +6513,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Surface
|
||||
- H2: Related docs
|
||||
|
||||
## plugins/reference/linux-node.md
|
||||
|
||||
- Route: /plugins/reference/linux-node
|
||||
- Headings:
|
||||
- H1: Linux Node plugin
|
||||
- H2: Distribution
|
||||
- H2: Surface
|
||||
|
||||
## plugins/reference/litellm.md
|
||||
|
||||
- Route: /plugins/reference/litellm
|
||||
@@ -7365,6 +7377,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H3: Capability registration
|
||||
- H3: Tools and commands
|
||||
- H3: Infrastructure
|
||||
- H4: Requester-scoped MCP connections
|
||||
- H3: Host hooks for workflow plugins
|
||||
- H3: Gateway discovery registration
|
||||
- H3: CLI registration metadata
|
||||
|
||||
@@ -125,9 +125,9 @@ All CLI backends live under `agents.defaults.cliBackends`, keyed by provider id
|
||||
|
||||
The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run.
|
||||
|
||||
Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.security: "full"` and `tools.exec.ask: "off"`) launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Per-agent `agents.list[].tools.exec` settings override the global `tools.exec` for that agent. Raw backend args may still include `--permission-mode`, but live Claude launches normalize that flag to match the effective policy.
|
||||
Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.security: "full"` and `tools.exec.ask: "off"`) normally launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Root-run gateways also use `default` because Claude Code rejects bypass mode for root; OpenClaw still answers Claude's stdio tool-control requests from the configured exec policy. Per-agent `agents.list[].tools.exec` settings override the global `tools.exec` for that agent. Raw backend args may still include `--permission-mode`, but live Claude launches normalize that flag to match the effective policy and host restriction.
|
||||
|
||||
The backend also maps OpenClaw `/think` levels to Claude Code's native `--effort` flag: `minimal`/`low` -> `low`, `medium` -> `medium`, and `high`/`xhigh`/`max` pass through directly. `adaptive` removes configured `--effort` flags and supplies no replacement, so Claude Code resolves effective effort from its own environment, settings, and model defaults. Other CLI backends need their owning plugin to declare an equivalent argv mapper before `/think` affects the spawned CLI.
|
||||
The backend also maps OpenClaw `/think` levels to Claude Code's native `--effort` flag: `minimal`/`low` -> `low`, `medium` -> `medium`, and `high`/`xhigh`/`max` pass through directly. This keeps the supported Fable 5 effort levels the same for subscription-backed Claude CLI and API-key routes. `adaptive` removes configured `--effort` flags and supplies no replacement, so Claude Code resolves effective effort from its own environment, settings, and model defaults. Other CLI backends need their owning plugin to declare an equivalent argv mapper before `/think` affects the spawned CLI.
|
||||
|
||||
Before OpenClaw can use `claude-cli`, Claude Code itself must be logged in on the same host:
|
||||
|
||||
|
||||
@@ -24,11 +24,12 @@ Cloud workers are opt-in and invisible until you configure a profile. Unconfigur
|
||||
| Live streaming into the sidebar | Gateway fanout, fed by the worker's replayable event stream |
|
||||
| Workspace git history | Authored on the box credential-free; the Gateway adopts commits and owns push/PR |
|
||||
|
||||
The box needs no inbound ports except `sshd` and no egress beyond what your setup command uses: the Gateway connects out via SSH and a reverse tunnel carries the worker's WebSocket back. No Tailscale or VPN required.
|
||||
The box needs no inbound ports except `sshd`: the Gateway connects out via pinned SSH, and a reverse tunnel carries the worker's WebSocket back. The bundled Crabbox provider forces the public SSH route and disables managed Tailscale enrollment. Outbound internet access is provider policy; the default AWS profile can reach the internet unless you restrict its network or security group.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A worker provider plugin. The bundled `crabbox` plugin drives the [Crabbox](https://github.com/openclaw/crabbox) CLI, which brokers leases across cloud backends (AWS, Hetzner, and others). The `crabbox` binary must be on `PATH` (or set `settings.binary`) with provider credentials already configured.
|
||||
- A worker provider plugin. The bundled `crabbox` plugin drives the [Crabbox](https://github.com/openclaw/crabbox) CLI, which brokers leases across cloud backends (AWS, Hetzner, and others). The `crabbox` binary must be on `PATH` (or set `settings.binary`) with provider credentials already configured. AWS admission requires Crabbox 0.38.1 or newer.
|
||||
- For Crabbox AWS workers, the effective `aws.instanceProfile` must be empty. The provider checks `crabbox config show --json` before allocation, then requires `crabbox inspect --json` to report `providerMetadata.instanceProfileAttached: false` from EC2 `DescribeInstances`. Leases with an instance role or without authoritative metadata are stopped and rejected.
|
||||
- Node.js on the leased machine. Bare cloud images usually lack it — install it in the profile's `setup` command.
|
||||
- A session with a session-owned managed worktree (create one with `worktree: true`). Dispatch moves that worktree's contents; plain directories sync as a manifest mirror.
|
||||
|
||||
@@ -58,12 +59,12 @@ Add a profile under `cloudWorkers.profiles` in `openclaw.json`:
|
||||
|
||||
Profile fields:
|
||||
|
||||
| Key | Meaning |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | Worker provider id registered by a plugin (`crabbox` for the bundled plugin). |
|
||||
| `install` | `bundle` (default) ships the running Gateway's build; `npm` installs the exact released Gateway version with pinned integrity. `npm` requires the Gateway to run from a packaged release. |
|
||||
| `settings` | Provider-owned JSON. For crabbox: `provider` (backend), `class` (machine class), `ttl`, `idleTimeout` (Go durations), optional `setup` and absolute `binary` path. |
|
||||
| `lifetime` | Optional stored policy (`idleTimeoutMinutes`, `maxLifetimeMinutes`). |
|
||||
| Key | Meaning |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `provider` | Worker provider id registered by a plugin (`crabbox` for the bundled plugin). |
|
||||
| `install` | `bundle` (default) ships the running Gateway's build; `npm` installs the exact released Gateway version with pinned integrity. `npm` requires the Gateway to run from a packaged release. |
|
||||
| `settings` | Provider-owned JSON. For crabbox: `provider` (backend), `class` (machine class), `ttl`, `idleTimeout` (Go durations), optional `setup` and absolute `binary` path. OpenClaw forces public SSH and disables managed Tailscale for these leases. |
|
||||
| `lifetime` | Optional stored policy (`idleTimeoutMinutes`, `maxLifetimeMinutes`). |
|
||||
|
||||
### The setup command
|
||||
|
||||
@@ -78,6 +79,8 @@ Profile fields:
|
||||
|
||||
Create a session with a managed worktree, then dispatch it (the RPC requires `operator.admin` and only exists when profiles are configured):
|
||||
|
||||
Cloud workers run the OpenClaw agent runtime. Choose an `openai/*` or other model that resolves to that runtime; sessions configured for an external CLI runtime such as `claude-cli` cannot dispatch.
|
||||
|
||||
```bash
|
||||
openclaw gateway call sessions.create \
|
||||
--params '{"key":"agent:main:big-refactor","worktree":true,"cwd":"/path/to/repo","worktreeName":"big-refactor"}'
|
||||
@@ -89,20 +92,23 @@ openclaw gateway call sessions.dispatch \
|
||||
|
||||
`sessions.dispatch` closes local turn admission, drains active work, provisions the lease, runs setup, bootstraps OpenClaw, syncs the workspace, and returns once the placement reaches `active` worker ownership. Budget several minutes for the first dispatch; leases and installs are cached where the provider supports it. After that, talk to the session as usual — turns route to the worker automatically.
|
||||
|
||||
Placement moves through a durable state machine (`local → requested → provisioning → syncing → starting → active`), so a Gateway restart mid-dispatch reconciles instead of leaking machines. Dispatch is one-way in v1: there is no pull-back RPC yet, and a failed worker turn fail-stops the placement with the worker's stderr tail preserved in the placement error for diagnosis.
|
||||
Placement moves through a durable state machine (`local → requested → provisioning → syncing → starting → active`), so a Gateway restart mid-dispatch reconciles instead of leaking machines. Dispatch is one-way in v1: there is no pull-back RPC yet. A failed worker turn fails that turn and keeps the active placement available for a retry; lifecycle failures instead move the placement to an error or reclaimed state and preserve their diagnostic tail.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Closed worker ingress.** Workers speak a dedicated protocol on the tunneled socket with a closed method allowlist — a worker cannot call operator RPCs.
|
||||
- **Minted credentials, hashed at rest.** Each dispatch mints a worker credential; the Gateway stores only its hash. Credential rotation and owner-epoch fencing guarantee at most one live owner per session — a stale worker that reconnects is fenced, never merged.
|
||||
- **Host-key pinning.** The provider must surface the box's SSH host key at provision time; bootstrap connects with strict pinning and fails closed without it.
|
||||
- **No secrets on the box.** Model auth stays on the Gateway (inference travels by `{provider, model}` reference), and workspace git commits are authored on the box without credentials.
|
||||
- **No standing model, forge, or cloud credentials on the box.** Model auth stays on the Gateway (inference travels by `{provider, model}` reference), workspace git commits are authored without forge credentials, and Crabbox AWS lease metadata is checked authoritatively for an instance role before setup. Keep setup commands credential-free too.
|
||||
- **Provider-owned egress.** The reverse tunnel removes any OpenClaw need for direct model access, but OpenClaw does not rewrite provider firewalls. Restrict outbound traffic in the worker provider when the task requires it.
|
||||
- **Durable, exactly-once transcripts.** The worker commits transcript batches through a compare-and-swap protocol against the session's leaf; a stale base fail-stops the run instead of duplicating or rebasing paid output.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`sessions.dispatch` is an unknown method** — no `cloudWorkers.profiles` are configured, or the caller lacks `operator.admin`.
|
||||
- **"Cloud worker turns require the OpenClaw runtime"** — choose a model whose configured runtime is OpenClaw. External CLI runtimes such as `claude-cli` do not support worker inference.
|
||||
- **"Worker bootstrap requires Node.js on the leased host"** — add a Node install to `settings.setup` (see above).
|
||||
- **AWS instance-role attestation fails** — clear `aws.instanceProfile` (and `CRABBOX_AWS_INSTANCE_PROFILE`, if set). Install Crabbox 0.38.1 or newer; older binaries do not expose the authoritative `providerMetadata.instanceProfileAttached` contract required for AWS admission.
|
||||
- **Dispatch fails with a provider error** — the placement record and `environments.list` keep the last error, including the setup/bootstrap stderr tail. Boxes are destroyed on failure, so that tail is the primary forensic.
|
||||
- **Client timeout while dispatching** — `openclaw gateway call` defaults to a 10s timeout; pass `--timeout` generously (dispatch keeps running server-side either way, and a retry while provisioning is rejected with `session cannot dispatch from placement provisioning`).
|
||||
- **Lease housekeeping** — `crabbox list --provider <backend>` shows live leases; `crabbox stop --provider <backend> --id <lease>` releases one manually. Idle leases expire on the profile's `idleTimeout`.
|
||||
|
||||
@@ -403,7 +403,7 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
- Object form sets primary plus ordered failover models.
|
||||
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise fall back to the agent's primary model, and narration stays off. Set `utilityModel: ""` to disable utility routing entirely. `agents.list[].utilityModel` overrides the default (an empty per-agent value disables it for that agent), and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements.
|
||||
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
|
||||
- Used by the `image` tool path as its vision-model config.
|
||||
- Used by the `image` tool path as its vision-model config when the active model cannot accept images. Native-vision models receive loaded image bytes directly instead.
|
||||
- Also used as fallback routing when the selected/default model cannot accept image input.
|
||||
- Prefer explicit `provider/model` refs. Bare IDs are accepted for compatibility; if a bare ID uniquely matches a configured image-capable entry in `models.providers.*.models`, OpenClaw qualifies it to that provider. Ambiguous configured matches require an explicit provider prefix.
|
||||
- `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
|
||||
|
||||
@@ -379,6 +379,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures.
|
||||
- `channels.discord.streaming` is the canonical stream mode key. Discord defaults to `streaming.mode: "progress"` so tool/work progress appears in one edited preview message; set `streaming.mode: "off"` to disable it. Legacy flat keys (`streamMode`, `chunkMode`, `blockStreaming`, `draftChunk`, `blockStreamingCoalesce`) are no longer read at runtime; run `openclaw doctor --fix` to migrate persisted config.
|
||||
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
|
||||
- `channels.discord.guilds.<id>.presenceEvents` routes human availability arrivals into one configured Discord channel as agent system events. It seeds current online members from complete `GUILD_CREATE` snapshots, routes observed offline-to-online transitions, and treats a first later online signal for an unseen member as newly available without asserting whether they came online or joined after the snapshot. Guilds above Discord's 75,000-member snapshot limit require an explicit offline update first. It requires `channels.discord.intents.presence=true`, the privileged Presence Intent in Discord's Developer Portal, and an enabled agent heartbeat.
|
||||
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
|
||||
- `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization.
|
||||
- `enabled`: `true`, `false`, or `"auto"` (default). In auto mode, exec approvals activate when approvers can be resolved from `approvers` or `commands.ownerAllowFrom`.
|
||||
|
||||
@@ -790,9 +790,9 @@ The bundled `crabbox` provider provisions an SSH-capable lease through the local
|
||||
|
||||
Unknown settings are rejected. Crabbox credentials and backend-specific account configuration remain owned by Crabbox; do not place them in `settings`. OpenClaw invokes only the local CLI and makes no provider network calls from this plugin. Provisioning always passes `--keep=true`; OpenClaw owns the external lifecycle and destroys the lease with `crabbox stop`.
|
||||
|
||||
<Warning>
|
||||
OpenClaw resolves Crabbox's lease-local `sshKey` path through the provider-owned secret resolver. Current `crabbox inspect --json` output does not expose a provisioned `sshHostKey`, so Crabbox-backed workers still fail closed before bootstrap or tunnel setup. Crabbox must provision an authoritative per-lease host key and return `sshHostKey` as exactly `algorithm base64`, without a hostname or comment. Its current lease-local `known_hosts` cache is not provisioning trust material.
|
||||
</Warning>
|
||||
<Note>
|
||||
OpenClaw resolves Crabbox's lease-local `sshKey` path through the provider-owned secret resolver and pins the authoritative `sshHostKey` returned by `crabbox inspect --json`. AWS admission also requires `providerMetadata.instanceProfileAttached`. Install Crabbox 0.38.1 or newer for this closed inspection contract.
|
||||
</Note>
|
||||
|
||||
### Static SSH development profile
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g.
|
||||
`node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry.
|
||||
The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh`
|
||||
path is not supported; use plugin surfaces.
|
||||
The snapshot's optional `appliedConfigHash` is the resolved source-config revision
|
||||
accepted by the active Gateway runtime. Clients can compare it with
|
||||
`config.get.configRevisionHash` to determine whether a newer saved config still
|
||||
needs a restart. `config.get.hash` remains the raw root-file revision used by
|
||||
config write conflict guards.
|
||||
|
||||
While the gateway is still finishing startup sidecars, `connect` can return a
|
||||
retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and
|
||||
@@ -487,7 +492,7 @@ methods. Treat this as feature discovery, not a full enumeration of
|
||||
<Accordion title="Secrets, config, update, and wizard">
|
||||
- `secrets.reload` re-resolves active SecretRefs and swaps runtime secret state only on full success.
|
||||
- `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
|
||||
- `config.get` returns the current config snapshot and hash.
|
||||
- `config.get` returns the current on-disk config snapshot, raw root-file `hash`, resolved `configRevisionHash`, and optional `appliedConfigHash` for the resolved revision accepted by the active Gateway runtime.
|
||||
- `config.set` writes a validated config payload.
|
||||
- `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.list[].skills`.
|
||||
- `config.apply` validates + replaces the full config payload.
|
||||
|
||||
@@ -759,7 +759,7 @@ OpenClaw loads workspace-local `.env` files for agents and tools, but never lets
|
||||
|
||||
- Provider credential environment variables are blocked from untrusted workspace `.env` files - for example `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY`, and provider auth keys declared by installed trusted plugins. Put provider credentials in the Gateway process environment, `~/.openclaw/.env` (`$OPENCLAW_STATE_DIR/.env`), the config `env` block, or an optional login-shell import instead.
|
||||
- Any key starting with `OPENCLAW_` is blocked from untrusted workspace `.env` files, reserving the whole runtime namespace so a future `OPENCLAW_*` control is fail-closed by default rather than silently inheritable from checked-in or attacker-supplied `.env` content.
|
||||
- Channel endpoint settings for Matrix, Mattermost, IRC, and Synology Chat are also blocked from workspace `.env` overrides (for example `MATRIX_HOMESERVER`, `MATTERMOST_URL`, `IRC_HOST`, `SYNOLOGY_CHAT_INCOMING_URL`), so a cloned workspace cannot redirect bundled connector traffic through local endpoint config. These must come from the gateway process environment or `env.shellEnv`.
|
||||
- Channel and provider endpoint-routing settings are also blocked from workspace `.env` overrides (for example `MATRIX_HOMESERVER`, `MATTERMOST_URL`, `IRC_HOST`, `SYNOLOGY_CHAT_INCOMING_URL`, `AZURE_SPEECH_ENDPOINT`, and other keys ending in `_ENDPOINT`), so a cloned workspace cannot redirect bundled connector traffic through local endpoint config. These must come from the gateway process environment, global runtime dotenv, explicit config, or `env.shellEnv`.
|
||||
- Trusted process/OS environment variables, global runtime dotenv, config `env`, and enabled login-shell import still apply - this only constrains workspace `.env` file loading.
|
||||
|
||||
Workspace `.env` files frequently live next to agent code, get committed by accident, or get written by tools; blocking provider credentials prevents a cloned workspace from substituting attacker-controlled provider accounts.
|
||||
|
||||
@@ -24,7 +24,7 @@ If the config file is missing entirely, step 4 is skipped; shell import still ru
|
||||
|
||||
## Provider credentials and workspace `.env`
|
||||
|
||||
Do not keep provider API keys only in a workspace `.env`. OpenClaw blocks a large set of provider credential and endpoint-redirect keys from workspace `.env` files, including every known provider auth env var (for example `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY`), plus any key ending in `_API_HOST`, `_BASE_URL`, or `_HOMESERVER`, and the entire `OPENCLAW_*`, `CLAWHUB_*`, `ANTHROPIC_API_KEY_*`, and `OPENAI_API_KEY_*` namespaces.
|
||||
Do not keep provider API keys only in a workspace `.env`. OpenClaw blocks a large set of provider credential and endpoint-redirect keys from workspace `.env` files, including every known provider auth env var (for example `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY`), plus any key ending in `_API_HOST`, `_BASE_URL`, `_ENDPOINT`, or `_HOMESERVER`, and the entire `OPENCLAW_*`, `CLAWHUB_*`, `ANTHROPIC_API_KEY_*`, and `OPENAI_API_KEY_*` namespaces.
|
||||
|
||||
Use one of these trusted sources for provider credentials instead:
|
||||
|
||||
@@ -33,7 +33,7 @@ Use one of these trusted sources for provider credentials instead:
|
||||
- The config `env` block in `~/.openclaw/openclaw.json`.
|
||||
- Optional login-shell import when `env.shellEnv.enabled` or `OPENCLAW_LOAD_SHELL_ENV=1` is enabled.
|
||||
|
||||
If you previously stored provider keys only in a workspace `.env`, move them to one of the trusted sources above. Workspace `.env` can still provide ordinary project variables that are not credentials, endpoint redirects, host overrides, or `OPENCLAW_*` runtime controls.
|
||||
If you previously stored provider keys or endpoint routing values only in a workspace `.env`, move them to one of the trusted sources above. Workspace `.env` can still provide ordinary project variables that are not credentials, endpoint redirects, host overrides, or `OPENCLAW_*` runtime controls.
|
||||
|
||||
See [Workspace `.env` files](/gateway/security#workspace-env-files) for the security rationale.
|
||||
|
||||
|
||||
+1
-1
@@ -900,7 +900,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
|
||||
- `.env` from the current working directory.
|
||||
- a global fallback `.env` from `~/.openclaw/.env` (`$OPENCLAW_STATE_DIR/.env`).
|
||||
|
||||
Neither `.env` file overrides existing env vars. Provider credential keys are an exception for workspace `.env`: keys such as `GEMINI_API_KEY`, `XAI_API_KEY`, or `MISTRAL_API_KEY` (and other bundled-provider auth env vars) are ignored from workspace `.env` and should live in the process environment, `~/.openclaw/.env`, or config `env`.
|
||||
Neither `.env` file overrides existing env vars. Provider credential and endpoint-routing keys are an exception for workspace `.env`: keys such as `GEMINI_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, or any key ending in `_ENDPOINT` (and other bundled-provider auth or endpoint env vars) are ignored from workspace `.env` and should live in the process environment, `~/.openclaw/.env`, or config `env`.
|
||||
|
||||
Inline env vars in config apply only if missing from the process env:
|
||||
|
||||
|
||||
+35
-3
@@ -1,12 +1,12 @@
|
||||
---
|
||||
summary: "Camera capture (iOS/Android nodes + macOS app) for agent use: photos (jpg) and short video clips (mp4)"
|
||||
summary: "Camera capture on iOS, Android, macOS, and Linux nodes for photos and short video clips"
|
||||
read_when:
|
||||
- Adding or modifying camera capture on iOS/Android nodes or macOS
|
||||
- Adding or modifying camera capture on node platforms
|
||||
- Extending agent-accessible MEDIA temp-file workflows
|
||||
title: "Camera capture"
|
||||
---
|
||||
|
||||
OpenClaw supports camera capture for agent workflows on paired **iOS**, **Android**, and **macOS** nodes: capture a photo (`jpg`) or a short video clip (`mp4`, with optional audio) via Gateway `node.invoke`.
|
||||
OpenClaw supports camera capture for agent workflows on paired **iOS**, **Android**, **macOS**, and **Linux** nodes: capture a photo (`jpg`) or a short video clip (`mp4`, with optional audio) via Gateway `node.invoke`.
|
||||
|
||||
All camera access is gated behind a user-controlled setting per platform.
|
||||
|
||||
@@ -124,6 +124,38 @@ openclaw nodes camera clip --node <id> --no-audio
|
||||
- `camera.snap` waits `delayMs` (default 2000ms, clamped to `[0, 10000]`) after warm-up/exposure settle before capturing.
|
||||
- Photo payloads are recompressed to keep base64 under 5MB.
|
||||
|
||||
## Linux node host
|
||||
|
||||
The bundled Linux Node plugin adds camera capture to the CLI `openclaw node` service. It works on a headless host and does not require the Linux desktop app.
|
||||
|
||||
Camera access defaults to off. Enable it under the plugin entry, then restart the node service so its Gateway advertisement is rebuilt:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"linux-node": {
|
||||
config: {
|
||||
camera: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- FFmpeg with V4L2 input, `libx264`, and AAC support
|
||||
- a `/dev/video*` device readable by the node-service user; on common distributions, add that user to the `video` group
|
||||
- for clips with the default `includeAudio: true`, a working PulseAudio server or PipeWire PulseAudio compatibility layer with a default source
|
||||
|
||||
Linux returns capture-capable, readable V4L2 device paths from `camera.list`; FFmpeg probes each `/dev/video*` candidate and omits metadata or output-only nodes. Device `position` is `unknown`, so facing requests without `deviceId` produce one `unknown`-position photo or clip instead of claiming a front or back camera. Use `deviceId` when a host has multiple cameras. `camera.snap` uses FFmpeg input warm-up for `delayMs` and preserves aspect ratio while limiting width. `camera.clip` records microphone audio as the MP4 audio track; OpenClaw deliberately exposes no standalone microphone command.
|
||||
|
||||
The plugin uses `libx264` for MP4 video and does not silently change codecs. An FFmpeg build without the required input or encoders returns `CAMERA_UNAVAILABLE`. Photos and clips that would exceed the 25MB base64 payload budget fail with `PAYLOAD_TOO_LARGE`.
|
||||
|
||||
`camera.snap` and `camera.clip` remain dangerous commands. Add them to `gateway.nodes.allowCommands` only when you intend to arm capture; enabling the plugin alone does not bypass Gateway policy.
|
||||
|
||||
## Safety + practical limits
|
||||
|
||||
- Camera and microphone access trigger the usual OS permission prompts (and require usage strings in `Info.plist`).
|
||||
|
||||
+16
-3
@@ -289,6 +289,9 @@ A desktop or server node can expose chat-capable models from an Ollama server ru
|
||||
The official `codex` plugin can expose non-archived Codex sessions on a
|
||||
headless node host or native macOS node. Catalog registration no longer depends
|
||||
on `supervision.enabled`; that option gates the agent-facing supervision tools.
|
||||
Set `sessionCatalog.enabled: false` in the Codex plugin config to disable the
|
||||
operator catalog and paired-node catalog commands without disabling the
|
||||
provider or harness.
|
||||
The plugin must still be active on both computers, and the node setting remains
|
||||
local consent: enabling only the Gateway cannot read another computer's Codex
|
||||
state.
|
||||
@@ -316,8 +319,11 @@ pagination, local continuation, and the metadata security boundary.
|
||||
### Claude sessions and transcripts
|
||||
|
||||
The bundled `anthropic` plugin discovers non-archived Claude CLI and Claude
|
||||
Desktop sessions on the Gateway and paired nodes. Unlike Codex supervision,
|
||||
this needs no separate opt-in: a remote macOS app node advertises
|
||||
Desktop sessions on the Gateway and paired nodes by default. Set
|
||||
`plugins.entries.anthropic.config.sessionCatalog.enabled: false` to disable the
|
||||
operator catalog and paired-node catalog commands without disabling Anthropic
|
||||
models or the Claude CLI backend.
|
||||
A remote macOS app node advertises
|
||||
`anthropic.claude.sessions.list.v1` and `anthropic.claude.sessions.read.v1`
|
||||
when the Anthropic plugin is enabled and `~/.claude/projects/` exists. Approve
|
||||
the node pairing upgrade when those commands first appear.
|
||||
@@ -382,7 +388,10 @@ catalogs on the Gateway and paired nodes. A node advertises
|
||||
`opencode.sessions.list.v1` / `opencode.sessions.read.v1` when the `opencode`
|
||||
CLI is installed, and `acpx.pi.sessions.list.v1` / `acpx.pi.sessions.read.v1`
|
||||
when Pi's session directory exists. Approve the node pairing upgrade when new
|
||||
commands first appear.
|
||||
commands first appear. When the matching CLI is also available, the node adds
|
||||
`opencode.terminal.resume.v1` or `acpx.pi.terminal.resume.v1`; the existing row
|
||||
menu and viewer header can then reopen the selected session in its owning
|
||||
terminal with `opencode --session <id>` or `pi --session <id>`.
|
||||
|
||||
OpenCode reads through its official CLI JSON/export surface. Pi reads its
|
||||
documented JSONL session store, including project and global `settings.json`
|
||||
@@ -390,6 +399,10 @@ session directories plus `PI_CODING_AGENT_DIR` and
|
||||
`PI_CODING_AGENT_SESSION_DIR` overrides. Both catalogs are enabled by default;
|
||||
turn them off in the Web UI under **Config > Plugins**.
|
||||
|
||||
Terminal resume uses the stored session working directory and the same
|
||||
allowlisted duplex PTY relay as Codex and Claude. It does not expose arbitrary
|
||||
node command execution.
|
||||
|
||||
## Invoking commands
|
||||
|
||||
Low-level (raw RPC):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Location command for nodes (location.get), permission modes, and Android foreground behavior"
|
||||
summary: "Location command for nodes, platform permission modes, and Linux GeoClue setup"
|
||||
read_when:
|
||||
- Adding location node support or permissions UI
|
||||
- Designing Android location permissions or foreground behavior
|
||||
@@ -86,6 +86,32 @@ Errors (stable codes):
|
||||
- Android Play builds and `While Using` mode deny `location.get` while backgrounded.
|
||||
- Other node platforms may differ.
|
||||
|
||||
## Linux node host
|
||||
|
||||
The bundled Linux Node plugin adds `location.get` to the CLI `openclaw node` service, including headless hosts without the Linux desktop app. Location defaults to off. Enable it under the plugin entry, then restart the node service:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"linux-node": {
|
||||
config: {
|
||||
location: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Install GeoClue2 and its `where-am-i` demo (`geoclue-2-demo` on Debian and Ubuntu). The node-service user must be allowed by the host's GeoClue policy and authorization agent.
|
||||
|
||||
The plugin uses `where-am-i` instead of a sequence of `busctl` calls. GeoClue ties client creation, properties, start, updates, and stop to one D-Bus client connection; the demo keeps that lifecycle together while separate `busctl` subprocesses do not. No npm dependency is added.
|
||||
|
||||
Linux maps `coarse`, `balanced`, and `precise` to GeoClue accuracy levels `4`, `6`, and `8`. It validates `maxAgeMs` against the returned timestamp. GeoClue's demo does not expose the selected provider, so `source` is `unknown`; `isPrecise` is true only when reported accuracy is 100 meters or better.
|
||||
|
||||
Linux uses the same stable errors: `LOCATION_DISABLED`, `LOCATION_TIMEOUT`, and `LOCATION_UNAVAILABLE`.
|
||||
|
||||
## Model/tooling integration
|
||||
|
||||
- Agent tool: the `nodes` tool's `location_get` action (node required).
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
summary: "Linux support + companion app status"
|
||||
read_when:
|
||||
- Looking for Linux companion app status
|
||||
- Enabling camera, location, or notifications on a Linux node host
|
||||
- Planning platform coverage or contributions
|
||||
- Debugging Linux OOM kills or exit 137 on a VPS or container
|
||||
title: "Linux app"
|
||||
@@ -56,6 +57,49 @@ The CLI remains the simplest option for a headless server, a VPS, or a remote Ga
|
||||
Full server guide: [Linux Server](/vps). Step-by-step VPS example:
|
||||
[exe.dev](/install/exe-dev).
|
||||
|
||||
## Node capabilities
|
||||
|
||||
The bundled Linux Node plugin gives the CLI `openclaw node` service device capabilities without requiring the desktop app. Commands are advertised to the Gateway only when their capability is enabled and the required local tool exists.
|
||||
|
||||
| Capability | Default | Requirement |
|
||||
| --------------------------------------- | ------- | --------------------------------------------------------------------- |
|
||||
| Desktop notifications (`system.notify`) | On | `notify-send` from libnotify and a desktop notification session |
|
||||
| Camera photos and clips (`camera.*`) | Off | FFmpeg, V4L2 camera access, and PulseAudio or PipeWire for clip audio |
|
||||
| Location (`location.get`) | Off | GeoClue2 and its `where-am-i` demo |
|
||||
|
||||
Configure the plugin in `openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"linux-node": {
|
||||
config: {
|
||||
notify: { enabled: true },
|
||||
camera: { enabled: true },
|
||||
location: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Restart the node service after changing these settings. Availability is determined once per process and the node advertisement is rebuilt on restart.
|
||||
|
||||
The Gateway approves the node's command and capability surface separately from device pairing. On first start, or after enabling more capabilities, approve the pending surface:
|
||||
|
||||
```bash
|
||||
openclaw nodes pending
|
||||
openclaw nodes approve <requestId>
|
||||
```
|
||||
|
||||
A node can be connected and device-paired while its effective `caps` and `commands` remain empty until this approval completes.
|
||||
|
||||
Camera devices must be readable by the service user, commonly through the `video` group. Camera clips use the default PulseAudio or PipeWire source when `includeAudio` is true; microphone audio exists only as that clip track, not as a standalone command. Location requires the node-service user to be permitted by the host's GeoClue policy.
|
||||
|
||||
`camera.snap` and `camera.clip` also require explicit Gateway arming through `gateway.nodes.allowCommands`. See [Camera capture](/nodes/camera) and [Location command](/nodes/location-command) for payloads, limits, and errors.
|
||||
|
||||
## Install
|
||||
|
||||
- [Getting Started](/start/getting-started)
|
||||
|
||||
@@ -579,6 +579,7 @@ Plugins can also launch background subagent runs through `api.runtime.subagent`:
|
||||
const result = await api.runtime.subagent.run({
|
||||
sessionKey: "agent:main:subagent:search-helper",
|
||||
message: "Expand this query into focused follow-up searches.",
|
||||
toolsAlsoAllow: ["my_plugin_progress"],
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
deliver: false,
|
||||
@@ -588,6 +589,7 @@ const result = await api.runtime.subagent.run({
|
||||
Notes:
|
||||
|
||||
- `provider` and `model` are optional per-run overrides, not persistent session changes.
|
||||
- `toolsAlsoAllow` accepts exact, uniquely owned tool names registered by the calling plugin. Core and ambiguous names are rejected. It is additive to the normal profile, but operator allowlists and denies remain authoritative.
|
||||
- OpenClaw only honors those override fields for trusted callers.
|
||||
- For plugin-owned fallback runs, operators must opt in with `plugins.entries.<id>.subagent.allowModelOverride: true`.
|
||||
- Use `plugins.entries.<id>.subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly.
|
||||
|
||||
@@ -46,12 +46,13 @@ Top-level fields:
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support, including opt-in access to connected account apps. See [Native Codex plugins](/plugins/codex-native-plugins). |
|
||||
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
|
||||
| `supervision` | disabled | Non-archived native-session catalog, local branch continuation, and agent-tool policy. See [Codex supervision](/plugins/codex-supervision). |
|
||||
| `sessionCatalog` | enabled | Native Codex session discovery for the sidebar. Set `enabled: false` to disable discovery without disabling the provider or harness. |
|
||||
| `supervision` | disabled | Agent-facing native-session transcript and write-control policy. See [Codex supervision](/plugins/codex-supervision). |
|
||||
|
||||
## Supervision
|
||||
|
||||
Supervision lists non-archived Codex sessions from the Gateway computer and
|
||||
opted-in paired nodes. Enable it independently from the agent harness:
|
||||
Native session discovery lists non-archived Codex sessions from the Gateway
|
||||
computer and opted-in paired nodes by default. Disable only that catalog with:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -60,8 +61,8 @@ opted-in paired nodes. Enable it independently from the agent harness:
|
||||
codex: {
|
||||
enabled: true,
|
||||
config: {
|
||||
supervision: {
|
||||
enabled: true,
|
||||
sessionCatalog: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -70,11 +71,11 @@ opted-in paired nodes. Enable it independently from the agent harness:
|
||||
}
|
||||
```
|
||||
|
||||
`supervision` fields:
|
||||
`supervision` separately controls agent-facing tools:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Advertise the local session catalog and, on the Gateway, aggregate opted-in paired-node catalogs for the Codex Sessions page. |
|
||||
| `enabled` | `false` | Enable agent-facing Codex supervision tools. This does not control the authenticated operator session catalog. |
|
||||
| `endpoints` | built-in local endpoint | Compatibility and advanced endpoint targets for the retained Codex supervision agent and standalone MCP tools. The human catalog and branch flow ignore these targets and use the supervision App Server resolved from `appServer`. |
|
||||
| `allowRawTranscripts` | `false` | With supervision enabled, allow autonomous agent or standalone MCP transcript reads and transcript-derived list fields. `codex_threads` metadata-only reads remain available. Does not control authenticated Control UI continuation. |
|
||||
| `allowWriteControls` | `false` | With supervision enabled, allow autonomous `codex_threads` fork, rename, archive, and unarchive mutations plus standalone MCP send, steer, and interrupt operations. Does not bypass other binding, host, status, or confirmation checks. |
|
||||
|
||||
@@ -153,9 +153,10 @@ stdio App Servers. Codex coordinates live writers inside one App Server, not
|
||||
across separate processes. Forking is the safe coexistence path for ordinary
|
||||
user-home stdio sessions.
|
||||
|
||||
`appServer.homeScope: "user"` alone does not enable the fleet catalog. Use
|
||||
`supervision.enabled: true` when you want native sessions to appear in the
|
||||
OpenClaw sidebar. Supervision uses a separate supervision connection; without
|
||||
`appServer.homeScope: "user"` alone does not control the fleet catalog. Native
|
||||
session discovery is enabled while the plugin is active; set
|
||||
`sessionCatalog.enabled: false` to remove it from the OpenClaw sidebar without
|
||||
disabling Codex. The catalog uses a separate supervision connection; without
|
||||
explicit `appServer` connection settings, that connection defaults to managed
|
||||
user-home stdio while the ordinary harness stays agent-scoped. Explicit
|
||||
`appServer` settings are honored by both paths. Set `homeScope: "user"`
|
||||
@@ -183,7 +184,7 @@ rules, paired-node limits, metadata exposure, and troubleshooting.
|
||||
| Need | Set | Where |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------- |
|
||||
| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config |
|
||||
| Show non-archived Codex sessions | `plugins.entries.codex.config.supervision.enabled: true` | Codex plugin config |
|
||||
| Hide native Codex session discovery | `plugins.entries.codex.config.sessionCatalog.enabled: false` | Codex plugin config |
|
||||
| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config |
|
||||
| Allow eligible OpenAI turns to use Codex implicitly | Exact official HTTPS Responses/ChatGPT route, no authored request override, runtime unset/`auto` | OpenAI provider/model config |
|
||||
| Sign in with ChatGPT/Codex OAuth | `openclaw models auth login --provider openai` | CLI auth profile |
|
||||
@@ -673,7 +674,8 @@ Supported top-level Codex plugin fields:
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. |
|
||||
| `supervision` | disabled | Non-archived native-session catalog, local branch continuation, and agent-tool policy. |
|
||||
| `sessionCatalog` | enabled | Sidebar discovery for native Codex sessions on this Gateway and eligible paired nodes. |
|
||||
| `supervision` | disabled | Agent-facing native-session transcript and write-control policy. |
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
|
||||
@@ -58,7 +58,10 @@ plugin activation succeeds. App Server availability is checked when
|
||||
supervision first connects. An explicit Codex plugin disable or policy block
|
||||
prevents opportunistic activation, and an existing explicit
|
||||
`supervision.enabled: false` disables agent-facing supervision tools; the
|
||||
operator catalog remains registered whenever the Codex plugin is active.
|
||||
operator catalog remains registered whenever the Codex plugin is active unless
|
||||
`sessionCatalog.enabled: false` disables it. This separate switch leaves the
|
||||
Codex provider, harness, and agent-facing supervision policy unchanged while
|
||||
also removing the paired-node catalog list/read commands from this host.
|
||||
Existing installations can enable the same capability manually:
|
||||
|
||||
Enable the `codex` plugin and its supervision capability in `openclaw.json`:
|
||||
@@ -96,6 +99,7 @@ Its private supervision binding uses the supervision connection for source
|
||||
reads, canonical branch creation, history injection, and every later turn. With
|
||||
the default local connection, that preserves the native user Codex home, auth,
|
||||
and provider configuration without changing the default for other sessions.
|
||||
Watched adopted Chats also participate in [session state awareness](/concepts/session-state).
|
||||
|
||||
For the default local supervision connection, the store is shared with native
|
||||
Codex clients. OpenClaw does not assume that another client shares the same live
|
||||
@@ -142,6 +146,13 @@ does not change a thread's native status to `offline`. Session rows use Codex
|
||||
statuses such as `idle`, `active`, `notLoaded`, or error. A failed host does not
|
||||
hide results from healthy hosts.
|
||||
|
||||
The sidebar warning includes the catalog error code and the safe underlying
|
||||
Gateway error. Open **Settings > Automation > Plugins > Codex > Native Session
|
||||
Discovery** to disable discovery without disabling Codex. For
|
||||
`NODE_LIST_FAILED`, compare `openclaw nodes list` and **Settings > Devices**;
|
||||
the detailed cause identifies the pairing-store, node-registry, permission, or
|
||||
Gateway lifecycle failure that needs repair.
|
||||
|
||||
## Use the operator CLI
|
||||
|
||||
The terminal CLI exposes the same non-archived catalog and Gateway-local branch
|
||||
|
||||
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Core npm package
|
||||
|
||||
66 plugins
|
||||
67 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
@@ -99,6 +99,8 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[imessage](/plugins/reference/imessage)** (`@openclaw/imessage`) - included in OpenClaw. Adds the iMessage channel surface for sending and receiving OpenClaw messages.
|
||||
|
||||
- **[linux-node](/plugins/reference/linux-node)** (`@openclaw/linux-node`) - included in OpenClaw. Desktop notifications, camera capture, and location for Linux node hosts.
|
||||
|
||||
- **[litellm](/plugins/reference/litellm)** (`@openclaw/litellm-provider`) - included in OpenClaw. Adds LiteLLM model provider support to OpenClaw.
|
||||
|
||||
- **[llm-task](/plugins/reference/llm-task)** (`@openclaw/llm-task`) - included in OpenClaw. Generic JSON-only LLM tool for structured tasks callable from workflows.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 139
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 140
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
summary: "Desktop notifications, camera capture, and location for Linux node hosts."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the linux-node plugin
|
||||
title: "Linux Node plugin"
|
||||
---
|
||||
|
||||
# Linux Node plugin
|
||||
|
||||
Desktop notifications, camera capture, and location for Linux node hosts.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/linux-node`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
plugin
|
||||
@@ -340,6 +340,25 @@ register(api) {
|
||||
}
|
||||
```
|
||||
|
||||
Long-lived services may emit small invalidation or lifecycle events through
|
||||
their service context:
|
||||
|
||||
```typescript
|
||||
api.registerService({
|
||||
id: "index-events",
|
||||
start(ctx) {
|
||||
ctx.gatewayEvents?.emit("changed", { revision: 1 }, { scope: "operator.read" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
OpenClaw namespaces this as `plugin.<plugin-id>.changed`. Event names are one
|
||||
lowercase segment, payloads must be bounded JSON, and the scope must be
|
||||
`operator.read`, `operator.write`, or `operator.admin`. The emitter exists only
|
||||
for the service lifetime and is revoked after stop or failed start. Prefer
|
||||
version or invalidation payloads over full records so authorized clients reread
|
||||
canonical state through the plugin's scoped Gateway methods.
|
||||
|
||||
Discovery mode builds a non-activating registry snapshot. It may still
|
||||
evaluate the plugin entry and the channel plugin object so OpenClaw can
|
||||
register channel capabilities and static CLI descriptors. Treat module
|
||||
|
||||
@@ -185,28 +185,99 @@ advertised node command.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Method | What it registers |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `api.registerHook(events, handler, opts?)` | Event hook |
|
||||
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
|
||||
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
|
||||
| `api.registerGatewayDiscoveryService(service)` | Local Gateway discovery advertiser |
|
||||
| `api.registerCli(registrar, opts?)` | CLI subcommand |
|
||||
| `api.registerNodeCliFeature(registrar, opts?)` | Node feature CLI under `openclaw nodes` |
|
||||
| `api.registerService(service)` | Background service |
|
||||
| `api.registerInteractiveHandler(registration)` | Interactive handler |
|
||||
| `api.registerAgentToolResultMiddleware(...)` | Runtime tool-result middleware |
|
||||
| `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section |
|
||||
| `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus |
|
||||
| `api.registerHostedMediaResolver(resolver)` | Resolver for browser-style hosted media URLs |
|
||||
| `api.registerTextTransforms(transforms)` | Plugin-owned prompt/message compatibility text rewrites |
|
||||
| `api.registerConfigMigration(migrate)` | Lightweight config migration run before plugin runtime loads |
|
||||
| `api.registerMigrationProvider(provider)` | Importer for `openclaw migrate` |
|
||||
| `api.registerAutoEnableProbe(probe)` | Config probe that can auto-enable this plugin |
|
||||
| `api.registerReload(registration)` | Restart/hot/noop config-prefix policy for reload handling |
|
||||
| `api.registerNodeHostCommand(command)` | Command handler exposed to paired nodes |
|
||||
| `api.registerNodeInvokePolicy(policy)` | Allowlist/approval policy for node-invoked commands |
|
||||
| `api.registerSecurityAuditCollector(collector)` | Findings collector for `openclaw security audit` |
|
||||
| Method | What it registers |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `api.registerHook(events, handler, opts?)` | Event hook |
|
||||
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
|
||||
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
|
||||
| `api.registerGatewayDiscoveryService(service)` | Local Gateway discovery advertiser |
|
||||
| `api.registerCli(registrar, opts?)` | CLI subcommand |
|
||||
| `api.registerNodeCliFeature(registrar, opts?)` | Node feature CLI under `openclaw nodes` |
|
||||
| `api.registerService(service)` | Background service |
|
||||
| `api.registerInteractiveHandler(registration)` | Interactive handler |
|
||||
| `api.registerAgentToolResultMiddleware(...)` | Runtime tool-result middleware |
|
||||
| `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section |
|
||||
| `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus |
|
||||
| `api.registerHostedMediaResolver(resolver)` | Resolver for browser-style hosted media URLs |
|
||||
| `api.registerMcpServerConnectionResolver(...)` | Per-requester MCP transport (`url`/`headers`) for a static server name |
|
||||
| `api.registerTextTransforms(transforms)` | Plugin-owned prompt/message compatibility text rewrites |
|
||||
| `api.registerConfigMigration(migrate)` | Lightweight config migration run before plugin runtime loads |
|
||||
| `api.registerMigrationProvider(provider)` | Importer for `openclaw migrate` |
|
||||
| `api.registerAutoEnableProbe(probe)` | Config probe that can auto-enable this plugin |
|
||||
| `api.registerReload(registration)` | Restart/hot/noop config-prefix policy for reload handling |
|
||||
| `api.registerNodeHostCommand(command)` | Command handler exposed to paired nodes |
|
||||
| `api.registerNodeInvokePolicy(policy)` | Allowlist/approval policy for node-invoked commands |
|
||||
| `api.registerSecurityAuditCollector(collector)` | Findings collector for `openclaw security audit` |
|
||||
|
||||
#### Requester-scoped MCP connections
|
||||
|
||||
Keep the MCP server **identity** static (name, tool filter) in `mcp.servers` or a
|
||||
bundle manifest. Optionally register a connection resolver so each trusted
|
||||
message requester gets their own transport:
|
||||
|
||||
```ts
|
||||
api.registerMcpServerConnectionResolver({
|
||||
serverName: "user-email",
|
||||
resolve: async (ctx) => {
|
||||
// ctx.requesterSenderId is host-trusted; never invent sender identity here.
|
||||
const token = await lookupUserToken(ctx.requesterSenderId);
|
||||
if (!token) {
|
||||
return null; // omit this server for the current run
|
||||
}
|
||||
return {
|
||||
url: "https://mcp.example.com/email",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Contract notes:
|
||||
|
||||
- Resolver context carries trusted host identity only (`requesterSenderId`,
|
||||
optional `agentAccountId` / `messageChannel`). Future trusted fields (for
|
||||
example cron/subagent user context) can be added additively.
|
||||
- One plugin owns one server name: a duplicate
|
||||
`registerMcpServerConnectionResolver` for the same `serverName` from another
|
||||
plugin is rejected with an error diagnostic (first registration wins), so
|
||||
connection ownership never depends on plugin load order.
|
||||
- Tool names are derived from the full declared server set so partial resolution
|
||||
never changes safe server names between requesters or turns. Core does not
|
||||
verify that different requester endpoints serve identical tool schemas; a
|
||||
resolver must point every requester at the same logical service, or tool
|
||||
schemas (and prompt-cache stability) diverge per requester.
|
||||
- Runs without a trusted `requesterSenderId` (cron, subagent, heartbeat, public
|
||||
gateway) never materialize requester-scoped servers. There is no shared
|
||||
fallback connection.
|
||||
- `resolve` is bounded at 10 seconds per server; a timeout or throw omits that
|
||||
server for the run without failing static MCP.
|
||||
- Resolved connections are revalidated at most every 5 minutes per requester:
|
||||
rotation rebuilds the transport with fresh credentials, and a `null` result
|
||||
revokes it (the cached runtime is disposed even mid-session). A revoked or
|
||||
rotated credential can therefore stay in use for up to 5 minutes.
|
||||
- Resolved `headers` are never logged or persisted; core keeps only an ephemeral
|
||||
in-memory keyed digest (process-local HMAC) to detect credential rotation, and
|
||||
registers resolved header/URL credential values with the log/debug-capture
|
||||
redaction registry.
|
||||
- Requester-scoped servers do not mint MCP App views: a view outlives the
|
||||
requester-authenticated run and the gateway view boundary has no requester
|
||||
identity, so app previews stay fail-closed for these servers. Tool results
|
||||
are unaffected.
|
||||
- Static servers without a resolver keep the existing session-scoped lifecycle.
|
||||
- **Harness delivery rule:** requester-scoped servers never enter harness-native
|
||||
MCP client config (Codex thread `mcp_servers`, CLI `-c mcp_servers=…`, or any
|
||||
other session-shared MCP projection). Harnesses deliver them as run-scoped
|
||||
tools instead:
|
||||
- Embedded runner: session MCP runtime + bundle tools (static + scoped).
|
||||
- Codex app-server: dynamic tools via
|
||||
`materializeRequesterScopedMcpToolsForHarnessRun` (scoped-only; static
|
||||
servers stay on Codex's native MCP client).
|
||||
- Scoped tool **specs** are session-stable after the first successful resolve in
|
||||
that session, so shared-thread harnesses (Codex) do not rotate threads when
|
||||
senders change. Before any requester resolves, no scoped specs are advertised.
|
||||
- Unauthenticated requesters on a shared-thread harness still see the advertised
|
||||
scoped tools; calling one returns a clean not-connected tool error for that
|
||||
requester. OpenClaw never falls back to another requester's credentials.
|
||||
|
||||
Memory prompt supplement builders receive optional `agentId`,
|
||||
`agentSessionKey`, and `sandboxed` context. Memory corpus supplement `search`
|
||||
|
||||
@@ -299,6 +299,7 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
const { runId } = await api.runtime.subagent.run({
|
||||
sessionKey: "agent:main:subagent:search-helper",
|
||||
message: "Expand this query into focused follow-up searches.",
|
||||
toolsAlsoAllow: ["my_plugin_progress"],
|
||||
provider: "openai", // optional override
|
||||
model: "gpt-5.6-sol", // optional override
|
||||
deliver: false,
|
||||
@@ -323,6 +324,8 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries.<id>.subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected.
|
||||
</Warning>
|
||||
|
||||
`toolsAlsoAllow` adds exact, uniquely owned tools registered by the calling plugin to the worker's normal tool surface. The runtime rejects core tools and names shared with another plugin. Profiles and operator tool policies still apply, including explicit allowlists and denies.
|
||||
|
||||
`deleteSession(...)` can delete sessions created by the same plugin through `api.runtime.subagent.run(...)`. Deleting arbitrary user or operator sessions still requires an admin-scoped Gateway request.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -332,10 +332,11 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, EnvHttpProxyAgent option, and pinned lookup helpers |
|
||||
| `plugin-sdk/runtime-fetch` | Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
|
||||
| `plugin-sdk/inline-image-data-url-runtime` | Inline image data URL sanitizer and signature sniffing helpers without the broad media runtime surface |
|
||||
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
|
||||
| `plugin-sdk/response-limit-runtime` | Byte-, idle-, and deadline-bounded response-body readers without the broad media runtime surface |
|
||||
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
|
||||
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
|
||||
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
|
||||
| `plugin-sdk/html-entity-runtime` | Single-pass semicolon-terminated HTML5 entity decoding without broad text utilities |
|
||||
| `plugin-sdk/text-utility-runtime` | Low-level text and path helpers, including five-entity HTML escaping |
|
||||
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
|
||||
| `plugin-sdk/retry-runtime` | Retry config and retry runner helpers |
|
||||
@@ -352,7 +353,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` and `saveMediaStream` |
|
||||
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
|
||||
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports |
|
||||
| `plugin-sdk/text-chunking` | Outbound text and offset-preserving range chunking, markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
|
||||
| `plugin-sdk/text-chunking` | Outbound text and offset-preserving range chunking, markdown chunking/render helpers, quote-aware HTML tag tokenization, markdown table conversion, directive-tag stripping, and safe-text utilities |
|
||||
| `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports |
|
||||
| `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and speech helper exports |
|
||||
| `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper |
|
||||
|
||||
@@ -87,6 +87,28 @@ operator see how a card moved through the board without opening the linked
|
||||
session; it is local operating context, not a replacement for session
|
||||
transcripts or GitHub issue history.
|
||||
|
||||
The plugin and Control UI use one Workboard card contract. Dashboard refreshes
|
||||
therefore preserve workspace provenance and authority, claim state, diagnostic
|
||||
actions, and notification sequence numbers instead of projecting a smaller
|
||||
UI-only copy of the card. Unknown diagnostic kinds, diagnostic severities, and
|
||||
notification kinds are ignored until both surfaces support them; they are never
|
||||
rewritten into another valid state.
|
||||
|
||||
The open dashboard updates from `plugin.workboard.changed` invalidations. Each
|
||||
event contains only a store epoch and revision; the UI then rereads canonical
|
||||
cards through the normal `operator.read` RPC. Multiple revisions coalesce into
|
||||
one follow-up read. Workboard defers that read while a card is being dragged,
|
||||
edited, or written, then resumes after the local interaction finishes. A
|
||||
reconnect always performs a canonical reload. There is no routine full-card
|
||||
poll, and **Refresh** remains available as manual recovery.
|
||||
|
||||
When more than one board exists, the toolbar includes a **Board** filter backed
|
||||
by persisted board metadata rather than only the currently visible cards. Empty
|
||||
and archived boards therefore remain selectable. Cards without an explicit
|
||||
board id belong to the canonical `default` board. The selected board is stored
|
||||
in the `?board=` query parameter, so the filtered Workboard URL can be bookmarked
|
||||
or shared; choosing **All boards** removes the parameter.
|
||||
|
||||
Cards are stored in the plugin's own Gateway state and move with the rest of
|
||||
that Gateway's OpenClaw state (see [Storage](#storage)).
|
||||
|
||||
@@ -136,6 +158,7 @@ rule as linked sessions (see [Session lifecycle sync](#session-lifecycle-sync)).
|
||||
| `workboard_promote` / `workboard_reassign` / `workboard_reclaim` | Recover or hand off stuck work. |
|
||||
| `workboard_comment` / `workboard_proof` | Add handoff notes or attach proof/artifact references. |
|
||||
| `workboard_unblock` | Move blocked work back to `todo`. |
|
||||
| `workboard_move` | Move a card to another status; claimed cards require the caller's agent claim scope. |
|
||||
| `workboard_dispatch` | Nudge dependency promotion or stale-claim cleanup without launching workers; worker launch uses Gateway or slash-command dispatch. |
|
||||
|
||||
Claimed cards reject agent-tool mutations from other agents unless the caller
|
||||
@@ -241,25 +264,28 @@ through the normal Workboard tools.
|
||||
openclaw workboard list [--board <id>] [--status <status>] [--include-archived] [--json]
|
||||
openclaw workboard create "Fix stale card lifecycle" --priority high --labels bug,workboard
|
||||
openclaw workboard show <card-id> [--json]
|
||||
openclaw workboard move <card-id> --status <status> [--json]
|
||||
openclaw workboard dispatch [--board <id>] [--json]
|
||||
```
|
||||
|
||||
`list` text output hides archived cards by default (`--include-archived`
|
||||
overrides); `--json` always includes archived cards, matching the full-card
|
||||
contract used by existing scripts. `show` accepts an unambiguous id prefix.
|
||||
`list`, `create`, and `show` always read/write local plugin state directly.
|
||||
Only `dispatch` calls the running Gateway, with the fallback described above.
|
||||
contract used by existing scripts. `show` and `move` accept an unambiguous id
|
||||
prefix. `list`, `create`, `show`, and `move` always read/write local plugin
|
||||
state directly. Only `dispatch` calls the running Gateway, with the fallback
|
||||
described above.
|
||||
|
||||
See [Workboard CLI](/cli/workboard) for full flags, JSON output, Gateway
|
||||
fallback behavior, id-prefix handling, dispatch selection rules, and
|
||||
troubleshooting.
|
||||
|
||||
`/workboard list`, `/workboard show <card-id>`, `/workboard create <title>`,
|
||||
and `/workboard dispatch` mirror the CLI. List and show are read operations
|
||||
for any authorized command sender. Create and dispatch require owner status on
|
||||
chat surfaces, or a Gateway client with `operator.write`/`operator.admin`.
|
||||
Their worktree access still follows the same workspace boundary described
|
||||
above.
|
||||
`/workboard move <card-id> --status <status>`, and `/workboard dispatch` mirror
|
||||
the CLI. List and show are read operations for any authorized command sender.
|
||||
Create, move, and dispatch require owner status on chat surfaces, or a Gateway
|
||||
client with `operator.write`/`operator.admin`. Manual operator moves use the
|
||||
same claim-override behavior as dashboard drag-and-drop. Their worktree access
|
||||
still follows the same workspace boundary described above.
|
||||
|
||||
## Session lifecycle sync
|
||||
|
||||
@@ -304,7 +330,8 @@ the template id is stored as card metadata.
|
||||
optional linked session - or open Sessions and choose **Add to Workboard**
|
||||
for an existing session.
|
||||
3. Drag the card between columns, or focus its compact status control and use
|
||||
the menu or ArrowLeft/ArrowRight.
|
||||
the menu or ArrowLeft/ArrowRight. During a drag, the source card dims and
|
||||
available drop columns gain an outline.
|
||||
4. Start work from the card to create or reuse a dashboard session.
|
||||
5. Open the linked session from the card while the agent works.
|
||||
6. Let lifecycle sync move running work into `review`/`blocked`, then manually
|
||||
|
||||
@@ -69,7 +69,7 @@ All options live under `messages.tts.providers["azure-speech"]`.
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `apiKey` | Azure Speech resource key. Falls back to `AZURE_SPEECH_KEY`, `AZURE_SPEECH_API_KEY`, or `SPEECH_KEY`. |
|
||||
| `region` | Azure Speech resource region. Falls back to `AZURE_SPEECH_REGION` or `SPEECH_REGION`. |
|
||||
| `endpoint` | Optional Azure Speech endpoint override. Falls back to `AZURE_SPEECH_ENDPOINT`. |
|
||||
| `endpoint` | Optional Azure Speech endpoint override. Falls back to trusted `AZURE_SPEECH_ENDPOINT`. |
|
||||
| `baseUrl` | Optional Azure Speech base URL override. |
|
||||
| `voice` | Azure voice ShortName (default `en-US-JennyNeural`). Legacy alias: `voiceId`. |
|
||||
| `lang` | SSML language code (default `en-US`). |
|
||||
@@ -79,7 +79,9 @@ All options live under `messages.tts.providers["azure-speech"]`.
|
||||
|
||||
The provider is considered configured once `apiKey` is set plus one of
|
||||
`region`, `endpoint`, or `baseUrl`. Env vars are only checked as a fallback
|
||||
for config keys left unset.
|
||||
for config keys left unset. Workspace `.env` files cannot set
|
||||
`AZURE_SPEECH_ENDPOINT`; use the process environment, global runtime dotenv,
|
||||
or explicit config for endpoint routing.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ identically-named `exec`/`wait` tools.
|
||||
## What it does
|
||||
|
||||
- The model-visible tool list becomes `exec`, `wait`, plus any direct-only tool
|
||||
such as `computer` whose image result cannot survive the guest bridge.
|
||||
such as `computer` or the native-vision `image` loader whose image result
|
||||
cannot survive the guest bridge.
|
||||
- `exec` evaluates model-generated JavaScript or TypeScript in an isolated
|
||||
QuickJS-WASI worker thread.
|
||||
- Every catalog-eligible enabled tool (OpenClaw core, plugin, MCP, client) is hidden from
|
||||
|
||||
+30
-28
@@ -10,19 +10,18 @@ title: "Tests"
|
||||
|
||||
## Agent default
|
||||
|
||||
Agent sessions run tests and computationally intensive validation remotely
|
||||
through Crabbox. Trusted maintainer code defaults to Blacksmith Testbox. The
|
||||
configured Testbox workflow hydrates credentials, so untrusted contributor or
|
||||
fork code must use secretless fork CI or sanitized direct AWS Crabbox instead.
|
||||
Agent sessions run one/few focused tests and cheap static checks locally only
|
||||
for trusted source and when the existing dependency install is ready. Never
|
||||
execute untrusted repository tooling locally. Larger suites, changed gates with
|
||||
typecheck/lint fan-out, builds, Docker, package lanes, E2E, live proof, and
|
||||
cross-platform validation run remotely through Crabbox. Trusted maintainer
|
||||
heavy proof defaults to Blacksmith Testbox. The configured Testbox workflow
|
||||
hydrates credentials, so untrusted contributor or fork code must use
|
||||
secretless fork CI or sanitized direct AWS Crabbox instead.
|
||||
|
||||
When a trusted code task is likely to need tests or heavy proof, pre-warm
|
||||
immediately in a background command session, keep working while it hydrates,
|
||||
reuse the returned `tbx_...` id, sync the current checkout on every run, and
|
||||
stop it before handoff:
|
||||
|
||||
```bash
|
||||
node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json
|
||||
```
|
||||
Do not pre-warm for anticipated work. Acquire the backend lazily when the
|
||||
first heavy command is ready, reuse the returned `tbx_...` id for later heavy
|
||||
commands, sync the current checkout on every run, and stop it before handoff.
|
||||
|
||||
After the first successful reuse, the wrapper records the lease's base,
|
||||
dependency, and Testbox workflow fingerprint under `.crabbox/testbox-leases/`.
|
||||
@@ -32,11 +31,11 @@ fresh lease. Every run still syncs the current checkout.
|
||||
`OPENCLAW_TESTBOX_ALLOW_STALE=1` is only for intentional diagnostics, not
|
||||
release proof.
|
||||
|
||||
Local test commands below are for human workflows or an explicit agent fallback
|
||||
requested by the user. Remote-provider unavailability must be reported; it is
|
||||
not permission to silently run a broad local gate.
|
||||
Local test commands below are for human workflows and bounded agent proof.
|
||||
Remote-provider unavailability must be reported; it is not permission to
|
||||
silently run a broad local gate.
|
||||
|
||||
For untrusted code, pre-warm with `--provider aws`. Every run must set
|
||||
For untrusted heavy proof, lazily warm with `--provider aws`. Every run must set
|
||||
`CRABBOX_ENV_ALLOW=CI`, pass `--provider aws --no-hydrate`, and use
|
||||
a fresh temporary remote `HOME` before installing dependencies or running
|
||||
tests. Use a newly warmed lease dedicated to that untrusted source; never reuse
|
||||
@@ -67,9 +66,12 @@ report public networking with no Tailscale state before uploading any script.
|
||||
In a Codex worktree or linked/sparse checkout, agents avoid direct local
|
||||
`pnpm test*` / `pnpm check*` / `pnpm crabbox:run`:
|
||||
|
||||
- Explicit user-requested local fallback for a tiny file:
|
||||
- Bounded focused proof with ready dependencies:
|
||||
`node scripts/run-vitest.mjs <path-or-filter>`.
|
||||
- Changed gates or broad proof: `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
|
||||
- Classify-first changed check: `node scripts/check-changed.mjs`; docs-only,
|
||||
no-change, and small metadata plans stay local when dependencies are ready,
|
||||
while heavy or dependency-missing plans delegate to Testbox.
|
||||
- Explicit kept-lease broad proof: `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox.
|
||||
- The wrapper's final `exitCode` and timing JSON are the command result. A delegated Blacksmith GitHub Actions run may show `cancelled` after a successful SSH command because the Testbox is stopped from outside the keepalive action; check the wrapper summary and command output before treating that as a failure.
|
||||
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
|
||||
|
||||
@@ -77,16 +79,16 @@ In a Codex worktree or linked/sparse checkout, agents avoid direct local
|
||||
|
||||
Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary; Vitest's own duration line stays the per-shard detail.
|
||||
|
||||
| Command | What it does |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm test` | Explicit file/directory targets route through scoped Vitest lanes. Untargeted runs are full-suite proof: fixed shard groups expand to leaf configs for local parallel execution, with the expected shard fanout printed before starting. The extension group always expands to per-extension shard configs instead of one giant root-project process. |
|
||||
| `pnpm test:changed` | Cheap smart changed-test run: precise targets from direct test edits, sibling `*.test.ts` files, explicit source mappings, and the local import graph. Broad/config/package changes are skipped unless they map to precise tests. |
|
||||
| `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` | Explicit broad changed-test run; use when a test harness/config/package edit should fall back to Vitest's broader changed-test behavior. |
|
||||
| `pnpm test:force` | Frees the configured OpenClaw gateway port (default `18789`), then runs the full suite with an isolated gateway port so server tests do not collide with a running instance. |
|
||||
| `pnpm test:coverage` | Emits an informational V8 coverage report for the default unit lane (`vitest.unit.config.ts`); no coverage thresholds are enforced. |
|
||||
| `pnpm test:coverage:changed` | Unit coverage only for files changed since `origin/main`. |
|
||||
| `pnpm changed:lanes` | Shows the architectural lanes triggered by the diff against `origin/main`. |
|
||||
| `pnpm check:changed` | Delegates to Crabbox/Testbox by default outside CI, then runs the smart changed check gate inside the remote child: formatting plus typecheck, lint, and guard commands for affected lanes. Does not run Vitest; use `pnpm test:changed` or `pnpm test <target>` for test proof. |
|
||||
| Command | What it does |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm test` | Explicit file/directory targets route through scoped Vitest lanes. Untargeted runs are full-suite proof: fixed shard groups expand to leaf configs for local parallel execution, with the expected shard fanout printed before starting. The extension group always expands to per-extension shard configs instead of one giant root-project process. |
|
||||
| `pnpm test:changed` | Cheap smart changed-test run: precise targets from direct test edits, sibling `*.test.ts` files, explicit source mappings, and the local import graph. Broad/config/package changes are skipped unless they map to precise tests. |
|
||||
| `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` | Explicit broad changed-test run; use when a test harness/config/package edit should fall back to Vitest's broader changed-test behavior. |
|
||||
| `pnpm test:force` | Frees the configured OpenClaw gateway port (default `18789`), then runs the full suite with an isolated gateway port so server tests do not collide with a running instance. |
|
||||
| `pnpm test:coverage` | Emits an informational V8 coverage report for the default unit lane (`vitest.unit.config.ts`); no coverage thresholds are enforced. |
|
||||
| `pnpm test:coverage:changed` | Unit coverage only for files changed since `origin/main`. |
|
||||
| `pnpm changed:lanes` | Shows the architectural lanes triggered by the diff against `origin/main`. |
|
||||
| `pnpm check:changed` | Classifies the changed lanes before choosing execution. Docs-only, no-change, and small metadata plans stay local when dependencies are ready; plans with typecheck/lint fan-out, other heavy lanes, or missing local dependencies delegate to Crabbox/Testbox outside CI. Does not run Vitest; use `pnpm test:changed` or `pnpm test <target>` for test proof. |
|
||||
|
||||
## Shared test state and process helpers
|
||||
|
||||
|
||||
@@ -21,8 +21,14 @@ Supervisor plugin or second Codex protocol implementation.
|
||||
|
||||
## Product boundary
|
||||
|
||||
The catalog registers whenever the Codex plugin is active. Enable agent-facing
|
||||
supervision tools with:
|
||||
The catalog registers whenever the Codex plugin is active unless native session
|
||||
discovery is explicitly disabled with:
|
||||
|
||||
```text
|
||||
plugins.entries.codex.config.sessionCatalog.enabled = false
|
||||
```
|
||||
|
||||
Enable agent-facing supervision tools with:
|
||||
|
||||
```text
|
||||
plugins.entries.codex.config.supervision.enabled = true
|
||||
@@ -54,7 +60,9 @@ backend passes its live check, independently of which primary backend the user
|
||||
selects. Supervision activates only when that opportunistic plugin setup
|
||||
succeeds. An explicit disabled plugin, policy block, or
|
||||
`supervision.enabled: false` remains authoritative for supervision tools, but
|
||||
does not disable the operator session catalog.
|
||||
does not disable the operator session catalog. `sessionCatalog.enabled: false`
|
||||
disables operator discovery and paired-node catalog commands; the Codex
|
||||
provider and harness remain active.
|
||||
|
||||
## Ownership
|
||||
|
||||
|
||||
@@ -178,9 +178,12 @@ plugins.
|
||||
| `/stop` | Abort the current run |
|
||||
| `/session idle <duration\|off>` | Manage thread-binding idle expiry |
|
||||
| `/session max-age <duration\|off>` | Manage thread-binding max-age expiry |
|
||||
| `/export-session [path]` | Export the current session to HTML. Alias: `/export` |
|
||||
| `/export-session [path]` | Owner-only. Export the current session to HTML inside the workspace. Alias: `/export` |
|
||||
| `/export-trajectory [path]` | Export a JSONL trajectory bundle for the current session. Alias: `/trajectory` |
|
||||
|
||||
Explicit `/export-session` paths replace existing files inside the
|
||||
workspace. Omit the path to generate a collision-safe filename.
|
||||
|
||||
<Note>
|
||||
Control UI intercepts typed `/new` to create and switch to a fresh
|
||||
dashboard session, except when `session.dmScope: "main"` is configured
|
||||
|
||||
@@ -47,14 +47,14 @@ describe("acpx plugin", () => {
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
const openKeyedStore = vi.fn();
|
||||
|
||||
const api = {
|
||||
const api = createTestPluginApi({
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
runtime: { state: { openKeyedStore } },
|
||||
runtime: { state: { openKeyedStore } } as never,
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
plugin.register(api as never);
|
||||
plugin.register(api);
|
||||
|
||||
expect(createAcpxRuntimeServiceMock).toHaveBeenCalledWith({
|
||||
pluginConfig: api.pluginConfig,
|
||||
@@ -75,14 +75,14 @@ describe("acpx plugin", () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
|
||||
const api = {
|
||||
const api = createTestPluginApi({
|
||||
pluginConfig: { timeoutSeconds: 180 },
|
||||
runtime: { state: { openKeyedStore: vi.fn() } },
|
||||
runtime: { state: { openKeyedStore: vi.fn() } } as never,
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
plugin.register(api as never);
|
||||
plugin.register(api);
|
||||
|
||||
expect(api.on).toHaveBeenCalledWith("reply_dispatch", expect.any(Function), {
|
||||
timeoutMs: 180_000,
|
||||
@@ -93,14 +93,14 @@ describe("acpx plugin", () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
|
||||
const api = {
|
||||
const api = createTestPluginApi({
|
||||
pluginConfig: {},
|
||||
runtime: {},
|
||||
runtime: {} as never,
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(() => plugin.register(api as never)).not.toThrow();
|
||||
expect(() => plugin.register(api)).not.toThrow();
|
||||
expect(api.registerService).toHaveBeenCalledWith(service);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// ACPX tests cover claude agent acp completion plugin behavior.
|
||||
import { ClaudeAcpAgent } from "@agentclientprotocol/claude-agent-acp";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
type IteratorResultResolver = (value: IteratorResult<unknown>) => void;
|
||||
|
||||
class ManualAsyncIterator implements AsyncIterator<unknown> {
|
||||
private readonly pending: IteratorResultResolver[] = [];
|
||||
private readonly queued: IteratorResult<unknown>[] = [];
|
||||
|
||||
next(): Promise<IteratorResult<unknown>> {
|
||||
const next = this.queued.shift();
|
||||
if (next) {
|
||||
return Promise.resolve(next);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.pending.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
push(value: unknown): void {
|
||||
this.resolve({ value, done: false });
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.resolve({ value: undefined, done: true });
|
||||
}
|
||||
|
||||
private resolve(value: IteratorResult<unknown>): void {
|
||||
const pending = this.pending.shift();
|
||||
if (pending) {
|
||||
pending(value);
|
||||
return;
|
||||
}
|
||||
this.queued.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
function makeResultMessage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
result: "finished",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIdleMessage() {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "session_state_changed",
|
||||
state: "idle",
|
||||
session_id: "session-1",
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createAgentWithSession(query: ManualAsyncIterator) {
|
||||
const agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: vi.fn(),
|
||||
extNotification: vi.fn(),
|
||||
} as unknown as ConstructorParameters<typeof ClaudeAcpAgent>[0]);
|
||||
agent.sessions["session-1"] = {
|
||||
cancelled: false,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
contextWindowSize: 200_000,
|
||||
cwd: "/tmp",
|
||||
emitRawSDKMessages: false,
|
||||
input: {
|
||||
push: vi.fn(),
|
||||
end: vi.fn(),
|
||||
},
|
||||
nextPendingOrder: 0,
|
||||
pendingMessages: new Map(),
|
||||
promptRunning: false,
|
||||
query,
|
||||
settingsManager: {
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as unknown as (typeof agent.sessions)[string];
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe("claude-agent-acp completion", () => {
|
||||
it("does not resolve a prompt on idle before the result message", async () => {
|
||||
const query = new ManualAsyncIterator();
|
||||
const agent = createAgentWithSession(query);
|
||||
|
||||
let resolved = false;
|
||||
const promptPromise = agent
|
||||
.prompt({
|
||||
sessionId: "session-1",
|
||||
prompt: [{ type: "text", text: "do work" }],
|
||||
})
|
||||
.then((value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
query.push(makeIdleMessage());
|
||||
await flushMicrotasks();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
query.push(makeResultMessage());
|
||||
const result = await promptPromise;
|
||||
expect(result.stopReason).toBe("end_turn");
|
||||
expect(result.usage?.inputTokens).toBe(1);
|
||||
expect(result.usage?.outputTokens).toBe(1);
|
||||
});
|
||||
|
||||
it("does not resolve a prompt after a task-notification result goes idle", async () => {
|
||||
const query = new ManualAsyncIterator();
|
||||
const agent = createAgentWithSession(query);
|
||||
|
||||
let resolved = false;
|
||||
const promptPromise = agent
|
||||
.prompt({
|
||||
sessionId: "session-1",
|
||||
prompt: [{ type: "text", text: "do foreground work" }],
|
||||
})
|
||||
.then((value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
query.push(makeResultMessage({ origin: { kind: "task-notification" } }));
|
||||
await flushMicrotasks();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
query.push(makeIdleMessage());
|
||||
await flushMicrotasks();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
query.push(makeResultMessage());
|
||||
const result = await promptPromise;
|
||||
expect(result.stopReason).toBe("end_turn");
|
||||
// Background task-notification usage stays out of the foreground prompt response.
|
||||
expect(result.usage?.inputTokens).toBe(1);
|
||||
expect(result.usage?.outputTokens).toBe(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user