assistant-ui 0.0.119 → 0.0.121
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/README.md +60 -1
- package/dist/commands/cloud.d.ts +25 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +194 -0
- package/dist/commands/cloud.js.map +1 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +8 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/info.js +1 -1
- package/dist/lib/agent-skill.d.ts +2 -2
- package/dist/lib/agent-skill.js +1 -1
- package/dist/lib/agent-skill.js.map +1 -1
- package/dist/lib/cloud-abort.d.ts +2 -0
- package/dist/lib/cloud-abort.d.ts.map +1 -0
- package/dist/lib/cloud-abort.js +13 -0
- package/dist/lib/cloud-abort.js.map +1 -0
- package/dist/lib/cloud-auth.d.ts +28 -0
- package/dist/lib/cloud-auth.d.ts.map +1 -0
- package/dist/lib/cloud-auth.js +169 -0
- package/dist/lib/cloud-auth.js.map +1 -0
- package/dist/lib/cloud-config.d.ts +21 -0
- package/dist/lib/cloud-config.d.ts.map +1 -0
- package/dist/lib/cloud-config.js +76 -0
- package/dist/lib/cloud-config.js.map +1 -0
- package/dist/lib/cloud-provision.d.ts +20 -0
- package/dist/lib/cloud-provision.d.ts.map +1 -0
- package/dist/lib/cloud-provision.js +55 -0
- package/dist/lib/cloud-provision.js.map +1 -0
- package/dist/lib/cloud-setup-login.d.ts +9 -0
- package/dist/lib/cloud-setup-login.d.ts.map +1 -0
- package/dist/lib/cloud-setup-login.js +137 -0
- package/dist/lib/cloud-setup-login.js.map +1 -0
- package/dist/lib/cloud-url.d.ts +2 -0
- package/dist/lib/cloud-url.d.ts.map +1 -0
- package/dist/lib/cloud-url.js +14 -0
- package/dist/lib/cloud-url.js.map +1 -0
- package/dist/lib/transform.js +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +2 -1
- package/dist/program.js.map +1 -1
- package/package.json +17 -13
- package/src/commands/cloud.test.ts +238 -0
- package/src/commands/cloud.ts +455 -0
- package/src/commands/create.ts +8 -0
- package/src/lib/agent-skill.ts +1 -1
- package/src/lib/cloud-abort.ts +12 -0
- package/src/lib/cloud-auth.test.ts +500 -0
- package/src/lib/cloud-auth.ts +273 -0
- package/src/lib/cloud-config.test.ts +82 -0
- package/src/lib/cloud-config.ts +138 -0
- package/src/lib/cloud-provision.test.ts +132 -0
- package/src/lib/cloud-provision.ts +93 -0
- package/src/lib/cloud-setup-login.test.ts +361 -0
- package/src/lib/cloud-setup-login.ts +240 -0
- package/src/lib/cloud-url.ts +19 -0
- package/src/program.ts +2 -0
- package/templates/cloud-harness/.assistant-ui/template.json +3 -0
- package/templates/cloud-harness/.env.example +1 -0
- package/templates/cloud-harness/.gitignore +42 -0
- package/templates/cloud-harness/README.md +28 -0
- package/templates/cloud-harness/app/api/chat/route.ts +13 -0
- package/templates/cloud-harness/app/api/credential/route.ts +14 -0
- package/templates/cloud-harness/app/assistant.tsx +83 -0
- package/templates/cloud-harness/app/favicon.ico +0 -0
- package/templates/cloud-harness/app/globals.css +139 -0
- package/templates/cloud-harness/app/layout.tsx +37 -0
- package/templates/cloud-harness/app/page.tsx +5 -0
- package/templates/cloud-harness/components/assistant-ui/elements/attachment.aui.tsx +279 -0
- package/templates/cloud-harness/components/assistant-ui/elements/markdown-text.tsx +238 -0
- package/templates/cloud-harness/components/assistant-ui/elements/thread.aui.tsx +521 -0
- package/templates/cloud-harness/components/assistant-ui/elements/tool-fallback.aui.tsx +1153 -0
- package/templates/cloud-harness/components/assistant-ui/elements/tooltip-icon-button.tsx +49 -0
- package/templates/cloud-harness/components/ui/avatar.tsx +109 -0
- package/templates/cloud-harness/components/ui/button.tsx +58 -0
- package/templates/cloud-harness/components/ui/collapsible.tsx +21 -0
- package/templates/cloud-harness/components/ui/dialog.tsx +159 -0
- package/templates/cloud-harness/components/ui/textarea.tsx +18 -0
- package/templates/cloud-harness/components/ui/tooltip.tsx +69 -0
- package/templates/cloud-harness/components.json +24 -0
- package/templates/cloud-harness/hooks/use-attachment-src.ts +46 -0
- package/templates/cloud-harness/hooks/use-copy-to-clipboard.ts +54 -0
- package/templates/cloud-harness/lib/utils.ts +1 -0
- package/templates/cloud-harness/next.config.ts +6 -0
- package/templates/cloud-harness/package.json +51 -0
- package/templates/cloud-harness/postcss.config.mjs +5 -0
- package/templates/cloud-harness/tsconfig.json +33 -0
- package/templates/cloud-harness/vendor/README.md +13 -0
- package/templates/cloud-harness/vendor/assistant-ui-react-harness-sdk-0.0.2.tgz +0 -0
- package/templates/cloud-harness/vendor/harness-sdk-0.3.1.tgz +0 -0
- package/templates/cloud-harness/vendor/provenance.json +38 -0
- package/templates/cloud-harness/vendor/statewire-0.19.3.tgz +0 -0
- /package/{LICENSE → templates/cloud-harness/vendor/LICENSE} +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readOptionalFile, writeCloudFile } from "./cloud-config";
|
|
4
|
+
import { validateCloudUrl } from "./cloud-auth";
|
|
5
|
+
|
|
6
|
+
export type CloudProvisionRequest = {
|
|
7
|
+
org_id: string;
|
|
8
|
+
project_id: string;
|
|
9
|
+
harness_name: string;
|
|
10
|
+
backend_urls: string[];
|
|
11
|
+
allow_localhost: boolean;
|
|
12
|
+
idempotency_key: string;
|
|
13
|
+
access_code?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type CloudProvisionResult = {
|
|
17
|
+
project_id: string;
|
|
18
|
+
workspace_id: string;
|
|
19
|
+
harness_id: string;
|
|
20
|
+
harness_origin: string;
|
|
21
|
+
api_key: string;
|
|
22
|
+
api_origin: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const resolveProvisionRequest = async (
|
|
26
|
+
directory: string,
|
|
27
|
+
input: Omit<CloudProvisionRequest, "idempotency_key" | "access_code">,
|
|
28
|
+
accessCode?: string,
|
|
29
|
+
): Promise<CloudProvisionRequest> => {
|
|
30
|
+
const file = path.join(directory, ".assistant-ui", "provision.json");
|
|
31
|
+
const previous = await readOptionalFile(file);
|
|
32
|
+
let request: CloudProvisionRequest;
|
|
33
|
+
if (previous) {
|
|
34
|
+
request = JSON.parse(previous) as CloudProvisionRequest;
|
|
35
|
+
const { idempotency_key: _key, ...savedInput } = request;
|
|
36
|
+
if (JSON.stringify(savedInput) !== JSON.stringify(input)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"This directory has a pending setup for another project or backend. Resume that setup or use a new directory.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
request = { ...input, idempotency_key: randomUUID() };
|
|
43
|
+
await writeCloudFile(file, `${JSON.stringify(request, null, 2)}\n`, {
|
|
44
|
+
exclusive: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return { ...request, ...(accessCode ? { access_code: accessCode } : {}) };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const provisionCloudHarness = async (
|
|
51
|
+
managementUrl: string,
|
|
52
|
+
accessToken: string,
|
|
53
|
+
request: CloudProvisionRequest,
|
|
54
|
+
): Promise<CloudProvisionResult> => {
|
|
55
|
+
const response = await fetch(
|
|
56
|
+
`${validateCloudUrl(managementUrl)}/api/cli/provision`,
|
|
57
|
+
{
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: `Bearer ${accessToken}`,
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(request),
|
|
64
|
+
signal: AbortSignal.timeout(30_000),
|
|
65
|
+
redirect: "error",
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const body = (await response.json().catch(() => null)) as {
|
|
70
|
+
error?: string;
|
|
71
|
+
message?: string;
|
|
72
|
+
} | null;
|
|
73
|
+
const detail = body?.message ?? body?.error ?? response.statusText;
|
|
74
|
+
throw new Error(`Cloud setup failed (${response.status}): ${detail}`);
|
|
75
|
+
}
|
|
76
|
+
const result = (await response.json()) as CloudProvisionResult;
|
|
77
|
+
if (
|
|
78
|
+
!result ||
|
|
79
|
+
[
|
|
80
|
+
result.api_key,
|
|
81
|
+
result.harness_id,
|
|
82
|
+
result.workspace_id,
|
|
83
|
+
result.harness_origin,
|
|
84
|
+
result.api_origin,
|
|
85
|
+
].some((value) => typeof value !== "string" || value.length === 0) ||
|
|
86
|
+
result.project_id !== request.project_id
|
|
87
|
+
) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Cloud returned an incomplete setup response. Rerun the same command to resume safely.",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { connectCloudLoginSetup, setupLoginUrl } from "./cloud-setup-login";
|
|
3
|
+
|
|
4
|
+
const fixture = vi.hoisted(() => ({
|
|
5
|
+
state: {
|
|
6
|
+
version: 2,
|
|
7
|
+
id: "session-a",
|
|
8
|
+
status: "planning",
|
|
9
|
+
inputs: [] as {
|
|
10
|
+
id: string;
|
|
11
|
+
status: string;
|
|
12
|
+
preset?: string;
|
|
13
|
+
help?: { href?: string };
|
|
14
|
+
}[],
|
|
15
|
+
} as
|
|
16
|
+
| {
|
|
17
|
+
version: number;
|
|
18
|
+
id: string | null;
|
|
19
|
+
status: string;
|
|
20
|
+
inputs: {
|
|
21
|
+
id: string;
|
|
22
|
+
status: string;
|
|
23
|
+
preset?: string;
|
|
24
|
+
help?: { href?: string };
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
27
|
+
| undefined,
|
|
28
|
+
status: "connected",
|
|
29
|
+
listeners: new Set<() => void>(),
|
|
30
|
+
ask: vi.fn(),
|
|
31
|
+
answer: vi.fn(),
|
|
32
|
+
dispose: vi.fn(),
|
|
33
|
+
transport: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("statewire", async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<typeof import("statewire")>()),
|
|
37
|
+
StatewireHttp: (...args: unknown[]) => fixture.transport(...args),
|
|
38
|
+
StatewireClient: class {
|
|
39
|
+
get state() {
|
|
40
|
+
return fixture.state;
|
|
41
|
+
}
|
|
42
|
+
get connection() {
|
|
43
|
+
return { status: fixture.status };
|
|
44
|
+
}
|
|
45
|
+
commands = { "agent/ask": fixture.ask, "checkout/answer": fixture.answer };
|
|
46
|
+
subscribe = (listener: () => void) => {
|
|
47
|
+
fixture.listeners.add(listener);
|
|
48
|
+
return () => fixture.listeners.delete(listener);
|
|
49
|
+
};
|
|
50
|
+
dispose = fixture.dispose;
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const issuer = "https://accounts.assistant-ui.com";
|
|
55
|
+
const authorization = () => ({
|
|
56
|
+
deviceCode: "private-device-code",
|
|
57
|
+
userCode: "ABCD-EFGH",
|
|
58
|
+
verificationUri: `${issuer}/device`,
|
|
59
|
+
verificationUriComplete: `${issuer}/device?user_code=ABCD-EFGH&device_code=do-not-forward`,
|
|
60
|
+
expiresAt: Date.now() + 60_000,
|
|
61
|
+
intervalMs: 5_000,
|
|
62
|
+
resource: `${issuer}/api/auth`,
|
|
63
|
+
});
|
|
64
|
+
const update = () => {
|
|
65
|
+
for (const listener of fixture.listeners) listener();
|
|
66
|
+
};
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
fixture.state = {
|
|
69
|
+
version: 2,
|
|
70
|
+
id: "session-a",
|
|
71
|
+
status: "planning",
|
|
72
|
+
inputs: [],
|
|
73
|
+
};
|
|
74
|
+
fixture.status = "connected";
|
|
75
|
+
fixture.listeners.clear();
|
|
76
|
+
vi.useRealTimers();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("cloud setup device login", () => {
|
|
80
|
+
it("shares only the public code, trusted approval URL, expiration and attempt", () => {
|
|
81
|
+
const url = new URL(setupLoginUrl(authorization(), issuer));
|
|
82
|
+
expect(url.origin).toBe(issuer);
|
|
83
|
+
expect([...url.searchParams.keys()]).toEqual([
|
|
84
|
+
"user_code",
|
|
85
|
+
"setup_attempt",
|
|
86
|
+
"setup_expires",
|
|
87
|
+
]);
|
|
88
|
+
expect(url.searchParams.get("user_code")).toBe("ABCD-EFGH");
|
|
89
|
+
expect(url.searchParams.get("setup_attempt")).toMatch(/^[0-9a-f-]{36}$/);
|
|
90
|
+
expect(url.href).not.toContain("private-device-code");
|
|
91
|
+
expect(url.href).not.toContain("device_code");
|
|
92
|
+
});
|
|
93
|
+
it.each([
|
|
94
|
+
{
|
|
95
|
+
verificationUriComplete:
|
|
96
|
+
"https://accounts.assistant-ui.com.evil.test/device?user_code=ABCD-EFGH",
|
|
97
|
+
},
|
|
98
|
+
{ verificationUriComplete: `${issuer}/device?user_code=WRONG` },
|
|
99
|
+
{
|
|
100
|
+
verificationUriComplete: `${issuer}/device?user_code=ABCD-EFGH&user_code=SECOND`,
|
|
101
|
+
},
|
|
102
|
+
{ verificationUriComplete: `${issuer}/device?user_code=ABCD-EFGH#token` },
|
|
103
|
+
{ expiresAt: 1 },
|
|
104
|
+
{ expiresAt: Date.now() + 60 * 60_000 },
|
|
105
|
+
])("rejects unexpected approval metadata %j", (patch) => {
|
|
106
|
+
expect(() =>
|
|
107
|
+
setupLoginUrl({ ...authorization(), ...patch }, issuer),
|
|
108
|
+
).toThrow("invalid device approval link");
|
|
109
|
+
});
|
|
110
|
+
it("rejects arbitrary issuers and malformed attempt identifiers", () => {
|
|
111
|
+
expect(() =>
|
|
112
|
+
setupLoginUrl(authorization(), "https://accounts.other.test"),
|
|
113
|
+
).toThrow();
|
|
114
|
+
expect(() =>
|
|
115
|
+
setupLoginUrl(authorization(), issuer, "../redirect"),
|
|
116
|
+
).toThrow();
|
|
117
|
+
});
|
|
118
|
+
it("publishes a reserved input and sends only terminal status after CLI approval", async () => {
|
|
119
|
+
fixture.ask.mockResolvedValue({ inputId: "q1" });
|
|
120
|
+
fixture.answer.mockResolvedValue(undefined);
|
|
121
|
+
const bridge = await connectCloudLoginSetup(
|
|
122
|
+
"https://checkout.test/session-a",
|
|
123
|
+
);
|
|
124
|
+
await bridge.publish(authorization(), issuer);
|
|
125
|
+
expect(fixture.ask).toHaveBeenLastCalledWith(
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
kind: "text",
|
|
128
|
+
preset: "assistant-ui-cli-login",
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
const call = fixture.ask.mock.lastCall![0];
|
|
132
|
+
expect(JSON.stringify(call)).not.toContain("private-device-code");
|
|
133
|
+
await bridge.complete("signed-in");
|
|
134
|
+
expect(fixture.answer).toHaveBeenLastCalledWith({
|
|
135
|
+
inputId: "q1",
|
|
136
|
+
answer: "signed-in",
|
|
137
|
+
});
|
|
138
|
+
await bridge.dispose();
|
|
139
|
+
expect(fixture.listeners.size).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
it("ignores a forged answer, but cancels a dismissed input", async () => {
|
|
142
|
+
fixture.ask.mockResolvedValue({ inputId: "q2" });
|
|
143
|
+
const bridge = await connectCloudLoginSetup(
|
|
144
|
+
"http://127.0.0.1:8791/session-a",
|
|
145
|
+
);
|
|
146
|
+
await bridge.publish(authorization(), issuer);
|
|
147
|
+
fixture.state!.inputs = [{ id: "q2", status: "answered" }];
|
|
148
|
+
update();
|
|
149
|
+
expect(bridge.signal.aborted).toBe(false);
|
|
150
|
+
fixture.state!.inputs[0]!.status = "dismissed";
|
|
151
|
+
update();
|
|
152
|
+
expect(bridge.signal.aborted).toBe(true);
|
|
153
|
+
const count = fixture.ask.mock.calls.length;
|
|
154
|
+
await expect(bridge.publish(authorization(), issuer)).rejects.toThrow(
|
|
155
|
+
"cancelled",
|
|
156
|
+
);
|
|
157
|
+
expect(fixture.ask.mock.calls.length).toBe(count);
|
|
158
|
+
await bridge.dispose();
|
|
159
|
+
});
|
|
160
|
+
it.each(["cancelled", "done"])(
|
|
161
|
+
"cancels when the checkout becomes %s",
|
|
162
|
+
async (status) => {
|
|
163
|
+
const bridge = await connectCloudLoginSetup(
|
|
164
|
+
"https://checkout.test/session-a",
|
|
165
|
+
);
|
|
166
|
+
fixture.state!.status = status;
|
|
167
|
+
update();
|
|
168
|
+
expect(bridge.signal.aborted).toBe(true);
|
|
169
|
+
await bridge.dispose();
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
it("cancels when the original session is replaced", async () => {
|
|
173
|
+
const bridge = await connectCloudLoginSetup(
|
|
174
|
+
"https://checkout.test/session-a",
|
|
175
|
+
);
|
|
176
|
+
fixture.state!.id = "session-b";
|
|
177
|
+
update();
|
|
178
|
+
expect(bridge.signal.aborted).toBe(true);
|
|
179
|
+
await bridge.dispose();
|
|
180
|
+
});
|
|
181
|
+
it.each([null, 1, "input", {}, { id: "q1" }, { status: "pending" }])(
|
|
182
|
+
"rejects malformed initial input entries: %j",
|
|
183
|
+
async (input) => {
|
|
184
|
+
fixture.state!.inputs = [input] as unknown as NonNullable<
|
|
185
|
+
typeof fixture.state
|
|
186
|
+
>["inputs"];
|
|
187
|
+
await expect(
|
|
188
|
+
connectCloudLoginSetup("https://checkout.test/session-a"),
|
|
189
|
+
).rejects.toThrow("Start the setup");
|
|
190
|
+
expect(fixture.ask).not.toHaveBeenCalled();
|
|
191
|
+
expect(fixture.listeners.size).toBe(0);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
it.each([null, 1, "input", {}, { id: "q1" }, { status: "pending" }])(
|
|
195
|
+
"cancels without throwing or answering malformed updated entries: %j",
|
|
196
|
+
async (input) => {
|
|
197
|
+
fixture.ask.mockResolvedValueOnce({ inputId: "q1" });
|
|
198
|
+
const bridge = await connectCloudLoginSetup(
|
|
199
|
+
"https://checkout.test/session-a",
|
|
200
|
+
);
|
|
201
|
+
await bridge.publish(authorization(), issuer);
|
|
202
|
+
fixture.state!.inputs = [input] as unknown as NonNullable<
|
|
203
|
+
typeof fixture.state
|
|
204
|
+
>["inputs"];
|
|
205
|
+
expect(update).not.toThrow();
|
|
206
|
+
expect(bridge.signal.aborted).toBe(true);
|
|
207
|
+
await bridge.complete("cancelled");
|
|
208
|
+
expect(fixture.answer).not.toHaveBeenCalled();
|
|
209
|
+
await bridge.dispose();
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
it("disposes a connection that never supplies a snapshot before its deadline", async () => {
|
|
213
|
+
fixture.state = undefined;
|
|
214
|
+
fixture.status = "connecting";
|
|
215
|
+
const deadline = new AbortController();
|
|
216
|
+
const timeout = vi
|
|
217
|
+
.spyOn(AbortSignal, "timeout")
|
|
218
|
+
.mockReturnValueOnce(deadline.signal);
|
|
219
|
+
const count = fixture.dispose.mock.calls.length;
|
|
220
|
+
try {
|
|
221
|
+
const connection = connectCloudLoginSetup(
|
|
222
|
+
"https://checkout.test/session-a",
|
|
223
|
+
);
|
|
224
|
+
deadline.abort(new Error("snapshot timeout"));
|
|
225
|
+
await expect(connection).rejects.toThrow("snapshot timeout");
|
|
226
|
+
expect(fixture.dispose.mock.calls.length).toBe(count + 1);
|
|
227
|
+
expect(fixture.listeners.size).toBe(0);
|
|
228
|
+
} finally {
|
|
229
|
+
timeout.mockRestore();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
it("observes dismissal even when it arrives before the ask response", async () => {
|
|
233
|
+
let finish!: (result: { inputId: string }) => void;
|
|
234
|
+
fixture.ask.mockImplementationOnce(
|
|
235
|
+
() =>
|
|
236
|
+
new Promise((resolve) => {
|
|
237
|
+
finish = resolve;
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
const bridge = await connectCloudLoginSetup(
|
|
241
|
+
"https://checkout.test/session-a",
|
|
242
|
+
);
|
|
243
|
+
const publishing = bridge.publish(authorization(), issuer);
|
|
244
|
+
fixture.state!.inputs = [{ id: "q3", status: "dismissed" }];
|
|
245
|
+
update();
|
|
246
|
+
finish({ inputId: "q3" });
|
|
247
|
+
await expect(publishing).rejects.toThrow("cancelled");
|
|
248
|
+
expect(bridge.signal.aborted).toBe(true);
|
|
249
|
+
await bridge.dispose();
|
|
250
|
+
});
|
|
251
|
+
it("reconciles an accepted prompt from its snapshot when the ask acknowledgment times out", async () => {
|
|
252
|
+
let finish!: (result: { inputId: string }) => void;
|
|
253
|
+
fixture.ask.mockImplementationOnce(
|
|
254
|
+
() =>
|
|
255
|
+
new Promise((resolve) => {
|
|
256
|
+
finish = resolve;
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
fixture.answer.mockResolvedValue(undefined);
|
|
260
|
+
const bridge = await connectCloudLoginSetup(
|
|
261
|
+
"https://checkout.test/session-a",
|
|
262
|
+
);
|
|
263
|
+
const deadline = new AbortController();
|
|
264
|
+
const timeout = vi
|
|
265
|
+
.spyOn(AbortSignal, "timeout")
|
|
266
|
+
.mockReturnValueOnce(deadline.signal);
|
|
267
|
+
const publishing = bridge.publish(authorization(), issuer);
|
|
268
|
+
const href = fixture.ask.mock.lastCall![0].help.href;
|
|
269
|
+
fixture.state!.inputs = [
|
|
270
|
+
{
|
|
271
|
+
id: "late",
|
|
272
|
+
status: "pending",
|
|
273
|
+
preset: "assistant-ui-cli-login",
|
|
274
|
+
help: { href },
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
update();
|
|
278
|
+
deadline.abort(new Error("ask timeout"));
|
|
279
|
+
await expect(publishing).rejects.toThrow("ask timeout");
|
|
280
|
+
expect(fixture.answer).toHaveBeenLastCalledWith({
|
|
281
|
+
inputId: "late",
|
|
282
|
+
answer: "failed",
|
|
283
|
+
});
|
|
284
|
+
const disposing = bridge.dispose();
|
|
285
|
+
finish({ inputId: "late" });
|
|
286
|
+
await disposing;
|
|
287
|
+
expect(fixture.ask).toHaveBeenCalledOnce();
|
|
288
|
+
expect(fixture.answer).toHaveBeenCalledOnce();
|
|
289
|
+
expect(fixture.listeners.size).toBe(0);
|
|
290
|
+
timeout.mockRestore();
|
|
291
|
+
});
|
|
292
|
+
it("reconciles a late accepted prompt after cancellation without replaying the ask", async () => {
|
|
293
|
+
let finish!: (result: { inputId: string }) => void;
|
|
294
|
+
fixture.ask.mockImplementationOnce(
|
|
295
|
+
() =>
|
|
296
|
+
new Promise((resolve) => {
|
|
297
|
+
finish = resolve;
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
fixture.answer.mockResolvedValue(undefined);
|
|
301
|
+
const bridge = await connectCloudLoginSetup(
|
|
302
|
+
"https://checkout.test/session-a",
|
|
303
|
+
);
|
|
304
|
+
const deadline = new AbortController();
|
|
305
|
+
const timeout = vi
|
|
306
|
+
.spyOn(AbortSignal, "timeout")
|
|
307
|
+
.mockReturnValueOnce(deadline.signal);
|
|
308
|
+
const publishing = bridge.publish(authorization(), issuer);
|
|
309
|
+
deadline.abort(new Error("ask timeout"));
|
|
310
|
+
await expect(publishing).rejects.toThrow("ask timeout");
|
|
311
|
+
await bridge.complete("cancelled");
|
|
312
|
+
const disposing = bridge.dispose();
|
|
313
|
+
finish({ inputId: "late" });
|
|
314
|
+
await disposing;
|
|
315
|
+
expect(fixture.answer).toHaveBeenLastCalledWith({
|
|
316
|
+
inputId: "late",
|
|
317
|
+
answer: "cancelled",
|
|
318
|
+
});
|
|
319
|
+
expect(fixture.ask).toHaveBeenCalledOnce();
|
|
320
|
+
timeout.mockRestore();
|
|
321
|
+
});
|
|
322
|
+
it("never reconciles a late acknowledgment into a replacement session with reused input IDs", async () => {
|
|
323
|
+
let finish!: (result: { inputId: string }) => void;
|
|
324
|
+
fixture.ask.mockImplementationOnce(
|
|
325
|
+
() =>
|
|
326
|
+
new Promise((resolve) => {
|
|
327
|
+
finish = resolve;
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
const bridge = await connectCloudLoginSetup(
|
|
331
|
+
"https://checkout.test/session-a",
|
|
332
|
+
);
|
|
333
|
+
const publishing = bridge.publish(authorization(), issuer);
|
|
334
|
+
fixture.state!.id = "session-b";
|
|
335
|
+
fixture.state!.inputs = [{ id: "q1", status: "pending" }];
|
|
336
|
+
update();
|
|
337
|
+
await expect(publishing).rejects.toThrow("cancelled");
|
|
338
|
+
await bridge.complete("cancelled");
|
|
339
|
+
const disposing = bridge.dispose();
|
|
340
|
+
finish({ inputId: "q1" });
|
|
341
|
+
await disposing;
|
|
342
|
+
expect(fixture.answer).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
it("disposes a stopped connection before requesting a device grant", async () => {
|
|
345
|
+
fixture.state = undefined;
|
|
346
|
+
fixture.status = "stopped";
|
|
347
|
+
const count = fixture.dispose.mock.calls.length;
|
|
348
|
+
await expect(
|
|
349
|
+
connectCloudLoginSetup("https://checkout.test/session-a"),
|
|
350
|
+
).rejects.toThrow("stopped");
|
|
351
|
+
expect(fixture.dispose.mock.calls.length).toBe(count + 1);
|
|
352
|
+
expect(fixture.listeners.size).toBe(0);
|
|
353
|
+
});
|
|
354
|
+
it.each([
|
|
355
|
+
"https://user:pass@checkout.test/session",
|
|
356
|
+
"http://remote.test/session",
|
|
357
|
+
"https://checkout.test/session?token=x",
|
|
358
|
+
])("rejects unsafe setup targets %s", async (url) => {
|
|
359
|
+
await expect(connectCloudLoginSetup(url)).rejects.toThrow("HTTPS");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { DeviceAuthorization } from "aui-auth/device";
|
|
3
|
+
import { StatewireClient, StatewireHttp } from "statewire";
|
|
4
|
+
import { abortable } from "./cloud-abort";
|
|
5
|
+
import { validateCloudUrl } from "./cloud-url";
|
|
6
|
+
|
|
7
|
+
type SetupState = {
|
|
8
|
+
version: number;
|
|
9
|
+
id: string | null;
|
|
10
|
+
status: string;
|
|
11
|
+
inputs: {
|
|
12
|
+
id: string;
|
|
13
|
+
status: string;
|
|
14
|
+
preset?: string;
|
|
15
|
+
help?: { href?: string };
|
|
16
|
+
}[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type SetupCommands = {
|
|
20
|
+
"agent/ask": (input: {
|
|
21
|
+
kind: "text";
|
|
22
|
+
preset: string;
|
|
23
|
+
prompt: string;
|
|
24
|
+
help: { summary: string; href: string };
|
|
25
|
+
}) => { inputId: string };
|
|
26
|
+
"checkout/answer": (answer: { inputId: string; answer: string }) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const validInputs = (inputs: unknown): inputs is SetupState["inputs"] =>
|
|
30
|
+
Array.isArray(inputs) &&
|
|
31
|
+
inputs.every(
|
|
32
|
+
(input) =>
|
|
33
|
+
input !== null &&
|
|
34
|
+
typeof input === "object" &&
|
|
35
|
+
typeof input.id === "string" &&
|
|
36
|
+
typeof input.status === "string",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const setupLoginUrl = (
|
|
40
|
+
authorization: DeviceAuthorization,
|
|
41
|
+
issuer: string,
|
|
42
|
+
attempt: string = randomUUID(),
|
|
43
|
+
) => {
|
|
44
|
+
const url = new URL(authorization.verificationUriComplete);
|
|
45
|
+
const issuerOrigin = new URL(issuer).origin;
|
|
46
|
+
if (
|
|
47
|
+
!["https://accounts.assistant-ui.com", "https://accounts.aui.dev"].includes(
|
|
48
|
+
issuerOrigin,
|
|
49
|
+
) ||
|
|
50
|
+
url.origin !== issuerOrigin ||
|
|
51
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
52
|
+
attempt,
|
|
53
|
+
) ||
|
|
54
|
+
!/^[A-Z0-9-]{4,32}$/.test(authorization.userCode) ||
|
|
55
|
+
url.pathname !== "/device" ||
|
|
56
|
+
url.username ||
|
|
57
|
+
url.password ||
|
|
58
|
+
url.hash ||
|
|
59
|
+
url.searchParams.get("user_code") !== authorization.userCode ||
|
|
60
|
+
url.searchParams.getAll("user_code").length !== 1 ||
|
|
61
|
+
!Number.isFinite(authorization.expiresAt) ||
|
|
62
|
+
authorization.expiresAt <= Date.now() ||
|
|
63
|
+
authorization.expiresAt > Date.now() + 10 * 60 * 1000
|
|
64
|
+
) {
|
|
65
|
+
throw new Error("Accounts returned an invalid device approval link.");
|
|
66
|
+
}
|
|
67
|
+
const publicUrl = new URL("/device", url.origin);
|
|
68
|
+
publicUrl.searchParams.set("user_code", authorization.userCode);
|
|
69
|
+
publicUrl.searchParams.set("setup_attempt", attempt);
|
|
70
|
+
publicUrl.searchParams.set("setup_expires", String(authorization.expiresAt));
|
|
71
|
+
return publicUrl.href;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const connectCloudLoginSetup = async (url: string) => {
|
|
75
|
+
const target = validateCloudUrl(url);
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const client = new StatewireClient<SetupState | undefined, SetupCommands>({
|
|
78
|
+
transport: StatewireHttp({ url: target }),
|
|
79
|
+
onError: (error) => controller.abort(error),
|
|
80
|
+
});
|
|
81
|
+
let unsubscribe = () => {};
|
|
82
|
+
let inputId: string | undefined;
|
|
83
|
+
let sessionId: string;
|
|
84
|
+
let pendingHref: string | undefined;
|
|
85
|
+
let pendingAsk: Promise<{ inputId: string }> | undefined;
|
|
86
|
+
let terminalResult: "signed-in" | "cancelled" | "failed" | undefined;
|
|
87
|
+
let completion: Promise<void> | undefined;
|
|
88
|
+
let disposed = false;
|
|
89
|
+
const reconcile = () => {
|
|
90
|
+
const state = client.state;
|
|
91
|
+
if (state && !validInputs(state.inputs)) {
|
|
92
|
+
controller.abort(new Error("The setup returned invalid inputs."));
|
|
93
|
+
return Promise.resolve();
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
disposed ||
|
|
97
|
+
inputId === undefined ||
|
|
98
|
+
terminalResult === undefined ||
|
|
99
|
+
state?.id !== sessionId ||
|
|
100
|
+
!Array.isArray(state.inputs) ||
|
|
101
|
+
!["planning", "installing"].includes(state.status) ||
|
|
102
|
+
["dismissed", "answered"].includes(
|
|
103
|
+
state.inputs.find((input) => input.id === inputId)?.status ?? "",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
completion ??= abortable(
|
|
108
|
+
client.commands["checkout/answer"]({ inputId, answer: terminalResult }),
|
|
109
|
+
AbortSignal.timeout(10_000),
|
|
110
|
+
);
|
|
111
|
+
return completion;
|
|
112
|
+
};
|
|
113
|
+
const checkSession = () => {
|
|
114
|
+
const next = client.state;
|
|
115
|
+
if (next && !validInputs(next.inputs)) {
|
|
116
|
+
controller.abort(new Error("The setup returned invalid inputs."));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (next?.id === sessionId && Array.isArray(next.inputs) && pendingHref) {
|
|
120
|
+
inputId ??= next.inputs.find(
|
|
121
|
+
(input) =>
|
|
122
|
+
input.preset === "assistant-ui-cli-login" &&
|
|
123
|
+
input.help?.href === pendingHref,
|
|
124
|
+
)?.id;
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
next?.version !== 2 ||
|
|
128
|
+
next.id !== sessionId ||
|
|
129
|
+
!Array.isArray(next.inputs) ||
|
|
130
|
+
!["planning", "installing"].includes(next.status) ||
|
|
131
|
+
(inputId !== undefined &&
|
|
132
|
+
next.inputs.find((input) => input.id === inputId)?.status ===
|
|
133
|
+
"dismissed")
|
|
134
|
+
) {
|
|
135
|
+
controller.abort(new Error("The setup login was cancelled."));
|
|
136
|
+
}
|
|
137
|
+
if (client.connection.status === "stopped")
|
|
138
|
+
controller.abort(new Error("The setup connection stopped."));
|
|
139
|
+
void reconcile().catch(() => {});
|
|
140
|
+
};
|
|
141
|
+
const dispose = async () => {
|
|
142
|
+
try {
|
|
143
|
+
if (pendingAsk && terminalResult) {
|
|
144
|
+
await abortable(pendingAsk, AbortSignal.timeout(10_000)).catch(
|
|
145
|
+
() => {},
|
|
146
|
+
);
|
|
147
|
+
await reconcile().catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
disposed = true;
|
|
151
|
+
unsubscribe();
|
|
152
|
+
client.dispose();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
let resolveReady!: () => void;
|
|
157
|
+
const ready = new Promise<void>((resolve) => {
|
|
158
|
+
resolveReady = resolve;
|
|
159
|
+
});
|
|
160
|
+
unsubscribe = client.subscribe(() => {
|
|
161
|
+
if (client.state !== undefined) resolveReady();
|
|
162
|
+
if (client.connection.status === "stopped") {
|
|
163
|
+
controller.abort(new Error("The setup connection stopped."));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
if (client.state !== undefined) resolveReady();
|
|
167
|
+
if (client.connection.status === "stopped") {
|
|
168
|
+
controller.abort(new Error("The setup connection stopped."));
|
|
169
|
+
}
|
|
170
|
+
await abortable(
|
|
171
|
+
ready,
|
|
172
|
+
AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]),
|
|
173
|
+
);
|
|
174
|
+
const state = client.state!;
|
|
175
|
+
if (
|
|
176
|
+
state?.version !== 2 ||
|
|
177
|
+
!state.id ||
|
|
178
|
+
!validInputs(state.inputs) ||
|
|
179
|
+
!["planning", "installing"].includes(state.status)
|
|
180
|
+
) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"Start the setup in the wizard before signing in the CLI.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
sessionId = state.id;
|
|
186
|
+
unsubscribe();
|
|
187
|
+
unsubscribe = client.subscribe(checkSession);
|
|
188
|
+
checkSession();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await dispose();
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
const command = <T>(run: () => Promise<T>) => {
|
|
194
|
+
controller.signal.throwIfAborted();
|
|
195
|
+
return abortable(
|
|
196
|
+
run(),
|
|
197
|
+
AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]),
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
signal: controller.signal,
|
|
202
|
+
async publish(authorization: DeviceAuthorization, issuer: string) {
|
|
203
|
+
controller.signal.throwIfAborted();
|
|
204
|
+
const href = setupLoginUrl(authorization, issuer);
|
|
205
|
+
pendingHref = href;
|
|
206
|
+
pendingAsk = client.commands["agent/ask"]({
|
|
207
|
+
kind: "text",
|
|
208
|
+
preset: "assistant-ui-cli-login",
|
|
209
|
+
prompt: "Sign in to Assistant Cloud",
|
|
210
|
+
help: {
|
|
211
|
+
summary:
|
|
212
|
+
"Authorize the CLI in your browser, then return to this setup.",
|
|
213
|
+
href,
|
|
214
|
+
},
|
|
215
|
+
}).then(async (result) => {
|
|
216
|
+
if (!disposed && client.state?.id === sessionId) {
|
|
217
|
+
inputId = result.inputId;
|
|
218
|
+
checkSession();
|
|
219
|
+
await reconcile().catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
await command(() => pendingAsk!);
|
|
225
|
+
checkSession();
|
|
226
|
+
controller.signal.throwIfAborted();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
terminalResult = controller.signal.aborted ? "cancelled" : "failed";
|
|
229
|
+
await reconcile().catch(() => {});
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
return href;
|
|
233
|
+
},
|
|
234
|
+
async complete(result: "signed-in" | "cancelled" | "failed") {
|
|
235
|
+
terminalResult = result;
|
|
236
|
+
await reconcile();
|
|
237
|
+
},
|
|
238
|
+
dispose,
|
|
239
|
+
};
|
|
240
|
+
};
|