Skip to content

Commit 1e08af4

Browse files
authored
fix(sms): add Twilio webhook diagnostics
* fix(sms): diagnose Twilio webhook setup * test(sms): satisfy diagnostic lint gates * fix(sms): redact recent probe participants * docs(sms): refresh SecretRef credential matrix * fix(sms): probe Messaging Service webhooks * fix(sms): resolve env-backed SecretRefs
1 parent 6d76acc commit 1e08af4

13 files changed

Lines changed: 1360 additions & 20 deletions

docs/channels/sms.md

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ https://gateway.example.com/webhooks/sms
8484

8585
</Step>
8686

87+
<Step title="Expose the exact SMS webhook path">
88+
Your public URL must route the SMS path to the Gateway process. If you use Tailscale Funnel for local testing, expose `/webhooks/sms` explicitly:
89+
90+
```bash
91+
tailscale funnel --bg --set-path /webhooks/sms http://127.0.0.1:<gateway-port>/webhooks/sms
92+
tailscale funnel status
93+
```
94+
95+
Voice Call and SMS use separate webhook paths. If the same Twilio number handles both, keep both routes configured in Twilio and in your tunnel.
96+
97+
</Step>
98+
8799
<Step title="Start the Gateway and approve first sender">
88100

89101
```bash
@@ -149,6 +161,27 @@ Then enable the channel in config:
149161

150162
`TWILIO_SMS_FROM` is accepted as an alias for `TWILIO_PHONE_NUMBER`. Use `TWILIO_MESSAGING_SERVICE_SID` instead of a phone-number sender when Twilio should choose the sender from a Messaging Service.
151163

164+
### SecretRef auth token
165+
166+
`authToken` can be a SecretRef. Use this when the Gateway should resolve the Twilio Auth Token from the OpenClaw secrets runtime instead of storing plaintext config:
167+
168+
```json5
169+
{
170+
channels: {
171+
sms: {
172+
enabled: true,
173+
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
174+
authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" },
175+
fromNumber: "+15551234567",
176+
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
177+
dmPolicy: "pairing",
178+
},
179+
},
180+
}
181+
```
182+
183+
The referenced environment variable or secret provider must be visible to the Gateway runtime. Restart managed Gateway processes after changing host environment variables.
184+
152185
### Allowlist-only private number
153186

154187
Use `allowlist` when only known phone numbers should be able to talk to the agent:
@@ -245,17 +278,37 @@ SMS output is plain text. OpenClaw strips markdown, flattens fenced code blocks,
245278
After the Gateway starts:
246279

247280
1. Confirm the Gateway log shows the SMS webhook route.
248-
2. Send an SMS to the Twilio number from your phone.
249-
3. Run `openclaw pairing list sms`.
250-
4. Approve the pairing code with `openclaw pairing approve sms <CODE>`.
251-
5. Send another SMS and confirm the agent replies.
281+
2. Run a Twilio-side probe:
282+
283+
```bash
284+
openclaw channels capabilities --channel sms
285+
openclaw channels status --channel sms --probe --json
286+
```
287+
288+
3. Send an SMS to the Twilio number from your phone.
289+
4. Run `openclaw pairing list sms`.
290+
5. Approve the pairing code with `openclaw pairing approve sms <CODE>`.
291+
6. Send another SMS and confirm the agent replies.
252292

253293
For outbound-only testing, use:
254294

255295
```bash
256296
openclaw message send --channel sms --target sms:+15557654321 --message "OpenClaw SMS test"
257297
```
258298

299+
### End-to-end test from macOS iMessage/SMS
300+
301+
On a Mac that can send carrier SMS through Messages, you can use `imsg` to drive the sender side without touching your phone:
302+
303+
```bash
304+
imsg send --to "+15551234567" --service sms --text "OpenClaw SMS E2E $(date -u +%Y%m%dT%H%M%SZ)" --json
305+
openclaw pairing list sms
306+
openclaw pairing approve sms <CODE>
307+
imsg send --to "+15551234567" --service sms --text "reply exactly SMS pong" --json
308+
```
309+
310+
The first message should create a pairing request. The second message should receive the agent reply through Twilio.
311+
259312
## Webhook security
260313

261314
By default, OpenClaw validates `X-Twilio-Signature` using `publicWebhookUrl` and `authToken`. Keep `publicWebhookUrl` byte-for-byte aligned with the URL configured in Twilio, including scheme, host, path, and query string.
@@ -311,6 +364,13 @@ Check that `publicWebhookUrl` exactly matches the URL configured in Twilio, incl
311364

312365
Check the Twilio number's **Messaging** webhook URL and method. It must point to the SMS webhook URL and use `POST`. Also confirm the Gateway is reachable from the public internet or through your tunnel.
313366

367+
If the Twilio message log shows error `11200`, Twilio accepted the inbound SMS but could not reach your webhook. Check:
368+
369+
- Twilio **Messaging > A message comes in** points at `publicWebhookUrl`.
370+
- The method is `POST`.
371+
- The tunnel or reverse proxy exposes the exact `webhookPath`; for Tailscale Funnel, run `tailscale funnel status` and confirm `/webhooks/sms` is listed.
372+
- `publicWebhookUrl` uses the same scheme, host, path, and query string Twilio sends, so signature validation can reproduce the signed URL.
373+
314374
### Outbound sends fail
315375

316376
Confirm `accountSid`, `authToken`, and either `fromNumber` or `messagingServiceSid` are resolved. If you use a trial Twilio account, the destination number may need to be verified in Twilio before outbound SMS will send.

docs/reference/secretref-credential-surface.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ Scope intent:
7373
- `channels.slack.accounts.*.appToken`
7474
- `channels.slack.accounts.*.userToken`
7575
- `channels.slack.accounts.*.signingSecret`
76+
- `channels.sms.authToken`
77+
- `channels.sms.accounts.*.authToken`
7678
- `channels.discord.token`
7779
- `channels.discord.pluralkit.token`
7880
- `channels.discord.voice.tts.providers.*.apiKey`

docs/reference/secretref-user-supplied-credentials-matrix.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,20 @@
337337
"secretShape": "secret_input",
338338
"optIn": true
339339
},
340+
{
341+
"id": "channels.sms.accounts.*.authToken",
342+
"configFile": "openclaw.json",
343+
"path": "channels.sms.accounts.*.authToken",
344+
"secretShape": "secret_input",
345+
"optIn": true
346+
},
347+
{
348+
"id": "channels.sms.authToken",
349+
"configFile": "openclaw.json",
350+
"path": "channels.sms.authToken",
351+
"secretShape": "secret_input",
352+
"optIn": true
353+
},
340354
{
341355
"id": "channels.telegram.accounts.*.botToken",
342356
"configFile": "openclaw.json",

extensions/sms/contract-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export {
2+
collectRuntimeConfigAssignments,
3+
secretTargetRegistryEntries,
4+
} from "./src/secret-contract.js";
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export {
2+
channelSecrets,
3+
collectRuntimeConfigAssignments,
4+
secretTargetRegistryEntries,
5+
} from "./src/secret-contract.js";

extensions/sms/src/channel.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ describe("smsPlugin status", () => {
6565
describe("smsPlugin outbound", () => {
6666
it("declares an active text chunker and account-aware chunk limit", () => {
6767
expect(smsPlugin.configSchema).toBeDefined();
68+
expect(smsPlugin.status?.probeAccount).toBeDefined();
69+
expect(smsPlugin.status?.formatCapabilitiesProbe).toBeDefined();
70+
expect(smsPlugin.secrets?.secretTargetRegistryEntries?.map((entry) => entry.id)).toEqual([
71+
"channels.sms.accounts.*.authToken",
72+
"channels.sms.authToken",
73+
]);
6874
expect(smsPlugin.messaging?.targetPrefixes).toEqual(["twilio-sms"]);
6975
expect(smsPlugin.outbound?.chunker?.("alpha beta", 6)).toEqual(["alpha", "beta"]);
7076
expect(

extensions/sms/src/channel.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import {
2828
normalizeSmsAllowFrom,
2929
normalizeSmsPhoneNumber,
3030
} from "./phone.js";
31+
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
3132
import { sendSmsTextChunks, toSmsPlainText } from "./send.js";
33+
import { formatSmsProbeLines, probeSmsAccount, type SmsProbe } from "./status.js";
3234
import type { ResolvedSmsAccount } from "./types.js";
3335

3436
const CHANNEL_ID = "sms";
@@ -227,7 +229,7 @@ const smsMessageAdapter = defineChannelMessageAdapter({
227229
},
228230
});
229231

230-
export const smsPlugin: ChannelPlugin<ResolvedSmsAccount> = createChatChannelPlugin({
232+
export const smsPlugin: ChannelPlugin<ResolvedSmsAccount, SmsProbe> = createChatChannelPlugin({
231233
base: {
232234
id: CHANNEL_ID,
233235
meta: {
@@ -302,11 +304,17 @@ export const smsPlugin: ChannelPlugin<ResolvedSmsAccount> = createChatChannelPlu
302304
lastInboundAt: null,
303305
lastOutboundAt: null,
304306
},
307+
probeAccount: async ({ account, timeoutMs }) => await probeSmsAccount({ account, timeoutMs }),
308+
formatCapabilitiesProbe: ({ probe }) => formatSmsProbeLines(probe),
305309
buildAccountSnapshot: buildSmsAccountSnapshot,
306310
buildCapabilitiesDiagnostics: async ({ account }) => ({
307311
lines: collectSmsStartupWarnings(account).map((text) => ({ text, tone: "warn" })),
308312
}),
309313
},
314+
secrets: {
315+
secretTargetRegistryEntries,
316+
collectRuntimeConfigAssignments,
317+
},
310318
agentPrompt: {
311319
messageToolHints: () => [
312320
"",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
2+
import {
3+
applyResolvedAssignments,
4+
createResolverContext,
5+
resolveSecretRefValues,
6+
} from "openclaw/plugin-sdk/secret-ref-runtime";
7+
import { describe, expect, it } from "vitest";
8+
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
9+
10+
async function resolveSmsSecretAssignments(
11+
sourceConfig: OpenClawConfig,
12+
env: NodeJS.ProcessEnv,
13+
): Promise<{
14+
config: OpenClawConfig;
15+
warnings: ReturnType<typeof createResolverContext>["warnings"];
16+
}> {
17+
const resolvedConfig: OpenClawConfig = structuredClone(sourceConfig);
18+
const context = createResolverContext({ sourceConfig, env });
19+
20+
collectRuntimeConfigAssignments({
21+
config: resolvedConfig,
22+
defaults: sourceConfig.secrets?.defaults,
23+
context,
24+
});
25+
26+
const resolved = await resolveSecretRefValues(
27+
context.assignments.map((assignment) => assignment.ref),
28+
{
29+
config: sourceConfig,
30+
env: context.env,
31+
cache: context.cache,
32+
},
33+
);
34+
applyResolvedAssignments({ assignments: context.assignments, resolved });
35+
36+
return { config: resolvedConfig, warnings: context.warnings };
37+
}
38+
39+
describe("sms secret contract", () => {
40+
it("publishes SMS auth token targets", () => {
41+
expect(secretTargetRegistryEntries.map((entry) => entry.id)).toEqual([
42+
"channels.sms.accounts.*.authToken",
43+
"channels.sms.authToken",
44+
]);
45+
});
46+
47+
it("resolves top-level authToken SecretRefs for SMS accounts", async () => {
48+
const resolved = await resolveSmsSecretAssignments(
49+
{
50+
channels: {
51+
sms: {
52+
enabled: true,
53+
accountSid: "AC123",
54+
authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" },
55+
fromNumber: "+15557654321",
56+
},
57+
},
58+
} as OpenClawConfig,
59+
{ TWILIO_AUTH_TOKEN: "resolved-token" },
60+
);
61+
62+
expect(resolved.config.channels?.sms?.authToken).toBe("resolved-token");
63+
expect(resolved.warnings).toStrictEqual([]);
64+
});
65+
66+
it("keeps top-level authToken active for an implicit default sender plus named accounts", async () => {
67+
const resolved = await resolveSmsSecretAssignments(
68+
{
69+
channels: {
70+
sms: {
71+
enabled: true,
72+
accountSid: "AC123",
73+
authToken: { source: "env", provider: "default", id: "TWILIO_DEFAULT_TOKEN" },
74+
fromNumber: "+15557654321",
75+
accounts: {
76+
support: {
77+
enabled: true,
78+
accountSid: "AC456",
79+
authToken: { source: "env", provider: "default", id: "TWILIO_SUPPORT_TOKEN" },
80+
fromNumber: "+15558675309",
81+
},
82+
},
83+
},
84+
},
85+
} as OpenClawConfig,
86+
{
87+
TWILIO_DEFAULT_TOKEN: "resolved-default-token",
88+
TWILIO_SUPPORT_TOKEN: "resolved-support-token",
89+
},
90+
);
91+
92+
expect(resolved.config.channels?.sms?.authToken).toBe("resolved-default-token");
93+
expect(resolved.config.channels?.sms?.accounts?.support?.authToken).toBe(
94+
"resolved-support-token",
95+
);
96+
expect(resolved.warnings).toStrictEqual([]);
97+
});
98+
99+
it("keeps top-level authToken active for env-backed default senders plus named accounts", async () => {
100+
const resolved = await resolveSmsSecretAssignments(
101+
{
102+
channels: {
103+
sms: {
104+
enabled: true,
105+
authToken: { source: "env", provider: "default", id: "TWILIO_DEFAULT_TOKEN" },
106+
accounts: {
107+
support: {
108+
enabled: true,
109+
accountSid: "AC456",
110+
authToken: { source: "env", provider: "default", id: "TWILIO_SUPPORT_TOKEN" },
111+
fromNumber: "+15558675309",
112+
},
113+
},
114+
},
115+
},
116+
} as OpenClawConfig,
117+
{
118+
TWILIO_ACCOUNT_SID: "AC-env",
119+
TWILIO_PHONE_NUMBER: "+15550001111",
120+
TWILIO_DEFAULT_TOKEN: "resolved-default-token",
121+
TWILIO_SUPPORT_TOKEN: "resolved-support-token",
122+
},
123+
);
124+
125+
expect(resolved.config.channels?.sms?.authToken).toBe("resolved-default-token");
126+
expect(resolved.config.channels?.sms?.accounts?.support?.authToken).toBe(
127+
"resolved-support-token",
128+
);
129+
expect(resolved.warnings).toStrictEqual([]);
130+
});
131+
132+
it("treats top-level authToken refs as inactive when all enabled accounts override them", async () => {
133+
const resolved = await resolveSmsSecretAssignments(
134+
{
135+
channels: {
136+
sms: {
137+
authToken: { source: "env", provider: "default", id: "UNUSED_TWILIO_TOKEN" },
138+
accounts: {
139+
support: {
140+
enabled: true,
141+
accountSid: "AC456",
142+
authToken: { source: "env", provider: "default", id: "TWILIO_SUPPORT_TOKEN" },
143+
fromNumber: "+15558675309",
144+
},
145+
},
146+
},
147+
},
148+
} as OpenClawConfig,
149+
{ TWILIO_SUPPORT_TOKEN: "resolved-support-token" },
150+
);
151+
152+
expect(resolved.config.channels?.sms?.authToken).toEqual({
153+
source: "env",
154+
provider: "default",
155+
id: "UNUSED_TWILIO_TOKEN",
156+
});
157+
expect(resolved.config.channels?.sms?.accounts?.support?.authToken).toBe(
158+
"resolved-support-token",
159+
);
160+
expect(resolved.warnings.map((warning) => warning.path)).toContain("channels.sms.authToken");
161+
});
162+
});

0 commit comments

Comments
 (0)