mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 03:52:42 +08:00
Compare commits
93 Commits
fix/skip-e
...
script-to-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0571de523 | ||
|
|
af0c99be9b | ||
|
|
afa188f5ff | ||
|
|
d27f84153b | ||
|
|
2df4512b20 | ||
|
|
5de6ebf5fe | ||
|
|
7019da8c7b | ||
|
|
5a15ea1b5c | ||
|
|
38988d5395 | ||
|
|
d371112c41 | ||
|
|
34be976c6d | ||
|
|
e54c56962b | ||
|
|
c41bc58cf6 | ||
|
|
8ce486a3be | ||
|
|
9e5bebb1a2 | ||
|
|
b35b1f2b7c | ||
|
|
aa498cfe11 | ||
|
|
27e56828ad | ||
|
|
d8f2f5c884 | ||
|
|
1ee2733b2f | ||
|
|
dbcbafc208 | ||
|
|
21125352d8 | ||
|
|
baa389ebed | ||
|
|
5556f19b8c | ||
|
|
59fb685884 | ||
|
|
3c1b346115 | ||
|
|
3952ac9585 | ||
|
|
f83693490b | ||
|
|
cf79735a65 | ||
|
|
1579d833d6 | ||
|
|
d4f11d3005 | ||
|
|
62563c2cfc | ||
|
|
a7f96847ce | ||
|
|
014c4ae103 | ||
|
|
c85bd45284 | ||
|
|
402c85b07a | ||
|
|
c56a4aad85 | ||
|
|
076aa93356 | ||
|
|
405df6f166 | ||
|
|
45d7167ea2 | ||
|
|
d1169c3dd0 | ||
|
|
4d6befe7cd | ||
|
|
b45f65f90a | ||
|
|
64afc856bc | ||
|
|
63df9f7b11 | ||
|
|
019fb52411 | ||
|
|
6f981c494a | ||
|
|
dd92ea1319 | ||
|
|
d2491412f5 | ||
|
|
2ea7ed6b5a | ||
|
|
05bbcabacf | ||
|
|
bc1af44e7c | ||
|
|
a77d0b0acc | ||
|
|
38e03ef4b6 | ||
|
|
f2f975112d | ||
|
|
63b0e45e56 | ||
|
|
2b00b39da9 | ||
|
|
6c84475a50 | ||
|
|
275e835aa1 | ||
|
|
9ffd4c9f01 | ||
|
|
16a5d3b51a | ||
|
|
606f8ec669 | ||
|
|
73df6d48af | ||
|
|
e7aa2a66f2 | ||
|
|
ec3f76b380 | ||
|
|
aaa73a5ba2 | ||
|
|
d98394a865 | ||
|
|
aa4978e9ab | ||
|
|
6802eca299 | ||
|
|
1914cc35bd | ||
|
|
40bd375ef3 | ||
|
|
2ab883a7b8 | ||
|
|
97ce204d97 | ||
|
|
7a74bb280d | ||
|
|
2195b446d4 | ||
|
|
f3f2d398f6 | ||
|
|
45f9086d29 | ||
|
|
5053ce248c | ||
|
|
47cad606f4 | ||
|
|
731dfcc5f9 | ||
|
|
2e27a37791 | ||
|
|
9d04064e73 | ||
|
|
c05acc7a14 | ||
|
|
4e2351dd4d | ||
|
|
8b8b13417e | ||
|
|
38723a531d | ||
|
|
0e46fd1081 | ||
|
|
e2292d18e2 | ||
|
|
023ce6e96c | ||
|
|
39250bbe65 | ||
|
|
fb6df23a89 | ||
|
|
b3a422d987 | ||
|
|
e3b2c1c30a |
63
.github/workflows/security-sensitive-guard.yml
vendored
63
.github/workflows/security-sensitive-guard.yml
vendored
@@ -9,6 +9,11 @@ permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
# Temporary rollout bridge for PRs opened before this workflow's script landed.
|
||||
# Remove once the pre-rollout PR set has drained.
|
||||
OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA: 5d9c010628ea4de3492a12e32f9be5b8c5dfa9ed
|
||||
|
||||
concurrency:
|
||||
group: security-sensitive-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
@@ -19,13 +24,40 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security-sensitive guard rollout eligibility
|
||||
id: rollout
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
status="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
|
||||
--jq '.status'
|
||||
)"
|
||||
case "$status" in
|
||||
ahead|identical)
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
behind|diverged)
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out trusted base workflow scripts
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect security-sensitive changes
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
@@ -40,13 +72,40 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security-sensitive guard rollout eligibility
|
||||
id: rollout
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
status="$(
|
||||
gh api \
|
||||
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
|
||||
--jq '.status'
|
||||
)"
|
||||
case "$status" in
|
||||
ahead|identical)
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
behind|diverged)
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out trusted base workflow scripts
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce security-sensitive guard
|
||||
if: steps.rollout.outputs.ready == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
|
||||
@@ -110,7 +110,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
|
||||
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
|
||||
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- For iterative local commits, `scripts/committer --fast "message" <files...>` passes `FAST_COMMIT=1` through to the pre-commit hook so it skips the repo-wide `pnpm check`. Only use it when you've already run equivalent targeted validation for the touched surface.
|
||||
- For iterative local commits, `scripts/committer --fast "message" <files...>` skips commit hooks. Only use it when you've already run equivalent targeted validation for the touched surface.
|
||||
- For extension/plugin changes, run the fast local lane first:
|
||||
- `pnpm test:extension <extension-name>`
|
||||
- `pnpm test:extension --list` to see valid extension ids
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
b810f3b17d1eb746a6fbc4c45095a3b2bb3e08c5cd62a5928f9add2c59bb95b9 plugin-sdk-api-baseline.json
|
||||
36174a54f2a9e11b822f499b5659d0b1351198ce98112946d95283b0ee1032dd plugin-sdk-api-baseline.jsonl
|
||||
c84eab270f19d11a807ce71e783d35ee95a7620295dbffcca7fff31dacfcc882 plugin-sdk-api-baseline.json
|
||||
55656396a5f1941af61603402c43e23e0ffc90003e7efa7c1857c4541a0f1bb4 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -231,7 +231,7 @@ Retention and pruning are controlled in config:
|
||||
## Migrating older jobs
|
||||
|
||||
<Note>
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination.
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates `notify: true` webhook fallback jobs from `cron.webhook` to explicit webhook delivery. Jobs that already announce to a chat keep that delivery and get a completion webhook destination. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for jobs with no migration target (the existing delivery is preserved unchanged), so `doctor --fix` no longer keeps re-warning about them.
|
||||
</Note>
|
||||
|
||||
## Common edits
|
||||
|
||||
@@ -373,11 +373,11 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
|
||||
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
|
||||
- payload `provider` delivery aliases → explicit `delivery.channel`
|
||||
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook`; announce jobs keep their chat delivery and get `delivery.completionDestination`
|
||||
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from `cron.webhook` when set; announce jobs keep their chat delivery and get `delivery.completionDestination`. When `cron.webhook` is unset, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it
|
||||
|
||||
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before they are removed from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually.
|
||||
|
||||
Doctor and Gateway startup use the same `notify: true` migration before the scheduler runs. If `cron.webhook` is missing, doctor warns and leaves the legacy notify marker for manual repair.
|
||||
Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves the persisted cron config for doctor repair. When `cron.webhook` is unset, doctor removes the inert marker for jobs with no migration target (`delivery.mode` none/absent, an unusable webhook target, or existing announce/chat delivery), leaving the existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job. If `cron.webhook` is set but not a valid HTTP(S) URL, doctor still warns and leaves the marker so you can fix the URL.
|
||||
|
||||
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.
|
||||
|
||||
|
||||
@@ -335,6 +335,8 @@ the config fields that accept SecretRefs.
|
||||
- `BWS_ACCESS_TOKEN` available to the Gateway service.
|
||||
- `PATH` passed to the resolver, or `BWS_BIN` set to the absolute `bws`
|
||||
binary path.
|
||||
- `BWS_SERVER_URL` must be set in the environment when using a self-hosted
|
||||
Bitwarden instance.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -343,7 +345,7 @@ the config fields that accept SecretRefs.
|
||||
bws: {
|
||||
source: "exec",
|
||||
command: "/usr/local/bin/openclaw-bws-resolver.mjs",
|
||||
passEnv: ["BWS_ACCESS_TOKEN", "PATH", "BWS_BIN"],
|
||||
passEnv: ["BWS_ACCESS_TOKEN", "BWS_SERVER_URL", "PATH", "BWS_BIN"],
|
||||
jsonOnly: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -46,6 +46,29 @@ Docker is **optional**. Use it only if you want a containerized gateway or to va
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Airgapped rerun">
|
||||
On offline hosts, transfer and load the image first:
|
||||
|
||||
```bash
|
||||
docker load -i openclaw-image.tar
|
||||
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
|
||||
./scripts/docker/setup.sh --offline
|
||||
```
|
||||
|
||||
`--offline` verifies that `OPENCLAW_IMAGE` already exists locally, disables
|
||||
implicit Compose pulls and builds, then runs the normal setup flow such as
|
||||
`.env` synchronization, permission fixes, onboarding, gateway config sync,
|
||||
and Compose startup.
|
||||
|
||||
If `OPENCLAW_SANDBOX=1`, offline setup also checks the configured default
|
||||
and active per-agent sandbox images on the daemon behind
|
||||
`OPENCLAW_DOCKER_SOCKET`. Docker-backed browser images must also carry the
|
||||
current OpenClaw browser contract label. When a required image is missing or
|
||||
incompatible, setup exits without changing sandbox configuration instead of
|
||||
reporting success with an unusable sandbox.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Complete onboarding">
|
||||
The setup script runs onboarding automatically. It will:
|
||||
|
||||
|
||||
@@ -1097,11 +1097,10 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
|
||||
legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files and removes
|
||||
the imported sources. Plugin target writebacks update matching `cron_jobs`
|
||||
rows instead of loading and replacing the whole cron store.
|
||||
- Doctor and Gateway startup translate legacy `notify: true` webhook fallback
|
||||
into explicit SQLite delivery before the scheduler runs. Jobs that already
|
||||
announce to a chat keep that delivery and receive a webhook
|
||||
`completionDestination`; jobs without `cron.webhook` are reported for manual
|
||||
repair.
|
||||
- Gateway startup ignores legacy `notify: true` markers in the runtime
|
||||
projection. Doctor translates them into explicit SQLite delivery when
|
||||
`cron.webhook` is valid, removes inert markers when it is unset, and preserves
|
||||
them with a warning when the configured webhook is invalid.
|
||||
- Outbound and session delivery queues now store queue status, entry kind,
|
||||
session key, channel, target, account id, retry count, last attempt/error,
|
||||
recovery state, and platform-send markers as typed columns in the shared
|
||||
|
||||
@@ -190,4 +190,24 @@ describe("ClickClack gateway", () => {
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("clears running status when backlog polling fails", async () => {
|
||||
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
|
||||
await expect(startClickClackGatewayAccount(ctx)).rejects.toThrow("clickclack unavailable");
|
||||
|
||||
expect(ctx.setStatus).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
running: true,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
baseUrl: "https://clickclack.example",
|
||||
});
|
||||
expect(ctx.setStatus).toHaveBeenLastCalledWith({
|
||||
accountId: "default",
|
||||
running: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,62 +146,67 @@ export async function startClickClackGatewayAccount(
|
||||
});
|
||||
let afterCursor = "";
|
||||
let initialized = false;
|
||||
while (!ctx.abortSignal.aborted) {
|
||||
const backlog = await client.events(workspaceId, afterCursor);
|
||||
if (!initialized) {
|
||||
// First pass establishes the cursor without replaying historical backlog
|
||||
// into fresh gateway sessions.
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
}
|
||||
initialized = true;
|
||||
} else {
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const socket = client.websocket(workspaceId, afterCursor);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
resolve();
|
||||
};
|
||||
ctx.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
socket.on("message", (data) => {
|
||||
void (async () => {
|
||||
const event = parseSocketEvent(data);
|
||||
if (!event) {
|
||||
ctx.log?.warn?.(`[${account.accountId}] skipped malformed ClickClack websocket event`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
while (!ctx.abortSignal.aborted) {
|
||||
const backlog = await client.events(workspaceId, afterCursor);
|
||||
if (!initialized) {
|
||||
// First pass establishes the cursor without replaying historical backlog
|
||||
// into fresh gateway sessions.
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
}
|
||||
initialized = true;
|
||||
} else {
|
||||
for (const event of backlog) {
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId ?? "",
|
||||
botUserId: account.botUserId,
|
||||
});
|
||||
})().catch(reject);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
ctx.abortSignal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
}
|
||||
}
|
||||
const socket = client.websocket(workspaceId, afterCursor);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
socket.close();
|
||||
resolve();
|
||||
};
|
||||
ctx.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
socket.on("message", (data) => {
|
||||
void (async () => {
|
||||
const event = parseSocketEvent(data);
|
||||
if (!event) {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] skipped malformed ClickClack websocket event`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
client,
|
||||
event,
|
||||
botUserId: account.botUserId ?? "",
|
||||
});
|
||||
})().catch(reject);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
ctx.abortSignal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
}
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
}
|
||||
|
||||
@@ -1313,6 +1313,28 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
expect(shouldForceMessageTool(params)).toBe(false);
|
||||
});
|
||||
|
||||
it("can retain message in the registered schema when disabled for the current turn", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.disableMessageTool = true;
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
params.toolsAllow = [];
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setOpenClawCodingToolsFactoryForTests((options) =>
|
||||
options?.disableMessageTool ? [] : [createRuntimeDynamicTool("message")],
|
||||
);
|
||||
|
||||
const availableTools = await buildDynamicToolsForTest(params, workspaceDir);
|
||||
const registeredTools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
ignoreDisableMessageTool: true,
|
||||
ignoreRuntimePlan: true,
|
||||
});
|
||||
|
||||
expect(availableTools.map((tool) => tool.name)).not.toContain("message");
|
||||
expect(registeredTools.map((tool) => tool.name)).toContain("message");
|
||||
});
|
||||
|
||||
it("passes the live run session key to Codex dynamic tools when sandbox policy uses another key", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
|
||||
@@ -76,6 +76,7 @@ export type DynamicToolBuildParams = {
|
||||
pluginConfig: CodexPluginConfig;
|
||||
profilerEnabled?: boolean;
|
||||
forceHeartbeatTool?: boolean;
|
||||
ignoreDisableMessageTool?: boolean;
|
||||
ignoreRuntimePlan?: boolean;
|
||||
onYieldDetected: () => void;
|
||||
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
|
||||
@@ -203,6 +204,9 @@ export function formatCodexDynamicToolBuildStageSummary(
|
||||
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
|
||||
export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
const { params } = input;
|
||||
const messagePolicyParams = input.ignoreDisableMessageTool
|
||||
? { ...params, disableMessageTool: false }
|
||||
: params;
|
||||
if (params.disableTools) {
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
@@ -295,8 +299,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
requireExplicitMessageTarget:
|
||||
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(params),
|
||||
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
|
||||
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
onYield: (message) => {
|
||||
@@ -375,7 +379,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
transientWebSearchRestriction &&
|
||||
webSearchPolicy.persistentAllowed),
|
||||
);
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, messagePolicyParams);
|
||||
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
|
||||
toolBuildStages.mark("allowlist-filter");
|
||||
const normalizedTools = normalizeAgentRuntimeTools({
|
||||
|
||||
@@ -74,7 +74,11 @@ import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import * as sharedClientModule from "./shared-client.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
|
||||
import {
|
||||
buildTurnStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
startOrResumeThread,
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
function flushDiagnosticEvents() {
|
||||
return waitForDiagnosticEventsDrained();
|
||||
@@ -211,7 +215,7 @@ async function buildDynamicToolsForTest(
|
||||
options: Partial<
|
||||
Pick<
|
||||
Parameters<typeof testing.buildDynamicTools>[0],
|
||||
"forceHeartbeatTool" | "ignoreRuntimePlan"
|
||||
"forceHeartbeatTool" | "ignoreDisableMessageTool" | "ignoreRuntimePlan"
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
@@ -309,7 +313,7 @@ function createCodexToolBridgeForTest(
|
||||
tools,
|
||||
registeredTools,
|
||||
signal,
|
||||
directToolNames: testing.shouldForceMessageTool(params) ? ["message"] : [],
|
||||
directToolNames: testing.resolveCodexDynamicToolDirectNames(params),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1678,6 +1682,55 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(specNames(nextNormalBridge.specs)).toEqual(registeredToolNames);
|
||||
});
|
||||
|
||||
it("keeps message in the registered schema when disabled for an internal turn", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.disableMessageTool = true;
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
|
||||
const availableTools: RuntimeDynamicToolForTest[] = [];
|
||||
const registeredTools = [createRuntimeDynamicTool("message")];
|
||||
const bridge = createCodexToolBridgeForTest(params, availableTools, registeredTools);
|
||||
const normalParams = createParams(sessionFile, workspaceDir);
|
||||
normalParams.disableTools = false;
|
||||
normalParams.sourceReplyDeliveryMode = "message_tool_only";
|
||||
normalParams.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const normalTools = [createRuntimeDynamicTool("message")];
|
||||
const normalRegisteredTools = [createRuntimeDynamicTool("message")];
|
||||
const normalBridge = createCodexToolBridgeForTest(
|
||||
normalParams,
|
||||
normalTools,
|
||||
normalRegisteredTools,
|
||||
);
|
||||
|
||||
expect(bridge.availableSpecs.map((tool) => tool.name)).not.toContain("message");
|
||||
expect(bridge.specs.map((tool) => tool.name)).toContain("message");
|
||||
expect(codexDynamicToolsFingerprint(bridge.specs)).toBe(
|
||||
codexDynamicToolsFingerprint(normalBridge.specs),
|
||||
);
|
||||
await expect(
|
||||
bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw tool is not available for this turn: message",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => {
|
||||
testing.setOpenClawCodingToolsFactoryForTests((options) => [
|
||||
createRuntimeDynamicTool("message"),
|
||||
|
||||
@@ -778,6 +778,7 @@ export async function runCodexAppServerAttempt(
|
||||
pluginConfig,
|
||||
profilerEnabled,
|
||||
forceHeartbeatTool: true,
|
||||
ignoreDisableMessageTool: true,
|
||||
ignoreRuntimePlan: true,
|
||||
onYieldDetected: () => {
|
||||
yieldDetected = true;
|
||||
@@ -789,7 +790,7 @@ export async function runCodexAppServerAttempt(
|
||||
registeredTools,
|
||||
signal: runAbortController.signal,
|
||||
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
|
||||
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
|
||||
directToolNames: resolveCodexDynamicToolDirectNames(params),
|
||||
hookContext: {
|
||||
agentId: sessionAgentId,
|
||||
config: params.config,
|
||||
@@ -3186,6 +3187,13 @@ function handleApprovalRequest(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexDynamicToolDirectNames(params: EmbeddedRunAttemptParams): string[] {
|
||||
if (params.sourceReplyDeliveryMode !== "message_tool_only") {
|
||||
return [];
|
||||
}
|
||||
return ["message"];
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
buildCodexNativeHookRelayId,
|
||||
buildDeveloperInstructions,
|
||||
@@ -3199,6 +3207,7 @@ export const testing = {
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
shouldForceMessageTool,
|
||||
resolveCodexDynamicToolDirectNames,
|
||||
hasPendingDynamicToolTerminalDiagnostic,
|
||||
toTranscriptToolResultForTests: toTranscriptToolResult,
|
||||
withCodexStartupTimeout,
|
||||
|
||||
@@ -5,6 +5,14 @@ import { createToolFactoryHarness } from "./tool-factory-test-harness.js";
|
||||
|
||||
const createFeishuClientMock = vi.fn((creds: { appId?: string } | undefined) => ({
|
||||
__appId: creds?.appId,
|
||||
application: {
|
||||
scope: {
|
||||
list: vi.fn(async () => ({
|
||||
code: 0,
|
||||
data: { scopes: [] },
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function feishuClientAppId(callIndex: number): string | undefined {
|
||||
@@ -61,6 +69,28 @@ describe("feishu_doc account selection", () => {
|
||||
} as OpenClawPluginApi["config"];
|
||||
}
|
||||
|
||||
function createMixedToolConfig(): OpenClawPluginApi["config"] {
|
||||
return {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
a: {
|
||||
appId: "app-a",
|
||||
appSecret: "sec-a", // pragma: allowlist secret
|
||||
tools: { doc: false, scopes: false },
|
||||
},
|
||||
b: {
|
||||
appId: "app-b",
|
||||
appSecret: "sec-b", // pragma: allowlist secret
|
||||
tools: { doc: true, scopes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawPluginApi["config"];
|
||||
}
|
||||
|
||||
test("uses agentAccountId context when params omit accountId", async () => {
|
||||
const cfg = createDocEnabledConfig();
|
||||
|
||||
@@ -93,4 +123,44 @@ describe("feishu_doc account selection", () => {
|
||||
|
||||
expect(feishuClientAppId(-1)).toBe("app-a");
|
||||
});
|
||||
|
||||
test("rejects a disabled contextual account when another account enables docs", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const docTool = resolveTool("feishu_doc", { agentAccountId: "a" });
|
||||
const result = await docTool.execute("call-disabled", {
|
||||
action: "list_blocks",
|
||||
doc_token: "d",
|
||||
});
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("rejects an explicit disabled account override for docs", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const docTool = resolveTool("feishu_doc", { agentAccountId: "b" });
|
||||
const result = await docTool.execute("call-disabled", {
|
||||
action: "list_blocks",
|
||||
doc_token: "d",
|
||||
accountId: "a",
|
||||
});
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Doc tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("rejects a disabled contextual account when another account enables app scopes", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(createMixedToolConfig());
|
||||
registerFeishuDocTools(api);
|
||||
|
||||
const scopesTool = resolveTool("feishu_app_scopes", { agentAccountId: "a" });
|
||||
const result = await scopesTool.execute("call-disabled", {});
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu App Scopes tools are disabled for account "a"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1384,14 +1384,23 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
|
||||
|
||||
const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
|
||||
createFeishuToolClient({ api, executeParams: params, defaultAccountId });
|
||||
createFeishuToolClient({
|
||||
api,
|
||||
executeParams: params,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "doc", label: "Doc" },
|
||||
});
|
||||
|
||||
const getMediaMaxBytes = (
|
||||
params: { accountId?: string } | undefined,
|
||||
defaultAccountId?: string,
|
||||
) =>
|
||||
(resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
|
||||
?.mediaMaxMb ?? 30) *
|
||||
(resolveFeishuToolAccount({
|
||||
api,
|
||||
executeParams: params,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "doc", label: "Doc" },
|
||||
}).config?.mediaMaxMb ?? 30) *
|
||||
1024 *
|
||||
1024;
|
||||
|
||||
@@ -1584,7 +1593,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
parameters: Type.Object({}),
|
||||
async execute() {
|
||||
try {
|
||||
const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
|
||||
const result = await listAppScopes(
|
||||
createFeishuToolClient({
|
||||
api,
|
||||
defaultAccountId: ctx.agentAccountId,
|
||||
requiredTool: { family: "scopes", label: "App Scopes" },
|
||||
}),
|
||||
);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: formatErrorMessage(err) });
|
||||
|
||||
@@ -765,6 +765,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "drive", label: "Drive" },
|
||||
});
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
|
||||
@@ -145,6 +145,7 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "perm", label: "Perm" },
|
||||
});
|
||||
switch (p.action) {
|
||||
case "list":
|
||||
|
||||
@@ -119,6 +119,21 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool implicit fallback selects an account with wiki enabled", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { drive: true, wiki: false },
|
||||
toolsB: { wiki: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_wiki");
|
||||
await tool.execute("call", { action: "search" });
|
||||
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
@@ -190,6 +205,22 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("drive tool rejects a disabled contextual account when another account enables it", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { drive: false },
|
||||
toolsB: { drive: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuDriveTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_drive", { agentAccountId: "a" });
|
||||
const result = await tool.execute("call", { action: "unknown_action" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Drive tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("perm tool registers when only second account enables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
@@ -205,6 +236,38 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-b");
|
||||
});
|
||||
|
||||
test("perm tool rejects a disabled contextual account when another account enables it", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { perm: false },
|
||||
toolsB: { perm: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuPermTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_perm", { agentAccountId: "a" });
|
||||
const result = await tool.execute("call", { action: "unknown_action" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("perm tool rejects an explicit disabled account override", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { perm: false },
|
||||
toolsB: { perm: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuPermTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_perm", { agentAccountId: "b" });
|
||||
const result = await tool.execute("call", { action: "unknown_action", accountId: "a" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Perm tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("bitable tool registers when only second account enables it and routes to agentAccountId", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
@@ -386,6 +449,22 @@ describe("feishu tool account routing", () => {
|
||||
expect(lastClientAppId()).toBe("app-a");
|
||||
});
|
||||
|
||||
test("wiki tool rejects an explicit disabled account override", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
toolsA: { wiki: false },
|
||||
toolsB: { wiki: true },
|
||||
}),
|
||||
);
|
||||
registerFeishuWikiTools(api);
|
||||
|
||||
const tool = resolveTool("feishu_wiki", { agentAccountId: "b" });
|
||||
const result = await tool.execute("call", { action: "search", accountId: "a" });
|
||||
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
expect(result.details.error).toBe('Feishu Wiki tools are disabled for account "a"');
|
||||
});
|
||||
|
||||
test("does not silently fall back when the contextual account is real but uses non-env SecretRefs", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness({
|
||||
channels: {
|
||||
|
||||
@@ -12,11 +12,17 @@ import { resolveToolsConfig } from "./tools-config.js";
|
||||
import type { FeishuToolsConfig, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
type AccountAwareParams = { accountId?: string };
|
||||
type FeishuToolFamily = keyof FeishuToolsConfig;
|
||||
type FeishuToolRequirement = {
|
||||
family: FeishuToolFamily;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function resolveImplicitToolAccountId(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
requiredTool?: FeishuToolRequirement;
|
||||
}): string | undefined {
|
||||
const explicitAccountId = normalizeOptionalString(params.executeParams?.accountId);
|
||||
if (explicitAccountId) {
|
||||
@@ -45,6 +51,19 @@ function resolveImplicitToolAccountId(params: {
|
||||
return configuredDefaultAccountId;
|
||||
}
|
||||
|
||||
if (params.requiredTool && params.api.config) {
|
||||
for (const accountId of listFeishuAccountIds(params.api.config)) {
|
||||
const account = resolveFeishuAccount({ cfg: params.api.config, accountId });
|
||||
if (
|
||||
account.enabled &&
|
||||
account.configured &&
|
||||
resolveToolsConfig(account.config.tools)[params.requiredTool.family]
|
||||
) {
|
||||
return accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -52,20 +71,31 @@ export function resolveFeishuToolAccount(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
requiredTool?: FeishuToolRequirement;
|
||||
}): ResolvedFeishuAccount {
|
||||
if (!params.api.config) {
|
||||
throw new Error("Feishu config unavailable");
|
||||
}
|
||||
return resolveFeishuRuntimeAccount({
|
||||
const account = resolveFeishuRuntimeAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: resolveImplicitToolAccountId(params),
|
||||
});
|
||||
if (
|
||||
params.requiredTool &&
|
||||
!resolveToolsConfig(account.config.tools)[params.requiredTool.family]
|
||||
) {
|
||||
throw new Error(
|
||||
`Feishu ${params.requiredTool.label} tools are disabled for account "${account.accountId}"`,
|
||||
);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
export function createFeishuToolClient(params: {
|
||||
api: Pick<OpenClawPluginApi, "config">;
|
||||
executeParams?: AccountAwareParams;
|
||||
defaultAccountId?: string;
|
||||
requiredTool?: FeishuToolRequirement;
|
||||
}): Lark.Client {
|
||||
return createFeishuClient(resolveFeishuToolAccount(params));
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
api,
|
||||
executeParams: p,
|
||||
defaultAccountId,
|
||||
requiredTool: { family: "wiki", label: "Wiki" },
|
||||
});
|
||||
switch (p.action) {
|
||||
case "spaces":
|
||||
|
||||
@@ -44,6 +44,17 @@ export async function startGoogleChatGatewayAccount(ctx: {
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
let stopped = false;
|
||||
const markStopped = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
};
|
||||
if (
|
||||
isGoogleChatNativeApprovalClientEnabled({
|
||||
cfg: ctx.cfg,
|
||||
@@ -59,26 +70,28 @@ export async function startGoogleChatGatewayAccount(ctx: {
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
}
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
markStopped();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
markStopped();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Googlechat tests cover setup plugin behavior.
|
||||
import {
|
||||
createStartAccountContext,
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
@@ -383,6 +385,22 @@ describe("googlechat setup", () => {
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
|
||||
it("clears running status when monitor startup fails", async () => {
|
||||
hoisted.startGoogleChatMonitor.mockRejectedValue(new Error("webhook bind failed"));
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
|
||||
const task = startGoogleChatGatewayAccount(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(task).rejects.toThrow("webhook bind failed");
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGoogleChatAccount", () => {
|
||||
|
||||
@@ -315,6 +315,24 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("does not record running state when bot startup fails", async () => {
|
||||
createLineBotMock.mockImplementation(() => {
|
||||
throw new Error("line bot startup failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("line bot startup failed");
|
||||
|
||||
expect(getLineRuntimeState("default")?.running).not.toBe(true);
|
||||
expect(registerWebhookTargetWithPluginRouteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches shared-path webhook posts to the account matching the signature", async () => {
|
||||
const firstMonitor = await monitorLineProvider({
|
||||
channelAccessToken: "first-token",
|
||||
|
||||
@@ -175,15 +175,6 @@ export async function monitorLineProvider(
|
||||
throw new Error("LINE webhook mode requires a non-empty channel secret.");
|
||||
}
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const bot = createLineBot({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
@@ -473,6 +464,15 @@ export async function monitorLineProvider(
|
||||
},
|
||||
});
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
||||
|
||||
let stopped = false;
|
||||
|
||||
87
extensions/qa-channel/src/gateway.test.ts
Normal file
87
extensions/qa-channel/src/gateway.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Qa Channel tests cover gateway lifecycle behavior.
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startQaGatewayAccount } from "./gateway.js";
|
||||
import type { ChannelGatewayContext } from "./runtime-api.js";
|
||||
import type { ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
async function startJsonServer(
|
||||
handler: (req: { url?: string | undefined }) => { statusCode?: number; body: string },
|
||||
) {
|
||||
const server = createServer((req, res) => {
|
||||
const response = handler({ url: req.url });
|
||||
res.writeHead(response.statusCode ?? 200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
res.end(response.body);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server failed to bind");
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
async stop() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("qa-channel gateway", () => {
|
||||
const stops: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(stops.splice(0).map((stop) => stop()));
|
||||
});
|
||||
|
||||
it("clears running status when polling fails", async () => {
|
||||
const server = await startJsonServer(() => ({
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: "qa bus unavailable" }),
|
||||
}));
|
||||
stops.push(() => server.stop());
|
||||
const account: ResolvedQaChannelAccount = {
|
||||
accountId: "default",
|
||||
baseUrl: server.baseUrl,
|
||||
botDisplayName: "QA Bot",
|
||||
botUserId: "qa-bot",
|
||||
config: {},
|
||||
configured: true,
|
||||
enabled: true,
|
||||
pollTimeoutMs: 1,
|
||||
};
|
||||
const setStatus = vi.fn();
|
||||
|
||||
await expect(
|
||||
startQaGatewayAccount("qa-channel", "QA Channel", {
|
||||
abortSignal: new AbortController().signal,
|
||||
account,
|
||||
cfg: {},
|
||||
setStatus,
|
||||
} as unknown as ChannelGatewayContext<ResolvedQaChannelAccount>),
|
||||
).rejects.toThrow("qa bus unavailable");
|
||||
|
||||
expect(setStatus.mock.calls.map(([status]) => status)).toEqual([
|
||||
{
|
||||
accountId: "default",
|
||||
baseUrl: server.baseUrl,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
},
|
||||
{
|
||||
accountId: "default",
|
||||
running: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -48,9 +48,10 @@ export async function startQaGatewayAccount(
|
||||
if (!(error instanceof Error) || error.name !== "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
});
|
||||
}
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Qa Lab tests cover docker up plugin behavior.
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -8,31 +7,6 @@ import { runQaDockerUp } from "./docker-up.runtime.js";
|
||||
|
||||
type QaDockerUpDeps = NonNullable<Parameters<typeof runQaDockerUp>[1]>;
|
||||
|
||||
async function occupyPortOrAcceptExisting(port: number): Promise<{ close: () => Promise<void> }> {
|
||||
const server = createServer();
|
||||
const listening = await new Promise<boolean>((resolve, reject) => {
|
||||
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () => resolve(true));
|
||||
});
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
if (!listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHealthyDockerDeps(calls: string[]): QaDockerUpDeps {
|
||||
return {
|
||||
async runCommand(command, args, cwd) {
|
||||
@@ -163,7 +137,8 @@ describe("runQaDockerUp", () => {
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-up-"));
|
||||
const gatewayPort = 18789;
|
||||
const qaLabPort = 43124;
|
||||
const resolveHostPort = vi.fn(async (preferredPort: number) => {
|
||||
const resolveHostPort = vi.fn(async (preferredPort: number, pinned: boolean) => {
|
||||
expect(pinned).toBe(false);
|
||||
if (preferredPort === gatewayPort) {
|
||||
return 28001;
|
||||
}
|
||||
@@ -172,16 +147,12 @@ describe("runQaDockerUp", () => {
|
||||
}
|
||||
return preferredPort;
|
||||
});
|
||||
const gatewayPortReservation = await occupyPortOrAcceptExisting(18789);
|
||||
const qaLabPortReservation = await occupyPortOrAcceptExisting(43124);
|
||||
|
||||
try {
|
||||
const result = await runQaDockerUp(
|
||||
{
|
||||
repoRoot: "/repo/openclaw",
|
||||
outputDir,
|
||||
gatewayPort,
|
||||
qaLabPort,
|
||||
skipUiBuild: true,
|
||||
usePrebuiltImage: true,
|
||||
},
|
||||
@@ -202,9 +173,9 @@ describe("runQaDockerUp", () => {
|
||||
expect(result.qaLabUrl).not.toBe(`http://127.0.0.1:${qaLabPort}`);
|
||||
expect(result.gatewayUrl).toBe("http://127.0.0.1:28001/");
|
||||
expect(result.qaLabUrl).toBe("http://127.0.0.1:28002");
|
||||
expect(resolveHostPort).toHaveBeenCalledWith(gatewayPort, false);
|
||||
expect(resolveHostPort).toHaveBeenCalledWith(qaLabPort, false);
|
||||
} finally {
|
||||
await gatewayPortReservation.close();
|
||||
await qaLabPortReservation.close();
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -258,7 +229,7 @@ describe("runQaDockerUp", () => {
|
||||
`docker compose -f ${composeFile} up -d @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} ps --format json openclaw-qa-gateway @${repoRoot}`,
|
||||
`docker compose -f ${composeFile} ps -q openclaw-qa-gateway @${repoRoot}`,
|
||||
`docker inspect --format {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} gateway-container @${repoRoot}`,
|
||||
`docker inspect --format {{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}} gateway-container @${repoRoot}`,
|
||||
]);
|
||||
expect(fetchCalls).toEqual([
|
||||
"http://127.0.0.1:43124/healthz",
|
||||
|
||||
@@ -978,6 +978,47 @@ describe("buildQaRuntimeEnv", () => {
|
||||
expect([child.exitCode, child.signalCode]).not.toEqual([null, null]);
|
||||
});
|
||||
|
||||
it("does not trust an exited gateway wrapper while its process group is alive", async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 12346,
|
||||
exitCode: 0 as number | null,
|
||||
signalCode: null as string | null,
|
||||
kill: vi.fn(),
|
||||
});
|
||||
let sawForceKill = false;
|
||||
let postKillLivenessChecks = 0;
|
||||
const processKill = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
|
||||
if (signal === "SIGKILL") {
|
||||
sawForceKill = true;
|
||||
return true;
|
||||
}
|
||||
if (signal === 0 && sawForceKill) {
|
||||
postKillLivenessChecks += 1;
|
||||
if (postKillLivenessChecks >= 2) {
|
||||
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
await testing.stopQaGatewayChildProcessTree(
|
||||
child as unknown as Parameters<typeof testing.stopQaGatewayChildProcessTree>[0],
|
||||
{
|
||||
gracefulTimeoutMs: 1,
|
||||
forceTimeoutMs: 50,
|
||||
},
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(processKill).toHaveBeenCalledWith(-12346, "SIGTERM");
|
||||
expect(processKill).toHaveBeenCalledWith(-12346, "SIGKILL");
|
||||
expect(postKillLivenessChecks).toBe(2);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats bind collisions as retryable gateway startup errors", () => {
|
||||
expect(
|
||||
testing.isRetryableGatewayStartupError(
|
||||
|
||||
@@ -354,6 +354,28 @@ function hasChildExited(child: ChildProcess) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
function isProcessAlreadyExitedError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === "ESRCH";
|
||||
}
|
||||
|
||||
function isQaGatewayChildProcessTreeAlive(child: ChildProcess) {
|
||||
if (!child.pid) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isProcessAlreadyExitedError(error)) {
|
||||
return false;
|
||||
}
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
}
|
||||
|
||||
function signalQaGatewayChildProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
@@ -374,22 +396,21 @@ function signalQaGatewayChildProcessTree(child: ChildProcess, signal: NodeJS.Sig
|
||||
}
|
||||
|
||||
async function waitForQaGatewayChildExit(child: ChildProcess, timeoutMs: number) {
|
||||
if (hasChildExited(child)) {
|
||||
return true;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() <= deadline) {
|
||||
if (!isQaGatewayChildProcessTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(Math.min(25, Math.max(0, deadline - Date.now())));
|
||||
}
|
||||
return await Promise.race([
|
||||
new Promise<boolean>((resolve) => {
|
||||
child.once("exit", () => resolve(true));
|
||||
}),
|
||||
sleep(timeoutMs).then(() => false),
|
||||
]);
|
||||
return !isQaGatewayChildProcessTreeAlive(child);
|
||||
}
|
||||
|
||||
async function stopQaGatewayChildProcessTree(
|
||||
child: ChildProcess,
|
||||
opts?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
|
||||
) {
|
||||
if (hasChildExited(child)) {
|
||||
if (!isQaGatewayChildProcessTreeAlive(child)) {
|
||||
return;
|
||||
}
|
||||
signalQaGatewayChildProcessTree(child, "SIGTERM");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readQaJsonBody } from "./bus-server.js";
|
||||
import {
|
||||
startQaLabServer,
|
||||
@@ -12,7 +12,23 @@ import {
|
||||
type QaLabServerStartParams,
|
||||
} from "./lab-server.js";
|
||||
|
||||
vi.mock("@openclaw/qa-channel/api.js", async () => await import("../../qa-channel/api.js"));
|
||||
const qaChannelMock = vi.hoisted(() => ({
|
||||
resolveAccount: vi.fn(),
|
||||
setRuntime: vi.fn(),
|
||||
startAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
qaChannelPlugin: {
|
||||
config: {
|
||||
resolveAccount: qaChannelMock.resolveAccount,
|
||||
},
|
||||
gateway: {
|
||||
startAccount: qaChannelMock.startAccount,
|
||||
},
|
||||
},
|
||||
setQaChannelRuntime: qaChannelMock.setRuntime,
|
||||
}));
|
||||
|
||||
const captureMock = vi.hoisted(() => {
|
||||
const sessions: Array<Record<string, unknown>> = [];
|
||||
@@ -150,6 +166,31 @@ async function startQaLabServerForTest(params?: QaLabServerStartParams) {
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
qaChannelMock.resolveAccount.mockReset();
|
||||
qaChannelMock.resolveAccount.mockImplementation((_cfg: unknown, accountId: string) => ({
|
||||
accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
}));
|
||||
qaChannelMock.setRuntime.mockReset();
|
||||
qaChannelMock.startAccount.mockReset();
|
||||
qaChannelMock.startAccount.mockImplementation(
|
||||
async ({ abortSignal }: { abortSignal?: AbortSignal }) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!abortSignal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
captureMock.reset();
|
||||
while (cleanups.length > 0) {
|
||||
@@ -289,6 +330,51 @@ async function createQaLabRepoRootFixture(params?: {
|
||||
}
|
||||
|
||||
describe("qa-lab server", () => {
|
||||
it("cleans up capture state when embedded gateway setup fails", async () => {
|
||||
qaChannelMock.resolveAccount.mockImplementationOnce(() => {
|
||||
throw new Error("embedded setup failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
startQaLabServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
}),
|
||||
).rejects.toThrow("embedded setup failed");
|
||||
|
||||
expect(captureMock.store.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes the server and capture state when embedded gateway stop fails", async () => {
|
||||
qaChannelMock.startAccount.mockImplementationOnce(
|
||||
async ({ abortSignal }: { abortSignal?: AbortSignal }) =>
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (!abortSignal) {
|
||||
return;
|
||||
}
|
||||
if (abortSignal.aborted) {
|
||||
reject(new Error("gateway stop failed"));
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("gateway stop failed")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const lab = await startQaLabServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
|
||||
await expect(lab.stop()).rejects.toThrow("gateway stop failed");
|
||||
|
||||
expect(captureMock.store.close).toHaveBeenCalledTimes(1);
|
||||
await expect(fetch(`${lab.baseUrl}/healthz`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("serves bootstrap state and message state", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-"));
|
||||
cleanups.push(async () => {
|
||||
|
||||
@@ -168,6 +168,10 @@ function createQaLabConfig(baseUrl: string): OpenClawConfig {
|
||||
return createQaChannelGatewayConfig({ baseUrl });
|
||||
}
|
||||
|
||||
function normalizeQaLabCleanupError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(formatErrorMessage(error));
|
||||
}
|
||||
|
||||
async function startQaGatewayLoop(params: { state: QaBusState; baseUrl: string }) {
|
||||
const runtime = createQaRunnerRuntime();
|
||||
setQaChannelRuntime(runtime);
|
||||
@@ -242,7 +246,10 @@ export async function startQaLabServer(
|
||||
| undefined;
|
||||
const embeddedGatewayEnabled = params?.embeddedGateway !== "disabled";
|
||||
let labHandle: QaLabServerHandle | null = null;
|
||||
let captureStoreReleased = false;
|
||||
let serverListening = false;
|
||||
|
||||
let listenUrl = "";
|
||||
let publicBaseUrl = "";
|
||||
let runnerModelCatalogPromise: Promise<void> | null = null;
|
||||
let runnerModelCatalogAbort: AbortController | null = null;
|
||||
@@ -628,82 +635,107 @@ export async function startQaLabServer(
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("qa-lab failed to bind");
|
||||
}
|
||||
const listenUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
});
|
||||
publicBaseUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
advertiseHost: params?.advertiseHost,
|
||||
advertisePort: params?.advertisePort,
|
||||
});
|
||||
if (embeddedGatewayEnabled) {
|
||||
gateway = await startQaGatewayLoop({ state, baseUrl: listenUrl });
|
||||
}
|
||||
if (params?.sendKickoffOnStart) {
|
||||
injectKickoffMessage({
|
||||
state,
|
||||
defaults: bootstrapDefaults,
|
||||
kickoffTask: scenarioCatalog.kickoffTask,
|
||||
});
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (!controlUiProxyTarget || !isControlUiProxyPath(url.pathname)) {
|
||||
socket.destroy();
|
||||
const releaseCaptureStore = () => {
|
||||
if (captureStoreReleased) {
|
||||
return;
|
||||
}
|
||||
proxyUpgradeRequest({
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
target: controlUiProxyTarget,
|
||||
authorizationToken: controlUiProxyToken,
|
||||
});
|
||||
});
|
||||
|
||||
const lab = {
|
||||
baseUrl: publicBaseUrl,
|
||||
listenUrl,
|
||||
state,
|
||||
setControlUi(next: {
|
||||
controlUiUrl?: string | null;
|
||||
controlUiProxyToken?: string | null;
|
||||
controlUiProxyTarget?: string | null;
|
||||
}) {
|
||||
controlUiUrl = sanitizeControlUiPublicUrl(next.controlUiUrl?.trim() || null);
|
||||
controlUiProxyToken = next.controlUiProxyToken?.trim() || null;
|
||||
controlUiProxyTarget = next.controlUiProxyTarget?.trim()
|
||||
? new URL(next.controlUiProxyTarget)
|
||||
: null;
|
||||
},
|
||||
setScenarioRun(next: Omit<QaLabScenarioRun, "counts"> | null) {
|
||||
latestScenarioRun = next ? withQaLabRunCounts(next) : null;
|
||||
},
|
||||
setLatestReport(next: QaLabLatestReport | null) {
|
||||
latestReport = next;
|
||||
},
|
||||
runSelfCheck,
|
||||
async stop() {
|
||||
runnerModelCatalogAbort?.abort();
|
||||
await runnerModelCatalogPromise?.catch(() => undefined);
|
||||
await gateway?.stop();
|
||||
await closeQaHttpServer(server);
|
||||
captureStoreLease.release();
|
||||
},
|
||||
captureStoreReleased = true;
|
||||
captureStoreLease.release();
|
||||
};
|
||||
labHandle = lab;
|
||||
return lab;
|
||||
|
||||
const stopLabServerResources = async (): Promise<Error | undefined> => {
|
||||
runnerModelCatalogAbort?.abort();
|
||||
await runnerModelCatalogPromise?.catch(() => undefined);
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => gateway?.stop()),
|
||||
Promise.resolve().then(() => (serverListening ? closeQaHttpServer(server) : undefined)),
|
||||
Promise.resolve().then(releaseCaptureStore),
|
||||
]);
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
return failed ? normalizeQaLabCleanupError(failed.reason) : undefined;
|
||||
};
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(params?.port ?? 0, params?.host ?? "127.0.0.1", () => resolve());
|
||||
});
|
||||
serverListening = true;
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("qa-lab failed to bind");
|
||||
}
|
||||
listenUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
});
|
||||
publicBaseUrl = resolveAdvertisedBaseUrl({
|
||||
bindHost: params?.host ?? "127.0.0.1",
|
||||
bindPort: address.port,
|
||||
advertiseHost: params?.advertiseHost,
|
||||
advertisePort: params?.advertisePort,
|
||||
});
|
||||
if (embeddedGatewayEnabled) {
|
||||
gateway = await startQaGatewayLoop({ state, baseUrl: listenUrl });
|
||||
}
|
||||
if (params?.sendKickoffOnStart) {
|
||||
injectKickoffMessage({
|
||||
state,
|
||||
defaults: bootstrapDefaults,
|
||||
kickoffTask: scenarioCatalog.kickoffTask,
|
||||
});
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (!controlUiProxyTarget || !isControlUiProxyPath(url.pathname)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
proxyUpgradeRequest({
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
target: controlUiProxyTarget,
|
||||
authorizationToken: controlUiProxyToken,
|
||||
});
|
||||
});
|
||||
|
||||
const lab = {
|
||||
baseUrl: publicBaseUrl,
|
||||
listenUrl,
|
||||
state,
|
||||
setControlUi(next: {
|
||||
controlUiUrl?: string | null;
|
||||
controlUiProxyToken?: string | null;
|
||||
controlUiProxyTarget?: string | null;
|
||||
}) {
|
||||
controlUiUrl = sanitizeControlUiPublicUrl(next.controlUiUrl?.trim() || null);
|
||||
controlUiProxyToken = next.controlUiProxyToken?.trim() || null;
|
||||
controlUiProxyTarget = next.controlUiProxyTarget?.trim()
|
||||
? new URL(next.controlUiProxyTarget)
|
||||
: null;
|
||||
},
|
||||
setScenarioRun(next: Omit<QaLabScenarioRun, "counts"> | null) {
|
||||
latestScenarioRun = next ? withQaLabRunCounts(next) : null;
|
||||
},
|
||||
setLatestReport(next: QaLabLatestReport | null) {
|
||||
latestReport = next;
|
||||
},
|
||||
runSelfCheck,
|
||||
async stop() {
|
||||
const cleanupError = await stopLabServerResources();
|
||||
if (cleanupError) {
|
||||
throw cleanupError;
|
||||
}
|
||||
},
|
||||
};
|
||||
labHandle = lab;
|
||||
return lab;
|
||||
} catch (error) {
|
||||
await stopLabServerResources().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSelfCheck(result: QaSelfCheckResult) {
|
||||
|
||||
@@ -118,6 +118,44 @@ describe("telegram live qa runtime", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the Telegram channel account is connected", async () => {
|
||||
const gateway = {
|
||||
call: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "sut",
|
||||
connected: false,
|
||||
restartPending: false,
|
||||
running: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
telegram: [
|
||||
{
|
||||
accountId: "sut",
|
||||
connected: true,
|
||||
restartPending: false,
|
||||
running: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await testing.waitForTelegramChannelRunning(gateway as never, "sut", {
|
||||
pollMs: 1,
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
expect(gateway.call).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("normalizes the Telegram QA canary timeout env", () => {
|
||||
expect(testing.resolveTelegramQaCanaryTimeoutMs({})).toBe(30_000);
|
||||
expect(
|
||||
|
||||
@@ -1229,9 +1229,15 @@ function assertTelegramScenarioMessageSet(params: {
|
||||
async function waitForTelegramChannelRunning(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
accountId: string,
|
||||
options?: {
|
||||
pollMs?: number;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 45_000) {
|
||||
const timeoutMs = options?.timeoutMs ?? 45_000;
|
||||
const pollMs = options?.pollMs ?? 500;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const payload = (await gateway.call(
|
||||
"channels.status",
|
||||
@@ -1240,19 +1246,24 @@ async function waitForTelegramChannelRunning(
|
||||
)) as {
|
||||
channelAccounts?: Record<
|
||||
string,
|
||||
Array<{ accountId?: string; running?: boolean; restartPending?: boolean }>
|
||||
Array<{
|
||||
accountId?: string;
|
||||
connected?: boolean;
|
||||
running?: boolean;
|
||||
restartPending?: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.telegram ?? [];
|
||||
const match = accounts.find((entry) => entry.accountId === accountId);
|
||||
if (match?.running && match.restartPending !== true) {
|
||||
if (match?.running && match.connected === true && match.restartPending !== true) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
setTimeout(resolve, pollMs);
|
||||
});
|
||||
}
|
||||
throw new Error(`telegram account "${accountId}" did not become ready`);
|
||||
@@ -2254,6 +2265,7 @@ export const testing = {
|
||||
shouldLogTelegramQaLiveProgress,
|
||||
formatTelegramQaProgressDetails,
|
||||
renderTelegramQaMarkdown,
|
||||
waitForTelegramChannelRunning,
|
||||
waitForObservedMessage,
|
||||
};
|
||||
export { testing as __testing };
|
||||
|
||||
@@ -129,6 +129,46 @@ describe("runQaManualLane", () => {
|
||||
expect(result.reply).toBe("Protocol note: mock reply.");
|
||||
});
|
||||
|
||||
it("cleans up lab and mock provider when gateway startup fails", async () => {
|
||||
startQaGatewayChild.mockRejectedValueOnce(new Error("gateway startup failed"));
|
||||
|
||||
await expect(
|
||||
runQaManualLane({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
alternateModel: "mock-openai/gpt-5.5-alt",
|
||||
message: "check the kickoff file",
|
||||
timeoutMs: 5_000,
|
||||
replySettleMs: 0,
|
||||
}),
|
||||
).rejects.toThrow("gateway startup failed");
|
||||
|
||||
expect(gatewayStop).not.toHaveBeenCalled();
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(labStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues provider and lab teardown when gateway stop fails", async () => {
|
||||
gatewayStop.mockRejectedValueOnce(new Error("gateway stop failed"));
|
||||
|
||||
await expect(
|
||||
runQaManualLane({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
alternateModel: "mock-openai/gpt-5.5-alt",
|
||||
message: "check the kickoff file",
|
||||
timeoutMs: 5_000,
|
||||
replySettleMs: 0,
|
||||
}),
|
||||
).rejects.toThrow("gateway stop failed");
|
||||
|
||||
expect(gatewayStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(labStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caps the gateway client timeout for oversized manual waits", async () => {
|
||||
const result = await runQaManualLane({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
|
||||
@@ -24,6 +24,30 @@ type QaManualLaneParams = {
|
||||
replySettleMs?: number;
|
||||
};
|
||||
|
||||
type ManualLaneResult = {
|
||||
model: string;
|
||||
waited: { status?: string; error?: string };
|
||||
reply: string | null;
|
||||
watchUrl: string;
|
||||
};
|
||||
|
||||
function normalizeManualLaneCleanupError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(formatErrorMessage(error));
|
||||
}
|
||||
|
||||
async function stopManualLaneResources(resources: {
|
||||
gateway?: { stop: () => Promise<void> | void };
|
||||
lab?: { stop: () => Promise<void> | void };
|
||||
mock?: { stop: () => Promise<void> | void } | null;
|
||||
}): Promise<Error | undefined> {
|
||||
const stopTasks = [resources.gateway, resources.mock, resources.lab]
|
||||
.filter((resource): resource is { stop: () => Promise<void> | void } => Boolean(resource))
|
||||
.map((resource) => Promise.resolve().then(() => resource.stop()));
|
||||
const results = await Promise.allSettled(stopTasks);
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
return failed ? normalizeManualLaneCleanupError(failed.reason) : undefined;
|
||||
}
|
||||
|
||||
function resolveManualLaneTimeoutMs(params: {
|
||||
providerMode: QaProviderMode;
|
||||
primaryModel: string;
|
||||
@@ -50,35 +74,42 @@ function resolveManualLaneTimeoutMs(params: {
|
||||
|
||||
export async function runQaManualLane(params: QaManualLaneParams) {
|
||||
const sessionSuffix = params.primaryModel.replace(/[^a-z0-9._-]+/gi, "-");
|
||||
const lab = await startQaLabServer({
|
||||
repoRoot: params.repoRoot,
|
||||
embeddedGateway: "disabled",
|
||||
});
|
||||
const transport = createQaTransportAdapter({
|
||||
id: params.transportId ?? "qa-channel",
|
||||
state: lab.state,
|
||||
});
|
||||
const mock = await startQaProviderServer(params.providerMode);
|
||||
const gateway = await startQaGatewayChild({
|
||||
repoRoot: params.repoRoot,
|
||||
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
|
||||
transport,
|
||||
transportBaseUrl: lab.listenUrl,
|
||||
providerMode: params.providerMode,
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
fastMode: params.fastMode,
|
||||
thinkingDefault: params.thinkingDefault,
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
let lab: Awaited<ReturnType<typeof startQaLabServer>> | undefined;
|
||||
let mock: Awaited<ReturnType<typeof startQaProviderServer>> | undefined;
|
||||
let result: ManualLaneResult | undefined;
|
||||
let cleanupError: Error | undefined;
|
||||
let runError: unknown;
|
||||
|
||||
const timeoutMs = resolveManualLaneTimeoutMs({
|
||||
providerMode: params.providerMode,
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
try {
|
||||
lab = await startQaLabServer({
|
||||
repoRoot: params.repoRoot,
|
||||
embeddedGateway: "disabled",
|
||||
});
|
||||
const transport = createQaTransportAdapter({
|
||||
id: params.transportId ?? "qa-channel",
|
||||
state: lab.state,
|
||||
});
|
||||
mock = await startQaProviderServer(params.providerMode);
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: params.repoRoot,
|
||||
providerBaseUrl: mock ? `${mock.baseUrl}/v1` : undefined,
|
||||
transport,
|
||||
transportBaseUrl: lab.listenUrl,
|
||||
providerMode: params.providerMode,
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
fastMode: params.fastMode,
|
||||
thinkingDefault: params.thinkingDefault,
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const timeoutMs = resolveManualLaneTimeoutMs({
|
||||
providerMode: params.providerMode,
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const delivery = transport.buildAgentDelivery({
|
||||
target: "dm:qa-operator",
|
||||
});
|
||||
@@ -124,17 +155,26 @@ export async function runQaManualLane(params: QaManualLaneParams) {
|
||||
candidate.direction === "outbound" && candidate.conversation.id === "qa-operator",
|
||||
)?.text ?? null;
|
||||
|
||||
return {
|
||||
result = {
|
||||
model: params.primaryModel,
|
||||
waited,
|
||||
reply,
|
||||
watchUrl: lab.baseUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(formatErrorMessage(error), { cause: error });
|
||||
runError = error;
|
||||
} finally {
|
||||
await gateway.stop();
|
||||
await mock?.stop();
|
||||
await lab.stop();
|
||||
cleanupError = await stopManualLaneResources({ gateway, lab, mock });
|
||||
}
|
||||
if (runError) {
|
||||
throw new Error(formatErrorMessage(runError), { cause: runError });
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw cleanupError;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error("manual lane did not produce a result");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -51,8 +51,18 @@ describe("qa scenario catalog", () => {
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution?.kind !== "flow")
|
||||
.map((scenario) => scenario.id),
|
||||
).toStrictEqual(["control-ui-chat-flow-playwright"]);
|
||||
.map((scenario) => scenario.id)
|
||||
.toSorted(),
|
||||
).toStrictEqual(
|
||||
[
|
||||
"channel-message-flows",
|
||||
"control-ui-chat-flow-playwright",
|
||||
"gateway-smoke",
|
||||
"package-openclaw-for-docker",
|
||||
"plugin-lifecycle-probe",
|
||||
"qa-otel-smoke",
|
||||
].toSorted(),
|
||||
);
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution.kind === "flow")
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("matrix harness runtime", () => {
|
||||
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml ps -q matrix-qa-homeserver @/repo/openclaw`,
|
||||
);
|
||||
expect(calls).toContain(
|
||||
"docker inspect --format {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} container-123 @/repo/openclaw",
|
||||
"docker inspect --format {{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}} container-123 @/repo/openclaw",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// Twitch tests cover plugin.lifecycle plugin behavior.
|
||||
import {
|
||||
createStartAccountContext,
|
||||
expectLifecyclePatch,
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
|
||||
@@ -84,4 +86,20 @@ describe("twitch startAccount lifecycle", () => {
|
||||
expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears running status when monitor startup fails", async () => {
|
||||
hoisted.monitorTwitchProvider.mockRejectedValue(new Error("irc join failed"));
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
|
||||
const task = requireStartAccount()(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(task).rejects.toThrow("irc join failed");
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,20 +186,29 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
// Keep startAccount pending until abort fires; otherwise the channel
|
||||
// supervisor reads the settled task as `channel exited without an
|
||||
// error` and triggers a restart loop. See #60071.
|
||||
await runStoppablePassiveMonitor({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () => {
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||
return monitorTwitchProvider({
|
||||
account,
|
||||
accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runStoppablePassiveMonitor({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () => {
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||
return monitorTwitchProvider({
|
||||
account,
|
||||
accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.setStatus?.({
|
||||
accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
stopAccount: async (ctx): Promise<void> => {
|
||||
const account = ctx.account;
|
||||
|
||||
@@ -140,6 +140,26 @@ describe("web auto-reply connection", () => {
|
||||
expect(formatEnvelopeTimestamp(d, " America/Los_Angeles ")).toBe("Tue 2024-12-31 16:00:00 PST");
|
||||
});
|
||||
|
||||
it("does not publish running status when config loading fails", async () => {
|
||||
setLoadConfigMock(() => {
|
||||
throw new Error("config snapshot failed");
|
||||
});
|
||||
|
||||
const statuses: Array<{ running?: boolean }> = [];
|
||||
const listenerFactory = vi.fn(async () => createMockWebListener());
|
||||
const { run } = startWebAutoReplyMonitor({
|
||||
monitorWebChannelFn: monitorWebChannel as never,
|
||||
listenerFactory,
|
||||
sleep: vi.fn(async () => {}),
|
||||
statusSink: (next) => statuses.push({ ...next }),
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("config snapshot failed");
|
||||
|
||||
expect(listenerFactory).not.toHaveBeenCalled();
|
||||
expect(statuses.some((status) => status.running === true)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles reconnect progress and max-attempt stop behavior", async () => {
|
||||
for (const scenario of [
|
||||
{
|
||||
|
||||
@@ -159,9 +159,6 @@ export async function monitorWebChannel(
|
||||
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
|
||||
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
|
||||
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
|
||||
const statusController = createWebChannelStatusController(tuning.statusSink);
|
||||
statusController.emit();
|
||||
|
||||
const baseCfg = getRuntimeConfig();
|
||||
const sourceCfg = getRuntimeConfigSourceSnapshot();
|
||||
const { cfg, account } = resolveWebMonitorConfigSnapshot({
|
||||
@@ -234,6 +231,8 @@ export async function monitorWebChannel(
|
||||
sleep,
|
||||
isNonRetryableStatus: isNonRetryableWebCloseStatus,
|
||||
});
|
||||
const statusController = createWebChannelStatusController(tuning.statusSink);
|
||||
statusController.emit();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
||||
@@ -825,9 +825,7 @@ export class CoreAgentHarness<
|
||||
throw new AgentHarnessError("auth", "No auth available for compaction");
|
||||
}
|
||||
const branchEntries = await this.session.getBranch();
|
||||
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS, {
|
||||
force: true,
|
||||
});
|
||||
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
|
||||
if (!preparationResult.ok) {
|
||||
throw preparationResult.error;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAssistantMessageEventStream } from "../../llm.js";
|
||||
import type { AssistantMessage, Model, StreamFn } from "../../llm.js";
|
||||
import { buildSessionContext } from "../session/session.js";
|
||||
import type { SessionTreeEntry } from "../types.js";
|
||||
import { DEFAULT_COMPACTION_SETTINGS, prepareCompaction, generateSummary } from "./compaction.js";
|
||||
import { generateSummary } from "./compaction.js";
|
||||
|
||||
describe("generateSummary thinking options", () => {
|
||||
it("maps explicit Fable off to low effort for compaction", async () => {
|
||||
@@ -62,197 +60,3 @@ describe("generateSummary thinking options", () => {
|
||||
expect(streamFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareCompaction", () => {
|
||||
function createHighUsageSmallTranscriptEntries(): SessionTreeEntry[] {
|
||||
return [
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-17T08:45:00.000Z",
|
||||
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "user-1",
|
||||
timestamp: "2026-06-17T08:45:10.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Stored." }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
usage: {
|
||||
input: 625,
|
||||
output: 6,
|
||||
cacheRead: 172_928,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 173_559,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("skips automatic no-op summaries when usage is high but transcript text is below the kept-tail budget", () => {
|
||||
const entries = createHighUsageSmallTranscriptEntries();
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
|
||||
|
||||
expect(preparation).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
it("forces manual preparation when usage is high but transcript text is below the kept-tail budget", () => {
|
||||
const entries = createHighUsageSmallTranscriptEntries();
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
|
||||
|
||||
expect(preparation).toEqual({
|
||||
ok: true,
|
||||
value: expect.objectContaining({
|
||||
firstKeptEntryId: "assistant-1",
|
||||
messagesToSummarize: entries.map((entry) =>
|
||||
entry.type === "message" ? entry.message : undefined,
|
||||
),
|
||||
tokensBefore: 173_559,
|
||||
turnPrefixMessages: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors a forced boundary on the assistant tool call, not a trailing tool result", () => {
|
||||
const entries: SessionTreeEntry[] = [
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-17T08:45:00.000Z",
|
||||
message: { role: "user", content: "Read the notes file.", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "user-1",
|
||||
timestamp: "2026-06-17T08:45:10.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call-1", name: "read_file", arguments: { path: "notes.md" } },
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
usage: {
|
||||
input: 625,
|
||||
output: 6,
|
||||
cacheRead: 172_928,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 173_559,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "tool-1",
|
||||
parentId: "assistant-1",
|
||||
timestamp: "2026-06-17T08:45:11.000Z",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read_file",
|
||||
content: [{ type: "text", text: "notes body" }],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
|
||||
|
||||
// Anchor must be the assistant that owns the tool call, never the trailing
|
||||
// tool result, or the rebuilt context would replay an orphaned tool result.
|
||||
expect(preparation).toEqual({
|
||||
ok: true,
|
||||
value: expect.objectContaining({ firstKeptEntryId: "assistant-1" }),
|
||||
});
|
||||
|
||||
const compactedContext = buildSessionContext([
|
||||
...entries,
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction-1",
|
||||
parentId: "tool-1",
|
||||
timestamp: "2026-06-17T08:45:20.000Z",
|
||||
summary: "Checkpoint of the file read.",
|
||||
firstKeptEntryId: "assistant-1",
|
||||
tokensBefore: 173_559,
|
||||
},
|
||||
]);
|
||||
expect(compactedContext.messages.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows why the old empty-summary compaction replayed the whole transcript", () => {
|
||||
const entries: SessionTreeEntry[] = [
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-17T08:45:00.000Z",
|
||||
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "user-1",
|
||||
timestamp: "2026-06-17T08:45:10.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Stored." }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
usage: {
|
||||
input: 625,
|
||||
output: 6,
|
||||
cacheRead: 172_928,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 173_559,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const compactedContext = buildSessionContext([
|
||||
...entries,
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction-1",
|
||||
parentId: "assistant-1",
|
||||
timestamp: "2026-06-17T08:45:20.000Z",
|
||||
summary: "No prior conversation content provided.",
|
||||
firstKeptEntryId: "user-1",
|
||||
tokensBefore: 173_559,
|
||||
},
|
||||
]);
|
||||
expect(compactedContext.messages.map((message) => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -626,16 +626,10 @@ export interface CompactionPreparation {
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
export interface CompactionPreparationOptions {
|
||||
/** Prepare a real summary even when the kept-tail heuristic would otherwise summarize nothing. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionTreeEntry[],
|
||||
settings: CompactionSettings,
|
||||
options: CompactionPreparationOptions = {},
|
||||
): Result<CompactionPreparation | undefined, CompactionError> {
|
||||
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return ok(undefined);
|
||||
@@ -692,41 +686,6 @@ export function prepareCompaction(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
|
||||
if (options.force === true) {
|
||||
const forcedMessagesToSummarize: AgentMessage[] = [];
|
||||
for (let i = boundaryStart; i < boundaryEnd; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
|
||||
if (msg) {
|
||||
forcedMessagesToSummarize.push(msg);
|
||||
}
|
||||
}
|
||||
// Anchor the kept tail on the last valid cut point, not the raw final entry.
|
||||
// findValidCutPoints excludes tool results, so a forced boundary that is not
|
||||
// collapsed to summary-only later never keeps an orphaned tool result.
|
||||
const forcedCutPoints = findValidCutPoints(pathEntries, boundaryStart, boundaryEnd);
|
||||
const forcedKeepIndex =
|
||||
forcedCutPoints.length > 0 ? forcedCutPoints[forcedCutPoints.length - 1] : -1;
|
||||
if (forcedMessagesToSummarize.length > 0 && forcedKeepIndex >= 0) {
|
||||
const forcedFileOps = extractFileOperations(
|
||||
forcedMessagesToSummarize,
|
||||
pathEntries,
|
||||
prevCompactionIndex,
|
||||
);
|
||||
return ok({
|
||||
firstKeptEntryId: pathEntries[forcedKeepIndex].id,
|
||||
messagesToSummarize: forcedMessagesToSummarize,
|
||||
turnPrefixMessages: [],
|
||||
isSplitTurn: false,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps: forcedFileOps,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (const msg of turnPrefixMessages) {
|
||||
|
||||
@@ -46,7 +46,6 @@ export {
|
||||
shouldCompact,
|
||||
type CompactionDetails,
|
||||
type CompactionPreparation,
|
||||
type CompactionPreparationOptions,
|
||||
type CompactionResult,
|
||||
type CompactionSettings,
|
||||
type ContextUsageEstimate,
|
||||
|
||||
@@ -500,10 +500,11 @@ function formatGatewayClientErrorForLog(err: unknown): string {
|
||||
export function resolveGatewayClientConnectChallengeTimeoutMs(
|
||||
opts: Pick<
|
||||
GatewayClientOptions,
|
||||
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
|
||||
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
|
||||
>,
|
||||
): number {
|
||||
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
|
||||
env: opts.env,
|
||||
configuredTimeoutMs: opts.preauthHandshakeTimeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -1598,7 +1599,14 @@ export class GatewayClient {
|
||||
});
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
});
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
try {
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
} catch (error) {
|
||||
const pending = this.pending.get(id);
|
||||
this.pending.delete(id);
|
||||
pending?.cleanup?.();
|
||||
throw error;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ describe("GatewayClient", () => {
|
||||
preauthHandshakeTimeoutMs: 30_000,
|
||||
}),
|
||||
).toBe(30_000);
|
||||
expect(
|
||||
resolveGatewayClientConnectChallengeTimeoutMs({
|
||||
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "6000" },
|
||||
}),
|
||||
).toBe(6_000);
|
||||
});
|
||||
|
||||
test("closes on missing ticks", async () => {
|
||||
@@ -312,6 +317,27 @@ describe("GatewayClient", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("cleans pending request state when websocket send throws", async () => {
|
||||
const client = new GatewayClient({
|
||||
requestTimeoutMs: 25,
|
||||
});
|
||||
const sendError = new Error("synthetic send failure");
|
||||
(
|
||||
client as unknown as {
|
||||
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
|
||||
}
|
||||
).ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: vi.fn(() => {
|
||||
throw sendError;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(client.request("status")).rejects.toThrow("synthetic send failure");
|
||||
expect(getPendingCount(client)).toBe(0);
|
||||
});
|
||||
|
||||
test("does not auto-timeout expectFinal requests", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
28
packages/gateway-client/src/readiness.test.ts
Normal file
28
packages/gateway-client/src/readiness.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Gateway Client tests cover readiness behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startGatewayClientWithReadinessWait } from "./readiness.js";
|
||||
|
||||
describe("startGatewayClientWithReadinessWait", () => {
|
||||
it("uses the injected client env when resolving the readiness timeout", async () => {
|
||||
const waitForReady = vi.fn(async () => ({
|
||||
ready: true,
|
||||
aborted: false,
|
||||
elapsedMs: 0,
|
||||
checks: 1,
|
||||
maxDriftMs: 0,
|
||||
}));
|
||||
const client = { start: vi.fn() };
|
||||
|
||||
await startGatewayClientWithReadinessWait(waitForReady, client, {
|
||||
clientOptions: {
|
||||
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "6000" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(waitForReady).toHaveBeenCalledWith({
|
||||
maxWaitMs: 6_000,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(client.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export type GatewayClientStartReadinessOptions = {
|
||||
timeoutMs?: number;
|
||||
clientOptions?: Pick<
|
||||
GatewayClientOptions,
|
||||
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
|
||||
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
|
||||
>;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
@@ -42,6 +42,7 @@ function resolveGatewayClientStartReadinessTimeoutMs(
|
||||
? clientOptions.connectDelayMs
|
||||
: undefined;
|
||||
return resolveConnectChallengeTimeoutMs(timeoutOverride, {
|
||||
env: clientOptions.env,
|
||||
configuredTimeoutMs: clientOptions.preauthHandshakeTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
53
packages/sdk/src/transport.test.ts
Normal file
53
packages/sdk/src/transport.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// OpenClaw SDK tests cover transport behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayClientTransport } from "./transport.js";
|
||||
|
||||
type MockGatewayClientInstance = {
|
||||
opts: {
|
||||
onConnectError?: (error: Error) => void;
|
||||
onHelloOk?: (hello: unknown) => void;
|
||||
};
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stopAndWait: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const gatewayClientMocks = vi.hoisted(() => ({
|
||||
instances: [] as MockGatewayClientInstance[],
|
||||
}));
|
||||
|
||||
vi.mock("@openclaw/gateway-client", () => ({
|
||||
GatewayClient: class {
|
||||
readonly opts: MockGatewayClientInstance["opts"];
|
||||
readonly request = vi.fn();
|
||||
readonly start = vi.fn();
|
||||
readonly stopAndWait = vi.fn(async () => {});
|
||||
|
||||
constructor(opts: MockGatewayClientInstance["opts"]) {
|
||||
this.opts = opts;
|
||||
gatewayClientMocks.instances.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GatewayClientTransport", () => {
|
||||
beforeEach(() => {
|
||||
gatewayClientMocks.instances.length = 0;
|
||||
});
|
||||
|
||||
it("rejects a pending connect when the transport closes before hello-ok", async () => {
|
||||
const transport = new GatewayClientTransport();
|
||||
|
||||
const connect = transport.connect();
|
||||
const connectExpectation = expect(connect).rejects.toThrow(
|
||||
"gateway transport closed before connect completed",
|
||||
);
|
||||
const client = gatewayClientMocks.instances[0];
|
||||
expect(client?.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await transport.close();
|
||||
|
||||
await connectExpectation;
|
||||
expect(client?.stopAndWait).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
private readonly options: GatewayClientTransportOptions;
|
||||
private client: GatewayClientLike | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private rejectPendingConnect: ((error: Error) => void) | null = null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(options: GatewayClientTransportOptions = {}) {
|
||||
@@ -89,6 +90,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
return this.connectPromise;
|
||||
}
|
||||
this.connectPromise = new Promise<void>((resolve, reject) => {
|
||||
this.rejectPendingConnect = reject;
|
||||
const client = new GatewayClient({
|
||||
...this.options,
|
||||
onEvent: (event: unknown) => {
|
||||
@@ -98,6 +100,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
},
|
||||
onHelloOk: (_hello: unknown) => {
|
||||
this.options.onHelloOk?.(_hello);
|
||||
this.rejectPendingConnect = null;
|
||||
resolve();
|
||||
},
|
||||
onConnectError: (error: Error) => {
|
||||
@@ -109,6 +112,7 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
void client.stopAndWait().catch(() => {});
|
||||
this.rejectPendingConnect = null;
|
||||
reject(error);
|
||||
},
|
||||
onReconnectPaused: this.options.onReconnectPaused,
|
||||
@@ -145,6 +149,9 @@ export class GatewayClientTransport implements ConnectableOpenClawTransport {
|
||||
this.eventsHub.close();
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
const rejectPendingConnect = this.rejectPendingConnect;
|
||||
this.rejectPendingConnect = null;
|
||||
rejectPendingConnect?.(new Error("gateway transport closed before connect completed"));
|
||||
this.connectPromise = null;
|
||||
this.closePromise = client?.stopAndWait() ?? Promise.resolve();
|
||||
await this.closePromise;
|
||||
|
||||
27
qa/scenarios/channels/channel-message-flows.yaml
Normal file
27
qa/scenarios/channels/channel-message-flows.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
title: Channel message flows QA evidence
|
||||
|
||||
scenario:
|
||||
id: channel-message-flows
|
||||
surface: channel-framework
|
||||
coverage:
|
||||
primary:
|
||||
- outbound-direct-text-media-sends
|
||||
secondary:
|
||||
- channels.streaming
|
||||
- channels.direct-visible-replies
|
||||
objective: Exercise Telegram-shaped streamed previews and durable final text delivery through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Telegram flow flags parse channel, target, account, thread, timing, and flow mode.
|
||||
- Thinking preview updates flush, clear, and then send a durable final answer.
|
||||
- Failed preview streaming clears the preview and does not send the final answer.
|
||||
- Working preview updates render rich tool/status text before the durable final answer.
|
||||
docsRefs:
|
||||
- docs/channels/telegram.md
|
||||
- docs/channels/qa-channel.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts
|
||||
summary: Vitest coverage for channel preview clearing and durable final text sends.
|
||||
26
qa/scenarios/plugins/plugin-lifecycle-probe.yaml
Normal file
26
qa/scenarios/plugins/plugin-lifecycle-probe.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
title: Plugin lifecycle probe evidence
|
||||
|
||||
scenario:
|
||||
id: plugin-lifecycle-probe
|
||||
surface: plugins
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.lifecycle
|
||||
secondary:
|
||||
- plugin-validation-and-repair
|
||||
- plugin-setup
|
||||
objective: Exercise strict plugin load/uninstall proof parsing through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Enabled loaded plugin inspect JSON is accepted as proof.
|
||||
- Pending or missing inspect JSON is rejected instead of treated as loaded.
|
||||
- Malformed config during uninstall proof fails with a bounded diagnostic.
|
||||
docsRefs:
|
||||
- docs/plugins/manifest.md
|
||||
- docs/cli/plugins.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
|
||||
summary: Vitest coverage for plugin lifecycle proof parsing.
|
||||
28
qa/scenarios/runtime/gateway-smoke.yaml
Normal file
28
qa/scenarios/runtime/gateway-smoke.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: Gateway smoke QA evidence
|
||||
|
||||
scenario:
|
||||
id: gateway-smoke
|
||||
surface: gateway-runtime
|
||||
coverage:
|
||||
primary:
|
||||
- health-apis
|
||||
secondary:
|
||||
- websocket-transport
|
||||
- connect-request
|
||||
- hello-ok-snapshot
|
||||
objective: Exercise loopback Gateway WebSocket connect and health checks through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Gateway smoke passes against a loopback Gateway WebSocket using the real client.
|
||||
- The smoke sends the expected operator connect request before health.
|
||||
- Failed connect and health responses close the WebSocket client and report bounded errors.
|
||||
- Unpaired iOS-shaped smoke clients do not call scoped chat history.
|
||||
docsRefs:
|
||||
- docs/gateway/protocol.md
|
||||
- docs/gateway/index.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts
|
||||
summary: Vitest coverage for Gateway smoke connect and health behavior.
|
||||
28
qa/scenarios/runtime/package-openclaw-for-docker.yaml
Normal file
28
qa/scenarios/runtime/package-openclaw-for-docker.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: Docker package artifact QA evidence
|
||||
|
||||
scenario:
|
||||
id: package-openclaw-for-docker
|
||||
surface: docker-podman-hosting
|
||||
coverage:
|
||||
primary:
|
||||
- docker-e2e-package-artifact-generation
|
||||
secondary:
|
||||
- package-manager-installs
|
||||
- runtime.package-update
|
||||
objective: Exercise bounded OpenClaw package artifact generation through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Package artifact output flags are parsed strictly.
|
||||
- The Docker package path uses the single bounded build-all step before npm pack.
|
||||
- Changelog trimming is restored after successful and failed ignore-scripts packaging.
|
||||
- Timed-out and externally terminated child process groups are cleaned up without leaked descendants.
|
||||
- Captured command output is bounded.
|
||||
docsRefs:
|
||||
- docs/install/updating.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
|
||||
summary: Vitest coverage for Docker package artifact creation and cleanup behavior.
|
||||
28
qa/scenarios/runtime/qa-otel-smoke.yaml
Normal file
28
qa/scenarios/runtime/qa-otel-smoke.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: QA OTEL smoke evidence
|
||||
|
||||
scenario:
|
||||
id: qa-otel-smoke
|
||||
surface: telemetry
|
||||
coverage:
|
||||
primary:
|
||||
- telemetry.otel
|
||||
secondary:
|
||||
- harness.qa-lab
|
||||
- plugin-sdk-diagnostic-runtime-exports
|
||||
objective: Exercise bounded local OTLP capture and OpenTelemetry smoke assertions through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Package-manager forwarded QA OTEL smoke arguments parse correctly.
|
||||
- Body-size limits are strict positive integers.
|
||||
- Local OTLP receiver rejects malformed, oversized, or truncated protobuf payloads with bounded diagnostics.
|
||||
- Captured OTLP body text is bounded and leak needles remain detectable.
|
||||
- Active local receiver sockets close during cleanup.
|
||||
- Smoke assertions fail on non-2xx OTLP requests and missing release-critical signals.
|
||||
docsRefs:
|
||||
- docs/gateway/opentelemetry.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts
|
||||
summary: Vitest coverage for QA OTEL smoke receiver bounds and signal assertions.
|
||||
@@ -219,6 +219,12 @@ function isSourceFile(filePath) {
|
||||
return sourceFileExtensions.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function isGeneratedAssetSourceFile(filePath) {
|
||||
return /(?:^|\/)extensions\/[^/]+\/assets\/[^/]+\.[cm]?js$/u.test(
|
||||
filePath.replaceAll(path.sep, "/"),
|
||||
);
|
||||
}
|
||||
|
||||
function isTestLikeSourceFile(filePath) {
|
||||
return sourceTestSuffixes.some((suffix) => filePath.endsWith(suffix));
|
||||
}
|
||||
@@ -235,7 +241,11 @@ async function collectSourceFiles(targetPath) {
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
return isSourceFile(targetPath) && !isTestLikeSourceFile(targetPath) ? [targetPath] : [];
|
||||
return isSourceFile(targetPath) &&
|
||||
!isTestLikeSourceFile(targetPath) &&
|
||||
!isGeneratedAssetSourceFile(targetPath)
|
||||
? [targetPath]
|
||||
: [];
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
||||
@@ -249,7 +259,12 @@ async function collectSourceFiles(targetPath) {
|
||||
files.push(...(await collectSourceFiles(entryPath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isSourceFile(entryPath) && !isTestLikeSourceFile(entryPath)) {
|
||||
if (
|
||||
entry.isFile() &&
|
||||
isSourceFile(entryPath) &&
|
||||
!isTestLikeSourceFile(entryPath) &&
|
||||
!isGeneratedAssetSourceFile(entryPath)
|
||||
) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
* Grace period before force-killing a timed-out knip child process.
|
||||
*/
|
||||
export const KNIP_KILL_GRACE_MS = 5_000;
|
||||
const KNIP_PROCESS_TREE_EXIT_POLL_MS = 25;
|
||||
const KNIP_POST_FORCE_KILL_WAIT_MS = 1_000;
|
||||
/**
|
||||
* Heartbeat interval used while knip runs without output.
|
||||
*/
|
||||
@@ -154,6 +156,34 @@ function signalProcessTree(child, signal) {
|
||||
}
|
||||
}
|
||||
|
||||
function processTreeAlive(child) {
|
||||
if (!child.pid) {
|
||||
return false;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessTreeExit(child, timeoutMs) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, KNIP_PROCESS_TREE_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !processTreeAlive(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs knip and returns parsed unused-file results.
|
||||
*/
|
||||
@@ -230,6 +260,16 @@ export async function runKnipUnusedFiles(params = {}) {
|
||||
output: output.join(""),
|
||||
});
|
||||
};
|
||||
const finishAfterProcessTreeCleanup = async (result) => {
|
||||
if (processTreeAlive(child)) {
|
||||
await waitForProcessTreeExit(child, killGraceMs);
|
||||
}
|
||||
if (processTreeAlive(child)) {
|
||||
signalProcessTree(child, "SIGKILL");
|
||||
await waitForProcessTreeExit(child, KNIP_POST_FORCE_KILL_WAIT_MS);
|
||||
}
|
||||
finish(result);
|
||||
};
|
||||
|
||||
const appendOutput = (chunk) => {
|
||||
if (settled) {
|
||||
@@ -283,7 +323,7 @@ export async function runKnipUnusedFiles(params = {}) {
|
||||
exitSignal = exitSignal ?? signal;
|
||||
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
||||
if (timedOut) {
|
||||
finish({
|
||||
void finishAfterProcessTreeCleanup({
|
||||
errorCode: "ETIMEDOUT",
|
||||
errorMessage: `Knip unused-file scan timed out after ${elapsedSeconds}s`,
|
||||
signal: exitSignal,
|
||||
@@ -292,7 +332,7 @@ export async function runKnipUnusedFiles(params = {}) {
|
||||
return;
|
||||
}
|
||||
if (bufferExceeded) {
|
||||
finish({
|
||||
void finishAfterProcessTreeCleanup({
|
||||
errorCode: "ENOBUFS",
|
||||
errorMessage: `Knip unused-file scan exceeded ${maxBufferBytes} output bytes`,
|
||||
signal: exitSignal,
|
||||
|
||||
@@ -35,6 +35,8 @@ const prepareBoundaryArtifactsBin = resolve(
|
||||
const extensionPackageBoundaryBaseConfig = "../tsconfig.package-boundary.base.json";
|
||||
const FAILURE_OUTPUT_TAIL_LINES = 40;
|
||||
const STEP_OUTPUT_MAX_CHARS = 256 * 1024;
|
||||
const STEP_PROCESS_GROUP_EXIT_POLL_MS = 25;
|
||||
const STEP_POST_FORCE_KILL_WAIT_MS = 1_000;
|
||||
const SLOW_COMPILE_SUMMARY_LIMIT = 10;
|
||||
const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".json"]);
|
||||
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
|
||||
@@ -420,6 +422,34 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
|
||||
child.kill(signal);
|
||||
}
|
||||
};
|
||||
const processGroupAlive = () => {
|
||||
if (platform === "win32" || typeof child.pid !== "number") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
killProcess(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
};
|
||||
const waitForProcessGroupExit = async (ms) => {
|
||||
const deadlineAt = Date.now() + ms;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processGroupAlive()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, STEP_PROCESS_GROUP_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !processGroupAlive();
|
||||
};
|
||||
const waitAfterForceKill = async () => {
|
||||
if (processGroupAlive()) {
|
||||
await waitForProcessGroupExit(STEP_POST_FORCE_KILL_WAIT_MS);
|
||||
}
|
||||
};
|
||||
const abortSignal = abortController?.signal;
|
||||
const abortListener = () => {
|
||||
signalChild("SIGTERM");
|
||||
@@ -443,30 +473,33 @@ export function runNodeStepAsync(label, args, timeoutMs, params = {}) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
signalChild("SIGKILL");
|
||||
const stdoutText = formatCapturedStepOutput(stdout);
|
||||
const stderrText = formatCapturedStepOutput(stderr);
|
||||
const error = attachStepFailureMetadata(
|
||||
new Error(
|
||||
formatStepFailure(label, {
|
||||
void (async () => {
|
||||
await waitAfterForceKill();
|
||||
const stdoutText = formatCapturedStepOutput(stdout);
|
||||
const stderrText = formatCapturedStepOutput(stderr);
|
||||
const error = attachStepFailureMetadata(
|
||||
new Error(
|
||||
formatStepFailure(label, {
|
||||
stdout: stdoutText,
|
||||
stderr: stderrText,
|
||||
kind: "timeout",
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
note: `${label} timed out after ${timeoutMs}ms`,
|
||||
}),
|
||||
),
|
||||
label,
|
||||
{
|
||||
stdout: stdoutText,
|
||||
stderr: stderrText,
|
||||
kind: "timeout",
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
note: `${label} timed out after ${timeoutMs}ms`,
|
||||
}),
|
||||
),
|
||||
label,
|
||||
{
|
||||
stdout: stdoutText,
|
||||
stderr: stderrText,
|
||||
kind: "timeout",
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
note: `${label} timed out after ${timeoutMs}ms`,
|
||||
},
|
||||
);
|
||||
onFailure?.(error);
|
||||
abortSiblingSteps(abortController);
|
||||
rejectPromise(toLintErrorObject(error, "Step timed out"));
|
||||
},
|
||||
);
|
||||
onFailure?.(error);
|
||||
abortSiblingSteps(abortController);
|
||||
rejectPromise(toLintErrorObject(error, "Step timed out"));
|
||||
})();
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
|
||||
@@ -209,6 +209,13 @@ Options:
|
||||
--cpu-core-warn <ratio> Hot CPU threshold (default: 0.9)
|
||||
--hot-wall-warn-ms <ms> Minimum wall time for hot CPU observations (default: 30000)
|
||||
--max-rss-warn-mb <mb> Maximum RSS warning threshold (default: 1536)
|
||||
--wall-anomaly-multiplier <n> Wall-time anomaly multiplier (default: 3)
|
||||
--rss-anomaly-multiplier <n> RSS anomaly multiplier (default: 2.5)
|
||||
--qa-cpu-regression-multiplier <n> QA baseline CPU regression multiplier (default: 2)
|
||||
--qa-wall-regression-multiplier <n> QA baseline wall regression multiplier (default: 2)
|
||||
--command-timeout-ms <ms> Lifecycle/slash command timeout (default: 120000)
|
||||
--build-timeout-ms <ms> Prebuild command timeout (default: 600000)
|
||||
--qa-timeout-ms <ms> QA chunk timeout (default: 900000)
|
||||
--skip-prebuild Skip the upfront build used to avoid per-command rebuild noise
|
||||
--skip-lifecycle Skip plugin install/inspect/disable/enable/doctor/uninstall
|
||||
--skip-qa Skip QA Lab RPC conversation runs
|
||||
@@ -216,6 +223,14 @@ Options:
|
||||
--allow-empty Allow zero-command runs when every active phase is skipped
|
||||
--fail-on-observation Treat RSS/CPU/wall observation rows as guard failures
|
||||
--keep-run-root Preserve isolated HOME/state/log temp root after success
|
||||
|
||||
Environment:
|
||||
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS Comma-separated plugin ids to include
|
||||
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_TOTAL Total plugin shards
|
||||
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_INDEX Zero-based shard index
|
||||
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_FAIL_ON_OBSERVATION=1
|
||||
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_KEEP_RUN_ROOT=1
|
||||
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_QA_SUMMARY_MAX_BYTES QA summary read ceiling
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const migratedSessionAccessorFiles = new Set([
|
||||
"src/gateway/sessions-resolve.ts",
|
||||
"src/gateway/server-methods/sessions.ts",
|
||||
"src/infra/outbound/message-action-tts.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]);
|
||||
|
||||
export const migratedBundledPluginSessionAccessorFiles = new Set([
|
||||
@@ -98,6 +99,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
|
||||
"src/auto-reply/reply/session-reset-model.ts",
|
||||
"src/auto-reply/reply/session-updates.ts",
|
||||
"src/auto-reply/reply/session-usage.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]);
|
||||
|
||||
export const migratedTranscriptWriterFiles = new Set([
|
||||
@@ -290,8 +292,13 @@ export async function main() {
|
||||
"src/cron",
|
||||
"src/gateway",
|
||||
"src/infra",
|
||||
"src/tui",
|
||||
]);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/tui",
|
||||
]);
|
||||
const writeSourceRoots = resolveSourceRoots(repoRoot, ["src/agents", "src/auto-reply"]);
|
||||
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
|
||||
"src/agents/command",
|
||||
"src/agents/embedded-agent-runner",
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// Uses installed tools when present, otherwise falls back to pinned hooks where
|
||||
// possible, then runs repo-specific workflow guards.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ACTIONLINT_VERSION = "1.7.11";
|
||||
const PRE_COMMIT_VERSION = "4.2.0";
|
||||
const WORKFLOW_DIR = ".github/workflows";
|
||||
|
||||
function commandExists(command, args = ["--version"]) {
|
||||
@@ -25,6 +27,59 @@ function run(command, args) {
|
||||
}
|
||||
}
|
||||
|
||||
function runChecked(command, args) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" });
|
||||
if (result.error) {
|
||||
return {
|
||||
message: `[check-workflows] failed to run ${command}: ${result.error.message}`,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
message: null,
|
||||
status: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function runPreCommitFromTempVenv(hook, hookArgs) {
|
||||
if (!commandExists("python3", ["--version"])) {
|
||||
return false;
|
||||
}
|
||||
const venvDir = mkdtempSync(join(tmpdir(), "openclaw-check-workflows-pre-commit-"));
|
||||
const python = join(venvDir, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
|
||||
let failure;
|
||||
try {
|
||||
failure = runChecked("python3", ["-m", "venv", venvDir]);
|
||||
if (!failure) {
|
||||
failure = runChecked(python, [
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--disable-pip-version-check",
|
||||
`pre-commit==${PRE_COMMIT_VERSION}`,
|
||||
]);
|
||||
}
|
||||
if (!failure) {
|
||||
failure = runChecked(python, ["-m", "pre_commit", ...hookArgs]);
|
||||
}
|
||||
if (failure) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
rmSync(venvDir, { force: true, recursive: true });
|
||||
if (failure) {
|
||||
if (failure.message) {
|
||||
console.error(failure.message);
|
||||
}
|
||||
process.exit(failure.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workflowFiles() {
|
||||
return readdirSync(WORKFLOW_DIR)
|
||||
.filter((file) => file.endsWith(".yml") || file.endsWith(".yaml"))
|
||||
@@ -42,9 +97,12 @@ function runPreCommitHook(hook, files) {
|
||||
run("python3", ["-m", "pre_commit", ...hookArgs]);
|
||||
return;
|
||||
}
|
||||
if (runPreCommitFromTempVenv(hook, hookArgs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[check-workflows] missing pre-commit runtime for ${hook}: install pre-commit or python3 pre_commit.`,
|
||||
`[check-workflows] missing pre-commit runtime for ${hook}: install pre-commit or Python venv support for pre-commit ${PRE_COMMIT_VERSION}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -57,7 +115,8 @@ if (commandExists("actionlint")) {
|
||||
run("go", ["run", `github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION}`]);
|
||||
} else if (
|
||||
commandExists("pre-commit") ||
|
||||
commandExists("python3", ["-m", "pre_commit", "--version"])
|
||||
commandExists("python3", ["-m", "pre_commit", "--version"]) ||
|
||||
commandExists("python3", ["--version"])
|
||||
) {
|
||||
runPreCommitHook("actionlint", workflows);
|
||||
} else {
|
||||
|
||||
@@ -216,7 +216,7 @@ fi
|
||||
committed=false
|
||||
if [ "$fast_commit" = true ]; then
|
||||
declare -a commit_env=(FAST_COMMIT=1)
|
||||
if run_git_with_lock_retry "commit" env "${commit_env[@]}" git commit -m "$commit_message"; then
|
||||
if run_git_with_lock_retry "commit" env "${commit_env[@]}" git commit --no-verify -m "$commit_message"; then
|
||||
committed=true
|
||||
fi
|
||||
else
|
||||
|
||||
@@ -15,12 +15,28 @@ TIMEZONE="${OPENCLAW_TZ:-}"
|
||||
RAW_SKIP_ONBOARDING="${OPENCLAW_SKIP_ONBOARDING:-}"
|
||||
SKIP_ONBOARDING=""
|
||||
DOCKER_PULL_TIMEOUT="${OPENCLAW_DOCKER_SETUP_PULL_TIMEOUT:-600s}"
|
||||
OFFLINE_MODE=""
|
||||
DEFAULT_SANDBOX_IMAGE="openclaw-sandbox:bookworm-slim"
|
||||
DEFAULT_SANDBOX_BROWSER_IMAGE="openclaw-sandbox-browser:bookworm-slim"
|
||||
SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH="2026-05-12-cdp-relay-auth"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--offline)
|
||||
OFFLINE_MODE="1"
|
||||
;;
|
||||
*)
|
||||
fail "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing dependency: $1" >&2
|
||||
@@ -47,6 +63,14 @@ run_docker_pull() {
|
||||
docker pull "$image"
|
||||
}
|
||||
|
||||
require_local_docker_image() {
|
||||
local image="$1"
|
||||
if docker image inspect "$image" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fail "Offline Docker setup requires preloaded image $image. Load it with 'docker load -i <image.tar>' before running scripts/docker/setup.sh --offline."
|
||||
}
|
||||
|
||||
is_truthy_value() {
|
||||
local raw="${1:-}"
|
||||
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
||||
@@ -154,8 +178,16 @@ sync_gateway_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
run_compose_one_off() {
|
||||
local -a run_args=(run)
|
||||
if [[ -n "$OFFLINE_MODE" ]]; then
|
||||
run_args+=(--pull never)
|
||||
fi
|
||||
docker compose "${COMPOSE_ARGS[@]}" "${run_args[@]}" "$@"
|
||||
}
|
||||
|
||||
run_prestart_gateway() {
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps "$@"
|
||||
run_compose_one_off --rm --no-deps "$@"
|
||||
}
|
||||
|
||||
run_prestart_cli() {
|
||||
@@ -182,7 +214,11 @@ run_runtime_cli() {
|
||||
shift 2
|
||||
|
||||
local -a compose_args
|
||||
local -a run_args=(run --rm)
|
||||
local -a run_args=(run)
|
||||
if [[ -n "$OFFLINE_MODE" ]]; then
|
||||
run_args+=(--pull never)
|
||||
fi
|
||||
run_args+=(--rm)
|
||||
|
||||
case "$compose_scope" in
|
||||
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
|
||||
@@ -199,6 +235,181 @@ run_runtime_cli() {
|
||||
docker compose "${compose_args[@]}" "${run_args[@]}" openclaw-cli "$@"
|
||||
}
|
||||
|
||||
run_gateway_up() {
|
||||
local compose_scope="${1:-current}"
|
||||
shift
|
||||
|
||||
local -a compose_args
|
||||
local -a up_args=(up -d)
|
||||
|
||||
case "$compose_scope" in
|
||||
current) compose_args=("${COMPOSE_ARGS[@]}") ;;
|
||||
base) compose_args=("${BASE_COMPOSE_ARGS[@]}") ;;
|
||||
*) fail "Unknown gateway compose scope: $compose_scope" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "$OFFLINE_MODE" ]]; then
|
||||
up_args+=(--pull never --no-build)
|
||||
fi
|
||||
up_args+=("$@")
|
||||
|
||||
docker compose "${compose_args[@]}" "${up_args[@]}" openclaw-gateway
|
||||
}
|
||||
|
||||
resolve_offline_sandbox_images() {
|
||||
local agents_json sandbox_tools_json
|
||||
agents_json="$(run_prestart_cli config get agents --json 2>/dev/null || true)"
|
||||
if [[ -z "$agents_json" ]]; then
|
||||
agents_json="{}"
|
||||
fi
|
||||
sandbox_tools_json="$(
|
||||
run_prestart_cli config get tools.sandbox.tools --json 2>/dev/null || true
|
||||
)"
|
||||
if [[ -z "$sandbox_tools_json" ]]; then
|
||||
sandbox_tools_json="{}"
|
||||
fi
|
||||
|
||||
printf '%s' "$agents_json" | run_prestart_gateway \
|
||||
-T --entrypoint node openclaw-gateway -e '
|
||||
const fs = require("node:fs");
|
||||
const agents = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
||||
const globalToolPolicy = JSON.parse(process.argv[3] || "{}");
|
||||
const defaultSandbox = agents?.defaults?.sandbox ?? {};
|
||||
const defaultDockerImage = defaultSandbox?.docker?.image ?? process.argv[1];
|
||||
const defaultBrowserImage = defaultSandbox?.browser?.image ?? process.argv[2];
|
||||
const images = new Set();
|
||||
const configuredEntries = Array.isArray(agents?.list)
|
||||
? agents.list.filter((entry) => entry !== null && typeof entry === "object")
|
||||
: [];
|
||||
const entries = configuredEntries.length > 0 ? configuredEntries : [{ sandbox: {} }];
|
||||
|
||||
const matchesBrowser = (rawPattern) => {
|
||||
const pattern = String(rawPattern ?? "").trim().toLowerCase();
|
||||
if (pattern === "group:openclaw" || pattern === "group:ui") {
|
||||
return true;
|
||||
}
|
||||
if (!pattern) {
|
||||
return false;
|
||||
}
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`^${escaped.replaceAll("*", ".*")}$`).test("browser");
|
||||
};
|
||||
const permitsBrowser = (entry) => {
|
||||
const agentPolicy = entry?.tools?.sandbox?.tools ?? {};
|
||||
const allow = Array.isArray(agentPolicy.allow)
|
||||
? agentPolicy.allow
|
||||
: Array.isArray(globalToolPolicy?.allow)
|
||||
? globalToolPolicy.allow
|
||||
: undefined;
|
||||
const alsoAllow = Array.isArray(agentPolicy.alsoAllow)
|
||||
? agentPolicy.alsoAllow
|
||||
: Array.isArray(globalToolPolicy?.alsoAllow)
|
||||
? globalToolPolicy.alsoAllow
|
||||
: undefined;
|
||||
const deny = Array.isArray(agentPolicy.deny)
|
||||
? agentPolicy.deny
|
||||
: Array.isArray(globalToolPolicy?.deny)
|
||||
? globalToolPolicy.deny
|
||||
: undefined;
|
||||
|
||||
// Browser is absent from the default allowlist and present in the default
|
||||
// denylist. Explicit allow patterns re-enable it unless an explicit deny wins.
|
||||
const explicitAllows = [...(allow ?? []), ...(alsoAllow ?? [])];
|
||||
const allowedByAllowlist = Array.isArray(allow)
|
||||
? allow.length === 0 || explicitAllows.some(matchesBrowser)
|
||||
: (alsoAllow ?? []).some(matchesBrowser);
|
||||
const denied = Array.isArray(deny)
|
||||
? deny.some(matchesBrowser)
|
||||
: !explicitAllows.some(matchesBrowser);
|
||||
return allowedByAllowlist && !denied;
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const sandbox = entry?.sandbox ?? {};
|
||||
const mode = sandbox.mode ?? "non-main";
|
||||
const backend = (
|
||||
sandbox.backend?.trim() ||
|
||||
defaultSandbox.backend?.trim() ||
|
||||
"docker"
|
||||
).toLowerCase();
|
||||
if (mode === "off" || backend !== "docker") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Setup writes defaults scope=agent. Explicit per-agent scope still wins,
|
||||
// and shared scope intentionally ignores per-agent Docker/browser overrides.
|
||||
const scope = sandbox.scope ?? "agent";
|
||||
const agentDocker = scope === "shared" ? undefined : sandbox.docker;
|
||||
images.add(`sandbox\t${agentDocker?.image ?? defaultDockerImage}`);
|
||||
|
||||
const agentBrowser = scope === "shared" ? undefined : sandbox.browser;
|
||||
const browserEnabled = agentBrowser?.enabled ?? defaultSandbox?.browser?.enabled ?? false;
|
||||
if (browserEnabled && permitsBrowser(entry)) {
|
||||
images.add(`browser\t${agentBrowser?.image ?? defaultBrowserImage}`);
|
||||
}
|
||||
}
|
||||
process.stdout.write([...images].join("\n"));
|
||||
' "$DEFAULT_SANDBOX_IMAGE" "$DEFAULT_SANDBOX_BROWSER_IMAGE" "$sandbox_tools_json"
|
||||
}
|
||||
|
||||
validate_offline_sandbox_prerequisites() {
|
||||
if [[ ! -S "$DOCKER_SOCKET_PATH" ]]; then
|
||||
fail "Offline sandbox setup requires a Docker socket at $DOCKER_SOCKET_PATH."
|
||||
fi
|
||||
|
||||
local sandbox_images
|
||||
sandbox_images="$(resolve_offline_sandbox_images)"
|
||||
local -a sandbox_image_errors=()
|
||||
local image_kind sandbox_image browser_contract
|
||||
while IFS=$'\t' read -r image_kind sandbox_image; do
|
||||
[[ -n "$image_kind" ]] || continue
|
||||
case "$image_kind" in
|
||||
sandbox)
|
||||
if ! docker --host "unix://$DOCKER_SOCKET_PATH" image inspect "$sandbox_image" >/dev/null 2>&1; then
|
||||
sandbox_image_errors+=("$sandbox_image (missing)")
|
||||
fi
|
||||
;;
|
||||
browser)
|
||||
if ! browser_contract="$(
|
||||
docker --host "unix://$DOCKER_SOCKET_PATH" image inspect \
|
||||
-f '{{ index .Config.Labels "org.openclaw.sandbox-browser.contract" }}' \
|
||||
"$sandbox_image" 2>/dev/null
|
||||
)"; then
|
||||
sandbox_image_errors+=("$sandbox_image (missing)")
|
||||
elif [[ "$browser_contract" != "$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH" ]]; then
|
||||
sandbox_image_errors+=(
|
||||
"$sandbox_image (browser contract=${browser_contract:-missing}, expected=$SANDBOX_BROWSER_IMAGE_CONTRACT_EPOCH)"
|
||||
)
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "Unknown offline sandbox image kind: $image_kind"
|
||||
;;
|
||||
esac
|
||||
done <<<"$sandbox_images"
|
||||
|
||||
if [[ ${#sandbox_image_errors[@]} -gt 0 ]]; then
|
||||
echo "WARNING: offline Docker setup cannot use required sandbox images:" >&2
|
||||
local sandbox_image_error
|
||||
for sandbox_image_error in "${sandbox_image_errors[@]}"; do
|
||||
echo " - $sandbox_image_error" >&2
|
||||
done
|
||||
echo " Load them with 'docker load -i <sandbox-image.tar>' before enabling sandboxed agents." >&2
|
||||
fail "Offline sandbox prerequisites are incomplete; sandbox configuration was not changed."
|
||||
fi
|
||||
|
||||
echo "Using preloaded sandbox images:"
|
||||
while IFS=$'\t' read -r _ sandbox_image; do
|
||||
if [[ -n "$sandbox_image" ]]; then
|
||||
echo " - $sandbox_image"
|
||||
fi
|
||||
done <<<"$sandbox_images"
|
||||
|
||||
if ! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
|
||||
fail "Offline sandbox setup requires Docker CLI in $IMAGE_NAME."
|
||||
fi
|
||||
}
|
||||
|
||||
contains_disallowed_chars() {
|
||||
local value="$1"
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
@@ -539,7 +750,10 @@ upsert_env "$ENV_FILE" \
|
||||
OPENCLAW_OTEL_PRELOADED \
|
||||
OPENCLAW_SKIP_ONBOARDING
|
||||
|
||||
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
||||
if [[ -n "$OFFLINE_MODE" ]]; then
|
||||
require_local_docker_image "$IMAGE_NAME"
|
||||
echo "==> Using preloaded Docker image: $IMAGE_NAME"
|
||||
elif [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
||||
echo "==> Building Docker image: $IMAGE_NAME"
|
||||
run_docker_build \
|
||||
--build-arg "OPENCLAW_IMAGE_APT_PACKAGES=${OPENCLAW_IMAGE_APT_PACKAGES}" \
|
||||
@@ -618,9 +832,15 @@ echo "Discord (bot token):"
|
||||
echo " ${COMPOSE_HINT} run --rm openclaw-cli channels add --channel discord --token <token>"
|
||||
echo "Docs: https://docs.openclaw.ai/channels"
|
||||
|
||||
if [[ -n "$SANDBOX_ENABLED" && -n "$OFFLINE_MODE" ]]; then
|
||||
echo ""
|
||||
echo "==> Sandbox preflight"
|
||||
validate_offline_sandbox_prerequisites
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Starting gateway"
|
||||
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
|
||||
run_gateway_up current
|
||||
|
||||
# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
@@ -628,13 +848,19 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
echo "==> Sandbox setup"
|
||||
|
||||
sandbox_dockerfile="$ROOT_DIR/scripts/docker/sandbox/Dockerfile"
|
||||
if [[ -f "$sandbox_dockerfile" ]]; then
|
||||
echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
|
||||
if [[ -z "$OFFLINE_MODE" && ! -S "$DOCKER_SOCKET_PATH" ]]; then
|
||||
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
|
||||
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
|
||||
SANDBOX_ENABLED=""
|
||||
fi
|
||||
|
||||
if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" && -f "$sandbox_dockerfile" ]]; then
|
||||
echo "Building sandbox image: $DEFAULT_SANDBOX_IMAGE"
|
||||
run_docker_build \
|
||||
-t "openclaw-sandbox:bookworm-slim" \
|
||||
-t "$DEFAULT_SANDBOX_IMAGE" \
|
||||
-f "$sandbox_dockerfile" \
|
||||
"$ROOT_DIR"
|
||||
else
|
||||
elif [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]]; then
|
||||
echo "WARNING: sandbox Dockerfile not found at $sandbox_dockerfile" >&2
|
||||
echo " Sandbox config will be applied but no sandbox image will be built." >&2
|
||||
echo " Agent exec may fail if the configured sandbox image does not exist." >&2
|
||||
@@ -643,7 +869,8 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
# Defense-in-depth: verify Docker CLI in the running image before enabling
|
||||
# sandbox. This avoids claiming sandbox is enabled when the image cannot
|
||||
# launch sandbox containers.
|
||||
if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
|
||||
if [[ -n "$SANDBOX_ENABLED" && -z "$OFFLINE_MODE" ]] &&
|
||||
! run_compose_one_off --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
|
||||
echo "WARNING: Docker CLI not found inside the container image." >&2
|
||||
echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
|
||||
echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
|
||||
@@ -656,27 +883,21 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
# Mount Docker socket via a dedicated compose overlay. This overlay is
|
||||
# created only after sandbox prerequisites pass, so the socket is never
|
||||
# exposed when sandbox cannot actually run.
|
||||
if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
|
||||
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
|
||||
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
|
||||
SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
|
||||
cat >"$SANDBOX_COMPOSE_FILE" <<YAML
|
||||
services:
|
||||
openclaw-gateway:
|
||||
volumes:
|
||||
- $(quote_yaml_string "${DOCKER_SOCKET_PATH}:/var/run/docker.sock")
|
||||
YAML
|
||||
if [[ -n "${DOCKER_GID:-}" ]]; then
|
||||
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
|
||||
if [[ -n "${DOCKER_GID:-}" ]]; then
|
||||
cat >>"$SANDBOX_COMPOSE_FILE" <<YAML
|
||||
group_add:
|
||||
- "${DOCKER_GID}"
|
||||
YAML
|
||||
fi
|
||||
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
|
||||
echo "==> Sandbox: added Docker socket mount"
|
||||
else
|
||||
echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
|
||||
echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
|
||||
SANDBOX_ENABLED=""
|
||||
fi
|
||||
COMPOSE_ARGS+=("-f" "$SANDBOX_COMPOSE_FILE")
|
||||
echo "==> Sandbox: added Docker socket mount"
|
||||
fi
|
||||
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
@@ -702,7 +923,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
|
||||
echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
|
||||
# Restart gateway with sandbox compose overlay to pick up socket mount + config.
|
||||
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
|
||||
run_gateway_up current
|
||||
else
|
||||
echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
|
||||
echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
|
||||
@@ -716,7 +937,7 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
rm -f "$SANDBOX_COMPOSE_FILE"
|
||||
fi
|
||||
# Ensure gateway service definition is reset without sandbox overlay mount.
|
||||
docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
|
||||
run_gateway_up base --force-recreate
|
||||
fi
|
||||
else
|
||||
# Keep reruns deterministic: if sandbox is not active for this run, reset
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import childProcess from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -30,7 +31,6 @@ const DEFAULT_MAX_COMMAND_RSS_MIB = 8192;
|
||||
const DEFAULT_OUTPUT_CAPTURE_CHARS = 1024 * 1024;
|
||||
const GATEWAY_TEARDOWN_GRACE_MS = 10000;
|
||||
const GATEWAY_TEARDOWN_KILL_GRACE_MS = 2000;
|
||||
const DEFAULT_PORT = 19000 + Math.floor(Math.random() * 1000);
|
||||
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
|
||||
const LOG_TAIL_BYTES = 256 * 1024;
|
||||
@@ -65,12 +65,17 @@ Environment:
|
||||
OPENCLAW_ENTRY Built OpenClaw entrypoint. Defaults to dist/index.mjs or dist/index.js.
|
||||
OPENCLAW_KITCHEN_SINK_NPM_SPEC Plugin package spec. Default: npm:@openclaw/kitchen-sink@latest.
|
||||
OPENCLAW_KITCHEN_SINK_PLUGIN_ID Plugin id. Default: openclaw-kitchen-sink-fixture.
|
||||
OPENCLAW_KITCHEN_SINK_PERSONALITY Plugin fixture personality. Default: conformance.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_PORT Gateway loopback port. Default: OS-selected free port.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_READY_MS Gateway readiness timeout.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_COMMAND_MS OpenClaw command timeout.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_INSTALL_MS Plugin install timeout.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_CALL_MS RPC call timeout.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_FETCH_MS HTTP readiness probe timeout.
|
||||
OPENCLAW_KITCHEN_SINK_RPC_FETCH_BODY_BYTES HTTP readiness probe response ceiling.
|
||||
OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB Gateway RSS ceiling.
|
||||
OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB Install/CLI command RSS ceiling.
|
||||
OPENCLAW_KITCHEN_SINK_OUTPUT_CAPTURE_CHARS Per-command stdout/stderr capture ceiling.
|
||||
OPENCLAW_KITCHEN_SINK_KEEP_TMP=1 Preserve the isolated temp home.
|
||||
`;
|
||||
}
|
||||
@@ -145,6 +150,42 @@ export function resolveKitchenSinkRpcConfig(env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function findAvailableLoopbackPort(options = {}) {
|
||||
const createServer = options.createServer ?? (() => net.createServer());
|
||||
const server = createServer();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const fail = (error) => {
|
||||
server.close?.(() => {});
|
||||
reject(toLintErrorObject(error, "Unable to reserve Kitchen Sink RPC loopback port"));
|
||||
};
|
||||
server.once("error", fail);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off?.("error", fail);
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(toLintErrorObject(error, "Unable to close Kitchen Sink RPC loopback port"));
|
||||
return;
|
||||
}
|
||||
if (!Number.isSafeInteger(port) || port <= 0) {
|
||||
reject(new Error(`unable to reserve Kitchen Sink RPC loopback port: ${String(port)}`));
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveKitchenSinkRpcPort(env = process.env, options = {}) {
|
||||
const rawPort = (env.OPENCLAW_KITCHEN_SINK_RPC_PORT || "").trim();
|
||||
if (rawPort) {
|
||||
return readPositiveInt(rawPort, 0, "OPENCLAW_KITCHEN_SINK_RPC_PORT");
|
||||
}
|
||||
return await (options.findAvailablePort ?? findAvailableLoopbackPort)();
|
||||
}
|
||||
|
||||
function resolveOpenClawRunner() {
|
||||
if (process.env.OPENCLAW_ENTRY) {
|
||||
return {
|
||||
@@ -980,7 +1021,8 @@ async function startGateway(runner, port, env, logPath) {
|
||||
}
|
||||
|
||||
export async function stopGateway(child, options = {}) {
|
||||
if (!child || hasChildExited(child)) {
|
||||
const killProcess = options.killProcess ?? defaultKillProcess;
|
||||
if (!child || !isGatewayAlive(child, killProcess)) {
|
||||
return;
|
||||
}
|
||||
const teardownGraceMs = Math.max(0, options.teardownGraceMs ?? GATEWAY_TEARDOWN_GRACE_MS);
|
||||
@@ -988,18 +1030,21 @@ export async function stopGateway(child, options = {}) {
|
||||
const exited = new Promise((resolve) => {
|
||||
child.once("exit", resolve);
|
||||
});
|
||||
const waitForExit = async (ms) =>
|
||||
hasChildExited(child)
|
||||
? true
|
||||
: await Promise.race([exited.then(() => true), delay(ms).then(() => false)]);
|
||||
const waitForExit = async (ms) => {
|
||||
if (!isGatewayAlive(child, killProcess)) {
|
||||
return true;
|
||||
}
|
||||
await Promise.race([exited, delay(ms)]);
|
||||
return !isGatewayAlive(child, killProcess);
|
||||
};
|
||||
|
||||
if (!signalGateway(child, "SIGTERM")) {
|
||||
if (!signalGateway(child, "SIGTERM", killProcess)) {
|
||||
return;
|
||||
}
|
||||
if (await waitForExit(teardownGraceMs)) {
|
||||
return;
|
||||
}
|
||||
if (!signalGateway(child, "SIGKILL")) {
|
||||
if (!signalGateway(child, "SIGKILL", killProcess)) {
|
||||
return;
|
||||
}
|
||||
if (await waitForExit(killGraceMs)) {
|
||||
@@ -1012,6 +1057,25 @@ export function hasChildExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
function defaultKillProcess(pid, signal) {
|
||||
return process.kill(pid, signal);
|
||||
}
|
||||
|
||||
function isGatewayAlive(child, killProcess) {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
killProcess(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return !hasChildExited(child);
|
||||
}
|
||||
|
||||
function createChildExitPromise(child) {
|
||||
if (!child || typeof child.once !== "function") {
|
||||
return null;
|
||||
@@ -1028,10 +1092,10 @@ function releaseUnsettledGatewayChild(child) {
|
||||
child.unref?.();
|
||||
}
|
||||
|
||||
function signalGateway(child, signal) {
|
||||
function signalGateway(child, signal, killProcess = defaultKillProcess) {
|
||||
if (process.platform !== "win32" && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
killProcess(-child.pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.code === "ESRCH") {
|
||||
@@ -2200,11 +2264,7 @@ function isNonEmptyString(value) {
|
||||
export async function main() {
|
||||
const config = resolveKitchenSinkRpcConfig();
|
||||
let runner = resolveOpenClawRunner();
|
||||
const port = readPositiveInt(
|
||||
process.env.OPENCLAW_KITCHEN_SINK_RPC_PORT,
|
||||
DEFAULT_PORT,
|
||||
"OPENCLAW_KITCHEN_SINK_RPC_PORT",
|
||||
);
|
||||
const port = await resolveKitchenSinkRpcPort();
|
||||
const { root, env } = makeEnv();
|
||||
const logPath = path.join(root, "gateway.log");
|
||||
const keepTmp = process.env.OPENCLAW_KITCHEN_SINK_KEEP_TMP === "1";
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
// Probe script for plugin lifecycle matrix E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
const home = os.homedir();
|
||||
|
||||
function openclawPath(...parts) {
|
||||
return path.join(home, ".openclaw", ...parts);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readRequiredJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`failed to read JSON from ${file}: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function records() {
|
||||
return readPluginInstallRecords();
|
||||
}
|
||||
|
||||
function recordFor(pluginId) {
|
||||
return records()[pluginId];
|
||||
}
|
||||
|
||||
function config() {
|
||||
return readJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
|
||||
}
|
||||
|
||||
function requiredConfig() {
|
||||
return readRequiredJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVersion(pluginId, version) {
|
||||
const record = recordFor(pluginId);
|
||||
assert(record, `install record missing for ${pluginId}`);
|
||||
assert(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
|
||||
assert(
|
||||
record.resolvedVersion === version || record.version === version,
|
||||
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
|
||||
);
|
||||
assert(record.installPath, `install path missing for ${pluginId}`);
|
||||
const packageJson = readJson(path.join(record.installPath, "package.json"));
|
||||
assert(
|
||||
packageJson.version === version,
|
||||
`expected installed package version ${version}, got ${packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNpmProjectRoot(pluginId, packageName) {
|
||||
const record = recordFor(pluginId);
|
||||
assert(record?.installPath, `install path missing for ${pluginId}`);
|
||||
const relative = path.relative(openclawPath("npm", "projects"), record.installPath);
|
||||
assert(
|
||||
!relative.startsWith("..") && !path.isAbsolute(relative),
|
||||
`install path outside npm projects: ${record.installPath}`,
|
||||
);
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = packageName.split("/");
|
||||
assert(
|
||||
segments.length === 2 + packageSegments.length,
|
||||
`unexpected npm project install path: ${record.installPath}`,
|
||||
);
|
||||
assert(Boolean(segments[0]), `missing npm project directory: ${record.installPath}`);
|
||||
assert(
|
||||
segments[1] === "node_modules",
|
||||
`missing project node_modules segment: ${record.installPath}`,
|
||||
);
|
||||
for (let index = 0; index < packageSegments.length; index++) {
|
||||
assert(
|
||||
segments[index + 2] === packageSegments[index],
|
||||
`package path mismatch: ${record.installPath}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!fs.existsSync(openclawPath("npm", "node_modules", ...packageSegments)),
|
||||
`legacy flat npm install path exists for ${packageName}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertInspectLoaded(pluginId, inspectPath) {
|
||||
assert(inspectPath, "inspect JSON path is required");
|
||||
const inspect = readRequiredJson(inspectPath);
|
||||
const plugin = inspect.plugin;
|
||||
assert(plugin?.id === pluginId, `expected inspected plugin id ${pluginId}, got ${plugin?.id}`);
|
||||
assert(plugin.enabled === true, `expected ${pluginId} inspect enabled=true`);
|
||||
assert(
|
||||
plugin.status === "loaded",
|
||||
`expected ${pluginId} inspect status loaded, got ${plugin.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertEnabled(pluginId, expectedRaw) {
|
||||
const expected = expectedRaw === "true";
|
||||
const entry = config().plugins?.entries?.[pluginId];
|
||||
assert(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
|
||||
}
|
||||
|
||||
function printInstallPath(pluginId) {
|
||||
const record = recordFor(pluginId);
|
||||
assert(record?.installPath, `install path missing for ${pluginId}`);
|
||||
process.stdout.write(record.installPath);
|
||||
}
|
||||
|
||||
function assertUninstalled(pluginId) {
|
||||
const cfg = requiredConfig();
|
||||
const record = recordFor(pluginId);
|
||||
assert(!record, `install record still present for ${pluginId}`);
|
||||
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
|
||||
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
|
||||
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
|
||||
const loadPaths = cfg.plugins?.load?.paths ?? [];
|
||||
assert(
|
||||
!loadPaths.some((entry) => String(entry).includes(pluginId)),
|
||||
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [command, pluginId, arg] = process.argv.slice(2);
|
||||
switch (command) {
|
||||
case "assert-version":
|
||||
assertVersion(pluginId, arg);
|
||||
break;
|
||||
case "assert-npm-project-root":
|
||||
assertNpmProjectRoot(pluginId, arg);
|
||||
break;
|
||||
case "assert-inspect-loaded":
|
||||
assertInspectLoaded(pluginId, arg);
|
||||
break;
|
||||
case "assert-enabled":
|
||||
assertEnabled(pluginId, arg);
|
||||
break;
|
||||
case "install-path":
|
||||
printInstallPath(pluginId);
|
||||
break;
|
||||
case "assert-uninstalled":
|
||||
assertUninstalled(pluginId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown plugin lifecycle matrix probe command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_package /tmp/openclaw-plugin-lifecycle-install.log "mounted OpenClaw package" /tmp/npm-prefix
|
||||
|
||||
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
export PATH="/tmp/npm-prefix/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
|
||||
source scripts/e2e/lib/plugins/fixtures.sh
|
||||
|
||||
plugin_id="lifecycle-claw"
|
||||
package_name="@openclaw/lifecycle-claw"
|
||||
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
|
||||
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
|
||||
resource_dir="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"
|
||||
pack_root=""
|
||||
registry_root=""
|
||||
tarball_v1="$resource_dir/lifecycle-claw-1.0.0.tgz"
|
||||
tarball_v2="$resource_dir/lifecycle-claw-2.0.0.tgz"
|
||||
inspect_v1="$resource_dir/plugin-lifecycle-inspect-v1.json"
|
||||
|
||||
cleanup() {
|
||||
openclaw_plugins_cleanup_fixture_servers
|
||||
rm -rf "$resource_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
summary_tsv="$resource_dir/resource-summary.tsv"
|
||||
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
|
||||
|
||||
run_measured() {
|
||||
local phase="$1"
|
||||
shift
|
||||
|
||||
echo "Running plugin lifecycle phase: $phase"
|
||||
node "$measure" "$summary_tsv" "$phase" -- "$@"
|
||||
}
|
||||
|
||||
pack_root="$(mktemp -d "$resource_dir/pack.XXXXXX")"
|
||||
registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"
|
||||
pack_fixture_plugin "$pack_root/v1" "$tarball_v1" "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
|
||||
pack_fixture_plugin "$pack_root/v2" "$tarball_v2" "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
|
||||
start_npm_fixture_registry "$package_name" 1.0.0 "$tarball_v1" "$registry_root" "$package_name" 2.0.0 "$tarball_v2"
|
||||
trap cleanup EXIT
|
||||
|
||||
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >"$3"' bash "$entry" "$plugin_id" "$inspect_v1"
|
||||
node "$probe" assert-inspect-loaded "$plugin_id" "$inspect_v1"
|
||||
|
||||
run_measured disable node "$entry" plugins disable "$plugin_id"
|
||||
node "$probe" assert-enabled "$plugin_id" false
|
||||
|
||||
run_measured enable node "$entry" plugins enable "$plugin_id"
|
||||
node "$probe" assert-enabled "$plugin_id" true
|
||||
|
||||
run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 2.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
install_path="$(node "$probe" install-path "$plugin_id")"
|
||||
rm -rf "$install_path"
|
||||
if [[ -e "$install_path" ]]; then
|
||||
echo "Failed to remove plugin code before missing-code uninstall: $install_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_measured missing-code-uninstall node "$entry" plugins uninstall "$plugin_id" --force
|
||||
node "$probe" assert-uninstalled "$plugin_id"
|
||||
|
||||
echo "Plugin lifecycle resource summary:"
|
||||
cat "$summary_tsv"
|
||||
echo "Plugin lifecycle matrix passed."
|
||||
@@ -13,6 +13,11 @@ PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ
|
||||
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
|
||||
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}"
|
||||
case "$OUTPUT_DIR" in
|
||||
/*) OUTPUT_DIR_HOST="$OUTPUT_DIR" ;;
|
||||
*) OUTPUT_DIR_HOST="$ROOT_DIR/$OUTPUT_DIR" ;;
|
||||
esac
|
||||
OUTPUT_DIR_CONTAINER="/app/.artifacts/qa-e2e/npm-telegram-live-output"
|
||||
|
||||
resolve_credential_source() {
|
||||
if [ -n "${OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE:-}" ]; then
|
||||
@@ -156,6 +161,7 @@ validate_credential_preflight
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" npm-telegram-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET"
|
||||
|
||||
mkdir -p "$ROOT_DIR/.artifacts/qa-e2e"
|
||||
mkdir -p "$OUTPUT_DIR_HOST"
|
||||
run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-telegram-live.XXXXXX")"
|
||||
npm_prefix_host="$(mktemp -d "$ROOT_DIR/.artifacts/qa-e2e/npm-telegram-live-prefix.XXXXXX")"
|
||||
trap 'rm -f "$run_log"; rm -rf "$npm_prefix_host"' EXIT
|
||||
@@ -166,7 +172,7 @@ docker_env=(
|
||||
-e TMPDIR=/tmp
|
||||
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="$PACKAGE_SPEC"
|
||||
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL"
|
||||
-e OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="$OUTPUT_DIR"
|
||||
-e OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="$OUTPUT_DIR_CONTAINER"
|
||||
-e OPENCLAW_QA_PACKAGE_SOURCE="$package_install_source"
|
||||
-e OPENCLAW_QA_PACKAGE_SOURCE_KIND="$package_source_kind"
|
||||
-e OPENCLAW_QA_RUNNER="${OPENCLAW_QA_RUNNER:-docker}"
|
||||
@@ -290,6 +296,7 @@ EOF
|
||||
run_logged docker_e2e_run_with_harness \
|
||||
"${docker_env[@]}" \
|
||||
-v "$ROOT_DIR/.artifacts:/app/.artifacts" \
|
||||
-v "$OUTPUT_DIR_HOST:$OUTPUT_DIR_CONTAINER" \
|
||||
-v "$ROOT_DIR/extensions/qa-lab:/app/extensions/qa-lab:ro" \
|
||||
-v "$npm_prefix_host:/npm-global" \
|
||||
-i "$IMAGE_NAME" bash -s <<'EOF'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Guest Transports script supports OpenClaw repository automation.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { run } from "./host-command.ts";
|
||||
import type { PhaseRunner } from "./phase-runner.ts";
|
||||
import { encodePowerShell, psSingleQuote } from "./powershell.ts";
|
||||
@@ -24,6 +25,10 @@ export interface WindowsBackgroundPowerShellOptions {
|
||||
vmName: string;
|
||||
}
|
||||
|
||||
function guestScriptName(extension: string): string {
|
||||
return `openclaw-parallels-${randomUUID()}.${extension}`;
|
||||
}
|
||||
|
||||
function appendOutput(
|
||||
append: ((chunk: string | Uint8Array) => void) | undefined,
|
||||
result: CommandResult,
|
||||
@@ -65,7 +70,7 @@ export async function runWindowsBackgroundPowerShell(
|
||||
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
|
||||
const runCommand = options.runCommand ?? run;
|
||||
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
|
||||
const nonce = `${safeLabel}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const nonce = `${safeLabel}-${randomUUID()}`;
|
||||
const fileBase = `openclaw-parallels-${nonce}`;
|
||||
const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)}
|
||||
$scriptPath = "$base.ps1"
|
||||
@@ -366,7 +371,7 @@ export class LinuxGuest {
|
||||
}
|
||||
|
||||
bash(script: string): string {
|
||||
const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`;
|
||||
const scriptPath = `/tmp/${guestScriptName("sh")}`;
|
||||
const write = run(
|
||||
"prlctl",
|
||||
[
|
||||
@@ -450,7 +455,7 @@ export class MacosGuest {
|
||||
}
|
||||
|
||||
sh(script: string, env: Record<string, string> = {}): string {
|
||||
const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`;
|
||||
const scriptPath = `/tmp/${guestScriptName("sh")}`;
|
||||
this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], {
|
||||
input: `umask 022\n${script}`,
|
||||
});
|
||||
@@ -486,7 +491,7 @@ export class WindowsGuest {
|
||||
}
|
||||
|
||||
powershell(script: string, options: GuestExecOptions = {}): string {
|
||||
const scriptName = `openclaw-parallels-${process.pid}-${Date.now()}.ps1`;
|
||||
const scriptName = guestScriptName("ps1");
|
||||
const writeScript = `$scriptPath = Join-Path $env:TEMP ${JSON.stringify(scriptName)}
|
||||
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))`;
|
||||
const write = run(
|
||||
|
||||
@@ -94,7 +94,7 @@ async function stopHostServerChild(
|
||||
terminateTimeoutMs = 2_000,
|
||||
killTimeoutMs = 1_500,
|
||||
): Promise<boolean> {
|
||||
if (child.exitCode != null) {
|
||||
if (hasHostServerChildExited(child)) {
|
||||
return true;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
@@ -109,13 +109,13 @@ async function waitForChildExit(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
if (child.exitCode != null) {
|
||||
if (hasHostServerChildExited(child)) {
|
||||
return true;
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const onExit = () => settle(true);
|
||||
const timeout = setTimeout(() => settle(child.exitCode != null), timeoutMs);
|
||||
const timeout = setTimeout(() => settle(hasHostServerChildExited(child)), timeoutMs);
|
||||
timeout.unref();
|
||||
function settle(exited: boolean): void {
|
||||
if (settled) {
|
||||
@@ -130,6 +130,10 @@ async function waitForChildExit(
|
||||
});
|
||||
}
|
||||
|
||||
function hasHostServerChildExited(child: ChildProcessWithoutNullStreams): boolean {
|
||||
return child.exitCode != null || child.signalCode != null;
|
||||
}
|
||||
|
||||
async function waitForHostServer(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
port: number,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Macos Discord script supports OpenClaw repository automation.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { MacosGuest } from "./guest-transports.ts";
|
||||
@@ -58,7 +59,7 @@ ${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe
|
||||
}
|
||||
|
||||
async runRoundtrip(phase: DiscordSmokePhase): Promise<void> {
|
||||
const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const nonce = randomUUID();
|
||||
const outboundNonce = `${phase}-out-${nonce}`;
|
||||
const inboundNonce = `${phase}-in-${nonce}`;
|
||||
const outboundLog = path.join(this.input.runDir, `${phase}.discord-send.json`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env -S pnpm tsx
|
||||
// Npm Update Smoke script supports OpenClaw repository automation.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { copyFile, readFile, rm } from "node:fs/promises";
|
||||
@@ -1098,7 +1099,7 @@ export class NpmUpdateSmoke {
|
||||
}
|
||||
|
||||
private writeGuestScript(vm: string, script: string, prefix: string): string {
|
||||
const scriptPath = `/tmp/${prefix}-${process.pid}-${Date.now()}.sh`;
|
||||
const scriptPath = `/tmp/${prefix}-${randomUUID()}.sh`;
|
||||
const write = run("prlctl", ["exec", vm, "/usr/bin/tee", scriptPath], {
|
||||
check: false,
|
||||
input: script,
|
||||
|
||||
@@ -17,12 +17,10 @@ PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-lifecycle-matrix "${OPENCLA
|
||||
docker_e2e_package_mount_args "$PACKAGE_TGZ"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-lifecycle-matrix "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-lifecycle-matrix empty)"
|
||||
DOCKER_ENV_ARGS=(
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
-e OPENCLAW_SKIP_CHANNELS=1
|
||||
-e OPENCLAW_SKIP_PROVIDERS=1
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
|
||||
)
|
||||
if [ -n "${OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS:-}" ]; then
|
||||
DOCKER_ENV_ARGS+=(-e "OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS=$OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS")
|
||||
@@ -45,6 +43,6 @@ docker_e2e_run_with_harness \
|
||||
"${DOCKER_ENV_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
|
||||
tsx test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts --lifecycle-matrix
|
||||
|
||||
echo "Plugin lifecycle matrix Docker E2E passed."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
// Telegram User Credential script supports OpenClaw repository automation.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -224,6 +224,10 @@ async function postBroker(params: {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildTelegramUserCredentialOwnerId() {
|
||||
return `telegram-user-${randomUUID()}`;
|
||||
}
|
||||
|
||||
async function resolveConvexLeaseConfig(opts: Map<string, string>) {
|
||||
const envFile = opts.get("env-file") || DEFAULT_CONVEX_ENV_FILE;
|
||||
const fileEnv = await readEnvFile(envFile);
|
||||
@@ -259,7 +263,7 @@ async function resolveConvexLeaseConfig(opts: Map<string, string>) {
|
||||
ownerId:
|
||||
opts.get("owner-id") ||
|
||||
process.env.OPENCLAW_QA_CREDENTIAL_OWNER_ID?.trim() ||
|
||||
`telegram-user-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
|
||||
buildTelegramUserCredentialOwnerId(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,8 @@ docker_e2e_harness_mount_args() {
|
||||
DOCKER_E2E_HARNESS_ARGS=(
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro"
|
||||
-v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro"
|
||||
-v "$ROOT_DIR/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"
|
||||
-v "$ROOT_DIR/test/helpers:/app/test/helpers:ro"
|
||||
-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
const TEARDOWN_GRACE_MS = 2_000;
|
||||
const TEARDOWN_KILL_GRACE_MS = 1_000;
|
||||
const EXIT_POLL_MS = 10;
|
||||
|
||||
export type ChildExit = {
|
||||
exitCode: number | null;
|
||||
@@ -23,43 +24,95 @@ export async function stopChild(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
options: { killGraceMs?: number; teardownGraceMs?: number } = {},
|
||||
): Promise<StopChildResult> {
|
||||
const currentExit = (): ChildExit | null =>
|
||||
child.exitCode != null || child.signalCode != null
|
||||
const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS;
|
||||
const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS;
|
||||
let observedExit: ChildExit | null = null;
|
||||
const directExit = (): ChildExit | null =>
|
||||
observedExit ??
|
||||
(child.exitCode != null || child.signalCode != null
|
||||
? { exitCode: child.exitCode, signal: child.signalCode }
|
||||
: null;
|
||||
: null);
|
||||
const currentExit = (): ChildExit | null => {
|
||||
const exit = directExit();
|
||||
if (exit == null || isProcessTreeAlive(child)) {
|
||||
return null;
|
||||
}
|
||||
return exit;
|
||||
};
|
||||
const waitForProcessTreeExit = async (ms: number): Promise<boolean> => {
|
||||
const deadlineAt = Date.now() + ms;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!isProcessTreeAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await delay(Math.min(EXIT_POLL_MS, deadlineAt - Date.now()));
|
||||
}
|
||||
return !isProcessTreeAlive(child);
|
||||
};
|
||||
const cleanupExitedProcessTree = async (
|
||||
exit: ChildExit,
|
||||
exitedBeforeTeardown: boolean,
|
||||
): Promise<StopChildResult> => {
|
||||
if (!isProcessTreeAlive(child)) {
|
||||
return { ...exit, exitedBeforeTeardown };
|
||||
}
|
||||
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
|
||||
if (sentTeardownSignal) {
|
||||
await waitForProcessTreeExit(teardownGraceMs);
|
||||
}
|
||||
if (sentTeardownSignal && isProcessTreeAlive(child)) {
|
||||
killProcessTree(child, "SIGKILL");
|
||||
await waitForProcessTreeExit(killGraceMs);
|
||||
}
|
||||
if (!sentTeardownSignal) {
|
||||
releaseUnsettledChild(child);
|
||||
}
|
||||
return { ...exit, exitedBeforeTeardown };
|
||||
};
|
||||
|
||||
const existingExit = currentExit();
|
||||
const existingExit = directExit();
|
||||
if (existingExit != null) {
|
||||
return { ...existingExit, exitedBeforeTeardown: true };
|
||||
return await cleanupExitedProcessTree(existingExit, true);
|
||||
}
|
||||
|
||||
let observedExit: ChildExit | null = null;
|
||||
const exited = new Promise<ChildExit>((resolve) => {
|
||||
child.once("exit", (exitCode, signal) => {
|
||||
observedExit = { exitCode, signal };
|
||||
resolve(observedExit);
|
||||
});
|
||||
});
|
||||
const waitForExit = async (ms: number): Promise<ChildExit | null> =>
|
||||
await Promise.race([exited, delay(ms).then(() => null)]);
|
||||
const waitForExit = async (ms: number): Promise<ChildExit | null> => {
|
||||
const deadlineAt = Date.now() + ms;
|
||||
while (Date.now() < deadlineAt) {
|
||||
const waitMs = Math.min(EXIT_POLL_MS, deadlineAt - Date.now());
|
||||
if (directExit() == null) {
|
||||
await Promise.race([exited, delay(waitMs)]);
|
||||
} else {
|
||||
await delay(waitMs);
|
||||
}
|
||||
const exit = currentExit();
|
||||
if (exit != null) {
|
||||
return exit;
|
||||
}
|
||||
}
|
||||
return currentExit();
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
const queuedExit = observedExit ?? currentExit();
|
||||
const queuedExit = directExit();
|
||||
if (queuedExit != null) {
|
||||
return { ...queuedExit, exitedBeforeTeardown: true };
|
||||
return await cleanupExitedProcessTree(queuedExit, true);
|
||||
}
|
||||
|
||||
const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS;
|
||||
const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS;
|
||||
const sentTeardownSignal = killProcessTree(child, "SIGTERM");
|
||||
const gracefulExit = await waitForExit(teardownGraceMs);
|
||||
if (gracefulExit != null) {
|
||||
return { ...gracefulExit, exitedBeforeTeardown: !sentTeardownSignal };
|
||||
}
|
||||
|
||||
const postGraceExit = currentExit() ?? observedExit;
|
||||
const postGraceExit = currentExit();
|
||||
if (postGraceExit != null) {
|
||||
return { ...postGraceExit, exitedBeforeTeardown: !sentTeardownSignal };
|
||||
}
|
||||
@@ -70,7 +123,7 @@ export async function stopChild(
|
||||
|
||||
killProcessTree(child, "SIGKILL");
|
||||
const killedExit = await waitForExit(killGraceMs);
|
||||
const finalExit = killedExit ?? currentExit() ?? observedExit;
|
||||
const finalExit = killedExit ?? currentExit();
|
||||
if (finalExit != null) {
|
||||
return { ...finalExit, exitedBeforeTeardown: false };
|
||||
}
|
||||
@@ -86,6 +139,23 @@ function releaseUnsettledChild(child: ChildProcessWithoutNullStreams): void {
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function isProcessTreeAlive(child: ChildProcessWithoutNullStreams): boolean {
|
||||
if (process.platform === "win32" || child.pid === undefined) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return isProcessStillExistsError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessStillExistsError(error: unknown): boolean {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return code === "EPERM";
|
||||
}
|
||||
|
||||
function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
|
||||
if (process.platform !== "win32" && child.pid !== undefined) {
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_ITERATIONS = 10;
|
||||
export const READY_TIMEOUT_MS = 120_000;
|
||||
/** Per-probe timeout used while polling gateway readiness endpoints. */
|
||||
export const READY_PROBE_TIMEOUT_MS = 1_000;
|
||||
const GATEWAY_FORCE_KILL_GRACE_MS = 250;
|
||||
const PARENT_TERMINATION_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"];
|
||||
const IS_DIRECT_RUN =
|
||||
typeof process.argv[1] === "string" &&
|
||||
@@ -320,10 +321,12 @@ export async function stopGateway(child, options = {}) {
|
||||
return;
|
||||
}
|
||||
const killGraceMs = Math.max(0, options.killGraceMs ?? 1_500);
|
||||
const forceKillGraceMs = Math.max(0, options.forceKillGraceMs ?? GATEWAY_FORCE_KILL_GRACE_MS);
|
||||
signalGatewayProcess(child, "SIGTERM", options.killProcess);
|
||||
const exited = await waitForGatewayExit(child, killGraceMs, options.killProcess);
|
||||
if (!exited) {
|
||||
signalGatewayProcess(child, "SIGKILL", options.killProcess);
|
||||
await waitForGatewayExit(child, forceKillGraceMs, options.killProcess);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Executed directly via Node.js + tsx in the release workflow.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
@@ -2354,7 +2355,7 @@ async function runInstalledModelsSet(params) {
|
||||
async function runInstalledAgentTurn(params) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
const sessionId = `cross-os-release-check-${params.label}-${Date.now()}-${attempt}`;
|
||||
const sessionId = buildCrossOsReleaseAgentSessionId(params.label, attempt);
|
||||
try {
|
||||
const logOffset = readLogFileSize(params.logPath);
|
||||
const result = await runInstalledCli({
|
||||
@@ -2756,8 +2757,7 @@ async function maybeRunDiscordRoundtrip(params) {
|
||||
return "skipped-missing-config";
|
||||
}
|
||||
|
||||
const outboundNonce = `native-cross-os-outbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const inboundNonce = `native-cross-os-inbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const { outboundNonce, inboundNonce } = buildCrossOsDiscordRoundtripNonces();
|
||||
let sentMessageId = null;
|
||||
let hostMessageId = null;
|
||||
try {
|
||||
@@ -3282,7 +3282,7 @@ async function runModelsSet(params) {
|
||||
async function runAgentTurn(params) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
const sessionId = `cross-os-release-check-${params.label}-${Date.now()}-${attempt}`;
|
||||
const sessionId = buildCrossOsReleaseAgentSessionId(params.label, attempt);
|
||||
try {
|
||||
const logOffset = readLogFileSize(params.logPath);
|
||||
const result = await runOpenClaw({
|
||||
@@ -3365,6 +3365,17 @@ export function shouldSkipOptionalCrossOsAgentTurnError(error, logPath) {
|
||||
return /"status"\s*:\s*"timeout"|Request timed out before a response was generated/u.test(log);
|
||||
}
|
||||
|
||||
export function buildCrossOsReleaseAgentSessionId(label, attempt) {
|
||||
return `cross-os-release-check-${label}-${randomUUID()}-${attempt}`;
|
||||
}
|
||||
|
||||
export function buildCrossOsDiscordRoundtripNonces() {
|
||||
return {
|
||||
outboundNonce: `native-cross-os-outbound-${randomUUID()}`,
|
||||
inboundNonce: `native-cross-os-inbound-${randomUUID()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReleaseAgentTurnArgs(sessionId) {
|
||||
return [
|
||||
"agent",
|
||||
|
||||
@@ -14,6 +14,8 @@ const DEFAULT_PACKAGE_INVENTORY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_PACKAGE_PACK_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_PACKAGE_TARBALL_CHECK_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_TIMEOUT_KILL_AFTER_MS = 5_000;
|
||||
const PROCESS_GROUP_EXIT_POLL_MS = 25;
|
||||
const POST_FORCE_KILL_WAIT_MS = 1_000;
|
||||
const DEFAULT_CAPTURED_STDOUT_MAX_BYTES = 1024 * 1024;
|
||||
const ACTIVE_CHILD_KILLERS = new Set();
|
||||
const SIGNAL_EXIT_CODES = {
|
||||
@@ -208,6 +210,18 @@ function run(command, args, cwd, options = {}) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
};
|
||||
const waitForProcessGroupExit = async (timeoutMs) => {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processGroupAlive()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !processGroupAlive();
|
||||
};
|
||||
const terminateChild = () => {
|
||||
killChild("SIGTERM");
|
||||
forceKillTimeout = setTimeout(() => {
|
||||
@@ -228,6 +242,16 @@ function run(command, args, cwd, options = {}) {
|
||||
terminateChild();
|
||||
}, options.timeoutMs);
|
||||
timeout?.unref?.();
|
||||
const finishAfterTeardown = async (error, value = "") => {
|
||||
if (processGroupAlive()) {
|
||||
await waitForProcessGroupExit(options.killAfterMs ?? DEFAULT_TIMEOUT_KILL_AFTER_MS);
|
||||
}
|
||||
if (processGroupAlive()) {
|
||||
killChild("SIGKILL");
|
||||
await waitForProcessGroupExit(POST_FORCE_KILL_WAIT_MS);
|
||||
}
|
||||
finish(error, value);
|
||||
};
|
||||
if (options.captureStdout) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
if (outputLimitExceeded) {
|
||||
@@ -250,11 +274,13 @@ function run(command, args, cwd, options = {}) {
|
||||
child.on("error", (error) => finish(error));
|
||||
child.on("close", (status, signal) => {
|
||||
if (timedOut) {
|
||||
finish(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`));
|
||||
void finishAfterTeardown(
|
||||
new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (outputLimitExceeded) {
|
||||
finish(
|
||||
void finishAfterTeardown(
|
||||
new Error(
|
||||
`${command} ${args.join(" ")} exceeded captured stdout limit (${maxCapturedStdoutBytes} bytes)`,
|
||||
),
|
||||
|
||||
@@ -7,6 +7,8 @@ import { performance } from "node:perf_hooks";
|
||||
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024;
|
||||
const TIMEOUT_KILL_GRACE_MS = 5_000;
|
||||
const PROCESS_GROUP_EXIT_POLL_MS = 25;
|
||||
const POST_FORCE_KILL_WAIT_MS = 1_000;
|
||||
|
||||
/** Ordered list of supplemental boundary checks used by CI sharding. */
|
||||
export const BOUNDARY_CHECKS = [
|
||||
@@ -227,6 +229,31 @@ function terminateChild(child, signal) {
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function processGroupAlive(child) {
|
||||
if (process.platform === "win32" || !child.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessGroupExit(child, timeoutMs) {
|
||||
const deadlineAt = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadlineAt) {
|
||||
if (!processGroupAlive(child)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolvePoll) => {
|
||||
setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
return !processGroupAlive(child);
|
||||
}
|
||||
|
||||
function terminateActiveChildren(activeChildren, signal) {
|
||||
for (const child of activeChildren) {
|
||||
terminateChild(child, signal);
|
||||
@@ -317,6 +344,16 @@ export function runSingleCheck(
|
||||
output: output.read(),
|
||||
});
|
||||
};
|
||||
const finishAfterTimeoutTeardown = async (code, signal) => {
|
||||
if (processGroupAlive(child)) {
|
||||
await waitForProcessGroupExit(child, TIMEOUT_KILL_GRACE_MS);
|
||||
}
|
||||
if (processGroupAlive(child)) {
|
||||
terminateChild(child, "SIGKILL");
|
||||
await waitForProcessGroupExit(child, POST_FORCE_KILL_WAIT_MS);
|
||||
}
|
||||
finish(code, signal);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
output.append(
|
||||
@@ -341,7 +378,13 @@ export function runSingleCheck(
|
||||
output.append(`${error.stack ?? error.message}\n`);
|
||||
finish(1, null);
|
||||
});
|
||||
child.on("close", (code, signal) => finish(code, signal));
|
||||
child.on("close", (code, signal) => {
|
||||
if (timedOut) {
|
||||
void finishAfterTimeoutTeardown(code, signal);
|
||||
return;
|
||||
}
|
||||
finish(code, signal);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const main = async () => {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
BWS_ACCESS_TOKEN: process.env.BWS_ACCESS_TOKEN,
|
||||
BWS_SERVER_URL: process.env.BWS_SERVER_URL,
|
||||
PATH: process.env.PATH || "",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
// scripts/test-projects.mjs, and focused tests. Exports are intentionally
|
||||
// granular so project selection stays testable without spawning Vitest.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isChannelSurfaceTestFile } from "../test/vitest/vitest.channel-paths.mjs";
|
||||
import {
|
||||
commandsLightTestFiles,
|
||||
isCommandsLightTarget,
|
||||
resolveCommandsLightIncludePattern,
|
||||
} from "../test/vitest/vitest.commands-light-paths.mjs";
|
||||
@@ -36,11 +38,13 @@ import { isWhatsAppExtensionRoot } from "../test/vitest/vitest.extension-whatsap
|
||||
import { isZaloExtensionRoot } from "../test/vitest/vitest.extension-zalo-paths.mjs";
|
||||
import {
|
||||
isPluginSdkLightTarget,
|
||||
pluginSdkLightTestFiles,
|
||||
resolvePluginSdkLightIncludePattern,
|
||||
} from "../test/vitest/vitest.plugin-sdk-paths.mjs";
|
||||
import { fullSuiteVitestShards } from "../test/vitest/vitest.test-shards.mjs";
|
||||
import { isUnitUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs";
|
||||
import {
|
||||
getUnitFastTestFiles,
|
||||
resolveUnitFastTestIncludePattern,
|
||||
resolveUnitFastTimerTestIncludePattern,
|
||||
} from "../test/vitest/vitest.unit-fast-paths.mjs";
|
||||
@@ -254,6 +258,10 @@ function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function isPathAtOrUnder(relative, root) {
|
||||
return relative === root || relative.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders full-suite specs so expensive shards start first in parallel runs.
|
||||
*/
|
||||
@@ -584,7 +592,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
|
||||
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
|
||||
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
|
||||
["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]],
|
||||
[
|
||||
"scripts/package-openclaw-for-docker.mjs",
|
||||
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
|
||||
],
|
||||
["scripts/postinstall-bundled-plugins.mjs", ["test/scripts/postinstall-bundled-plugins.test.ts"]],
|
||||
["scripts/prepare-git-hooks.mjs", ["test/scripts/prepare-git-hooks.test.ts"]],
|
||||
[
|
||||
@@ -639,14 +650,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
|
||||
["test/scripts/plugin-lifecycle-measure.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs",
|
||||
["test/scripts/plugin-lifecycle-probe.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh",
|
||||
["test/scripts/plugin-lifecycle-probe.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/release-media-memory-docker.sh",
|
||||
["test/scripts/docker-e2e-plan.test.ts", "test/scripts/release-media-memory-scenario.test.ts"],
|
||||
@@ -660,6 +663,12 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
|
||||
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
|
||||
["scripts/tsdown-build.mjs", ["test/scripts/tsdown-build.test.ts"]],
|
||||
[
|
||||
"scripts/dev/channel-message-flows.ts",
|
||||
["test/e2e/qa-lab/channels/channel-message-flows.e2e.test.ts"],
|
||||
],
|
||||
["scripts/dev/gateway-smoke.ts", ["test/e2e/qa-lab/runtime/gateway-smoke.e2e.test.ts"]],
|
||||
["scripts/qa-otel-smoke.ts", ["test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts"]],
|
||||
["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
|
||||
["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]],
|
||||
["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]],
|
||||
@@ -1024,6 +1033,14 @@ function isExistingFileTarget(arg, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
function isExistingDirectoryTarget(arg, cwd) {
|
||||
try {
|
||||
return fs.statSync(path.resolve(cwd, arg)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobTarget(arg) {
|
||||
return /[*?[\]{}]/u.test(arg);
|
||||
}
|
||||
@@ -1166,6 +1183,14 @@ function expandExplicitSourceTestTargets(targetArgs, cwd) {
|
||||
}).length;
|
||||
const forceFullImportGraph = sourceTargetCount > EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD;
|
||||
return targetArgs.flatMap((targetArg) => {
|
||||
const relative = toRepoRelativeTarget(targetArg, cwd);
|
||||
if (relative === "src/commands" && isExistingDirectoryTarget(targetArg, cwd)) {
|
||||
return [COMMANDS_LIGHT_VITEST_CONFIG, COMMANDS_VITEST_CONFIG];
|
||||
}
|
||||
const exactDirectoryTargets = resolveExactSourceDirectoryTestTargets(targetArg, cwd);
|
||||
if (exactDirectoryTargets) {
|
||||
return exactDirectoryTargets;
|
||||
}
|
||||
const targets = resolveExplicitSourceTestTargets(targetArg, cwd, {
|
||||
forceFullImportGraph,
|
||||
});
|
||||
@@ -1173,6 +1198,55 @@ function expandExplicitSourceTestTargets(targetArgs, cwd) {
|
||||
});
|
||||
}
|
||||
|
||||
const exactSourceDirectoryRoots = [
|
||||
"src/acp",
|
||||
"src/agents",
|
||||
"src/auto-reply",
|
||||
"src/channels",
|
||||
"src/cli",
|
||||
"src/commands",
|
||||
"src/config",
|
||||
"src/cron",
|
||||
"src/daemon",
|
||||
"src/gateway",
|
||||
"src/hooks",
|
||||
"src/infra",
|
||||
"src/logging",
|
||||
"src/media",
|
||||
"src/media-understanding",
|
||||
"src/plugin-sdk",
|
||||
"src/plugins",
|
||||
"src/process",
|
||||
"src/secrets",
|
||||
"src/shared",
|
||||
"src/tasks",
|
||||
"src/tui",
|
||||
"src/utils",
|
||||
"src/wizard",
|
||||
"ui/src",
|
||||
];
|
||||
|
||||
function isExactSourceDirectoryTarget(relative) {
|
||||
return exactSourceDirectoryRoots.some((root) => isPathAtOrUnder(relative, root));
|
||||
}
|
||||
|
||||
function resolveExactSourceDirectoryTestTargets(targetArg, cwd) {
|
||||
if (!isExistingDirectoryTarget(targetArg, cwd)) {
|
||||
return null;
|
||||
}
|
||||
const relative = toRepoRelativeTarget(targetArg, cwd).replace(/\/+$/u, "");
|
||||
if (!isExactSourceDirectoryTarget(relative)) {
|
||||
return null;
|
||||
}
|
||||
const prefix = `${relative}/`;
|
||||
const lightTargets = uniqueOrdered([
|
||||
...getUnitFastTestFiles(),
|
||||
...pluginSdkLightTestFiles,
|
||||
...commandsLightTestFiles,
|
||||
]).filter((file) => file.startsWith(prefix));
|
||||
return lightTargets.length > 0 ? [...lightTargets, targetArg] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds explicit test path targets that do not match any known project plan.
|
||||
*/
|
||||
@@ -1922,7 +1996,7 @@ function classifyTarget(arg, cwd) {
|
||||
if (isControlUiE2eTarget(relative)) {
|
||||
return "uiE2e";
|
||||
}
|
||||
if (relative.startsWith("ui/src/")) {
|
||||
if (isPathAtOrUnder(relative, "ui/src")) {
|
||||
if (isUnitUiTestTarget(relative)) {
|
||||
return "unitUi";
|
||||
}
|
||||
@@ -2042,6 +2116,7 @@ function classifyTarget(arg, cwd) {
|
||||
}
|
||||
if (
|
||||
relative.startsWith("test/") ||
|
||||
relative === "src/scripts" ||
|
||||
relative.startsWith("src/scripts/") ||
|
||||
relative === "src/config/doc-baseline.integration.test.ts" ||
|
||||
relative === "src/config/schema.base.generated.test.ts" ||
|
||||
@@ -2052,76 +2127,76 @@ function classifyTarget(arg, cwd) {
|
||||
if (isBundledPluginDependentUnitTestFile(relative)) {
|
||||
return "bundled";
|
||||
}
|
||||
if (relative.startsWith("src/channels/")) {
|
||||
if (isPathAtOrUnder(relative, "src/channels")) {
|
||||
return "channel";
|
||||
}
|
||||
if (relative.startsWith("src/gateway/")) {
|
||||
if (isPathAtOrUnder(relative, "src/gateway")) {
|
||||
return "gateway";
|
||||
}
|
||||
if (relative.startsWith("src/hooks/")) {
|
||||
if (isPathAtOrUnder(relative, "src/hooks")) {
|
||||
return "hooks";
|
||||
}
|
||||
if (relative.startsWith("src/infra/")) {
|
||||
if (isPathAtOrUnder(relative, "src/infra")) {
|
||||
return "infra";
|
||||
}
|
||||
if (relative.startsWith("src/config/")) {
|
||||
if (isPathAtOrUnder(relative, "src/config")) {
|
||||
return "runtimeConfig";
|
||||
}
|
||||
if (relative.startsWith("src/cron/")) {
|
||||
if (isPathAtOrUnder(relative, "src/cron")) {
|
||||
return "cron";
|
||||
}
|
||||
if (relative.startsWith("src/daemon/")) {
|
||||
if (isPathAtOrUnder(relative, "src/daemon")) {
|
||||
return "daemon";
|
||||
}
|
||||
if (relative.startsWith("src/media-understanding/")) {
|
||||
if (isPathAtOrUnder(relative, "src/media-understanding")) {
|
||||
return "mediaUnderstanding";
|
||||
}
|
||||
if (relative.startsWith("src/media/")) {
|
||||
if (isPathAtOrUnder(relative, "src/media")) {
|
||||
return "media";
|
||||
}
|
||||
if (relative.startsWith("src/logging/")) {
|
||||
if (isPathAtOrUnder(relative, "src/logging")) {
|
||||
return "logging";
|
||||
}
|
||||
if (relative.startsWith("src/plugin-sdk/")) {
|
||||
if (isPathAtOrUnder(relative, "src/plugin-sdk")) {
|
||||
return isPluginSdkLightTarget(relative) ? "pluginSdkLight" : "pluginSdk";
|
||||
}
|
||||
if (relative.startsWith("src/process/")) {
|
||||
if (isPathAtOrUnder(relative, "src/process")) {
|
||||
return "process";
|
||||
}
|
||||
if (relative.startsWith("src/secrets/")) {
|
||||
if (isPathAtOrUnder(relative, "src/secrets")) {
|
||||
return "secrets";
|
||||
}
|
||||
if (relative.startsWith("src/shared/")) {
|
||||
if (isPathAtOrUnder(relative, "src/shared")) {
|
||||
return "sharedCore";
|
||||
}
|
||||
if (relative.startsWith("src/tasks/")) {
|
||||
if (isPathAtOrUnder(relative, "src/tasks")) {
|
||||
return "tasks";
|
||||
}
|
||||
if (relative.startsWith("src/tui/")) {
|
||||
if (isPathAtOrUnder(relative, "src/tui")) {
|
||||
return "tui";
|
||||
}
|
||||
if (relative.startsWith("src/acp/")) {
|
||||
if (isPathAtOrUnder(relative, "src/acp")) {
|
||||
return "acp";
|
||||
}
|
||||
if (relative.startsWith("src/cli/")) {
|
||||
if (isPathAtOrUnder(relative, "src/cli")) {
|
||||
return "cli";
|
||||
}
|
||||
if (relative.startsWith("src/commands/")) {
|
||||
if (isPathAtOrUnder(relative, "src/commands")) {
|
||||
return isCommandsLightTarget(relative) ? "commandLight" : "command";
|
||||
}
|
||||
if (relative.startsWith("src/auto-reply/")) {
|
||||
if (isPathAtOrUnder(relative, "src/auto-reply")) {
|
||||
return "autoReply";
|
||||
}
|
||||
if (relative.startsWith("src/agents/")) {
|
||||
if (isPathAtOrUnder(relative, "src/agents")) {
|
||||
return "agent";
|
||||
}
|
||||
if (relative.startsWith("src/plugins/")) {
|
||||
if (isPathAtOrUnder(relative, "src/plugins")) {
|
||||
return "plugin";
|
||||
}
|
||||
if (relative.startsWith("src/utils/")) {
|
||||
if (isPathAtOrUnder(relative, "src/utils")) {
|
||||
return "utils";
|
||||
}
|
||||
if (relative.startsWith("src/wizard/")) {
|
||||
if (isPathAtOrUnder(relative, "src/wizard")) {
|
||||
return "wizard";
|
||||
}
|
||||
return "default";
|
||||
@@ -2661,7 +2736,7 @@ export function createVitestRunSpecs(args, params = {}) {
|
||||
const includeFilePath = plan.includePatterns
|
||||
? path.join(
|
||||
params.tempDir ?? os.tmpdir(),
|
||||
`openclaw-vitest-include-${process.pid}-${Date.now()}-${index}.json`,
|
||||
`openclaw-vitest-include-${randomUUID()}-${index}.json`,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
|
||||
@@ -998,10 +998,16 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
meta: { durationMs: 0, stopReason: "end_turn" },
|
||||
}));
|
||||
state.persistCliTurnTranscriptMock.mockImplementation(
|
||||
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
|
||||
async (params: { sessionEntry?: unknown }) => ({
|
||||
kind: "persisted",
|
||||
sessionEntry: params.sessionEntry,
|
||||
}),
|
||||
);
|
||||
state.persistAcpTurnTranscriptMock.mockImplementation(
|
||||
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
|
||||
async (params: { sessionEntry?: unknown }) => ({
|
||||
kind: "persisted",
|
||||
sessionEntry: params.sessionEntry,
|
||||
}),
|
||||
);
|
||||
state.runCliTurnCompactionLifecycleMock.mockImplementation(
|
||||
async (params: { sessionEntry?: unknown }) => params.sessionEntry,
|
||||
@@ -1244,7 +1250,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
state.persistAcpTurnTranscriptMock.mockImplementation(
|
||||
async (params: { sessionEntry?: unknown }) => {
|
||||
controller.abort(createAgentRunRestartAbortError());
|
||||
return params.sessionEntry;
|
||||
return { kind: "persisted", sessionEntry: params.sessionEntry };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1792,7 +1798,10 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
state.updateSessionStoreAfterAgentRunMock.mockImplementation(async () => {
|
||||
state.sessionStoreMock = { "agent:main:main": rotatedEntry };
|
||||
});
|
||||
state.persistCliTurnTranscriptMock.mockResolvedValue(rotatedEntry);
|
||||
state.persistCliTurnTranscriptMock.mockResolvedValue({
|
||||
kind: "persisted",
|
||||
sessionEntry: rotatedEntry,
|
||||
});
|
||||
state.runCliTurnCompactionLifecycleMock.mockResolvedValue(rotatedEntry);
|
||||
|
||||
await runBasicAgentCommand();
|
||||
@@ -1813,6 +1822,33 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips post-run persistence after the session is deleted", async () => {
|
||||
setupSingleAttemptFallback();
|
||||
setupSessionTouchStore();
|
||||
const result = makeSuccessResult("openai", "gpt-5.4") as ReturnType<
|
||||
typeof makeSuccessResult
|
||||
> & {
|
||||
meta: Record<string, unknown> & { executionTrace: Record<string, unknown> };
|
||||
};
|
||||
result.meta.executionTrace = {
|
||||
runner: "cli",
|
||||
fallbackUsed: false,
|
||||
winnerProvider: "openai",
|
||||
winnerModel: "gpt-5.4",
|
||||
};
|
||||
state.runAgentAttemptMock.mockResolvedValue(result);
|
||||
state.persistCliTurnTranscriptMock.mockResolvedValue({
|
||||
kind: "session-rebound",
|
||||
sessionEntry: undefined,
|
||||
});
|
||||
|
||||
await runBasicAgentCommand();
|
||||
|
||||
expect(state.persistCliTurnTranscriptMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.runCliTurnCompactionLifecycleMock).not.toHaveBeenCalled();
|
||||
expect(state.deliverAgentCommandResultMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not treat backend CLI session id as OpenClaw session identity", async () => {
|
||||
setupSingleAttemptFallback();
|
||||
setupSessionTouchStore();
|
||||
|
||||
@@ -891,6 +891,7 @@ async function agentCommandInternal(
|
||||
assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration);
|
||||
const effectiveCwd = cwd ? resolveUserPath(cwd) : workspaceDir;
|
||||
let sessionEntry = prepared.sessionEntry;
|
||||
let sessionReboundDuringRun = false;
|
||||
let trackedRestartRecoveryDeliveryContext = false;
|
||||
let currentRunDeliveryContext: DeliveryContext | undefined;
|
||||
|
||||
@@ -1099,7 +1100,7 @@ async function agentCommandInternal(
|
||||
sessionFile: internalSessionFile,
|
||||
}
|
||||
: sessionEntry;
|
||||
sessionEntry = await attemptExecutionRuntime.persistAcpTurnTranscript({
|
||||
const transcriptResult = await attemptExecutionRuntime.persistAcpTurnTranscript({
|
||||
body,
|
||||
transcriptBody,
|
||||
finalText: finalTextRaw,
|
||||
@@ -1113,6 +1114,7 @@ async function agentCommandInternal(
|
||||
sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
|
||||
config: cfg,
|
||||
});
|
||||
sessionEntry = transcriptResult.sessionEntry;
|
||||
if (internalSessionFile) {
|
||||
sessionEntry = prepared.sessionEntry;
|
||||
}
|
||||
@@ -2166,7 +2168,10 @@ async function agentCommandInternal(
|
||||
transcriptPersistenceRunner === "embedded" ||
|
||||
(transcriptPersistenceRunner === undefined &&
|
||||
Boolean(result.meta.finalAssistantVisibleText?.trim()));
|
||||
if (transcriptPersistenceRunner === "cli" || embeddedAssistantGapFill) {
|
||||
if (
|
||||
!sessionReboundDuringRun &&
|
||||
(transcriptPersistenceRunner === "cli" || embeddedAssistantGapFill)
|
||||
) {
|
||||
let persistedCliTurnTranscript = false;
|
||||
try {
|
||||
const transcriptSessionEntry: SessionEntry | undefined = suppressVisibleSessionEffects
|
||||
@@ -2180,7 +2185,7 @@ async function agentCommandInternal(
|
||||
sessionFile: effectiveSessionFile,
|
||||
}
|
||||
: sessionEntry;
|
||||
sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({
|
||||
const transcriptResult = await attemptExecutionRuntime.persistCliTurnTranscript({
|
||||
body,
|
||||
transcriptBody,
|
||||
result,
|
||||
@@ -2195,10 +2200,12 @@ async function agentCommandInternal(
|
||||
config: cfg,
|
||||
embeddedAssistantGapFill,
|
||||
});
|
||||
sessionEntry = transcriptResult.sessionEntry;
|
||||
sessionReboundDuringRun = transcriptResult.kind === "session-rebound";
|
||||
if (suppressVisibleSessionEffects) {
|
||||
sessionEntry = prepared.sessionEntry;
|
||||
}
|
||||
persistedCliTurnTranscript = true;
|
||||
persistedCliTurnTranscript = transcriptResult.kind === "persisted";
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Turn transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -2240,6 +2247,7 @@ async function agentCommandInternal(
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
!suppressVisibleSessionEffects &&
|
||||
!sessionReboundDuringRun &&
|
||||
payloads.length > 0 &&
|
||||
!isSubagentSessionKey(sessionKey)
|
||||
) {
|
||||
@@ -2316,7 +2324,8 @@ async function agentCommandInternal(
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
!isSubagentSessionKey(sessionKey) &&
|
||||
!suppressVisibleSessionEffects
|
||||
!suppressVisibleSessionEffects &&
|
||||
!sessionReboundDuringRun
|
||||
) {
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
const noPendingTextForThisRun =
|
||||
@@ -2348,7 +2357,12 @@ async function agentCommandInternal(
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (trackedRestartRecoveryDeliveryContext && sessionStore && sessionKey) {
|
||||
if (
|
||||
!sessionReboundDuringRun &&
|
||||
trackedRestartRecoveryDeliveryContext &&
|
||||
sessionStore &&
|
||||
sessionKey
|
||||
) {
|
||||
try {
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
if (entry?.restartRecoveryDeliveryContext && entry.restartRecoveryDeliveryRunId === runId) {
|
||||
|
||||
@@ -12,7 +12,6 @@ export type { AuthProfileEligibilityReasonCode } from "./auth-profiles/order.js"
|
||||
export { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
|
||||
export { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
|
||||
export {
|
||||
externalCliDiscoveryExisting,
|
||||
externalCliDiscoveryForConfigStatus,
|
||||
externalCliDiscoveryForProviderAuth,
|
||||
externalCliDiscoveryForProviders,
|
||||
|
||||
@@ -64,20 +64,6 @@ export function externalCliDiscoveryNone(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Allows discovery of already-existing external CLI auth profiles. */
|
||||
export function externalCliDiscoveryExisting(params?: {
|
||||
config?: OpenClawConfig;
|
||||
allowKeychainPrompt?: boolean;
|
||||
}): ExternalCliAuthDiscovery {
|
||||
return {
|
||||
mode: "existing",
|
||||
...(params?.allowKeychainPrompt !== undefined
|
||||
? { allowKeychainPrompt: params.allowKeychainPrompt }
|
||||
: {}),
|
||||
...(params?.config ? { config: params.config } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Allows external CLI auth discovery for specific providers and/or profiles. */
|
||||
export function externalCliDiscoveryScoped(params: {
|
||||
config?: OpenClawConfig;
|
||||
|
||||
@@ -326,13 +326,6 @@ async function buildHostApprovalDecisionParams(
|
||||
};
|
||||
}
|
||||
|
||||
/** Requests and waits for an approval decision for host/node exec. */
|
||||
export async function requestExecApprovalDecisionForHost(
|
||||
params: HostExecApprovalParams,
|
||||
): Promise<string | null> {
|
||||
return await requestExecApprovalDecision(await buildHostApprovalDecisionParams(params));
|
||||
}
|
||||
|
||||
/** Registers a host/node approval request without waiting for a decision. */
|
||||
export async function registerExecApprovalRequestForHost(
|
||||
params: HostExecApprovalParams,
|
||||
|
||||
@@ -127,6 +127,16 @@ function makeCliResult(text: string): EmbeddedAgentRunResult {
|
||||
};
|
||||
}
|
||||
|
||||
async function persistCliTranscriptEntry(
|
||||
params: Parameters<typeof persistCliTurnTranscript>[0],
|
||||
): Promise<SessionEntry | undefined> {
|
||||
const result = await persistCliTurnTranscript(params);
|
||||
if (result.kind !== "persisted") {
|
||||
throw new Error("expected CLI transcript persistence to keep the current session");
|
||||
}
|
||||
return result.sessionEntry;
|
||||
}
|
||||
|
||||
async function readSessionMessages(sessionFile: string) {
|
||||
return (await readSessionFileJsonLines<{ type?: string; message?: unknown }>(sessionFile))
|
||||
.filter((entry) => entry.type === "message")
|
||||
@@ -1143,7 +1153,7 @@ describe("CLI attempt execution", () => {
|
||||
});
|
||||
let updatedEntry: SessionEntry | undefined;
|
||||
try {
|
||||
updatedEntry = await persistCliTurnTranscript({
|
||||
updatedEntry = await persistCliTranscriptEntry({
|
||||
body: "persist this",
|
||||
result: makeCliResult("hello from cli"),
|
||||
sessionId: sessionEntry.sessionId,
|
||||
@@ -1204,6 +1214,40 @@ describe("CLI attempt execution", () => {
|
||||
expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt);
|
||||
});
|
||||
|
||||
it("does not append a CLI transcript after the session is deleted", async () => {
|
||||
const sessionKey = "agent:main:subagent:cli-transcript-deleted";
|
||||
const staleSessionFile = path.join(tmpDir, "session-cli-stale.jsonl");
|
||||
const staleEntry: SessionEntry = {
|
||||
sessionId: "session-cli-stale",
|
||||
sessionFile: staleSessionFile,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: staleEntry };
|
||||
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf-8");
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
const result = await persistCliTurnTranscript({
|
||||
body: "late prompt",
|
||||
result: makeCliResult("late reply"),
|
||||
sessionId: staleEntry.sessionId,
|
||||
sessionKey,
|
||||
sessionEntry: staleEntry,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionAgentId: "main",
|
||||
sessionCwd: tmpDir,
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: "session-rebound", sessionEntry: undefined });
|
||||
await expect(fs.stat(staleSessionFile)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
|
||||
string,
|
||||
SessionEntry
|
||||
>;
|
||||
expect(persisted[sessionKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("embedded assistant gap-fill skips user mirror and dedupes identical assistant tails", async () => {
|
||||
const sessionKey = "agent:main:subagent:embedded-gap-fill";
|
||||
const sessionEntry: SessionEntry = {
|
||||
@@ -1221,7 +1265,7 @@ describe("CLI attempt execution", () => {
|
||||
runner: "embedded",
|
||||
};
|
||||
|
||||
const updatedFirst = await persistCliTurnTranscript({
|
||||
const updatedFirst = await persistCliTranscriptEntry({
|
||||
body: "ignored for gap fill",
|
||||
transcriptBody: "also ignored",
|
||||
result,
|
||||
@@ -1278,7 +1322,7 @@ describe("CLI attempt execution", () => {
|
||||
runner: "embedded",
|
||||
};
|
||||
|
||||
const updatedFirst = await persistCliTurnTranscript({
|
||||
const updatedFirst = await persistCliTranscriptEntry({
|
||||
body: "ignored for gap fill",
|
||||
result,
|
||||
sessionId: sessionEntry.sessionId,
|
||||
@@ -1344,7 +1388,7 @@ describe("CLI attempt execution", () => {
|
||||
runner: "embedded",
|
||||
};
|
||||
|
||||
const updatedFirst = await persistCliTurnTranscript({
|
||||
const updatedFirst = await persistCliTranscriptEntry({
|
||||
body: "ignored for gap fill",
|
||||
result,
|
||||
sessionId: sessionEntry.sessionId,
|
||||
@@ -1415,7 +1459,7 @@ describe("CLI attempt execution", () => {
|
||||
runner: "embedded",
|
||||
};
|
||||
|
||||
const updatedFirst = await persistCliTurnTranscript({
|
||||
const updatedFirst = await persistCliTranscriptEntry({
|
||||
body: "ignored for gap fill",
|
||||
result,
|
||||
sessionId: sessionEntry.sessionId,
|
||||
@@ -1484,7 +1528,7 @@ describe("CLI attempt execution", () => {
|
||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
|
||||
|
||||
const updatedEntry = await persistCliTurnTranscript({
|
||||
const updatedEntry = await persistCliTranscriptEntry({
|
||||
body: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"secret runtime context",
|
||||
|
||||
@@ -127,6 +127,10 @@ type PersistTextTurnTranscriptParams = {
|
||||
};
|
||||
};
|
||||
|
||||
type PersistTextTurnTranscriptResult =
|
||||
| { kind: "persisted"; sessionEntry: SessionEntry | undefined }
|
||||
| { kind: "session-rebound"; sessionEntry: undefined };
|
||||
|
||||
type HarnessAuthProfileSelection = {
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: "auto" | "user";
|
||||
@@ -285,11 +289,11 @@ function resolveTranscriptUsage(usage: PersistTextTurnTranscriptParams["assistan
|
||||
|
||||
async function persistTextTurnTranscript(
|
||||
params: PersistTextTurnTranscriptParams,
|
||||
): Promise<SessionEntry | undefined> {
|
||||
): Promise<PersistTextTurnTranscriptResult> {
|
||||
const promptText = params.transcriptBody ?? params.body;
|
||||
const replyText = params.finalText;
|
||||
if (!promptText && !replyText) {
|
||||
return params.sessionEntry;
|
||||
return { kind: "persisted", sessionEntry: params.sessionEntry };
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
@@ -356,9 +360,13 @@ async function persistTextTurnTranscript(
|
||||
publishWhen: "always",
|
||||
touchSessionEntry: true,
|
||||
updateMode: "file-only",
|
||||
...(params.sessionStore && params.storePath ? { expectedSessionId: params.sessionId } : {}),
|
||||
},
|
||||
);
|
||||
return turn.sessionEntry;
|
||||
if (turn.rejectedReason === "session-rebound") {
|
||||
return { kind: "session-rebound", sessionEntry: undefined };
|
||||
}
|
||||
return { kind: "persisted", sessionEntry: turn.sessionEntry };
|
||||
}
|
||||
|
||||
function resolveCliTranscriptReplyText(result: EmbeddedAgentRunResult): string {
|
||||
@@ -391,7 +399,7 @@ export async function persistAcpTurnTranscript(params: {
|
||||
threadId?: string | number;
|
||||
sessionCwd: string;
|
||||
config: OpenClawConfig;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
}): Promise<PersistTextTurnTranscriptResult> {
|
||||
return await persistTextTurnTranscript({
|
||||
...params,
|
||||
assistant: {
|
||||
@@ -417,7 +425,7 @@ export async function persistCliTurnTranscript(params: {
|
||||
sessionCwd: string;
|
||||
config: OpenClawConfig;
|
||||
embeddedAssistantGapFill?: boolean;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
}): Promise<PersistTextTurnTranscriptResult> {
|
||||
const replyText = resolveCliTranscriptReplyText(params.result);
|
||||
const provider = params.result.meta.agentMeta?.provider?.trim() ?? "cli";
|
||||
const model = params.result.meta.agentMeta?.model?.trim() ?? "default";
|
||||
|
||||
@@ -679,6 +679,7 @@ export async function runCliTurnCompactionLifecycle(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
expectedSessionId: params.sessionId,
|
||||
})) ?? params.sessionEntry
|
||||
);
|
||||
}
|
||||
@@ -696,6 +697,7 @@ export async function runCliTurnCompactionLifecycle(params: {
|
||||
tokensAfter: nativeCompactionResult?.result?.tokensAfter,
|
||||
newSessionId: nativeCompactionResult?.result?.sessionId,
|
||||
newSessionFile: nativeCompactionResult?.result?.sessionFile,
|
||||
expectedSessionId: params.sessionId,
|
||||
})) ?? params.sessionEntry
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1849,7 +1849,10 @@ describe("updateSessionStoreAfterAgentRun", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not recreate a missing persisted row while preserving user-facing state", async () => {
|
||||
it.each([
|
||||
["normal", false],
|
||||
["user-facing state preserving", true],
|
||||
])("does not recreate a missing persisted row after a %s run", async (_mode, preserve) => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:explicit:missing-visible-row";
|
||||
@@ -1872,7 +1875,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
|
||||
sessionStore,
|
||||
defaultProvider: "claude-cli",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
preserveUserFacingSessionModelState: true,
|
||||
preserveUserFacingSessionModelState: preserve,
|
||||
result: {
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
@@ -1895,6 +1898,88 @@ describe("updateSessionStoreAfterAgentRun", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a missing persisted row for a new normal run", async () => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:explicit:new-normal-row";
|
||||
const sessionId = "new-normal-row-session";
|
||||
const sessionStore: Record<string, SessionEntry> = {};
|
||||
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf8");
|
||||
|
||||
await updateSessionStoreAfterAgentRun({
|
||||
cfg,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionStore,
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-5.5",
|
||||
result: {
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: {
|
||||
sessionId,
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sessionStore[sessionKey]).toMatchObject({ sessionId });
|
||||
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toMatchObject({
|
||||
sessionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite a replacement persisted row after a normal run", async () => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const sessionKey = "agent:main:explicit:rebound-visible-row";
|
||||
const sessionId = "run-session-id";
|
||||
const replacementEntry: SessionEntry = {
|
||||
sessionId: "replacement-session-id",
|
||||
updatedAt: 2,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: replacementEntry }, null, 2));
|
||||
|
||||
await updateSessionStoreAfterAgentRun({
|
||||
cfg,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionStore,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
result: {
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: {
|
||||
sessionId,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(
|
||||
replacementEntry,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves contextTokens unset when entry has prior model but no contextTokens (heartbeat bleed guard)", async () => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
@@ -2301,6 +2386,35 @@ describe("recordCliCompactionInStore", () => {
|
||||
expect(persisted?.cliSessionBindings?.codex).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not recreate a missing row when a post-run compaction has an expected session id", async () => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const sessionKey = "agent:main:explicit:test-record-cli-compaction-deleted";
|
||||
const sessionId = "test-record-cli-compaction-deleted-session";
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
cliSessionIds: {
|
||||
codex: "stale-cli-session",
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf8");
|
||||
|
||||
const result = await recordCliCompactionInStore({
|
||||
provider: "codex",
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
expectedSessionId: sessionId,
|
||||
tokensAfter: 42,
|
||||
});
|
||||
|
||||
expect(result).toEqual(sessionStore[sessionKey]);
|
||||
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCliSessionInStore", () => {
|
||||
@@ -2435,4 +2549,29 @@ describe("clearCliSessionInStore", () => {
|
||||
expect(persisted?.claudeCliSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not recreate a missing row when a post-run binding clear has an expected session id", async () => {
|
||||
await withTempSessionStore(async ({ storePath }) => {
|
||||
const sessionKey = "agent:main:explicit:test-clear-cli-deleted-row";
|
||||
const sessionId = "openclaw-session-1";
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: 1,
|
||||
claudeCliSessionId: "claude-session-1",
|
||||
},
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify({}, null, 2), "utf8");
|
||||
|
||||
await clearCliSessionInStore({
|
||||
provider: "claude-cli",
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
expectedSessionId: sessionId,
|
||||
});
|
||||
|
||||
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,7 +299,14 @@ export async function updateSessionStoreAfterAgentRun(params: {
|
||||
sessionKey,
|
||||
},
|
||||
(_currentEntry, context) => {
|
||||
if (preserveUserFacingRunState && !context.existingEntry) {
|
||||
if (
|
||||
(!preserveUserFacingRunState &&
|
||||
context.existingEntry &&
|
||||
context.existingEntry.sessionId !== entry.sessionId) ||
|
||||
(!context.existingEntry && sessionStore[sessionKey])
|
||||
) {
|
||||
// A normal run may rotate its session id, so compare to the pre-run entry.
|
||||
// Do not merge stale finalizer metadata after a delete or a competing reset.
|
||||
return null;
|
||||
}
|
||||
return metadataPatch;
|
||||
@@ -320,8 +327,9 @@ export async function clearCliSessionInStore(params: {
|
||||
sessionKey: string;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
expectedSessionId?: string;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
const { provider, sessionKey, sessionStore, storePath } = params;
|
||||
const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params;
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -336,7 +344,15 @@ export async function clearCliSessionInStore(params: {
|
||||
storePath,
|
||||
sessionKey,
|
||||
},
|
||||
() => next,
|
||||
(currentEntry, context) => {
|
||||
if (
|
||||
expectedSessionId &&
|
||||
(!context.existingEntry || currentEntry.sessionId !== expectedSessionId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ fallbackEntry: entry },
|
||||
);
|
||||
if (persisted) {
|
||||
@@ -354,8 +370,9 @@ export async function recordCliCompactionInStore(params: {
|
||||
tokensAfter?: number;
|
||||
newSessionId?: string;
|
||||
newSessionFile?: string;
|
||||
expectedSessionId?: string;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
const { provider, sessionKey, sessionStore, storePath } = params;
|
||||
const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params;
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -410,7 +427,15 @@ export async function recordCliCompactionInStore(params: {
|
||||
storePath,
|
||||
sessionKey,
|
||||
},
|
||||
() => next,
|
||||
(currentEntry, context) => {
|
||||
if (
|
||||
expectedSessionId &&
|
||||
(!context.existingEntry || currentEntry.sessionId !== expectedSessionId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ fallbackEntry: entry },
|
||||
);
|
||||
if (persisted) {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
/**
|
||||
* Interactive terminal theme loader.
|
||||
*
|
||||
* Validates theme JSON, resolves color variables, watches custom theme files, and exposes Pi TUI theme adapters.
|
||||
* Validates theme JSON, resolves color variables, watches custom theme files, and exposes terminal styling helpers.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type EditorTheme,
|
||||
getCapabilities,
|
||||
type MarkdownTheme,
|
||||
type SelectListTheme,
|
||||
type SettingsListTheme,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { getCapabilities } from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
@@ -468,59 +462,6 @@ function getBuiltinThemes(): Record<string, ThemeJson> {
|
||||
return BUILTIN_THEMES;
|
||||
}
|
||||
|
||||
export function getAvailableThemes(): string[] {
|
||||
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
if (fs.existsSync(customThemesDir)) {
|
||||
const files = fs.readdirSync(customThemesDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
themes.add(file.slice(0, -5));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of registeredThemes.keys()) {
|
||||
themes.add(name);
|
||||
}
|
||||
return Array.from(themes).toSorted();
|
||||
}
|
||||
|
||||
export interface ThemeInfo {
|
||||
name: string;
|
||||
path: string | undefined;
|
||||
}
|
||||
|
||||
export function getAvailableThemesWithPaths(): ThemeInfo[] {
|
||||
const themesDir = getThemesDir();
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const result: ThemeInfo[] = [];
|
||||
|
||||
// Built-in themes
|
||||
for (const name of Object.keys(getBuiltinThemes())) {
|
||||
result.push({ name, path: path.join(themesDir, `${name}.json`) });
|
||||
}
|
||||
|
||||
// Custom themes
|
||||
if (fs.existsSync(customThemesDir)) {
|
||||
for (const file of fs.readdirSync(customThemesDir)) {
|
||||
if (file.endsWith(".json")) {
|
||||
const name = file.slice(0, -5);
|
||||
if (!result.some((t) => t.name === name)) {
|
||||
result.push({ name, path: path.join(customThemesDir, file) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, theme] of registeredThemes.entries()) {
|
||||
if (!result.some((t) => t.name === name)) {
|
||||
result.push({ name, path: theme.sourcePath });
|
||||
}
|
||||
}
|
||||
|
||||
return result.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function parseThemeJson(label: string, json: unknown): ThemeJson {
|
||||
if (!validateThemeJson.Check(json)) {
|
||||
const errors = Array.from(validateThemeJson.Errors(json));
|
||||
@@ -635,14 +576,6 @@ function loadTheme(name: string, mode?: ColorMode): Theme {
|
||||
return createTheme(themeJson, mode);
|
||||
}
|
||||
|
||||
export function getThemeByName(name: string): Theme | undefined {
|
||||
try {
|
||||
return loadTheme(name);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type TerminalTheme = "dark" | "light";
|
||||
|
||||
export interface RgbColor {
|
||||
@@ -685,67 +618,6 @@ function getAnsiColorLuminance(index: number): number {
|
||||
return getRgbColorLuminance(hexToRgb(ansi256ToHex(index)));
|
||||
}
|
||||
|
||||
export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme {
|
||||
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
|
||||
}
|
||||
|
||||
function parseOscHexChannel(channel: string): number | undefined {
|
||||
if (!/^[0-9a-f]+$/i.test(channel)) {
|
||||
return undefined;
|
||||
}
|
||||
const max = 16 ** channel.length - 1;
|
||||
if (max <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round((Number.parseInt(channel, 16) / max) * 255);
|
||||
}
|
||||
|
||||
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
|
||||
const prefix = "\u001B]11;";
|
||||
const belSuffix = "\u0007";
|
||||
const escSuffix = "\u001B\\";
|
||||
if (!data.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const suffixLength = data.endsWith(belSuffix)
|
||||
? belSuffix.length
|
||||
: data.endsWith(escSuffix)
|
||||
? escSuffix.length
|
||||
: 0;
|
||||
if (suffixLength === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = data.slice(prefix.length, -suffixLength).trim();
|
||||
if (value.includes("\u0007") || value.includes("\u001B")) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.startsWith("#")) {
|
||||
const hex = value.slice(1);
|
||||
if (/^[0-9a-f]{6}$/i.test(hex)) {
|
||||
return hexToRgb(value);
|
||||
}
|
||||
if (/^[0-9a-f]{12}$/i.test(hex)) {
|
||||
const r = parseOscHexChannel(hex.slice(0, 4));
|
||||
const g = parseOscHexChannel(hex.slice(4, 8));
|
||||
const b = parseOscHexChannel(hex.slice(8, 12));
|
||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rgbValue = value.replace(/^rgba?:/i, "");
|
||||
const [red, green, blue] = rgbValue.split("/");
|
||||
if (red === undefined || green === undefined || blue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const r = parseOscHexChannel(red);
|
||||
const g = parseOscHexChannel(green);
|
||||
const b = parseOscHexChannel(blue);
|
||||
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
|
||||
}
|
||||
|
||||
export function detectTerminalBackground(
|
||||
options: TerminalThemeDetectionOptions = {},
|
||||
): TerminalThemeDetection {
|
||||
@@ -799,18 +671,8 @@ function setGlobalTheme(t: Theme): void {
|
||||
let currentThemeName: string | undefined;
|
||||
let themeWatcher: fs.FSWatcher | undefined;
|
||||
let themeReloadTimer: NodeJS.Timeout | undefined;
|
||||
let onThemeChangeCallback: (() => void) | undefined;
|
||||
const registeredThemes = new Map<string, Theme>();
|
||||
|
||||
export function setRegisteredThemes(themes: Theme[]): void {
|
||||
registeredThemes.clear();
|
||||
for (const themeLocal of themes) {
|
||||
if (themeLocal.name) {
|
||||
registeredThemes.set(themeLocal.name, themeLocal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme(themeName?: string, enableWatcher = false): void {
|
||||
const name = themeName ?? getDefaultTheme();
|
||||
currentThemeName = name;
|
||||
@@ -837,9 +699,6 @@ export function setTheme(
|
||||
if (enableWatcher) {
|
||||
startThemeWatcher();
|
||||
}
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// Theme is invalid - fall back to dark theme
|
||||
@@ -853,19 +712,6 @@ export function setTheme(
|
||||
}
|
||||
}
|
||||
|
||||
export function setThemeInstance(themeInstance: Theme): void {
|
||||
setGlobalTheme(themeInstance);
|
||||
currentThemeName = "<in-memory>";
|
||||
stopThemeWatcher(); // Can't watch a direct instance
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
export function onThemeChange(callback: () => void): void {
|
||||
onThemeChangeCallback = callback;
|
||||
}
|
||||
|
||||
function startThemeWatcher(): void {
|
||||
stopThemeWatcher();
|
||||
|
||||
@@ -906,10 +752,6 @@ function startThemeWatcher(): void {
|
||||
const reloadedTheme = loadThemeFromPath(themeFile);
|
||||
registeredThemes.set(watchedThemeName, reloadedTheme);
|
||||
setGlobalTheme(reloadedTheme);
|
||||
// Notify callback (to invalidate UI)
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors (file might be in invalid state while being edited)
|
||||
}
|
||||
@@ -998,83 +840,6 @@ function ansi256ToHex(index: number): string {
|
||||
return `#${grayHex}${grayHex}${grayHex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved theme colors as CSS-compatible hex strings.
|
||||
* Used by HTML export to generate CSS custom properties.
|
||||
*/
|
||||
export function getResolvedThemeColors(themeName?: string): Record<string, string> {
|
||||
const name = themeName ?? currentThemeName ?? getDefaultTheme();
|
||||
const isLight = name === "light";
|
||||
const themeJson = loadThemeJson(name);
|
||||
const resolved = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
|
||||
// Default text color for empty values (terminal uses default fg color)
|
||||
const defaultText = isLight ? "#000000" : "#e5e5e7";
|
||||
|
||||
const cssColors: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
if (typeof value === "number") {
|
||||
cssColors[key] = ansi256ToHex(value);
|
||||
} else if (value === "") {
|
||||
// Empty means default terminal color - use sensible fallback for HTML
|
||||
cssColors[key] = defaultText;
|
||||
} else {
|
||||
cssColors[key] = value;
|
||||
}
|
||||
}
|
||||
return cssColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a theme is a "light" theme (for CSS that needs light/dark variants).
|
||||
*/
|
||||
export function isLightTheme(themeName?: string): boolean {
|
||||
// Currently just check the name - could be extended to analyze colors
|
||||
return themeName === "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicit export colors from theme JSON, if specified.
|
||||
* Returns undefined for each color that isn't explicitly set.
|
||||
*/
|
||||
export function getThemeExportColors(themeName?: string): {
|
||||
pageBg?: string;
|
||||
cardBg?: string;
|
||||
infoBg?: string;
|
||||
} {
|
||||
const name = themeName ?? currentThemeName ?? getDefaultTheme();
|
||||
try {
|
||||
const themeJson = loadThemeJson(name);
|
||||
const exportSection = themeJson.export;
|
||||
if (!exportSection) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const vars = themeJson.vars ?? {};
|
||||
const resolve = (value: ColorValue | undefined): string | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = resolveVarRefs(value, vars);
|
||||
if (typeof resolved === "number") {
|
||||
return ansi256ToHex(resolved);
|
||||
}
|
||||
if (resolved === "") {
|
||||
return undefined;
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
return {
|
||||
pageBg: resolve(exportSection.pageBg),
|
||||
cardBg: resolve(exportSection.cardBg),
|
||||
infoBg: resolve(exportSection.infoBg),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUI Helpers
|
||||
// ============================================================================
|
||||
@@ -1209,70 +974,3 @@ export function getLanguageFromPath(filePath: string): string | undefined {
|
||||
|
||||
return extToLang[ext];
|
||||
}
|
||||
|
||||
export function getMarkdownTheme(): MarkdownTheme {
|
||||
return {
|
||||
heading: (text: string) => theme.fg("mdHeading", text),
|
||||
link: (text: string) => theme.fg("mdLink", text),
|
||||
linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
|
||||
code: (text: string) => theme.fg("mdCode", text),
|
||||
codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
|
||||
codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
|
||||
quote: (text: string) => theme.fg("mdQuote", text),
|
||||
quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
|
||||
hr: (text: string) => theme.fg("mdHr", text),
|
||||
listBullet: (text: string) => theme.fg("mdListBullet", text),
|
||||
bold: (text: string) => theme.bold(text),
|
||||
italic: (text: string) => theme.italic(text),
|
||||
underline: (text: string) => theme.underline(text),
|
||||
strikethrough: (text: string) => chalk.strikethrough(text),
|
||||
highlightCode: (code: string, lang?: string): string[] => {
|
||||
// Validate language before highlighting to avoid stderr spam from cli-highlight
|
||||
const validLang = lang && supportsLanguage(lang) ? lang : undefined;
|
||||
// Skip highlighting when no valid language is specified. cli-highlight's
|
||||
// auto-detection is unreliable and can misidentify prose as AppleScript,
|
||||
// LiveCodeServer, etc., coloring random English words as keywords.
|
||||
if (!validLang) {
|
||||
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
|
||||
}
|
||||
const opts = {
|
||||
language: validLang,
|
||||
ignoreIllegals: true,
|
||||
theme: getCliHighlightTheme(theme),
|
||||
};
|
||||
try {
|
||||
return highlight(code, opts).split("\n");
|
||||
} catch {
|
||||
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelectListTheme(): SelectListTheme {
|
||||
return {
|
||||
selectedPrefix: (text: string) => theme.fg("accent", text),
|
||||
selectedText: (text: string) => theme.fg("accent", text),
|
||||
description: (text: string) => theme.fg("muted", text),
|
||||
scrollInfo: (text: string) => theme.fg("muted", text),
|
||||
noMatch: (text: string) => theme.fg("muted", text),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEditorTheme(): EditorTheme {
|
||||
return {
|
||||
borderColor: (text: string) => theme.fg("borderMuted", text),
|
||||
selectList: getSelectListTheme(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSettingsListTheme(): SettingsListTheme {
|
||||
return {
|
||||
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
||||
value: (text: string, selected: boolean) =>
|
||||
selected ? theme.fg("accent", text) : theme.fg("muted", text),
|
||||
description: (text: string) => theme.fg("dim", text),
|
||||
cursor: theme.fg("accent", "→ "),
|
||||
hint: (text: string) => theme.fg("dim", text),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1920,9 +1920,7 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
const preparation = unwrapCoreResult(
|
||||
prepareCompaction(pathEntries, options.settings, { force: isManual }),
|
||||
);
|
||||
const preparation = unwrapCoreResult(prepareCompaction(pathEntries, options.settings));
|
||||
if (!preparation) {
|
||||
if (isManual) {
|
||||
const lastEntry = pathEntries[pathEntries.length - 1];
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
openClawAgentCoreRuntime,
|
||||
type CompactionDetails,
|
||||
type CompactionPreparation,
|
||||
type CompactionPreparationOptions,
|
||||
type CompactionResult,
|
||||
type CompactionSettings,
|
||||
type ContextUsageEstimate,
|
||||
@@ -59,9 +58,8 @@ function unwrapCompactionResult<T>(result: Result<T, Error>): T {
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionEntry[],
|
||||
settings: CompactionSettings,
|
||||
options?: CompactionPreparationOptions,
|
||||
): CompactionPreparation | undefined {
|
||||
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings, options));
|
||||
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings));
|
||||
}
|
||||
|
||||
/** Generates a compaction summary through the shared agent-core runtime. */
|
||||
|
||||
@@ -713,6 +713,56 @@ describe("ensureAgentWorkspace", () => {
|
||||
"# Add tasks below when you want the agent to check something periodically.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recreate optional bootstrap files when workspace setup is already completed", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
|
||||
// First call: set up the workspace and complete setup by customizing profile files.
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_IDENTITY_FILENAME,
|
||||
content: "custom identity",
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_USER_FILENAME,
|
||||
content: "custom user",
|
||||
});
|
||||
// Delete BOOTSTRAP.md to trigger completion on next ensure call.
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
// Verify setup is completed.
|
||||
const state = await readWorkspaceState(tempDir);
|
||||
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
|
||||
// Delete optional bootstrap files and customize AGENTS.md to simulate
|
||||
// a repository workspace where optional files only exist under agent
|
||||
// subdirectories but the root still has customized required files.
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_SOUL_FILENAME));
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_USER_FILENAME));
|
||||
await fs.unlink(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
|
||||
await writeWorkspaceFile({
|
||||
dir: tempDir,
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
content: "custom agents instructions\n",
|
||||
});
|
||||
|
||||
// Third call: should NOT recreate optional files for an already-configured workspace.
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
// Verify optional files are NOT recreated at the root level.
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_SOUL_FILENAME));
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_USER_FILENAME));
|
||||
await expectPathMissing(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
|
||||
|
||||
// Verify required files (AGENTS.md, TOOLS.md) still exist.
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWorkspaceBootstrapFiles", () => {
|
||||
|
||||
@@ -954,6 +954,15 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
|
||||
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
|
||||
const skipOptionalBootstrapFiles = new Set(params?.skipOptionalBootstrapFiles ?? []);
|
||||
// When the workspace is already configured, skip optional bootstrap files to
|
||||
// prevent subagent spawns from recreating root-level SOUL.md, USER.md,
|
||||
// IDENTITY.md, or HEARTBEAT.md that were removed intentionally or only exist
|
||||
// under agent-specific subdirectories.
|
||||
if (await isWorkspaceSetupCompleted(dir)) {
|
||||
for (const filename of OPTIONAL_BOOTSTRAP_FILENAMES) {
|
||||
skipOptionalBootstrapFiles.add(filename);
|
||||
}
|
||||
}
|
||||
const shouldWriteBootstrapFile = (fileName: string): boolean =>
|
||||
!OPTIONAL_BOOTSTRAP_FILENAMES.has(fileName) || !skipOptionalBootstrapFiles.has(fileName);
|
||||
|
||||
|
||||
@@ -44,11 +44,6 @@ function hasInboundHistoryMedia(ctx: MsgContext): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** True when current or recent inbound history may contain agent-turn attachments. */
|
||||
export function hasPotentialAgentTurnAttachments(ctx: MsgContext): boolean {
|
||||
return hasInboundMedia(ctx) || hasInboundHistoryMedia(ctx);
|
||||
}
|
||||
|
||||
/** Resolves image attachments for the current agent turn and recent image history. */
|
||||
export async function resolveAgentTurnAttachments(params: {
|
||||
ctx: MsgContext;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** Reply-layer facade for parsing audio presentation tags. */
|
||||
export { parseAudioTag } from "../../media/audio-tags.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user