Skip to content

Commit 9046b3e

Browse files
committed
Add noninteractive identity bootstrap
1 parent 4f33266 commit 9046b3e

4 files changed

Lines changed: 306 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ The one thing to have ready: be **logged into Codex** — a ChatGPT/Codex login
3838

3939
Flags: `--start` (launch the background gateway when done), `--no-setup` (install only). From a local checkout, run `./install.sh`. Re-running is safe.
4040

41+
### Bootstrap an existing identity without prompts
42+
43+
For unattended agent setup, install without opening the wizard and pass the API key through the environment (or standard input), never a command-line argument:
44+
45+
```bash
46+
curl -fsSL https://raw.githubusercontent.com/inkbox-ai/codex-plugin/main/install.sh | bash -s -- --no-setup
47+
export INKBOX_API_KEY="ApiKey_..."
48+
inkbox-codex bootstrap --identity my-agent --project-dir "$PWD" \
49+
--voice-ai --rotate-signing-key --start-gateway
50+
unset INKBOX_API_KEY
51+
```
52+
53+
`bootstrap` validates that the key can access exactly the requested identity, scopes down an admin key before saving it, preserves existing Voice AI settings, enables native Inkbox tool approvals, and starts or restarts the detached gateway. Signing-key replacement is opt-in because it transfers verified webhook delivery away from any gateway using the previous key. The command prints a secret-redacted JSON result and is safe to resume.
54+
4155
Check it any time:
4256

4357
```bash

inkbox_codex/bootstrap.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""Non-interactive bootstrap for an existing Inkbox Codex identity."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from typing import Any
7+
8+
from . import daemon
9+
from .config import INKBOX_BASE_URL_DEFAULT, VoiceStack, inkbox_client_kwargs
10+
from .setup_wizard import _enum_value, _env, _load_inkbox_symbols, _save
11+
12+
13+
def _handle(value: str) -> str:
14+
return value.strip().removeprefix("@").strip()
15+
16+
17+
def _identity_for_key(client: Any, expected: str) -> Any:
18+
handles = {_handle(str(getattr(item, "agent_handle", ""))) for item in client.list_identities()}
19+
if expected not in handles:
20+
raise ValueError("The API key is not scoped to the requested identity.")
21+
return client.get_identity(expected)
22+
23+
24+
def _resolve_credentials(api_key: str, expected: str, base_url: str, symbols: dict[str, Any], actions: list[str]) -> tuple[str, Any]:
25+
Inkbox = symbols["Inkbox"]
26+
client = Inkbox(**inkbox_client_kwargs(api_key, base_url))
27+
info = client.whoami()
28+
if _enum_value(getattr(info, "auth_type", "")) != "api_key":
29+
raise ValueError("Bootstrap requires an Inkbox API key.")
30+
subtype = _enum_value(getattr(info, "auth_subtype", ""))
31+
claimed = _enum_value(symbols["AGENT_CLAIMED"])
32+
if subtype == claimed:
33+
return api_key, _identity_for_key(client, expected)
34+
if subtype == _enum_value(symbols["AGENT_UNCLAIMED"]):
35+
raise ValueError("The API key is not attached to a claimed identity yet.")
36+
if subtype != _enum_value(symbols["ADMIN_SCOPED"]):
37+
raise ValueError("Use an agent-scoped or admin-scoped Inkbox API key.")
38+
saved_key = _env("INKBOX_API_KEY").strip()
39+
if saved_key and _handle(_env("INKBOX_IDENTITY")) == expected:
40+
try:
41+
saved_client = Inkbox(**inkbox_client_kwargs(saved_key, base_url))
42+
if _enum_value(getattr(saved_client.whoami(), "auth_subtype", "")) == claimed:
43+
actions.append("reused_saved_agent_key")
44+
return saved_key, _identity_for_key(saved_client, expected)
45+
except Exception:
46+
pass
47+
identity = client.get_identity(expected)
48+
created = client.api_keys.create(
49+
label=f"Codex gateway - {expected}",
50+
description="Agent-scoped key created by the Codex Inkbox bootstrap.",
51+
scoped_identity_id=identity.id,
52+
)
53+
scoped_key = str(getattr(created, "api_key", "") or "")
54+
if not scoped_key:
55+
raise RuntimeError("Inkbox did not return the new agent-scoped API key.")
56+
actions.append("minted_agent_scoped_key")
57+
scoped_client = Inkbox(**inkbox_client_kwargs(scoped_key, base_url))
58+
return scoped_key, scoped_client.get_identity(expected)
59+
60+
61+
def _voice_instructions(identity: Any, client: Any) -> str:
62+
handle = _handle(str(getattr(identity, "agent_handle", "")))
63+
mailbox = getattr(identity, "mailbox", None)
64+
channels = []
65+
email = getattr(identity, "email_address", None) or getattr(mailbox, "email_address", None)
66+
phone = getattr(getattr(identity, "phone_number", None), "number", None)
67+
tunnel = getattr(getattr(identity, "tunnel", None), "public_host", None)
68+
dedicated = getattr(getattr(identity, "imessage_number", None), "number", None)
69+
if email:
70+
channels.append(f"Email: {email}.")
71+
if phone:
72+
channels.append(f"VoIP phone: {phone}.")
73+
if tunnel:
74+
channels.append(f"Public address: https://{tunnel}.")
75+
if dedicated:
76+
channels.append(f"Dedicated iMessage line: {dedicated}.")
77+
elif bool(getattr(identity, "imessage_enabled", False)):
78+
try:
79+
triage = client.imessages.get_triage_number()
80+
number = str(getattr(triage, "number", "") or "")
81+
command = str(getattr(triage, "connect_command", "") or f"connect @{handle}")
82+
if number:
83+
channels.append(f"Shared iMessage: text '{command}' to {number}.")
84+
except Exception:
85+
channels.append("Shared iMessage is enabled; use the current Inkbox connection instructions.")
86+
configured = " ".join(channels) or "No direct communication channel is currently configured."
87+
return f"You are the hosted voice interface for Inkbox agent @{handle}. Help callers connect using only these configured channels. {configured}"
88+
89+
90+
def _configure_voice(identity: Any, client: Any, instructions: str | None) -> None:
91+
hosted = identity.get_hosted_agent_config()
92+
desired = instructions if instructions is not None else (getattr(hosted, "instructions", None) or _voice_instructions(identity, client))
93+
if len(desired) > 8000:
94+
raise ValueError("Voice AI instructions must be 8,000 characters or fewer.")
95+
if getattr(hosted, "instructions", None) != desired:
96+
identity.set_hosted_agent_config(voice=getattr(hosted, "voice", None), model=getattr(hosted, "model", None), instructions=desired)
97+
incoming = identity.get_incoming_call_action()
98+
if _enum_value(getattr(incoming, "incoming_call_action", "")) != "hosted_agent" or getattr(incoming, "client_websocket_url", None) is not None or getattr(incoming, "incoming_call_webhook_url", None) is not None:
99+
identity.set_incoming_call_action(incoming_call_action="hosted_agent", client_websocket_url=None, incoming_call_webhook_url=None)
100+
_save("INKBOX_VOICE_STACK", VoiceStack.INKBOX_VOICE_AI.value)
101+
_save("INKBOX_VOICE_AI_AUTHORITY_MODE", _enum_value(getattr(hosted, "authority_mode", "contact_scoped")))
102+
_save("INKBOX_REALTIME_ENABLED", "false")
103+
104+
105+
def _configure_signing(identity: Any, client: Any, rotate: bool, same_identity: bool, actions: list[str]) -> str | None:
106+
status_reader = getattr(identity, "get_signing_key_status", None) or getattr(client, "get_signing_key_status")
107+
configured = bool(getattr(status_reader(), "configured", False))
108+
if _env("INKBOX_SIGNING_KEY").strip() and same_identity and configured and not rotate:
109+
_save("INKBOX_REQUIRE_SIGNATURE", "true")
110+
actions.append("reused_local_signing_key")
111+
return None
112+
if configured and not rotate:
113+
return "A signing key already exists but is unavailable in this Codex profile. Set INKBOX_SIGNING_KEY or rerun with --rotate-signing-key."
114+
creator = getattr(identity, "create_signing_key", None) or getattr(client, "create_signing_key")
115+
created = creator()
116+
key = str(getattr(created, "signing_key", "") or "")
117+
if not key:
118+
raise RuntimeError("Inkbox did not return the new signing key.")
119+
_save("INKBOX_SIGNING_KEY", key)
120+
_save("INKBOX_REQUIRE_SIGNATURE", "true")
121+
actions.append("rotated_signing_key" if configured else "created_signing_key")
122+
return None
123+
124+
125+
def _start_gateway(actions: list[str]) -> bool:
126+
was_running = daemon.running_pid() is not None
127+
code = daemon.restart() if was_running else daemon.start()
128+
actions.append("restarted_gateway" if was_running else "started_gateway_process")
129+
if code != 0:
130+
return False
131+
deadline = time.monotonic() + 8
132+
while time.monotonic() < deadline:
133+
if daemon.running_pid():
134+
return True
135+
time.sleep(0.25)
136+
return False
137+
138+
139+
def bootstrap(*, identity_handle: str, api_key: str, base_url: str = INKBOX_BASE_URL_DEFAULT, project_dir: str = "", voice_ai: bool = False, voice_ai_instructions: str | None = None, rotate_signing_key: bool = False, start_gateway: bool = False) -> dict[str, Any]:
140+
handle = _handle(identity_handle)
141+
if not handle:
142+
return {"status": "error", "error": "identity is required"}
143+
if not api_key.strip():
144+
return {"status": "error", "error": "API key is required"}
145+
actions: list[str] = []
146+
secrets = [api_key.strip()]
147+
try:
148+
previous = _handle(_env("INKBOX_IDENTITY"))
149+
symbols = _load_inkbox_symbols()
150+
scoped_key, identity = _resolve_credentials(api_key.strip(), handle, base_url, symbols, actions)
151+
secrets.append(scoped_key)
152+
client = symbols["Inkbox"](**inkbox_client_kwargs(scoped_key, base_url))
153+
_save("INKBOX_API_KEY", scoped_key)
154+
_save("INKBOX_IDENTITY", handle)
155+
if base_url:
156+
_save("INKBOX_BASE_URL", base_url)
157+
if project_dir:
158+
_save("CODEX_PROJECT_DIR", project_dir)
159+
_save("INKBOX_ALLOW_ALL_USERS", "true")
160+
_save("INKBOX_CODEX_AUTO_APPROVE_INKBOX_TOOLS", "true")
161+
actions.append("saved_codex_configuration")
162+
if voice_ai:
163+
_configure_voice(identity, client, voice_ai_instructions)
164+
actions.append("configured_voice_ai")
165+
blocker = _configure_signing(identity, client, rotate_signing_key, not previous or previous == handle, actions)
166+
if blocker:
167+
return {"status": "requires_human", "identity": handle, "actions": actions, "human_actions": [blocker]}
168+
running = False
169+
if start_gateway:
170+
running = _start_gateway(actions)
171+
if not running:
172+
return {"status": "error", "identity": handle, "actions": actions, "error": "Codex gateway did not become ready. Check ~/.inkbox-codex/gateway.log."}
173+
return {"status": "configured", "identity": handle, "actions": actions, "gateway_running": running}
174+
except Exception as exc:
175+
message = str(exc)
176+
for secret in secrets:
177+
if secret:
178+
message = message.replace(secret, "[redacted]")
179+
return {"status": "error", "identity": handle, "actions": actions, "error": message}

inkbox_codex/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
from __future__ import annotations
44

55
import argparse
6+
import json
7+
import os
68
import sys
79

810
try:
911
from . import daemon
12+
from .bootstrap import bootstrap
1013
from .config import inkbox_client_kwargs, read_config
1114
from .doctor import print_doctor
1215
from .setup_wizard import interactive_setup
1316
except ImportError: # pragma: no cover - direct local import/test fallback
1417
import daemon
18+
from bootstrap import bootstrap
1519
from config import inkbox_client_kwargs, read_config
1620
from doctor import print_doctor
1721
from setup_wizard import interactive_setup
@@ -50,6 +54,15 @@ def main(argv: list[str] | None = None) -> int:
5054
)
5155
sub = parser.add_subparsers(dest="command", required=True)
5256
sub.add_parser("setup", help="run the interactive setup wizard")
57+
bootstrap_parser = sub.add_parser("bootstrap", help="configure an existing identity without prompts")
58+
bootstrap_parser.add_argument("--identity", required=True)
59+
bootstrap_parser.add_argument("--api-key-stdin", action="store_true")
60+
bootstrap_parser.add_argument("--base-url", default="")
61+
bootstrap_parser.add_argument("--project-dir", default="")
62+
bootstrap_parser.add_argument("--voice-ai", action="store_true")
63+
bootstrap_parser.add_argument("--voice-ai-instructions-file")
64+
bootstrap_parser.add_argument("--rotate-signing-key", action="store_true")
65+
bootstrap_parser.add_argument("--start-gateway", action="store_true")
5366
sub.add_parser("run", help="run the bridge gateway in the foreground")
5467
sub.add_parser("start", help="start the bridge gateway in the background")
5568
sub.add_parser("stop", help="stop the background bridge gateway")
@@ -67,6 +80,15 @@ def main(argv: list[str] | None = None) -> int:
6780
if args.command == "setup":
6881
interactive_setup()
6982
return 0
83+
if args.command == "bootstrap":
84+
api_key = sys.stdin.read().strip() if args.api_key_stdin else os.getenv("INKBOX_API_KEY", "").strip()
85+
instructions = None
86+
if args.voice_ai_instructions_file:
87+
with open(args.voice_ai_instructions_file, encoding="utf-8") as source:
88+
instructions = source.read()
89+
result = bootstrap(identity_handle=args.identity, api_key=api_key, base_url=args.base_url, project_dir=args.project_dir, voice_ai=args.voice_ai, voice_ai_instructions=instructions, rotate_signing_key=args.rotate_signing_key, start_gateway=args.start_gateway)
90+
print(json.dumps(result, indent=2, sort_keys=True))
91+
return 0 if result.get("status") == "configured" else 2
7092
if args.command == "run":
7193
return daemon.run_foreground()
7294
if args.command == "start":

tests/test_bootstrap.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import types
5+
6+
from inkbox_codex import bootstrap as subject
7+
from inkbox_codex import cli
8+
9+
10+
class Identity:
11+
id = "identity-1"
12+
agent_handle = "helper"
13+
mailbox = types.SimpleNamespace(email_address="helper@example.com")
14+
phone_number = types.SimpleNamespace(number="+15551234567")
15+
tunnel = types.SimpleNamespace(public_host="helper.example.com")
16+
imessage_enabled = False
17+
imessage_number = None
18+
19+
def __init__(self, signing=False):
20+
self.signing = signing
21+
self.hosted = types.SimpleNamespace(voice="cedar", model="voice", instructions=None, authority_mode="contact_scoped")
22+
self.incoming = types.SimpleNamespace(incoming_call_action="auto_accept", client_websocket_url="wss://old", incoming_call_webhook_url=None)
23+
self.signing_creations = 0
24+
25+
def get_hosted_agent_config(self): return self.hosted
26+
def set_hosted_agent_config(self, **values): self.hosted = types.SimpleNamespace(authority_mode="contact_scoped", **values)
27+
def get_incoming_call_action(self): return self.incoming
28+
def set_incoming_call_action(self, **values): self.incoming = types.SimpleNamespace(**values)
29+
def get_signing_key_status(self): return types.SimpleNamespace(configured=self.signing)
30+
def create_signing_key(self):
31+
self.signing = True
32+
self.signing_creations += 1
33+
return types.SimpleNamespace(signing_key="signing-secret")
34+
35+
36+
class Client:
37+
def __init__(self, key, identity):
38+
self.key = key
39+
self.identity = identity
40+
self.api_keys = types.SimpleNamespace(create=lambda **_kwargs: types.SimpleNamespace(api_key="agent-secret"))
41+
self.imessages = types.SimpleNamespace()
42+
def whoami(self): return types.SimpleNamespace(auth_type="api_key", auth_subtype="api_key.agent_scoped.claimed")
43+
def list_identities(self): return [self.identity]
44+
def get_identity(self, handle):
45+
if handle != self.identity.agent_handle: raise RuntimeError("not found")
46+
return self.identity
47+
48+
49+
def install(monkeypatch, identity):
50+
saved = {}
51+
class Inkbox:
52+
def __new__(cls, *, api_key, **_kwargs): return Client(api_key, identity)
53+
monkeypatch.setattr(subject, "_load_inkbox_symbols", lambda: {
54+
"Inkbox": Inkbox,
55+
"ADMIN_SCOPED": "api_key.admin_scoped",
56+
"AGENT_CLAIMED": "api_key.agent_scoped.claimed",
57+
"AGENT_UNCLAIMED": "api_key.agent_scoped.unclaimed",
58+
})
59+
monkeypatch.setattr(subject, "_save", lambda name, value: saved.__setitem__(name, value))
60+
monkeypatch.setattr(subject, "_env", lambda name: saved.get(name, ""))
61+
return saved
62+
63+
64+
def test_bootstrap_configures_voice_signing_approvals_and_gateway(monkeypatch):
65+
identity = Identity()
66+
saved = install(monkeypatch, identity)
67+
monkeypatch.setattr(subject, "_start_gateway", lambda actions: actions.append("started_gateway_process") or True)
68+
result = subject.bootstrap(identity_handle="@helper", api_key="agent-secret", project_dir="/work", voice_ai=True, rotate_signing_key=True, start_gateway=True)
69+
assert result["status"] == "configured"
70+
assert result["gateway_running"] is True
71+
assert saved["CODEX_PROJECT_DIR"] == "/work"
72+
assert saved["INKBOX_CODEX_AUTO_APPROVE_INKBOX_TOOLS"] == "true"
73+
assert saved["INKBOX_SIGNING_KEY"] == "signing-secret"
74+
75+
76+
def test_bootstrap_requires_explicit_signing_rotation(monkeypatch):
77+
identity = Identity(signing=True)
78+
install(monkeypatch, identity)
79+
result = subject.bootstrap(identity_handle="helper", api_key="agent-secret")
80+
assert result["status"] == "requires_human"
81+
assert "--rotate-signing-key" in result["human_actions"][0]
82+
assert identity.signing_creations == 0
83+
84+
85+
def test_cli_never_prints_key(monkeypatch, capsys):
86+
monkeypatch.setenv("INKBOX_API_KEY", "top-secret")
87+
monkeypatch.setattr(cli, "bootstrap", lambda **kwargs: {"status": "configured", "identity": kwargs["identity_handle"]})
88+
assert cli.main(["bootstrap", "--identity", "helper"]) == 0
89+
output = capsys.readouterr().out
90+
assert json.loads(output)["status"] == "configured"
91+
assert "top-secret" not in output

0 commit comments

Comments
 (0)