Files
openclaw/test/scripts/docker-build-helper.test.ts
Aamir Jawaid 04c2982535 fix(msteams): rebase TeamsSDK patterns to simplify Teams Integration (#76262)
* fix(msteams): rebase SDK migration onto current main

Reapply the msteams SDK migration (originally on feat/msteams-sdk-migration)
on top of upstream/main, resolving conflicts with parallel msteams work that
landed upstream during our session.

What got applied vs decisions made:

CLEANLY APPLIED (3-way patch):
- monitor.ts, monitor-handler.ts, polls.ts, reply-stream-controller.ts/.test.ts,
  reply-dispatcher.ts, attachments/download.ts, monitor.lifecycle.test.ts,
  monitor-handler/message-handler.ts, monitor-handler.types.ts, etc.
- streaming-message.ts + .test.ts deletions

WHOLESALE TAKE FROM ORIGINAL BRANCH (partial 3-way left broken cross-refs):
- sdk.ts, sdk.test.ts, messenger.ts, feedback-reflection.ts,
  send-context.ts, send.test.ts

KEPT UPSTREAM (deferred for separate cleanup):
- extensions/msteams/package.json (still has jsonwebtoken/jwks-rsa per
  Peter's b3bc60ae25 incremental approach)
- src/plugins/contracts/package-manifest.contract.test.ts (consistent with
  package.json)
- pnpm-lock.yaml (avoids lockfile churn; pnpm install --frozen-lockfile clean)

ADAPTED:
- Dockerfile matrix-sdk-crypto check now wraps upstream's new retry-loop in
  the if-matrix-bundled gate

KNOWN TEST FAILURES (need eyes — see PR comment):
- attachments.test.ts: 1 fail (pre-existing — warn meta arg shape changed in
  our migration but test wasn't updated)
- reply-dispatcher.test.ts: 6 fails (pre-existing — tests mock old
  TeamsHttpStream, not updated for our ctx.stream rewrite)
- send.test.ts: 4 fails (NEW from merge — upstream's send.ts changed media
  loading; our mocks need updating or take upstream's send.test.ts wholesale)

UPSTREAM COMMITS POTENTIALLY MISSED (in wholesale-take files):
- 08c4af0ddf fix(msteams): accept conversation id allowlists
- e1840b8581 fix(msteams): bind global audience tokens to app id
- Channels turn-kernel refactor (ffe67e9cdc / 1ead1b2d18 / 9a9cd0c0ab) —
  may be partially preserved in cleanly-patched files

Static checks pass: pnpm check:changed is green (typecheck, lint, contract
tests, import cycles, etc.). Manual testing required before merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): preserve thread routing for channel and group-chat replies

- monitor.ts: adaptSdkContext now uses ctx.reply() for channel and groupChat
  conversations (so the SDK threads outbound activities to the inbound's
  replyToId/serviceUrl) and ctx.send() only for personal DMs (where
  reply()'s blockquote-prepend is ugly).
- messenger.ts: sendProactively passes resolvedThreadId on the non-thread
  fallback path so channel @mentions that fall through outbound.ts -> send.ts
  still land in the original thread instead of top-level.

Live-validated: channel @mention -> bot replies in thread, threaded reply
-> bot replies in same thread, no top-level leakage.

* fix(msteams): tag outbound SDK calls with OpenClaw User-Agent

- user-agent.ts: add buildOpenClawUserAgentFragment() that returns just
  'OpenClaw/<version>'. The SDK's Client.clone merges this with its own
  'teams.ts[apps]/<sdk-version>' identifier — passing the full buildUserAgent()
  here would double-print the SDK token.
- sdk.ts: pass the fragment via AppOptions.client.headers['User-Agent'] so
  the Teams backend can identify OpenClaw traffic for usage telemetry.

Final UA looks like 'OpenClaw/<openclaw-version> teams.ts[apps]/<sdk-version>'.

* fix(msteams): handle StreamCancelledError when user presses Stop mid-stream

The new SDK throws StreamCancelledError synchronously from stream.emit/update
when the user pressed Stop in Teams: Teams replies 403 to the next chunk
update, the SDK flips _canceled, and any subsequent emit() throws. The old
custom TeamsHttpStream either swallowed cancel or didn't expose this exception
type, so the migration inherited an SDK behavior the original code didn't have
to handle.

Symptom on 2026-05-05: pressing Stop during a streaming reply caused an
unhandled promise rejection that crashed the Node 24 process. Docker restarted
the gateway about two minutes after each Stop click. Two related bugs surfaced
once the crash was caught: the would-be block fallback re-delivered the full
text as a second message (duplicate after Stop), and the typing-keepalive kept
pulsing in Teams for the rest of the agent run because nothing told it to
stop.

reply-stream-controller.ts:
- Wrap stream.update / stream.emit / stream.close in try/catch that swallows
  StreamCancelledError (matched by .name to dodge tsgo's SDK re-export
  resolution quirk). Latch a wasCanceled flag so subsequent calls
  short-circuit even if stream.canceled is stale.
- preparePayload() returns undefined when the stream was canceled — the
  streamed prefix is already visible to the user, so dropping the payload
  prevents a duplicate block message from overriding the cancel intent.

reply-dispatcher.ts:
- Typing-keepalive gate now also checks streamController.wasCanceled() so
  typing pulses stop firing once Stop is observed. Otherwise the bot keeps
  pulsing for the rest of the (uncancellable) agent run.

reply-stream-controller.test.ts:
- 6 new regression tests cover: cancel-during-emit (the crash scenario),
  cancel-during-update, cancel-during-finalize, non-cancel error propagation,
  post-cancel inactivity, and dropped-payload-on-cancel.

Live-validated: long streaming reply + Stop mid-stream -> stream freezes,
no duplicate message, no zombie typing, container stays healthy.

* fix(msteams): allow Bearer-token retry on Skype CDN attachment downloads

Teams puts inline DM images and clipboard-pasted images on
*.asm.skype.com URLs (e.g. us-api.asm.skype.com/v1/objects/<id>/views/imgo).
The download path in attachments/download.ts already does a plain GET first
and falls back to a Bearer-token retry on 401/403 — but the retry was gated
on the URL being in DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST. asm.skype.com hosts
were in DEFAULT_MEDIA_HOST_ALLOWLIST (download permitted) but not in the
auth-host list, so a 401 plain-GET response skipped the retry and surfaced
as a missing image to the agent.

Add asm.skype.com and ams.skype.com to the auth allowlist so openclaw
attempts the Bearer-token retry consistently, matching how it treats the
other CDN/Bot-Framework hosts already in the list.

Note: this does not unblock all clipboard-pasted DM images — for at least
some tenants asm.skype.com rejects the Bot Framework token (returns 401
even with auth). Routing those URLs through <serviceUrl>/v3/attachments/...
the way #62219 already handles HTML-wrapped attachments is a separate
follow-up. The +button 'Upload from this device' path works today because
Teams generates an attachment with an HTML wrapper that triggers the
existing BF v3 attachments fallback in monitor-handler/inbound-media.ts.

* fix(msteams): align docker-compose msteams port default with plugin default

The plugin defaults webhook.port to 3978 (the Bot Framework standard used in
Microsoft samples) and listens on whatever the operator sets there. The
docker-compose.yml port mapping was exposing ${OPENCLAW_MSTEAMS_PORT:-3000}:3000
which only works for operators who explicitly set webhook.port to 3000.
Default-config users would have the plugin listening on 3978 inside the
container while compose forwarded 3000, causing connection refused.

Realign to ${OPENCLAW_MSTEAMS_PORT:-3978}:3978 so a default-config docker
compose up Just Works with Teams. Operators wanting a custom port override
both webhook.port in openclaw.json and OPENCLAW_MSTEAMS_PORT env var.

* fix(msteams): post-rebase reconciliation with main

Three follow-ups after rebasing the SDK migration onto current main:

- reply-dispatcher.ts: rename createChannelReplyPipeline to its post-rebase
  identifier createChannelMessageReplyPipeline (the plugin-sdk barrel renamed
  it during the 1454-commit rebase window).
- reply-dispatcher.ts: tighten the typing-keepalive onStartError signature to
  (err: unknown) to satisfy upstream's stricter type checks.
- messenger.ts: drop the unconditional thread suffix on the bottom proactive
  fallback. The previous behavior threaded all top-level proactive sends when
  the stored ref had a threadId, which contradicts replyStyle='top-level'
  semantics (and breaks the new upstream test). Threading on the proactive
  path is preserved where it matters — the onRevoked branch within
  replyStyle==='thread' still passes resolvedThreadId, which is the original
  #55198 fix path.
- attachments.test.ts: update the warn-call assertion to match the migration's
  inline message format (host=... error=...) — the structured meta object was
  being dropped by the logger formatter pre-migration.

* feat(msteams): port streaming preview/progress features to ctx.stream

While the SDK migration was open, upstream landed preview/progress/draft
streaming features built on the OLD custom TeamsHttpStream class (which the
migration deletes). This commit ports the user-visible parts of those
features onto the new ctx.stream substrate so the migration doesn't lose
ground:

- pickInformativeStatusText: reads custom labels from
  msteams.streaming.progressDraft config via resolveChannelProgressDraftLabel.
  Falls back to the plugin-sdk default rotation. Pre-rebase used a hardcoded
  4-string array.
- streamMode resolution: "partial" (default, per-token streaming),
  "progress" (no tokens; preview card carries informative label that updates
  as tools run), or "block" (no native streaming). Mode is read from
  cfg.channels.msteams.streaming.preview.
- progress-draft gate: createChannelProgressDraftGate gates informative
  updates so the rotating label only starts firing once meaningful work has
  begun (avoids flicker before the first tool call).
- noteProgressWork() / pushProgressLine(): public methods on the controller
  for callers (typing keepalive ticks, tool-event callbacks) to signal work.
  pushProgressLine appends tool names as bullets above the rotating label
  when streaming.previewToolProgress is enabled. Wiring these into actual
  tool events is a separate follow-up.
- preparePayload progress-mode path: when stream is active but no tokens
  streamed (progress mode) and a final text payload arrives, emit the text
  into the stream so the preview card transitions in place to the final
  reply on close().

reply-dispatcher: pass log + msteamsConfig + a stable progressSeed
(${accountId}:${conversation.id}) to createTeamsReplyStreamController so the
informative-label rotation is consistent across reconnects.

What's NOT ported and why:
- Live-edit-via-replaceInformativeWithFinal: the SDK's HttpStream natively
  accumulates emitted text + entities + channelData and flushes ONE final
  activity at close() using the same activity id as the preview. So the
  separate "replace informative with final" call from upstream is
  unnecessary — we get live-finalization for free via the SDK's design.
- pushProgressLine triggers from tool events: needs reply-pipeline-side
  callbacks the new SDK migration didn't surface yet. Follow-up.

Tests: existing 22 reply-stream-controller tests still pass (the new
behaviors are additive).

* feat(msteams): wire pipeline tool events to streaming progress + fix test debt

Two follow-ups from yesterday's stopping point:

1. Wire pipeline events into the stream controller's progress-draft surface.
   reply-dispatcher's replyOptions now exposes onReasoningStream, onToolStart,
   onItemEvent, onPlanUpdate, onApprovalEvent, onCommandOutput callbacks that
   format each event via the channel-streaming helpers and route through
   streamController.pushProgressLine(). Mirrors the discord adapter's wiring.
   Also:
   - resolveChannelStreamingPreviewToolProgress + ...SuppressDefaultTool... so
     the dispatcher exposes suppressDefaultToolProgressMessages on its
     replyOptions when progress mode is on.
   - Switch disableBlockStreaming resolution to the channel-streaming helpers
     (resolveChannelPreviewStreamMode + resolveChannelStreamingBlockEnabled)
     so streaming.mode='block' and streaming.block.enabled=true are honored
     alongside the legacy blockStreaming boolean.

2. Fix the test debt that the rebase exposed:
   - reply-dispatcher.test.ts: drop the streamInstances + TeamsHttpStream
     mock pattern (file deleted by migration); replace with a streamMock
     provided via context.stream that mirrors the SDK's IStreamer shape
     (update/emit/close/canceled). Update assertions on sendInformativeUpdate
     -> stream.update, stream.update -> stream.emit. Drop the
     resumes-typing-between-segments test (no equivalent in the new
     ctx.stream model — the SDK's HttpStream doesn't have a 'between
     segments' notion; close ends the stream).
   - send.test.ts: fix two stale mock targets — loadOutboundMediaFromUrl
     comes from openclaw/plugin-sdk/outbound-media (not /msteams), and
     resolveMarkdownTableMode comes from openclaw/plugin-sdk/markdown-table-runtime
     (not /config-runtime). The previous mock paths were no-ops post-migration.

All 854 msteams tests now pass (was 17 failing in 4 files yesterday).

* fix(msteams): SDK streaming delta + use app.reply for proactive thread sends

Two narrow regressions exposed by the @microsoft/teams.apps migration:

- The SDK's HttpStream.emit appends each chunk to its internal buffer
  (`this.text += activity.text`), but the channel reply pipeline emits
  cumulative text on each chunk. Forwarding cumulative text into an
  appending sink produced "chunk1 + chunk1chunk2 + chunk1chunk2chunk3..."
  duplication for streamed (DM) replies. Track the emitted prefix length
  in the stream controller and only forward the new tail.
- Replace the manual `${convId};messageid=${msgId}` URL construction in
  the proactive thread fallback with `app.reply()`, which builds the
  threaded conversation id via the SDK's own toThreadedConversationId
  helper. Mechanically equivalent today; removes coupling to Teams' URL
  format and tracks any future SDK changes.

Also adds the `reply` method to the structural MSTeamsApp type so the
refactor typechecks without casts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): bump @microsoft/teams.api and teams.apps to 2.0.10

2.0.10 adds support for the AAD v1 token issuer that the Bot Framework
JWT validator needs. The minor version bump pulls teams.cards / common /
graph along to 2.0.10 too.

Add `@microsoft/teams.*` to `minimumReleaseAgeExclude` in
pnpm-workspace.yaml because 2.0.10 was published <48h ago and the default
`minimumReleaseAge: 2880` (~2 days) would otherwise reject it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert(msteams): remove asm.skype.com auth-host allowlist additions

These hosts were added in dfc169d31d for inline DM image auth-retry, but
the commit's own footnote acknowledges it doesn't actually unblock
clipboard-pasted images (asm.skype.com rejects Bot Framework tokens in
at least some tenants). The change is unrelated to the SDK migration and
the user-visible bug it claimed to fix isn't fixed; lifting it out keeps
this PR focused on the migration. Will land as a separate PR if the
auth-allowlist consistency improvement is wanted on its own.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(msteams): typed ExpressAdapter helper, drop unknown-cast pyramid

The monitor's SDK bootstrap had an awkward chain:

  httpServerAdapter: new (
    (await import("@microsoft/teams.apps")) as unknown as {
      ExpressAdapter: new (app: unknown) => unknown;
    }
  ).ExpressAdapter(expressApp) as never,

Three casts (`unknown`, structural shape literal, `never`) were a
defensive workaround from when the SDK's hashed d.ts files tripped up
tsgo. With the SDK's exports now resolving cleanly, the same import can
be done with full types.

- Extend the lazy `loadSdkModules()` cache to include `ExpressAdapter`
  alongside `App` so the dynamic import is shared.
- Add `createMSTeamsExpressAdapter(serverOrApp)` helper in `sdk.ts` that
  encapsulates the lazy import and returns a properly-typed adapter
  instance.
- Replace `httpServerAdapter`'s structural shape on `CreateMSTeamsAppOptions`
  with the SDK's own `IHttpServerAdapter` interface (re-exported from
  `@microsoft/teams.apps`).

The call site in `monitor.ts` becomes a single typed call with no `any`,
no `unknown`, no `as never`. The lazy-load behavior is preserved: nothing
imports `@microsoft/teams.apps` at module load time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): unbreak tsgo:extensions on the ExpressAdapter helper

CI's check-prod-types failed because the previous commit's typed helper
used `typeof import("@microsoft/teams.apps").ExpressAdapter`, which
tsc/tsgo's NodeNext resolution can't follow through the SDK's chained
`export *` barrel:

    @microsoft/teams.apps/dist/index.d.ts:
        export * from "./http";          // folder with index.d.ts
        export * from "./app";           // single .d.ts file

The folder re-export drops `ExpressAdapter` and `IHttpServerAdapter` from
the namespace shape under `tsconfig.extensions.json` (passes under the
per-extension `tsconfig.json` because of inherited `paths`). Same root
cause as why we already model `MSTeamsApp` structurally (line 47 comment).

Switch the ExpressAdapter side to the same structural-shape pattern:
- Define `MSTeamsHttpServerAdapter` and `MSTeamsExpressAdapterCtor` locally.
- Cast `m.ExpressAdapter` once inside `loadSdkModules` (the runtime export
  is fine; only the type surface is hidden).
- `httpServerAdapter` on `CreateMSTeamsAppOptions` and the return type of
  `createMSTeamsExpressAdapter` use the local structural type.

Net result: the call site in `monitor.ts` stays the cast-free single line
the previous commit landed; the one remaining cast is confined to the
SDK-loading helper with an explanatory comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): drop unused jsonwebtoken/jwks-rsa deps

The SDK migration removed all `import "jsonwebtoken"` / `import "jwks-rsa"`
from source code (the SDK does JWT validation internally now), but the
package.json entries and the matching `package-manifest.contract.test.ts`
expectation were left orphaned. Drop both:

- `extensions/msteams/package.json`: remove `jsonwebtoken` (^9), `jwks-rsa`
  (^4) from `dependencies` and `@types/jsonwebtoken` from `devDependencies`.
- `src/plugins/contracts/package-manifest.contract.test.ts`: remove the
  two entries from msteams's `pluginLocalRuntimeDeps` expectation.
- `monitor.lifecycle.test.ts`: extend the `./sdk.js` mock with the
  `createMSTeamsExpressAdapter` export added in the typed-helper cleanup,
  so the lifecycle suite still mounts after the deps drop.

Lockfile regenerates accordingly. All msteams tests (865) pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): drop unused @microsoft/teams.api direct dep

CI's deadcode:dependencies (knip) flagged @microsoft/teams.api as
unused in extensions/msteams. The plugin source uses structural type
aliases (MSTeamsActivityParams, MSTeamsActivityLike, etc.) to dodge
tsgo resolution bugs with teams.api's hashed d.ts files, so it never
imports teams.api directly. The package is brought in transitively
via @microsoft/teams.apps; the only other reference is
probe.test.ts's vi.mock("@microsoft/teams.api"), which works on the
import-path string and doesn't require a direct dep declaration.

Lockfile regenerates accordingly. tsgo:extensions, knip, and all
865 msteams tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): clear three CI gate failures (lint, contract, deprecated config API)

Three CI checks flagged on the latest run; all three are msteams-local
and unrelated to one another:

- **check-lint** / **check-additional-extension-bundled**:
  `oxlint` flagged a redundant `as string[]` assertion in
  `reply-dispatcher.ts:431`. The preceding `every((s: unknown) => typeof
  s === "string")` already narrows the array type, so the cast does
  nothing. Drop it.

- **checks-fast-contracts-plugins-c**: the
  `package-manifest.contract.test.ts` `pluginLocalRuntimeDeps` for
  msteams still expected `@microsoft/teams.api`, but the deadcode
  cleanup commit (8f4050f51a) dropped it from
  `extensions/msteams/package.json`. Remove it from the contract test
  too — `teams.api` is only present transitively via `teams.apps`,
  which is the reason knip flagged it.

- **check-additional-runtime-topology-architecture**: the deprecated
  internal config API guard caught `messenger.ts:223` calling
  `getMSTeamsRuntime().config.loadConfig()`. Switch to
  `config.current()` to match the pattern used by phone-control,
  synology-chat, and matrix.

Pre-existing failures on this run that are NOT msteams-related and not
caused by this PR: `check-test-types` (errors in
`src/agents/openai-transport-stream.test.ts` and
`pi-embedded-runner/openai-stream-wrappers.test.ts`) and `macos-swift`
(`hoistAwait` in `MacNodeRuntime.swift`). Leaving those for upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): cast config.current() return to OpenClawConfig

The previous commit switched `messenger.ts:223` from the deprecated
`config.loadConfig()` to `config.current()` to satisfy the architecture
guard, but `config.current()` returns a deeply-readonly type that's not
assignable to the `Partial<OpenClawConfig>` parameter
`resolveMarkdownTableMode` expects (a mutable type from the SDK
contract). Phone-control, synology-chat, and matrix all cast at this
seam — adopt the same pattern.

Verified locally: tsgo:core, tsgo:extensions, check:architecture, and
test:extensions:package-boundary:compile all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): address PR review — pre-auth body limit, allowlist log level, /api/messages forwarder, narrow release-age exclude

Four narrow fixes from the PR review (BradGroux + clawsweeper bot +
galiniliev's plan), each its own concern:

- **pre-auth-body-limit** (monitor.ts) — install
  `express.json({ limit: DEFAULT_WEBHOOK_MAX_BODY_BYTES })` before the
  bearer-presence gate and SDK route. Express memoizes the parsed body
  on the request, so the SDK's later `json()` becomes a no-op and our
  limit applies before any handler parses bodies. Closes the gap where
  a `Bearer garbage`-shaped attacker could force unbounded JSON parsing
  before token validation.

- **allowlist-error-logging** (monitor.ts) — restore main's `runtime.error`
  level for the `msteams resolve failed` catch (was downgraded to
  `runtime.log` mid-merge). Graph allowlist resolution failures are
  security-relevant; they need to surface to operators.

- **legacy-messages-route** (monitor.ts) — when `webhook.path` is set
  to a custom value, also accept POSTs on the legacy `/api/messages`
  path with a one-time deprecation warning, then re-enter the Express
  middleware chain on the configured path. Keeps existing Azure Bot
  registrations working through the transition. Cast-free
  (`expressApp(req, res, next)` works because `Application extends
  IRouter extends RequestHandler`).

- **release-age-scope** (pnpm-workspace.yaml) — narrow
  `@microsoft/teams.*` glob to the single direct dep
  `@microsoft/teams.apps`. Future scoped packages no longer get a
  freshness-guard pass.

Tests + checks: msteams suite (867), tsgo:core, tsgo:extensions,
tsgo:test, lint:extensions, check:architecture, knip --dependencies,
package-manifest contract, all green.

Still pending from the review (separate commits):
- auth-coverage-tests (Brad #1 + comment) — tests proving the SDK accepts
  `aud=<bot app id>` and rejects `aud=api.botframework.com`.
- invoke-response-handling (Brad #2, codex P2) — file-consent invoke ack
  must return through the SDK invoke handler, not `ctx.sendActivity`.
- stream-failure-fallback (codex P2, galin F5) — `streamFailed` latch so
  partial streams fall back to block delivery on non-cancel errors.
- serviceurl-routing (Brad #4, codex P2) — proposed rebuttal pending
  empirical confirmation that `smba.trafficmanager.net/teams` routes to
  non-default-region conversations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(msteams): lock SDK auth contract — aud + v1/v2 issuer coverage

Adds extensions/msteams/src/auth-coverage.test.ts driving ServiceTokenValidator
and createEntraTokenValidator directly with jose-minted RS256 tokens against an
in-memory JWKS (via JwksClient.prototype patch). Locks in the three contract
cases @BradGroux flagged on #76262: aud=<bot app id> accepted, aud=api.botframework.com
rejected even when appid/azp match, and v1/v2 issuers accepted for allowed tenant
(disallowed tenant rejected).

Drops a stale ambient module declaration in src/types/microsoft-teams-sdk.d.ts
that was shadowing the SDK's real jwt-validator types with a long-renamed
createServiceTokenValidator surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): route file-consent invokes through typed app.on, drop broken invokeResponse send

Brad #2 / codex #4 on PR #76262 — `ctx.sendActivity({ type: "invokeResponse", ... })`
no longer reaches Teams as an HTTP InvokeResponse on the new SDK; it becomes
an outbound Bot Framework activity instead. Move file-consent accept/decline
to typed `app.on("file.consent.accept|decline", ...)` handlers. The SDK's
typed-route layer wraps a void return into `{ status: 200 }`
(`app.process.js:130`), so the manual ack disappears.

While in here, type `MSTeamsApp.on` properly. Borrowing the SDK's `App.on`
directly fails because that function carries a `this: App<TPlugin>`
constraint our structural alias can't satisfy, so we model an equivalent
generic over `IRoutes` with route-specific overloads (`card.action`,
`file.consent.*`, `activity`). The overloads work around a tsgo bug — the
`@microsoft/teams.api` `Activity` discriminated union collapses to `any`,
turning `ActivityRoutes` into a `[string]: RouteHandler<X, void>` index
signature that swallows every typed `Out` not already void-compatible
(card.action returns `AdaptiveCardActionResponse`; the others happen to
include `void`). Real tsc resolves cleanly. Linked upstream:
https://github.com/microsoft/typescript-go/issues/1057.

Other cleanups:
- Cast-free call sites for `adaptSdkContext` (now returns
  `MSTeamsTurnContext` instead of `unknown`).
- card.action error responses include `innerHttpError` per the SDK's
  `HttpError` shape requirement.
- Activity catch-all also skips `fileConsent/invoke` now that it's
  typed-routed (parallel to the existing `adaptiveCard/action` skip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): route SSO sign-in invokes through typed app.on, drop broken invokeResponse send

Brad #2 / codex #4 on PR #76262, SSO half. Continue the typed-route migration:
`signin/tokenExchange` and `signin/verifyState` now register via
`app.on("signin.token-exchange" | "signin.verify-state", ...)`. Per the
SDK's router, registering a user route with the same name as a system
route removes the system default — so the SDK's built-in handlers (which
would call `api.users.token.exchange` themselves and emit a `signin` event
nobody currently subscribes to) are silenced, and only ours runs. The SDK
wraps a void return into the HTTP 200 InvokeResponse, so the legacy
`ctx.sendActivity({ type: "invokeResponse", ... })` ack — broken on the new
SDK because it becomes an outbound BF activity instead of the HTTP
response — is gone.

The handler body is extracted from the activity-catch-all dispatch in
`monitor-handler.ts` to a new `signin-invoke.ts`, parallel to
`file-consent-invoke.ts`. `isSigninInvokeAuthorized` is now exported from
`monitor-handler.ts` so the new handler can reuse it. The activity
catch-all skips the SSO invoke names alongside the existing skips for
`adaptiveCard/action` and `fileConsent/invoke`.

`MSTeamsAppOn` overloads now cover the two SSO routes with their typed
ctx (`ISignInTokenExchangeInvokeActivity` / `ISignInVerifyStateInvokeActivity`).
Tests in `monitor-handler.sso.test.ts` were rewritten to call the
extracted handler directly — the `registered.run(ctx)` shape no longer
covers SSO, and the `expect(ctx.sendActivity).toHaveBeenCalledWith({ type:
"invokeResponse" })` assertions were dropped to match the new contract
(the SDK ack happens via the typed-route return value).

Note on overlap with #77784 (Stefan Stüben, Microsoft): that PR is doing
a much bigger SSO rework (sign-in card / sign-in-link / six-digit-code
fallbacks plus a `ctx.auth` plumbed to plugin tools). This change is
the small migration-correctness fix and is structured so #77784's SSO
body changes drop into the typed-route registrations cleanly on rebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): route message-submit (feedback) invokes through typed app.on

Last invoke off the activity catch-all dispatch. `message/submitAction`
(thumbs up/down on AI-generated messages) now registers via
`app.on("message.submit", ...)`. Same shape as file-consent and SSO:
handler body extracted to a new `feedback-invoke.ts`, the SDK wraps a
void return into the HTTP 200 InvokeResponse, the broken
`ctx.sendActivity({ type: "invokeResponse", ... })` line is gone, and
the activity catch-all skips this invoke name alongside the others.

`isFeedbackInvokeAuthorized` is exported from `monitor-handler.ts` so
`feedback-invoke.ts` can reuse it. Tests in
`monitor-handler.feedback-authz.test.ts` were rewritten to call the
extracted handler directly — the old `handler.run(ctx)` shape no longer
intercepts feedback, and `originalRun` was removed because the typed
route is the dispatch point now.

`MSTeamsAppOn` overload added with the typed
`IMessageSubmitActionInvokeActivity` ctx, slotted between the SSO
overloads and the `activity` catch-all so `activity` stays last.

This leaves only `message`, `conversationUpdate`, and `messageReaction`
flowing through `app.on("activity", ...)` → `handler.run`. Promoting
those is the path to deleting the catch-all entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): fall back to block delivery when partial-mode stream fails mid-flight

codex #5 / Galin F5 on PR #76262. `reply-stream-controller.ts` previously
re-threw any non-cancel error from `stream.emit` during partial streaming
and from `stream.emit`/`stream.close` during finalize. Combined with
`preparePayload` suppressing block delivery once `tokensEmitted` was
true, that meant a network blip or API error mid-stream produced a
truncated reply with no recovery — the user saw the prefix that made it
through and nothing else.

Add a `streamFailed` latch parallel to `canceledLocally` / `tokensEmitted`:

- `onPartialReply`: catch non-cancel errors, set `streamFailed = true`,
  log a warn, don't propagate (the pipeline must keep running so
  `preparePayload` can decide).
- `preparePayload`: when `tokensEmitted && streamFailed`, fall through to
  block delivery instead of suppressing. The user may see a duplicate
  (streamed prefix + full block reply); intentional — matches the
  pre-migration `TeamsHttpStream.hasContent` recovery and is better than
  truncated-only.
- `finalize`: same latch + warn on non-cancel close failure, swallow
  rather than throw. The streamed content already reached the user; the
  closing activity (AI-Generated marker, feedback channelData) is the
  only loss, not worth blowing up the dispatcher.
- `isStreamActive` returns false once the stream has failed.

New tests cover crash-mid-stream after tokens were emitted (assert block
delivery payload is returned), happy-path no-duplicate behavior (assert
`preparePayload` still suppresses when nothing failed), and finalize
close-failure (assert no throw). The pre-existing "re-throws non-cancel"
test was inverted to assert non-throwing latch behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): declare @microsoft/teams.api as a runtime dependency

Type-only `import("@microsoft/teams.api/dist/...").TypeName` references
in `sdk.ts` (added when typed `MSTeamsApp.on` overloads were introduced)
are picked up by the `extension-runtime-dependencies` contract test as
genuine runtime imports. Declaring `@microsoft/teams.api` as a direct
dep makes the contract pass; the package was already coming in
transitively via `@microsoft/teams.apps`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): keep SSO on SDK signin routes

* test(msteams): avoid redundant signin handler assertion

* docs(msteams): clarify Teams cloud support

* fix(msteams): use current SDK string helper

* fix(msteams): gate SDK invoke side effects

* test(msteams): avoid implicit any in lifecycle tests

* fix(msteams): preserve SDK user agent and matrix check

* fix(msteams): expose SDK common dependency

* fix(msteams): use SDK user agent merge

* fix(msteams): fall back when stream close no-ops

* chore(msteams): drop unrelated merge artifacts

* chore(msteams): restore unrelated main files

* chore(msteams): restore unrelated main files

* chore(msteams): restore unrelated main files

* test(msteams): type stream close mock result

* fix(msteams): configure Teams cloud service URL

* chore(msteams): refresh shrinkwrap

* chore(deps): refresh shrinkwrap locks

* chore(ci): rerun guards after main sync

* chore(deps): refresh shrinkwrap for node 24

* chore(config): refresh docs baseline

* fix(msteams): preserve Teams SDK proactive references

* fix(msteams): harden SDK proactive sends

* fix(msteams): align service url contract

* test: fix bonjour beacon type narrowing

* fix(msteams): ignore ambient service url

* fix(msteams): fall through submit invokes

* test: align shrinkwrap override policy with Teams SDK deps

* fix(msteams): ack invoke routes promptly

* fix(msteams): support china cloud boundaries

* test: sync PR with current CI gates

* test: isolate channel setup registry metadata

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 22:54:34 +01:00

1843 lines
67 KiB
TypeScript

import { execFileSync } from "node:child_process";
import {
chmodSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const HELPER_PATH = "scripts/lib/docker-build.sh";
const DOCKER_ALL_SCHEDULER_PATH = "scripts/test-docker-all.mjs";
const DOCKER_E2E_PACKAGE_HELPER_PATH = "scripts/lib/docker-e2e-package.sh";
const DOCKER_E2E_IMAGE_HELPER_PATH = "scripts/lib/docker-e2e-image.sh";
const DOCKER_E2E_SCENARIOS_PATH = "scripts/lib/docker-e2e-scenarios.mjs";
const INSTALL_E2E_RUNNER_PATH = "scripts/docker/install-sh-e2e/run.sh";
const CLEANUP_DOCKER_SMOKE_PATH = "scripts/test-cleanup-docker.sh";
const INSTALL_E2E_DOCKER_SMOKE_PATH = "scripts/test-install-sh-e2e-docker.sh";
const LIVE_CLI_BACKEND_DOCKER_PATH = "scripts/test-live-cli-backend-docker.sh";
const LIVE_BUILD_DOCKER_PATH = "scripts/test-live-build-docker.sh";
const OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH = "scripts/e2e/openai-web-search-minimal-docker.sh";
const OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH =
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh";
const OPENAI_WEB_SEARCH_MINIMAL_CLIENT_PATH =
"scripts/e2e/lib/openai-web-search-minimal/client.mjs";
const OPENWEBUI_DOCKER_E2E_PATH = "scripts/e2e/openwebui-docker.sh";
const ONBOARD_DOCKER_E2E_PATH = "scripts/e2e/onboard-docker.sh";
const KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH = "scripts/e2e/kitchen-sink-plugin-docker.sh";
const KITCHEN_SINK_RPC_DOCKER_E2E_PATH = "scripts/e2e/kitchen-sink-rpc-docker.sh";
const CODEX_ON_DEMAND_DOCKER_E2E_PATH = "scripts/e2e/codex-on-demand-docker.sh";
const CODEX_MEDIA_PATH_SCENARIO_PATH = "scripts/e2e/lib/codex-media-path/scenario.sh";
const CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH = "scripts/e2e/codex-npm-plugin-live-docker.sh";
const LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH = "scripts/e2e/live-plugin-tool-docker.sh";
const NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH = "scripts/e2e/npm-onboard-channel-agent-docker.sh";
const SKILL_INSTALL_DOCKER_E2E_PATH = "scripts/e2e/skill-install-docker.sh";
const PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH =
"scripts/e2e/plugin-binding-command-escape-docker.sh";
const PLUGIN_BINDING_COMMAND_ESCAPE_DOCKERFILE_PATH =
"scripts/e2e/plugin-binding-command-escape.Dockerfile";
const QR_IMPORT_DOCKER_E2E_PATH = "scripts/e2e/qr-import-docker.sh";
const MULTI_NODE_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/multi-node-update-docker.sh";
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH =
"scripts/e2e/bundled-plugin-install-uninstall-docker.sh";
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH =
"scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh";
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_PROBE_PATH =
"scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs";
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_RUNTIME_SMOKE_PATH =
"scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs";
const CLEANUP_SMOKE_DOCKERFILE_PATH = "scripts/docker/cleanup-smoke/Dockerfile";
const PLUGINS_DOCKER_E2E_PATH = "scripts/e2e/plugins-docker.sh";
const PLUGINS_DOCKER_SWEEP_PATH = "scripts/e2e/lib/plugins/sweep.sh";
const PLUGINS_DOCKER_MARKETPLACE_PATH = "scripts/e2e/lib/plugins/marketplace.sh";
const PLUGINS_DOCKER_CLAWHUB_PATH = "scripts/e2e/lib/plugins/clawhub.sh";
const PLUGINS_DOCKER_ASSERTIONS_PATH = "scripts/e2e/lib/plugins/assertions.mjs";
const PLUGINS_DOCKER_NPM_REGISTRY_PATH = "scripts/e2e/lib/plugins/npm-registry-server.mjs";
const PLUGIN_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/plugin-update-unchanged-docker.sh";
const PLUGIN_UPDATE_SCENARIO_PATH = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh";
const PLUGIN_UPDATE_CORRUPT_SCENARIO_PATH =
"scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh";
const PLUGIN_UPDATE_PROBE_PATH = "scripts/e2e/lib/plugin-update/probe.mjs";
const PLUGIN_LIFECYCLE_MATRIX_DOCKER_E2E_PATH = "scripts/e2e/plugin-lifecycle-matrix-docker.sh";
const DOCTOR_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/doctor-install-switch-docker.sh";
const DOCTOR_SWITCH_SCENARIO_PATH = "scripts/e2e/lib/doctor-install-switch/scenario.sh";
const PACKAGE_COMPAT_PATH = "scripts/e2e/lib/package-compat.mjs";
const UPGRADE_SURVIVOR_DOCKER_E2E_PATH = "scripts/e2e/upgrade-survivor-docker.sh";
const UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/update-channel-switch-docker.sh";
const UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH =
"scripts/e2e/lib/update-channel-switch/assertions.mjs";
const RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH =
"scripts/e2e/lib/release-upgrade-user-journey/scenario.sh";
const UPGRADE_SURVIVOR_RUN_SCRIPT = "scripts/e2e/lib/upgrade-survivor/run.sh";
const UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH =
"scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh";
const GATEWAY_NETWORK_DOCKER_E2E_PATH = "scripts/e2e/gateway-network-docker.sh";
const CENTRALIZED_BUILD_SCRIPTS = [
"scripts/docker/setup.sh",
"scripts/e2e/browser-cdp-snapshot-docker.sh",
"scripts/e2e/qr-import-docker.sh",
"scripts/lib/docker-e2e-image.sh",
"scripts/sandbox-browser-setup.sh",
"scripts/sandbox-common-setup.sh",
"scripts/sandbox-setup.sh",
"scripts/test-cleanup-docker.sh",
"scripts/test-install-sh-docker.sh",
"scripts/test-install-sh-e2e-docker.sh",
"scripts/test-live-build-docker.sh",
] as const;
function packageBackedDockerRunnerPaths(): string[] {
return readdirSync("scripts/e2e")
.filter((entry) => entry.endsWith("-docker.sh"))
.map((entry) => join("scripts/e2e", entry))
.filter((path) => readFileSync(path, "utf8").includes("docker_e2e_prepare_package_tgz"))
.sort();
}
function shellQuote(value: string): string {
return `'${value.replace(/'/gu, `'\\''`)}'`;
}
describe("docker build helper", () => {
it("forces BuildKit for centralized Docker builds", () => {
const helper = readFileSync(HELPER_PATH, "utf8");
expect(helper).toContain("DOCKER_BUILDKIT=1");
expect(helper).toContain("docker_build_exec()");
expect(helper).toContain("docker_build_run()");
expect(helper).toContain("docker buildx build --load");
expect(helper).toContain("docker_build_transient_failure()");
expect(helper).toContain("OPENCLAW_DOCKER_BUILD_RETRIES");
expect(helper).toContain("OPENCLAW_DOCKER_BUILD_TIMEOUT");
expect(helper).toContain('docker_build_run_command "$timeout_value" "${command[@]}"');
expect(helper).toContain("OPENCLAW_DOCKER_BUILD_REQUIRE_TIMEOUT");
expect(helper).toContain("frontend grpc server closed unexpectedly");
});
it("keeps shell-script Docker builds behind the helper", () => {
for (const path of CENTRALIZED_BUILD_SCRIPTS) {
const script = readFileSync(path, "utf8");
expect(script, path).toMatch(/docker-build\.sh|docker-e2e-image\.sh/);
expect(script, path).not.toMatch(/\bdocker build\b/);
expect(script, path).not.toMatch(/run_logged\s+\S+\s+docker\s+build/);
}
});
it("routes standalone Docker smoke runs through the timeout-aware helper", () => {
const cleanupSmoke = readFileSync(CLEANUP_DOCKER_SMOKE_PATH, "utf8");
const installE2eSmoke = readFileSync(INSTALL_E2E_DOCKER_SMOKE_PATH, "utf8");
expect(cleanupSmoke).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"');
expect(cleanupSmoke).toContain(
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_CLEANUP_SMOKE_DOCKER_TIMEOUT:-600s}}"',
);
expect(cleanupSmoke).toContain(
'docker_e2e_docker_run_cmd run --rm --platform "$PLATFORM" -t "$IMAGE_NAME"',
);
expect(cleanupSmoke).not.toContain('docker run --rm --platform "$PLATFORM" -t "$IMAGE_NAME"');
expect(installE2eSmoke).toContain('source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"');
expect(installE2eSmoke).toContain(
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_INSTALL_E2E_DOCKER_TIMEOUT:-2700s}}"',
);
expect(installE2eSmoke).toContain("docker_e2e_docker_run_cmd run --rm \\");
expect(installE2eSmoke).not.toContain("docker run --rm \\");
});
it("lets Testbox fall back to building when a reused Docker image is missing", () => {
const helper = readFileSync(HELPER_PATH, "utf8");
const e2eImageHelper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8");
const liveBuild = readFileSync(LIVE_BUILD_DOCKER_PATH, "utf8");
const liveCliBackend = readFileSync(LIVE_CLI_BACKEND_DOCKER_PATH, "utf8");
expect(helper).toContain("docker_build_on_missing_enabled()");
expect(helper).toContain("OPENCLAW_DOCKER_BUILD_ON_MISSING");
expect(helper).toContain("OPENCLAW_TESTBOX");
expect(e2eImageHelper).toContain("docker_build_on_missing_enabled");
expect(e2eImageHelper).toContain("Docker image not available; building");
expect(e2eImageHelper).toContain('docker_e2e_docker_cmd image inspect "$image_name"');
expect(e2eImageHelper).toContain('docker_e2e_docker_cmd pull "$image_name"');
expect(liveBuild).toContain('source "$SCRIPT_ROOT_DIR/scripts/lib/docker-e2e-container.sh"');
expect(liveBuild).toContain(
'DOCKER_COMMAND_TIMEOUT="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_LIVE_DOCKER_PULL_TIMEOUT:-600s}}"',
);
expect(liveBuild).toContain('docker_e2e_docker_cmd image inspect "$LIVE_IMAGE_NAME"');
expect(liveBuild).toContain('docker_e2e_docker_cmd pull "$LIVE_IMAGE_NAME"');
expect(liveBuild).not.toContain('docker image inspect "$LIVE_IMAGE_NAME"');
expect(liveBuild).not.toContain('docker pull "$LIVE_IMAGE_NAME"');
expect(liveBuild).toContain("Live-test image not available; building");
expect(readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8")).toContain(
'DOCKER_COMMAND_TIMEOUT="$DOCKER_PULL_TIMEOUT" docker_e2e_docker_cmd pull "$OPENWEBUI_IMAGE"',
);
expect(readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8")).not.toContain(
'timeout "$DOCKER_PULL_TIMEOUT" docker pull "$OPENWEBUI_IMAGE"',
);
expect(liveCliBackend).toContain(
'OPENCLAW_LIVE_DOCKER_REPO_ROOT="$ROOT_DIR" "$TRUSTED_HARNESS_DIR/scripts/test-live-build-docker.sh"',
);
expect(liveCliBackend).toContain("codex-cli is no longer a bundled CLI backend");
expect(liveCliBackend).not.toContain("==> Direct Codex CLI probe ok");
expect(liveCliBackend).not.toContain(
'echo "==> Reuse live-test image: $LIVE_IMAGE_NAME (OPENCLAW_SKIP_DOCKER_BUILD=1)"',
);
});
it("wraps centralized Docker builds with the timeout helper", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-timeout-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "timeout"),
`#!/bin/bash
set -euo pipefail
if [[ "$1" = "--kill-after=1s" ]]; then
exit 0
fi
printf '%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen"
shift 2
"$@"
`,
);
chmodSync(join(binDir, "timeout"), 0o755);
writeFileSync(
join(binDir, "docker"),
`#!/bin/sh
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
`,
);
chmodSync(join(binDir, "docker"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin:$PATH"
export OPENCLAW_DOCKER_BUILD_TIMEOUT=17s
source "$ROOT_DIR/scripts/lib/docker-build.sh"
docker_build_run e2e-build -t demo-image .
grep -q '^--kill-after=30s 17s|env DOCKER_BUILDKIT=1 docker build -t demo-image .$' "$TMPDIR/timeout-seen"
grep -q '^build -t demo-image .$' "$TMPDIR/docker-seen"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("fails centralized Docker builds fast when timeout is unavailable", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-timeout-required-"));
try {
mkdirSync(join(workDir, "bin"));
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export OPENCLAW_DOCKER_BUILD_TIMEOUT=19s
dirname() {
/usr/bin/dirname "$@"
}
grep() {
/usr/bin/grep "$@"
}
cat() {
/bin/cat "$@"
}
rm() {
/bin/rm "$@"
}
mktemp() {
/usr/bin/mktemp "$@"
}
docker() {
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
}
export -f dirname grep cat rm mktemp docker
source "$ROOT_DIR/scripts/lib/docker-build.sh"
set +e
docker_build_run e2e-build -t demo-image . >"$TMPDIR/stdout" 2>"$TMPDIR/stderr"
status="$?"
set -e
stdout="$(<"$TMPDIR/stdout")"
[[ "$status" = "1" ]]
[[ "$stdout" = *"timeout command not found; cannot bound Docker command after 19s"* ]]
[[ ! -e "$TMPDIR/docker-seen" ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("keeps setup-style Docker builds compatible when timeout is unavailable", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-timeout-optional-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "env"),
`#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
*=*)
shift
;;
*)
break
;;
esac
done
exec "$@"
`,
);
chmodSync(join(binDir, "env"), 0o755);
writeFileSync(
join(binDir, "docker"),
`#!/bin/sh
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
`,
);
chmodSync(join(binDir, "docker"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export OPENCLAW_DOCKER_BUILD_TIMEOUT=23s
dirname() {
/usr/bin/dirname "$@"
}
grep() {
/usr/bin/grep "$@"
}
rm() {
/bin/rm "$@"
}
mktemp() {
/usr/bin/mktemp "$@"
}
export -f dirname grep rm mktemp
source "$ROOT_DIR/scripts/lib/docker-build.sh"
docker_build_exec -t setup-image .
[[ "$(<"$TMPDIR/docker-seen")" = "build -t setup-image ." ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("keeps reused Docker image probes behind the timeout-aware helper", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-image-reuse-timeout-"));
try {
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export DOCKER_COMMAND_TIMEOUT=3s
export OPENCLAW_SKIP_DOCKER_BUILD=1
mkdir -p "$TMPDIR/bin"
cat >"$TMPDIR/bin/timeout" <<'SH'
#!/usr/bin/env bash
case "$1" in
--kill-after=1s)
exit 0
;;
--kill-after=30s)
printf "%s %s|%s\\n" "$1" "$2" "$3 $4 $5" >>"$TMPDIR/timeout-seen"
shift 2
;;
*)
printf "%s|%s\\n" "$1" "$2 $3 $4" >>"$TMPDIR/timeout-seen"
shift
;;
esac
"$@"
SH
chmod +x "$TMPDIR/bin/timeout"
export PATH="$TMPDIR/bin:$PATH"
docker() {
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
case "$1 $2" in
"image inspect")
return 1
;;
"pull openclaw-reuse-image")
return 0
;;
*)
return 9
;;
esac
}
export -f docker
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
docker_e2e_build_or_reuse \\
openclaw-reuse-image \\
reuse-timeout-proof \\
"$ROOT_DIR/scripts/e2e/Dockerfile" \\
"$ROOT_DIR" \\
functional
test "$(grep -c '^--kill-after=30s 3s|' "$TMPDIR/timeout-seen")" = "2"
grep -q '^image inspect openclaw-reuse-image$' "$TMPDIR/docker-seen"
grep -q '^pull openclaw-reuse-image$' "$TMPDIR/docker-seen"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("fails Docker commands fast when timeout is unavailable", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-timeout-required-"));
try {
mkdirSync(join(workDir, "bin"));
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export DOCKER_COMMAND_TIMEOUT=7s
docker() {
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
}
export -f docker
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
set +e
docker_e2e_docker_cmd ps 2>"$TMPDIR/stderr"
status="$?"
set -e
stderr="$(<"$TMPDIR/stderr")"
[[ "$status" = "127" ]]
[[ "$stderr" = *"timeout command not found; cannot bound Docker command after 7s"* ]]
[[ ! -e "$TMPDIR/docker-seen" ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("uses a Node watchdog for Docker commands when timeout is unavailable", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-node-timeout-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "node"),
`#!/bin/bash\nexec ${shellQuote(process.execPath)} "$@"\n`,
);
writeFileSync(
join(binDir, "docker"),
`#!/bin/bash\ninput="$(/bin/cat)"\nprintf "%s|%s\\n" "$*" "$input" >"$TMPDIR/docker-seen"\nexit 13\n`,
);
chmodSync(join(binDir, "node"), 0o755);
chmodSync(join(binDir, "docker"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export DOCKER_COMMAND_TIMEOUT=7s
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
set +e
printf payload | docker_e2e_docker_cmd run -i demo 2>"$TMPDIR/stderr"
status="$?"
set -e
stderr="$(<"$TMPDIR/stderr")"
[[ "$status" = "13" ]]
[[ "$stderr" = *"timeout command not found; using Node watchdog for Docker command timeout 7s"* ]]
[[ "$(<"$TMPDIR/docker-seen")" = "run -i demo|payload" ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("escalates Docker watchdog children that ignore parent termination", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-node-signal-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "node"),
`#!/bin/bash\nexec ${shellQuote(process.execPath)} "$@"\n`,
);
writeFileSync(
join(binDir, "docker"),
`#!/bin/bash
printf "%s\\n" "$$" >"$TMPDIR/docker-pid"
printf "%s\\n" "$PPID" >"$TMPDIR/watchdog-pid"
trap "" TERM
while true; do /bin/sleep 1; done
`,
);
chmodSync(join(binDir, "node"), 0o755);
chmodSync(join(binDir, "docker"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export DOCKER_COMMAND_TIMEOUT=30s
export OPENCLAW_DOCKER_TIMEOUT_KILL_GRACE_MS=100
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
docker_e2e_docker_cmd run demo &
watchdog_pid="$!"
for ((i = 0; i < 100; i += 1)); do
[ -s "$TMPDIR/docker-pid" ] && [ -s "$TMPDIR/watchdog-pid" ] && break
/bin/sleep 0.02
done
[ -s "$TMPDIR/docker-pid" ]
[ -s "$TMPDIR/watchdog-pid" ]
kill -TERM "$(/bin/cat "$TMPDIR/watchdog-pid")"
set +e
wait "$watchdog_pid"
status="$?"
set -e
[ "$status" = "143" ]
docker_pid="$(/bin/cat "$TMPDIR/docker-pid")"
for ((i = 0; i < 100; i += 1)); do
kill -0 "$docker_pid" 2>/dev/null || exit 0
/bin/sleep 0.02
done
echo "docker child still alive after watchdog termination" >&2
exit 1
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("uses plain timeout when kill-after is unsupported", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-plain-timeout-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "timeout"),
`#!/bin/bash
set -euo pipefail
if [[ "$1" = "--kill-after=1s" ]]; then
exit 1
fi
printf 'plain:%s|%s\\n' "$1" "\${*:2}" >>"$TMPDIR/timeout-seen"
shift
"$@"
`,
);
chmodSync(join(binDir, "timeout"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin:$PATH"
export DOCKER_COMMAND_TIMEOUT=9s
docker() {
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
}
export -f docker
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
docker_e2e_docker_cmd image inspect demo
grep -q '^plain:9s|docker image inspect demo$' "$TMPDIR/timeout-seen"
grep -q '^image inspect demo$' "$TMPDIR/docker-seen"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("uses gtimeout when timeout is unavailable", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-gtimeout-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "gtimeout"),
`#!/bin/bash
set -euo pipefail
if [[ "$1" = "--kill-after=1s" ]]; then
exit 0
fi
printf 'gtimeout:%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen"
shift 2
"$@"
`,
);
chmodSync(join(binDir, "gtimeout"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=13s
docker() {
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
}
export -f docker
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
docker_e2e_docker_run_cmd run demo
[[ "$(<"$TMPDIR/timeout-seen")" = "gtimeout:--kill-after=30s 13s|docker run demo" ]]
[[ "$(<"$TMPDIR/docker-seen")" = "run demo" ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("keeps package-backed Docker runs bounded without the shared timeout helper", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-timeout-required-"));
try {
mkdirSync(join(workDir, "bin"));
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=11s
dirname() {
/usr/bin/dirname "$@"
}
docker_e2e_docker_cmd() {
return 0
}
docker() {
printf "%s\\n" "$*" >"$TMPDIR/docker-seen"
}
export -f docker_e2e_docker_cmd docker
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
set +e
docker_e2e_docker_run_cmd run demo 2>"$TMPDIR/stderr"
status="$?"
set -e
stderr="$(<"$TMPDIR/stderr")"
[[ "$status" = "127" ]]
[[ "$stderr" = *"timeout command not found; cannot bound Docker run after 11s"* ]]
[[ ! -e "$TMPDIR/docker-seen" ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("uses gtimeout for package-backed Docker runs without the shared timeout helper", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-gtimeout-"));
try {
const binDir = join(workDir, "bin");
mkdirSync(binDir);
writeFileSync(
join(binDir, "gtimeout"),
`#!/bin/bash
set -euo pipefail
if [[ "$1" = "--kill-after=1s" ]]; then
exit 0
fi
printf 'gtimeout:%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen"
shift 2
"$@"
`,
);
chmodSync(join(binDir, "gtimeout"), 0o755);
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export PATH="$TMPDIR/bin"
export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=15s
dirname() {
/usr/bin/dirname "$@"
}
docker_e2e_docker_cmd() {
return 0
}
docker() {
printf "%s\\n" "$*" >>"$TMPDIR/docker-seen"
}
export -f docker_e2e_docker_cmd docker
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
docker_e2e_docker_run_cmd run demo
[[ "$(<"$TMPDIR/timeout-seen")" = "gtimeout:--kill-after=30s 15s|docker run demo" ]]
[[ "$(<"$TMPDIR/docker-seen")" = "run demo" ]]
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("removes functional Docker build package inputs after the build", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-cleanup-"));
try {
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
node() {
local script="$1"
shift
if [[ "$script" != "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" ]]; then
command node "$script" "$@"
return
fi
local output_dir=""
local output_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output-dir)
output_dir="$2"
shift 2
;;
--output-name)
output_name="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$output_dir"
printf fixture >"$output_dir/$output_name"
printf "%s\\n" "$output_dir/$output_name"
}
export -f node
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
docker_build_run() {
local build_context=""
local arg
for arg in "$@"; do
case "$arg" in
openclaw_package=*)
build_context="\${arg#openclaw_package=}"
;;
esac
done
test -n "$build_context"
test -f "$build_context/openclaw-current.tgz"
printf "%s\\n" "$build_context" >"$TMPDIR/build-context-seen"
}
docker_e2e_build_or_reuse \\
openclaw-test-image \\
cleanup-proof \\
"$ROOT_DIR/scripts/e2e/Dockerfile" \\
"$ROOT_DIR" \\
functional
test -f "$TMPDIR/build-context-seen"
leftovers="$(find "$TMPDIR" -maxdepth 1 \\( \\
-name 'openclaw-docker-e2e-pack.*' \\
-o -name 'openclaw-docker-e2e-package-context.*' \\
\\) -print)"
if [[ -n "$leftovers" ]]; then
printf 'leftover functional build inputs:\\n%s\\n' "$leftovers" >&2
exit 1
fi
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("keeps caller-provided functional Docker build packages", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-external-package-"));
try {
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
external_dir="$TMPDIR/external-package"
mkdir -p "$external_dir"
printf fixture >"$external_dir/openclaw-current.tgz"
OPENCLAW_CURRENT_PACKAGE_TGZ="$external_dir/openclaw-current.tgz"
export OPENCLAW_CURRENT_PACKAGE_TGZ
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
docker_build_run() {
local build_context=""
local arg
for arg in "$@"; do
case "$arg" in
openclaw_package=*)
build_context="\${arg#openclaw_package=}"
;;
esac
done
test -n "$build_context"
test -f "$build_context/openclaw-current.tgz"
printf "%s\\n" "$build_context" >"$TMPDIR/build-context-seen"
}
docker_e2e_build_or_reuse \\
openclaw-test-image \\
external-package-proof \\
"$ROOT_DIR/scripts/e2e/Dockerfile" \\
"$ROOT_DIR" \\
functional
test -f "$TMPDIR/build-context-seen"
test -f "$OPENCLAW_CURRENT_PACKAGE_TGZ"
leftovers="$(find "$TMPDIR" -maxdepth 1 -name 'openclaw-docker-e2e-package-context.*' -print)"
if [[ -n "$leftovers" ]]; then
printf 'leftover functional build context:\\n%s\\n' "$leftovers" >&2
exit 1
fi
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("cleans generated package mounts after harness Docker runs", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-mount-cleanup-"));
try {
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
export DOCKER_COMMAND_TIMEOUT=3s
mkdir -p "$TMPDIR/bin"
cat >"$TMPDIR/bin/timeout" <<'SH'
#!/usr/bin/env bash
case "$1" in
--kill-after=1s)
exit 0
;;
--kill-after=30s)
printf "%s %s\\n" "$1" "$2" >"$TMPDIR/docker-timeout-seen"
shift 2
;;
*)
printf "%s\\n" "$1" >"$TMPDIR/docker-timeout-seen"
shift
;;
esac
"$@"
SH
chmod +x "$TMPDIR/bin/timeout"
export PATH="$TMPDIR/bin:$PATH"
node() {
local script="$1"
shift
if [[ "$script" != "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" ]]; then
command node "$script" "$@"
return
fi
local output_dir=""
local output_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--output-dir)
output_dir="$2"
shift 2
;;
--output-name)
output_name="$2"
shift 2
;;
*)
shift
;;
esac
done
mkdir -p "$output_dir"
printf fixture >"$output_dir/$output_name"
printf "%s\\n" "$output_dir/$output_name"
}
export -f node
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
docker() {
local mount_path=""
local expect_volume_path=0
local arg
for arg in "$@"; do
if [[ "$expect_volume_path" == "1" ]]; then
mount_path="\${arg%%:*}"
expect_volume_path=0
continue
fi
if [[ "$arg" == "-v" ]]; then
expect_volume_path=1
fi
done
test -n "$mount_path"
test -f "$mount_path"
printf "%s\\n" "$mount_path" >"$TMPDIR/package-mount-seen"
return "\${DOCKER_STUB_STATUS:-0}"
}
export -f docker
package_tgz="$(docker_e2e_prepare_package_tgz mount-cleanup)"
pack_dir="$(dirname "$package_tgz")"
docker_e2e_package_mount_args "$package_tgz"
DOCKER_STUB_STATUS=7 docker_e2e_run_with_harness image-name bash -lc true || run_status="$?"
test "\${run_status:-0}" = "7"
test "$(cat "$TMPDIR/docker-timeout-seen")" = "--kill-after=30s 3s"
test -f "$TMPDIR/package-mount-seen"
test ! -e "$pack_dir"
external_dir="$TMPDIR/external-package"
mkdir -p "$external_dir"
printf fixture >"$external_dir/openclaw-current.tgz"
docker_e2e_package_mount_args "$external_dir/openclaw-current.tgz"
unset DOCKER_COMMAND_TIMEOUT
rm -f "$TMPDIR/docker-timeout-seen"
docker_e2e_run_with_harness image-name bash -lc true
test "$(cat "$TMPDIR/docker-timeout-seen")" = "--kill-after=30s 3600s"
test -f "$external_dir/openclaw-current.tgz"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("propagates shared E2E command timeouts into package-backed containers", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-timeout-env-"));
try {
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
package="$TMPDIR/openclaw-current.tgz"
printf fixture >"$package"
export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s
export OPENCLAW_E2E_COMMAND_TIMEOUT=23s
docker_e2e_package_mount_args "$package"
printf "%s\\n" "\${DOCKER_E2E_PACKAGE_ARGS[@]}" >"$TMPDIR/package-args"
grep -qx -- "-e" "$TMPDIR/package-args"
grep -qx -- "OPENCLAW_CURRENT_PACKAGE_TGZ=/tmp/openclaw-current.tgz" "$TMPDIR/package-args"
grep -qx -- "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=42s" "$TMPDIR/package-args"
grep -qx -- "OPENCLAW_E2E_COMMAND_TIMEOUT=23s" "$TMPDIR/package-args"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("passes plugin lifecycle sampler timeout overrides into Docker", () => {
const runner = readFileSync(PLUGIN_LIFECYCLE_MATRIX_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain('if [ -n "${OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS:-}" ]; then');
expect(runner).toContain(
'DOCKER_ENV_ARGS+=(-e "OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS=$OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS")',
);
expect(runner).toContain(
'if [ -n "${OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS:-}" ]; then',
);
expect(runner).toContain(
'DOCKER_ENV_ARGS+=(-e "OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS=$OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS")',
);
expect(runner).toContain('docker_e2e_run_with_harness \\\n "${DOCKER_ENV_ARGS[@]}"');
});
it("wraps direct Docker E2E npm installs with the shared timeout helper", () => {
const multiNode = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
const updateChannel = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
const doctorSwitch = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
const releaseUpgrade = readFileSync(RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH, "utf8");
const upgradeSurvivor = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
const pluginCorrupt = readFileSync(PLUGIN_UPDATE_CORRUPT_SCENARIO_PATH, "utf8");
expect(multiNode).toContain(
'openclaw_e2e_install_package "$ARTIFACTS/install-a.log" "OpenClaw package under node-A prefix" "$NPM_PREFIX_A"',
);
expect(updateChannel).toContain(
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit',
);
expect(updateChannel).toContain(
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path"',
);
expect(doctorSwitch).toContain(
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install --omit=optional --no-fund --no-audit',
);
expect(doctorSwitch).toContain(
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g --prefix /tmp/npm-prefix --omit=optional "$package_tgz"',
);
for (const script of [releaseUpgrade, upgradeSurvivor, pluginCorrupt]) {
expect(script).toContain(
'openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g',
);
}
});
it("keeps upgrade survivor mutable state off the host-mounted artifact tree", () => {
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
for (const script of [runner, publishedRunner]) {
expect(script).toContain("openclaw-upgrade-survivor-runtime");
expect(script).toContain("OPENCLAW_UPGRADE_SURVIVOR_TMPDIR");
expect(script).toContain("OPENCLAW_UPGRADE_SURVIVOR_TEST_STATE_TMPDIR");
expect(script).toContain(
'export npm_config_cache="${OPENCLAW_UPGRADE_SURVIVOR_NPM_CACHE:-$OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT/npm-cache}"',
);
expect(script).toContain('export NPM_CONFIG_CACHE="$npm_config_cache"');
expect(script).toContain('chmod 700 "$npm_config_cache" || true');
expect(script).not.toContain('export TMPDIR="$ARTIFACT_ROOT/tmp"');
expect(script).not.toContain('export TMPDIR="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/tmp"');
expect(script).not.toContain('export npm_config_cache="$ARTIFACT_ROOT/npm-cache"');
expect(script).not.toContain(
'export npm_config_cache="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/npm-cache"',
);
}
});
it("wraps package-backed scenario OpenClaw CLI calls with the shared timeout helper", () => {
const paths = [
CODEX_ON_DEMAND_DOCKER_E2E_PATH,
CODEX_MEDIA_PATH_SCENARIO_PATH,
CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH,
LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH,
NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH,
RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH,
"scripts/e2e/lib/release-media-memory/scenario.sh",
"scripts/e2e/lib/release-plugin-marketplace/scenario.sh",
"scripts/e2e/lib/release-typed-onboarding/scenario.sh",
"scripts/e2e/lib/release-user-journey/scenario.sh",
];
for (const path of paths) {
const script = readFileSync(path, "utf8");
expect(script, path).toContain("openclaw_e2e_enable_openclaw_cli_timeout");
}
expect(readFileSync(RELEASE_UPGRADE_USER_JOURNEY_SCENARIO_PATH, "utf8")).toContain(
'openclaw_e2e_run_command node "$baseline_entry" onboard',
);
});
it("kills timed Docker scenario runners after the grace period", () => {
const multiNode = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
const upgradeSurvivor = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
expect(multiNode).toContain('timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash -lc');
expect(upgradeSurvivor).toContain(
'timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash scripts/e2e/lib/upgrade-survivor/run.sh',
);
expect(upgradeSurvivor).toContain('timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" bash -lc');
for (const script of [multiNode, upgradeSurvivor]) {
expect(script).not.toContain('timeout "$DOCKER_RUN_TIMEOUT"');
}
});
it("bounds upgrade survivor foreground OpenClaw CLI calls", () => {
const runner = readFileSync(UPGRADE_SURVIVOR_DOCKER_E2E_PATH, "utf8");
const publishedRunner = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
const updateRestartAuth = readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8");
expect(runner).toContain(
'COMMAND_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
);
expect(runner).toContain('-e OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT="$COMMAND_TIMEOUT"');
expect(runner).toContain(
'command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
);
expect(runner).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN',
);
expect(runner).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" openclaw doctor --fix --non-interactive',
);
expect(runner).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" openclaw config validate',
);
expect(runner).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" openclaw gateway status',
);
expect(runner).toContain(
'openclaw gateway --port "$PORT" --bind loopback --allow-unconfigured',
);
expect(publishedRunner).toContain(
'COMMAND_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" env -u OPENCLAW_GATEWAY_TOKEN',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw --version',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw config validate >"$BASELINE_CONFIG_VALIDATE_LOG"',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" "${update_env[@]}" openclaw',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" "${root_cli_env[@]}" openclaw',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw doctor --fix --non-interactive',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw config validate',
);
expect(publishedRunner).toContain(
'openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" openclaw gateway status',
);
expect(publishedRunner).toContain('openclaw gateway --port "$port" --bind loopback');
expect(updateRestartAuth).toContain(
'command_timeout="${OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT:-900s}"',
);
expect(updateRestartAuth).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" env -u OPENCLAW_GATEWAY_TOKEN',
);
expect(updateRestartAuth).toContain('openclaw gateway --port "$port" --bind loopback');
});
it("keeps the harness run wrapper available with pre-sourced Docker command helpers", () => {
const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-helper-guard-"));
try {
const rootDir = process.cwd();
const script = `
set -euo pipefail
ROOT_DIR=${shellQuote(rootDir)}
TMPDIR=${shellQuote(workDir)}
export ROOT_DIR TMPDIR
mkdir -p "$TMPDIR/bin"
cat >"$TMPDIR/bin/timeout" <<'SH'
#!/usr/bin/env bash
case "$1" in
--kill-after=1s)
exit 0
;;
--kill-after=30s)
shift 2
;;
*)
shift
;;
esac
"$@"
SH
chmod +x "$TMPDIR/bin/timeout"
export PATH="$TMPDIR/bin:$PATH"
docker_e2e_docker_cmd() {
printf "%s\\n" "$*" >"$TMPDIR/docker-cmd-seen"
}
docker() {
printf "%s\\n" "$*" >"$TMPDIR/docker-run-seen"
}
export -f docker
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
docker_e2e_run_with_harness image-name bash -lc true
test -f "$TMPDIR/docker-run-seen"
docker_e2e_run_detached_with_harness image-name
test -f "$TMPDIR/docker-cmd-seen"
`;
execFileSync("bash", ["-lc", script], { encoding: "utf8" });
} finally {
rmSync(workDir, { recursive: true, force: true });
}
});
it("cleans Codex npm plugin live package artifacts on every exit path", () => {
const runner = readFileSync(CODEX_NPM_PLUGIN_LIVE_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain('CODEX_PLUGIN_PACK_DIR=""');
expect(runner).toContain('run_log=""');
expect(runner).toMatch(
/cleanup\(\) \{[\s\S]*rm -rf "\$CODEX_PLUGIN_PACK_DIR"[\s\S]*docker_e2e_cleanup_package_tgz "\$PACKAGE_TGZ"[\s\S]*rm -f "\$run_log"/u,
);
expect(runner).toContain("trap cleanup EXIT");
expect(runner).not.toContain('rm -f "$run_log"\n exit 1');
});
it("cleans package-backed onboarding and plugin Docker artifacts on every exit path", () => {
for (const path of [
CODEX_ON_DEMAND_DOCKER_E2E_PATH,
LIVE_PLUGIN_TOOL_DOCKER_E2E_PATH,
NPM_ONBOARD_CHANNEL_AGENT_DOCKER_E2E_PATH,
]) {
const runner = readFileSync(path, "utf8");
expect(runner, path).toContain('run_log=""');
expect(runner, path).toMatch(
/cleanup\(\) \{[\s\S]*docker_e2e_cleanup_package_tgz "\$PACKAGE_TGZ"[\s\S]*rm -f "\$run_log"/u,
);
expect(runner, path).toContain("trap cleanup EXIT");
expect(runner, path).not.toContain('rm -f "$run_log"\n exit 1');
}
});
it("cleans every prepared Docker package tarball on every runner exit path", () => {
const paths = packageBackedDockerRunnerPaths();
expect(paths.length).toBeGreaterThan(0);
for (const path of paths) {
const runner = readFileSync(path, "utf8");
expect(runner, path).toMatch(
/docker_e2e_cleanup_package_tgz "\$\{PACKAGE_TGZ:-\}"|docker_e2e_cleanup_package_tgz "\$PACKAGE_TGZ"/u,
);
expect(runner, path).toMatch(/trap cleanup(?:_outer)? EXIT/u);
expect(runner, path).not.toContain('rm -f "$run_log"\n exit 1');
}
});
it("runs skill install through the package-cleaning Docker harness", () => {
const runner = readFileSync(SKILL_INSTALL_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain('docker_e2e_package_mount_args "$PACKAGE_TGZ"');
expect(runner).toMatch(
/run_logged_print \\\n\s+skill-install-run \\\n\s+docker_e2e_run_with_harness \\/u,
);
expect(runner).not.toContain("docker_e2e_harness_mount_args");
expect(runner).not.toContain("docker run --rm");
});
it("includes procps in the shared Docker E2E image for process watchdogs", () => {
const dockerfile = readFileSync("scripts/e2e/Dockerfile", "utf8");
expect(dockerfile).toContain("procps");
});
it("keeps onboarding Docker E2E resource-guarded", () => {
const runner = readFileSync(ONBOARD_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain("OPENCLAW_ONBOARD_MAX_MEMORY_MIB");
expect(runner).toContain("OPENCLAW_ONBOARD_MAX_CPU_PERCENT");
expect(runner).toContain(
'COMMAND_TIMEOUT="${OPENCLAW_ONBOARD_COMMAND_TIMEOUT:-${OPENCLAW_E2E_COMMAND_TIMEOUT:-300s}}"',
);
expect(runner).toContain('-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$COMMAND_TIMEOUT"');
expect(runner).toContain('--name "$CONTAINER_NAME"');
expect(runner).toContain("docker_e2e_docker_cmd stats --no-stream");
expect(runner).toContain("assert-resource-ceiling.mjs");
expect(runner).not.toContain("docker_e2e_run_with_harness -t");
});
it("cleans resource-sampled Docker E2E temp logs on every exit path", () => {
for (const { path, label } of [
{ path: ONBOARD_DOCKER_E2E_PATH, label: "onboard" },
{ path: KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH, label: "kitchen-sink" },
{ path: KITCHEN_SINK_RPC_DOCKER_E2E_PATH, label: "kitchen-sink-rpc" },
]) {
const runner = readFileSync(path, "utf8");
const resourceAssertion = `node scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs "$STATS_LOG" "$MAX_MEMORY_MIB" "$MAX_CPU_PERCENT" ${label}`;
expect(runner, path).toContain('RUN_LOG="$(mktemp');
expect(runner, path).toContain('STATS_LOG="$(mktemp');
expect(runner, path).toContain(
'DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME"',
);
expect(runner, path).toContain('DOCKER_RUN_TIMEOUT="${OPENCLAW_');
expect(runner, path).toContain('docker_e2e_docker_cmd inspect "$CONTAINER_NAME"');
expect(runner, path).toContain("docker_e2e_docker_cmd stats --no-stream");
expect(runner, path).not.toMatch(/(^|\n)docker run --name "\$CONTAINER_NAME"/u);
expect(runner, path).not.toMatch(/(^|\n)docker (?:inspect|stats) /u);
expect(runner, path).toMatch(/cleanup\(\) \{[\s\S]*rm -f "\$RUN_LOG" "\$STATS_LOG"/u);
expect(runner, path).toContain(`if [ "$run_status" -eq 0 ]; then\n ${resourceAssertion}`);
expect(runner, path).toContain(
`elif [ -s "$STATS_LOG" ]; then\n ${resourceAssertion} || true`,
);
expect(runner, path).not.toContain(`${resourceAssertion}\n\nexit "$run_status"`);
}
});
it("bounds kitchen-sink plugin CLI commands inside the Docker sweep", () => {
const runner = readFileSync(KITCHEN_SINK_PLUGIN_DOCKER_E2E_PATH, "utf8");
const sweep = readFileSync("scripts/e2e/lib/kitchen-sink-plugin/sweep.sh", "utf8");
expect(runner).toContain(
'KITCHEN_SINK_CLI_TIMEOUT="${OPENCLAW_KITCHEN_SINK_PLUGIN_CLI_TIMEOUT:-${KITCHEN_SINK_CLI_TIMEOUT:-180s}}"',
);
expect(runner).toContain('-e "KITCHEN_SINK_CLI_TIMEOUT=$KITCHEN_SINK_CLI_TIMEOUT"');
expect(sweep).toContain('KITCHEN_SINK_CLI_TIMEOUT="${KITCHEN_SINK_CLI_TIMEOUT:-180s}"');
expect(sweep).toContain("run_kitchen_sink_openclaw_logged()");
expect(sweep).toContain("run_kitchen_sink_openclaw_capture()");
expect(sweep).toContain(
'run_logged_print "$label" openclaw_e2e_maybe_timeout "$KITCHEN_SINK_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@"',
);
for (const line of sweep.split("\n")) {
if (!line.includes('node "$OPENCLAW_ENTRY" plugins')) {
continue;
}
expect(line).toContain("openclaw_e2e_maybe_timeout");
}
});
it("routes named Docker E2E container cleanup through the timeout-aware helper", () => {
for (const path of readdirSync("scripts/e2e")
.filter((entry) => entry.endsWith("-docker.sh"))
.map((entry) => join("scripts/e2e", entry))) {
const runner = readFileSync(path, "utf8");
if (!runner.includes('CONTAINER_NAME="')) {
continue;
}
expect(runner, path).not.toMatch(/(^|\n)\s*docker rm -f "\$CONTAINER_NAME"/u);
expect(runner, path).toContain('docker_e2e_docker_cmd rm -f "$CONTAINER_NAME"');
}
});
it("routes the gateway network client through the timeout-aware run helper", () => {
const runner = readFileSync(GATEWAY_NETWORK_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain(
'DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docker_e2e_docker_run_cmd run --rm',
);
expect(runner).not.toContain(
'run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm',
);
});
it("copies root lifecycle scripts before cleanup-smoke installs dependencies", () => {
const dockerfile = readFileSync(CLEANUP_SMOKE_DOCKERFILE_PATH, "utf8");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
for (const script of [
"scripts/preinstall-package-manager-warning.mjs",
"scripts/postinstall-bundled-plugins.mjs",
"scripts/prepare-git-hooks.mjs",
]) {
const copyIndex = dockerfile.indexOf(script);
expect(copyIndex, script).toBeGreaterThanOrEqual(0);
expect(copyIndex, script).toBeLessThan(installIndex);
}
});
it("mounts root helper modules imported by bare Docker E2E scripts", () => {
const helper = readFileSync(DOCKER_E2E_PACKAGE_HELPER_PATH, "utf8");
expect(helper).toContain(
'-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"',
);
});
it("preserves pnpm lookup paths for scheduled Docker child lanes", () => {
const scheduler = readFileSync(DOCKER_ALL_SCHEDULER_PATH, "utf8");
expect(scheduler).toContain("env.PNPM_HOME");
expect(scheduler).toContain("env.npm_execpath ? path.dirname(env.npm_execpath)");
expect(scheduler).toContain("path.dirname(process.execPath)");
expect(scheduler).toContain("env.PATH = [...new Set(pathEntries)].join(path.delimiter)");
expect(scheduler).toContain("withResolvedPnpmCommand");
expect(scheduler).toContain("OPENCLAW_DOCKER_ALL_PNPM_COMMAND");
});
it("runs release installer E2E against the npm beta tag", () => {
const scenarios = readFileSync(DOCKER_E2E_SCENARIOS_PATH, "utf8");
const openWebUiRunner = readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8");
expect(scenarios).toContain(
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS=120 OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=120 pnpm test:install:e2e"',
);
expect(scenarios).toContain(
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=anthropic OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-anthropic:local pnpm test:install:e2e"',
);
expect(scenarios).toContain(
'"OPENCLAW_OPENWEBUI_MODEL=openai/gpt-5.4-mini OPENWEBUI_SMOKE_MODE=models OPENCLAW_OPENWEBUI_PROVIDER_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openwebui"',
);
expect(openWebUiRunner).toContain(
'SMOKE_MODE="${OPENWEBUI_SMOKE_MODE:-${OPENCLAW_OPENWEBUI_SMOKE_MODE:-chat}}"',
);
expect(openWebUiRunner).toContain('-e "OPENWEBUI_SMOKE_MODE=$SMOKE_MODE"');
});
it("times and parallelizes release installer E2E agent turns after gateway startup", () => {
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
const wrapper = readFileSync("scripts/test-install-sh-e2e-docker.sh", "utf8");
expect(runner).toContain(
'AGENT_TURNS_PARALLEL="${OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL:-1}"',
);
expect(runner).toContain("time_phase");
expect(runner).toContain("phase_mark_start");
expect(runner).toContain("run_agent_turn_bg");
expect(runner).toContain("wait_agent_turn_batch");
expect(runner).toContain("agent_turn_outputs_include_billing_drift");
expect(runner).toContain("SKIP: Anthropic billing drift during installer agent tool smoke");
expect(runner).not.toContain('run_agent_turn_bg "read proof"');
expect(runner).toContain('run_agent_turn_bg "image write"');
expect(runner).toContain('run_agent_turn_logged_or_skip_profile "read proof copy"');
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_OPENAI_MODEL");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS:-300");
expect(runner).toContain("OPENCLAW_INSTALL_E2E_OPENAI_MODEL");
expect(runner).toContain("OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS");
expect(runner).toContain(
'AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS:-300}"',
);
});
it("keeps package acceptance plugin coverage offline-capable", () => {
const scenarios = readFileSync(DOCKER_E2E_SCENARIOS_PATH, "utf8");
expect(scenarios).toContain('"plugins-offline"');
expect(scenarios).toContain("`bundled-plugin-install-uninstall-${index}`");
expect(scenarios).toContain("pnpm test:docker:bundled-plugin-install-uninstall");
expect(scenarios).toContain("OPENCLAW_PLUGINS_E2E_CLAWHUB=0");
});
it("allows plugin update smoke to tolerate config metadata migrations", () => {
const runner = readFileSync(PLUGIN_UPDATE_DOCKER_E2E_PATH, "utf8");
const scenario = readFileSync(PLUGIN_UPDATE_SCENARIO_PATH, "utf8");
const probe = readFileSync(PLUGIN_UPDATE_PROBE_PATH, "utf8");
expect(runner).toContain("scripts/e2e/lib/plugin-update/unchanged-scenario.sh");
expect(probe).toContain("plugin install record changed unexpectedly");
expect(probe).toContain("index.installRecords ?? index.records ?? config.plugins?.installs");
expect(scenario).toContain("Config changed unexpectedly for modern package");
expect(scenario).not.toContain("before_hash");
});
it("fails the multi-node update probe on update or restart regressions", () => {
const runner = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain("UPDATE_FAILED=0");
expect(runner).toContain("GATEWAY_START_FAILED=0");
expect(runner).toContain("GATEWAY_HEALTH_FAILED=0");
expect(runner).toContain('if [ "$UPDATE_FAILED" -ne 0 ]; then');
expect(runner).toContain('if [ "$GATEWAY_START_FAILED" -ne 0 ]; then');
expect(runner).toContain('if [ "$GATEWAY_HEALTH_FAILED" -ne 0 ]; then');
expect(runner).toContain("ActiveState=active");
expect(runner).toContain("OPENCLAW_NO_RESPAWN=1");
expect(runner).toContain("is-enabled)");
expect(runner).toContain("/healthz");
expect(runner).toContain("FAIL: gateway install failed before update");
expect(runner).not.toContain('gateway-install.err" || true');
expect(runner).not.toContain("WARNING: Gateway status probe failed");
});
it("caps package acceptance legacy compatibility at 2026.4.25", () => {
const doctorScenario = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
const updateChannel = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
const pluginsSweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8");
const pluginsMarketplace = readFileSync(PLUGINS_DOCKER_MARKETPLACE_PATH, "utf8");
const pluginsClawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8");
const pluginsAssertions = readFileSync(PLUGINS_DOCKER_ASSERTIONS_PATH, "utf8");
const pluginUpdateScenario = readFileSync(PLUGIN_UPDATE_SCENARIO_PATH, "utf8");
const pluginUpdateProbe = readFileSync(PLUGIN_UPDATE_PROBE_PATH, "utf8");
const updateChannelAssertions = readFileSync(UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH, "utf8");
const packageCompat = readFileSync(PACKAGE_COMPAT_PATH, "utf8");
const scripts = [
doctorScenario,
updateChannel,
updateChannelAssertions,
pluginsSweep,
pluginsMarketplace,
pluginsClawhub,
pluginsAssertions,
pluginUpdateScenario,
pluginUpdateProbe,
];
expect(readFileSync(DOCTOR_SWITCH_DOCKER_E2E_PATH, "utf8")).toContain(
"scripts/e2e/lib/doctor-install-switch/scenario.sh",
);
expect(readFileSync(PLUGINS_DOCKER_E2E_PATH, "utf8")).toContain(
"scripts/e2e/lib/plugins/sweep.sh",
);
expect(readFileSync(PLUGIN_UPDATE_DOCKER_E2E_PATH, "utf8")).toContain(
"scripts/e2e/lib/plugin-update/unchanged-scenario.sh",
);
expect(packageCompat).toContain("day <= 25");
expect(doctorScenario).toContain("scripts/e2e/lib/package-compat.mjs");
expect(pluginsSweep).toContain("scripts/e2e/lib/package-compat.mjs");
expect(pluginUpdateProbe).toContain("../package-compat.mjs");
expect(scripts.join("\n")).toContain("OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT");
expect(scripts.join("\n")).toContain(
"Package $package_version must support gateway install --wrapper.",
);
expect(updateChannel).toContain("assert-config-channel dev");
expect(updateChannelAssertions).toContain("expected persisted update.channel ${channel}");
expect(pluginsAssertions).toContain("expected modern installRecords in installed plugin index");
});
it("routes doctor install switch commands through the E2E timeout helper", () => {
const scenario = readFileSync(DOCTOR_SWITCH_SCENARIO_PATH, "utf8");
expect(scenario).toContain(
'command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}"',
);
expect(scenario).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$install_cmd"',
);
expect(scenario).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" bash -c "$doctor_cmd"',
);
expect(scenario).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force',
);
expect(scenario).toContain(
'openclaw_e2e_maybe_timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes',
);
expect(scenario).not.toMatch(/^\s*if ! timeout "\$command_timeout"/mu);
});
it("prepares pnpm workspace package fixtures without package dependencies", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-update-channel-fixture-"));
try {
mkdirSync(join(root, "patches"));
writeFileSync(
join(root, "package.json"),
`${JSON.stringify({ name: "openclaw", version: "2026.5.6", scripts: {} }, null, 2)}\n`,
"utf8",
);
writeFileSync(
join(root, "pnpm-workspace.yaml"),
[
"packages:",
" - .",
"",
"patchedDependencies:",
' "kept@1.0.0": "patches/kept.patch"',
"allowBuilds:",
" esbuild: true",
"",
].join("\n"),
"utf8",
);
writeFileSync(join(root, "patches", "kept.patch"), "", "utf8");
execFileSync(process.execPath, [
UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH,
"prepare-git-fixture",
root,
]);
const workspace = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8");
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
pnpm?: unknown;
};
expect(workspace).toContain(' "kept@1.0.0": "patches/kept.patch"');
expect(workspace).toContain("allowUnusedPatches: true");
expect(workspace).toContain("minimumReleaseAge: 0");
expect(workspace).toContain("allowBuilds:");
expect(manifest.pnpm).toBeUndefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("keeps bundled plugin install/uninstall sweep chunkable", () => {
const runner = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH, "utf8");
const sweep = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH, "utf8");
const probe = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_PROBE_PATH, "utf8");
const runtimeSmoke = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_RUNTIME_SMOKE_PATH, "utf8");
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL");
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX");
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS");
expect(runner).toContain("scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh");
expect(runner).toContain('tee "$RUN_LOG"');
expect(runner).not.toContain('cat "$RUN_LOG"');
expect(probe).toContain('"openclaw.plugin.json"');
expect(runtimeSmoke).toContain("process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS");
expect(runtimeSmoke).toContain("900000");
expect(sweep).toContain("read -r plugin_id plugin_dir requires_config");
expect(sweep).toContain('node "$OPENCLAW_ENTRY" plugins install "$plugin_id"');
expect(sweep).toContain('node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force');
expect(sweep).toContain("assert-installed");
expect(sweep).toContain("assert-uninstalled");
});
it("passes installer tag env to bash, not curl", () => {
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
expect(runner).toContain('curl -fsSL "$INSTALL_URL" | OPENCLAW_BETA=1 bash');
expect(runner).toContain('curl -fsSL "$INSTALL_URL" | OPENCLAW_VERSION="$INSTALL_TAG" bash');
expect(runner).not.toContain('OPENCLAW_BETA=1 curl -fsSL "$INSTALL_URL" | bash');
expect(runner).not.toContain(
'OPENCLAW_VERSION="$INSTALL_TAG" curl -fsSL "$INSTALL_URL" | bash',
);
});
it("keeps installer E2E agent turns out of the interactive bootstrap ritual", () => {
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
expect(runner).toContain('rm -f "$workspace/BOOTSTRAP.md"');
expect(runner.indexOf('rm -f "$workspace/BOOTSTRAP.md"')).toBeLessThan(
runner.indexOf('phase_mark_start "Agent turns ($profile)"'),
);
});
it("keeps installer E2E tool smokes in isolated sessions", () => {
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
expect(runner).toContain('SESSION_ID_PREFIX="e2e-tools-${profile}"');
expect(runner).toContain('TURN2B_SESSION_ID="${SESSION_ID_PREFIX}-read-copy"');
expect(runner).toContain('TURN3_SESSION_ID="${SESSION_ID_PREFIX}-exec-hostname"');
expect(runner).toContain('TURN4_SESSION_ID="${SESSION_ID_PREFIX}-image-write"');
});
it("keeps OpenAI web search smoke on one gateway agent connection", () => {
const runner = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH, "utf8");
const scenario = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH, "utf8");
const client = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_CLIENT_PATH, "utf8");
expect(runner).toContain("scripts/e2e/lib/openai-web-search-minimal/scenario.sh");
expect(scenario).toContain("scripts/e2e/lib/openai-web-search-minimal/client.mjs");
expect(client).toContain("const callGateway = await loadCallGateway();");
expect(client).toContain('method: "agent"');
expect(client).toContain("expectFinal: true");
expect(client).toContain('scopes: ["operator.write"]');
expect(client).not.toContain('"agent.wait"');
});
it("keeps ClawHub plugin Docker smoke hermetic by default", () => {
const runner = readFileSync(PLUGINS_DOCKER_E2E_PATH, "utf8");
const sweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8");
const clawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8");
expect(runner).toContain("scripts/e2e/lib/plugins/sweep.sh");
expect(runner).toContain("OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB");
expect(sweep).toContain("scripts/e2e/lib/plugins/clawhub.sh");
expect(clawhub).toContain("start_clawhub_fixture_server()");
expect(clawhub).toContain('OPENCLAW_CLAWHUB_URL="http://127.0.0.1:');
expect(clawhub).toContain("OPENCLAW_PLUGINS_E2E_LIVE_CLAWHUB");
expect(clawhub).toContain("OPENCLAW_PLUGINS_E2E_LIVE_NPM_REGISTRY");
expect(clawhub).toContain("live ClawHub can rate-limit CI");
expect(clawhub).toContain('[[ -n "${OPENCLAW_CLAWHUB_URL:-}" || -n "${CLAWHUB_URL:-}" ]]');
expect(clawhub).toContain("Ignoring ambient ClawHub URL for fixture-mode plugin E2E");
expect(clawhub).toContain("unset OPENCLAW_CLAWHUB_URL CLAWHUB_URL");
});
it("keeps the plugin binding command escape Docker smoke focused", () => {
const runner = readFileSync(PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_E2E_PATH, "utf8");
const dockerfile = readFileSync(PLUGIN_BINDING_COMMAND_ESCAPE_DOCKERFILE_PATH, "utf8");
expect(runner).toContain("--reporter=verbose -t");
expect(runner).not.toContain("-- --reporter=verbose");
expect(runner).toContain(
'DOCKER_RUN_TIMEOUT="${OPENCLAW_PLUGIN_BINDING_COMMAND_ESCAPE_DOCKER_RUN_TIMEOUT:-900s}"',
);
expect(runner).toContain(
'DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --rm',
);
expect(runner).toContain('docker_e2e_docker_cmd rm -f "$CONTAINER_NAME"');
expect(runner).not.toMatch(/(^|\n)docker run --rm/u);
expect(runner).toContain("expected focused Vitest summary for exactly 3 passed tests");
expect(dockerfile).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL=1");
expect(dockerfile).toContain(
"pnpm install --frozen-lockfile --ignore-scripts --filter openclaw",
);
});
it("routes QR import Docker smoke through the timeout-aware run helper", () => {
const runner = readFileSync(QR_IMPORT_DOCKER_E2E_PATH, "utf8");
expect(runner).toContain("scripts/lib/docker-e2e-container.sh");
expect(runner).toContain("run_logged qr-import-run docker_e2e_docker_run_cmd run --rm -t");
expect(runner).not.toContain("run_logged qr-import-run docker run --rm");
});
it("covers plugin CLI sources in the Docker plugin sweep", () => {
const sweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8");
const marketplace = readFileSync(PLUGINS_DOCKER_MARKETPLACE_PATH, "utf8");
const clawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8");
const assertions = readFileSync(PLUGINS_DOCKER_ASSERTIONS_PATH, "utf8");
const npmRegistry = readFileSync(PLUGINS_DOCKER_NPM_REGISTRY_PATH, "utf8");
expect(sweep).toContain('OPENCLAW_PLUGINS_CLI_TIMEOUT="${OPENCLAW_PLUGINS_CLI_TIMEOUT:-180s}"');
expect(sweep).toContain(
'run_logged "$label" openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@"',
);
expect(sweep).toContain("run_plugins_openclaw_capture()");
expect(sweep).toContain(
'openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT" node "$OPENCLAW_ENTRY" "$@" >"$output_file"',
);
expect(sweep).not.toContain('run_logged install-npm node "$OPENCLAW_ENTRY"');
for (const [path, script] of [
[PLUGINS_DOCKER_SWEEP_PATH, sweep],
[PLUGINS_DOCKER_MARKETPLACE_PATH, marketplace],
[PLUGINS_DOCKER_CLAWHUB_PATH, clawhub],
] as const) {
const unboundedPluginCliLines = script
.split("\n")
.filter((line) => line.includes('node "$OPENCLAW_ENTRY" plugins'))
.filter((line) => !line.includes("openclaw_e2e_maybe_timeout"));
expect(unboundedPluginCliLines, path).toEqual([]);
}
expect(sweep).toContain('plugins install "$dir_plugin"');
expect(sweep).toContain("plugins update demo-plugin-dir");
expect(assertions).toContain('Skipping "demo-plugin-dir" (source: path).');
expect(sweep).toContain("start_npm_fixture_registry");
expect(sweep).toContain('plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"');
expect(sweep).toContain("plugins update demo-plugin-npm");
expect(assertions).toContain("demo-plugin-npm is up to date (0.0.1).");
expect(npmRegistry).toContain('"dist-tags": { latest: entry.latestVersion }');
expect(npmRegistry).toContain("existing.latestVersion = version");
expect(npmRegistry).toContain("packageArgs.length % 3");
expect(sweep).toContain('plugins install "git:$git_update_repo_url@main"');
expect(sweep).toContain("plugins update demo-plugin-git-update");
expect(assertions).toContain("demo.git.update.v2");
expect(clawhub).toContain('plugins install "$CLAWHUB_PLUGIN_SPEC"');
expect(clawhub).toContain('plugins update "$CLAWHUB_PLUGIN_ID"');
expect(clawhub).toContain("run_plugins_openclaw_logged install-clawhub");
expect(clawhub).toContain('openclaw_e2e_maybe_timeout "$OPENCLAW_PLUGINS_CLI_TIMEOUT"');
expect(clawhub).toContain("clawhub:@openclaw/kitchen-sink");
expect(assertions).toContain("clawhub-updated");
expect(assertions).toContain("record.clawpackSha256");
expect(assertions).toContain("record.artifactKind");
expect(assertions).toContain("record.npmIntegrity");
});
});