Compare commits

..

33 Commits

Author SHA1 Message Date
Alex Knight
fd09d2e7d0 fix(compaction): anchor forced manual boundary off trailing tool results 2026-06-17 21:21:25 +10:00
Alex Knight
3aecc4ee9d Force manual compaction past empty kept-tail cuts 2026-06-17 21:21:25 +10:00
Ayaan Zaidi
02330f372c fix(qa): use writable tmp in Telegram package runner
Set TMPDIR=/tmp inside the package Telegram Docker runner so runtime scratch files are written to a writable container path.

Proof:
- pnpm test test/scripts/npm-telegram-live.test.ts
- git diff --check
2026-06-17 16:45:34 +05:30
Vincent Koc
5645dd4d22 refactor(agents): delete unused helper paths 2026-06-17 19:11:20 +08:00
Alex Knight
5a7857dc18 feat(agents): trace compaction summarization model calls
Compaction summarization consumes the model stream via result() only (no
iteration), so it never emitted model.call diagnostic spans. Observe the
stream's result() in the diagnostic wrapper and wire the wrapper into the
direct compaction path so these LLM calls are traced (request/response
content, byte accounting, traceparent).

Decouple underlying-iterator cleanup from terminal-event dedup. The agent
loop awaits result() on the terminal event then abandons the iterator, so
once result() also emits the terminal event, gating safeReturnIterator on
terminalEventEmitted skipped provider cleanup (idle-timeout abort listeners
on the long-lived run signal, SSE readers). Track iterator settlement
separately so return() cleanup always runs; emit dedup stays on
terminalEventEmitted.

Parent compaction model-call spans to the active run/harness trace rather
than a phantom child trace that emits no span of its own.
2026-06-17 21:06:44 +10:00
Vincent Koc
25bd8a7191 fix(ci): install docker heartbeat traps before launch 2026-06-17 19:04:31 +08:00
nas
df87b40bec fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers (#93938)
Merged via squash.

Prepared head SHA: 583b22354d
Co-authored-by: Nas01010101 <156536069+Nas01010101@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-17 18:56:25 +08:00
joshavant
5d9c010628 ci: add security-sensitive file guard 2026-06-17 12:50:18 +02:00
Vincent Koc
03ca096e84 test(qa): cover otel smoke safety checks 2026-06-17 12:42:28 +02:00
joshavant
22ddf87d2c docs: explain Android signing sync 2026-06-17 12:37:29 +02:00
joshavant
2147312aa2 android: add release signing sync 2026-06-17 12:37:29 +02:00
Vincent Koc
9698070939 fix(qa): allow safe otel log bodies 2026-06-17 12:33:45 +02:00
Vincent Koc
1c0b38f960 fix(sdk): refresh plugin surface baselines 2026-06-17 12:25:42 +02:00
Vincent Koc
0842cb71eb refactor(runtime): hide default constants 2026-06-17 18:20:05 +08:00
Vincent Koc
392bd16a1d refactor(config): hide io constants 2026-06-17 18:14:08 +08:00
Vincent Koc
f3050ab614 refactor(config): hide default constants 2026-06-17 18:11:28 +08:00
Vincent Koc
6e798c02d8 fix(codex): refresh app server protocol mirrors 2026-06-17 12:10:52 +02:00
Vincent Koc
911cd683d5 refactor(commands): hide onboarding defaults 2026-06-17 18:08:16 +08:00
Vincent Koc
4637b65470 refactor(agents): hide compaction warning helpers 2026-06-17 18:05:16 +08:00
Vincent Koc
e2b6753b87 fix(qa-lab): bound credential payload reads 2026-06-17 11:59:55 +02:00
Vincent Koc
366ef93641 test(agents): inline auth profile ordering fixtures 2026-06-17 17:56:13 +08:00
Vincent Koc
dc881a6a31 refactor(acp): hide policy helpers 2026-06-17 17:53:31 +08:00
Vincent Koc
ea72a3382d refactor(acp): remove file event ledger runtime 2026-06-17 17:50:53 +08:00
Vincent Koc
19677bd4ef refactor(acp): hide permission relay helpers 2026-06-17 17:47:13 +08:00
Vincent Koc
9c9c884526 refactor(entry): hide respawn internals 2026-06-17 17:44:12 +08:00
Vincent Koc
120fd2f702 refactor(cli): hide shell support internals 2026-06-17 17:41:44 +08:00
Vincent Koc
582c2d41b9 fix(msteams): unwrap adaptive card submit data 2026-06-17 11:40:52 +02:00
Vincent Koc
30955d3660 refactor(channels): narrow status helper exports 2026-06-17 17:33:40 +08:00
Vincent Koc
5370e73ee9 refactor(channels): hide internal channel types 2026-06-17 17:31:04 +08:00
Vincent Koc
cf7850040e fix(codex): align network proxy profile config 2026-06-17 17:27:34 +08:00
Vincent Koc
1380a9e094 refactor(auto-reply): hide local reply types 2026-06-17 17:23:32 +08:00
Vincent Koc
5055f32ee3 refactor(auto-reply): hide internal command types 2026-06-17 17:18:39 +08:00
Vincent Koc
1075f3819c refactor(utils): narrow helper exports 2026-06-17 17:13:29 +08:00
157 changed files with 3454 additions and 1034 deletions

5
.github/CODEOWNERS vendored
View File

@@ -12,9 +12,14 @@
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
/.github/workflows/security-sensitive-guard.yml @openclaw/openclaw-secops
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/security-sensitive-guard-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
/scripts/github/security-sensitive-guard.mjs @openclaw/openclaw-secops
/.gitignore @openclaw/openclaw-secops
/package-lock.json @openclaw/openclaw-secops
/npm-shrinkwrap.json @openclaw/openclaw-secops
/extensions/*/package-lock.json @openclaw/openclaw-secops

View File

@@ -0,0 +1,55 @@
name: Security Sensitive Guard
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: security-sensitive-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
security-sensitive-guard-detect:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Detect security-sensitive changes
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: detect
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs
security-sensitive-guard:
if: ${{ !github.event.pull_request.draft && always() }}
needs:
- security-sensitive-guard-detect
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Enforce security-sensitive guard
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE: enforce
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/security-sensitive-guard.mjs

1
.gitignore vendored
View File

@@ -77,6 +77,7 @@ extensions/canvas/src/host/a2ui/*.map
# fastlane (iOS)
apps/ios/fastlane/README.md
apps/android/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/

View File

@@ -0,0 +1,14 @@
{
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"assetPath": "android/openclaw",
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
"materializedRoot": "apps/android/build/release-signing",
"gradlePropertyNames": [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
]
}

View File

@@ -53,6 +53,16 @@ pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
```
Release-owner signing sync:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
Generate raw Google Play screenshots:
```bash
@@ -64,7 +74,7 @@ pnpm android:screenshots
- Play build: `openclaw-<version>-play-release.aab`
- Third-party build: `openclaw-<version>-third-party-release.apk`
`pnpm android:bundle:release` is an alias for the same archive helper.
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.

View File

@@ -25,6 +25,8 @@ pnpm android:version:check
pnpm android:version:sync
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
pnpm android:release:preflight
```
@@ -45,10 +47,19 @@ Recommended workflow:
1. Pin Android to the intended release version.
2. Run `pnpm android:version:sync`.
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
4. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
5. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
6. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
7. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
8. Promote to production manually in Google Play Console.
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
9. Promote to production manually in Google Play Console.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.

View File

@@ -9,6 +9,12 @@ default_platform(:android)
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
DEFAULT_PLAY_TRACK = "internal"
DEFAULT_PLAY_RELEASE_STATUS = "completed"
ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
].freeze
def load_env_file(path)
return unless File.exist?(path)
@@ -36,6 +42,14 @@ def repo_root
File.expand_path("../..", android_root)
end
def android_release_signing_script
File.join(repo_root, "scripts", "android-release-signing.mjs")
end
def android_release_signing_materialized_properties_path
File.join(android_root, "build", "release-signing", "gradle.properties")
end
def shell_join(args)
args.shelljoin
end
@@ -183,6 +197,45 @@ def capture_android_screenshots!
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
end
def read_android_release_signing_properties!(path)
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
properties = {}
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
properties[key] = value.strip
end
missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) }
UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty?
properties
end
def export_android_release_signing_properties!(path)
read_android_release_signing_properties!(path).each do |key, value|
ENV["ORG_GRADLE_PROJECT_#{key}"] = value
end
end
def sync_android_release_signing!
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"]))
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
def prepare_android_release_signing!
if env_present?(ENV["MATCH_PASSWORD"])
sync_android_release_signing!
elsif File.exist?(android_release_signing_materialized_properties_path)
export_android_release_signing_properties!(android_release_signing_materialized_properties_path)
end
end
def validate_android_release_signing!
Dir.chdir(android_root) do
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
@@ -201,6 +254,7 @@ end
def validate_android_release_preflight!(version_metadata)
validate_play_auth!
prepare_android_release_signing!
validate_android_release_signing!
validate_android_release_notes!
print_android_release_plan!(version_metadata)
@@ -258,6 +312,30 @@ platform :android do
UI.success("Google Play API credentials are valid.")
end
desc "Print the Android release signing plan"
lane :signing_plan do
sh(shell_join(["node", android_release_signing_script, "--mode", "plan"]))
end
desc "Pull encrypted Android release signing assets and validate Gradle release signing"
lane :signing_check do
sync_android_release_signing!
validate_android_release_signing!
UI.success("Android release signing assets are available locally.")
end
desc "Pull encrypted Android release signing assets from the shared signing repo"
lane :signing_sync_pull do
sync_android_release_signing!
UI.success("Pulled Android release signing assets.")
end
desc "Create or refresh encrypted Android release signing assets in the shared signing repo"
lane :signing_sync_push do
sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"]))
UI.success("Pushed Android release signing assets.")
end
desc "Validate Android Play release auth, signing, versioning, and release notes"
lane :release_preflight do
sync_android_versioning!
@@ -278,6 +356,7 @@ platform :android do
desc "Build signed Android release artifacts locally without uploading"
lane :play_store_archive do
sync_android_versioning!
prepare_android_release_signing!
build_release_artifacts!
end

View File

@@ -20,6 +20,35 @@ Optional app targeting:
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
```
Android release signing uses the same private `apps-signing` repository and `MATCH_PASSWORD` secret as iOS, but with Android-specific encrypted assets. Pull the shared upload key before release validation:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The pull command materializes decrypted signing files under `apps/android/build/release-signing/`, which is gitignored. Later Fastlane release commands reload those materialized values and export them to Gradle for the current process.
For the first setup or rotation, provide the Play upload keystore and a local signing properties file, then push encrypted assets to `apps-signing`:
```bash
MATCH_PASSWORD=<signing repo password> \
OPENCLAW_ANDROID_UPLOAD_KEYSTORE=<path-to-upload-keystore.jks> \
OPENCLAW_ANDROID_SIGNING_PROPERTIES=<path-to-android-signing.properties> \
pnpm android:release:signing:sync:push
```
The source signing properties file must contain:
```properties
OPENCLAW_ANDROID_STORE_PASSWORD=<store-password>
OPENCLAW_ANDROID_KEY_ALIAS=<upload-key-alias>
OPENCLAW_ANDROID_KEY_PASSWORD=<key-password>
```
Store the Google Play upload key, not the irreplaceable app signing key, when Play App Signing is enabled.
Validate auth:
```bash
@@ -58,6 +87,8 @@ Release rules:
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from that changelog by `pnpm android:version:sync`.
- `apps/android/Config/ReleaseSigning.json` pins the encrypted Android signing assets in the shared signing repo.
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
@@ -65,6 +96,8 @@ Release rules:
- `pnpm android:version:sync` updates generated version artifacts.
- `pnpm android:version:check` validates checked-in Android version artifacts.
- `pnpm android:release:preflight` validates Google Play auth, Android release signing, synced versioning, release notes, and prints the package/track/version/versionCode that will be uploaded.
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.

View File

@@ -1,2 +1,2 @@
e1928b7528c130ebac4f8f5cf0d1de545c996898182b50cbdf4efdc89a8637cd plugin-sdk-api-baseline.json
d9c227be6d344676e36d6ccc37c3c8cf05f80dcc82eadc4a686b3dccd1667990 plugin-sdk-api-baseline.jsonl
b810f3b17d1eb746a6fbc4c45095a3b2bb3e08c5cd62a5928f9add2c59bb95b9 plugin-sdk-api-baseline.json
36174a54f2a9e11b822f499b5659d0b1351198ce98112946d95283b0ee1032dd plugin-sdk-api-baseline.jsonl

View File

@@ -587,7 +587,7 @@ export default {
},
unixSockets: {
"/tmp/proxy.sock": "allow",
"/tmp/blocked.sock": "deny",
"/tmp/blocked.sock": "none",
},
allowUpstreamProxy: true,
proxyUrl: "http://127.0.0.1:3128",
@@ -605,7 +605,7 @@ If the normal app-server runtime would be `danger-full-access`, enabling
permission profile. Codex managed network enforcement is sandboxed networking,
so a full-access profile would not protect outbound traffic.
Domain entries use `allow` or `deny`; Unix socket entries use Codex's
`allow` or `deny` values.
`allow` or `none` values.
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second

View File

@@ -675,9 +675,10 @@ is disabled, uninstalled, or rolled back:
clearCodeModeNamespacesForPlugin(pluginId);
```
Use `unregisterCodeModeNamespace(namespaceId)` only when removing one known
namespace. Tests can call `clearCodeModeNamespacesForTest()` to avoid leaking
registrations across cases.
Code-mode cleanup is plugin-owned; clear the plugin's namespace registrations
when its lifecycle ends instead of keeping per-namespace teardown handles. Tests
can call `clearCodeModeNamespacesForTest()` to avoid leaking registrations
across cases.
### Test checklist

View File

@@ -221,7 +221,7 @@
"type": "object",
"additionalProperties": {
"type": "string",
"enum": ["allow", "deny"]
"enum": ["allow", "none"]
}
},
"proxyUrl": { "type": "string" },
@@ -453,7 +453,7 @@
},
"appServer.networkProxy.unixSockets": {
"label": "Unix Sockets",
"help": "Unix socket allow and deny rules for Codex sandboxed networking.",
"help": "Unix socket allow and none rules for Codex sandboxed networking.",
"advanced": true
},
"appServer.networkProxy.proxyUrl": {

View File

@@ -156,7 +156,7 @@ describe("Codex app-server config", () => {
},
unixSockets: {
" /tmp/mock-proxy.sock ": "allow",
"/tmp/blocked.sock": "deny",
"/tmp/blocked.sock": "none",
},
proxyUrl: "http://127.0.0.1:3128",
socksUrl: "socks5h://127.0.0.1:8081",
@@ -183,7 +183,7 @@ describe("Codex app-server config", () => {
"mock-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": {
":project_roots": {
".": "write",
},
},
@@ -196,7 +196,7 @@ describe("Codex app-server config", () => {
},
unix_sockets: {
"/tmp/mock-proxy.sock": "allow",
"/tmp/blocked.sock": "deny",
"/tmp/blocked.sock": "none",
},
proxy_url: "http://127.0.0.1:3128",
socks_url: "socks5h://127.0.0.1:8081",
@@ -229,12 +229,12 @@ describe("Codex app-server config", () => {
const profileName = runtime.networkProxy?.profileName;
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
string,
{ filesystem: { ":workspace_roots": { ".": string } } }
{ filesystem: { ":project_roots": { ".": string } } }
>;
expect(profileName).toMatch(/^openclaw-network-[a-f0-9]{16}$/u);
expect(runtime.networkProxy?.configPatch.default_permissions).toBe(profileName);
expect(permissions[profileName ?? ""]?.filesystem[":workspace_roots"]["."]).toBe("read");
expect(permissions[profileName ?? ""]?.filesystem[":project_roots"]["."]).toBe("read");
});
it("clamps oversized app-server timer config", () => {

View File

@@ -112,7 +112,7 @@ export type CodexAppServerExperimentalConfig = {
};
export type CodexAppServerNetworkProxyDomainPermission = "allow" | "deny";
export type CodexAppServerNetworkProxyUnixSocketPermission = "allow" | "deny";
export type CodexAppServerNetworkProxyUnixSocketPermission = "allow" | "none";
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
export type CodexAppServerNetworkProxyMode = "limited" | "full";
@@ -310,7 +310,7 @@ const codexAppServerExperimentalSchema = z
})
.strict();
const codexAppServerNetworkProxyDomainPermissionSchema = z.enum(["allow", "deny"]);
const codexAppServerNetworkProxyUnixSocketPermissionSchema = z.enum(["allow", "deny"]);
const codexAppServerNetworkProxyUnixSocketPermissionSchema = z.enum(["allow", "none"]);
const codexAppServerNetworkProxySchema = z
.object({
enabled: z.boolean().optional(),
@@ -908,7 +908,7 @@ function resolveCodexAppServerNetworkProxy(
const profile = {
filesystem: {
":minimal": "read",
":workspace_roots": {
":project_roots": {
".": fileSystemMode,
},
},

View File

@@ -1055,14 +1055,6 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"

View File

@@ -1055,14 +1055,6 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"

View File

@@ -1182,7 +1182,7 @@ describe("runCodexAppServerSideQuestion", () => {
"side-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": { ".": "write" },
":project_roots": { ".": "write" },
},
network: {
enabled: true,

View File

@@ -42,7 +42,7 @@ function createNetworkProxyThreadLifecycleAppServerOptions() {
"openclaw-network": {
filesystem: {
":minimal": "read",
":workspace_roots": {
":project_roots": {
".": "write",
},
},

View File

@@ -95,7 +95,7 @@ function createNetworkProxyAppServerOptions() {
"mock-proxy": {
filesystem: {
":minimal": "read",
":workspace_roots": {
":project_roots": {
".": "write",
},
},

View File

@@ -94,12 +94,57 @@ function createActivityHandler() {
return { handler, run };
}
async function runAdaptiveCardInvoke(
registered: MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
},
value: unknown,
) {
await registered.run({
activity: {
id: "invoke-1",
type: "invoke",
name: "adaptiveCard/action",
channelId: "msteams",
serviceUrl: "https://service.example.test",
from: {
id: "user-bf",
aadObjectId: "user-aad",
name: "User",
},
recipient: {
id: "bot-id",
name: "Bot",
},
conversation: {
id: "19:personal-chat;messageid=abc123",
conversationType: "personal",
},
channelData: {},
attachments: [],
value,
},
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
sendActivities: async () => [],
} as unknown as MSTeamsTurnContext);
}
function lastDispatchedCtxPayload(): Record<string, unknown> {
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls.at(
-1,
)?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
if (!dispatched?.ctxPayload) {
throw new Error("expected dispatched context payload");
}
return dispatched.ctxPayload;
}
describe("msteams adaptive card action invoke", () => {
beforeEach(() => {
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear();
});
it("forwards adaptive card invoke values to the agent as message text", async () => {
it("forwards adaptive card submitted data to the agent as message text", async () => {
const deps = createDeps();
const { handler, run } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
@@ -116,44 +161,117 @@ describe("msteams adaptive card action invoke", () => {
trigger: "button-click",
};
await registered.run({
activity: {
id: "invoke-1",
type: "invoke",
name: "adaptiveCard/action",
channelId: "msteams",
serviceUrl: "https://service.example.test",
from: {
id: "user-bf",
aadObjectId: "user-aad",
name: "User",
},
recipient: {
id: "bot-id",
name: "Bot",
},
conversation: {
id: "19:personal-chat;messageid=abc123",
conversationType: "personal",
},
channelData: {},
attachments: [],
value: payload,
},
sendActivity: vi.fn(async () => ({ id: "activity-id" })),
sendActivities: async () => [],
} as unknown as MSTeamsTurnContext);
await runAdaptiveCardInvoke(registered, payload);
expect(run).not.toHaveBeenCalled();
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
1,
);
const dispatched = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock
.calls[0]?.[0] as { ctxPayload?: Record<string, unknown> } | undefined;
expect(dispatched?.ctxPayload?.RawBody).toBe(JSON.stringify(payload));
expect(dispatched?.ctxPayload?.BodyForAgent).toBe(JSON.stringify(payload));
expect(dispatched?.ctxPayload?.CommandBody).toBe(JSON.stringify(payload));
expect(dispatched?.ctxPayload?.SessionKey).toBe("msteams:direct:user-aad");
expect(dispatched?.ctxPayload?.SenderId).toBe("user-aad");
const expectedBody = JSON.stringify(payload.action.data);
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.RawBody).toBe(expectedBody);
expect(ctxPayload.BodyForAgent).toBe(expectedBody);
expect(ctxPayload.CommandBody).toBe(expectedBody);
expect(ctxPayload.SessionKey).toBe("msteams:direct:user-aad");
expect(ctxPayload.SenderId).toBe("user-aad");
});
it("routes Teams imBack actions as the submitted message text", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data: { msteams: { type: "imBack", value: "Summarize my last meeting" } },
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe("Summarize my last meeting");
expect(ctxPayload.CommandBody).toBe("Summarize my last meeting");
});
it("routes typed command submit actions as command text", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data: "/codex plugins menu",
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe("/codex plugins menu");
expect(ctxPayload.CommandBody).toBe("/codex plugins menu");
});
it("preserves legacy presentation submit values as structured data", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const data = { value: "/codex permissions yolo", label: "Run" };
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data,
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
});
it("preserves arbitrary submitted data with a value field", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const data = { value: "selected", formId: "deploy-approval", choices: ["canary"] };
await runAdaptiveCardInvoke(registered, {
action: {
type: "Action.Submit",
data,
},
});
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(data));
expect(ctxPayload.CommandBody).toBe(JSON.stringify(data));
});
it("preserves generic Action.Execute verb metadata", async () => {
const deps = createDeps();
const { handler } = createActivityHandler();
const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
const payload = {
action: {
type: "Action.Execute",
verb: "ticket.approve",
data: { ticketId: "ticket-123" },
},
};
await runAdaptiveCardInvoke(registered, payload);
const ctxPayload = lastDispatchedCtxPayload();
expect(ctxPayload.BodyForAgent).toBe(JSON.stringify(payload));
expect(ctxPayload.CommandBody).toBe(JSON.stringify(payload));
});
});

View File

@@ -1,5 +1,9 @@
// Msteams plugin module implements monitor handler behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
isRecord,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatUnknownError } from "./errors.js";
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
@@ -25,16 +29,43 @@ export type MSTeamsActivityHandler = {
run?: (context: unknown) => Promise<void>;
};
function extractAdaptiveCardSubmittedData(value: unknown): unknown {
if (!isRecord(value)) {
return value;
}
const action = isRecord(value.action) ? value.action : undefined;
if (action && normalizeOptionalLowercaseString(action.type) === "action.submit" && "data" in action) {
return action.data;
}
return value;
}
function readMSTeamsImBackValue(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
const msteams = isRecord(value.msteams) ? value.msteams : undefined;
if (!msteams || normalizeOptionalLowercaseString(msteams.type) !== "imback") {
return null;
}
return normalizeOptionalString(msteams.value) ?? null;
}
function serializeAdaptiveCardActionValue(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
const submittedValue = extractAdaptiveCardSubmittedData(value);
if (typeof submittedValue === "string") {
const trimmed = submittedValue.trim();
return trimmed ? trimmed : null;
}
if (value === undefined) {
const imBackValue = readMSTeamsImBackValue(submittedValue);
if (imBackValue) {
return imBackValue;
}
if (submittedValue == null) {
return null;
}
try {
return JSON.stringify(value);
return JSON.stringify(submittedValue);
} catch {
return null;
}

View File

@@ -75,6 +75,7 @@ vi.mock("./model-selection.runtime.js", () => ({
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
import {
runQaLabSelfCheckCommand,
runQaCredentialsAddCommand,
runQaDockerBuildImageCommand,
runQaDockerScaffoldCommand,
runQaDockerUpCommand,
@@ -2188,6 +2189,30 @@ describe("qa cli runtime", () => {
expectWriteContains(stdoutWrite, "QA self-check report: /tmp/failed-report.md");
});
it("rejects oversized credential payload files before broker setup", async () => {
const previousMaxBytes = process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
const payloadPath = path.join(suiteArtifactsDir, "oversized-credential.json");
await fs.writeFile(payloadPath, JSON.stringify({ blob: "x".repeat(64) }), "utf8");
process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = "32";
try {
await expect(
runQaCredentialsAddCommand({
kind: "telegram",
payloadFile: payloadPath,
}),
).rejects.toThrow(
"Payload file exceeds OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES (32 bytes).",
);
} finally {
if (previousMaxBytes === undefined) {
delete process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
} else {
process.env.OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = previousMaxBytes;
}
}
});
it("resolves docker scaffold paths relative to the explicit repo root", async () => {
await runQaDockerScaffoldCommand({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
buildQaAgenticParityComparison,
@@ -90,6 +91,8 @@ import {
} from "./tool-coverage-report.js";
const QA_SUITE_INFRA_RETRY_LIMIT = 1;
const QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV = "OPENCLAW_QA_CREDENTIAL_PAYLOAD_MAX_BYTES";
const DEFAULT_QA_CREDENTIAL_PAYLOAD_MAX_BYTES = 64 * 1024 * 1024;
const QA_SUITE_INFRA_RETRY_NETWORK_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
@@ -543,7 +546,29 @@ async function runInterruptibleServer(label: string, server: InterruptibleServer
await new Promise(() => {});
}
function resolveQaCredentialPayloadFileMaxBytes(env: NodeJS.ProcessEnv = process.env) {
const raw = env[QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV]?.trim();
if (!raw) {
return DEFAULT_QA_CREDENTIAL_PAYLOAD_MAX_BYTES;
}
const parsed = parseStrictPositiveInteger(raw);
if (parsed === undefined) {
throw new Error(`${QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV} must be a positive integer.`);
}
return parsed;
}
async function readQaCredentialPayloadFile(filePath: string) {
const maxBytes = resolveQaCredentialPayloadFileMaxBytes();
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
throw new Error("Payload file must be a regular JSON file.");
}
if (stat.size > maxBytes) {
throw new Error(
`Payload file exceeds ${QA_CREDENTIAL_PAYLOAD_MAX_BYTES_ENV} (${maxBytes} bytes).`,
);
}
const text = await fs.readFile(filePath, "utf8");
let payload: unknown;
try {

View File

@@ -424,4 +424,47 @@ describe("markdownToTelegramHtml", () => {
it("fails loudly when tag overhead leaves no room for text", () => {
expect(() => splitTelegramHtmlChunks("<b><i><u>x</u></i></b>", 10)).toThrow(/tag overhead/i);
});
it("does not split an astral char across the chunk boundary", () => {
// Emoji surrogate pair straddles index 10 (limit): high at 9, low at 10.
const input = `${"A".repeat(9)}😀${"B".repeat(20)}`;
const chunks = splitTelegramHtmlChunks(input, 10);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe(input);
for (const chunk of chunks) {
expect(containsLoneSurrogate(chunk)).toBe(false);
}
});
it("keeps an astral char whole when a positive limit starts on its pair", () => {
expect(splitTelegramHtmlChunks("A😀B", 1)).toEqual(["A", "😀", "B"]);
});
it("keeps astral chars whole in rendered Markdown chunks", () => {
const chunks = markdownToTelegramChunks("A😀B", 1);
expect(chunks.map((chunk) => chunk.text)).toEqual(["A", "😀", "B"]);
for (const chunk of chunks) {
expect(containsLoneSurrogate(chunk.html)).toBe(false);
expect(containsLoneSurrogate(chunk.text)).toBe(false);
}
});
});
function containsLoneSurrogate(text: string): boolean {
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
const isHigh = code >= 0xd800 && code <= 0xdbff;
const isLow = code >= 0xdc00 && code <= 0xdfff;
if (isHigh) {
const next = text.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) {
return true;
}
index += 1;
} else if (isLow) {
return true;
}
}
return false;
}

View File

@@ -1070,11 +1070,30 @@ function findTelegramHtmlEntityEnd(text: string, start: number): number {
return text[index] === ";" ? index : -1;
}
// Never return a split index that lands between a UTF-16 surrogate pair, or
// both chunks would carry a lone surrogate that re-encodes to U+FFFD. If the
// pair starts the segment, keep it whole so chunking still advances.
function clampToSurrogateBoundary(text: string, index: number): number {
const high = text.charCodeAt(index - 1);
const low = text.charCodeAt(index);
const splitsPair =
index > 0 && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
if (!splitsPair) {
return index;
}
return index > 1 ? index - 1 : index + 1;
}
function findTelegramHtmlSafeSplitIndex(text: string, maxLength: number): number {
if (text.length <= maxLength) {
return text.length;
}
const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
const splitIndex = findTelegramHtmlEntitySafeSplitIndex(text, normalizedMaxLength);
return clampToSurrogateBoundary(text, splitIndex);
}
function findTelegramHtmlEntitySafeSplitIndex(text: string, normalizedMaxLength: number): number {
const lastAmpersand = text.lastIndexOf("&", normalizedMaxLength - 1);
if (lastAmpersand === -1) {
return normalizedMaxLength;

View File

@@ -0,0 +1,57 @@
// Telegram tests cover plain-text chunk-splitting behavior.
import { describe, expect, it } from "vitest";
import { splitTelegramPlainTextChunksForTests } from "./send.js";
function containsLoneSurrogate(text: string): boolean {
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
const isHigh = code >= 0xd800 && code <= 0xdbff;
const isLow = code >= 0xdc00 && code <= 0xdfff;
if (isHigh) {
const next = text.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) {
return true;
}
index += 1;
} else if (isLow) {
return true;
}
}
return false;
}
describe("splitTelegramPlainTextChunks", () => {
it("does not split an astral char across the chunk boundary", () => {
// Emoji surrogate pair straddles index 10 (limit): high at 9, low at 10.
const input = `${"A".repeat(9)}😀${"B".repeat(20)}`;
const chunks = splitTelegramPlainTextChunksForTests(input, 10);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe(input);
for (const chunk of chunks) {
expect(containsLoneSurrogate(chunk)).toBe(false);
}
});
it("does not hang when limit=1 and text starts with an astral char", () => {
// Regression: with limit=1 the clamp would return start (no advance),
// causing the while-loop to spin forever. The surrogate pair must be
// emitted as a unit (2 code units) so the loop always advances.
const input = "😀X";
const chunks = splitTelegramPlainTextChunksForTests(input, 1);
expect(chunks.join("")).toBe(input);
for (const chunk of chunks) {
expect(containsLoneSurrogate(chunk)).toBe(false);
}
});
it("does not hang when limit=1 and an astral char appears mid-string at a chunk boundary", () => {
// 'A' + emoji: with limit=1, second iteration starts at index 1 (high
// surrogate) — same stall condition as above, now mid-string.
const input = "A😀B";
const chunks = splitTelegramPlainTextChunksForTests(input, 1);
expect(chunks.join("")).toBe(input);
for (const chunk of chunks) {
expect(containsLoneSurrogate(chunk)).toBe(false);
}
});
});

View File

@@ -179,14 +179,40 @@ function resolveTelegramMessageIdOrThrow(
throw new Error(`Telegram ${context} returned no message_id`);
}
// Pull a chunk end back off a UTF-16 surrogate pair so neither chunk carries a
// lone surrogate that re-encodes to U+FFFD. Mirrors the guard in
// bot/native-quote.ts `truncateUtf16Safe`; shared by both plain-text splitters.
//
// `start` is the beginning of the current chunk — the return value is
// guaranteed to be > start, so callers that loop on `start = end` always
// advance. When clamping would land on `start` (i.e. the surrogate pair begins
// exactly at `start`), we emit both surrogates together (end = start + 2)
// rather than emitting a lone surrogate or stalling.
function surrogateSafeChunkEnd(text: string, end: number, start: number): number {
const high = text.charCodeAt(end - 1);
const low = text.charCodeAt(end);
const splitsPair = end > 0 && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
if (!splitsPair) {
return end;
}
const clamped = end - 1;
// Guard: never return an index that would stall the loop. If clamped equals
// start the surrogate pair's high unit is the very first char of this chunk;
// emit both surrogates together instead of splitting or stalling.
return clamped > start ? clamped : start + 2;
}
function splitTelegramPlainTextChunks(text: string, limit: number): string[] {
if (!text) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
const chunks: string[] = [];
for (let start = 0; start < text.length; start += normalizedLimit) {
chunks.push(text.slice(start, start + normalizedLimit));
let start = 0;
while (start < text.length) {
const end = surrogateSafeChunkEnd(text, start + normalizedLimit, start);
chunks.push(text.slice(start, end));
start = end;
}
return chunks;
}
@@ -209,12 +235,19 @@ function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit:
remainingChunks === 1
? remainingChars
: Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks));
chunks.push(text.slice(offset, offset + nextChunkLength));
offset += nextChunkLength;
const end = surrogateSafeChunkEnd(text, offset + nextChunkLength, offset);
chunks.push(text.slice(offset, end));
offset = end;
}
return chunks;
}
// Test-only handle: the plain-text splitter is internal, but its surrogate-safe
// chunk boundary needs direct behavior coverage.
export function splitTelegramPlainTextChunksForTests(text: string, limit: number): string[] {
return splitTelegramPlainTextChunks(text, limit);
}
function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): void {
const parts = [
"telegram outbound send ok",

View File

@@ -43,6 +43,17 @@ describe("telegramPlugin outbound", () => {
expect(telegramOutbound.chunker?.(text, 4000)).toEqual([text]);
});
it("keeps astral characters whole at positive configured chunk limits", () => {
clearTelegramRuntime();
expect(telegramOutbound.chunker?.("A😀B", 1)).toEqual(["A", "😀", "B"]);
expect(telegramOutbound.chunker?.("A😀B", 1, { formatting: { parseMode: "HTML" } })).toEqual([
"A",
"😀",
"B",
]);
});
it("preserves markdown tables for the configured delivery renderer", () => {
clearTelegramRuntime();
const text = ["| Name | Value |", "|------|-------|", "| A | 1 |"].join("\n");

View File

@@ -1432,7 +1432,7 @@
"scripts": {
"android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug",
"android:assemble:third-party": "node scripts/run-android-gradle.mjs :app:assembleThirdPartyDebug",
"android:bundle:release": "bun apps/android/scripts/build-release-artifacts.ts",
"android:bundle:release": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android play_store_archive'",
"android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat",
"android:install": "node scripts/run-android-gradle.mjs :app:installPlayDebug",
"android:install:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug",
@@ -1441,10 +1441,14 @@
"android:run": "node scripts/run-android-gradle.mjs :app:installPlayDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
"android:run:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
"android:release": "bash scripts/android-release.sh",
"android:release:archive": "bun apps/android/scripts/build-release-artifacts.ts",
"android:release:archive": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android play_store_archive'",
"android:release:auth:check": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android auth_check'",
"android:release:metadata": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android metadata'",
"android:release:preflight": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android release_preflight'",
"android:release:signing:check": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android signing_check'",
"android:release:signing:plan": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android signing_plan'",
"android:release:signing:sync:pull": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android signing_sync_pull'",
"android:release:signing:sync:push": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android signing_sync_push'",
"android:release:upload": "bash scripts/android-release-upload.sh",
"android:screenshots": "bash scripts/android-screenshots.sh",
"android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest",

View File

@@ -825,7 +825,9 @@ export class CoreAgentHarness<
throw new AgentHarnessError("auth", "No auth available for compaction");
}
const branchEntries = await this.session.getBranch();
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS, {
force: true,
});
if (!preparationResult.ok) {
throw preparationResult.error;
}

View File

@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantMessageEventStream } from "../../llm.js";
import type { AssistantMessage, Model, StreamFn } from "../../llm.js";
import { generateSummary } from "./compaction.js";
import { buildSessionContext } from "../session/session.js";
import type { SessionTreeEntry } from "../types.js";
import { DEFAULT_COMPACTION_SETTINGS, prepareCompaction, generateSummary } from "./compaction.js";
describe("generateSummary thinking options", () => {
it("maps explicit Fable off to low effort for compaction", async () => {
@@ -60,3 +62,197 @@ describe("generateSummary thinking options", () => {
expect(streamFn).toHaveBeenCalledOnce();
});
});
describe("prepareCompaction", () => {
function createHighUsageSmallTranscriptEntries(): SessionTreeEntry[] {
return [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "Stored." }],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
},
];
}
it("skips automatic no-op summaries when usage is high but transcript text is below the kept-tail budget", () => {
const entries = createHighUsageSmallTranscriptEntries();
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toEqual({ ok: true, value: undefined });
});
it("forces manual preparation when usage is high but transcript text is below the kept-tail budget", () => {
const entries = createHighUsageSmallTranscriptEntries();
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
expect(preparation).toEqual({
ok: true,
value: expect.objectContaining({
firstKeptEntryId: "assistant-1",
messagesToSummarize: entries.map((entry) =>
entry.type === "message" ? entry.message : undefined,
),
tokensBefore: 173_559,
turnPrefixMessages: [],
}),
});
});
it("anchors a forced boundary on the assistant tool call, not a trailing tool result", () => {
const entries: SessionTreeEntry[] = [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "Read the notes file.", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "read_file", arguments: { path: "notes.md" } },
],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
},
},
{
type: "message",
id: "tool-1",
parentId: "assistant-1",
timestamp: "2026-06-17T08:45:11.000Z",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read_file",
content: [{ type: "text", text: "notes body" }],
isError: false,
timestamp: 3,
},
},
];
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
// Anchor must be the assistant that owns the tool call, never the trailing
// tool result, or the rebuilt context would replay an orphaned tool result.
expect(preparation).toEqual({
ok: true,
value: expect.objectContaining({ firstKeptEntryId: "assistant-1" }),
});
const compactedContext = buildSessionContext([
...entries,
{
type: "compaction",
id: "compaction-1",
parentId: "tool-1",
timestamp: "2026-06-17T08:45:20.000Z",
summary: "Checkpoint of the file read.",
firstKeptEntryId: "assistant-1",
tokensBefore: 173_559,
},
]);
expect(compactedContext.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"assistant",
"toolResult",
]);
});
it("shows why the old empty-summary compaction replayed the whole transcript", () => {
const entries: SessionTreeEntry[] = [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "Stored." }],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
},
];
const compactedContext = buildSessionContext([
...entries,
{
type: "compaction",
id: "compaction-1",
parentId: "assistant-1",
timestamp: "2026-06-17T08:45:20.000Z",
summary: "No prior conversation content provided.",
firstKeptEntryId: "user-1",
tokensBefore: 173_559,
},
]);
expect(compactedContext.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
]);
});
});

View File

@@ -626,10 +626,16 @@ export interface CompactionPreparation {
settings: CompactionSettings;
}
export interface CompactionPreparationOptions {
/** Prepare a real summary even when the kept-tail heuristic would otherwise summarize nothing. */
force?: boolean;
}
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
options: CompactionPreparationOptions = {},
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
return ok(undefined);
@@ -686,6 +692,41 @@ export function prepareCompaction(
}
}
}
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
if (options.force === true) {
const forcedMessagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < boundaryEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) {
forcedMessagesToSummarize.push(msg);
}
}
// Anchor the kept tail on the last valid cut point, not the raw final entry.
// findValidCutPoints excludes tool results, so a forced boundary that is not
// collapsed to summary-only later never keeps an orphaned tool result.
const forcedCutPoints = findValidCutPoints(pathEntries, boundaryStart, boundaryEnd);
const forcedKeepIndex =
forcedCutPoints.length > 0 ? forcedCutPoints[forcedCutPoints.length - 1] : -1;
if (forcedMessagesToSummarize.length > 0 && forcedKeepIndex >= 0) {
const forcedFileOps = extractFileOperations(
forcedMessagesToSummarize,
pathEntries,
prevCompactionIndex,
);
return ok({
firstKeptEntryId: pathEntries[forcedKeepIndex].id,
messagesToSummarize: forcedMessagesToSummarize,
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore,
previousSummary,
fileOps: forcedFileOps,
settings,
});
}
}
return ok(undefined);
}
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {

View File

@@ -46,6 +46,7 @@ export {
shouldCompact,
type CompactionDetails,
type CompactionPreparation,
type CompactionPreparationOptions,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,

View File

@@ -42,6 +42,23 @@ function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhi
return { lastNewline, lastWhitespace };
}
/**
* Keeps UTF-16 chunk boundaries from separating a supplementary-plane character.
* A one-unit positive limit still needs to emit an entire surrogate pair.
*/
export function avoidTrailingHighSurrogateBreak(text: string, start: number, end: number): number {
if (
end >= text.length ||
text.charCodeAt(end - 1) < 0xd800 ||
text.charCodeAt(end - 1) > 0xdbff ||
text.charCodeAt(end) < 0xdc00 ||
text.charCodeAt(end) > 0xdfff
) {
return end;
}
return end - 1 > start ? end - 1 : end + 1;
}
/**
* Splits plain text into size-bounded chunks at readable boundaries.
*
@@ -66,7 +83,11 @@ export function chunkText(text: string, limit: number): string[] {
// Prefer block boundaries, then spaces, then a hard size cut when no
// readable breakpoint exists inside this window.
const breakOffset = lastNewline > 0 ? lastNewline : lastWhitespace;
const end = breakOffset > 0 ? cursor + breakOffset : windowEnd;
const end = avoidTrailingHighSurrogateBreak(
text,
cursor,
breakOffset > 0 ? cursor + breakOffset : windowEnd,
);
chunks.push(text.slice(cursor, end));
cursor = end;
while (cursor < text.length && /\s/.test(text[cursor] ?? "")) {

View File

@@ -85,6 +85,28 @@ describe("renderMarkdownIRChunksWithinLimit", () => {
expect(chunks.every((chunk) => chunk.rendered.length <= 1)).toBe(true);
});
it("keeps astral characters whole when a positive limit reaches their pair", () => {
const chunks = renderMarkdownIRChunksWithinLimit({
ir: markdownToIR("A😀B"),
limit: 1,
renderChunk: (chunk) => chunk.text,
measureRendered: (rendered) => rendered.length,
});
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["A", "😀", "B"]);
});
it("keeps astral characters whole when rendered size requires a retry split", () => {
const chunks = renderMarkdownIRChunksWithinLimit({
ir: markdownToIR("A😀"),
limit: 3,
renderChunk: (chunk) => (chunk.text === "A😀" ? "too long" : chunk.text),
measureRendered: (rendered) => rendered.length,
});
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["A", "😀"]);
});
it("treats Infinity as no size cap and returns a single chunk", () => {
const text = "one two three four five six seven eight nine ten";
const ir = markdownToIR(text);

View File

@@ -1,3 +1,4 @@
import { avoidTrailingHighSurrogateBreak } from "./chunk-text.js";
// Markdown Core module implements render aware chunking behavior.
import {
chunkMarkdownIR,
@@ -127,10 +128,11 @@ function findLargestChunkTextLengthWithinRenderedLimit<TRendered>(
// Rendered length is not guaranteed to be monotonic after escaping/link or
// file-reference rewriting, so test exact candidates from longest to shortest.
for (let candidateLength = currentTextLength - 1; candidateLength >= 1; candidateLength -= 1) {
const candidate = sliceMarkdownIR(chunk, 0, candidateLength);
const safeCandidateLength = avoidTrailingHighSurrogateBreak(chunk.text, 0, candidateLength);
const candidate = sliceMarkdownIR(chunk, 0, safeCandidateLength);
const rendered = options.renderChunk(candidate);
if (options.measureRendered(rendered) <= renderedLimit) {
return candidateLength;
return safeCandidateLength;
}
}
return 0;
@@ -215,7 +217,7 @@ function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: n
if (lastAnyWhitespaceBreak > start) {
return resolveWhitespaceBreak(lastAnyWhitespaceBreak, lastAnyWhitespaceRunStart);
}
return maxEnd;
return avoidTrailingHighSurrogateBreak(text, start, maxEnd);
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {

View File

@@ -0,0 +1,449 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultManifestPath = path.join(rootDir, "apps", "android", "Config", "ReleaseSigning.json");
const requiredPropertyNames = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD",
];
const sourceRequiredPropertyNames = requiredPropertyNames.filter(
(name) => name !== "OPENCLAW_ANDROID_STORE_FILE",
);
function usage() {
process.stdout.write(`Usage:
scripts/android-release-signing.mjs --mode plan
scripts/android-release-signing.mjs --mode check
scripts/android-release-signing.mjs --mode sync-pull
scripts/android-release-signing.mjs --mode sync-push --keystore PATH --properties PATH
Options:
--manifest PATH Defaults to apps/android/Config/ReleaseSigning.json.
--workspace PATH Defaults to <materializedRoot>/apps-signing.
--materialized-dir PATH Defaults to materializedRoot from the manifest.
--keystore PATH Upload keystore source for --mode sync-push.
--properties PATH Signing properties source for --mode sync-push.
sync-pull and sync-push use MATCH_PASSWORD to decrypt/encrypt Android release
signing assets in the shared apps-signing repository.
`);
}
function parseArgs(argv) {
const options = {
mode: "",
manifestPath: defaultManifestPath,
workspace: "",
materializedDir: "",
keystorePath: process.env.OPENCLAW_ANDROID_UPLOAD_KEYSTORE || "",
propertiesPath: process.env.OPENCLAW_ANDROID_SIGNING_PROPERTIES || "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--mode") {
options.mode = argv[index + 1] || "";
index += 1;
} else if (arg === "--manifest") {
options.manifestPath = path.resolve(argv[index + 1] || "");
index += 1;
} else if (arg === "--workspace") {
options.workspace = path.resolve(argv[index + 1] || "");
index += 1;
} else if (arg === "--materialized-dir") {
options.materializedDir = path.resolve(argv[index + 1] || "");
index += 1;
} else if (arg === "--keystore") {
options.keystorePath = path.resolve(argv[index + 1] || "");
index += 1;
} else if (arg === "--properties") {
options.propertiesPath = path.resolve(argv[index + 1] || "");
index += 1;
} else if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.mode) {
throw new Error("Missing required --mode.");
}
return options;
}
function requireString(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Android release signing manifest missing ${key}.`);
}
return value.trim();
}
function readManifest(manifestPath) {
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifest = {
signingRepo: requireString(parsed.signingRepo, "signingRepo"),
signingBranch: requireString(parsed.signingBranch, "signingBranch"),
assetPath: requireString(parsed.assetPath, "assetPath"),
uploadKeystoreEncryptedFile: requireString(
parsed.uploadKeystoreEncryptedFile,
"uploadKeystoreEncryptedFile",
),
gradlePropertiesEncryptedFile: requireString(
parsed.gradlePropertiesEncryptedFile,
"gradlePropertiesEncryptedFile",
),
materializedRoot: requireString(parsed.materializedRoot, "materializedRoot"),
gradlePropertyNames: parsed.gradlePropertyNames,
};
if (
!Array.isArray(manifest.gradlePropertyNames) ||
manifest.gradlePropertyNames.length !== requiredPropertyNames.length ||
!requiredPropertyNames.every((name) => manifest.gradlePropertyNames.includes(name))
) {
throw new Error(
`Android release signing manifest must list Gradle properties: ${requiredPropertyNames.join(", ")}.`,
);
}
return manifest;
}
function relativePath(filePath) {
const relative = path.relative(rootDir, filePath);
return relative && !relative.startsWith("..") ? relative : filePath;
}
function resolveMaterializedDir(manifest, options) {
return options.materializedDir || path.resolve(rootDir, manifest.materializedRoot);
}
function resolveWorkspace(manifest, options) {
return options.workspace || path.join(resolveMaterializedDir(manifest, options), "apps-signing");
}
function assertWorkspaceInsideMaterialized(workspace, materializedDir) {
const resolvedWorkspace = path.resolve(workspace);
const resolvedMaterializedDir = path.resolve(materializedDir);
const relative = path.relative(resolvedMaterializedDir, resolvedWorkspace);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(
`Android signing workspace must be inside ${relativePath(resolvedMaterializedDir)}.`,
);
}
}
function assetDir(workspace, manifest) {
return path.join(workspace, manifest.assetPath);
}
function encryptedKeystorePath(workspace, manifest) {
return path.join(assetDir(workspace, manifest), manifest.uploadKeystoreEncryptedFile);
}
function encryptedPropertiesPath(workspace, manifest) {
return path.join(assetDir(workspace, manifest), manifest.gradlePropertiesEncryptedFile);
}
function materializedKeystorePath(materializedDir) {
return path.join(materializedDir, "upload-keystore.jks");
}
function materializedPropertiesPath(materializedDir) {
return path.join(materializedDir, "gradle.properties");
}
function requireMatchPassword() {
if (!process.env.MATCH_PASSWORD || process.env.MATCH_PASSWORD.trim() === "") {
throw new Error("MATCH_PASSWORD is required for Android release signing sync.");
}
}
function run(command, args, options = {}) {
execFileSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
stdio: options.stdio || "pipe",
});
}
function runText(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function cloneSigningRepo(manifest, workspace, materializedDir) {
assertWorkspaceInsideMaterialized(workspace, materializedDir);
fs.rmSync(workspace, { recursive: true, force: true });
fs.mkdirSync(path.dirname(workspace), { recursive: true });
run("git", ["clone", "--branch", manifest.signingBranch, manifest.signingRepo, workspace]);
}
function opensslCrypt({ decrypt, inputPath, outputPath }) {
requireMatchPassword();
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (decrypt) {
fs.rmSync(outputPath, { force: true });
}
const args = [
"enc",
"-aes-256-cbc",
"-pbkdf2",
"-md",
"sha256",
...(decrypt ? ["-d"] : ["-salt"]),
"-in",
inputPath,
"-out",
outputPath,
"-pass",
"env:MATCH_PASSWORD",
];
const previousUmask = decrypt ? process.umask(0o077) : undefined;
try {
run("openssl", args);
} finally {
if (previousUmask !== undefined) {
process.umask(previousUmask);
}
}
if (decrypt) {
fs.chmodSync(outputPath, 0o600);
}
}
function readProperties(filePath) {
const properties = new Map();
for (const rawLine of fs.readFileSync(filePath, "utf8").split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separator = line.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid signing properties line in ${relativePath(filePath)}.`);
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (!key || !value) {
throw new Error(`Invalid empty signing property in ${relativePath(filePath)}.`);
}
properties.set(key, value);
}
return properties;
}
function requireProperties(properties, names, filePath) {
const missing = names.filter((name) => !properties.get(name));
if (missing.length > 0) {
throw new Error(
`${relativePath(filePath)} is missing Android signing properties: ${missing.join(", ")}.`,
);
}
}
function writeMaterializedProperties(materializedDir, sourceProperties) {
const keystorePath = materializedKeystorePath(materializedDir);
const propertiesPath = materializedPropertiesPath(materializedDir);
const tempPath = `${propertiesPath}.${process.pid}.tmp`;
const properties = new Map(sourceProperties);
properties.set("OPENCLAW_ANDROID_STORE_FILE", keystorePath);
requireProperties(properties, requiredPropertyNames, propertiesPath);
const content = [
"# Generated by scripts/android-release-signing.mjs.",
"# Contains decrypted Android release signing values. Do not commit.",
...requiredPropertyNames.map((name) => `${name}=${properties.get(name)}`),
"",
].join("\n");
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.chmodSync(tempPath, 0o600);
fs.renameSync(tempPath, propertiesPath);
fs.chmodSync(propertiesPath, 0o600);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function validateMaterializedSigning(materializedDir) {
const keystorePath = materializedKeystorePath(materializedDir);
const propertiesPath = materializedPropertiesPath(materializedDir);
if (!fs.existsSync(keystorePath) || fs.statSync(keystorePath).size === 0) {
throw new Error(
`Missing materialized Android upload keystore at ${relativePath(keystorePath)}.`,
);
}
if (!fs.existsSync(propertiesPath)) {
throw new Error(
`Missing materialized Android signing properties at ${relativePath(propertiesPath)}.`,
);
}
const properties = readProperties(propertiesPath);
requireProperties(properties, requiredPropertyNames, propertiesPath);
if (properties.get("OPENCLAW_ANDROID_STORE_FILE") !== keystorePath) {
throw new Error(
`${relativePath(propertiesPath)} must point OPENCLAW_ANDROID_STORE_FILE at ${relativePath(keystorePath)}.`,
);
}
}
function writePlan(manifest, options) {
const materializedDir = resolveMaterializedDir(manifest, options);
process.stdout.write(`Android release signing plan
Signing repo: ${manifest.signingRepo}
Signing branch: ${manifest.signingBranch}
Signing assets: ${manifest.assetPath}
Encrypted upload keystore: ${manifest.uploadKeystoreEncryptedFile}
Encrypted Gradle properties: ${manifest.gradlePropertiesEncryptedFile}
Materialized output: ${relativePath(materializedDir)}
Gradle bridge: Fastlane exports ORG_GRADLE_PROJECT_* values from the materialized properties file.
`);
}
function writeSigningRepoManifest(workspace, manifest) {
const signingManifestPath = path.join(assetDir(workspace, manifest), "manifest.json");
const signingManifest = {
version: 1,
assetPath: manifest.assetPath,
uploadKeystoreEncryptedFile: manifest.uploadKeystoreEncryptedFile,
gradlePropertiesEncryptedFile: manifest.gradlePropertiesEncryptedFile,
gradlePropertyNames: requiredPropertyNames,
};
fs.writeFileSync(signingManifestPath, `${JSON.stringify(signingManifest, null, 2)}\n`);
}
function syncPull(manifest, options) {
const workspace = resolveWorkspace(manifest, options);
const materializedDir = resolveMaterializedDir(manifest, options);
const tempPropertiesPath = path.join(materializedDir, ".gradle.properties.decrypted.tmp");
cloneSigningRepo(manifest, workspace, materializedDir);
if (!fs.existsSync(encryptedKeystorePath(workspace, manifest))) {
throw new Error(
`Missing encrypted Android upload keystore in signing repo at ${manifest.assetPath}/${manifest.uploadKeystoreEncryptedFile}.`,
);
}
if (!fs.existsSync(encryptedPropertiesPath(workspace, manifest))) {
throw new Error(
`Missing encrypted Android signing properties in signing repo at ${manifest.assetPath}/${manifest.gradlePropertiesEncryptedFile}.`,
);
}
fs.mkdirSync(materializedDir, { recursive: true });
opensslCrypt({
decrypt: true,
inputPath: encryptedKeystorePath(workspace, manifest),
outputPath: materializedKeystorePath(materializedDir),
});
try {
opensslCrypt({
decrypt: true,
inputPath: encryptedPropertiesPath(workspace, manifest),
outputPath: tempPropertiesPath,
});
const properties = readProperties(tempPropertiesPath);
requireProperties(properties, sourceRequiredPropertyNames, tempPropertiesPath);
writeMaterializedProperties(materializedDir, properties);
} finally {
fs.rmSync(tempPropertiesPath, { force: true });
}
validateMaterializedSigning(materializedDir);
process.stdout.write(
`Materialized Android release signing assets in ${relativePath(materializedDir)}.\n`,
);
}
function requirePushSources(options) {
if (!options.keystorePath) {
throw new Error(
"Missing Android upload keystore source. Pass --keystore or set OPENCLAW_ANDROID_UPLOAD_KEYSTORE.",
);
}
if (!options.propertiesPath) {
throw new Error(
"Missing Android signing properties source. Pass --properties or set OPENCLAW_ANDROID_SIGNING_PROPERTIES.",
);
}
if (!fs.existsSync(options.keystorePath) || fs.statSync(options.keystorePath).size === 0) {
throw new Error(
`Android upload keystore source is missing or empty: ${relativePath(options.keystorePath)}.`,
);
}
if (!fs.existsSync(options.propertiesPath)) {
throw new Error(
`Android signing properties source is missing: ${relativePath(options.propertiesPath)}.`,
);
}
const properties = readProperties(options.propertiesPath);
requireProperties(properties, sourceRequiredPropertyNames, options.propertiesPath);
}
function syncPush(manifest, options) {
requireMatchPassword();
requirePushSources(options);
const workspace = resolveWorkspace(manifest, options);
cloneSigningRepo(manifest, workspace, resolveMaterializedDir(manifest, options));
fs.mkdirSync(assetDir(workspace, manifest), { recursive: true });
opensslCrypt({
decrypt: false,
inputPath: options.keystorePath,
outputPath: encryptedKeystorePath(workspace, manifest),
});
opensslCrypt({
decrypt: false,
inputPath: options.propertiesPath,
outputPath: encryptedPropertiesPath(workspace, manifest),
});
writeSigningRepoManifest(workspace, manifest);
run("git", ["add", manifest.assetPath], { cwd: workspace });
const status = runText("git", ["status", "--porcelain"], { cwd: workspace }).trim();
if (!status) {
process.stdout.write("Android release signing assets were already up to date.\n");
return;
}
run("git", ["commit", "-m", "Update Android release signing assets"], { cwd: workspace });
run("git", ["push", "origin", manifest.signingBranch], { cwd: workspace });
process.stdout.write("Pushed encrypted Android release signing assets.\n");
}
try {
const options = parseArgs(process.argv.slice(2));
const manifest = readManifest(options.manifestPath);
if (options.mode === "plan") {
writePlan(manifest, options);
} else if (options.mode === "check") {
validateMaterializedSigning(resolveMaterializedDir(manifest, options));
process.stdout.write("Android release signing materialization is valid.\n");
} else if (options.mode === "sync-pull") {
syncPull(manifest, options);
} else if (options.mode === "sync-push") {
syncPush(manifest, options);
} else {
throw new Error(`Unknown mode: ${options.mode}`);
}
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exit(1);
}

View File

@@ -163,15 +163,6 @@ const sourceTestSuffixes = [
"test-utils.ts",
];
const ignoredSourceDirectories = new Set(["node_modules"]);
// Browser bundles written by build-diffs-viewer-runtime and bundle-a2ui are
// minified client payloads, not Node runtime source for this state policy.
const generatedStaticBundlePathSuffixes = [
"/extensions/canvas/src/host/a2ui/a2ui.bundle.js",
"/extensions/diffs/assets/viewer-runtime.js",
"/extensions/diffs-language-pack/assets/viewer-runtime.js",
];
function isAllowedLegacyOwnerPath(relativePath) {
return (
allowedFixturePaths.has(relativePath) ||
@@ -225,12 +216,7 @@ function consumeAllowedCurrentLegacyViolation(
}
function isSourceFile(filePath) {
return (
sourceFileExtensions.has(path.extname(filePath)) &&
!generatedStaticBundlePathSuffixes.some((suffix) =>
filePath.replaceAll(path.sep, "/").endsWith(suffix),
)
);
return sourceFileExtensions.has(path.extname(filePath));
}
function isTestLikeSourceFile(filePath) {
@@ -255,7 +241,7 @@ async function collectSourceFiles(targetPath) {
const entries = await fs.readdir(targetPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (ignoredSourceDirectories.has(entry.name)) {
if (entry.name === "node_modules") {
continue;
}
const entryPath = path.join(targetPath, entry.name);

View File

@@ -163,6 +163,7 @@ trap 'rm -f "$run_log"; rm -rf "$npm_prefix_host"' EXIT
docker_env=(
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-e OPENCLAW_E2E_COMMAND_TIMEOUT="${OPENCLAW_E2E_COMMAND_TIMEOUT:-300s}"
-e TMPDIR=/tmp
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="$PACKAGE_SPEC"
-e OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL="$PACKAGE_LABEL"
-e OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="$OUTPUT_DIR"

View File

@@ -0,0 +1,717 @@
#!/usr/bin/env node
// GitHub security-sensitive file guard: detects sensitive boundary files,
// manages sticky comments/labels, and requires SHA-bound secops/admin approval.
import { appendFile, readFile } from "node:fs/promises";
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
/** Marker used to identify security-sensitive guard comments. */
export const securitySensitiveGuardMarker = "<!-- openclaw:security-sensitive-guard -->";
export const securitySensitiveChangedLabel = "security-sensitive-changed";
export const allowSecuritySensitiveCommand = "/allow-security-sensitive-change";
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
const securityTeamSlug = process.env.OPENCLAW_SECURITY_TEAM_SLUG ?? "openclaw-secops";
const maxListedFiles = 25;
const securitySensitiveFiles = [
{
path: ".gitignore",
reason:
"Controls ignored secret and local files, including common `.env` files, before they can be accidentally committed.",
},
];
export function securitySensitiveFileDefinitions() {
return securitySensitiveFiles.map((entry) => ({ ...entry }));
}
export function securitySensitiveFileDefinition(filename) {
return securitySensitiveFiles.find((entry) => entry.path === filename) ?? null;
}
export function isSecuritySensitiveFile(filename) {
return securitySensitiveFileDefinition(filename) !== null;
}
export function sanitizeDisplayValue(value) {
return String(value)
.replace(/[\p{Cc}]/gu, "?")
.slice(0, 240);
}
export function markdownCode(value) {
return `\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
}
function* securitySensitiveOverrideCandidates({ comments, expectedSha, newerThan }) {
if (!expectedSha) {
return;
}
const commandPattern = /^\/allow-security-sensitive-change(?:\s+(.+))?$/gimu;
for (const comment of comments.toReversed()) {
const body = comment.body ?? "";
for (const match of body.matchAll(commandPattern)) {
const reason = match[1]?.trim();
const login = comment.user?.login;
if (!login || !isCommentNewerThan(comment, newerThan)) {
continue;
}
yield {
login,
reason: reason ? sanitizeDisplayValue(reason) : null,
sha: expectedSha,
url: comment.html_url,
};
}
}
}
export function findSecuritySensitiveOverrideCommand({
comments,
expectedSha,
isSecurityMember,
newerThan,
}) {
for (const candidate of securitySensitiveOverrideCandidates({
comments,
expectedSha,
newerThan,
})) {
if (isSecurityMember(candidate.login)) {
return candidate;
}
}
return null;
}
export async function findSecuritySensitiveOverrideCommandAsync(input) {
for (const candidate of securitySensitiveOverrideCandidates(input)) {
if (await input.isSecurityMember(candidate.login)) {
return candidate;
}
}
return null;
}
function isCommentNewerThan(comment, newerThan) {
if (!newerThan) {
return false;
}
const commentTime = Date.parse(comment.created_at ?? "");
const barrierTime = Date.parse(newerThan);
return Number.isFinite(commentTime) && Number.isFinite(barrierTime) && commentTime > barrierTime;
}
export function securitySensitiveGuardCommentHeadSha(comment) {
const body = comment?.body ?? "";
const patterns = [
/Approved SHA:\s+`([a-f0-9]{40})`/iu,
/current head SHA\s+\(`([a-f0-9]{40})`\)/iu,
/Current SHA:\s+`([a-f0-9]{40})`/iu,
];
for (const pattern of patterns) {
const match = body.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
}
export function securitySensitiveOverrideExpectedSha(existingGuardComment, currentHeadSha) {
if (
!currentHeadSha ||
existingGuardComment?.body?.includes("### Security-sensitive changes are blocked") !== true
) {
return null;
}
return securitySensitiveGuardCommentHeadSha(existingGuardComment) === currentHeadSha
? currentHeadSha
: null;
}
export function isSecuritySensitiveGuardAuthorizedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Security-sensitive change authorized") === true &&
securitySensitiveGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function isSecuritySensitiveGuardTrustedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Security-sensitive changes noted") === true &&
securitySensitiveGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function securitySensitiveGuardCommentAuthors(value) {
return new Set(
String(value ?? "github-actions[bot]")
.split(/[\s,]+/u)
.map((login) => login.trim().toLowerCase())
.filter(Boolean),
);
}
export function isSecuritySensitiveGuardMarkerComment(comment, trustedAuthors) {
const login = comment.user?.login?.toLowerCase();
return Boolean(
login && trustedAuthors.has(login) && comment.body?.includes(securitySensitiveGuardMarker),
);
}
function sortedSecuritySensitiveChanges(filenames) {
const byPath = new Map();
for (const filename of filenames) {
if (typeof filename !== "string") {
continue;
}
const definition = securitySensitiveFileDefinition(filename);
if (definition) {
byPath.set(definition.path, definition);
}
}
return [...byPath.values()].toSorted((left, right) => left.path.localeCompare(right.path));
}
export function collectSecuritySensitiveChanges(files) {
const filenames = [];
for (const file of files) {
if (typeof file === "string") {
filenames.push(file);
continue;
}
if (file && typeof file === "object") {
filenames.push(file.filename, file.previous_filename);
}
}
return sortedSecuritySensitiveChanges(filenames);
}
function renderChangedFileLines(changes) {
const listedFiles = changes.slice(0, maxListedFiles);
const omittedCount = changes.length - listedFiles.length;
const lines = listedFiles.map(
(change) => `- ${markdownCode(change.path)}: ${sanitizeDisplayValue(change.reason)}`,
);
if (omittedCount > 0) {
lines.push(`- ${omittedCount} additional security-sensitive files not shown`);
}
return lines;
}
export function renderSecuritySensitiveAwarenessComment(changes) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive file changes detected",
"",
"This PR changes files that define security boundaries. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...renderChangedFileLines(changes),
"",
"Maintainer follow-up:",
"- Review whether each security-sensitive file change is intentional.",
"- Confirm the change does not weaken secret, credential, or local-state protection.",
"- If this PR intentionally needs the change, a repository admin or member of `@openclaw/openclaw-secops` must approve the exact head SHA.",
].join("\n");
}
export function renderAuthorizedSecuritySensitiveComment(override) {
const lines = [
securitySensitiveGuardMarker,
"",
"### Security-sensitive change authorized",
"",
"This PR includes security-sensitive file changes. A repository admin or member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-security-sensitive-change`.",
"",
`- Approved SHA: ${markdownCode(override.sha)}`,
`- Approved by: @${sanitizeDisplayValue(override.login)}`,
];
if (override.reason) {
lines.push(`- Reason: ${markdownCode(override.reason)}`);
}
lines.push("", "A later push changes the PR head SHA and requires a fresh security approval.");
return lines.join("\n");
}
export function renderTrustedSecuritySensitiveComment({ actor, headSha, changes }) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive changes noted",
"",
"This PR includes security-sensitive file changes. The guard is informational because the PR author is a repository admin or a member of `@openclaw/openclaw-secops`.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
`- Trusted actor: @${sanitizeDisplayValue(actor.login)}`,
`- Trusted role: ${markdownCode(actor.reason)}`,
"",
"Changed files:",
...renderChangedFileLines(changes),
"",
"Security review is still recommended before merge when the change is intentional.",
].join("\n");
}
export function renderClearedSecuritySensitiveGuardComment({ headSha }) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive guard cleared",
"",
"This PR no longer has blocked security-sensitive file changes. A future security-sensitive change requires a fresh `/allow-security-sensitive-change` comment after the guard blocks that new head SHA.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
].join("\n");
}
export function renderBlockedSecuritySensitiveComment({ headSha, changes }) {
return [
securitySensitiveGuardMarker,
"",
"### Security-sensitive changes are blocked",
"",
"OpenClaw does not accept security-sensitive file changes through PRs unless a repository admin or security explicitly authorizes the current head SHA.",
"",
"Detected security-sensitive changes:",
...renderChangedFileLines(changes),
"",
"If this PR intentionally needs these changes, ask a repository admin or member of `@openclaw/openclaw-secops` to comment:",
"",
"```text",
allowSecuritySensitiveCommand,
"```",
"",
`The action will approve the current head SHA (${markdownCode(headSha ?? "<head-sha>")}) when it reruns. A later push requires a fresh approval.`,
].join("\n");
}
export function securitySensitiveGuardTrustedActorCandidates({
pullRequest,
event,
currentHeadSha,
}) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
const eventMatchesCurrentHead =
Boolean(currentHeadSha) &&
(eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha);
if (!eventMatchesCurrentHead) {
return [];
}
const candidates = [];
const seen = new Set();
for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) {
if (typeof login !== "string" || login.length === 0) {
continue;
}
const normalizedLogin = login.toLowerCase();
if (seen.has(normalizedLogin)) {
continue;
}
seen.add(normalizedLogin);
candidates.push({ login, source });
}
return candidates;
}
export async function findTrustedSecuritySensitiveGuardActor({
candidates,
isSecuritySensitiveApprover,
}) {
for (const candidate of candidates) {
const role = await isSecuritySensitiveApprover(candidate.login);
if (role) {
return {
login: candidate.login,
reason: `${candidate.source}; ${role}`,
};
}
}
return null;
}
function githubErrorBodyTooLarge(maxBytes) {
return new Error(`GitHub error response body exceeded ${maxBytes} bytes`);
}
export async function readBoundedGitHubErrorText(response, maxBytes = GITHUB_ERROR_BODY_MAX_BYTES) {
return await readBoundedResponseText(response, "GitHub error", maxBytes, {
createTooLargeError: () => githubErrorBodyTooLarge(maxBytes),
});
}
function timeoutError(path, method, timeoutMs) {
return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`);
}
function combineAbortSignals(signals) {
const activeSignals = signals.filter(Boolean);
if (activeSignals.length === 0) {
return undefined;
}
if (activeSignals.length === 1) {
return activeSignals[0];
}
return AbortSignal.any(activeSignals);
}
export function githubApi(token, options = {}) {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
const baseHeaders = {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "openclaw-security-sensitive-guard",
"x-github-api-version": "2022-11-28",
};
const request = async (path, requestOptions = {}) => {
const method = requestOptions.method ?? "GET";
const timeoutController = new AbortController();
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
timeoutController.abort();
reject(timeoutError(path, method, timeoutMs));
}, timeoutMs);
timeout.unref?.();
});
const operationPromise = (async () => {
const response = await fetchImpl(`https://api.github.com${path}`, {
...requestOptions,
signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]),
headers: { ...baseHeaders, ...requestOptions.headers },
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
let errorText;
try {
errorText = await readBoundedGitHubErrorText(response);
} catch (bodyError) {
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
}
const error = new Error(`${response.status} ${response.statusText}: ${errorText}`);
error.status = response.status;
throw error;
}
return response.json();
})();
operationPromise.catch(() => {});
try {
return await Promise.race([operationPromise, timeoutPromise]);
} finally {
clearTimeout(timeout);
}
};
return {
request,
paginate: async (path) => {
const items = [];
for (let page = 1; ; page += 1) {
const separator = path.includes("?") ? "&" : "?";
const pageItems = await request(`${path}${separator}per_page=100&page=${page}`);
items.push(...pageItems);
if (pageItems.length < 100) {
return items;
}
}
},
};
}
async function writeSummary(markdown) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) {
console.log(markdown);
return;
}
await appendFile(summaryPath, `${markdown}\n`);
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const eventPath = process.env.GITHUB_EVENT_PATH;
const repository = process.env.GITHUB_REPOSITORY;
if (!token || !eventPath || !repository) {
throw new Error("GITHUB_TOKEN, GITHUB_EVENT_PATH, and GITHUB_REPOSITORY are required.");
}
const [owner, repo] = repository.split("/");
const event = JSON.parse(await readFile(eventPath, "utf8"));
const eventPullRequest = event.pull_request;
if (!eventPullRequest) {
console.log("No pull_request payload found; skipping.");
return;
}
const api = githubApi(token);
const explicitSecurityApprovers = securityApproverSet(process.env.OPENCLAW_SECURITY_APPROVERS);
const trustedCommentAuthors = securitySensitiveGuardCommentAuthors(
process.env.OPENCLAW_SECURITY_SENSITIVE_GUARD_COMMENT_BOTS,
);
const issuePath = `/repos/${owner}/${repo}/issues/${eventPullRequest.number}`;
const pullPath = `/repos/${owner}/${repo}/pulls/${eventPullRequest.number}`;
const pullRequest = await api.request(pullPath);
const mode = process.env.OPENCLAW_SECURITY_SENSITIVE_GUARD_MODE ?? "enforce";
const files = await api.paginate(`${pullPath}/files`);
const securitySensitiveChanges = collectSecuritySensitiveChanges(files);
const [comments, labels] = await Promise.all([
api.paginate(`${issuePath}/comments`),
api.paginate(`${issuePath}/labels`),
]);
const existingGuardComment = comments.find((comment) =>
isSecuritySensitiveGuardMarkerComment(comment, trustedCommentAuthors),
);
const labelNames = new Set(labels.map((label) => label.name));
const ignoreUnavailableWritePermission = (action) => (error) => {
if (error?.status === 403) {
console.warn(`Skipping ${action}; token does not have write permission.`);
return;
}
if (error?.status === 404 || error?.status === 422) {
console.warn(`${action} is unavailable.`);
return;
}
throw error;
};
const removeLabelIfPresent = async (label) => {
if (!labelNames.has(label)) {
return;
}
await api
.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
method: "DELETE",
})
.catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
labelNames.delete(label);
};
const addLabelIfMissing = async (label) => {
if (labelNames.has(label)) {
return;
}
await api
.request(`${issuePath}/labels`, {
method: "POST",
body: JSON.stringify({ labels: [label] }),
})
.catch(ignoreUnavailableWritePermission(`label "${label}" update`));
labelNames.add(label);
};
const upsertComment = async (comment, body) => {
if (comment) {
return await api
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body }),
})
.catch(ignoreUnavailableWritePermission("comment update"));
}
return await api
.request(`${issuePath}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
})
.catch(ignoreUnavailableWritePermission("comment creation"));
};
if (securitySensitiveChanges.length === 0) {
await removeLabelIfPresent(securitySensitiveChangedLabel);
if (existingGuardComment) {
await upsertComment(
existingGuardComment,
renderClearedSecuritySensitiveGuardComment({ headSha: pullRequest.head?.sha }),
);
}
await writeSummary(
"## Security Sensitive Guard\n\nNo security-sensitive file changes detected.",
);
console.log("No security-sensitive file changes detected.");
return;
}
await addLabelIfMissing(securitySensitiveChangedLabel);
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Detected ${securitySensitiveChanges.length} security-sensitive file change(s).`,
"",
...securitySensitiveChanges.map((change) => `- ${markdownCode(change.path)}`),
].join("\n"),
);
console.log(`Detected ${securitySensitiveChanges.length} security-sensitive file change(s).`);
const membershipCache = new Map();
const permissionCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.has(normalizedLogin)) {
return true;
}
if (membershipCache.has(normalizedLogin)) {
return membershipCache.get(normalizedLogin);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(normalizedLogin, false);
return false;
}
};
const isRepositoryAdmin = async (login) => {
const normalizedLogin = login.toLowerCase();
if (permissionCache.has(normalizedLogin)) {
return permissionCache.get(normalizedLogin);
}
try {
const result = await api.request(
`/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
);
const allowed = result?.permission === "admin";
permissionCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify repository permission for ${login}: ${error.message}`);
}
permissionCache.set(normalizedLogin, false);
return false;
}
};
const isSecuritySensitiveApprover = async (login) => {
if (await isSecurityMember(login)) {
return securityTeamSlug;
}
if (await isRepositoryAdmin(login)) {
return "repository admin";
}
return null;
};
const currentHeadSha = pullRequest.head?.sha;
if (isSecuritySensitiveGuardTrustedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes remain informational for a trusted actor at ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Security-sensitive changes remain informational for this head SHA.");
return;
}
const trustedActor = await findTrustedSecuritySensitiveGuardActor({
candidates: securitySensitiveGuardTrustedActorCandidates({
pullRequest,
event,
currentHeadSha,
}),
isSecuritySensitiveApprover,
});
if (trustedActor) {
await upsertComment(
existingGuardComment,
renderTrustedSecuritySensitiveComment({
actor: trustedActor,
changes: securitySensitiveChanges,
headSha: currentHeadSha,
}),
);
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes noted for trusted actor @${sanitizeDisplayValue(trustedActor.login)} and allowed to continue.`,
].join("\n"),
);
console.log("Security-sensitive changes noted for trusted actor; guard is informational.");
return;
}
if (isSecuritySensitiveGuardAuthorizedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes remain authorized for ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Security-sensitive changes remain authorized for this head SHA.");
return;
}
const override = await findSecuritySensitiveOverrideCommandAsync({
comments,
expectedSha: securitySensitiveOverrideExpectedSha(existingGuardComment, currentHeadSha),
isSecurityMember: async (login) => Boolean(await isSecuritySensitiveApprover(login)),
newerThan: existingGuardComment?.updated_at ?? existingGuardComment?.created_at,
});
if (override) {
await upsertComment(existingGuardComment, renderAuthorizedSecuritySensitiveComment(override));
await writeSummary(
[
"## Security Sensitive Guard",
"",
`Security-sensitive changes authorized by @${sanitizeDisplayValue(override.login)} for ${markdownCode(override.sha)}.`,
].join("\n"),
);
console.log("Security-sensitive changes authorized by trusted override.");
return;
}
if (mode === "detect") {
await upsertComment(
existingGuardComment,
renderSecuritySensitiveAwarenessComment(securitySensitiveChanges),
);
await writeSummary(
"## Security Sensitive Guard\n\nSecurity-sensitive enforcement deferred to the final guard job.",
);
console.log("Security-sensitive enforcement deferred to the final guard job.");
return;
}
await upsertComment(
existingGuardComment,
renderBlockedSecuritySensitiveComment({
changes: securitySensitiveChanges,
headSha: pullRequest.head?.sha,
}),
);
await writeSummary(
"## Security Sensitive Guard\n\nSecurity-sensitive changes are blocked without a current admin or secops override.",
);
throw new Error(
"Security-sensitive changes require removal or a current admin or secops override.",
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(
/** @param {unknown} error */ (error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
},
);
}

View File

@@ -42,8 +42,7 @@ run_logged_print_heartbeat() {
fi
local log_file
log_file="$(docker_e2e_run_log "$label")"
"$@" >"$log_file" 2>&1 &
local command_pid=$!
local command_pid=""
local cleanup_done=0
local previous_int_trap
local previous_term_trap
@@ -52,6 +51,9 @@ run_logged_print_heartbeat() {
previous_term_trap="$(trap -p TERM || true)"
previous_hup_trap="$(trap -p HUP || true)"
terminate_heartbeat_command() {
if [ -z "$command_pid" ]; then
return 0
fi
kill -TERM "$command_pid" 2>/dev/null || true
local grace_seconds="${OPENCLAW_DOCKER_E2E_HEARTBEAT_TERM_GRACE_SECONDS:-30}"
if ! [[ "$grace_seconds" =~ ^[0-9]+$ ]] || [ "$grace_seconds" -lt 1 ]; then
@@ -106,6 +108,8 @@ run_logged_print_heartbeat() {
trap 'cleanup_heartbeat_command 130' INT
trap 'cleanup_heartbeat_command 143' TERM
trap 'cleanup_heartbeat_command 129' HUP
"$@" >"$log_file" 2>&1 &
command_pid=$!
local started_at="$SECONDS"
local next_heartbeat=$interval_seconds
local status=0

View File

@@ -87,7 +87,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"outbound-send-deps": 4,
"outbound-runtime": 16,
"file-access-runtime": 2,
"infra-runtime": 570,
"infra-runtime": 577,
"ssrf-policy": 1,
"ssrf-runtime": 1,
"media-runtime": 2,
@@ -161,11 +161,11 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 319),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10277),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10284),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5163),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3230,
3237,
),
publicWildcardReexports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",

View File

@@ -5,10 +5,7 @@ run_prepare_push_retry_gates() {
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
if [ "$docs_only" != "true" ]; then
run_quiet_logged \
"pnpm test:changed (lease-retry)" \
".local/lease-retry-test.log" \
env OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
fi
}
@@ -79,13 +76,15 @@ prepare_gates() {
local current_head
current_head=$(git rev-parse HEAD)
local previous_last_verified_head=""
local previous_full_gates_head=""
if [ -s .local/gates.env ]; then
# shellcheck disable=SC1091
source .local/gates.env
previous_last_verified_head="${LAST_VERIFIED_HEAD_SHA:-}"
previous_full_gates_head="${FULL_GATES_HEAD_SHA:-}"
fi
local gates_mode="changed"
local gates_mode="full"
local reuse_gates=false
if [ "$docs_only" = "true" ] && [ -n "$previous_last_verified_head" ] && git merge-base --is-ancestor "$previous_last_verified_head" HEAD 2>/dev/null; then
local delta_since_verified
@@ -104,14 +103,20 @@ prepare_gates() {
if [ "$docs_only" = "true" ]; then
gates_mode="docs_only"
echo "Docs-only change detected with high confidence; skipping pnpm test:changed."
echo "Docs-only change detected with high confidence; skipping pnpm test."
else
gates_mode="changed"
echo "Running pnpm test:changed with broad fallback for Vitest coverage."
run_quiet_logged \
"pnpm test:changed" \
".local/gates-test.log" \
env OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
gates_mode="full"
if [ -n "${OPENCLAW_VITEST_MAX_WORKERS:-}" ]; then
echo "Running pnpm test with OPENCLAW_VITEST_MAX_WORKERS=$OPENCLAW_VITEST_MAX_WORKERS."
run_quiet_logged \
"pnpm test" \
".local/gates-test.log" \
env OPENCLAW_VITEST_MAX_WORKERS="$OPENCLAW_VITEST_MAX_WORKERS" pnpm test
else
echo "Running pnpm test with host-aware scheduling defaults."
run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test
fi
previous_full_gates_head="$current_head"
fi
fi
@@ -122,6 +127,7 @@ prepare_gates() {
CHANGELOG_REQUIRED "$changelog_required" \
GATES_MODE "$gates_mode" \
LAST_VERIFIED_HEAD_SHA "$current_head" \
FULL_GATES_HEAD_SHA "${previous_full_gates_head:-}" \
GATES_PASSED_AT "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> .local/gates.env

View File

@@ -1441,12 +1441,6 @@ function assertSmoke(params: {
failures.push(`missing required metric ${name}`);
}
}
const rawLogBodies = params.logRecords
.map((record) => record.body)
.filter((body) => body !== "log");
if (rawLogBodies.length > 0) {
failures.push(`OTLP log records exported ${rawLogBodies.length} non-placeholder bodies`);
}
const correlatedLogRecords = params.logRecords.filter(
(record) => record.traceId && record.spanId,
);

View File

@@ -138,6 +138,7 @@ const EXACT_COLORS = new Map(
"dedupe:child": COLORS.paleBlue,
dependencies: COLORS.neutralGray,
"dependencies-changed": COLORS.paleYellow,
"security-sensitive-changed": COLORS.paleYellow,
github_actions: COLORS.neutralGray,
go: COLORS.neutralGray,
java: COLORS.neutralGray,

View File

@@ -401,6 +401,10 @@ const BROAD_ONLY_TEST_HELPERS = new Set(["test/helpers/poll.ts"]);
const TOOLING_SOURCE_TEST_TARGETS = new Map([
[".crabbox.yaml", ["test/scripts/package-acceptance-workflow.test.ts"]],
[".github/workflows/ci.yml", ["test/scripts/ci-workflow-guards.test.ts"]],
[
".github/workflows/security-sensitive-guard.yml",
["test/scripts/security-sensitive-guard-workflow.test.ts"],
],
[
".github/workflows/ci-check-testbox.yml",
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
@@ -504,6 +508,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
],
["scripts/dependency-changes-report.mjs", ["test/scripts/dependency-changes-report.test.ts"]],
[
"scripts/github/security-sensitive-guard.mjs",
["test/scripts/security-sensitive-guard-script.test.ts"],
],
[
"scripts/dependency-ownership-surface-report.mjs",
["test/scripts/dependency-ownership-surface-report.test.ts"],

View File

@@ -5,7 +5,6 @@ import { afterEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
createFileAcpEventLedger,
createInMemoryAcpEventLedger,
createSqliteAcpEventLedger,
migrateFileAcpEventLedgerToSqlite,
@@ -117,42 +116,6 @@ describe("ACP event ledger", () => {
});
});
it("persists file-backed replay state across ledger instances", async () => {
await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => {
const filePath = path.join(dir, "acp", "event-ledger.json");
const first = createFileAcpEventLedger({ filePath, now: () => 1000 });
await first.startSession({
sessionId: "session-1",
sessionKey: "agent:main:work",
cwd: "/work",
complete: true,
});
await first.recordUpdate({
sessionId: "session-1",
sessionKey: "agent:main:work",
runId: "run-1",
update: {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thinking" },
},
});
const second = createFileAcpEventLedger({ filePath });
const replay = await second.readReplay({
sessionId: "session-1",
sessionKey: "agent:main:work",
});
expect(replay.complete).toBe(true);
expect(replay.events).toHaveLength(1);
expect(replay.events[0]?.update).toEqual({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thinking" },
});
await expect(fs.readFile(filePath, "utf8")).resolves.toContain('"version":1');
});
});
it("persists SQLite-backed replay state across ledger instances", async () => {
await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => {
const databasePath = path.join(dir, "openclaw.sqlite");
@@ -193,22 +156,38 @@ describe("ACP event ledger", () => {
await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => {
const filePath = path.join(dir, "acp", "event-ledger.json");
const databasePath = path.join(dir, "openclaw.sqlite");
const legacy = createFileAcpEventLedger({ filePath, now: () => 1000 });
await legacy.startSession({
sessionId: "session-1",
sessionKey: "agent:main:work",
cwd: "/work",
complete: true,
});
await legacy.recordUpdate({
sessionId: "session-1",
sessionKey: "agent:main:work",
runId: "run-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Answer" },
},
});
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(
filePath,
JSON.stringify({
version: 1,
sessions: {
"session-1": {
sessionId: "session-1",
sessionKey: "agent:main:work",
cwd: "/work",
complete: true,
createdAt: 1000,
updatedAt: 1000,
nextSeq: 2,
events: [
{
seq: 1,
at: 1000,
sessionId: "session-1",
sessionKey: "agent:main:work",
runId: "run-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Answer" },
},
},
],
},
},
}),
"utf8",
);
const migrated = await migrateFileAcpEventLedgerToSqlite({
filePath,
@@ -449,81 +428,4 @@ describe("ACP event ledger", () => {
).resolves.toEqual({ complete: false, events: [] });
});
it("keeps the persisted ledger file under the serialized byte budget", async () => {
await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => {
const filePath = path.join(dir, "acp", "event-ledger.json");
const ledger = createFileAcpEventLedger({ filePath, maxSerializedBytes: 1024 });
await ledger.startSession({
sessionId: "session-1",
sessionKey: "agent:main:work",
cwd: "/work",
complete: true,
});
await ledger.recordUpdate({
sessionId: "session-1",
sessionKey: "agent:main:work",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
rawOutput: { content: "x".repeat(5_000) },
},
});
const bytes = Buffer.byteLength(await fs.readFile(filePath, "utf8"), "utf8");
expect(bytes).toBeLessThanOrEqual(1024);
await expect(
ledger.readReplay({ sessionId: "session-1", sessionKey: "agent:main:work" }),
).resolves.toEqual({ complete: false, events: [] });
});
});
it("ignores corrupt ledger files instead of replaying unknown state", async () => {
await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => {
const filePath = path.join(dir, "event-ledger.json");
await fs.writeFile(filePath, "{bad json", "utf8");
const ledger = createFileAcpEventLedger({ filePath });
await expect(
ledger.readReplay({ sessionId: "session-1", sessionKey: "agent:main:work" }),
).resolves.toEqual({ complete: false, events: [] });
});
});
it("reloads file-backed state under lock before writing", async () => {
await withTempDir({ prefix: "openclaw-acp-ledger-" }, async (dir) => {
const filePath = path.join(dir, "acp", "event-ledger.json");
const first = createFileAcpEventLedger({ filePath });
const second = createFileAcpEventLedger({ filePath });
await first.startSession({
sessionId: "session-1",
sessionKey: "acp:gateway-session-1",
cwd: "/work",
complete: true,
});
await second.startSession({
sessionId: "session-2",
sessionKey: "acp:gateway-session-2",
cwd: "/work",
complete: true,
});
await first.recordUpdate({
sessionId: "session-1",
sessionKey: "acp:gateway-session-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Answer" },
},
});
const reader = createFileAcpEventLedger({ filePath });
const replay = await reader.readReplay({
sessionId: "session-2",
sessionKey: "acp:gateway-session-2",
});
expect(replay.complete).toBe(true);
expect(replay.sessionKey).toBe("acp:gateway-session-2");
});
});
});

View File

@@ -6,7 +6,7 @@ import type { ContentBlock, SessionUpdate } from "@agentclientprotocol/sdk";
import { resolveIntegerOption } from "@openclaw/acp-core/numeric-options";
import { resolveStateDir } from "../config/paths.js";
import { withFileLock } from "../infra/file-lock.js";
import { readJsonFile, writeTextAtomic } from "../infra/json-files.js";
import { readJsonFile } from "../infra/json-files.js";
import {
openOpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
@@ -455,58 +455,6 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
/** Creates a file-backed ACP event ledger protected by a process/file lock. */
export function createFileAcpEventLedger(
params: { filePath: string } & LedgerOptions,
): AcpEventLedger {
const normalized = normalizeLedgerOptions(params);
const state: MutableLedgerState = {
store: createEmptyStore(),
...normalized,
};
let operation = Promise.resolve();
const load = async () => {
state.store = normalizeStore(await readJsonFile(params.filePath));
};
const ensureParentDir = async () => {
await fs.mkdir(path.dirname(params.filePath), { recursive: true, mode: 0o700 });
};
const enqueue = async <T>(fn: () => Promise<T>): Promise<T> => {
const task = operation.then(fn, fn);
operation = task.then(
() => {},
() => {},
);
return task;
};
return createLedgerApi({
state,
mutate: async (fn) =>
enqueue(async () => {
await ensureParentDir();
await withFileLock(params.filePath, FILE_LEDGER_LOCK_OPTIONS, async () => {
await load();
fn();
await writeTextAtomic(params.filePath, serializeLedgerStore(state.store), {
mode: 0o600,
dirMode: 0o700,
});
});
}),
read: async (fn) =>
enqueue(async () => {
await ensureParentDir();
return await withFileLock(params.filePath, FILE_LEDGER_LOCK_OPTIONS, async () => {
await load();
return fn();
});
}),
});
}
/** Migrates a legacy file ledger into the SQLite state database, preserving replay order. */
export async function migrateFileAcpEventLedgerToSqlite(
params: { filePath: string; archiveSource?: boolean } & OpenClawStateDatabaseOptions,

View File

@@ -1,42 +1,31 @@
/** Tests Gateway exec approval to ACP permission relay helpers. */
import { describe, expect, it } from "vitest";
import {
buildAcpPermissionOptions,
buildAcpPermissionRequest,
normalizeGatewayExecApprovalDecisions,
parseGatewayExecApprovalEventData,
parseGatewayExecApprovalRequestEventPayload,
resolveGatewayDecisionFromPermissionOutcome,
} from "./permission-relay.js";
describe("ACP permission relay helpers", () => {
it("maps Gateway exec approval decisions to ACP permission options", () => {
expect(buildAcpPermissionOptions(["allow-once", "allow-always", "deny"])).toEqual([
{
optionId: "allow-once",
name: "Allow once",
kind: "allow_once",
},
{
optionId: "allow-always",
name: "Allow always",
kind: "allow_always",
},
{
optionId: "deny",
name: "Deny",
kind: "reject_once",
},
]);
});
function buildOptionsForAllowedDecisions(allowedDecisions: unknown) {
return buildAcpPermissionRequest({
sessionId: "session-1",
event: {
approvalId: "approval-1",
command: "echo ok",
},
details: { allowedDecisions },
}).options;
}
describe("ACP permission relay helpers", () => {
it("filters unknown decisions and falls back to allow-once plus deny", () => {
expect(normalizeGatewayExecApprovalDecisions(["allow-once", "bogus", "deny"])).toEqual([
"allow-once",
"deny",
]);
expect(normalizeGatewayExecApprovalDecisions(["bogus"])).toEqual(["allow-once", "deny"]);
expect(normalizeGatewayExecApprovalDecisions(undefined)).toEqual(["allow-once", "deny"]);
const optionIds = (allowedDecisions: unknown) =>
buildOptionsForAllowedDecisions(allowedDecisions).map((option) => option.optionId);
expect(optionIds(["allow-once", "bogus", "deny"])).toEqual(["allow-once", "deny"]);
expect(optionIds(["bogus"])).toEqual(["allow-once", "deny"]);
expect(optionIds(undefined)).toEqual(["allow-once", "deny"]);
});
it("builds a request_permission payload from Gateway approval data", () => {
@@ -132,7 +121,7 @@ describe("ACP permission relay helpers", () => {
});
it("maps selected ACP outcomes back to Gateway decisions", () => {
const options = buildAcpPermissionOptions(["allow-once", "allow-always", "deny"]);
const options = buildOptionsForAllowedDecisions(["allow-once", "allow-always", "deny"]);
expect(
resolveGatewayDecisionFromPermissionOutcome(

View File

@@ -35,9 +35,7 @@ function normalizeGatewayExecApprovalDecision(
}
/** Normalizes allowed Gateway exec approval decisions with a conservative fallback set. */
export function normalizeGatewayExecApprovalDecisions(
value: unknown,
): GatewayExecApprovalDecision[] {
function normalizeGatewayExecApprovalDecisions(value: unknown): GatewayExecApprovalDecision[] {
const normalized = Array.isArray(value)
? value
.map(normalizeGatewayExecApprovalDecision)
@@ -47,7 +45,7 @@ export function normalizeGatewayExecApprovalDecisions(
}
/** Converts Gateway exec decisions into ACP permission options. */
export function buildAcpPermissionOptions(
function buildAcpPermissionOptions(
decisions: readonly GatewayExecApprovalDecision[],
): PermissionOption[] {
const unique = new Set<GatewayExecApprovalDecision>(decisions);

View File

@@ -2,13 +2,10 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
isAcpAgentAllowedByPolicy,
isAcpDispatchEnabledByPolicy,
isAcpEnabledByPolicy,
resolveAcpAgentPolicyError,
resolveAcpDispatchPolicyError,
resolveAcpDispatchPolicyMessage,
resolveAcpDispatchPolicyState,
resolveAcpExplicitTurnPolicyError,
} from "./policy.js";
@@ -16,8 +13,8 @@ describe("acp policy", () => {
it("treats ACP + ACP dispatch as enabled by default", () => {
const cfg = {} satisfies OpenClawConfig;
expect(isAcpEnabledByPolicy(cfg)).toBe(true);
expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(true);
expect(resolveAcpDispatchPolicyState(cfg)).toBe("enabled");
expect(resolveAcpDispatchPolicyMessage(cfg)).toBeNull();
expect(resolveAcpDispatchPolicyError(cfg)).toBeNull();
});
it("reports ACP disabled state when acp.enabled is false", () => {
@@ -27,7 +24,6 @@ describe("acp policy", () => {
},
} satisfies OpenClawConfig;
expect(isAcpEnabledByPolicy(cfg)).toBe(false);
expect(resolveAcpDispatchPolicyState(cfg)).toBe("acp_disabled");
expect(resolveAcpDispatchPolicyMessage(cfg)).toBe(
"ACP is disabled by policy (`acp.enabled=false`).",
);
@@ -43,8 +39,6 @@ describe("acp policy", () => {
},
},
} satisfies OpenClawConfig;
expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false);
expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled");
expect(resolveAcpDispatchPolicyMessage(cfg)).toBe(
"ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`).",
);
@@ -83,11 +77,9 @@ describe("acp policy", () => {
allowedAgents: ["Codex", "claude-code", "kimi"],
},
} satisfies OpenClawConfig;
expect(isAcpAgentAllowedByPolicy(cfg, "codex")).toBe(true);
expect(isAcpAgentAllowedByPolicy(cfg, "claude-code")).toBe(true);
expect(isAcpAgentAllowedByPolicy(cfg, "KIMI")).toBe(true);
expect(isAcpAgentAllowedByPolicy(cfg, "gemini")).toBe(false);
expect(resolveAcpAgentPolicyError(cfg, "gemini")?.code).toBe("ACP_SESSION_INIT_FAILED");
expect(resolveAcpAgentPolicyError(cfg, "codex")).toBeNull();
expect(resolveAcpAgentPolicyError(cfg, "claude-code")).toBeNull();
expect(resolveAcpAgentPolicyError(cfg, "KIMI")).toBeNull();
expect(resolveAcpAgentPolicyError(cfg, "gemini")?.code).toBe("ACP_SESSION_INIT_FAILED");
});
});

View File

@@ -15,7 +15,7 @@ export function isAcpEnabledByPolicy(cfg: OpenClawConfig): boolean {
}
/** Resolves the effective dispatch policy state for inbound ACP routing. */
export function resolveAcpDispatchPolicyState(cfg: OpenClawConfig): AcpDispatchPolicyState {
function resolveAcpDispatchPolicyState(cfg: OpenClawConfig): AcpDispatchPolicyState {
if (!isAcpEnabledByPolicy(cfg)) {
return "acp_disabled";
}
@@ -26,11 +26,6 @@ export function resolveAcpDispatchPolicyState(cfg: OpenClawConfig): AcpDispatchP
return "enabled";
}
/** Returns whether inbound ACP dispatch is currently allowed. */
export function isAcpDispatchEnabledByPolicy(cfg: OpenClawConfig): boolean {
return resolveAcpDispatchPolicyState(cfg) === "enabled";
}
/** Returns the operator-facing dispatch block message, if any. */
export function resolveAcpDispatchPolicyMessage(cfg: OpenClawConfig): string | null {
const state = resolveAcpDispatchPolicyState(cfg);
@@ -61,7 +56,7 @@ export function resolveAcpExplicitTurnPolicyError(cfg: OpenClawConfig): AcpRunti
}
/** Returns whether an agent id passes the optional ACP allowed-agent list. */
export function isAcpAgentAllowedByPolicy(cfg: OpenClawConfig, agentId: string): boolean {
function isAcpAgentAllowedByPolicy(cfg: OpenClawConfig, agentId: string): boolean {
const allowed = (cfg.acp?.allowedAgents ?? [])
.map((entry) => normalizeAgentId(entry))
.filter(Boolean);

View File

@@ -7,8 +7,6 @@ import {
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
isSilentOverflowProneModel,
resolveEffectiveCompactionMode,
resolveCompactionReserveTokensFloor,
shouldDisableAgentAutoCompaction,
} from "./agent-settings.js";
describe("applyAgentCompactionSettingsFromConfig", () => {
@@ -332,26 +330,6 @@ describe("applyAgentCompactionSettingsFromConfig", () => {
});
});
describe("resolveCompactionReserveTokensFloor", () => {
it("returns the default when config is missing", () => {
expect(resolveCompactionReserveTokensFloor()).toBe(
DEFAULT_AGENT_COMPACTION_RESERVE_TOKENS_FLOOR,
);
});
it("accepts configured floors, including zero", () => {
expect(
resolveCompactionReserveTokensFloor({
agents: { defaults: { compaction: { reserveTokensFloor: 24_000 } } },
}),
).toBe(24_000);
expect(
resolveCompactionReserveTokensFloor({
agents: { defaults: { compaction: { reserveTokensFloor: 0 } } },
}),
).toBe(0);
});
});
describe("resolveEffectiveCompactionMode", () => {
it("defaults to default compaction mode", () => {
expect(resolveEffectiveCompactionMode()).toBe("default");
@@ -471,36 +449,6 @@ describe("isSilentOverflowProneModel", () => {
});
});
describe("shouldDisableAgentAutoCompaction", () => {
it("returns false with no owner, default mode, and ordinary provider behavior", () => {
expect(shouldDisableAgentAutoCompaction({})).toBe(false);
expect(shouldDisableAgentAutoCompaction({ compactionMode: "default" })).toBe(false);
expect(
shouldDisableAgentAutoCompaction({
contextEngineInfo: { id: "legacy", name: "Legacy", ownsCompaction: false },
compactionMode: "default",
silentOverflowProneProvider: false,
}),
).toBe(false);
});
it("returns true when a context engine owns compaction", () => {
expect(
shouldDisableAgentAutoCompaction({
contextEngineInfo: { id: "third-party", name: "Third-party", ownsCompaction: true },
}),
).toBe(true);
});
it("returns true when effective compaction mode is safeguard", () => {
expect(shouldDisableAgentAutoCompaction({ compactionMode: "safeguard" })).toBe(true);
});
it("returns true for silent-overflow-prone providers", () => {
expect(shouldDisableAgentAutoCompaction({ silentOverflowProneProvider: true })).toBe(true);
});
});
describe("applyAgentAutoCompactionGuard", () => {
// Direct repro of openclaw#75799: shared model runtime's silent-overflow detection misfires
// on a successful turn against z.ai-style providers, triggering OpenClaw runtime's

View File

@@ -21,7 +21,7 @@ type AgentSettingsManagerLike = {
};
/** Resolves the configured reserve-token floor for agent compaction. */
export function resolveCompactionReserveTokensFloor(cfg?: OpenClawConfig): number {
function resolveCompactionReserveTokensFloor(cfg?: OpenClawConfig): number {
const raw = cfg?.agents?.defaults?.compaction?.reserveTokensFloor;
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
return Math.floor(raw);
@@ -165,7 +165,7 @@ export function isSilentOverflowProneModel(model: {
* Default-mode runs against ordinary providers keep OpenClaw runtime's auto-compaction as
* the existing baseline.
*/
export function shouldDisableAgentAutoCompaction(params: {
function shouldDisableAgentAutoCompaction(params: {
contextEngineInfo?: ContextEngineInfo;
compactionMode?: AgentCompactionMode;
silentOverflowProneProvider?: boolean;

View File

@@ -4,10 +4,7 @@
* round-robin ordering semantics.
*/
import { describe, expect, it, vi } from "vitest";
import {
ANTHROPIC_CFG,
ANTHROPIC_STORE,
} from "./auth-profiles.resolve-auth-profile-order.fixtures.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
@@ -42,6 +39,31 @@ function makeApiKeyProfilesByProviderProvider(
);
}
const ANTHROPIC_STORE = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
} satisfies AuthProfileStore;
const ANTHROPIC_CFG = {
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
},
} satisfies OpenClawConfig;
describe("resolveAuthProfileOrder", () => {
const store = ANTHROPIC_STORE;
const cfg = ANTHROPIC_CFG;

View File

@@ -1,32 +0,0 @@
/**
* Shared auth profile ordering fixtures.
* Keeps provider/profile config fixtures aligned across ordering regression
* tests without coupling them to production store loading.
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AuthProfileStore } from "./auth-profiles.js";
export const ANTHROPIC_STORE: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
};
export const ANTHROPIC_CFG: OpenClawConfig = {
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "api_key" },
"anthropic:work": { provider: "anthropic", mode: "api_key" },
},
},
};

View File

@@ -20,7 +20,6 @@ import {
} from "../infra/exec-approvals.js";
import { requestHeartbeat } from "../infra/heartbeat-wake.js";
import {
isDangerousHostInheritedEnvVarName,
sanitizeHostInheritedEnvEntry,
} from "../infra/host-env-security.js";
import { findPathKey, mergePathPrepend, removePathPrepend } from "../infra/path-prepend.js";
@@ -105,27 +104,6 @@ export function sanitizeHostBaseEnv(env: Record<string, string>): Record<string,
}
return sanitized;
}
/** Validates caller-provided host env, rejecting dangerous vars and PATH overrides. */
export function validateHostEnv(env: Record<string, string>): void {
for (const key of Object.keys(env)) {
const upperKey = key.toUpperCase();
// 1. Block known dangerous variables (Fail Closed)
if (isDangerousHostInheritedEnvVarName(upperKey)) {
throw new Error(
`Security Violation: Environment variable '${key}' is forbidden during host execution.`,
);
}
// 2. Strictly block PATH modification on host
// Allowing custom PATH on the gateway/node can lead to binary hijacking.
if (upperKey === "PATH") {
throw new Error(
"Security Violation: Custom 'PATH' variable is forbidden during host execution.",
);
}
}
}
/** Default retained aggregate output cap for exec sessions. */
export const DEFAULT_MAX_OUTPUT = clampWithDefault(
readEnvInt("OPENCLAW_BASH_MAX_OUTPUT_CHARS", "PI_BASH_MAX_OUTPUT_CHARS"),

View File

@@ -7,8 +7,6 @@ import {
buildBootstrapPromptWarning,
buildBootstrapPromptWarningNotice,
buildBootstrapTruncationReportMeta,
buildBootstrapTruncationSignature,
formatBootstrapTruncationWarningLines,
resolveBootstrapWarningSignaturesSeen,
} from "./bootstrap-budget.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
@@ -121,9 +119,10 @@ describe("bootstrap prompt warnings", () => {
});
(analysis.truncatedFiles[0] as { name?: string }).name = undefined;
const lines = formatBootstrapTruncationWarningLines({
const lines = buildBootstrapPromptWarning({
analysis,
});
mode: "always",
}).lines;
expect(lines.join("\n")).toContain("10 raw -> 1 injected");
});
@@ -345,10 +344,11 @@ describe("bootstrap prompt warnings", () => {
bootstrapMaxChars: 20,
bootstrapTotalMaxChars: 10,
});
const lines = formatBootstrapTruncationWarningLines({
const lines = buildBootstrapPromptWarning({
analysis,
mode: "always",
maxFiles: 2,
});
}).lines;
expect(lines).toContain("+1 more truncated file(s).");
});
@@ -367,7 +367,10 @@ describe("bootstrap prompt warnings", () => {
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const lines = formatBootstrapTruncationWarningLines({ analysis });
const lines = buildBootstrapPromptWarning({
analysis,
mode: "always",
}).lines;
expect(lines).toContain(
"AGENTS.md was truncated; read the full AGENTS.md before relying on scoped policy.",
@@ -397,9 +400,10 @@ describe("bootstrap prompt warnings", () => {
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 300,
});
const lines = formatBootstrapTruncationWarningLines({
const lines = buildBootstrapPromptWarning({
analysis,
});
mode: "always",
}).lines;
expect(lines.join("\n")).toContain("AGENTS.md (/tmp/a/AGENTS.md)");
expect(lines.join("\n")).toContain("AGENTS.md (/tmp/b/AGENTS.md)");
});
@@ -419,12 +423,15 @@ describe("bootstrap prompt warnings", () => {
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const signature = buildBootstrapTruncationSignature(analysis);
const seen = buildBootstrapPromptWarning({
analysis,
mode: "once",
});
const off = buildBootstrapPromptWarning({
analysis,
mode: "off",
seenSignatures: [signature ?? ""],
previousSignature: signature,
seenSignatures: seen.warningSignaturesSeen,
previousSignature: seen.signature,
});
expect(off.warningShown).toBe(false);
expect(off.lines).toStrictEqual([]);
@@ -432,8 +439,8 @@ describe("bootstrap prompt warnings", () => {
const always = buildBootstrapPromptWarning({
analysis,
mode: "always",
seenSignatures: [signature ?? ""],
previousSignature: signature,
seenSignatures: seen.warningSignaturesSeen,
previousSignature: seen.signature,
});
expect(always.warningShown).toBe(true);
expect(always.lines).toStrictEqual([
@@ -472,9 +479,9 @@ describe("bootstrap prompt warnings", () => {
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
expect(buildBootstrapTruncationSignature(left)).not.toBe(
buildBootstrapTruncationSignature(right),
);
const leftWarning = buildBootstrapPromptWarning({ analysis: left, mode: "once" });
const rightWarning = buildBootstrapPromptWarning({ analysis: right, mode: "once" });
expect(leftWarning.signature).not.toBe(rightWarning.signature);
});
it("builds truncation report metadata from analysis + warning decision", () => {

View File

@@ -233,7 +233,7 @@ export function analyzeBootstrapBudget(params: {
}
/** Builds a stable signature for once-per-truncation warning suppression. */
export function buildBootstrapTruncationSignature(
function buildBootstrapTruncationSignature(
analysis: BootstrapBudgetAnalysis,
): string | undefined {
if (!analysis.hasTruncation) {
@@ -267,7 +267,7 @@ export function buildBootstrapTruncationSignature(
}
/** Formats human-readable warning lines for the most important truncated files. */
export function formatBootstrapTruncationWarningLines(params: {
function formatBootstrapTruncationWarningLines(params: {
analysis: BootstrapBudgetAnalysis;
maxFiles?: number;
}): string[] {

View File

@@ -1291,10 +1291,3 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
senderIsOwner: params.senderIsOwner,
};
}
/** Runs the legacy Claude CLI wrapper through the generic CLI runner. */
export async function runClaudeCliAgent(
params: RunClaudeCliAgentParams,
): Promise<EmbeddedAgentRunResult> {
return runCliAgent(buildRunClaudeCliAgentParams(params));
}

View File

@@ -283,11 +283,6 @@ export function registerCodeModeNamespaceForPlugin(
registryState.registrations.set(normalized.id, normalized);
}
/** Removes one namespace registration by id. */
export function unregisterCodeModeNamespace(namespaceId: string): boolean {
return registryState.registrations.delete(namespaceId.trim());
}
/** Lists registered namespaces in deterministic id order. */
export function listCodeModeNamespaces(): RegisteredCodeModeNamespace[] {
return [...registryState.registrations.values()].toSorted((a, b) => a.id.localeCompare(b.id));

View File

@@ -65,22 +65,6 @@ const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
"Preserve all opaque identifiers exactly as written (no shortening or reconstruction), " +
"including UUIDs, hashes, IDs, hostnames, IPs, ports, URLs, and file names.";
const HANDOFF_INSTRUCTIONS = [
"Generate a concise recovery briefing for a new LLM taking over this session.",
"The previous model hit a quota limit and you are providing the context for a smooth handoff.",
"",
"LEADER HIERARCHY REINFORCEMENT:",
"- Explicitly state that the new model is the LEADER (Orchestrator).",
"- Identify any active autonomous units (like AutoClaw) as SUBORDINATES.",
"- Instruct the new model to NOT perform the subordinate's task, but to supervise and provide strategic commands.",
"",
"MUST CAPTURE:",
"- Current high-level goal and project path.",
"- Status of the latest tool executions (especially AutoClaw/Subagents).",
"- Critical files currently being modified.",
"- Pending items and next intended steps.",
].join("\n");
/** Optional instruction policy for preserving identifiers during compaction. */
export type CompactionSummarizationInstructions = {
identifierPolicy?: AgentCompactionIdentifierPolicy;
@@ -396,36 +380,6 @@ export async function summarizeInStages(params: {
});
}
/**
* Generates a concise handoff summary for model transitions, enforcing a 4000 token limit.
*/
export async function summarizeForHandoff(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
summarizationInstructions?: CompactionSummarizationInstructions;
}): Promise<string> {
const custom = params.customInstructions?.trim();
const handoffInstructions = custom
? `${HANDOFF_INSTRUCTIONS}\n\n${custom}`
: HANDOFF_INSTRUCTIONS;
// Use a hard cap of 4000 tokens for the handoff summary as per plan
const handoffMaxTokens = 4000;
return summarizeWithFallback({
...params,
reserveTokens: SUMMARIZATION_OVERHEAD_TOKENS,
maxChunkTokens: Math.min(params.maxChunkTokens, handoffMaxTokens),
customInstructions: handoffInstructions,
});
}
/** Resolves a positive context-window token count from model metadata. */
export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
const effective =

View File

@@ -1,7 +1,6 @@
/**
* Google/Gemini-specific embedded-agent runtime helpers.
*/
import { isGemma4ModelId } from "../../shared/google-models.js";
import { sanitizeGoogleTurnOrdering } from "./bootstrap.js";
/** Detects Google-owned embedded runtime APIs. */
@@ -9,11 +8,6 @@ export function isGoogleModelApi(api?: string | null): boolean {
return api === "google-gemini-cli" || api === "google-generative-ai";
}
/** Returns true for Gemma models whose reasoning payload must be stripped. */
export function isGemma4ModelRequiringReasoningStrip(modelId?: string | null): boolean {
return isGemma4ModelId(modelId);
}
// Re-exported from the helper barrel so Google-specific callers do not import
// bootstrap internals directly.
export { sanitizeGoogleTurnOrdering };

View File

@@ -529,7 +529,6 @@ export async function loadCompactHooksHarness(): Promise<{
applyAgentAutoCompactionGuard: vi.fn(() => ({ supported: true, disabled: false })),
applyAgentCompactionSettingsFromConfig: applyAgentCompactionSettingsFromConfigMock,
isSilentOverflowProneModel: vi.fn(() => false),
resolveCompactionReserveTokensFloor: vi.fn(() => 0),
}));
vi.doMock("../models-config.js", () => ({

View File

@@ -16,6 +16,12 @@ import {
resolveSessionCompactionCheckpointReason,
type CapturedCompactionCheckpointSnapshot,
} from "../../gateway/session-compaction-checkpoints.js";
import { resolveDiagnosticModelContentCapturePolicy } from "../../infra/diagnostic-llm-content.js";
import {
createDiagnosticTraceContext,
freezeDiagnosticTraceContext,
getActiveDiagnosticTraceContext,
} from "../../infra/diagnostic-trace-context.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { generateSecureToken } from "../../infra/secure-random.js";
@@ -161,6 +167,7 @@ import { readAgentModelContextTokens } from "./model-context-tokens.js";
import { resolveModelAsync } from "./model.js";
import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js";
import { createEmbeddedAgentResourceLoader } from "./resource-loader.js";
import { wrapStreamFnWithDiagnosticModelCallEvents } from "./run/attempt.model-diagnostic-events.js";
import { resolveAttemptSpawnWorkspaceDir } from "./run/attempt.thread-helpers.js";
import { buildEmbeddedSandboxInfo, resolveEmbeddedSandboxInfoExecPolicy } from "./sandbox-info.js";
import {
@@ -531,6 +538,16 @@ async function compactEmbeddedAgentSessionDirectOnce(
const attempt = params.attempt ?? 1;
const maxAttempts = params.maxAttempts ?? 1;
const runId = params.runId ?? params.sessionId;
// Parent compaction model-call spans to the active run/harness trace when one
// exists, otherwise start a fresh root. Compaction emits no intermediate span
// of its own (unlike the run lifecycle, which backs its run trace with a
// run.started span), so a child trace here would orphan the model call under a
// phantom parent. The :compaction: runId/callId already distinguishes the span.
const compactionModelCallTrace = freezeDiagnosticTraceContext(
getActiveDiagnosticTraceContext() ?? createDiagnosticTraceContext(),
);
const diagnosticCompactionRunId = `${runId}:compaction:${diagId}`;
let diagnosticModelCallSeq = 0;
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
ensureRuntimePluginsLoaded({
config: params.config,
@@ -1305,6 +1322,23 @@ async function compactEmbeddedAgentSessionDirectOnce(
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
session.agent.streamFn = wrapStreamFnWithDiagnosticModelCallEvents(
session.agent.streamFn,
{
runId: diagnosticCompactionRunId,
...(params.sessionKey && { sessionKey: params.sessionKey }),
sessionId: params.sessionId,
provider,
model: modelId,
api: effectiveModel.api,
transport: session.agent.transport,
contextTokenBudget,
trace: compactionModelCallTrace,
contentCapture: resolveDiagnosticModelContentCapturePolicy(params.config),
nextCallId: () =>
`${diagnosticCompactionRunId}:model:${(diagnosticModelCallSeq += 1)}`,
},
);
const prior = await sanitizeSessionHistory({
messages: session.messages,

View File

@@ -520,6 +520,135 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents", () => {
expect(events[1]?.privateData.modelContent?.outputMessages).toEqual([assistant]);
});
it("captures output and completes when callers only await stream.result()", async () => {
const assistant = {
role: "assistant",
content: [{ type: "text", text: "compaction summary" }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.4",
usage: { input: 11, output: 7, cacheRead: 0, cacheWrite: 0, totalTokens: 18 },
stopReason: "stop",
timestamp: 1,
};
const originalStream = {
[Symbol.asyncIterator]() {
return {
next() {
throw new Error("result-only callers should not need stream iteration");
},
};
},
result: vi.fn(async () => assistant),
};
const wrapped = wrapStreamFnWithDiagnosticModelCallEvents(
(() => originalStream) as unknown as StreamFn,
{
runId: "run-compact",
sessionKey: "session-key",
sessionId: "session-id",
provider: "openai",
model: "gpt-5.4",
trace: createDiagnosticTraceContext(),
contentCapture: {
inputMessages: true,
outputMessages: true,
toolInputs: false,
toolOutputs: false,
systemPrompt: true,
toolDefinitions: true,
anyModelContent: true,
},
nextCallId: () => "call-result-only",
},
);
const inputMessages = [{ role: "user", content: "summarize this transcript", timestamp: 1 }];
const events = await collectTrustedModelCallEvents(async () => {
const streamResult = wrapped(
{} as never,
{
systemPrompt: "summarize accurately",
messages: inputMessages,
} as never,
{},
) as unknown as typeof originalStream;
expect(await streamResult.result()).toBe(assistant);
});
expect(originalStream.result).toHaveBeenCalledOnce();
expect(events.map(({ event }) => event.type)).toEqual([
"model.call.started",
"model.call.completed",
]);
const completedEvent = getEvent(
events.map((entry) => entry.event),
1,
);
expect(completedEvent.type).toBe("model.call.completed");
expect(completedEvent.callId).toBe("call-result-only");
expect(completedEvent.responseStreamBytes).toBe(
Buffer.byteLength(JSON.stringify(assistant), "utf8"),
);
expect(events[1]?.privateData.modelContent?.inputMessages).toEqual(inputMessages);
expect(events[1]?.privateData.modelContent?.systemPrompt).toBe("summarize accurately");
expect(events[1]?.privateData.modelContent?.outputMessages).toEqual([assistant]);
});
it("closes the underlying iterator when result() completes before the consumer abandons it", async () => {
// Mirrors packages/agent-core/src/agent-loop.ts: iterate, await result() on
// the terminal event, then return (abandoning the iterator). The iterator's
// return() carries provider cleanup (idle-timeout abort listeners, readers),
// so it must still run even though result() emits the terminal event first.
let returnCalled = false;
const doneEvent = { type: "done", message: { role: "assistant", content: "ok" } };
const stream = {
[Symbol.asyncIterator]() {
let emitted = false;
return {
async next() {
if (!emitted) {
emitted = true;
return { value: doneEvent, done: false };
}
return { value: undefined, done: true };
},
async return() {
returnCalled = true;
return { value: undefined, done: true };
},
};
},
result: async () => doneEvent.message,
};
const wrapped = wrapStreamFnWithDiagnosticModelCallEvents(
(() => stream) as unknown as StreamFn,
{
runId: "run-cleanup",
provider: "openai",
model: "gpt-5.4",
trace: createDiagnosticTraceContext(),
nextCallId: () => "call-cleanup",
},
);
const events = await collectModelCallEvents(async () => {
const response = wrapped({} as never, {} as never, {} as never) as unknown as typeof stream;
for await (const event of response as AsyncIterable<{ type: string }>) {
if (event.type === "done") {
await (response as { result: () => Promise<unknown> }).result();
break;
}
}
});
expect(returnCalled).toBe(true);
expect(events.map((event) => event.type)).toEqual([
"model.call.started",
"model.call.completed",
]);
});
it("propagates the trusted model-call traceparent without mutating caller headers", async () => {
async function* stream() {
yield { type: "text", text: "ok" };

View File

@@ -85,6 +85,7 @@ type ModelCallObservationState = {
outputMessages?: unknown[];
contentCapture?: DiagnosticModelContentCapturePolicy;
lastStreamProgressAt?: number;
terminalEventEmitted?: boolean;
};
const MODEL_CALL_STREAM_PROGRESS_INTERVAL_MS = 30_000;
@@ -184,6 +185,23 @@ function observeOutputMessageContent(state: ModelCallObservationState, chunk: un
}
}
function observeResultMessageContent(
state: ModelCallObservationState,
startedAt: number,
result: unknown,
): void {
state.timeToFirstByteMs ??= Math.max(0, Date.now() - startedAt);
if (state.contentCapture?.outputMessages && state.outputMessages === undefined) {
state.outputMessages = [cloneDiagnosticContentValue(result)];
}
if (state.responseStreamBytes === 0) {
const bytes = utf8JsonByteLength(result);
if (bytes !== undefined) {
state.responseStreamBytes = bytes;
}
}
}
function observeResponseChunk(
state: ModelCallObservationState,
startedAt: number,
@@ -419,6 +437,10 @@ function emitModelCallCompleted(
startedAt: number,
state: ModelCallObservationState,
): void {
if (state.terminalEventEmitted) {
return;
}
state.terminalEventEmitted = true;
const durationMs = Date.now() - startedAt;
const sizeTimingFields = modelCallSizeTimingFields(state);
emitTrustedDiagnosticEventWithPrivateData(
@@ -443,6 +465,10 @@ function emitModelCallError(
state: ModelCallObservationState,
fields: ModelCallErrorFields,
): void {
if (state.terminalEventEmitted) {
return;
}
state.terminalEventEmitted = true;
const durationMs = Date.now() - startedAt;
const sizeTimingFields = modelCallSizeTimingFields(state);
emitTrustedDiagnosticEventWithPrivateData(
@@ -548,33 +574,80 @@ async function* observeModelCallIterator<T>(
startedAt: number,
state: ModelCallObservationState,
): AsyncIterable<T> {
let terminalEmitted = false;
// Tracks whether the underlying iterator terminated on its own (done or threw).
// This is independent of state.terminalEventEmitted: result() can emit the
// terminal event first, but the abandoned iterator still needs return() cleanup.
let iteratorSettled = false;
try {
for (;;) {
const next = await iterator.next();
if (next.done) {
iteratorSettled = true;
break;
}
observeResponseChunk(state, startedAt, next.value);
maybeEmitModelCallStreamProgress(eventBase, state);
yield next.value;
}
terminalEmitted = true;
emitModelCallCompleted(eventBase, startedAt, state);
} catch (err) {
terminalEmitted = true;
iteratorSettled = true;
emitModelCallError(eventBase, startedAt, state, modelCallErrorFields(err));
throw err;
} finally {
if (!terminalEmitted) {
// A consumer can stop reading before the provider emits done/error. Close
// the iterator best-effort and record the call as completed with observed bytes.
if (!iteratorSettled) {
// A consumer can stop reading before the provider emits done/error — e.g.
// the agent loop returns on the terminal event after awaiting result().
// Close the underlying iterator for provider cleanup (idle-timeout abort
// listeners, SSE readers) even when result() already emitted the terminal
// event; emitModelCallCompleted self-dedupes via state.terminalEventEmitted.
await safeReturnIterator(iterator);
emitModelCallCompleted(eventBase, startedAt, state);
}
}
}
function observeModelCallFinalResult<T>(
result: T,
eventBase: ModelCallEventBase,
startedAt: number,
state: ModelCallObservationState,
): T {
observeResultMessageContent(state, startedAt, result);
emitModelCallCompleted(eventBase, startedAt, state);
return result;
}
function createObservedResultFunction(
stream: unknown,
eventBase: ModelCallEventBase,
startedAt: number,
state: ModelCallObservationState,
): ((...args: unknown[]) => unknown) | undefined {
if (!isRecord(stream) || typeof stream.result !== "function") {
return undefined;
}
const resultFn = stream.result;
return (...args: unknown[]) => {
try {
const result = resultFn.apply(stream, args);
if (isPromiseLike(result)) {
return result.then(
(resolved) => observeModelCallFinalResult(resolved, eventBase, startedAt, state),
(err: unknown) => {
emitModelCallError(eventBase, startedAt, state, modelCallErrorFields(err));
throw err;
},
);
}
return observeModelCallFinalResult(result, eventBase, startedAt, state);
} catch (err) {
emitModelCallError(eventBase, startedAt, state, modelCallErrorFields(err));
throw err;
}
};
}
function observeModelCallStream<T extends AsyncIterable<unknown>>(
stream: T,
createIterator: () => AsyncIterator<unknown>,
@@ -584,6 +657,7 @@ function observeModelCallStream<T extends AsyncIterable<unknown>>(
): T {
const observedIterator = () =>
observeModelCallIterator(createIterator(), eventBase, startedAt, state)[Symbol.asyncIterator]();
const observedResult = createObservedResultFunction(stream, eventBase, startedAt, state);
let hasNonConfigurableIterator;
try {
hasNonConfigurableIterator =
@@ -594,6 +668,7 @@ function observeModelCallStream<T extends AsyncIterable<unknown>>(
if (hasNonConfigurableIterator) {
return {
[Symbol.asyncIterator]: observedIterator,
...(observedResult ? { result: observedResult } : {}),
} as T;
}
return new Proxy(stream, {
@@ -601,6 +676,9 @@ function observeModelCallStream<T extends AsyncIterable<unknown>>(
if (property === Symbol.asyncIterator) {
return observedIterator;
}
if (property === "result" && observedResult) {
return observedResult;
}
const value = Reflect.get(target, property, receiver);
return typeof value === "function" ? value.bind(target) : value;
},

View File

@@ -4,7 +4,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
abortEmbeddedAgentRunMock: vi.fn(),
requestEmbeddedRunModelSwitchMock: vi.fn(),
consumeEmbeddedRunModelSwitchMock: vi.fn(),
resolveDefaultModelForAgentMock: vi.fn(),
resolvePersistedSelectedModelRefMock: vi.fn(),
loadSessionStoreMock: vi.fn(),
@@ -22,8 +21,6 @@ vi.mock("./embedded-agent-runner/runs.js", () => ({
abortEmbeddedAgentRun: (...args: unknown[]) => state.abortEmbeddedAgentRunMock(...args),
requestEmbeddedRunModelSwitch: (...args: unknown[]) =>
state.requestEmbeddedRunModelSwitchMock(...args),
consumeEmbeddedRunModelSwitch: (...args: unknown[]) =>
state.consumeEmbeddedRunModelSwitchMock(...args),
}));
vi.mock("./model-selection.js", async () => {
@@ -86,7 +83,6 @@ describe("live model switch", () => {
beforeEach(() => {
state.abortEmbeddedAgentRunMock.mockReset().mockReturnValue(false);
state.requestEmbeddedRunModelSwitchMock.mockReset();
state.consumeEmbeddedRunModelSwitchMock.mockReset();
state.embeddedAgentModuleImported = false;
state.resolveDefaultModelForAgentMock
.mockReset()

View File

@@ -6,7 +6,6 @@ import { loadSessionStore, updateSessionStore } from "../config/sessions/store.j
import type { SessionEntry } from "../config/sessions/types.js";
import {
abortEmbeddedAgentRun,
consumeEmbeddedRunModelSwitch,
requestEmbeddedRunModelSwitch,
type EmbeddedRunModelSwitchRequest,
} from "./embedded-agent-runner/runs.js";
@@ -91,12 +90,6 @@ export function requestLiveSessionModelSwitch(params: {
return true;
}
export function consumeLiveSessionModelSwitch(
sessionId: string,
): LiveSessionModelSelection | undefined {
return consumeEmbeddedRunModelSwitch(sessionId);
}
function isAlreadyAppliedOpenAICodexRuntimePromotion(
current: { provider: string; model: string },
next: LiveSessionModelSelection,

View File

@@ -1920,7 +1920,9 @@ export class AgentSession {
}
const pathEntries = this.sessionManager.getBranch();
const preparation = unwrapCoreResult(prepareCompaction(pathEntries, options.settings));
const preparation = unwrapCoreResult(
prepareCompaction(pathEntries, options.settings, { force: isManual }),
);
if (!preparation) {
if (isManual) {
const lastEntry = pathEntries[pathEntries.length - 1];

View File

@@ -21,6 +21,7 @@ import {
openClawAgentCoreRuntime,
type CompactionDetails,
type CompactionPreparation,
type CompactionPreparationOptions,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,
@@ -58,8 +59,9 @@ function unwrapCompactionResult<T>(result: Result<T, Error>): T {
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
options?: CompactionPreparationOptions,
): CompactionPreparation | undefined {
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings));
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings, options));
}
/** Generates a compaction summary through the shared agent-core runtime. */

View File

@@ -604,6 +604,10 @@ describe("chunkMarkdownTextWithMode", () => {
expect(chunks.every((chunk) => !/[\uD800-\uDBFF]$/u.test(chunk))).toBe(true);
expect(chunks.every((chunk) => !/^[\uDC00-\uDFFF]/u.test(chunk))).toBe(true);
});
it("keeps an astral character whole when a positive hard limit starts on its pair", () => {
expect(chunkMarkdownTextWithMode("A😀B", 1, "length")).toEqual(["A", "😀", "B"]);
});
});
describe("resolveChunkMode", () => {

View File

@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { FinalizedMsgContext } from "../templating.js";
/** Result from the fast abort path before normal reply dispatch starts. */
export type FastAbortResult = {
type FastAbortResult = {
handled: boolean;
aborted: boolean;
stoppedSubagents?: number;

View File

@@ -27,7 +27,7 @@ const ACP_LIVE_HARD_FLUSH_CHARS = 480;
const TERMINAL_TOOL_STATUSES = new Set(["completed", "failed", "cancelled", "done", "error"]);
const HIDDEN_BOUNDARY_TAGS = new Set<AcpSessionUpdateTag>(["tool_call", "tool_call_update"]);
export type AcpProjectedDeliveryMeta = {
type AcpProjectedDeliveryMeta = {
tag?: AcpSessionUpdateTag;
toolCallId?: string;
toolStatus?: string;
@@ -161,7 +161,7 @@ function renderToolSummaryText(event: Extract<AcpRuntimeEvent, { type: "tool_cal
return formatToolSummary(display);
}
export type AcpReplyProjector = {
type AcpReplyProjector = {
onEvent: (event: AcpRuntimeEvent) => Promise<void>;
flush: (force?: boolean) => Promise<void>;
};

View File

@@ -26,7 +26,7 @@ const ACP_TAG_VISIBILITY_DEFAULTS: Record<AcpSessionUpdateTag, boolean> = {
};
/** ACP delivery strategy for projected assistant output. */
export type AcpDeliveryMode = "live" | "final_only";
type AcpDeliveryMode = "live" | "final_only";
export type AcpHiddenBoundarySeparator = "none" | "space" | "newline" | "paragraph";
/** Normalized ACP projection settings consumed by stream projectors. */

View File

@@ -12,7 +12,7 @@ import type { ReplyPayload } from "../types.js";
import type { HandleCommandsParams } from "./commands-types.js";
/** Resolved session entry and transcript file targeted by an export command. */
export interface ExportCommandSessionTarget {
interface ExportCommandSessionTarget {
entry: SessionEntry;
sessionFile: string;
}

View File

@@ -1,7 +1,7 @@
// Resolves reset command modes from user text into typed reset behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
export type SoftResetParseResult = { matched: false } | { matched: true; tail: string };
type SoftResetParseResult = { matched: false } | { matched: true; tail: string };
export function parseSoftResetCommand(commandBodyNormalized: string): SoftResetParseResult {
const normalized = normalizeLowercaseStringOrEmpty(commandBodyNormalized);

View File

@@ -1,7 +1,7 @@
// Parses config command set/unset requests into typed config operations.
import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js";
export type ConfigCommand =
type ConfigCommand =
| { action: "show"; path?: string }
| { action: "set"; path: string; value: unknown }
| { action: "unset"; path: string }

View File

@@ -1,7 +1,7 @@
// Implements debug command toggles used by maintainers during reply runs.
import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js";
export type DebugCommand =
type DebugCommand =
| { action: "show" }
| { action: "reset" }
| { action: "set"; path: string; value: unknown }

View File

@@ -75,7 +75,7 @@ export function formatModelOverrideResetEvent(params: {
return `Model override not allowed for this agent; reverted to ${params.initialModelLabel}.`;
}
export type ApplyDirectiveResult =
type ApplyDirectiveResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| {
kind: "continue";

View File

@@ -105,7 +105,7 @@ function resolveDirectiveCommandText(params: { ctx: MsgContext; sessionCtx: Temp
};
}
export type ReplyDirectiveContinuation = {
type ReplyDirectiveContinuation = {
commandSource: string;
command: ReturnType<typeof buildCommandContext>;
allowTextCommands: boolean;
@@ -145,7 +145,7 @@ export type ReplyDirectiveContinuation = {
};
};
export type ReplyDirectiveResult =
type ReplyDirectiveResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| { kind: "continue"; result: ReplyDirectiveContinuation };

View File

@@ -130,7 +130,7 @@ function isMentionOnlyResidualText(text: string, wasMentioned: boolean | undefin
}
/** Result of attempting to handle an inbound message as an inline action. */
export type InlineActionResult =
type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
| {
kind: "continue";

View File

@@ -5,7 +5,7 @@ import type { ActiveRunQueueAction } from "./queue-policy.js";
import type { QueueSettings } from "./queue.js";
/** Snapshot of the active reply run state used by queue admission. */
export type ReplyRunQueueBusyState = {
type ReplyRunQueueBusyState = {
activeSessionId: string | undefined;
isActive: boolean;
isStreaming: boolean;

View File

@@ -5,8 +5,8 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import type { MsgContext } from "../templating.js";
import type { HistoryEntry, HistoryMediaEntry } from "./history.types.js";
export const RECENT_HISTORY_IMAGE_TTL_MS = 30 * 60_000;
export const RECENT_HISTORY_IMAGE_LIMIT = 4;
const RECENT_HISTORY_IMAGE_TTL_MS = 30 * 60_000;
const RECENT_HISTORY_IMAGE_LIMIT = 4;
export type RecentInboundHistoryImage = {
path: string;

View File

@@ -1,7 +1,7 @@
// Implements MCP server command parsing and persisted enablement settings.
import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js";
export type McpCommand =
type McpCommand =
| { action: "show"; name?: string }
| { action: "set"; name: string; value: unknown }
| { action: "unset"; name: string }

View File

@@ -2,7 +2,7 @@
const DEFAULT_PENDING_TOOL_DRAIN_IDLE_TIMEOUT_MS = 30_000;
/** Result from waiting for pending tool tasks before final delivery. */
export type PendingToolTaskDrainResult =
type PendingToolTaskDrainResult =
| { kind: "settled" }
| { kind: "timeout"; remaining: number };

View File

@@ -5,7 +5,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
/** Parsed `/plugins` command variants accepted by auto-reply command handling. */
export type PluginsCommand =
type PluginsCommand =
| { action: "list" }
| { action: "inspect"; name?: string }
| { action: "install"; spec: string }

View File

@@ -10,7 +10,7 @@ import { parseReplyDirectives } from "./reply-directives.js";
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
import type { TypingSignaler } from "./typing-mode.js";
export type ReplyDirectiveParseMode = "always" | "auto" | "never";
type ReplyDirectiveParseMode = "always" | "auto" | "never";
/** Parses inline reply directives into payload fields and silent-reply state. */
export function normalizeReplyPayloadDirectives(params: {

View File

@@ -16,7 +16,7 @@ export type ReplyDirectiveParseResult = {
};
/** Options for extracting reply directives from model text. */
export type ReplyDirectiveParseOptions = {
type ReplyDirectiveParseOptions = {
currentMessageId?: string;
silentToken?: string;
extractMarkdownImages?: boolean;

View File

@@ -3,7 +3,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import type { ReplyToMode } from "../../config/types.js";
/** Stateful planner for reply-to ids across one delivery flow. */
export type ReplyReferencePlanner = {
type ReplyReferencePlanner = {
/** Returns the effective reply/thread id for the next send without updating state. */
peek(): string | undefined;
/** Returns the effective reply/thread id for the next send and updates state. */

View File

@@ -62,7 +62,7 @@ function replyDeliverySourceMatchesRoute(params: {
);
}
export type RouteReplyParams = {
type RouteReplyParams = {
/** The reply payload to send. */
payload: ReplyPayload;
/** The originating channel type. */
@@ -105,7 +105,7 @@ export type RouteReplyParams = {
runId?: string;
};
export type RouteReplyResult = {
type RouteReplyResult = {
/** Whether the reply was sent successfully. */
ok: boolean;
/** True when a hook intentionally suppressed provider delivery. */

View File

@@ -8,7 +8,7 @@ import type {
} from "../../plugins/hook-types.js";
/** Session identity attached to plugin session hook payloads. */
export type SessionHookContext = {
type SessionHookContext = {
sessionId: string;
sessionKey: string;
agentId: string;

View File

@@ -3,7 +3,7 @@ import { drainSystemEventEntries } from "../../infra/system-events.js";
import { clearSessionQueues, type ClearSessionQueueResult } from "./queue/cleanup.js";
/** Runtime cleanup result for reset-related queues and system events. */
export type ClearSessionResetRuntimeStateResult = ClearSessionQueueResult & {
type ClearSessionResetRuntimeStateResult = ClearSessionQueueResult & {
systemEventsCleared: number;
};

View File

@@ -3,10 +3,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_REPLAY_MAX_MESSAGES,
replayRecentUserAssistantMessages,
} from "./session-transcript-replay.js";
import { replayRecentUserAssistantMessages } from "./session-transcript-replay.js";
const DEFAULT_REPLAY_MAX_MESSAGES = 6;
const j = (obj: unknown): string => `${JSON.stringify(obj)}\n`;

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
/** Tail kept so DM continuity survives silent session rotations. */
export const DEFAULT_REPLAY_MAX_MESSAGES = 6;
const DEFAULT_REPLAY_MAX_MESSAGES = 6;
type SessionRecord = {
type?: unknown;

View File

@@ -105,7 +105,7 @@ export function resolveSourceReplyDeliveryMode(params: {
}
/** Full source-reply suppression decision consumed by run and hook code. */
export type SourceReplyVisibilityPolicy = {
type SourceReplyVisibilityPolicy = {
sourceReplyDeliveryMode: SourceReplyDeliveryMode;
sendPolicyDenied: boolean;
suppressAutomaticSourceDelivery: boolean;

View File

@@ -18,7 +18,7 @@ export type TypingModeContext = {
};
/** Group chats default to message-triggered typing to avoid noisy indicators. */
export const DEFAULT_GROUP_TYPING_MODE: TypingMode = "message";
const DEFAULT_GROUP_TYPING_MODE: TypingMode = "message";
/** Resolves the effective typing mode for the current auto-reply turn. */
export function resolveTypingMode({

View File

@@ -2,11 +2,14 @@
import { describe, expect, it } from "vitest";
import {
isNodeVersionManagerRuntime,
LINUX_CA_BUNDLE_PATHS,
resolveAutoNodeExtraCaCerts,
resolveLinuxSystemCaBundle,
} from "./node-extra-ca-certs.js";
const DEBIAN_CA_BUNDLE_PATH = "/etc/ssl/certs/ca-certificates.crt";
const FEDORA_CA_BUNDLE_PATH = "/etc/pki/tls/certs/ca-bundle.crt";
const GENERIC_CA_BUNDLE_PATH = "/etc/ssl/ca-bundle.pem";
function allowOnly(path: string) {
return (candidate: string) => {
if (candidate !== path) {
@@ -20,7 +23,7 @@ describe("resolveLinuxSystemCaBundle", () => {
expect(
resolveLinuxSystemCaBundle({
platform: "darwin",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[0]),
accessSync: allowOnly(DEBIAN_CA_BUNDLE_PATH),
}),
).toBeUndefined();
});
@@ -29,9 +32,9 @@ describe("resolveLinuxSystemCaBundle", () => {
expect(
resolveLinuxSystemCaBundle({
platform: "linux",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[1]),
accessSync: allowOnly(FEDORA_CA_BUNDLE_PATH),
}),
).toBe(LINUX_CA_BUNDLE_PATHS[1]);
).toBe(FEDORA_CA_BUNDLE_PATH);
});
});
@@ -120,7 +123,7 @@ describe("resolveAutoNodeExtraCaCerts", () => {
NODE_EXTRA_CA_CERTS: "/custom/ca.pem",
},
platform: "linux",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[0]),
accessSync: allowOnly(DEBIAN_CA_BUNDLE_PATH),
}),
).toBeUndefined();
});
@@ -131,7 +134,7 @@ describe("resolveAutoNodeExtraCaCerts", () => {
env: {},
platform: "linux",
execPath: "/usr/bin/node",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[0]),
accessSync: allowOnly(DEBIAN_CA_BUNDLE_PATH),
}),
).toBeUndefined();
});
@@ -142,8 +145,8 @@ describe("resolveAutoNodeExtraCaCerts", () => {
env: { NVM_DIR: "/home/test/.nvm" },
platform: "linux",
execPath: "/usr/bin/node",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[2]),
accessSync: allowOnly(GENERIC_CA_BUNDLE_PATH),
}),
).toBe(LINUX_CA_BUNDLE_PATHS[2]);
).toBe(GENERIC_CA_BUNDLE_PATH);
});
});

View File

@@ -1,7 +1,7 @@
// Resolves additional CA certificate settings for Node child processes.
import fs from "node:fs";
export const LINUX_CA_BUNDLE_PATHS = [
const LINUX_CA_BUNDLE_PATHS = [
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",

View File

@@ -1,8 +1,10 @@
// Verifies startup environment merge behavior for Node subprocesses.
import { describe, expect, it } from "vitest";
import { LINUX_CA_BUNDLE_PATHS } from "./node-extra-ca-certs.js";
import { resolveNodeStartupTlsEnvironment } from "./node-startup-env.js";
const FEDORA_CA_BUNDLE_PATH = "/etc/pki/tls/certs/ca-bundle.crt";
const GENERIC_CA_BUNDLE_PATH = "/etc/ssl/ca-bundle.pem";
function allowOnly(path: string) {
return (candidate: string) => {
if (candidate !== path) {
@@ -45,10 +47,10 @@ describe("resolveNodeStartupTlsEnvironment", () => {
env: { NVM_DIR: "/home/test/.nvm" },
platform: "linux",
execPath: "/usr/bin/node",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[1]),
accessSync: allowOnly(FEDORA_CA_BUNDLE_PATH),
}),
).toEqual({
NODE_EXTRA_CA_CERTS: LINUX_CA_BUNDLE_PATHS[1],
NODE_EXTRA_CA_CERTS: FEDORA_CA_BUNDLE_PATH,
NODE_USE_SYSTEM_CA: undefined,
});
});
@@ -71,10 +73,8 @@ describe("resolveNodeStartupTlsEnvironment", () => {
env: { NVM_DIR: "/home/test/.nvm" },
platform: "linux",
execPath: "/usr/bin/node",
accessSync: allowOnly(LINUX_CA_BUNDLE_PATHS[2]),
accessSync: allowOnly(GENERIC_CA_BUNDLE_PATH),
}).NODE_EXTRA_CA_CERTS;
if (value !== undefined) {
expect(LINUX_CA_BUNDLE_PATHS).toContain(value);
}
expect(value).toBe(GENERIC_CA_BUNDLE_PATH);
});
});

Some files were not shown because too many files have changed in this diff Show More