Skip to content

Commit 7a7a1aa

Browse files
dcramercodex
andcommitted
Fix Google auth callback loop with pending-flow recovery
Add capability.auth.list + ash-sb auth list, strengthen google skill/auth UX rules, and inject callback-aware recovery context for capability skills to prefer auth complete over auth begin loops. Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 58df50a commit 7a7a1aa

12 files changed

Lines changed: 407 additions & 5 deletions

File tree

packages/ash-sandbox-cli/src/ash_sandbox_cli/commands/capability.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,51 @@ def auth_begin(
177177
typer.echo(f" Expires: {result.get('expires_at', '')}")
178178

179179

180+
@auth_app.command("list")
181+
def auth_list(
182+
capability: Annotated[
183+
str | None,
184+
typer.Option("--capability", "-c", help="Optional namespaced capability id"),
185+
] = None,
186+
account_hint: Annotated[
187+
str | None,
188+
typer.Option("--account", help="Optional account reference hint"),
189+
] = None,
190+
) -> None:
191+
"""List pending capability auth flows for the current caller."""
192+
params: dict[str, Any] = {}
193+
if capability:
194+
params["capability"] = capability
195+
if account_hint:
196+
params["account_hint"] = account_hint
197+
result = _call("capability.auth.list", params)
198+
flows = result.get("flows") or []
199+
if not isinstance(flows, list) or not flows:
200+
typer.echo("No pending capability auth flows.")
201+
return
202+
203+
typer.echo("Pending capability auth flows:")
204+
for flow in flows:
205+
if not isinstance(flow, dict):
206+
continue
207+
flow_id = str(flow.get("flow_id", "")).strip() or "?"
208+
capability_id = str(flow.get("capability", "")).strip()
209+
account = str(flow.get("account_hint", "")).strip()
210+
if capability_id and account:
211+
typer.echo(f"- {flow_id} ({capability_id}, account={account})")
212+
elif capability_id:
213+
typer.echo(f"- {flow_id} ({capability_id})")
214+
else:
215+
typer.echo(f"- {flow_id}")
216+
typer.echo(f" Auth URL: {flow.get('auth_url', '')}")
217+
typer.echo(f" Flow type: {flow.get('flow_type', 'authorization_code')}")
218+
if flow.get("user_code"):
219+
typer.echo(f" User code: {flow['user_code']}")
220+
if flow.get("poll_interval_seconds") is not None:
221+
typer.echo(f" Poll interval: {flow['poll_interval_seconds']}s")
222+
typer.echo(f" Expires: {flow.get('expires_at', '')}")
223+
224+
180225
@auth_app.command("complete")
181226
def auth_complete(
182227
flow_id: Annotated[

specs/capabilities.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,40 @@ Response:
379379
}
380380
```
381381

382+
#### `capability.auth.list`
383+
384+
Lists pending auth flows for the verified caller so follow-up callback/code messages can
385+
complete an existing flow without restarting auth.
386+
387+
Request params:
388+
389+
```json
390+
{
391+
"capability": "gog.calendar",
392+
"account_hint": "work",
393+
"context_token": "<signed-token>"
394+
}
395+
```
396+
397+
`capability` and `account_hint` are optional filters.
398+
399+
Response:
400+
401+
```json
402+
{
403+
"flows": [
404+
{
405+
"flow_id": "caf_01...",
406+
"capability": "gog.calendar",
407+
"account_hint": "work",
408+
"auth_url": "https://...",
409+
"expires_at": "2026-02-24T20:10:00Z",
410+
"flow_type": "authorization_code"
411+
}
412+
]
413+
}
414+
```
415+
382416
#### `capability.auth.poll`
383417

384418
Polls a pending device code auth flow. See `specs/capability-auth.md` for full contract.
@@ -388,6 +422,7 @@ Polls a pending device code auth flow. See `specs/capability-auth.md` for full c
388422
- `ash-sb capability list`
389423
- `ash-sb capability invoke --capability <id> --operation <name> --input-json <json>`
390424
- `ash-sb capability auth begin --capability <id> [--account <hint>]`
425+
- `ash-sb capability auth list [--capability <id>] [--account <hint>]`
391426
- `ash-sb capability auth complete --flow-id <id> (--callback-url <url> | --code <code>)`
392427
- `ash-sb capability auth poll --flow-id <id> [--timeout <secs>] [--interval <secs>]`
393428
(See `specs/capability-auth.md`.)

src/ash/capabilities/manager.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,44 @@ async def auth_begin(
305305

306306
return _auth_begin_response(flow)
307307

308+
async def list_auth_flows(
309+
self,
310+
*,
311+
user_id: str,
312+
capability_id: str | None = None,
313+
account_hint: str | None = None,
314+
) -> list[dict[str, Any]]:
315+
"""List pending auth flows for the caller."""
316+
normalized_user_id = _required_text(
317+
value=user_id,
318+
code="capability_invalid_input",
319+
message="user_id is required",
320+
)
321+
normalized_capability_id = (
322+
_required_capability_id(capability_id)
323+
if capability_id is not None
324+
else None
325+
)
326+
normalized_account_hint = _optional_text(account_hint)
327+
328+
async with self._lock:
329+
self._prune_expired_flows_locked()
330+
matches = [
331+
flow
332+
for flow in self._auth_flows.values()
333+
if flow.user_id == normalized_user_id
334+
and (
335+
normalized_capability_id is None
336+
or flow.capability_id == normalized_capability_id
337+
)
338+
and (
339+
normalized_account_hint is None
340+
or flow.account_hint == normalized_account_hint
341+
)
342+
]
343+
matches.sort(key=lambda flow: flow.expires_at, reverse=True)
344+
return [_auth_begin_response(flow) for flow in matches]
345+
308346
async def auth_complete(
309347
self,
310348
*,
@@ -940,6 +978,8 @@ def _linked_accounts_locked(
940978
def _auth_begin_response(flow: CapabilityAuthFlow) -> dict[str, Any]:
941979
result: dict[str, Any] = {
942980
"flow_id": flow.flow_id,
981+
"capability": flow.capability_id,
982+
"account_hint": flow.account_hint,
943983
"auth_url": flow.auth_url,
944984
"expires_at": flow.expires_at.isoformat().replace("+00:00", "Z"),
945985
"flow_type": flow.flow_type,

src/ash/integrations/skills/capabilities/google/SKILL.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,18 @@ Then:
6868

6969
When presenting auth instructions, always include:
7070

71+
- The exact `flow_id` returned by `auth begin`.
7172
- The exact `auth_url` returned by `auth begin` (never paraphrase or omit it).
7273
- The exact `user_code` when flow type is `device_code`.
7374
- A single clear instruction: complete consent, then paste callback URL or code.
7475

7576
Use one of these response templates exactly:
7677

7778
Authorization code flow:
78-
`To continue, open this Google auth URL: <auth_url>\nAfter approval, paste the full callback URL (or just the code) here.`
79+
`To continue, open this Google auth URL: <auth_url>\nFlow ID: <flow_id>\nAfter approval, paste the full callback URL (or just the code) here.`
7980

8081
Device code flow:
81-
`To continue, open: <auth_url>\nEnter this code: <user_code>\nAfter approval, tell me when done and I will continue.`
82+
`To continue, open: <auth_url>\nFlow ID: <flow_id>\nEnter this code: <user_code>\nAfter approval, tell me when done and I will continue.`
8283

8384
Use these commands:
8485

@@ -90,6 +91,10 @@ ash-sb capability auth complete --flow-id <id> --code '<CODE>'
9091

9192
If user intent is setup-only, stop after successful auth confirmation.
9293

94+
If the user provides a callback URL or auth code in a follow-up message, complete that existing flow immediately with `auth complete`.
95+
Do not start a new `auth begin` while a valid callback/code is present unless completion fails with invalid/expired flow.
96+
If `flow_id` is not already known, run `ash-sb capability auth list -c <capability> --account <alias>` and use the most recent pending flow.
97+
9398
### 2b. Proactive re-auth when scopes change or auth expires
9499

95100
If a user asks for an operation and capability invoke/list returns auth-required or similar auth errors:
@@ -124,6 +129,17 @@ If the user asks a broad question and does not provide scope, use these defaults
124129
- Day-at-a-glance: `list_events` with `{"calendar":"primary","window":"1d"}` plus unread/recent email query
125130
- Message deep read: run `get_message` for each item you summarize
126131

132+
### Account and calendar defaults
133+
134+
Interpret account/calendar phrasing with these defaults unless user explicitly says otherwise:
135+
136+
- "work calendar", "my work calendar", or "calendar at work" means account alias `work` and calendar `primary`.
137+
- "personal calendar", "my calendar", or unspecified calendar means account alias `default` and calendar `primary`.
138+
- "add/connect/link my <alias> calendar" means start auth for `gog.calendar` using that alias.
139+
140+
Do not ask taxonomy prompts like "do you mean second account vs shared calendar vs subscribed calendar" before starting auth.
141+
If account alias is implied, proceed with that alias and only ask a single follow-up if a concrete operation later needs a non-primary calendar id.
142+
127143
## Behavior Playbooks
128144

129145
### Summarize Emails

src/ash/integrations/skills/capabilities/google/references/auth-and-failures.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ If user intent is setup-only, stop after auth succeeds.
1616

1717
If user provides a callback URL (`http://localhost/?code=...`), pass it with `--callback-url`.
1818
If user provides only a code value, pass it with `--code`.
19+
If `flow_id` is missing, use `ash-sb capability auth list` (filtered by capability/account when possible) and complete the most recent pending flow.
20+
Do not start a new auth flow while a valid callback URL/code is already provided.
1921

2022
Never ask for OAuth secrets.
2123

src/ash/rpc/methods/capability.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ async def capability_auth_begin(params: dict[str, Any]) -> dict[str, Any]:
9090
except CapabilityError as e:
9191
raise ValueError(f"{e.code}: {e}") from e
9292

93+
async def capability_auth_list(params: dict[str, Any]) -> dict[str, Any]:
94+
user_id = _required_text(params, "user_id")
95+
capability_id = _optional_text(params, "capability")
96+
account_hint = _optional_text(params, "account_hint")
97+
try:
98+
flows = await manager.list_auth_flows(
99+
user_id=user_id,
100+
capability_id=capability_id,
101+
account_hint=account_hint,
102+
)
103+
except CapabilityError as e:
104+
raise ValueError(f"{e.code}: {e}") from e
105+
return {"flows": flows}
106+
93107
async def capability_auth_complete(params: dict[str, Any]) -> dict[str, Any]:
94108
flow_id = _required_text(params, "flow_id")
95109
user_id = _required_text(params, "user_id")
@@ -134,6 +148,7 @@ async def capability_auth_poll(params: dict[str, Any]) -> dict[str, Any]:
134148
server.register("capability.list", capability_list)
135149
server.register("capability.invoke", capability_invoke)
136150
server.register("capability.auth.begin", capability_auth_begin)
151+
server.register("capability.auth.list", capability_auth_list)
137152
server.register("capability.auth.complete", capability_auth_complete)
138153
server.register("capability.auth.poll", capability_auth_poll)
139154

src/ash/tools/builtin/skills.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
_SECRET_ENV_NAME_PATTERNS = (
2020
r"(?i)(?:^|_)(?:api[_-]?key|token|secret|password|passwd|auth)(?:$|_)",
2121
)
22+
_OAUTH_CALLBACK_URL_PATTERN = re.compile(r"https?://localhost[^\s]*[?&]code=")
23+
_OAUTH_CODE_ONLY_PATTERN = re.compile(r"^\s*4/[^\s]+\s*$")
2224

2325
# Built-in skills that are handled specially (not loaded from SKILL.md files)
2426
BUILTIN_SKILLS: dict[str, str] = {}
@@ -75,9 +77,13 @@
7577
This skill declares host capabilities. When capability auth is required or re-authorization is needed:
7678
7779
1. Start auth immediately with `ash-sb capability auth begin -c <capability>` (do not only say "need auth").
78-
2. Include the exact `auth_url` returned by the command.
79-
3. Include the exact `user_code` for device-code flows.
80-
4. Ask for one clear next step: paste callback URL or code (or confirm completion for device flow polling).
80+
2. Include the exact `flow_id` returned by the command.
81+
3. Include the exact `auth_url` returned by the command.
82+
4. Include the exact `user_code` for device-code flows.
83+
5. Ask for one clear next step: paste callback URL or code (or confirm completion for device flow polling).
84+
85+
If the user already pasted a callback URL or auth code, complete the existing flow first.
86+
Do not run another `auth begin` until completion fails as invalid/expired.
8187
8288
Never replace auth instructions with generic hints or slash-command suggestions.
8389
Preserve auth URLs/codes verbatim in your `complete` output.
@@ -460,6 +466,37 @@ async def _check_capability_availability(
460466
f"Skill '{skill.name}' has unavailable capabilities: {', '.join(missing)}"
461467
)
462468

469+
def _looks_like_oauth_callback_or_code(self, message: str) -> bool:
470+
"""Detect user-provided OAuth callback URLs or code-only replies."""
471+
return bool(
472+
_OAUTH_CALLBACK_URL_PATTERN.search(message)
473+
or _OAUTH_CODE_ONLY_PATTERN.match(message)
474+
)
475+
476+
def _build_capability_auth_recovery_context(
477+
self,
478+
*,
479+
skill: SkillDefinition,
480+
message: str,
481+
user_context: str,
482+
) -> str:
483+
"""Inject deterministic auth-completion guidance for callback follow-ups."""
484+
if not skill.capabilities:
485+
return user_context
486+
if not self._looks_like_oauth_callback_or_code(message):
487+
return user_context
488+
489+
hint = (
490+
"OAuth callback/code detected in the latest user message.\n"
491+
"Do this before any new auth begin:\n"
492+
"1. Run `ash-sb capability auth list` (add `-c <capability>` / `--account <alias>` if known).\n"
493+
"2. Complete the newest matching pending flow with `ash-sb capability auth complete --flow-id <flow_id> --callback-url '<user_callback_url>'` or `--code '<user_code>'`.\n"
494+
"3. Only run `auth begin` if completion fails with invalid/expired flow."
495+
)
496+
if not user_context:
497+
return hint
498+
return f"{user_context}\n\n{hint}"
499+
463500
async def execute(
464501
self,
465502
input_data: dict[str, Any],
@@ -475,6 +512,9 @@ async def execute(
475512
if not message:
476513
return ToolResult.error("Missing required field: message")
477514

515+
message = str(message)
516+
user_context = str(user_context)
517+
478518
if not self._registry.has(skill_name):
479519
self._registry.reload_all(self._config.workspace)
480520
if not self._registry.has(skill_name):
@@ -486,6 +526,11 @@ async def execute(
486526
)
487527

488528
skill = self._registry.get(skill_name)
529+
user_context = self._build_capability_auth_recovery_context(
530+
skill=skill,
531+
message=message,
532+
user_context=user_context,
533+
)
489534
skill_config = self._config.skills.get(skill_name)
490535

491536
if skill_config and not skill_config.enabled:

tests/test_bundled_gog_skill.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,12 @@ def test_google_skill_includes_archive_and_label_mutation_guidance() -> None:
5757
assert "archive_messages" in text
5858
assert "update_labels" in text
5959
assert "always confirm key details" in text
60+
61+
62+
def test_google_skill_avoids_auth_loop_and_includes_flow_recovery_guidance() -> None:
63+
text = _load_google_skill_text().lower()
64+
assert "flow id: <flow_id>" in text
65+
assert (
66+
"do not start a new `auth begin` while a valid callback/code is present" in text
67+
)
68+
assert "ash-sb capability auth list" in text

tests/test_capabilities.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,44 @@ async def test_auth_begin_reuses_pending_flow_for_same_scope() -> None:
562562
assert len(provider.begin_calls) == 1
563563

564564

565+
@pytest.mark.asyncio
566+
async def test_list_auth_flows_filters_and_scopes_by_user() -> None:
567+
manager = CapabilityManager(auth_flow_ttl_seconds=300)
568+
provider = _RecordingProvider(namespace="gog")
569+
await manager.register_provider(provider)
570+
571+
user_one_work = await manager.auth_begin(
572+
capability_id="gog.email",
573+
user_id="user-1",
574+
chat_type="private",
575+
account_hint="work",
576+
)
577+
await manager.auth_begin(
578+
capability_id="gog.email",
579+
user_id="user-1",
580+
chat_type="private",
581+
account_hint="personal",
582+
)
583+
await manager.auth_begin(
584+
capability_id="gog.email",
585+
user_id="user-2",
586+
chat_type="private",
587+
account_hint="work",
588+
)
589+
590+
user_one_flows = await manager.list_auth_flows(user_id="user-1")
591+
assert len(user_one_flows) == 2
592+
assert {flow["account_hint"] for flow in user_one_flows} == {"work", "personal"}
593+
assert all(flow["capability"] == "gog.email" for flow in user_one_flows)
594+
595+
filtered = await manager.list_auth_flows(
596+
user_id="user-1",
597+
capability_id="gog.email",
598+
account_hint="work",
599+
)
600+
assert [flow["flow_id"] for flow in filtered] == [user_one_work["flow_id"]]
601+
602+
565603
@pytest.mark.asyncio
566604
async def test_provider_registration_enforces_namespace_prefix() -> None:
567605
manager = CapabilityManager()

0 commit comments

Comments
 (0)