@guuey/chat 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hitl.d.ts +5 -0
- package/dist/hitl.d.ts.map +1 -1
- package/dist/hitl.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/native/components.d.ts.map +1 -1
- package/dist/native/components.js +5 -5
- package/dist/oauth.d.ts +87 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +121 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +4 -1
- package/dist/react/components.d.ts.map +1 -1
- package/dist/react/components.js +7 -5
- package/dist/react/guuey-chat.d.ts +14 -0
- package/dist/react/guuey-chat.d.ts.map +1 -1
- package/dist/react/guuey-chat.js +21 -3
- package/dist/react/oauth-return.d.ts +61 -0
- package/dist/react/oauth-return.d.ts.map +1 -0
- package/dist/react/oauth-return.js +103 -0
- package/dist/react/use-transcript.d.ts +3 -3
- package/dist/react/use-transcript.d.ts.map +1 -1
- package/dist/react/use-transcript.js +4 -27
- package/dist/react.d.ts +1 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -0
- package/dist/strings.d.ts +10 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +4 -0
- package/dist/types.d.ts +25 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/corpus/README.md +7 -1
- package/src/corpus/__snapshots__/corpus.test.ts.snap +126 -7
- package/src/corpus/drive.ts +43 -12
- package/src/corpus/fixtures.ts +95 -4
- package/src/hitl.ts +5 -0
- package/src/index.ts +11 -0
- package/src/native/components.tsx +15 -7
- package/src/oauth.ts +157 -0
- package/src/plan.ts +4 -1
- package/src/react/components.tsx +17 -9
- package/src/react/guuey-chat.tsx +49 -1
- package/src/react/oauth-return.ts +129 -0
- package/src/react/use-transcript.ts +7 -32
- package/src/react.tsx +9 -0
- package/src/strings.ts +14 -0
- package/src/types.ts +22 -11
- package/styles.css +26 -0
package/src/corpus/drive.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* `@silverprotocol/core` Reducer). Test-only plumbing — excluded from the
|
|
6
6
|
* build; the 3b live/history assemblers are the production twins.
|
|
7
7
|
*/
|
|
8
|
-
import { Reducer, type AgEvent } from "@silverprotocol/core";
|
|
8
|
+
import { Reducer, type AgEvent, type AgPausedAsk } from "@silverprotocol/core";
|
|
9
9
|
import type { InvokeTurnEvent } from "@guuey/agent-client";
|
|
10
|
+
import { hitlPromptsFromFold, type HitlAnswerRecord } from "../hitl.js";
|
|
10
11
|
import type { ProfilePromptInput, TranscriptInputs, TranscriptMessage } from "../types.js";
|
|
11
12
|
|
|
12
13
|
export interface DriveOptions {
|
|
@@ -21,8 +22,10 @@ export interface DriveOptions {
|
|
|
21
22
|
viewPhases?: TranscriptInputs["viewPhases"];
|
|
22
23
|
historyCards?: TranscriptInputs["historyCards"];
|
|
23
24
|
historyState?: TranscriptInputs["historyState"];
|
|
24
|
-
/** Resolve accumulated prompts to this state
|
|
25
|
+
/** Resolve accumulated link prompts to this state. */
|
|
25
26
|
promptState?: ProfilePromptInput["state"];
|
|
27
|
+
/** The host-side answer ledger for AgJSON hitl asks (fixture 4's answered leg). */
|
|
28
|
+
hitlAnswers?: Readonly<Record<string, HitlAnswerRecord>>;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export function driveTurn(events: InvokeTurnEvent[], opts: DriveOptions = {}): TranscriptInputs {
|
|
@@ -53,15 +56,6 @@ export function driveTurn(events: InvokeTurnEvent[], opts: DriveOptions = {}): T
|
|
|
53
56
|
error = { message: ev.message, code: ev.code };
|
|
54
57
|
status = "ready";
|
|
55
58
|
break;
|
|
56
|
-
case "profile-consent":
|
|
57
|
-
prompts.push({
|
|
58
|
-
id: `consent-${prompts.length}`,
|
|
59
|
-
kind: "consent",
|
|
60
|
-
appId: ev.request.appId,
|
|
61
|
-
requested: ev.request.requested,
|
|
62
|
-
state: opts.promptState ?? "pending",
|
|
63
|
-
});
|
|
64
|
-
break;
|
|
65
59
|
case "profile-link":
|
|
66
60
|
prompts.push({
|
|
67
61
|
id: `link-${prompts.length}`,
|
|
@@ -101,7 +95,9 @@ export function driveTurn(events: InvokeTurnEvent[], opts: DriveOptions = {}): T
|
|
|
101
95
|
statusElapsedMs: opts.statusElapsedMs ?? 0,
|
|
102
96
|
activeTool,
|
|
103
97
|
error,
|
|
104
|
-
|
|
98
|
+
// The link ledger + the AgJSON hitl asks lifted from the fold — the
|
|
99
|
+
// live assembler's exact composition (use-transcript.ts).
|
|
100
|
+
prompts: [...prompts, ...hitlPromptsFromFold(sawAgEvents ? reducer.result() : null, opts.hitlAnswers ?? {})],
|
|
105
101
|
messages,
|
|
106
102
|
...(opts.sendStates !== undefined ? { sendStates: opts.sendStates } : {}),
|
|
107
103
|
...(opts.viewPhases !== undefined ? { viewPhases: opts.viewPhases } : {}),
|
|
@@ -180,5 +176,40 @@ export function frame(
|
|
|
180
176
|
};
|
|
181
177
|
}
|
|
182
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The runtime's cross-app profile consent ask (guuey#207) — the paused turn
|
|
181
|
+
* the pod appends AFTER the agent's own turn: `turn.start` → `hitl.ask` →
|
|
182
|
+
* `turn.done outcome:"paused"` carrying the same `AgPausedAsk`. Mirrors
|
|
183
|
+
* `nocode-runtime/src/profile-consent.ts` byte-for-byte in shape.
|
|
184
|
+
*/
|
|
185
|
+
export const CONSENT_ASK: AgPausedAsk = {
|
|
186
|
+
askId: "profile-consent:app-1:thread-corpus",
|
|
187
|
+
kind: "approval",
|
|
188
|
+
message: "Trip Planner wants to read your guuey profile — the notes agents keep about you across apps.",
|
|
189
|
+
grantModes: [
|
|
190
|
+
{ id: "always", label: "Always allow", description: "Every conversation with this agent" },
|
|
191
|
+
{ id: "once", label: "Allow this chat", description: "Only this conversation" },
|
|
192
|
+
],
|
|
193
|
+
metadata: { appId: "app-1", requested: "read" },
|
|
194
|
+
};
|
|
195
|
+
export function consentTurn(next: () => number, ask: AgPausedAsk = CONSENT_ASK): AgEvent[] {
|
|
196
|
+
const turnId = `${ask.askId}#turn`;
|
|
197
|
+
return [
|
|
198
|
+
{ type: "turn.start", threadId: THREAD, turnId, seq: next() },
|
|
199
|
+
{
|
|
200
|
+
type: "hitl.ask",
|
|
201
|
+
turnId,
|
|
202
|
+
askId: ask.askId,
|
|
203
|
+
kind: ask.kind,
|
|
204
|
+
...(ask.message !== undefined ? { message: ask.message } : {}),
|
|
205
|
+
...(ask.grantModes !== undefined ? { grantModes: ask.grantModes } : {}),
|
|
206
|
+
...(ask.metadata !== undefined ? { metadata: ask.metadata } : {}),
|
|
207
|
+
continuation: "turn",
|
|
208
|
+
seq: next(),
|
|
209
|
+
},
|
|
210
|
+
{ type: "turn.done", turnId, finishReason: "paused", outcome: { type: "paused", asks: [ask] }, seq: next() },
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
183
214
|
export const session: InvokeTurnEvent = { kind: "session", threadId: THREAD };
|
|
184
215
|
export const doneEvent: InvokeTurnEvent = { kind: "done", stopReason: "end_turn" };
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { InvokeTurnEvent } from "@guuey/agent-client";
|
|
|
13
13
|
import type { TranscriptInputs } from "../types.js";
|
|
14
14
|
import {
|
|
15
15
|
boot,
|
|
16
|
+
CONSENT_ASK,
|
|
17
|
+
consentTurn,
|
|
16
18
|
doneEvent,
|
|
17
19
|
driveTurn,
|
|
18
20
|
frame,
|
|
@@ -81,17 +83,29 @@ export function coldStart(elapsedMs: number): TranscriptInputs {
|
|
|
81
83
|
return driveTurn([], { userText: "hi", finalStatus: "connecting", statusElapsedMs: elapsedMs });
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* 4. consent-gate — R10 pending grant-mode card → answered one-line collapse.
|
|
88
|
+
* The pod's real shape (guuey#207): the agent turn runs WITHOUT the profile
|
|
89
|
+
* and closes, then the paused consent turn rides after it; the answered leg
|
|
90
|
+
* is the host ledger recording the picked mode (`resolved` + `always`).
|
|
91
|
+
*/
|
|
85
92
|
export function consentGate(state: "pending" | "answered"): TranscriptInputs {
|
|
86
93
|
const s = seqSource();
|
|
87
94
|
const events: InvokeTurnEvent[] = [
|
|
88
95
|
session,
|
|
89
96
|
frame(boot(s), { status: "thinking" }),
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
frame(textPart(s, "x", "Booked your usual — I couldn't see your profile yet."), {
|
|
98
|
+
status: "responding",
|
|
99
|
+
text: "Booked your usual — I couldn't see your profile yet.",
|
|
100
|
+
}),
|
|
101
|
+
frame([{ type: "message.end", id: "msg-corpus", seq: s() }, { type: "turn.done", turnId: "turn-corpus", finishReason: "stop", outcome: { type: "success" }, seq: s() }]),
|
|
102
|
+
frame(consentTurn(s)),
|
|
92
103
|
doneEvent,
|
|
93
104
|
];
|
|
94
|
-
return driveTurn(events, {
|
|
105
|
+
return driveTurn(events, {
|
|
106
|
+
userText: "book my usual",
|
|
107
|
+
...(state === "answered" ? { hitlAnswers: { [CONSENT_ASK.askId]: { status: "resolved", grantModeId: "always" } } } : {}),
|
|
108
|
+
});
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
/** 5. bypass-text-only — plan identical to a silver stream carrying only text. */
|
|
@@ -627,6 +641,83 @@ export function prodWireGguiRender(): TranscriptInputs {
|
|
|
627
641
|
return driveTurn(events, { userText: "show me the board" });
|
|
628
642
|
}
|
|
629
643
|
|
|
644
|
+
/**
|
|
645
|
+
* 25. oauth-auth-ask (guuey#178 Slice 4) — the MCP OAuth broker's "authorize
|
|
646
|
+
* this server" card: a paused turn whose PERSISTED ask is `kind:"auth"` with
|
|
647
|
+
* `authConfig:{ scheme:"oauth2", authorizationUrl }` + the two grant modes
|
|
648
|
+
* (the exact wire `nocode-runtime/src/mcp-oauth-consent.ts` emits — askId
|
|
649
|
+
* `mcp-oauth:<appId>:<serverName>:<threadId>`, `metadata:{appId, serverName,
|
|
650
|
+
* expiresAt}`, top-level `expiresAt`). Unlike family 20 there is NO answer
|
|
651
|
+
* door: a mode pick opens `authorizationUrl&mode=<id>&returnTo=<here>`; the
|
|
652
|
+
* decline is a dismissal ("Not now"). Driven through the real Reducer.
|
|
653
|
+
*/
|
|
654
|
+
export const OAUTH_ASK_AUTHORIZE_URL = "https://mcp.dev.sandbox.guuey.com/oauth/start?state=" + "a".repeat(64);
|
|
655
|
+
export function oauthAuthAsk(): TranscriptInputs {
|
|
656
|
+
const reducer = new Reducer();
|
|
657
|
+
let seq = 0;
|
|
658
|
+
const s = (): number => seq++;
|
|
659
|
+
const askId = "mcp-oauth:app_1:linear:thread-oauth";
|
|
660
|
+
const events: AgEvent[] = [
|
|
661
|
+
{ type: "turn.start", threadId: "thread-oauth", turnId: "turn-o", seq: s() },
|
|
662
|
+
{ type: "message.start", id: "msg-o", role: "assistant", turnId: "turn-o", threadId: "thread-oauth", seq: s() },
|
|
663
|
+
{ type: "text.start", id: "to", seq: s() },
|
|
664
|
+
{ type: "text.delta", id: "to", delta: "I can't reach Linear yet.", seq: s() },
|
|
665
|
+
{ type: "text.end", id: "to", seq: s() },
|
|
666
|
+
{ type: "turn.done", turnId: "turn-o", outcome: { type: "success" }, seq: s() },
|
|
667
|
+
{ type: "turn.start", threadId: "thread-oauth", turnId: `${askId}#turn`, seq: s() },
|
|
668
|
+
{
|
|
669
|
+
type: "hitl.ask",
|
|
670
|
+
turnId: `${askId}#turn`,
|
|
671
|
+
askId,
|
|
672
|
+
kind: "auth",
|
|
673
|
+
message: "Trip Planner wants to use your Linear account",
|
|
674
|
+
authConfig: { scheme: "oauth2", authorizationUrl: OAUTH_ASK_AUTHORIZE_URL },
|
|
675
|
+
grantModes: [
|
|
676
|
+
{ id: "always", label: "Always allow", description: "Every conversation with this agent" },
|
|
677
|
+
{ id: "once", label: "Allow this chat", description: "Only this conversation" },
|
|
678
|
+
],
|
|
679
|
+
metadata: { appId: "app_1", serverName: "linear", expiresAt: "2026-08-17T10:10:00.000Z" },
|
|
680
|
+
expiresAt: "2026-08-17T10:10:00.000Z",
|
|
681
|
+
continuation: "turn",
|
|
682
|
+
seq: s(),
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
type: "turn.done",
|
|
686
|
+
turnId: `${askId}#turn`,
|
|
687
|
+
finishReason: "paused",
|
|
688
|
+
outcome: {
|
|
689
|
+
type: "paused",
|
|
690
|
+
asks: [
|
|
691
|
+
{
|
|
692
|
+
askId,
|
|
693
|
+
kind: "auth",
|
|
694
|
+
message: "Trip Planner wants to use your Linear account",
|
|
695
|
+
authConfig: { scheme: "oauth2", authorizationUrl: OAUTH_ASK_AUTHORIZE_URL },
|
|
696
|
+
grantModes: [
|
|
697
|
+
{ id: "always", label: "Always allow", description: "Every conversation with this agent" },
|
|
698
|
+
{ id: "once", label: "Allow this chat", description: "Only this conversation" },
|
|
699
|
+
],
|
|
700
|
+
metadata: { appId: "app_1", serverName: "linear", expiresAt: "2026-08-17T10:10:00.000Z" },
|
|
701
|
+
expiresAt: "2026-08-17T10:10:00.000Z",
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
seq: s(),
|
|
706
|
+
},
|
|
707
|
+
];
|
|
708
|
+
for (const ev of events) reducer.push(ev);
|
|
709
|
+
return {
|
|
710
|
+
result: reducer.result(),
|
|
711
|
+
assistantText: "",
|
|
712
|
+
status: "ready",
|
|
713
|
+
statusElapsedMs: 0,
|
|
714
|
+
activeTool: null,
|
|
715
|
+
error: null,
|
|
716
|
+
prompts: [], // the test composes these via hitlPromptsFromFold + the answer ledger
|
|
717
|
+
messages: [{ role: "user", text: "what's on my Linear board?" }],
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
630
721
|
/**
|
|
631
722
|
* What ggui's `resources/read` answers for a render locator (guuey#209 C2):
|
|
632
723
|
* the shell that boots ggui's runtime with the live-channel material
|
package/src/hitl.ts
CHANGED
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* Answer states encode guuey's ratified dismissal ruling (#16): dismissal
|
|
20
20
|
* maps to `cancelled` (still-pending, re-askable — the card stays
|
|
21
21
|
* answerable), `declined` is the durable explicit deny.
|
|
22
|
+
*
|
|
23
|
+
* The first producer is the guuey runtime's cross-app profile consent
|
|
24
|
+
* (guuey#207): the pod appends a paused turn whose ask declares
|
|
25
|
+
* `grantModes: [always, once?]`; the host delivers the built answer through
|
|
26
|
+
* `@guuey/agent-client`'s `createHitlAnswerRelay` (`POST <pod>/agent/hitl-answer`).
|
|
22
27
|
*/
|
|
23
28
|
import {
|
|
24
29
|
validateHitlAnswer,
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,17 @@ export {
|
|
|
32
32
|
type HitlAnswerRecord,
|
|
33
33
|
type HitlPromptAction,
|
|
34
34
|
} from "./hitl.js";
|
|
35
|
+
export {
|
|
36
|
+
OAUTH_LINK_PARAMS,
|
|
37
|
+
OAUTH_RETURN_PARAMS,
|
|
38
|
+
OAUTH_SCHEME,
|
|
39
|
+
oauthAuthorizeAsk,
|
|
40
|
+
oauthAuthorizeHref,
|
|
41
|
+
parseOAuthReturn,
|
|
42
|
+
stripOAuthReturn,
|
|
43
|
+
type OAuthAuthorizeAsk,
|
|
44
|
+
type OAuthReturn,
|
|
45
|
+
} from "./oauth.js";
|
|
35
46
|
export {
|
|
36
47
|
DEFAULT_CHAT_THEME,
|
|
37
48
|
GUUEY_CHAT_THEME,
|
|
@@ -492,7 +492,9 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
492
492
|
<MutedText ctx={ctx}>
|
|
493
493
|
{item.state === "resolved"
|
|
494
494
|
? item.chosenModeLabel !== null
|
|
495
|
-
?
|
|
495
|
+
? item.oauth !== null
|
|
496
|
+
? s.promptOAuthSent(item.chosenModeLabel)
|
|
497
|
+
: s.promptAnsweredWith(item.chosenModeLabel)
|
|
496
498
|
: s.promptAccept
|
|
497
499
|
: s.promptDeclinedRecord}
|
|
498
500
|
</MutedText>
|
|
@@ -540,9 +542,17 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
540
542
|
</Pressable>
|
|
541
543
|
))
|
|
542
544
|
)}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
545
|
+
{/* The OAuth arm has no durable deny — "Not now" dismisses
|
|
546
|
+
(nothing written; the ask re-emits next turn). */}
|
|
547
|
+
{item.oauth !== null ? (
|
|
548
|
+
<Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "dismiss")}>
|
|
549
|
+
<Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptNotNow}</Text>
|
|
550
|
+
</Pressable>
|
|
551
|
+
) : (
|
|
552
|
+
<Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "decline")}>
|
|
553
|
+
<Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptDecline}</Text>
|
|
554
|
+
</Pressable>
|
|
555
|
+
)}
|
|
546
556
|
</View>
|
|
547
557
|
</>
|
|
548
558
|
)}
|
|
@@ -565,9 +575,7 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
565
575
|
style={{ backgroundColor: tokens.palette.surface, borderRadius: tokens.radius, padding: tokens.pad, gap: 8 }}
|
|
566
576
|
>
|
|
567
577
|
<Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
|
|
568
|
-
{item.
|
|
569
|
-
? `${item.appId} requests ${item.requested} access`
|
|
570
|
-
: `Link your account to ${item.appId}`}
|
|
578
|
+
{`Link your account to ${item.appId}`}
|
|
571
579
|
</Text>
|
|
572
580
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
573
581
|
<Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "accept")}>
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "authorize this server" arm of R10 (guuey#178 Slice 4) — the pure
|
|
3
|
+
* helpers every surface (web kit, widget, studio, portal native) uses for a
|
|
4
|
+
* `hitl.ask kind:"auth"` whose `authConfig.scheme` is `oauth2`.
|
|
5
|
+
*
|
|
6
|
+
* The producer is the guuey runtime's MCP OAuth broker: the pod appends a
|
|
7
|
+
* paused turn (`turn.done outcome:"paused"`) whose ask declares
|
|
8
|
+
* `authConfig:{ scheme:"oauth2", authorizationUrl }` + `grantModes:
|
|
9
|
+
* [always, once?]`. Unlike the profile-consent ask there is **no answer
|
|
10
|
+
* door**: the answer IS the redirect. Picking a mode opens
|
|
11
|
+
* `authorizationUrl` with two query params appended by THIS client —
|
|
12
|
+
*
|
|
13
|
+
* - `mode=<grantModeId>` — the pre-chosen grant (recorded by the broker's
|
|
14
|
+
* callback; the mode is fixed BEFORE the identity-provider hop), and
|
|
15
|
+
* - `returnTo=<the surface's own location>` — where the broker 302s back
|
|
16
|
+
* to when the dance ends (`?connected=<serverName>` on success,
|
|
17
|
+
* `?error=<reason>` on failure). The broker allowlists it; the client
|
|
18
|
+
* just says where it lives.
|
|
19
|
+
*
|
|
20
|
+
* On return the surface strips the two params, shows a one-line notice, and
|
|
21
|
+
* does nothing else: the NEXT turn's pre-turn preflight on the pod resolves
|
|
22
|
+
* `connected` (or asks again). No client-side state survives the redirect
|
|
23
|
+
* on purpose. "Not now" is a plain dismissal (`cancelled` — still pending,
|
|
24
|
+
* re-askable); nothing is written anywhere and the ask re-emits next turn.
|
|
25
|
+
*
|
|
26
|
+
* Everything here is string-level (no `URL`/`URLSearchParams`) so it runs
|
|
27
|
+
* identically on React Native, whose URL polyfill is partial.
|
|
28
|
+
*/
|
|
29
|
+
import type { AgPausedAsk } from "@silverprotocol/core";
|
|
30
|
+
|
|
31
|
+
/** The `authConfig.scheme` this arm recognises (the spec's OAuth 2 vocabulary). */
|
|
32
|
+
export const OAUTH_SCHEME = "oauth2";
|
|
33
|
+
|
|
34
|
+
/** The query params THIS client appends to the authorize link. */
|
|
35
|
+
export const OAUTH_LINK_PARAMS = { mode: "mode", returnTo: "returnTo" } as const;
|
|
36
|
+
|
|
37
|
+
/** The query params the broker's callback appends to `returnTo`. */
|
|
38
|
+
export const OAUTH_RETURN_PARAMS = { connected: "connected", error: "error" } as const;
|
|
39
|
+
|
|
40
|
+
/** What an auth ask declares once narrowed to the OAuth arm. */
|
|
41
|
+
export interface OAuthAuthorizeAsk {
|
|
42
|
+
authorizationUrl: string;
|
|
43
|
+
scopes: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Narrow an ask to the OAuth arm: `kind:"auth"` + `authConfig.scheme ===
|
|
48
|
+
* "oauth2"` + an `authorizationUrl` to open. Anything else (a plain
|
|
49
|
+
* approval, an auth ask with another scheme, a URL-less declaration) is
|
|
50
|
+
* `null` and renders as an ordinary hitl card.
|
|
51
|
+
*/
|
|
52
|
+
export function oauthAuthorizeAsk(ask: AgPausedAsk): OAuthAuthorizeAsk | null {
|
|
53
|
+
if (ask.kind !== "auth" || ask.authConfig === undefined) return null;
|
|
54
|
+
const { scheme, authorizationUrl, scopes } = ask.authConfig;
|
|
55
|
+
if (scheme !== OAUTH_SCHEME || authorizationUrl === undefined || authorizationUrl === "") return null;
|
|
56
|
+
return { authorizationUrl, scopes: scopes ?? [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The link to open for a mode pick: `authorizationUrl` + `&mode=<grantModeId>`
|
|
61
|
+
* + `&returnTo=<returnTo>` (each value URI-encoded; `?` vs `&` chosen from
|
|
62
|
+
* the URL as declared). `grantModeId` is required iff the ask declares
|
|
63
|
+
* modes (`null` = a plain accept on a mode-less auth ask — no `mode` param).
|
|
64
|
+
* Throws on a mode the ask did not declare — the inputs came from the
|
|
65
|
+
* declaration itself, so a mismatch is a construction bug, never a user
|
|
66
|
+
* state.
|
|
67
|
+
*/
|
|
68
|
+
export function oauthAuthorizeHref(ask: AgPausedAsk, grantModeId: string | null, returnTo: string): string {
|
|
69
|
+
const oauth = oauthAuthorizeAsk(ask);
|
|
70
|
+
if (oauth === null) throw new Error("oauthAuthorizeHref: the ask is not an oauth2 auth ask");
|
|
71
|
+
const declared = ask.grantModes ?? [];
|
|
72
|
+
if (grantModeId === null && declared.length > 0) {
|
|
73
|
+
throw new Error(`oauthAuthorizeHref: ${ask.askId} declares grant modes — a mode is required`);
|
|
74
|
+
}
|
|
75
|
+
if (grantModeId !== null && !declared.some((m) => m.id === grantModeId)) {
|
|
76
|
+
throw new Error(`oauthAuthorizeHref: grant mode "${grantModeId}" is not declared on ${ask.askId}`);
|
|
77
|
+
}
|
|
78
|
+
if (returnTo === "") throw new Error("oauthAuthorizeHref: returnTo is required");
|
|
79
|
+
const sep = oauth.authorizationUrl.includes("?") ? "&" : "?";
|
|
80
|
+
const mode = grantModeId === null ? "" : `${OAUTH_LINK_PARAMS.mode}=${encodeURIComponent(grantModeId)}&`;
|
|
81
|
+
return `${oauth.authorizationUrl}${sep}${mode}${OAUTH_LINK_PARAMS.returnTo}=${encodeURIComponent(returnTo)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The broker's answer, lifted off the return location. */
|
|
85
|
+
export type OAuthReturn =
|
|
86
|
+
| { kind: "connected"; serverName: string }
|
|
87
|
+
| { kind: "error"; reason: string };
|
|
88
|
+
|
|
89
|
+
/** `decodeURIComponent` that yields the raw text for a malformed escape (a foreign param is not ours to fail on). */
|
|
90
|
+
function safeDecode(s: string): string {
|
|
91
|
+
try {
|
|
92
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
93
|
+
} catch {
|
|
94
|
+
return s;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Split a URL (or bare query) into its query pairs — RN-safe, no `URLSearchParams`. */
|
|
99
|
+
function queryPairs(urlOrQuery: string): Array<[string, string]> {
|
|
100
|
+
const q = urlOrQuery.indexOf("?");
|
|
101
|
+
let query = q === -1 ? (urlOrQuery.startsWith("&") || !urlOrQuery.includes("=") ? "" : urlOrQuery) : urlOrQuery.slice(q + 1);
|
|
102
|
+
const hash = query.indexOf("#");
|
|
103
|
+
if (hash !== -1) query = query.slice(0, hash);
|
|
104
|
+
const pairs: Array<[string, string]> = [];
|
|
105
|
+
for (const part of query.split("&")) {
|
|
106
|
+
if (part === "") continue;
|
|
107
|
+
const eq = part.indexOf("=");
|
|
108
|
+
const k = eq === -1 ? part : part.slice(0, eq);
|
|
109
|
+
const v = eq === -1 ? "" : part.slice(eq + 1);
|
|
110
|
+
pairs.push([safeDecode(k), safeDecode(v)]);
|
|
111
|
+
}
|
|
112
|
+
return pairs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read the broker's return params off a location (a full URL, a deep link
|
|
117
|
+
* like `guuey://oauth/done?connected=linear`, or a bare `?connected=…`
|
|
118
|
+
* query). `null` when neither param is present. `connected` wins when both
|
|
119
|
+
* appear (the broker never sends both).
|
|
120
|
+
*/
|
|
121
|
+
export function parseOAuthReturn(urlOrQuery: string): OAuthReturn | null {
|
|
122
|
+
let connected: string | undefined;
|
|
123
|
+
let error: string | undefined;
|
|
124
|
+
for (const [k, v] of queryPairs(urlOrQuery)) {
|
|
125
|
+
if (k === OAUTH_RETURN_PARAMS.connected && v !== "") connected ??= v;
|
|
126
|
+
else if (k === OAUTH_RETURN_PARAMS.error && v !== "") error ??= v;
|
|
127
|
+
}
|
|
128
|
+
if (connected !== undefined) return { kind: "connected", serverName: connected };
|
|
129
|
+
if (error !== undefined) return { kind: "error", reason: error };
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The same location with the broker's return params removed (every other
|
|
135
|
+
* param, and the hash, preserved). Idempotent; a URL without them is
|
|
136
|
+
* returned unchanged. This is what a surface writes back into its address
|
|
137
|
+
* bar (`history.replaceState`) so a reload never re-shows the notice — and
|
|
138
|
+
* what it passes as `returnTo` for the next dance.
|
|
139
|
+
*/
|
|
140
|
+
export function stripOAuthReturn(url: string): string {
|
|
141
|
+
const q = url.indexOf("?");
|
|
142
|
+
if (q === -1) return url;
|
|
143
|
+
const hashAt = url.indexOf("#", q);
|
|
144
|
+
const base = url.slice(0, q);
|
|
145
|
+
const hash = hashAt === -1 ? "" : url.slice(hashAt);
|
|
146
|
+
const query = hashAt === -1 ? url.slice(q + 1) : url.slice(q + 1, hashAt);
|
|
147
|
+
const kept = query
|
|
148
|
+
.split("&")
|
|
149
|
+
.filter((part) => {
|
|
150
|
+
if (part === "") return false;
|
|
151
|
+
const eq = part.indexOf("=");
|
|
152
|
+
const key = safeDecode(eq === -1 ? part : part.slice(0, eq));
|
|
153
|
+
return key !== OAUTH_RETURN_PARAMS.connected && key !== OAUTH_RETURN_PARAMS.error;
|
|
154
|
+
})
|
|
155
|
+
.join("&");
|
|
156
|
+
return kept === "" ? `${base}${hash}` : `${base}?${kept}${hash}`;
|
|
157
|
+
}
|
package/src/plan.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from "@guuey/mcp-apps-host";
|
|
42
42
|
import type { TranscriptPolicy } from "./policy.js";
|
|
43
43
|
import { grantModeDisplay } from "./hitl.js";
|
|
44
|
+
import { oauthAuthorizeAsk } from "./oauth.js";
|
|
44
45
|
import type {
|
|
45
46
|
CitationsItem,
|
|
46
47
|
DataResultItem,
|
|
@@ -838,7 +839,8 @@ export function planTranscript(
|
|
|
838
839
|
}
|
|
839
840
|
|
|
840
841
|
// R10 — prompts, in input order (narrow on `kind`: the guuey-wire
|
|
841
|
-
//
|
|
842
|
+
// link-invite arm vs the AgJSON hitl arm — spec draft.2; consent rides
|
|
843
|
+
// hitl since guuey#207).
|
|
842
844
|
for (const prompt of inputs.prompts) {
|
|
843
845
|
const key = `p.${prompt.id}`;
|
|
844
846
|
if (prompt.kind === "hitl") {
|
|
@@ -858,6 +860,7 @@ export function planTranscript(
|
|
|
858
860
|
message: prompt.ask.message ?? null,
|
|
859
861
|
askKind: prompt.ask.kind,
|
|
860
862
|
grantModes: prompt.ask.grantModes ?? [],
|
|
863
|
+
oauth: oauthAuthorizeAsk(prompt.ask),
|
|
861
864
|
state: prompt.state,
|
|
862
865
|
chosenModeId: prompt.grantModeId ?? null,
|
|
863
866
|
chosenModeLabel: chosen !== undefined ? grantModeDisplay(chosen) : null,
|
package/src/react/components.tsx
CHANGED
|
@@ -520,11 +520,15 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
520
520
|
// the durable deny. `cancelled` is guuey's re-askable dismissal: it
|
|
521
521
|
// collapses to a record but stays answerable when expanded.
|
|
522
522
|
if (item.state === "resolved" || item.state === "declined") {
|
|
523
|
+
// The OAuth arm's resolved record: the pick SENT the user to the
|
|
524
|
+
// provider (no answer door) — the grant lands on the next turn.
|
|
523
525
|
return (
|
|
524
526
|
<p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
|
|
525
527
|
{item.state === "resolved"
|
|
526
528
|
? item.chosenModeLabel !== null
|
|
527
|
-
?
|
|
529
|
+
? item.oauth !== null
|
|
530
|
+
? s.promptOAuthSent(item.chosenModeLabel)
|
|
531
|
+
: s.promptAnsweredWith(item.chosenModeLabel)
|
|
528
532
|
: s.promptAccept
|
|
529
533
|
: s.promptDeclinedRecord}
|
|
530
534
|
</p>
|
|
@@ -570,9 +574,17 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
570
574
|
</button>
|
|
571
575
|
))
|
|
572
576
|
)}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
577
|
+
{/* The OAuth arm has no durable deny — "Not now" dismisses
|
|
578
|
+
(nothing written; the ask re-emits next turn). */}
|
|
579
|
+
{item.oauth !== null ? (
|
|
580
|
+
<button type="button" onClick={() => ctx.onPromptAction?.(item, "dismiss")}>
|
|
581
|
+
{s.promptNotNow}
|
|
582
|
+
</button>
|
|
583
|
+
) : (
|
|
584
|
+
<button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
|
|
585
|
+
{s.promptDecline}
|
|
586
|
+
</button>
|
|
587
|
+
)}
|
|
576
588
|
</div>
|
|
577
589
|
</>
|
|
578
590
|
)}
|
|
@@ -588,11 +600,7 @@ export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
|
588
600
|
}
|
|
589
601
|
return (
|
|
590
602
|
<div className="guuey-chat-prompt" role="group">
|
|
591
|
-
<p className="guuey-chat-prompt-ask">
|
|
592
|
-
{item.promptKind === "consent"
|
|
593
|
-
? `${item.appId} requests ${item.requested} access`
|
|
594
|
-
: `Link your account to ${item.appId}`}
|
|
595
|
-
</p>
|
|
603
|
+
<p className="guuey-chat-prompt-ask">{`Link your account to ${item.appId}`}</p>
|
|
596
604
|
<div className="guuey-chat-prompt-actions">
|
|
597
605
|
<button
|
|
598
606
|
ref={firstAction}
|
package/src/react/guuey-chat.tsx
CHANGED
|
@@ -71,6 +71,7 @@ import type { ThemeMode } from "./theme-css.js";
|
|
|
71
71
|
import { Transcript, type TranscriptWindowing } from "./transcript.js";
|
|
72
72
|
import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
|
|
73
73
|
import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
|
|
74
|
+
import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* The imperative seam (guuey#210): programmatic send/prefill/focus for
|
|
@@ -177,6 +178,20 @@ export interface GuueyChatProps {
|
|
|
177
178
|
* transport). Fired after the transcript record moves.
|
|
178
179
|
*/
|
|
179
180
|
onHitlAnswer?: (answer: AgHitlAnswer, ask: AgPausedAsk) => void;
|
|
181
|
+
/**
|
|
182
|
+
* The OAuth "authorize this server" arm (guuey#178): where the broker
|
|
183
|
+
* sends the user back after the provider dance. Defaults to this page's
|
|
184
|
+
* own location (stale return params stripped). Pass a value when the
|
|
185
|
+
* chat lives at a URL that is not where the user should land.
|
|
186
|
+
*/
|
|
187
|
+
oauthReturnTo?: string;
|
|
188
|
+
/**
|
|
189
|
+
* How the authorize link is opened. Defaults to `openOAuthAuthorize`
|
|
190
|
+
* (in-place navigation; a new tab when framed). The composite ALSO shows
|
|
191
|
+
* the return notice itself (`useOAuthReturn`) — a host that overrides
|
|
192
|
+
* `open` and lands elsewhere renders its own.
|
|
193
|
+
*/
|
|
194
|
+
onOAuthAuthorize?: (href: string, ask: AgPausedAsk) => void;
|
|
180
195
|
/** R11 action slot (sign-in / retry affordances). */
|
|
181
196
|
onErrorAction?: (item: ErrorItem) => void;
|
|
182
197
|
/**
|
|
@@ -226,6 +241,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
226
241
|
viewProps,
|
|
227
242
|
onPromptAction,
|
|
228
243
|
onHitlAnswer,
|
|
244
|
+
oauthReturnTo,
|
|
245
|
+
onOAuthAuthorize,
|
|
229
246
|
onErrorAction,
|
|
230
247
|
onReady,
|
|
231
248
|
onThread,
|
|
@@ -388,9 +405,27 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
388
405
|
[invoke],
|
|
389
406
|
);
|
|
390
407
|
|
|
408
|
+
// guuey#178: the broker's return notice (`?connected=` / `?error=`), read
|
|
409
|
+
// + stripped off the address bar once on mount.
|
|
410
|
+
const oauthReturn = useOAuthReturn();
|
|
411
|
+
|
|
391
412
|
const handlePromptAction = useCallback(
|
|
392
413
|
(item: PromptItem, action: "accept" | "decline" | "dismiss" | { grantModeId: string }) => {
|
|
393
414
|
if (item.promptKind === "hitl") {
|
|
415
|
+
// The OAuth arm: the answer is the redirect (no pod door, no
|
|
416
|
+
// `onHitlAnswer`); the ledger records the pick and the link opens.
|
|
417
|
+
if (
|
|
418
|
+
oauthPromptAction({
|
|
419
|
+
item,
|
|
420
|
+
action,
|
|
421
|
+
answerHitlPrompt,
|
|
422
|
+
...(oauthReturnTo !== undefined ? { returnTo: oauthReturnTo } : {}),
|
|
423
|
+
...(onOAuthAuthorize !== undefined ? { open: onOAuthAuthorize } : {}),
|
|
424
|
+
})
|
|
425
|
+
) {
|
|
426
|
+
onPromptAction?.(item, action);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
394
429
|
const answer = answerHitlPrompt(
|
|
395
430
|
item.ask,
|
|
396
431
|
typeof action === "object" ? action : action === "accept" ? "accept" : action,
|
|
@@ -404,7 +439,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
404
439
|
if (typeof action !== "object") resolvePrompt(item.promptId, PROMPT_STATE[action]);
|
|
405
440
|
onPromptAction?.(item, action);
|
|
406
441
|
},
|
|
407
|
-
[resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction],
|
|
442
|
+
[resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction, oauthReturnTo, onOAuthAuthorize],
|
|
408
443
|
);
|
|
409
444
|
|
|
410
445
|
const strings = policy.strings;
|
|
@@ -430,6 +465,19 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
|
|
|
430
465
|
onViewDiagnosis={onViewDiagnosis}
|
|
431
466
|
{...(viewProps !== undefined ? { viewProps } : {})}
|
|
432
467
|
/>
|
|
468
|
+
{oauthReturn.notice !== null && (
|
|
469
|
+
<p
|
|
470
|
+
role="status"
|
|
471
|
+
className={`guuey-chat-oauth-notice guuey-chat-oauth-${oauthReturn.notice.kind}`}
|
|
472
|
+
>
|
|
473
|
+
{oauthReturn.notice.kind === "connected"
|
|
474
|
+
? strings.oauthConnected(oauthReturn.notice.serverName)
|
|
475
|
+
: strings.oauthFailed(oauthReturn.notice.reason)}
|
|
476
|
+
<button type="button" className="guuey-chat-oauth-dismiss" onClick={oauthReturn.dismiss}>
|
|
477
|
+
{strings.promptDismissed}
|
|
478
|
+
</button>
|
|
479
|
+
</p>
|
|
480
|
+
)}
|
|
433
481
|
<form
|
|
434
482
|
className="guuey-chat-composer"
|
|
435
483
|
onSubmit={(e) => {
|