@lobu/worker 3.0.5 → 3.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/USAGE.md +120 -0
  2. package/docs/custom-base-image.md +88 -0
  3. package/package.json +2 -2
  4. package/scripts/worker-entrypoint.sh +184 -0
  5. package/src/__tests__/audio-provider-suggestions.test.ts +198 -0
  6. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +39 -0
  7. package/src/__tests__/embedded-tools.test.ts +558 -0
  8. package/src/__tests__/instructions.test.ts +59 -0
  9. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  10. package/src/__tests__/memory-flush.test.ts +64 -0
  11. package/src/__tests__/model-resolver.test.ts +156 -0
  12. package/src/__tests__/processor.test.ts +225 -0
  13. package/src/__tests__/setup.ts +109 -0
  14. package/src/__tests__/sse-client.test.ts +48 -0
  15. package/src/__tests__/tool-policy.test.ts +269 -0
  16. package/src/__tests__/worker.test.ts +89 -0
  17. package/src/core/error-handler.ts +70 -0
  18. package/src/core/project-scanner.ts +65 -0
  19. package/src/core/types.ts +125 -0
  20. package/src/core/url-utils.ts +9 -0
  21. package/src/core/workspace.ts +138 -0
  22. package/src/embedded/just-bash-bootstrap.ts +228 -0
  23. package/src/gateway/gateway-integration.ts +287 -0
  24. package/src/gateway/message-batcher.ts +128 -0
  25. package/src/gateway/sse-client.ts +955 -0
  26. package/src/gateway/types.ts +68 -0
  27. package/src/index.ts +146 -0
  28. package/src/instructions/builder.ts +80 -0
  29. package/src/instructions/providers.ts +27 -0
  30. package/src/modules/lifecycle.ts +92 -0
  31. package/src/openclaw/custom-tools.ts +290 -0
  32. package/src/openclaw/instructions.ts +38 -0
  33. package/src/openclaw/model-resolver.ts +150 -0
  34. package/src/openclaw/plugin-loader.ts +427 -0
  35. package/src/openclaw/processor.ts +216 -0
  36. package/src/openclaw/session-context.ts +277 -0
  37. package/src/openclaw/tool-policy.ts +212 -0
  38. package/src/openclaw/tools.ts +208 -0
  39. package/src/openclaw/worker.ts +1792 -0
  40. package/src/server.ts +329 -0
  41. package/src/shared/audio-provider-suggestions.ts +132 -0
  42. package/src/shared/processor-utils.ts +33 -0
  43. package/src/shared/provider-auth-hints.ts +64 -0
  44. package/src/shared/tool-display-config.ts +75 -0
  45. package/src/shared/tool-implementations.ts +768 -0
  46. package/tsconfig.json +21 -0
package/USAGE.md ADDED
@@ -0,0 +1,120 @@
1
+ # Worker Environment Variables
2
+
3
+ This document describes all environment variables used by Lobu Workers. Most of these are automatically set by the Gateway orchestrator and should not be manually configured.
4
+
5
+ ## Gateway-Managed Variables (Auto-Set)
6
+
7
+ These variables are automatically set by the Gateway when deploying worker containers. **Do not manually configure these.**
8
+
9
+ ### `DISPATCHER_URL`
10
+ **Description**: Gateway URL for worker-to-gateway communication
11
+ **Format**: HTTP URL
12
+ **Example**: `http://gateway:8080/worker/stream`
13
+ **Set by**: Gateway orchestrator
14
+ **Used by**: SSE connection to gateway, progress updates, session management
15
+
16
+ ### `WORKER_TOKEN`
17
+ **Description**: Authentication token for worker-gateway communication
18
+ **Format**: JWT token or user-specific token
19
+ **Set by**: Gateway orchestrator
20
+ **Used by**: Worker authentication, gateway proxy authentication
21
+
22
+ ### `DEPLOYMENT_NAME`
23
+ **Description**: Unique identifier for this worker deployment
24
+ **Format**: `lobu-worker-{user}-{timestamp}-{random}`
25
+ **Example**: `lobu-worker-u123abc-284-707819`
26
+ **Set by**: Gateway orchestrator
27
+ **Used by**: Deployment identification, logging
28
+
29
+ ### `USER_ID`
30
+ **Description**: Platform-specific user identifier
31
+ **Format**: Platform-dependent (e.g., Slack user ID starts with `U`)
32
+ **Example**: `U0123456789`
33
+ **Set by**: Gateway orchestrator (updated on first message)
34
+ **Used by**: User-specific credential lookup, session management
35
+
36
+ ### `THREAD_ID`
37
+ **Description**: Platform thread identifier for conversation context
38
+ **Format**: Platform-dependent
39
+ **Example**: `1234567890.123456`
40
+ **Set by**: Gateway orchestrator
41
+ **Used by**: Workspace isolation, session continuity
42
+
43
+ ### `WORKSPACE_DIR`
44
+ **Description**: Worker workspace directory path
45
+ **Format**: Absolute path
46
+ **Default**: `/workspace`
47
+ **Set by**: Worker initialization
48
+ **Used by**: File operations, MCP process working directory
49
+
50
+ ### `HOME`
51
+ **Description**: Home directory for worker processes
52
+ **Format**: Absolute path
53
+ **Default**: `/workspace` (to persist agent sessions)
54
+ **Set by**: Gateway orchestrator
55
+ **Used by**: Agent session storage
56
+
57
+ ### `HOSTNAME`
58
+ **Description**: Container hostname (fallback for DEPLOYMENT_NAME)
59
+ **Format**: Alphanumeric string
60
+ **Set by**: Container runtime
61
+ **Used by**: Deployment identification if DEPLOYMENT_NAME not set
62
+
63
+ ## MCP Configuration
64
+
65
+ ### `MCP_SERVER_CONFIG`
66
+ **Description**: JSON configuration for MCP servers (auto-generated)
67
+ **Format**: JSON string
68
+ **Set by**: Worker initialization (from user credentials)
69
+ **Used by**: MCP server initialization
70
+
71
+ ## Development/Debugging Variables
72
+
73
+ ### `DEBUG`
74
+ **Description**: Enable debug logging
75
+ **Format**: `1` or any truthy value
76
+ **Example**: `DEBUG=1`
77
+ **Set by**: Gateway orchestrator in development mode
78
+ **Used by**: Enhanced logging, crash debugging
79
+
80
+ ### `NODE_ENV`
81
+ **Description**: Environment mode
82
+ **Values**: `development` | `production`
83
+ **Set by**: Gateway orchestrator
84
+ **Used by**: Logging verbosity, error handling
85
+
86
+ ## Container Runtime Variables
87
+
88
+ These are automatically set by the container runtime:
89
+
90
+ ### Read-Only Root Filesystem
91
+ **Default**: Enabled (`WORKER_READONLY_ROOTFS=true` in gateway)
92
+ **Writable paths**:
93
+ - `/workspace` - Worker workspace (persistent via volume)
94
+ - `/tmp` - Temporary files (100MB tmpfs)
95
+ - `/home/bun/.cache` - Bun cache (200MB tmpfs)
96
+
97
+ ### Security Configuration
98
+ **Capabilities**: All dropped by default (CapDrop: ALL)
99
+ **Privilege Escalation**: Disabled (no-new-privileges)
100
+ **Seccomp**: Docker's default profile
101
+ **AppArmor**: Docker's default profile
102
+
103
+ ## Summary
104
+
105
+ **For normal operation, workers require NO manual environment configuration.** The Gateway orchestrator automatically sets all necessary variables when deploying worker containers.
106
+
107
+ ### Minimal Auto-Set Variables (by Gateway):
108
+ ```bash
109
+ DISPATCHER_URL=http://gateway:8080/worker/stream
110
+ WORKER_TOKEN=<auto-generated>
111
+ DEPLOYMENT_NAME=lobu-worker-<user>-<timestamp>
112
+ HOME=/workspace
113
+ ```
114
+
115
+ ### Optional Gateway-Passed Variables:
116
+ ```bash
117
+ DEBUG=1 # Development only
118
+ ```
119
+
120
+ All other configuration is handled automatically by the worker based on gateway-provided context.
@@ -0,0 +1,88 @@
1
+ # Using @lobu/worker with Custom Base Images
2
+
3
+ Install and run the Lobu worker in your own Docker base image instead of extending the default `ghcr.io/lobu-ai/lobu-worker-base`.
4
+
5
+ ## System Requirements
6
+
7
+ | Dependency | Version | Required |
8
+ |------------|---------|----------|
9
+ | Node.js or Bun | >= 18.0 | Yes — worker runtime |
10
+ | Git | >= 2.30 | Yes — code operations |
11
+ | Docker CLI | >= 20.10 | Optional — spawning sub-containers |
12
+ | Python | >= 3.9 | Optional — Python tools |
13
+
14
+ ## Installation
15
+
16
+ ### Bun (Recommended)
17
+
18
+ ```dockerfile
19
+ FROM oven/bun:1.2.9-alpine
20
+
21
+ RUN apk add --no-cache docker-cli git python3 py3-pip
22
+
23
+ RUN bun add -g @lobu/worker@^0.1.0
24
+
25
+ CMD ["lobu-worker"]
26
+ ```
27
+
28
+ ### Node.js
29
+
30
+ ```dockerfile
31
+ FROM node:20-alpine
32
+
33
+ RUN apk add --no-cache docker-cli git python3 py3-pip curl
34
+
35
+ RUN npm install -g @lobu/worker@^0.1.0
36
+
37
+ CMD ["lobu-worker"]
38
+ ```
39
+
40
+ ### Company Approved Base
41
+
42
+ ```dockerfile
43
+ FROM company-registry.example.com/ubuntu:22.04
44
+
45
+ RUN apt-get update && apt-get install -y \
46
+ nodejs npm docker.io git python3 curl
47
+
48
+ RUN npm install -g @lobu/worker@^0.1.0
49
+
50
+ CMD ["lobu-worker"]
51
+ ```
52
+
53
+ ## Environment Variables
54
+
55
+ All variables are auto-set by the gateway orchestrator. Manual configuration is not required.
56
+
57
+ ```bash
58
+ # Auto-set by gateway (do not configure manually)
59
+ DISPATCHER_URL=http://gateway:8080/worker/stream
60
+ WORKER_TOKEN=<auto-generated>
61
+ DEPLOYMENT_NAME=lobu-worker-<user>-<timestamp>
62
+ USER_ID=<platform-user-id>
63
+ HOME=/workspace
64
+ ```
65
+
66
+ See [USAGE.md](../USAGE.md) for the full variable reference.
67
+
68
+ ## Compatibility Matrix
69
+
70
+ | Base Image | Status | Notes |
71
+ |------------|--------|-------|
72
+ | `oven/bun:1.2.9` | Tested | Best performance |
73
+ | `node:20-alpine` | Tested | Small size |
74
+ | `node:20-slim` | Tested | Debian-based |
75
+ | `ubuntu:22.04` | Tested | Most compatible |
76
+ | `alpine:3.19` | Tested | Must install Node separately |
77
+
78
+ ## Troubleshooting
79
+
80
+ **`lobu-worker: command not found`** — Ensure the package was installed globally (`npm install -g` or `bun add -g`).
81
+
82
+ **`git: command not found`** — Install git in your base image (`apk add git` or `apt-get install git`).
83
+
84
+ **`EACCES: permission denied`** — Run as root or add user to the docker group.
85
+
86
+ ## Getting Help
87
+
88
+ - [GitHub Issues](https://github.com/lobu-ai/lobu/issues)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobu/worker",
3
- "version": "3.0.5",
3
+ "version": "3.0.6",
4
4
  "description": "Lobu worker runtime - run in your own Docker image or use our base image",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@hono/node-server": "^1.19.9",
38
- "@lobu/core": "3.0.5",
38
+ "@lobu/core": "workspace:*",
39
39
  "@lobu/owletto-openclaw": "^3.0.2",
40
40
  "@mariozechner/pi-agent-core": "^0.51.6",
41
41
  "@mariozechner/pi-ai": "^0.51.6",
@@ -0,0 +1,184 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Container entrypoint script for Lobu Worker
5
+ echo "🚀 Starting Lobu Worker..."
6
+
7
+ # Function to handle cleanup on exit
8
+ cleanup() {
9
+ echo "📦 Container shutting down, performing cleanup..."
10
+
11
+ # Kill any background processes
12
+ jobs -p | xargs -r kill || true
13
+
14
+ # Give processes time to exit gracefully
15
+ sleep 2
16
+
17
+ echo "✅ Cleanup completed"
18
+ exit 0
19
+ }
20
+
21
+ # Setup signal handlers for graceful shutdown
22
+ trap cleanup SIGTERM SIGINT
23
+
24
+ echo "🔍 Environment variables provided by orchestrator:"
25
+ echo " - USER_ID: ${USER_ID:-not set}"
26
+ echo " - CHANNEL_ID: ${CHANNEL_ID:-not set}"
27
+ echo " - REPOSITORY_URL: ${REPOSITORY_URL:-not set}"
28
+ echo " - DEPLOYMENT_NAME: ${DEPLOYMENT_NAME:-not set}"
29
+
30
+ # Basic validation for critical variables
31
+ if [[ -z "${USER_ID:-}" ]]; then
32
+ echo "❌ Error: USER_ID is required"
33
+ exit 1
34
+ fi
35
+
36
+ if [[ -z "${DEPLOYMENT_NAME:-}" ]]; then
37
+ echo "❌ Error: DEPLOYMENT_NAME is required"
38
+ exit 1
39
+ fi
40
+
41
+ # Setup workspace directory
42
+ echo "📁 Setting up workspace directory..."
43
+ WORKSPACE_DIR="/workspace"
44
+
45
+ # Workspace permissions are fixed by gateway before container starts
46
+ # Just verify we can write to it
47
+ if [ ! -w "$WORKSPACE_DIR" ]; then
48
+ echo "❌ Error: Cannot write to workspace directory $WORKSPACE_DIR"
49
+ exit 1
50
+ fi
51
+
52
+ # Route temp files and cache to workspace-backed paths.
53
+ # Keep /tmp mounted for compatibility with tools that ignore TMPDIR.
54
+ export TMPDIR="${TMPDIR:-$WORKSPACE_DIR/.tmp}"
55
+ export TMP="${TMP:-$TMPDIR}"
56
+ export TEMP="${TEMP:-$TMPDIR}"
57
+ export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$WORKSPACE_DIR/.cache}"
58
+ mkdir -p "$TMPDIR" "$XDG_CACHE_HOME"
59
+
60
+ cd "$WORKSPACE_DIR"
61
+
62
+ echo "✅ Workspace directory ready: $WORKSPACE_DIR"
63
+
64
+ # Log container information
65
+ echo "📊 Container Information:"
66
+ echo " - Session Key: $SESSION_KEY"
67
+ echo " - Repository: $REPOSITORY_URL"
68
+ echo " - Working Directory: $(pwd)"
69
+ echo " - Container Hostname: $(hostname)"
70
+ echo " - Container Memory Limit: $(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo 'unknown')"
71
+ echo " - Container CPU Limit: $(cat /sys/fs/cgroup/cpu.max 2>/dev/null || echo 'unknown')"
72
+
73
+ # Setup git global configuration
74
+ echo "⚙️ Setting up git configuration..."
75
+ git config --global user.name "Lobu Worker"
76
+ git config --global user.email "lobu@noreply.github.com"
77
+ git config --global init.defaultBranch main
78
+ git config --global pull.rebase false
79
+ git config --global safe.directory '*'
80
+
81
+ # In development mode, ensure core package can find its dependencies
82
+ # The packages/ dir is mounted as a volume which may contain node_modules from host
83
+ if [ "${NODE_ENV}" = "development" ]; then
84
+ # Remove any existing node_modules that aren't symlinks (non-fatal on read-only rootfs)
85
+ if [ -e "/app/packages/core/node_modules" ] && [ ! -L "/app/packages/core/node_modules" ]; then
86
+ echo "🗑️ Removing host node_modules from /app/packages/core/"
87
+ rm -rf /app/packages/core/node_modules 2>/dev/null || true
88
+ fi
89
+ if [ ! -e "/app/packages/core/node_modules" ]; then
90
+ echo "🔗 Creating symlink for core package dependencies..."
91
+ ln -sf /app/node_modules /app/packages/core/node_modules 2>/dev/null || true
92
+ fi
93
+
94
+ # Also for worker package if needed (non-fatal on read-only rootfs)
95
+ if [ -e "/app/packages/worker/node_modules" ] && [ ! -L "/app/packages/worker/node_modules" ]; then
96
+ rm -rf /app/packages/worker/node_modules 2>/dev/null || true
97
+ fi
98
+ if [ ! -e "/app/packages/worker/node_modules" ]; then
99
+ ln -sf /app/node_modules /app/packages/worker/node_modules 2>/dev/null || true
100
+ fi
101
+ fi
102
+
103
+ # Source Nix profile if installed (non-interactive shells don't source /etc/profile.d)
104
+ if [ -f /home/worker/.nix-profile/etc/profile.d/nix.sh ]; then
105
+ . /home/worker/.nix-profile/etc/profile.d/nix.sh
106
+ # Set NIX_PATH for nix-shell -p to find nixpkgs
107
+ export NIX_PATH="nixpkgs=/home/worker/.nix-defexpr/channels/nixpkgs"
108
+ fi
109
+
110
+ # Docker fallback: persist Nix store on workspace PVC via symlinks
111
+ # (K8s uses init container + subPath mounts instead, detected by .nix-pvc-mounted marker)
112
+ if [ -n "${NIX_PACKAGES:-}${NIX_FLAKE_URL:-}" ] && [ ! -d "/nix/store/.nix-pvc-mounted" ]; then
113
+ NIX_PVC_STORE="/workspace/.nix-store"
114
+ NIX_PVC_VAR="/workspace/.nix-var"
115
+ MARKER="/workspace/.nix-bootstrapped"
116
+ if [ ! -f "$MARKER" ]; then
117
+ echo "Bootstrapping Nix store to PVC..."
118
+ cp -a /nix/store "$NIX_PVC_STORE"
119
+ cp -a /nix/var "$NIX_PVC_VAR"
120
+ touch "$MARKER"
121
+ fi
122
+ rm -rf /nix/store /nix/var
123
+ ln -sf "$NIX_PVC_STORE" /nix/store
124
+ ln -sf "$NIX_PVC_VAR" /nix/var
125
+ echo "Nix store linked to PVC"
126
+ fi
127
+
128
+ # Nix environment activation
129
+ # Priority: API env vars > repo files
130
+ activate_nix_env() {
131
+ local cmd="$1"
132
+
133
+ # Check if Nix is installed
134
+ if ! command -v nix &> /dev/null; then
135
+ echo "⚠️ Nix not installed, skipping environment activation"
136
+ exec $cmd
137
+ fi
138
+
139
+ # 1. API-provided flake URL takes highest priority
140
+ if [ -n "${NIX_FLAKE_URL:-}" ]; then
141
+ echo "🔧 Activating Nix flake environment: $NIX_FLAKE_URL"
142
+ exec nix develop "$NIX_FLAKE_URL" --command $cmd
143
+ fi
144
+
145
+ # 2. API-provided packages list
146
+ if [ -n "${NIX_PACKAGES:-}" ]; then
147
+ # Convert comma-separated to space-separated
148
+ local packages="${NIX_PACKAGES//,/ }"
149
+ echo "🔧 Activating Nix packages: $packages"
150
+ exec nix-shell -p $packages --command "$cmd"
151
+ fi
152
+
153
+ # 3. Check for nix files in workspace (git-based config)
154
+ if [ -f "$WORKSPACE_DIR/flake.nix" ]; then
155
+ echo "🔧 Detected flake.nix in workspace, activating..."
156
+ exec nix develop "$WORKSPACE_DIR" --command $cmd
157
+ fi
158
+
159
+ if [ -f "$WORKSPACE_DIR/shell.nix" ]; then
160
+ echo "🔧 Detected shell.nix in workspace, activating..."
161
+ exec nix-shell "$WORKSPACE_DIR/shell.nix" --command "$cmd"
162
+ fi
163
+
164
+ # 4. Check for simple .nix-packages file (one package per line)
165
+ if [ -f "$WORKSPACE_DIR/.nix-packages" ]; then
166
+ local packages=$(cat "$WORKSPACE_DIR/.nix-packages" | tr '\n' ' ')
167
+ echo "🔧 Detected .nix-packages file, activating: $packages"
168
+ exec nix-shell -p $packages --command "$cmd"
169
+ fi
170
+
171
+ # No nix config found, run directly
172
+ exec $cmd
173
+ }
174
+
175
+ # Start the worker process
176
+ echo "🚀 Executing Worker..."
177
+ # Check if we're already in the worker directory
178
+ if [ "$(pwd)" != "/app/packages/worker" ]; then
179
+ cd /app/packages/worker || { echo "❌ Failed to cd to /app/packages/worker"; exit 1; }
180
+ fi
181
+
182
+ # Always run from source — Bun handles TypeScript natively and this avoids
183
+ # CJS/ESM interop issues with ESM-only dependencies (e.g. pi-coding-agent).
184
+ activate_nix_env "bun run src/index.ts"
@@ -0,0 +1,198 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { OpenClawWorker } from "../openclaw/worker";
3
+ import {
4
+ fetchAudioProviderSuggestions,
5
+ normalizeAudioProviderSuggestions,
6
+ } from "../shared/audio-provider-suggestions";
7
+ import { generateAudio } from "../shared/tool-implementations";
8
+
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ function extractText(result: {
12
+ content: Array<{ type: "text"; text: string }>;
13
+ }): string {
14
+ return result.content[0]?.text || "";
15
+ }
16
+
17
+ describe("audio provider suggestions", () => {
18
+ afterEach(() => {
19
+ globalThis.fetch = originalFetch;
20
+ mock.restore();
21
+ });
22
+
23
+ test("normalizes gateway capability providers into prefill IDs + display list", () => {
24
+ const normalized = normalizeAudioProviderSuggestions({
25
+ available: false,
26
+ providers: [
27
+ { provider: "openai", name: "OpenAI" },
28
+ { provider: "gemini", name: "Google Gemini" },
29
+ ],
30
+ });
31
+
32
+ expect(normalized.available).toBe(false);
33
+ expect(normalized.usedFallback).toBe(false);
34
+ expect(normalized.providerIds).toEqual(["chatgpt", "openai", "gemini"]);
35
+ expect(normalized.providerDisplayList).toBe("OpenAI, Google Gemini");
36
+ });
37
+
38
+ test("falls back safely when capability payload is malformed", () => {
39
+ const normalized = normalizeAudioProviderSuggestions({
40
+ available: true,
41
+ providers: [{ unexpected: "value" }],
42
+ });
43
+
44
+ expect(normalized.available).toBe(true);
45
+ expect(normalized.usedFallback).toBe(true);
46
+ expect(normalized.providerIds).toEqual(["chatgpt", "gemini", "elevenlabs"]);
47
+ expect(normalized.providerDisplayList).toBe("");
48
+ });
49
+
50
+ test("falls back safely when capability fetch fails", async () => {
51
+ globalThis.fetch = mock(async () => {
52
+ throw new Error("network down");
53
+ }) as unknown as typeof fetch;
54
+
55
+ const normalized = await fetchAudioProviderSuggestions({
56
+ gatewayUrl: "http://gateway",
57
+ workerToken: "token",
58
+ });
59
+
60
+ expect(normalized.available).toBeNull();
61
+ expect(normalized.usedFallback).toBe(true);
62
+ expect(normalized.providerIds).toEqual(["chatgpt", "gemini", "elevenlabs"]);
63
+ expect(normalized.providerDisplayList).toBe("");
64
+ });
65
+ });
66
+
67
+ describe("GenerateAudio dynamic provider messaging", () => {
68
+ afterEach(() => {
69
+ globalThis.fetch = originalFetch;
70
+ mock.restore();
71
+ });
72
+
73
+ test("uses dynamic capability providers in missing-scope guidance", async () => {
74
+ const fetchMock = mock(
75
+ async (input: RequestInfo | URL, init?: RequestInit) => {
76
+ const url = String(input);
77
+
78
+ if (url.endsWith("/internal/audio/capabilities")) {
79
+ return new Response(
80
+ JSON.stringify({
81
+ available: true,
82
+ providers: [
83
+ { provider: "openai", name: "OpenAI" },
84
+ { provider: "gemini", name: "Google Gemini" },
85
+ ],
86
+ }),
87
+ { status: 200, headers: { "Content-Type": "application/json" } }
88
+ );
89
+ }
90
+
91
+ if (url.endsWith("/internal/audio/synthesize")) {
92
+ expect(init?.method).toBe("POST");
93
+ return new Response(
94
+ JSON.stringify({
95
+ error: "missing_scope: api.model.audio.request",
96
+ }),
97
+ { status: 400, headers: { "Content-Type": "application/json" } }
98
+ );
99
+ }
100
+
101
+ throw new Error(`Unexpected URL: ${url}`);
102
+ }
103
+ );
104
+
105
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
106
+
107
+ const result = await generateAudio(
108
+ {
109
+ gatewayUrl: "http://gateway",
110
+ workerToken: "token",
111
+ channelId: "ch",
112
+ conversationId: "conv",
113
+ platform: "telegram",
114
+ },
115
+ { text: "hello world" }
116
+ );
117
+
118
+ const text = extractText(result as any);
119
+
120
+ expect(text).toContain("OpenAI, Google Gemini");
121
+ expect(text).toContain("Ask an admin");
122
+ });
123
+ });
124
+
125
+ describe("OpenClawWorker audio permission hint", () => {
126
+ afterEach(() => {
127
+ globalThis.fetch = originalFetch;
128
+ mock.restore();
129
+ });
130
+
131
+ test("uses dynamic providers in admin guidance", async () => {
132
+ const fetchMock = mock(async (input: RequestInfo | URL) => {
133
+ const url = String(input);
134
+ if (url.endsWith("/internal/audio/capabilities")) {
135
+ return new Response(
136
+ JSON.stringify({
137
+ available: true,
138
+ providers: [
139
+ { provider: "openai", name: "OpenAI" },
140
+ { provider: "elevenlabs", name: "ElevenLabs" },
141
+ ],
142
+ }),
143
+ { status: 200, headers: { "Content-Type": "application/json" } }
144
+ );
145
+ }
146
+ throw new Error(`Unexpected URL: ${url}`);
147
+ });
148
+
149
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
150
+
151
+ const hint = await (
152
+ OpenClawWorker.prototype as any
153
+ ).maybeBuildAudioPermissionHintMessage(
154
+ "Audio generation failed because token lacks api.model.audio.request",
155
+ "http://gateway",
156
+ "token"
157
+ );
158
+
159
+ expect(hint).toContain("OpenAI, ElevenLabs");
160
+ expect(hint).toContain("Ask an admin");
161
+ });
162
+
163
+ test("falls back to generic provider suggestions when capabilities lookup fails", async () => {
164
+ const fetchMock = mock(async () => {
165
+ throw new Error("capabilities unavailable");
166
+ });
167
+
168
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
169
+
170
+ const hint = await (
171
+ OpenClawWorker.prototype as any
172
+ ).maybeBuildAudioPermissionHintMessage(
173
+ "api.model.audio.request is missing",
174
+ "http://gateway",
175
+ "token"
176
+ );
177
+
178
+ expect(hint).toBeNull();
179
+ });
180
+ });
181
+
182
+ describe("OpenClawWorker auth hint messaging", () => {
183
+ test("routes missing provider auth to admin guidance", async () => {
184
+ const hint = await (
185
+ OpenClawWorker.prototype as any
186
+ ).maybeBuildAuthHintMessage(
187
+ 'Authentication failed for "openai"',
188
+ "openai",
189
+ "gpt-4.1",
190
+ "http://gateway",
191
+ "token"
192
+ );
193
+
194
+ expect(hint).toContain("gpt-4.1");
195
+ expect(hint).toContain("admin");
196
+ expect(hint).toContain("openai");
197
+ });
198
+ });
@@ -0,0 +1,39 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { buildBinaryInvocation } from "../embedded/just-bash-bootstrap";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ afterEach(() => {
10
+ for (const dir of tempDirs.splice(0)) {
11
+ fs.rmSync(dir, { recursive: true, force: true });
12
+ }
13
+ });
14
+
15
+ describe("buildBinaryInvocation", () => {
16
+ test("runs node shebang scripts through node", () => {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lobu-owletto-"));
18
+ tempDirs.push(dir);
19
+ const scriptPath = path.join(dir, "owletto");
20
+ fs.writeFileSync(
21
+ scriptPath,
22
+ "#!/usr/bin/env node\nconsole.log('ok');\n",
23
+ "utf8"
24
+ );
25
+ fs.chmodSync(scriptPath, 0o755);
26
+
27
+ expect(buildBinaryInvocation(scriptPath, ["version"])).toEqual({
28
+ command: "node",
29
+ args: [scriptPath, "version"],
30
+ });
31
+ });
32
+
33
+ test("executes normal binaries directly", () => {
34
+ expect(buildBinaryInvocation("/bin/echo", ["hello"])).toEqual({
35
+ command: "/bin/echo",
36
+ args: ["hello"],
37
+ });
38
+ });
39
+ });