mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 08:42:35 +08:00
Compare commits
33 Commits
fix/databa
...
fix/skip-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd09d2e7d0 | ||
|
|
3aecc4ee9d | ||
|
|
02330f372c | ||
|
|
5645dd4d22 | ||
|
|
5a7857dc18 | ||
|
|
25bd8a7191 | ||
|
|
df87b40bec | ||
|
|
5d9c010628 | ||
|
|
03ca096e84 | ||
|
|
22ddf87d2c | ||
|
|
2147312aa2 | ||
|
|
9698070939 | ||
|
|
1c0b38f960 | ||
|
|
0842cb71eb | ||
|
|
392bd16a1d | ||
|
|
f3050ab614 | ||
|
|
6e798c02d8 | ||
|
|
911cd683d5 | ||
|
|
4637b65470 | ||
|
|
e2b6753b87 | ||
|
|
366ef93641 | ||
|
|
dc881a6a31 | ||
|
|
ea72a3382d | ||
|
|
19677bd4ef | ||
|
|
9c9c884526 | ||
|
|
120fd2f702 | ||
|
|
582c2d41b9 | ||
|
|
30955d3660 | ||
|
|
5370e73ee9 | ||
|
|
cf7850040e | ||
|
|
1380a9e094 | ||
|
|
5055f32ee3 | ||
|
|
1075f3819c |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
55
.github/workflows/security-sensitive-guard.yml
vendored
Normal file
55
.github/workflows/security-sensitive-guard.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -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/
|
||||
|
||||
14
apps/android/Config/ReleaseSigning.json
Normal file
14
apps/android/Config/ReleaseSigning.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1182,7 +1182,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
"side-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": { ".": "write" },
|
||||
":project_roots": { ".": "write" },
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
|
||||
@@ -42,7 +42,7 @@ function createNetworkProxyThreadLifecycleAppServerOptions() {
|
||||
"openclaw-network": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
":project_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ function createNetworkProxyAppServerOptions() {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
":project_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
57
extensions/telegram/src/send.chunks.test.ts
Normal file
57
extensions/telegram/src/send.chunks.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
shouldCompact,
|
||||
type CompactionDetails,
|
||||
type CompactionPreparation,
|
||||
type CompactionPreparationOptions,
|
||||
type CompactionResult,
|
||||
type CompactionSettings,
|
||||
type ContextUsageEstimate,
|
||||
|
||||
@@ -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] ?? "")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
449
scripts/android-release-signing.mjs
Normal file
449
scripts/android-release-signing.mjs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
717
scripts/github/security-sensitive-guard.mjs
Normal file
717
scripts/github/security-sensitive-guard.mjs
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user