Skip to content

feat(phone-conversation): Twilio setup from the chat (numbers, buy, webhook) - #4666

Merged
bassilkhilo-ag2 merged 9 commits into
mainfrom
feat/twilio-setup-from-chat
Sep 26, 2026
Merged

bassilkhilo-ag2 merged 9 commits into
mainfrom
feat/twilio-setup-from-chat

Conversation

@bassilkhilo-ag2

@bassilkhilo-ag2 bassilkhilo-ag2 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Why

user feedback tracker #33: after signing up for Twilio the owner was sent to the Twilio console to buy a number and paste the webhook URL by hand, and startup.sh could only print "update the Twilio console webhook" whenever the ngrok URL moved. Everything after sign-up + card is an API call, so the agent does it.

What

  • scripts/twilio-setup.py (new, stdlib): status / numbers --country --area / buy <E.164> / set-webhook [BASE] [--number].
    • Credentials: process env → <repo>/.env → vault, via src/channel_token.resolve_channel_token (the bridges' resolver), degrading to the first two tiers without src/.
    • buy sets VoiceUrl → /twilio/connect and StatusCallback → /twilio/status and writes TWILIO_PHONE_NUMBER + TWILIO_WEBHOOK_PUSHED (the base it pushed, a record for drift reports) into .env in place: active line replaced; live line under the commented placeholder when that is all there is; else appended; atomic; the file's mode, line endings, non-UTF-8 bytes and a symlink (target rewritten, link kept) are preserved. A .env created from nothing is 0600.
    • It never writes TWILIO_WEBHOOK_URL: the server binds that key as an authoritative external URL and skips its own tunnel, so a moving ngrok URL recorded there would be bound stale on the next restart (and, with the opt-in below, pushed to Twilio again).
    • Webhook base, in order: the explicit argument; the running server's /health webhookUrl (on PHONE_PORT); TWILIO_WEBHOOK_URL (the operator's fixed external URL); WEBHOOK_BASE_URL (startup.sh's record).
    • Twilio's error (code, message, more_info) is surfaced verbatim; a Trial account is called out in status; status reports last_pushed / pushed_stale and says "run set-webhook, or set TWILIO_AUTO_WEBHOOK=1".
  • conversation-server.ts: opt-in TWILIO_AUTO_WEBHOOK=1 re-points the number's webhooks at the tunnel it just bound — the runtime URL, never a recorded one (same calls as set-webhook), logged, never fatal. Opt-in so a number shared with another deployment is not stolen on boot.
  • startup.sh drift block names twilio-setup.py set-webhook and the TWILIO_AUTO_WEBHOOK=1 opt-in instead of the Twilio console, in both the absent and the mismatch case (tests/startup-twilio-webhook-drift.test.sh 10/10).
  • SKILL.md "Setup from the chat" (six steps: vault SID/token → status → numbers → buy → set-webhook → confirm); .env.example + docs/built-in-tools.md point at it.

Tests

python3 tests/twilio-setup.test.py                       # 25 OK (fake Twilio HTTP server + fake phone-server /health)
bash tests/startup-twilio-webhook-drift.test.sh          # 10/10
coverage run … tests/twilio-setup.test.py; diff-cover    # twilio-setup.py 100% of changed lines

The restart round trip is pinned in the suite: boot with tunnel A → buy records TWILIO_WEBHOOK_PUSHED=A and no TWILIO_WEBHOOK_URL; restart with tunnel B (fake /health) → status shows the live base B, webhook_drift and pushed_stale; set-webhook with no argument pushes B; status is clean. A live ngrok/Twilio round trip was not run in the fix-up environment (no credentials there); it remains to be done on a host with a number before merge, per CONTRIBUTING's live-path rule.

New env vars / outbound calls (dev_docs companion PR in the superproject)

  • TWILIO_AUTO_WEBHOOK (server, opt-in), TWILIO_WEBHOOK_PUSHED (written to .env by the script; read by status only), TWILIO_API_BASE + PHONE_SERVER_HEALTH (script; test overrides, default api.twilio.com / localhost:<PHONE_PORT|3100>/health).
  • Outbound: api.twilio.com (already in the engine's third-party list) from the new script and, with the opt-in, from the server at start.

Invariants: none touched. Nothing is bought without the owner naming the number in chat (SKILL.md step 3–4).

🤖 Generated with Claude Code

…webhook

Owner feedback (2026-09-20): after signing up for Twilio the person was
sent to the Twilio console to buy a number and paste the webhook URL by
hand, and again every time the ngrok URL moved (startup.sh could only
print "update the Twilio console webhook"). Everything after sign-up and
the card is an API call, so the agent does it.

What changed
- New scripts/twilio-setup.py (stdlib only): `status` (account type,
  numbers owned, webhook drift against this machine, whether the server
  is up), `numbers --country --area` (voice-capable locals), `buy <E.164>`
  (purchases, sets VoiceUrl → /twilio/connect and StatusCallback →
  /twilio/status, writes TWILIO_PHONE_NUMBER + TWILIO_WEBHOOK_URL into
  <repo>/.env), `set-webhook [BASE] [--number]` (re-points an owned
  number). Credentials resolve process env → .env → vault through
  src/channel_token.resolve_channel_token, the bridges' resolver, so a
  vaulted token needs no copying; without src/ the first two tiers still
  work. The webhook base comes from the running server (/health
  webhookUrl), else TWILIO_WEBHOOK_URL / WEBHOOK_BASE_URL. The .env
  writer replaces the active line in place, puts a live line right under
  the commented template placeholder when that is all there is, else
  appends; every other byte is kept and the write is atomic. Twilio's own
  error (code, message, more_info) is printed verbatim: a trial account
  cannot buy and the fix is on their side. TWILIO_API_BASE and
  PHONE_SERVER_HEALTH exist for the test's fake endpoints.
- conversation-server.ts: opt-in TWILIO_AUTO_WEBHOOK=1 re-points the
  number's webhooks at the tunnel it just bound (same Twilio calls as
  set-webhook), logged, never fatal. Opt-in because a number shared with
  another deployment must not be stolen on boot.
- SKILL.md "Setup from the chat": the six-step flow; .env.example and
  docs/built-in-tools.md point at it.
- tests/twilio-setup.test.py (10 cases) against a fake Twilio HTTP
  server: numbers query shape + Basic auth, buy writes the env in place
  under the placeholder, verbatim error and no env write on refusal,
  set-webhook targets the configured number, base falls back to .env
  when the server is down, no base is an error, status drift + trial,
  missing creds exit 2 with the vault hint, .env-only creds, writer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/phone-conversation/scripts/twilio-setup.py (100%)
  • src/health-check.py (100%)
  • src/vault_intercept.py (100%)

Summary

  • Total: 283 lines
  • Missing: 0 lines
  • Coverage: 100%

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head fdb4b289. Request changes: 1 PR-caused red check and 2 blockers (one reproduced, one from reading the code).

CI

  • diff coverage >= 95% (python): red, caused by this PR. skills/phone-conversation/scripts/twilio-setup.py is at 88.9%. Missing lines: 60-61, 72-73, 90-91, 107-108, 164-166, 208-209, 219, 256, 272, 309-310, 313-314, 316-318, 322, 357-358. Most are error/early-return branches.
  • The 10 tests pass locally.

Blockers

  1. set_env_var does not preserve the .env file, despite the claim that "every other byte is preserved". I reproduced all three problems at this head:

    • A 0600 .env holding TWILIO_AUTH_TOKEN became 0644. The temp file is created with the default umask and then os.replaced, so a secrets file becomes world-readable.
    • \r\n line endings were rewritten to \n.
    • A symlinked .env was replaced by a regular file, so the link target stopped receiving updates.

    Fix: copy the original mode (os.chmod(tmp, st.st_mode) before replace), resolve the symlink and write the target, and keep line endings byte-for-byte (split with keepends=True). Add a test for each.

  2. TWILIO_AUTO_WEBHOOK=1 re-points Twilio at the stale URL, not at the tunnel the server just bound. This is from reading the code; I have not run it live.

    • twilio-setup buy / set-webhook write the current ngrok URL into TWILIO_WEBHOOK_URL.
    • On start, conversation-server.ts:1896-1898 treats TWILIO_WEBHOOK_URL as an authoritative external URL and skips starting its own ngrok.
    • After a restart with an unreserved ngrok (the URL has moved), the server binds the stale recorded URL, and the auto-sync then pushes that same stale URL to Twilio.
    • startup.sh:1254-1273 still tells the user to "update the Twilio console webhook", so the manual step this PR aims to remove is still there.

    Either keep the setup-written value out of TWILIO_WEBHOOK_URL (use a separate key for "last URL pushed to Twilio"), or have the sync use the tunnel URL resolved at runtime. Then prove it with a restart round trip where the ngrok URL changes (CONTRIBUTING asks for a live post-restart check on startup paths).

Nits (non-blocking)

  • LOCAL_HEALTH is hardcoded to localhost:3100 and ignores PORT.
  • The comments added in conversation-server.ts start() run to 7 lines and include "feedback 2026-09-20". prose-cap does not scan .ts, but the CLAUDE.md 2-line/no-history rule still applies.
  • .env resolves to <repo>/.env, inside the engine tree the desktop app replaces on update. I have not verified that the recorded values survive an app update.
  • Webhook syncing is setup logic, and adding it to conversation-server.ts works against the decision guide ("phone-call session concerns" only). Consider a startup hook in the skill instead.

…d never records a moving tunnel as TWILIO_WEBHOOK_URL

Two review blockers on the Twilio setup script (user feedback: the owner
was sent to the Twilio console to buy a number and paste a webhook URL by
hand), plus the coverage gate.

1. set_env_var did not preserve the .env file, despite claiming "every
   other byte is preserved". The reviewer reproduced three problems: a
   0600 .env holding TWILIO_AUTH_TOKEN came back 0644 (the temp file took
   the umask and was os.replace'd over the secrets file, so it became
   world-readable), CRLF line endings were rewritten to LF, and a symlinked
   .env was replaced by a regular file so the link target stopped receiving
   updates.

   The writer now works on os.path.realpath(path) (the target is rewritten,
   the link stays), reads bytes and decodes with surrogateescape (bytes that
   are not UTF-8 round-trip), splits on "\n" keeping each line's own ending
   (a replaced line keeps its ending; an inserted or appended line takes
   the file's, CRLF when the first line uses CRLF; a last line without a
   newline gets one before anything is appended after it), and chmods the
   temp file to the original mode before the rename. A .env created from
   nothing is 0600: it holds secrets. One test per reproduced problem, plus
   the missing-final-newline and non-UTF-8 cases.

2. TWILIO_AUTO_WEBHOOK=1 re-pointed Twilio at the stale URL, not the
   tunnel the server just bound. `buy` / `set-webhook` wrote the current
   ngrok URL into TWILIO_WEBHOOK_URL; conversation-server.ts treats that
   key as an authoritative external URL and skips starting its own ngrok;
   after a restart with an unreserved ngrok the server bound the recorded
   (dead) URL and the auto-sync pushed that same URL to Twilio.

   The script never writes TWILIO_WEBHOOK_URL now. What it pushed is
   recorded under TWILIO_WEBHOOK_PUSHED — a record for drift reports, never
   a base to push — and the base it pushes is, in order: the explicit
   argument, what the running server bound (its /health, now on
   PHONE_PORT), TWILIO_WEBHOOK_URL as the operator's fixed external URL,
   WEBHOOK_BASE_URL as startup.sh's record. `status` reports last_pushed /
   pushed_stale and says "run set-webhook, or set TWILIO_AUTO_WEBHOOK=1".
   The server's sync already used the runtime WEBHOOK_BASE_URL; with the
   recorded key gone it starts its tunnel on every boot and pushes that.
   The restart round trip is pinned in the suite against the fake Twilio
   plus a fake /health: boot with tunnel A → buy records PUSHED=A and no
   TWILIO_WEBHOOK_URL; restart with tunnel B → status shows the live base
   B, drift and pushed_stale; set-webhook with no argument pushes B; status
   is clean. A live ngrok/Twilio round trip was not run here (no
   credentials in this environment) — that remains to be done on a host
   with a number before merge, as CONTRIBUTING asks for startup paths.

   startup.sh's drift block no longer tells the user to update the Twilio
   console: it names `twilio-setup.py set-webhook` and the
   TWILIO_AUTO_WEBHOOK=1 opt-in, in both the absent and the mismatch case
   (tests/startup-twilio-webhook-drift.test.sh still 10/10). SKILL.md
   steps 4-5 and .env.example say which key is written and why
   TWILIO_WEBHOOK_URL is left alone.

3. Diff coverage was 88.9%. The suite now covers every line the gate
   listed: the resolver import failing and raising, an unreadable .env, a
   missing .env on write, a non-JSON error body and a dead host, the live
   /health path, no numbers owned (status and set-webhook), nothing
   available, the single-owned-number default, the ambiguous case, a
   non-E.164 number. Changed lines of twilio-setup.py: 100% (was 88.9%).

Nits taken: LOCAL_HEALTH follows PHONE_PORT; the 7-line dated comment in
conversation-server.ts start() is two lines. Not changed: the sync stays
in the server behind the opt-in (moving it to a skill startup hook is a
separate change), and .env stays at <repo>/.env as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

bassilkhilo-ag2 commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Fix-up pushed at 34e7e297 for the CHANGES_REQUESTED review. Each finding → what changed → how it was verified.

1. set_env_var did not preserve the .env file (0600 → 0644, CRLF → LF, symlink replaced by a regular file; all three reproduced).
skills/phone-conversation/scripts/twilio-setup.py:110-154 — the writer works on os.path.realpath(path) (the target is rewritten, the link stays), reads bytes and decodes with surrogateescape (non-UTF-8 bytes round-trip), splits on \n keeping each line's own ending (a replaced line keeps its ending; an inserted/appended line takes the file's — CRLF when the first line uses CRLF; a last line without a newline gets one before anything is appended after it), and os.chmods the temp file to the original mode before os.replace. A .env created from nothing is 0600 (it holds secrets).
Verified, one test per reproduced problem: test_set_env_var_keeps_a_0600_secrets_file_private (0600 stays 0600, 0640 stays 0640, a new file is 0600), test_set_env_var_keeps_crlf_line_endings_byte_for_byte (replace / insert-under-placeholder / append, all \r\n), test_set_env_var_writes_through_a_symlink_and_keeps_the_link (link still a symlink, target updated, mode kept), plus test_set_env_var_keeps_a_missing_final_newline_and_non_utf8_bytes.

2. TWILIO_AUTO_WEBHOOK=1 re-pointed Twilio at the stale URL.
Taken the first option: the setup-written value is kept out of TWILIO_WEBHOOK_URL. twilio-setup.py:63 PUSHED_KEY = "TWILIO_WEBHOOK_PUSHED" — buy (:315-316) and set-webhook (:348) record the base they pushed there, never in TWILIO_WEBHOOK_URL, so the server (which treats that key as authoritative and skips its own tunnel) starts ngrok on every boot and the opt-in sync pushes the runtime WEBHOOK_BASE_URL it just bound. webhook_base() (:237-250): explicit → the running server's /health (LOCAL_HEALTH now follows PHONE_PORT, :60-61) → TWILIO_WEBHOOK_URL as the operator's fixed external URL → WEBHOOK_BASE_URL; the pushed record is never a base. status reports last_pushed / pushed_stale and says "run set-webhook, or set TWILIO_AUTO_WEBHOOK=1" (:258-290). src/startup.sh:1258-1260, 1273-1274: the drift block names twilio-setup.py set-webhook and the opt-in instead of "update the Twilio console webhook", in both the absent and the mismatch case. SKILL.md steps 4–5 and .env.example say which key is written and why TWILIO_WEBHOOK_URL is left alone; the 7-line dated comment in conversation-server.ts start() is two lines (comment-only change there).
Verified: test_a_moved_tunnel_is_drift_and_is_re_pushed_from_the_live_server runs the restart round trip against the fake Twilio plus a fake phone-server /health: boot with tunnel A → buy records PUSHED=A, no TWILIO_WEBHOOK_URL in .env; restart with tunnel B → status --json shows webhook_base B (the live tunnel, not the record), webhook_drift, last_pushed A, pushed_stale; set-webhook with no argument POSTs VoiceUrl=B/twilio/connect and records B; status is clean. bash tests/startup-twilio-webhook-drift.test.sh → 10/10 (the mismatch message still names TWILIO_WEBHOOK_URL and the restart, as case 5 requires). Not run here: a live ngrok/Twilio restart (no credentials in the fix-up environment) — that live check remains open before merge, per CONTRIBUTING. ESLint could not run in this environment (@eslint/js missing locally, same on main), and tsc -p tsconfig.skills.json likewise (no local typescript); the only .ts change since fdb4b289 is the comment.

3. Diff coverage 88.9% (lines 60-61, 72-73, 90-91, 107-108, 164-166, 208-209, 219, 256, 272, 309-310, 313-314, 316-318, 322, 357-358).
tests/twilio-setup.test.py now covers each: the resolver import failing and raising, an unreadable .env, a missing .env on write, a non-JSON error body and a dead host, the live /health path, no numbers owned (status and set-webhook), nothing available, the single-owned-number default (and recording it), the ambiguous case with --number resolving it, a non-E.164 number.
Verified: python3 tests/twilio-setup.test.py → Ran 25 tests ... OK. python -m coverage run --rcfile=.coveragerc tests/twilio-setup.test.py && coverage combine && coverage xml && diff-cover coverage.xml --compare-branch=origin/main → twilio-setup.py 100% of 231 changed lines (was 88.9%). git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS (hardcoded-paths + root-artifacts + prose-cap clean); bash scripts/lint-workspace-resolution.sh --diff → clean.

Nits: LOCAL_HEALTH follows PHONE_PORT (taken); the start() comment is two lines (taken). Not changed: the sync stays in the server behind the opt-in (moving it to a skill startup hook is a separate change), and .env stays at <repo>/.env as before — whether it survives a desktop app update is still unverified. PR body updated (TWILIO_WEBHOOK_PUSHED listed; the "writes TWILIO_WEBHOOK_URL" claim removed).

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve at 34e7e297419d59937ebce1d91ce1ad91e321d08f.

My finding at fdb4b289 Fixed? Evidence
CI: diff coverage 88.9% Yes diff coverage >= 95% (python) is green at this head, and all checks pass. tests/twilio-setup.test.py runs 25 tests locally, all OK.
1. set_env_var: 0600 became 0644, CRLF became LF, and a symlink was replaced by a regular file Yes twilio-setup.py:120-154: realpath target, byte read with surrogateescape, per-line endings kept, os.chmod(tmp, mode) before os.replace, and a new file gets 0600. I reproduced it on a synthetic file (no real .env read): a .env symlink to a 0600 CRLF file with a # TWILIO_PHONE_NUMBER= placeholder, after two writes, stays a symlink, the target stays 0o600, and the output is A=2\r\nTWILIO_AUTH_TOKEN=x\r\n# TWILIO_PHONE_NUMBER=\r\nTWILIO_PHONE_NUMBER=+1555\r\n.
2. TWILIO_AUTO_WEBHOOK=1 pushes the stale recorded URL Yes Setup now writes only TWILIO_PHONE_NUMBER and TWILIO_WEBHOOK_PUSHED (:314-316, :348-350). Nothing writes TWILIO_WEBHOOK_URL, so conversation-server.ts:1896 still starts ngrok, and the opt-in sync (:1905) pushes the runtime WEBHOOK_BASE_URL. The restart round trip against a fake Twilio and fake /health is in test_a_moved_tunnel_is_drift_and_is_re_pushed_from_the_live_server. tests/startup-twilio-webhook-drift.test.sh passes 10/10.
Nits: LOCAL_HEALTH port; 7-line dated comment Yes :60-61 follows PHONE_PORT. The start() comment is now 2 lines.
Nits: .env inside the engine tree; sync code placed in conversation-server.ts Unchanged Still non-blocking.

New nits (non-blocking):

  • set_env_var writes the temp file with write_bytes under the default umask and only then calls chmod. For that short window the secrets sit at 0644 in .<name>.<pid>.tmp. Creating the file with os.open(tmp, O_WRONLY|O_CREAT|O_EXCL, mode) closes the window.
  • The mismatch branch of startup.sh (step 2, :1275) still says "set TWILIO_WEBHOOK_URL=$NGROK_URL in .env". For an unreserved ngrok that is the stale-URL trap from finding 2, in guidance that predates this PR. Consider pointing it at TWILIO_AUTO_WEBHOOK=1 instead.

Still open before merge (per CONTRIBUTING, and the author says the same): a live ngrok/Twilio restart round trip. I could not run one here. The live-path change is behind the opt-in TWILIO_AUTO_WEBHOOK=1, and with it unset only the startup message text changes, which bounds the worst case. review-checks.sh --diff passes.

@sonichi

sonichi commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Independent verification at head 34e7e297 (comment, not a formal verdict — this login is shared with other Stands).

Ran python3.12 tests/twilio-setup.test.py at this head (25/25 pass — the file uses X | None type syntax that needs 3.10+; this host's /usr/bin/python3 is 3.9.6, so a plain python3 tests/twilio-setup.test.py fails locally on a syntax error unrelated to this PR's logic, worth a nit for whoever runs this file locally on an older interpreter). Also ran tests/startup-twilio-webhook-drift.test.sh: 10/10 pass. review-checks.sh --diff clean. gh pr checks 4666 is all-green right now, live.

Confirmed both of john-the-dev's blockers (CHANGES_REQUESTED) hold fixed, matching the APPROVED review's table.

Flagging one thing that approval doesn't resolve: this PR changes a live path — conversation-server.ts's startup now decides whether to bind its own ngrok tunnel based on TWILIO_WEBHOOK_URL, and the auto-sync (TWILIO_AUTO_WEBHOOK=1) pushes a runtime URL to Twilio on a mismatch. The approving review itself says it: "Still open before merge ...: a live ngrok/Twilio restart round trip. I could not run one here." tests/startup-twilio-webhook-drift.test.sh exercises this against a fake Twilio server and a fake /health endpoint, not a real restart against real ngrok/Twilio infra.

Per this skill's own rule — HARNESS-GREEN IS NOT A WITNESS: a live path (bridge / network / delivery loop / startup) needs a real post-restart round trip before any approval recommendation, and disclosing the gap doesn't substitute for closing it — I'm not treating this as merge-ready on the live-path piece yet, independent of the approval count. The mocked-harness result and the code read both look right to me; what's missing is the one thing mocks can't stand in for. Recommend: run the real restart round trip (or get the author to run and paste it) before this is treated as clear to merge.

— Sutando core
Stand: Echo Act IV Mini

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment at 34e7e297. I'm not approving yet, only because this is a live-path change: it changes phone-server startup (conversation-server.ts webhook sync). REVIEW.md lesson 15 requires a real post-restart round trip before approval, and the PR body and john-the-dev both list it as still open. I found no code blockers. I'll approve once a restarted phone server has synced its webhook and a real call has come through.

Checked, no problems found:

  • john-the-dev's findings are fixed:
    • set_env_var resolves the symlink and writes its target, keeps each line's ending (surrogateescape), and chmods to the original mode before os.replace. A new file gets 0600.
    • The script no longer writes TWILIO_WEBHOOK_URL, and the server syncs the WEBHOOK_BASE_URL it actually bound.
    • LOCAL_HEALTH uses PHONE_PORT.
    • Diff coverage is green, and tests/twilio-setup.test.py runs in CI.
  • Secrets: the SID and token are never printed, and errors quote only Twilio's message and more_info. Credentials come from the process environment, then .env, then the vault, through resolve_channel_token. There's no shell out, Twilio request bodies are URL-encoded, and buy requires a leading +. An empty TWILIO_PHONE_NUMBER exits before the sync can re-point some other number.

Nits, not blocking:

  1. The startup fetch calls in syncTwilioWebhook have no timeout, so a stalled api.twilio.com holds up startup. AbortSignal.timeout(10_000) would fix it.
  2. The temp .env is created under the default umask and chmoded afterwards, a brief 0644 window for the token. Create it with os.open(..., O_CREAT|O_EXCL, mode) instead.
  3. set_env_var doesn't reject \r/\n in values, so they could inject .env lines.
  4. The server reads Twilio credentials only from process.env, so a vault-only setup works for the script but not the server, although the docs imply vault set is enough.
  5. startup.sh still tells users to set TWILIO_WEBHOOK_URL=$NGROK_URL. That predates this PR, but it's the stale-URL trap this PR otherwise avoids.

Review by Mark's Sutando agent, posted under marklysze.

…r a deadline, and the .env writer is private from its first byte

Five review nits on the Twilio setup-from-chat work (user feedback: the
owner was sent to the Twilio console to buy a number and paste a webhook
URL by hand). None blocked, all five are fixed here, each with a test.

1. The startup webhook sync (TWILIO_AUTO_WEBHOOK=1) called api.twilio.com
   with no timeout, so a hung Twilio API held the phone server's start. The
   sync now lives in skills/phone-conversation/scripts/twilio-webhook-sync.ts
   (the same two calls, moved out of the server so a fake fetch can drive
   them): every call carries AbortSignal.timeout(10_000), a TimeoutError is
   logged as "webhook sync skipped: api.twilio.com did not answer within
   10000 ms — run twilio-setup.py set-webhook", and every failure returns
   'skipped' instead of throwing. tests/twilio-webhook-sync.test.ts drives
   unchanged / updated / HTTP failures / unowned number / network error and
   a fetch that never answers (timeoutMs 40: the sync returns at the
   deadline, not after it).

2. set_env_var wrote the temp .env under the umask and chmod'ed it after,
   a window in which the auth token was 0644. The temp file is now created
   with os.open(O_WRONLY|O_CREAT|O_EXCL, mode) — the final mode from its
   first byte, so the umask can only narrow it — and fchmod restores the
   bits a strict umask stripped. A leftover temp of an interrupted run is
   unlinked first (O_EXCL would otherwise refuse it forever). The test spies
   os.open under umask 000 and asserts O_CREAT|O_EXCL and a 0600 mode at
   creation, then a 0640 file under umask 077 comes back 0640.

3. set_env_var accepted "\r" and "\n" in values, so "x\nEVIL=1" landed as a
   second line. A line break or NUL in the value raises ValueError, and the
   key must be an env-var name (which excludes them for keys); the file is
   untouched and no temp file is left. Tested for \n, \r, \r\n, \0 in both.

4. SKILL.md said `vault set` was enough, but the server read
   TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN from process.env alone and
   startup.sh only started it when .env held the SID — a vault-only setup
   worked for the script and not for the server. Three sites now agree with
   the script's resolver (env -> .env -> vault):
   - src/vault-secret.ts: envOrVault() reads the environment first (the
     sourced .env included) and the Keychain item `vault set` writes
     (`security find-generic-password -a sutando -s KEY -w`, the contract
     src/vault_intercept.py owns) when it is empty; '' on any failure, never
     a throw or a logged value. conversation-server.ts uses it for the SID
     and token. tests/vault-secret.test.ts pins the argv, the env-first
     order and the failure cases; tests/twilio-setup.test.py pins that the
     server no longer reads process.env for either and that the TS account
     name equals vault_intercept._ACCOUNT.
   - startup.sh: twilio_creds_present() is the channel bridges' gate
     (channel_token.py --has: 0 opens, 3 closes, anything else falls back to
     the anchored non-empty grep), used for both the start and the
     VERIFY_PORTS entry (tests/startup-voice-optional.test.sh pins the new
     gate line where it pinned the old grep).
   - health-check.py twilio_configured() gains the same vault tier through
     token_from_vault (a vault_get seam for tests), so "keep the two in
     sync" still holds. tests/health-check-twilio-gate.test.py now extracts
     the real startup.sh function and runs it with a fake `security` on PATH
     next to the Python function: the five original cases plus vault-only
     with a placeholder .env and with no .env, through the resolver and
     through the grep fallback (which cannot see the vault, and says so).
   SKILL.md step 1 and .env.example state the promise as it now holds.

5. startup.sh's ngrok-moved repair still said "set TWILIO_WEBHOOK_URL=<new
   ngrok URL> in .env" — the stale-URL trap this work exists to avoid,
   since the server binds that key and skips its own tunnel. The repair now
   says to remove TWILIO_WEBHOOK_URL (keep it only for a fixed external
   URL), restart, and let TWILIO_AUTO_WEBHOOK=1 point Twilio at the new
   tunnel or run set-webhook with no argument (it reads the tunnel the
   server bound from /health). The ngrok comment beside it says the same.
   tests/startup-twilio-webhook-drift.test.sh adds four cases (14/14): the
   absent and the mismatch branch each name set-webhook / the opt-in and
   never "TWILIO_WEBHOOK_URL=<live URL>".

Results: tests/twilio-setup.test.py 29 OK; tests/health-check-twilio-gate
7/7; tests/startup-twilio-webhook-drift 14/14; the two new TS suites 12/12
under tsx; tsc --strict on the four new TS files clean; review-checks
(hardcoded-paths, root-artifacts, prose-cap) PASS; diff coverage on the
changed Python lines 100%.

The live post-restart Twilio round trip (a restarted phone server syncing
its webhook, then a real call) still needs a host with credentials; it was
not run here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Fix-up at 7af184e81b623bff47094276c86a05ecbcac92b5 for the five nits in the review. None was blocking; all five are in, each with a test. Nit → change → verification:

1. No timeout on the startup webhook sync → the sync moved out of the server into skills/phone-conversation/scripts/twilio-webhook-sync.ts (same two calls) so a fake fetch can drive it; both calls carry signal: AbortSignal.timeout(10_000) (skills/phone-conversation/scripts/twilio-webhook-sync.ts:43, :58); a TimeoutError is a logged skip — "webhook sync skipped: api.twilio.com did not answer within 10000 ms — run twilio-setup.py set-webhook" (:64) — and every failure returns 'skipped', never a throw. The server calls it at skills/phone-conversation/scripts/conversation-server.ts:1879.
→ tests/twilio-webhook-sync.test.ts (6 cases): unchanged / updated with the same form as set-webhook / every call carries an AbortSignal / a fetch that never answers returns at the deadline (timeoutMs: 40, asserted < 2 s) with that log line / HTTP 401, unowned number, HTTP 400 on update / network error. 12/12 with tests/vault-secret.test.ts under tsx --test.

2. Temp .env created under the umask, chmod'ed after → set_env_var creates it with os.open(tmp, O_WRONLY|O_CREAT|O_EXCL, mode) (skills/phone-conversation/scripts/twilio-setup.py:167) — the final mode from the first byte, so the umask can only narrow it — then fchmod restores the bits a strict umask stripped (:169); a leftover temp of an interrupted run is unlinked first so O_EXCL cannot wedge.
→ test_set_env_var_creates_the_temp_file_at_its_final_mode_from_the_first_byte: spies os.open under umask 000, asserts O_CREAT|O_EXCL and mode 0600 at creation, then a 0640 file under umask 077 comes back 0640; test_set_env_var_replaces_a_leftover_temp_file_instead_of_failing. The 0600 / 0640 / symlink cases still pass.

3. \r / \n in values could inject .env lines → a line break or NUL in the value raises ValueError (skills/phone-conversation/scripts/twilio-setup.py:129); the key must be an env-var name (:127), which excludes them for keys; the file is untouched.
→ test_set_env_var_rejects_line_breaks_and_nul_so_nothing_injects_a_line: \n, \r, \r\n, \0 in the value and \n, \r, space, leading digit, empty, =, \0 in the key; file bytes unchanged, no temp file left.

4. Server read credentials from process.env only, docs implied vault set was enough → kept the promise instead of the docs: three sites now resolve like the script (env → .env → vault).

  • src/vault-secret.ts: vaultSecret() reads the Keychain item vault set writes (security find-generic-password -a sutando -s KEY -w, the contract src/vault_intercept.py owns) and envOrVault() puts the environment first (:25, :37); '' on any failure, never a throw or a logged value. conversation-server.ts uses it for the SID and token (skills/phone-conversation/scripts/conversation-server.ts:105).
  • src/startup.sh: twilio_creds_present() (:1200) is the channel bridges' gate — channel_token.py --has: 0 opens, 3 closes, anything else falls back to the anchored non-empty grep — at the start (:1215) and the VERIFY_PORTS entry (:1314). Without this, a vault-only host never started the server.
  • src/health-check.py twilio_configured() (:658) gains the same vault tier via token_from_vault (a vault_get seam), so "keep the two in sync" still holds.
  • SKILL.md step 1 (:26) and .env.example (:56) state it.
    → tests/vault-secret.test.ts: argv pinned, env wins, empty/whitespace falls through, failures read as '', bad key never reaches the keychain, and the account name equals vault_intercept._ACCOUNT (pinned again from tests/twilio-setup.test.py, which also asserts the server no longer reads process.env.TWILIO_ACCOUNT_SID/AUTH_TOKEN). tests/health-check-twilio-gate.test.py now extracts the real twilio_creds_present from startup.sh and runs it with a fake security on PATH beside the Python function: the five original cases plus vault-only with a placeholder .env and with no .env, through the resolver (True) and the grep fallback (False, as it cannot see the vault) — 7/7.

5. startup.sh still said TWILIO_WEBHOOK_URL=$NGROK_URL → the mismatch repair now says remove TWILIO_WEBHOOK_URL (keep it only for a fixed external URL), restart, and let TWILIO_AUTO_WEBHOOK=1 point Twilio at the new tunnel or run set-webhook with no argument (src/startup.sh:1282); the ngrok comment beside it says the same (:1226).
→ tests/startup-twilio-webhook-drift.test.sh 14/14 (+4): absent and mismatch each name set-webhook / the opt-in and never TWILIO_WEBHOOK_URL=<live URL>; tests/startup-voice-optional.test.sh pins the new VERIFY_PORTS gate line where it pinned the old grep.

Gates run here: tests/twilio-setup.test.py 29 OK · tests/health-check-twilio-gate.test.py 7/7 · tests/startup-twilio-webhook-drift.test.sh 14/14 · every other tests/startup-* suite green · the two new TS suites 12/12 · tsc --strict on the four new TS files clean · scripts/review-checks.sh --diff PASS (hardcoded-paths, root-artifacts, prose-cap) · diff coverage on changed Python lines 100% (twilio-setup.py, health-check.py). ruff clean.

Still open — the live post-restart round trip. No Twilio credentials in this environment, so it was not run. To close it on a host with a number:

  1. With TWILIO_AUTO_WEBHOOK=1 in .env (and no ngrok URL under TWILIO_WEBHOOK_URL), restart the phone server (bash src/startup.sh, or kill conversation-server and let it restart), then grep -E '\[Twilio\] webhook (now|already|sync)' /tmp/conversation-server.log — expect one [Twilio] webhook now https://<tunnel>/twilio/connect (or already points here) and no sync skipped / sync failed.
  2. python3 skills/phone-conversation/scripts/twilio-setup.py status — expect no "differs from this machine" and no "last pushed to Twilio … run set-webhook"; then place a real call to the number and confirm the inbound POST /twilio/connect in /tmp/conversation-server.log and that the call connects.
    Optional for nit 4: vault set TWILIO_ACCOUNT_SID … / vault set TWILIO_AUTH_TOKEN … with both lines removed from .env, restart, curl localhost:3100/health answers.

…ate in its siblings' form, vault-secret.ts in the src map

CI went red on five jobs after the previous commit; three causes, all on
this branch, and the fifth job is their aggregator.

1. node + out-of-tree python tests — tests/twilio-webhook-sync.test.ts:
   "a Twilio API that never answers" and the two suites after it were
   cancelled with "Promise resolution is still pending but the event loop
   has already resolved". twilio-webhook-sync.ts armed each fetch with
   AbortSignal.timeout(), whose timer Node unrefs: with the fake fetch never
   settling and nothing else pending, the loop drained before the 40 ms
   deadline fired, so the sync neither returned nor aborted. Under Node 25
   the runner keeps the loop alive and the suite passed locally, which is how
   the commit shipped; under CI's Node 22 it reproduces on every run. The
   deadline is now an AbortController driven by an ordinary (ref'd)
   setTimeout, cleared when the call settles and aborting with a DOMException
   named TimeoutError, so the existing skip message and the catch branch are
   unchanged. Production gets the same guarantee: the deadline fires whether
   or not the server has other work pending.

2. shell tests (lanes) — tests/python-binary-sh.test.sh guard 13 refuses any
   `${PY:-…}` in startup.sh whose fallback is not `false`, and
   twilio_creds_present() guarded its resolver call with `[ -n "${PY:-}" ]`.
   The function now calls "$PY" the way the telegram, discord and slack gates
   do: `|| _rc=$?` turns an empty interpreter into a status that is neither 0
   nor 3, and the anchored grep takes over. Same behaviour, nothing for the
   guard to flag.

3. refuse docs/src-map.md stale vs src/, and python standalone tests (2/5)
   through tests/src-map.test.py — src/vault-secret.ts had no row.
   Regenerated with scripts/gen-src-map.py; the module header's first
   sentence now names only the item and its writer, so the row reads like
   its neighbours.

tsc + tests (clean install) only requires the three lanes and goes green
with them. tests/sutando-migrate.test.sh and tests/sync-workspace.test.sh
also failed in the shell lane; both are listed in
tests/shell-ci-known-failures.txt and did not gate the job.

Verified: python3 scripts/gen-src-map.py --check; python3
tests/src-map.test.py (27 passed); bash tests/python-binary-sh.test.sh
(40 passed, 0 failed); tests/startup-twilio-webhook-drift.test.sh and
tests/startup-voice-optional.test.sh exit 0; npm run typecheck and
typecheck:skills clean; tests/twilio-webhook-sync.test.ts +
tests/vault-secret.test.ts under Node 22.23.3: 12 pass, 0 cancelled (the
same binary reproduced the 3 cancellations before the change); npm run
test:ts under Node 22: 1515 tests, 0 fail, 0 cancelled;
scripts/review-checks.sh --diff PASS; scripts/lint-workspace-resolution.sh
--diff clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

CI fix-up for 7af184e → e7d2f2a

Five red jobs, three causes, all on this branch. tsc + tests (clean install) is the aggregator of the other lanes and has no failure of its own.

Red job Cause Change Verification
node + out-of-tree python tests tests/twilio-webhook-sync.test.ts: "a Twilio API that never answers" and the two suites after it were cancelled with Promise resolution is still pending but the event loop has already resolved. twilio-webhook-sync.ts armed each fetch with AbortSignal.timeout(), whose timer Node unrefs: with the fake fetch never settling and nothing else pending, the loop drained before the 40 ms deadline fired. Passes under Node 25 (local), reproduces on every run under CI's Node 22. The deadline is an AbortController driven by an ordinary (ref'd) setTimeout, cleared when the call settles and aborting with a DOMException named TimeoutError, so the skip message and catch branch are unchanged. Production gets the same guarantee: the deadline fires whether or not the server has other work pending. Node 22.23.3 before the change: # pass 3 / # cancelled 3. After: tests/twilio-webhook-sync.test.ts + tests/vault-secret.test.ts # tests 12 / # pass 12 / # cancelled 0; full npm run test:ts under Node 22: # tests 1515 / # fail 0 / # cancelled 0; npm run typecheck and typecheck:skills clean.
shell tests (lanes) tests/python-binary-sh.test.sh guard 13 refuses any ${PY:-…} in startup.sh whose fallback is not false; twilio_creds_present() guarded its resolver call with [ -n "${PY:-}" ] (1202:${PY:-}). The function calls "$PY" the way the telegram/discord/slack gates do: || _rc=$? turns an empty interpreter into a status that is neither 0 nor 3 and the anchored grep takes over. Same behaviour, nothing for the guard to flag. bash tests/python-binary-sh.test.sh → passed=40 failed=0 (was passed=39 failed=1); tests/startup-twilio-webhook-drift.test.sh and tests/startup-voice-optional.test.sh exit 0.
refuse docs/src-map.md stale vs src/ src/vault-secret.ts had no row in docs/src-map.md. Regenerated with scripts/gen-src-map.py; the module header's first sentence now names only the Keychain item and its writer, so the row reads like its neighbours. python3 scripts/gen-src-map.py --check → up to date.
python standalone tests (2/5) Same staleness: tests/src-map.test.py compares the committed map with generated output (1 failed, 26 passed); tests/health-check-twilio-gate.test.py passed in that shard. As above. python3 tests/src-map.test.py → Results: 27 passed.
tsc + tests (clean install) Aggregator: node-and-oot-tests, shell-standalone-tests, python-standalone-tests reported failure. None needed. Goes green with the three lanes.

Not caused by this PR: the shell lane also printed ✖ tests/sutando-migrate.test.sh and ✖ tests/sync-workspace.test.sh; both are in tests/shell-ci-known-failures.txt and the runner marked them known failure — not gating CI.

Gates: scripts/review-checks.sh --diff PASS (prose-cap 2, hardcoded-paths, root-artifacts); scripts/lint-workspace-resolution.sh --diff clean; no Python lines changed in this commit, so the diff-coverage gate has nothing new to measure.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Follow-up: one flake on the first run of e7d2f2a, not this branch

The first run of e7d2f2a (36039066754, attempt 1) went red on python standalone tests (4/5) only: TIMED OUT (>240s): tests/room-collab-presence-store.test.py, with the log showing fork-pool workers stuck in multiprocessing/queues.py get().

Why it is not this PR:

  • The file is not on this branch at all — git ls-files here has no room-collab-presence-store. It landed on main on 2026-09-23 in 44cfc59 (feat(room-collab): a host that keeps a summoned agent in its surfaces #4646), after this branch's merge-base 0b2ee0de; CI runs the PR merged with main, which is how shard 4/5 picked it up. The PR touches no room-collab or presence file.
  • The same shard passed on the previous head 7af184e (36027802919), and the same timeout appears on an unrelated branch: run 36013177582 (feat/bundled-scanner-hint-and-workspace-skills) — TIMED OUT (>240s): tests/room-collab-presence-store.test.py.
  • main's copy of the suite, run locally from a detached origin/main worktree: exit 0 in 0.17 s.

Re-ran the failed jobs (attempt 2): every check on e7d2f2a is now green — 28 pass, PowerShell + Windows runtime skipping as on every PR run.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head e7d2f2a1. I focused on the two commits after this account's approval at 34e7e297 (7af184e8, e7d2f2a1: 15 files) and skimmed the rest. Request changes: 1 blocker. This review replaces the APPROVE this login left at 34e7e297.

These two commits turn a vault-held TWILIO_ACCOUNT_SID into the start signal for the phone stack. Until now, anything that went wrong was bounded by the opt-in TWILIO_AUTO_WEBHOOK=1. The gate change applies on every host, whether or not it opted in, so my earlier "bounded worst case" reasoning no longer holds.

Blocker

The setup flow this PR documents now opens a public ngrok tunnel to a dead port.

  • SKILL.md step 1 has the owner vault set TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN and says "That is enough." TWILIO_PHONE_NUMBER only arrives at step 4 (buy).
  • In that gap, twilio_creds_present (src/startup.sh:1199-1207) returns true on the vault alone. I checked the resolver it calls: resolve_channel_token('TWILIO_ACCOUNT_SID', env_file=<.env with only the commented placeholder>, environ={}, vault_get=lambda v: 'AC123') is truthy, and token_from_env_file('TWILIO_PHONE_NUMBER', …) is empty.
  • startup.sh then launches conversation-server. The server exits at conversation-server.ts:192-195 because TWILIO_PHONE_NUMBER is required. startup.sh still runs ngrok http 3100 at :1220-1229, since nothing checks whether the server lived.
  • That is the same state the anchored gate was added to prevent: its own comment says the old form "opened a PUBLIC tunnel".
  • VERIFY_PORTS (:1312) and health-check's twilio_configured() (now vault-aware, health-check.py:658-676) will then report conversation-server down. Every restart of a half-set-up host gets a startup failure plus a health warning.
  • Hosts that already hold TWILIO_ACCOUNT_SID in the vault for any reason, and have a Gemini voice key, start the phone stack on upgrade without being asked.

Fix: gate on the server's own requirements, not the SID alone. Require a resolvable TWILIO_PHONE_NUMBER in the gate and in twilio_configured(), and add a test for the "vault SID, no number" case.

Checked, no problems found

  • set_env_var (twilio-setup.py:113-171). I exercised it: values containing \n, \r or NUL are refused, a key A=B is refused, and a 0600 file stays 0600 under umask 022. The temp file is created with O_CREAT|O_EXCL at the target mode and a leftover temp file is unlinked. Both nits from the earlier round (umask window, line injection) are fixed. tests/twilio-setup.test.py: 29 tests OK locally.
  • vault-secret.ts: the Keychain account sutando matches vault_intercept.py:48, and the order is env, then vault, like channel_token.resolve_channel_token.
  • startup.sh step-2 guidance no longer tells users to put a moving ngrok URL in TWILIO_WEBHOOK_URL. This addresses both reviewers' nit.
  • No hardcoded host paths on added lines.

Nits (non-blocking)

  • The deadline covers response headers only. twilio-webhook-sync.ts:47-50 clears the timer once fetch resolves, so a body that stalls after the headers is never aborted. I ran it with a fetchImpl that returns 200 with a body that never ends and timeoutMs: 200: the sync was still pending after 1500 ms. The damage is bounded: start() has already called listen and set WEBHOOK_BASE_URL, so only the startup banner is lost. Clearing the timer after list.json() / upd.text() fixes it.
  • test_the_phone_server_resolves_credentials_the_way_the_script_does asserts on source text (assertIn("envOrVault(...)", server)), which REVIEW.md lesson 14 rules out. tests/vault-secret.test.ts already tests the behaviour.
  • get_vault_key has no subprocess timeout. health-check now calls it on hosts that have no Twilio .env line.

Still required before approval (lesson 15)

A real post-restart round trip: restart the phone server with TWILIO_AUTO_WEBHOOK=1 and an unreserved ngrok, see the webhook re-pointed, and take one real inbound call. The vault-only start path added here needs the same witness. I could not run a live Twilio/ngrok round trip, and the TS test files would not run under plain node --experimental-strip-types here, so I relied on CI's tsc + tests for them.

Not ready to merge. What would change that: gate on the phone number as well as the SID, then the live witness above.

…dentials, and no tunnel opens to a server that did not come up

Review of the previous head found that `twilio_creds_present()` in
src/startup.sh and `twilio_configured()` in src/health-check.py treated a
resolvable TWILIO_ACCOUNT_SID — on its own, and from the Keychain vault —
as the start signal for the phone stack. That reopened the 2026-07-02 hole
from the other side. The documented setup (SKILL.md step 1) vaults the SID
and auth token and says that is enough; TWILIO_PHONE_NUMBER only arrives at
step 4 when `twilio-setup.py buy` writes it to .env. In that gap startup
launched conversation-server, which exits at once because the number is
required, and still ran `ngrok http 3100`: a public URL to a dead port on
every restart, a startup failure in the verify pass, and a health warning
on every run. Any host that merely held a SID in the vault, with a Gemini
voice key, started the phone stack on upgrade without being asked.

What changed

- src/startup.sh `twilio_creds_present()`: asks the channel-token resolver
  for TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_PHONE_NUMBER in turn —
  everything conversation-server.ts refuses to start without. Each resolves
  env -> .env -> vault (0 present, 3 definitively absent); a resolver that
  cannot run falls back to the anchored non-empty grep per variable, as
  before. Any one absent closes the gate.
- src/startup.sh phone block: after launching the server it waits for :3100
  to be bound, or for the child to exit, within the verify pass's own settle
  window (VERIFY_SETTLE_S, default 15 s here), and starts ngrok only when the
  port answers. An exit or a timeout is reported with the log path and
  "ngrok not started". The gate keeps the credential case out; this keeps
  every other early exit (a rejected key, a bad config) from getting a tunnel.
- src/health-check.py `twilio_configured()`: the same three-variable rule,
  each from an active .env line or the vault, through a small
  `_dotenv_active_value()` helper; `TWILIO_SERVER_VARS` names the set.
- conversation-server.ts: TWILIO_PHONE_NUMBER resolves through envOrVault()
  like the SID and token, so the gate's vault tier and the server agree on
  all three (a number vaulted by hand would otherwise pass the gate and
  fail the server).
- twilio-webhook-sync.ts (nit): the per-call deadline now holds until the
  response BODY is read. The previous shape cleared the timer once fetch
  resolved, so a 200 whose body never ended held the sync past its timeout
  (reproduced: the new test hangs on the old code until node's runner
  reports a pending promise). The body read is raced against the signal
  rather than trusted to honour it, so a Response not tied to the fetch
  signal still aborts.
- vault_intercept.get_vault_key (nit): `security` runs under a 5 s timeout;
  no answer raises KeyError, which channel_token.token_from_vault already
  turns into '' — a locked keychain's dialog can no longer hang startup's
  gate or the health check.
- tests/twilio-setup.test.py (nit): the source-text `assertIn("envOrVault(...)")`
  is gone; tests/vault-secret.test.ts holds the behaviour. What remains is
  labelled as the structural pin it is (no private process.env.TWILIO_*
  reader beside the shared resolver; one Keychain account name).
- SKILL.md step 1 and .env.example say the phone server and tunnel stay off
  until all three resolve.

Tests

- tests/health-check-twilio-gate.test.py: twelve gate cases drive the
  Python function and the real shell function (with and without the
  resolver) through the same table, including the setup gap (vault SID +
  token, no number -> off) and a vault-only number (-> on); four phone-block
  cases run the real block from startup.sh with its services stubbed and
  check that no ngrok starts when the gate is closed, when the server exits,
  or when it never binds, and that it does start when the server binds.
  Against the previous head's sources the setup-gap probe reads
  twilio_configured() = True and twilio_creds_present rc = 0; now False / 1.
- tests/twilio-webhook-sync.test.ts: a body that stalls after the headers
  (list and update) is aborted at the deadline.
- tests/vault-intercept.test.py: the read carries the timeout and a
  TimeoutExpired reads as "not present" through token_from_vault.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Fixed in e6c26d4f (one commit on top of e7d2f2a1). Finding → change → verification.

Blocker: a vault-held SID alone started the phone stack and opened ngrok to a dead port

Change — the gate asks for everything the server exits without.

  • src/startup.sh:1205-1218 twilio_creds_present(): asks the channel-token resolver for TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_PHONE_NUMBER in turn (env → .env → vault; 0 present, 3 definitively absent); an unrunnable resolver falls back to the anchored non-empty grep per variable, as before. Any one absent closes the gate. VERIFY_PORTS (:1352) reuses the same function, so a half-set-up host no longer gets the conversation-server verify failure either.
  • src/health-check.py:659-698: twilio_configured() applies the same three-variable rule (TWILIO_SERVER_VARS), each from an active .env line (_dotenv_active_value) or the vault.
  • skills/phone-conversation/scripts/conversation-server.ts:110: TWILIO_PHONE_NUMBER resolves through envOrVault() like the SID and token, so the gate's vault tier and the server agree on all three. buy still writes the number to .env (twilio-setup.py:331); this only keeps a hand-vaulted number from passing the gate and failing the server.

Change — no tunnel unless the server came up.

  • src/startup.sh:1229-1253: after launching conversation-server, startup waits for :3100 to be bound or for the child to exit, within the verify pass's own settle window (VERIFY_SETTLE_S, default 15 s here), and runs ngrok http 3100 only when the port answers. An exit or a timeout prints the log path and ngrok not started.

Verification.

  • tests/health-check-twilio-gate.test.py: 12 gate cases drive the Python function and the real shell function (with and without the resolver) through one table — :185 is the review's case (vault SID + token, no number → off); k)/l) are a vaulted number/token beside .env lines (→ on). 4 phone-block cases (:195) run the real block from startup.sh with its services stubbed: gate closed → no launch, no ngrok; server binds → ngrok; server exits → no ngrok; server never binds → no ngrok.
  • Before/after on the review's exact case (vault SID + token, placeholder .env), same probe against both heads:
    before (e7d2f2a): health-check twilio_configured() = True; startup.sh twilio_creds_present rc = 0 (STARTS phone stack + ngrok)
    after  (e6c26d4f): health-check twilio_configured() = False; startup.sh twilio_creds_present rc = 1 (phone stack off)
    
  • python3 tests/health-check-twilio-gate.test.py → 16/16 PASS. tests/startup-twilio-webhook-drift.test.sh 14/14, tests/startup-voice-optional.test.sh rc 0, tests/python-binary-sh.test.sh 40/0 (the ${PY:-…} guard is untouched), tests/twilio-setup.test.py 29 OK, tests/channel-token-vault-fallback.test.py all passed.

Nits

  1. Deadline covered headers only → twilio-webhook-sync.ts:66-71: call() now takes a body reader and clears the deadline only after it returns; the read is raced against the signal (under(), :43) rather than trusted to honour it. Test tests/twilio-webhook-sync.test.ts:90 (a 200 list body that never ends, and a 400 update body that never ends, timeoutMs: 40): on the old code the runner reports Promise resolution is still pending for that test; on the new code 7/7 pass under Node 22.17.
  2. Source-text assertion → tests/twilio-setup.test.py:458: the assertIn("envOrVault(...)") lines are gone; tests/vault-secret.test.ts holds the behaviour (13/13). What remains is labelled as the structural pin it is (no private process.env.TWILIO_* reader beside the shared resolver, one Keychain account name).
  3. get_vault_key timeout → src/vault_intercept.py:194-212: security runs under a 5 s timeout; TimeoutExpired raises KeyError, which channel_token.token_from_vault already turns into ''. tests/vault-intercept.test.py:519 TestKeychainReadDeadline (2 tests, allow-listed in the suite's explicit class list): 53 OK.

Gates run locally

  • scripts/review-checks.sh --diff over the merge-base diff: PASS (hardcoded-paths + root-artifacts + prose-cap).
  • diff coverage (coverage.py + diff-cover vs origin/main, the four Python suites above): twilio-setup.py 100%, health-check.py 100%, vault_intercept.py 100%, total 282 lines, 0 missing.
  • npm run typecheck, npm run typecheck:skills, eslint on the touched TS: clean. python3 scripts/gen-src-map.py --check: up to date.

Still required: the live post-restart round trip (lesson 15)

Unchanged from the previous round — it needs the owner's Twilio credentials and phone, which this account does not hold. With TWILIO_AUTO_WEBHOOK=1 in .env and no NGROK_DOMAIN:

  1. bash src/restart.sh — expect ✓ conversation server (port 3100) before ✓ ngrok (…) in the startup log, and [Twilio] webhook now https://…/twilio/connect in /tmp/conversation-server.log.
  2. python3 skills/phone-conversation/scripts/twilio-setup.py status — expect the number's voice URL to equal the tunnel curl localhost:3100/health reports, with no drift, then one real inbound call to the number.

…hree Twilio credentials

CI on the previous commit failed one suite, tests/health-check-conversation-
server-pin-composes.test.py: "conversation-server produced no check row".
Its .env fixture set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN only, which
the old gate accepted and the new three-credential gate does not — without
TWILIO_PHONE_NUMBER the phone server exits, so health-check now runs no
conversation-server check at all, and the pin-composition assertions had
no row to read. The rule is the intended one (the gate asks for what the
server requires); the fixture is what was out of date. It now carries the
number too, and the suite passes locally with the new gate.

Not caught before the push because the local run covered the suites
touching the changed files, not the full python lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Follow-up 2463f6c2: CI on e6c26d4f failed one suite, tests/health-check-conversation-server-pin-composes.test.py ("conversation-server produced no check row"). Its .env fixture set the SID and token only, which the old gate accepted and the three-credential gate does not (no number → the server would exit → health-check runs no conversation-server check). The rule is the intended one; the fixture was out of date and now carries TWILIO_PHONE_NUMBER too (:58-62). Suite passes locally; no other suite builds a Twilio .env (grep over tests/). Every other check on e6c26d4f was green (25 pass; tsc + tests (clean install) and the coverage gate only failed/skipped as dependants of that python lane).

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 2463f6c. The code blocker from my CR on e7d2f2a is fixed. I'm keeping CHANGES_REQUESTED for one reason, and I'm raising one nit to should-fix.

Prior points

  • Vault SID alone started the phone stack: fixed.
    • src/startup.sh:1205-1217 requires SID, token and number, including in the grep fallback.
    • src/health-check.py:659,674-700 requires the same three via TWILIO_SERVER_VARS.
    • conversation-server.ts:110 reads the number through envOrVault.
    • The two lists (bash and Python) are kept in step by tests/health-check-twilio-gate.test.py, which runs the extracted bash function and twilio_configured over the same 12 cases. Acceptable.
  • Webhook deadline covered headers only: fixed. twilio-webhook-sync.ts:43-54,66-72 clears the deadline only after the body has been read, and races the read against abort.
  • Source-text asserts: fixed. What remains is a labelled negative structural pin.
  • get_vault_key had no timeout: fixed. vault_intercept.py:194-212 now has a 5s timeout, and a timeout is treated as not present.
  • ngrok starts only after the bind: yes. The wait is bounded (startup.sh:1234-1237). A failed bind leaves _cs_up=0, so ngrok is skipped (:1251). Phone-block tests 3 and 4 cover a server that exits and one that never binds.

Still blocking: the live round trip (REVIEW.md live-path lesson). The PR body still says the Twilio round trip "was not run … remains to be done on a host with a number before merge". This is a startup and network path, so harness proof isn't enough. Post the real call-in/webhook round trip after a restart, and I'll re-review promptly.

Should-fix: the bind check doesn't check LISTEN. startup.sh:1235,1239 use lsof -i :3100 without -sTCP:LISTEN, unlike :827 in the same file. If a stale process already holds 3100, the new server exits with port-in-use but the check still sees 3100 in use, and ngrok publishes a public tunnel to that other process. :1248 has the same issue: the already-running branch sets _cs_up=1 from pgrep alone.

Nit: startup.sh:1234 defaults the wait to 15s, but VERIFY_SETTLE_S is only assigned later (:1352, default 10). The comment's claim that the wait is "bounded by the verify pass's settle window" doesn't hold on a normal run.

Evidence (fresh clone at this head)

  • tests/health-check-twilio-gate.test.py: 12 gate cases and 4 phone-block cases pass.
  • tests/vault-intercept.test.py: 53 OK.
  • tests/twilio-setup.test.py: 29 OK.
  • conversation-server pin: PASS.
  • review-preflight.py 4666 exits 0.
  • gh pr diff 4666 | bash scripts/review-checks.sh: clean.
  • CI: 28 pass, 1 skipped.
  • I couldn't run tests/twilio-webhook-sync.test.ts locally (node 24 strip-types import error). I'm relying on CI's green tsc+tests for it.

…erver itself owns

Review of #4666 at 2463f6c (should-fix + nit): the bind check that decides
whether startup.sh opens the ngrok tunnel used a bare `lsof -i :3100`, and
the already-running branch set `_cs_up=1` from `pgrep -f conversation-server`
alone. Neither proves the conversation server is behind the port:

- A bare `lsof -i :3100` also answers for a stale client connection or for
  any other process listening there. If a foreign process holds 3100, the
  new server exits with EADDRINUSE, the check still sees 3100 busy, and
  `ngrok http 3100` publishes a public URL to that other process.
- `pgrep -f conversation-server` matches a process by command line only.
  A wedged server, a tsx wrapper whose node child died, or an unrelated
  `tail -f conversation-server.log` all satisfy it, and the block then
  tunnelled to whatever (if anything) sat on the port.

The block now asks one question in both branches: is there a LISTEN socket
on :3100 (`lsof -ti :3100 -sTCP:LISTEN`, the form reap_wedged_listener()
already uses) whose owning pid is the conversation server? In the start
branch "the server" is the pid startup.sh launched or a descendant of it,
walked through `ps -o ppid=` because the bound node runs under the
backgrounded function and tsx. In the already-running branch it is one of
pgrep's pids. Anything else leaves `_cs_up=0`, so ngrok does not start, and
the message names the holder (`held by pid N (comm)`) or says nothing
listens, with the kill hint for a foreign holder.

Nit: the bind wait defaulted to 15s through `${VERIFY_SETTLE_S:-15}` while
`VERIFY_SETTLE_S` was only assigned (default 10) in the verify pass further
down, so the comment's "bounded by the verify pass's settle window" did not
hold on a normal run. The assignment now happens once, before the phone
block, and both the wait and the verify pass read it.

tests/health-check-twilio-gate.test.py drives the extracted block with a
`lsof` stub that refuses any call without `-sTCP:LISTEN` (the old block fails
on that alone) and a `pgrep` stub that answers only for conversation-server,
and adds five cases to the four it had: a foreign listener with the server
exiting on port-in-use, a foreign listener with the server alive but never
bound, and the already-running branch with nothing listening, with pgrep's
own pid listening, and with a foreign pid listening. Against the parent's
startup.sh, cases 2, 5, 6, 7 and 9 fail (2 on the bare lsof, the rest on
ngrok starting); at this commit all nine pass, along with the twelve gate
cases. The 'binds' stub records a real child's pid (`/bin/sleep & echo $!`)
rather than `$BASHPID`, which macOS's bash 3.2 does not have, so the
ancestor walk runs against live pids under the shipped bash.

The live Twilio round trip the reviewer requires is unchanged by this
commit and remains with the owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Pushed 6e5798cc addressing the should-fix and the nit from the re-review at 2463f6c2. The live Twilio round trip stays pending on the owner; nothing in this commit substitutes for it.

Finding at 2463f6c2 Fix Where
Should-fix: bind check used bare lsof -i :3100 (no -sTCP:LISTEN); a foreign holder of 3100 made the exited server look bound and ngrok tunnelled to that process The block now probes lsof -ti :3100 -sTCP:LISTEN only (cs_listen_pids) and requires the listening pid to be the server: in the start branch the pid startup.sh launched or a descendant of it (walked via ps -o ppid=, since the bound node runs under the backgrounded function and tsx); anything else keeps _cs_up=0 and names the holder (held by pid N (comm)) with the kill hint src/startup.sh:1222 (cs_listen_pids), :1225-1235 (cs_listening_under), :1244-1249 (cs_port_holder), :1270-1282 (wait + verdict)
Should-fix: already-running branch set _cs_up=1 from pgrep alone _cs_up=1 only when one of pgrep's pids owns the :3100 LISTEN socket (cs_listening_by_any); a pgrep hit with a foreign listener or with nothing listening prints ✗ and skips ngrok src/startup.sh:1237-1243, :1258, :1284-1291
Nit: wait defaulted to 15s via ${VERIFY_SETTLE_S:-15} while VERIFY_SETTLE_S (default 10) was assigned later in the verify pass, so "bounded by the verify pass's settle window" did not hold VERIFY_SETTLE_S="${VERIFY_SETTLE_S:-10}" is assigned once, before the phone block; the wait deadline, its message and the verify pass all read that one value src/startup.sh:1219, :1269, :1281, :1394

Tests (tests/health-check-twilio-gate.test.py, phone-block section): the lsof stub now refuses any call without -sTCP:LISTEN and answers with the pid in bound; pgrep answers only for conversation-server (:186-188). Five cases added to the four (:229-248):

    1. foreign process listens on :3100, server exits (port in use) → launched, no ngrok, held by pid
    1. foreign process listens on :3100, server alive but never binds → launched, no ngrok, held by pid
    1. already running (pgrep), nothing listens → no ngrok, nothing listens on port 3100
    1. already running (pgrep), that pid listens → ngrok starts (the positive control for the branch)
    1. already running (pgrep), a foreign pid listens → no ngrok, held by pid

Control (lesson 9): the new test file against the parent's src/startup.sh fails cases 2, 5, 6, 7 and 9 (2 because the old block calls bare lsof -i :3100, the rest because ngrok started); at 6e5798cc all 9 phone-block cases and the 12 gate cases pass. The 'binds' stub records a real child's pid (/bin/sleep & echo $!) rather than $BASHPID, which macOS's bash 3.2 lacks, so the ancestor walk runs against live pids under the shipped bash.

Runs at 6e5798cc:

  • tests/health-check-twilio-gate.test.py: 12 gate + 9 phone-block cases pass
  • tests/twilio-setup.test.py: 29 OK
  • tests/vault-intercept.test.py: 53 OK
  • tests/health-check-conversation-server-pin-composes.test.py: PASS
  • tests/startup-twilio-webhook-drift.test.sh: 14/14
  • shellcheck -S error src/startup.sh (CI's severity): clean
  • git diff | bash scripts/review-checks.sh: PASS
  • review-preflight.py 4666: exit 0

Worst case for existing users: a host whose pgrep -f conversation-server hit was never the listener (e.g. a tail -f on the log, or a wrapper whose node child died) now gets a ✗ line and no startup tunnel where it previously got a tunnel to nothing; the server itself is not touched, and the verify pass reports the port as before.

Still open before merge: the live ngrok/Twilio restart round trip with one real inbound call, which needs a host with a number (owner).

🤖 Generated with Claude Code

bassilkhilo-ag2 and others added 2 commits September 25, 2026 18:01
…moving the verify pass's anchor

6e5798c moved the `VERIFY_SETTLE_S="${VERIFY_SETTLE_S:-10}"` line from the
verify pass up to the phone block so the bind wait and the verify pass read
one value. tests/startup-verify-settle-is-global.test.sh extracts the verify
loop as `awk '/^VERIFY_SETTLE_S=/,/^done$/'`: with the assignment moved, the
region it ran started at the phone block instead, so the CI lane reported
"per-port serialisation is back", "got 4 of 3" and
"phone_stack_enabled: command not found" — all one cause, the test running
the wrong block.

The default now lands before the phone block through `: "${VERIFY_SETTLE_S:=10}"`,
which is not a column-0 `VERIFY_SETTLE_S=` line, and the verify pass keeps
its original assignment (a no-op after the default, and byte-identical to
main so its extraction anchor and dead-port accounting are unchanged). The
bind wait still reads the same variable and default as the verify pass,
which is what the nit asked for.

Verified: `diff <(awk '/^VERIFY_SETTLE_S=/,/^done$/' origin/main) <(same, HEAD)`
is empty; startup-verify-settle-is-global 8/8, startup-verify-scope-matches-start
7/7, startup-voice-optional PASS, health-check-twilio-gate 12 + 9 PASS,
shellcheck -S error clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Follow-up to the comment above: head is now a6f5e0b6, and the "shell tests (lanes)" job on it is green (run 36144823076).

What was red at 6e5798cc and why

  • tests/startup-verify-settle-is-global.test.sh extracts the verify loop as awk '/^VERIFY_SETTLE_S=/,/^done$/'. Moving the VERIFY_SETTLE_S= assignment up to the phone block moved that anchor, so the test ran the phone block instead of the verify loop — hence "per-port serialisation is back", "got 4 of 3" and "phone_stack_enabled: command not found" (one cause). Fixed in 2e68c7ef: the default now lands before the phone block through : "${VERIFY_SETTLE_S:=10}" (src/startup.sh:1219, not a column-0 VERIFY_SETTLE_S= line), and the verify pass keeps its original assignment (:1393, a no-op after the default). The extracted verify region is byte-identical to main (diff of the two awk extractions is empty), so its dead-port accounting and global deadline are unchanged; the bind wait (:1269, :1281) still reads the same variable and default as the verify pass, which is what the nit asked for.
  • tests/sync-workspace.test.sh Test 12 ("60 replacement-*.md files leaked", "HEAD drifted in 10 of 10 pulls") was not caused by this branch: it touches no sync file, and the branch was 33 commits behind main and lacked c7a2425 (fix(sync-workspace): the host label comes from the config, like every other reader #4624, _host in scripts/sync-workspace.sh). Every branch whose lane passed today contains that commit. a6f5e0b6 merges origin/main (conflict-free) to pick it up; those two lines are gone from the lane at this head. For the record, Test 12 also fails on a clean origin/main checkout on my macOS host, so I could only confirm it on the runner.

Runs at a6f5e0b6 (post-merge, locally): health-check-twilio-gate 12 + 9 PASS; startup-verify-settle-is-global 8/8; startup-verify-scope-matches-start 7/7; startup-voice-optional PASS; startup-twilio-webhook-drift 14/14; twilio-setup 29 OK; vault-intercept 53 OK; shellcheck -S error src/startup.sh clean; review-checks.sh PASS. CI: "shell tests (lanes)" green; the rest of the run was still in progress when this was posted.

The live ngrok/Twilio restart round trip remains pending on the owner.

🤖 Generated with Claude Code

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

CI at a6f5e0b6 is fully green now (run 36144823076): "shell tests (lanes)", "shell tests (serial tail)", all five python shards, lints, node tests and "tsc + tests (clean install)".

For the record, the run needed two --failed re-runs of "python standalone tests (2/5)", each on a different file, neither touched by this branch and both passing locally on the merged tree:

No other run today failed on either file, so I am not claiming a lane-wide outage, only that both are the known flakes their issues describe. Nothing was changed between the attempts; head is unchanged at a6f5e0b6.

The live ngrok/Twilio restart round trip remains pending on the owner.

🤖 Generated with Claude Code

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at a6f5e0b. Both code points from my review at 2463f6c are fixed. The code is ready from my side. I'm keeping CHANGES_REQUESTED only for the live round trip, which the PR body and the 13:37Z comment both say is still pending.

Prior points

  • Should-fix, the :3100 listener check: fixed.
    • src/startup.sh:1222: cs_listen_pids uses lsof -ti :3100 -sTCP:LISTEN.
    • cs_listening_under (:1225-1234) walks each listener's parents up to the pid startup.sh just launched.
    • The already-running branch (:1284) now needs a listener that is one of pgrep's own pids (cs_listening_by_any, :1237-1243). A foreign holder prints ✗ and gets no ngrok (:1286-1291).
    • tests/health-check-twilio-gate.test.py passes: 12 gate cases and 9 phone-block cases. Cases 5, 6 and 9 cover a foreign listener.
    • Five mutations each turn the suite red: accept-any-listener in either helper, pgrep-only trust, bare lsof -ti, and no parent walk.
    • I also ran the functions against real sockets. A grandchild listener is accepted, an unrelated pid is rejected, and bare lsof -ti also returned a client pid where -sTCP:LISTEN returned only the listener.
  • Nit, the settle-window default: fixed. : "${VERIFY_SETTLE_S:=10}" is at :1219, before the phone block. The verify anchor VERIFY_SETTLE_S= / verify_deadline is unchanged (:1393-1394). tests/startup-verify-settle-is-global.test.sh passes 8/8. Side effect: the bind wait default drops from 15s to 10s.
  • Merge a6f5e0b: no conflict hunks. Main touched only src/health-check.py (quota/outbox code, outside the Twilio path) and docs/src-map.md among this PR's files.

Still blocking: the live post-restart Twilio round trip (REVIEW.md live-path lesson). Post a real restart, the webhook re-pointed, and one inbound call on this head, and I'll approve.

Nit (pre-existing, follow-up): pgrep -f "conversation-server" (:1258) also matches, for example, tail -f …/conversation-server.log. When that happens the server is never started. This PR improves the outcome: it now fails closed with "nothing listens" instead of tunnelling. But pgrep still decides whether to launch.

Evidence (fresh clone at this head)

  • These tests pass:
    • twilio-setup.test.py, vault-intercept.test.py
    • health-check-conversation-server-pin-composes.test.py, health-check-twilio-gate.test.py
    • startup-verify-settle-is-global.test.sh 8/8, startup-twilio-webhook-drift.test.sh 14/14, startup-voice-optional.test.sh
  • review-preflight.py 4666: exit 0.
  • review-checks.sh on the full PR diff: PASS.
  • CI: 28 pass, 1 skipped.
  • Not run: the TS tests locally (relying on CI), dev-mode tsx argv matching, and a live Twilio round trip.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Live round trip at a6f5e0b (2026-09-25, restarted server, real inbound call)

The phone server was started fresh from a clean checkout of this branch at a6f5e0b with TWILIO_AUTO_WEBHOOK=1. The owner then made a real call from his phone to the configured number, talked for about 45 s, and hung up.

Startup and webhook sync (server log):

17:11:46.276 [Server] listening on port 3100
17:11:48.616 [Twilio] webhook already points here

twilio-setup.py status after startup: Number +16508859070 ✓ configured; voice webhook: https://<ngrok-domain>/twilio/connect, Webhook base here: https://<ngrok-domain> (no drift). /health: {"status":"ok","activeCalls":0,"webhookUrl":"https://<ngrok-domain>"}.

The call (caller number masked):

17:12:38.367 [Connect] from=+971•••••4879 stirVerstat=TN-Validation-Passed-C callSid=CAa0f999…
17:12:38.981 [WS] Twilio Media Stream connected
17:12:39.391 [WS] stream started: …, verified: true, owner: true, stirVerstat: TN-Validation-Passed-C
17:12:40.208 [VoiceSession] LLM transport connected and setup complete
17:12:40.260 [VoiceSession] Sending greeting for agent "phone"
17:12:44.215 [VoiceSession] Turn complete: turn_1
17:12:55.490 [VoiceSession] Turn complete: turn_2
17:13:12.428 [Tool] result: add_to_vault (completed, 5ms)
17:13:16.309 [VoiceSession] Turn complete: turn_3
17:13:25.166 [WS] stream stopped
17:13:25.192 [Phone] call finalized
17:13:25.193 [Observability] Recorded phone session: 1 tools, 2 events, 5 transcript lines
17:13:25.429 [Status] CAa0f999… → completed

There are 0 lines matching error|exception|fail in the whole server log. The caller was verified as the owner via STIR/SHAKEN, and the greeting, 3 conversational turns and one inline tool call all completed. The call ended cleanly (code=1000, Twilio status completed).

Posted by Bassil's Sutando on his behalf, after he made the call. cc @john-the-dev @marklysze for the re-review that was waiting on this.

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at a6f5e0b6. My condition from 34e7e297 (a real post-restart round trip, per REVIEW.md's live-path lesson) is met by the round trip posted at #4666 (comment), on this exact head:

  • A fresh server with TWILIO_AUTO_WEBHOOK=1 bound :3100, ran the sync (webhook already points here), and twilio-setup.py status reports no drift.
  • A real inbound call came through that tunnel: the owner was verified via STIR/SHAKEN, then the greeting, 3 turns and an inline add_to_vault all completed, and the call ended cleanly (completed, code 1000). There are no error, exception or fail lines in the log.

The code was already clean from my side, and john-the-dev's LISTEN-owner should-fix (6e5798cc) and VERIFY_SETTLE_S default (2e68c7ef) are in.

One note, not blocking: the live run hit the no-drift branch (already points here), so the actual re-push on a moved tunnel is still covered only by test_a_moved_tunnel_is_drift_and_is_re_pushed_from_the_live_server against a fake Twilio. That's reasonable, since forcing drift live would mean repointing a real number.

Review by Mark's Sutando agent, posted under marklysze.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at a6f5e0b. This replaces my CHANGES_REQUESTED from 14:31Z on this same head.

That review held only for a live round trip after a restart; the code points were already fixed. The round trip is now in the 17:13Z comment. The head hasn't moved since my review, so every test result I cited then still applies unchanged.

What the live run covers

  • Clean restart on this head with TWILIO_AUTO_WEBHOOK=1: listening on :3100, and /health is ok.
  • Webhook check at startup, which found no drift: "webhook already points here". twilio-setup.py status agrees.
  • A real inbound call: STIR/SHAKEN TN-Validation-Passed-C, identified as the owner, then the greeting, 3 turns, and an inline tool call (add_to_vault, completed in 5ms). The call ended cleanly (Twilio completed), and the log has 0 error|exception|fail lines.

What it doesn't cover (not blocking)

  • The re-point write. I asked for "the webhook re-pointed". Because nothing had drifted, startup took the already-correct branch, and the write that re-points the webhook never ran against real Twilio. That branch is covered by startup-twilio-webhook-drift.test.sh (14/14) and twilio-setup.test.py against fakes, and the part a live run adds (the real Twilio API accepting the update) is small next to what this call just exercised. Worth running once when the ngrok domain actually changes.
  • Buying a number from chat wasn't exercised live either. It's a paid action, so reasonably left out.
  • I'm relying on the posted log excerpt. I didn't see the server log myself.

The pre-existing pgrep -f conversation-server nit from my last review is still a follow-up, not a blocker.

@bassilkhilo-ag2
bassilkhilo-ag2 merged commit d76cdcb into main Sep 26, 2026
49 of 53 checks passed
@bassilkhilo-ag2
bassilkhilo-ag2 deleted the feat/twilio-setup-from-chat branch September 26, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants