Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ Beyond Inkbox's own events, the `/webhook` endpoint can wake the agent for event
| `CLAUDE_PROJECT_DIR` | yes | cwd | Directory Claude Code works in. |
| `CLAUDE_MODEL` | no | CLI default | Model override for bridged sessions. |
| `INKBOX_REQUIRE_SIGNATURE` | no | `true` | Refuse unsigned inbound webhooks unless `false`. |
| `INKBOX_SKIP_WEBHOOK_RECONCILE` | no | `false` | Leave webhook subscriptions untouched on start. For deployments that provision them ahead of time, where the destination is fixed or this API key may not change it. They must already point at this bridge's webhook URL, or nothing arrives. |
| `INKBOX_EXTERNAL_EVENTS_ENABLED` | no | `false` | Wake the agent on unrecognised/unverified external webhooks (see [External webhooks](#external-webhooks)). |
| `INKBOX_CONTACT_MEMORIES_ENABLED` | no | `true` | Include matched-contact memories as background context for human conversations and calls. |
| `INKBOX_WEBHOOK_SECRET_<NAME>` | per source | - | Verification secret for a registered third-party webhook source (e.g. `INKBOX_WEBHOOK_SECRET_GITHUB`). |
Expand Down
2 changes: 1 addition & 1 deletion inkbox_claude/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Inkbox bridge for Claude Code — email, SMS, iMessage, and voice."""

__version__ = "0.2.9"
__version__ = "0.2.10"
5 changes: 5 additions & 0 deletions inkbox_claude/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ class BridgeConfig:
allowed_users: List[str] = field(default_factory=list)
allow_all_users: bool = False
require_signature: bool = True
# Leave webhook subscriptions alone on start. For deployments that
# provision them ahead of time, where the destination is fixed or this
# API key may not change it; they must already point at this bridge.
skip_webhook_reconcile: bool = False
# Wake the agent on unrecognised/unverified external webhooks (default
# off: only registered, signature-verified sources get through).
external_events_enabled: bool = False
Expand Down Expand Up @@ -184,6 +188,7 @@ def read_config(extra: Dict[str, Any] | None = None) -> BridgeConfig:
allowed_users=_csv_env("INKBOX_ALLOWED_USERS"),
allow_all_users=env_flag("INKBOX_ALLOW_ALL_USERS", False),
require_signature=env_flag("INKBOX_REQUIRE_SIGNATURE", True),
skip_webhook_reconcile=env_flag("INKBOX_SKIP_WEBHOOK_RECONCILE", False),
external_events_enabled=env_flag("INKBOX_EXTERNAL_EVENTS_ENABLED", False),
contact_memories_enabled=env_flag("INKBOX_CONTACT_MEMORIES_ENABLED", True),
host=str(os.getenv("INKBOX_BRIDGE_HOST") or DEFAULT_HOST).strip(),
Expand Down
8 changes: 8 additions & 0 deletions inkbox_claude/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,14 @@ def _drive(listener):

def _patch_identity_objects(self) -> None:
"""Point the identity's mailbox/phone/iMessage events at this server."""
if self.cfg.skip_webhook_reconcile:
logger.info(
"[bridge] leaving webhook subscriptions alone; expecting them "
"to already deliver to %s%s",
self._public_url, DEFAULT_WEBHOOK_PATH,
)
return

webhook_url = f"{self._public_url}{DEFAULT_WEBHOOK_PATH}"
ws_url = f"wss://{self._public_host}{INKBOX_WS_PATH}"
identity = self._inkbox.get_identity(self.cfg.identity)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "claude-code-plugin"
version = "0.2.9"
version = "0.2.10"
description = "Inkbox bridge for Claude Code — talk to your coding agent over email, SMS, iMessage, and voice"
requires-python = ">=3.11"
dependencies = [
Expand Down
87 changes: 87 additions & 0 deletions tests/test_gateway_skip_webhook_reconcile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Startup normally installs webhook subscriptions pointing at whatever URL the
bridge just came up on, which is right when the bridge owns its ingress.

Deployments that provision subscriptions ahead of time need the opposite: the
destination is already fixed, and the API key may not be permitted to change
it, so writing on every start is redundant at best and fatal to startup at
worst. INKBOX_SKIP_WEBHOOK_RECONCILE turns that write off and leaves the rest
of startup alone."""

import pytest

from inkbox_claude.config import BridgeConfig, env_flag
from inkbox_claude.gateway import InkboxGateway


class _ExplodingSubscriptions:
"""Any call here means the skip did not take effect."""

def list(self, **_kwargs):
raise AssertionError("listed subscriptions despite the skip flag")

def create(self, **_kwargs):
raise AssertionError("created a subscription despite the skip flag")

def delete(self, _sub_id):
raise AssertionError("deleted a subscription despite the skip flag")


class _ExplodingInkbox:
def __init__(self):
self.webhooks = type("_W", (), {"subscriptions": _ExplodingSubscriptions()})()

def get_identity(self, _handle):
raise AssertionError("read the identity despite the skip flag")


def _gateway(*, skip: bool) -> InkboxGateway:
gw = InkboxGateway(
BridgeConfig(
identity="claude-agent",
allow_all_users=True,
skip_webhook_reconcile=skip,
)
)
gw._inkbox = _ExplodingInkbox()
gw._public_url = "https://agent.inkboxwire.com"
gw._public_host = "agent.inkboxwire.com"
return gw


def test_skipping_touches_no_subscriptions() -> None:
"""The point of the flag: startup proceeds without writing anything."""
_gateway(skip=True)._patch_identity_objects()


def test_not_skipping_still_reconciles() -> None:
"""Default behavior is unchanged; the fake asserts by exploding."""
with pytest.raises(AssertionError):
_gateway(skip=False)._patch_identity_objects()


def test_default_config_reconciles() -> None:
"""A config that never mentions the flag must keep the old behavior."""
assert BridgeConfig().skip_webhook_reconcile is False


@pytest.mark.parametrize("raw", ["true", "True", "1", "yes", "on", "ON"])
def test_truthy_spellings_enable_the_skip(monkeypatch, raw: str) -> None:
"""Operators set this by hand, so accept the obvious spellings."""
monkeypatch.setenv("INKBOX_SKIP_WEBHOOK_RECONCILE", raw)

assert env_flag("INKBOX_SKIP_WEBHOOK_RECONCILE", False) is True


@pytest.mark.parametrize("raw", ["false", "False", "0", "no", "off", ""])
def test_everything_else_leaves_reconcile_on(monkeypatch, raw: str) -> None:
"""Unrecognized must not silently disable subscription setup."""
monkeypatch.setenv("INKBOX_SKIP_WEBHOOK_RECONCILE", raw)

assert env_flag("INKBOX_SKIP_WEBHOOK_RECONCILE", False) is False


def test_unset_leaves_reconcile_on(monkeypatch) -> None:
"""The absent case, which is what almost every deployment has."""
monkeypatch.delenv("INKBOX_SKIP_WEBHOOK_RECONCILE", raising=False)

assert env_flag("INKBOX_SKIP_WEBHOOK_RECONCILE", False) is False
Loading