@arbium/enterprise-agent-connect-client 0.0.0-stage → 0.1.2-dev.d2aa5ca
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/lib/index.d.ts +1269 -0
- package/lib/index.js +235 -0
- package/lib/schema.d.ts +6439 -0
- package/lib/schema.js +5 -0
- package/lib/steps.d.ts +1422 -0
- package/lib/steps.js +716 -0
- package/lib/types.d.ts +62 -0
- package/lib/types.js +1 -0
- package/package.json +19 -4
- package/README.md +0 -3
package/lib/steps.js
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
// What the admin pages show, worked out from the admin API's answers: setup steps and their
|
|
2
|
+
// placeholders, statuses, the Integrations tab's grouping, Teams setup and new key setup.
|
|
3
|
+
// Pure functions, no fetch, so a browser bundle can use them.
|
|
4
|
+
/** A copy block's text: a line with {scope} repeats once per scope, {scopes} becomes the
|
|
5
|
+
* scopes space-separated, and any other {key} is filled from the connection's settings.
|
|
6
|
+
* Unknown placeholders stay visible so a missing value is obvious. */
|
|
7
|
+
export function expandCopy(lines, scopes, config = {},
|
|
8
|
+
/** How {scopes} is joined, e.g. ", " in click steps people read. */
|
|
9
|
+
joiner = " ") {
|
|
10
|
+
return lines
|
|
11
|
+
.flatMap((line) => line.includes("{scope}") ? scopes.map((s) => line.replaceAll("{scope}", s)) : [line])
|
|
12
|
+
.map((line) => line
|
|
13
|
+
.replaceAll("{scopes}", scopes.join(joiner))
|
|
14
|
+
.replace(/\{([A-Za-z_]+)\}/g, (whole, key) => config[key] ?? whole))
|
|
15
|
+
.join("\n");
|
|
16
|
+
}
|
|
17
|
+
/** {name} values the ticked capabilities fill into steps, later ones winning, e.g. the
|
|
18
|
+
* site role: read, or write once adding files is ticked. */
|
|
19
|
+
export function fillsFor(connector, ticked) {
|
|
20
|
+
return Object.assign({}, ...connector.capabilities.filter((c) => ticked.includes(c.id)).map((c) => c.fill ?? {}));
|
|
21
|
+
}
|
|
22
|
+
/** The choice fields' values: the stored or typed one, else each field's first option, which
|
|
23
|
+
* also holds while the earlier choice a field `requires` is not made. */
|
|
24
|
+
export function choiceValues(connector, values) {
|
|
25
|
+
const chosen = {};
|
|
26
|
+
for (const f of connector.fields)
|
|
27
|
+
if (f.options)
|
|
28
|
+
chosen[f.id] =
|
|
29
|
+
(isChosen(f.requires, chosen) && f.options.find((o) => o.value === values[f.id])?.value) ||
|
|
30
|
+
f.options[0].value;
|
|
31
|
+
return chosen;
|
|
32
|
+
}
|
|
33
|
+
/** True with no condition, or while its option is the chosen one. */
|
|
34
|
+
export const isChosen = (option, chosen) => !option || chosen[option.field] === option.value;
|
|
35
|
+
/** The choice fields shown: one that `requires` an earlier choice only while it is made. */
|
|
36
|
+
export const askedFields = (fields, values) => {
|
|
37
|
+
const chosen = choiceValues({ fields }, values);
|
|
38
|
+
return fields.filter((f) => isChosen(f.requires, chosen));
|
|
39
|
+
};
|
|
40
|
+
/** The steps that apply under the chosen options (a step with `option` only while chosen). */
|
|
41
|
+
export function chosenSteps(connector, steps, values) {
|
|
42
|
+
const chosen = choiceValues(connector, values);
|
|
43
|
+
return steps.filter((s) => isChosen(s.option, chosen));
|
|
44
|
+
}
|
|
45
|
+
/** Choices that change the permissions the setup steps ask for: made in step 1, with what
|
|
46
|
+
* agents may do, so the steps after it already show those permissions. */
|
|
47
|
+
export function permissionChoices(connector) {
|
|
48
|
+
return connector.fields.filter((f) => f.options?.some((o) => o.scopes?.length));
|
|
49
|
+
}
|
|
50
|
+
/** Permissions the chosen options need, e.g. Sites.Read.All for reading every site. */
|
|
51
|
+
export function optionScopes(connector, values) {
|
|
52
|
+
const chosen = choiceValues(connector, values);
|
|
53
|
+
return connector.fields.flatMap((f) => f.options?.find((o) => o.value === chosen[f.id])?.scopes ?? []);
|
|
54
|
+
}
|
|
55
|
+
/** {site} and {siteRef} (how Graph and PowerShell name it: host, or host:/path) for a site's
|
|
56
|
+
* grant steps and note. */
|
|
57
|
+
export function siteFills(site) {
|
|
58
|
+
const { hostname, pathname } = new URL(site);
|
|
59
|
+
return { site, siteRef: pathname === "/" ? hostname : `${hostname}:${pathname}` };
|
|
60
|
+
}
|
|
61
|
+
/** The note to send whoever manages SharePoint: the connector's request with the site, the
|
|
62
|
+
* app and the role filled, and its first grant step's commands where it says
|
|
63
|
+
* {grantCommands}. */
|
|
64
|
+
/** The site grant steps for the site role the ticks need (`site_role`). */
|
|
65
|
+
export function grantSteps(connector, siteRole) {
|
|
66
|
+
return (connector.siteAccess?.grant ?? []).filter((s) => !s.siteRole || s.siteRole === siteRole);
|
|
67
|
+
}
|
|
68
|
+
export function siteRequest(connector, site, scopes, config) {
|
|
69
|
+
const fills = { ...config, ...siteFills(site) };
|
|
70
|
+
const first = connector.siteAccess?.grant[0]?.copy ?? [];
|
|
71
|
+
return expandCopy([connector.siteAccess?.request ?? ""], scopes, {
|
|
72
|
+
...fills,
|
|
73
|
+
grantCommands: expandCopy(first, scopes, fills),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** The apps a key uses, in the order the apps are listed. */
|
|
77
|
+
export function keyApps(agent, connectors) {
|
|
78
|
+
return connectors.filter((c) => agent.grants.some((g) => g.connectorId === c.id));
|
|
79
|
+
}
|
|
80
|
+
/** How many active keys use an app (a disabled key uses nothing). */
|
|
81
|
+
export function keysUsing(agents, connectorId) {
|
|
82
|
+
return agents.filter((a) => a.status === "active" && a.grants.some((g) => g.connectorId === connectorId)).length;
|
|
83
|
+
}
|
|
84
|
+
/** The values a connected app reported for its discover probe, e.g. Jira project keys. */
|
|
85
|
+
export function discovered(connector, checks) {
|
|
86
|
+
if (!connector.discover || !checks)
|
|
87
|
+
return [];
|
|
88
|
+
return checks.find((c) => c.label === connector.discover.label)?.values ?? [];
|
|
89
|
+
}
|
|
90
|
+
/** Parses free-typed list input ("KAN, SAM1") into values. */
|
|
91
|
+
export function splitList(raw) {
|
|
92
|
+
return [
|
|
93
|
+
...new Set(raw
|
|
94
|
+
.split(/[\s,]+/)
|
|
95
|
+
.map((v) => v.trim())
|
|
96
|
+
.filter(Boolean)),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
/** Shown under Click through for a step the vendor offers only as commands. */
|
|
100
|
+
export const CLICK_UNAVAILABLE = "Microsoft has no admin center page for this step: it is done only with commands. Open Commands, or send them to whoever manages it.";
|
|
101
|
+
/** What a free-typed list box shows: the admin's own text while it still parses to the
|
|
102
|
+
* saved values (so a typed comma or space stays put), else the values themselves. */
|
|
103
|
+
export function listText(typed, values) {
|
|
104
|
+
const parsed = splitList(typed);
|
|
105
|
+
return parsed.length === values.length && parsed.every((v, i) => v === values[i])
|
|
106
|
+
? typed
|
|
107
|
+
: values.join(", ");
|
|
108
|
+
}
|
|
109
|
+
/** Scopes or in-app permissions the ticked capabilities need, in connector order, each once. */
|
|
110
|
+
export function neededFor(connector, ticked, kind) {
|
|
111
|
+
return [
|
|
112
|
+
...new Set([
|
|
113
|
+
...(kind === "scopes" ? (connector.scopes ?? []) : []),
|
|
114
|
+
...connector.capabilities.filter((c) => ticked.includes(c.id)).flatMap((c) => c[kind]),
|
|
115
|
+
]),
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
/** The capabilities after ticking or unticking one, in connector order. Required ones stay on;
|
|
119
|
+
* a destructive one is only turned on when `confirm` accepts its warning. */
|
|
120
|
+
export function toggleCapability(connector, current, id, on, confirm) {
|
|
121
|
+
const capability = connector.capabilities.find((c) => c.id === id);
|
|
122
|
+
if (!capability || capability.required)
|
|
123
|
+
return [...current];
|
|
124
|
+
if (on &&
|
|
125
|
+
capability.destructive &&
|
|
126
|
+
!current.includes(id) &&
|
|
127
|
+
!confirm(destructiveWarning(connector, capability)))
|
|
128
|
+
return [...current];
|
|
129
|
+
return connector.capabilities
|
|
130
|
+
.filter((c) => c.required || (c.id === id ? on : current.includes(c.id)))
|
|
131
|
+
.map((c) => c.id);
|
|
132
|
+
}
|
|
133
|
+
export function destructiveWarning(connector, capability) {
|
|
134
|
+
return `Turn on ${capability.label} in ${connector.name}? ${capability.description} Agents can do this without asking anyone first.`;
|
|
135
|
+
}
|
|
136
|
+
/** The provider form's values to send: only the fields on show, so a secret typed and then
|
|
137
|
+
* hidden by going back to the certificate is not sent (a secret sent wins the sign-in). */
|
|
138
|
+
export function providerFields(fields, useSecret, values) {
|
|
139
|
+
return Object.fromEntries(fields
|
|
140
|
+
.filter((f) => (!f.secret || useSecret) && values[f.id] !== undefined)
|
|
141
|
+
.map((f) => [f.id, values[f.id]]));
|
|
142
|
+
}
|
|
143
|
+
/** Whether a provider connection is usable by its apps. */
|
|
144
|
+
export function providerReady(connection) {
|
|
145
|
+
return connection !== null && connection.status !== "pending";
|
|
146
|
+
}
|
|
147
|
+
/** A provider connection's status, as its card and page show it. */
|
|
148
|
+
export function providerStatus(connection) {
|
|
149
|
+
if (!providerReady(connection))
|
|
150
|
+
return "not_connected";
|
|
151
|
+
return connection.status === "connected" ? "connected" : "needs_reconnect";
|
|
152
|
+
}
|
|
153
|
+
/** The Integrations tab's layout: apps of their own first, then each provider's apps
|
|
154
|
+
* together under it, by their `order`, then name. Providers without apps are left out. */
|
|
155
|
+
export function groupByProvider(apps, providers) {
|
|
156
|
+
const byOrder = (a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name);
|
|
157
|
+
const own = apps.filter((a) => !a.provider).sort(byOrder);
|
|
158
|
+
return [
|
|
159
|
+
...(own.length ? [{ provider: null, apps: own }] : []),
|
|
160
|
+
...providers
|
|
161
|
+
.map((provider) => ({
|
|
162
|
+
provider,
|
|
163
|
+
apps: apps.filter((a) => a.provider === provider.id).sort(byOrder),
|
|
164
|
+
}))
|
|
165
|
+
.filter((g) => g.apps.length > 0),
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
/** What the Integrations tab says about a provider connection: its certificate running out
|
|
169
|
+
* with no new one waiting (every app of it stops that day). */
|
|
170
|
+
export function certificateNotice(connection, now = Date.now()) {
|
|
171
|
+
const certificate = connection?.certificate;
|
|
172
|
+
if (!certificate || connection.nextCertificate || !expiresSoon(certificate, now))
|
|
173
|
+
return null;
|
|
174
|
+
return { expiresAt: certificate.expiresAt };
|
|
175
|
+
}
|
|
176
|
+
/** A certificate expiring within this many days is called out. */
|
|
177
|
+
export const CERTIFICATE_WARNING_DAYS = 30;
|
|
178
|
+
export function expiresSoon(info, now = Date.now()) {
|
|
179
|
+
return new Date(info.expiresAt).getTime() - now < CERTIFICATE_WARNING_DAYS * 86_400_000;
|
|
180
|
+
}
|
|
181
|
+
/** How a provider connection signs in, in the admin's words. */
|
|
182
|
+
export function signInMethod(connection) {
|
|
183
|
+
const auth = connection.config.auth;
|
|
184
|
+
if (auth === "certificate")
|
|
185
|
+
return "Certificate";
|
|
186
|
+
if (auth === "client_secret")
|
|
187
|
+
return "Client secret";
|
|
188
|
+
if (auth === "arbium_app")
|
|
189
|
+
return "The shared app, approved by your administrator";
|
|
190
|
+
throw new Error(`Unknown sign-in ${auth}`);
|
|
191
|
+
}
|
|
192
|
+
/** A saved connection whose last check failed needs reconnecting, as does one marked so; one
|
|
193
|
+
* whose check passed with a warning (e.g. Jira can browse no project yet) needs steps. */
|
|
194
|
+
export function appStatus(connection, healthy = true, warned = false) {
|
|
195
|
+
if (!connection)
|
|
196
|
+
return "not_connected";
|
|
197
|
+
if (connection.status !== "connected" || !healthy)
|
|
198
|
+
return "needs_reconnect";
|
|
199
|
+
return warned ? "needs_steps" : "connected";
|
|
200
|
+
}
|
|
201
|
+
/** A check that passed but found something to do. */
|
|
202
|
+
export function checkWarned(health) {
|
|
203
|
+
return health?.ok === true && health.checks.some((c) => c.warning);
|
|
204
|
+
}
|
|
205
|
+
/** What an app's own page shows, so it never contradicts its badge: a failed check, a failed
|
|
206
|
+
* recorded check, or a check that passed with a warning each offer Check again, and a warning
|
|
207
|
+
* leaves Prepare and "What agents can see" not done. */
|
|
208
|
+
export function appPageState(connection, health) {
|
|
209
|
+
const warned = checkWarned(health);
|
|
210
|
+
const notice = health && !health.ok
|
|
211
|
+
? "failed"
|
|
212
|
+
: connection?.status === "needs_reconnect"
|
|
213
|
+
? "recorded"
|
|
214
|
+
: warned
|
|
215
|
+
? "warned"
|
|
216
|
+
: null;
|
|
217
|
+
return { notice, prepareDone: connection !== null && !warned, seeDone: !warned };
|
|
218
|
+
}
|
|
219
|
+
/** An app of its own on the Integrations tab: its stored status, then its live read-only
|
|
220
|
+
* check (failed: needs reconnecting; could not run: needs attention; warned: needs steps).
|
|
221
|
+
* Before the check answers (`checks` null in `integrationCards`), the stored status alone. */
|
|
222
|
+
export function cardStatus(connection, check) {
|
|
223
|
+
const base = appStatus(connection, check?.ok !== false, checkWarned(check));
|
|
224
|
+
return base === "connected" && check === null ? "needs_attention" : base;
|
|
225
|
+
}
|
|
226
|
+
/** A provider's status as its card and its page's header show it: the sign-in's, then its
|
|
227
|
+
* apps' (reconnecting, attention, steps), with how many apps are on and how many need steps
|
|
228
|
+
* or attention, each app counted by `appState`, as its row and its own page show it. */
|
|
229
|
+
export function providerSummary(definition, connection, apps, confirmed) {
|
|
230
|
+
const rows = apps.map((a) => appState({
|
|
231
|
+
definition,
|
|
232
|
+
provider: connection,
|
|
233
|
+
connector: a.connector,
|
|
234
|
+
connection: a.connection,
|
|
235
|
+
state: a.state,
|
|
236
|
+
marks: confirmed,
|
|
237
|
+
}).status);
|
|
238
|
+
const steps = rows.filter((r) => r === "needs_steps").length;
|
|
239
|
+
const attention = rows.filter((r) => r === "needs_attention" || r === "needs_reconnect").length;
|
|
240
|
+
const signIn = providerStatus(connection);
|
|
241
|
+
const status = signIn !== "connected"
|
|
242
|
+
? signIn
|
|
243
|
+
: rows.includes("needs_reconnect")
|
|
244
|
+
? "needs_reconnect"
|
|
245
|
+
: attention
|
|
246
|
+
? "needs_attention"
|
|
247
|
+
: steps
|
|
248
|
+
? "needs_steps"
|
|
249
|
+
: "connected";
|
|
250
|
+
return { status, on: apps.filter((a) => a.connection).length, steps, attention };
|
|
251
|
+
}
|
|
252
|
+
/** Where a bot's setup stands, in the order the admin does it. The service sees what Add to Teams
|
|
253
|
+
* made and what it published; Teams telling it the bot was added somewhere proves the rest. */
|
|
254
|
+
export function teamsSetup(bot) {
|
|
255
|
+
const signedIn = bot?.status === "connected";
|
|
256
|
+
const places = bot?.installedIn ?? [];
|
|
257
|
+
const published = bot?.publishedVersion ?? null;
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
label: "Bot made in your Microsoft tenant",
|
|
261
|
+
done: Boolean(bot?.botAppId),
|
|
262
|
+
detail: bot?.botAppId ?? undefined,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
label: "Bot signs in to Microsoft",
|
|
266
|
+
done: signedIn,
|
|
267
|
+
detail: bot?.status === "needs_reconnect" ? "The last sign-in failed" : undefined,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
label: "Published to your organization's Teams apps",
|
|
271
|
+
// A bot set up with the script publishes itself; being added somewhere proves it.
|
|
272
|
+
done: published !== null || places.length > 0,
|
|
273
|
+
detail: published && bot && published !== bot.packageVersion
|
|
274
|
+
? "Its package changed: publish it again"
|
|
275
|
+
: undefined,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
label: "Added to a team, chat or person",
|
|
279
|
+
done: places.length > 0,
|
|
280
|
+
detail: places.length
|
|
281
|
+
? places.map(placeName).join(", ")
|
|
282
|
+
: (bot?.lastRefusedReason ?? undefined),
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
export function placeName(place) {
|
|
287
|
+
if (place.name)
|
|
288
|
+
return place.name;
|
|
289
|
+
return place.kind === "personal"
|
|
290
|
+
? "A one-to-one chat"
|
|
291
|
+
: place.kind === "groupChat"
|
|
292
|
+
? "A group chat"
|
|
293
|
+
: "A team";
|
|
294
|
+
}
|
|
295
|
+
/** The service's default runner, the only one whose fields the trigger form edits. */
|
|
296
|
+
export const CLAUDE_RUNNER = "claude-managed-agents";
|
|
297
|
+
export function triggerForm(trigger) {
|
|
298
|
+
return {
|
|
299
|
+
enabled: trigger?.enabled ?? false,
|
|
300
|
+
runner: trigger?.runner ?? CLAUDE_RUNNER,
|
|
301
|
+
settings: trigger?.settings ?? {},
|
|
302
|
+
managedAgentId: trigger?.managedAgentId ?? "",
|
|
303
|
+
environmentId: trigger?.environmentId ?? "",
|
|
304
|
+
vaultIds: trigger?.vaultIds.join(", ") ?? "",
|
|
305
|
+
budgetDollars: trigger ? (trigger.budgetCents / 100).toFixed(2) : "1.00",
|
|
306
|
+
dailyDollars: trigger ? (trigger.maxCentsPerDay / 100).toFixed(2) : "5.00",
|
|
307
|
+
maxRunsPerHour: String(trigger?.maxRunsPerHour ?? 20),
|
|
308
|
+
replyMode: trigger?.replyMode ?? "mentions",
|
|
309
|
+
allowedTeams: trigger?.allowedTeams ?? [],
|
|
310
|
+
allowedPeople: trigger?.allowedPeople.join(", ") ?? "",
|
|
311
|
+
anyoneInOrganization: trigger?.anyoneInOrganization ?? false,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
315
|
+
/** The trigger to save, or what to fix. The service checks again, and that the Claude agent
|
|
316
|
+
* and environment exist under the organization's key. */
|
|
317
|
+
export function triggerInput(form) {
|
|
318
|
+
const claude = form.runner === CLAUDE_RUNNER;
|
|
319
|
+
const managedAgentId = form.managedAgentId.trim();
|
|
320
|
+
const environmentId = form.environmentId.trim();
|
|
321
|
+
if (claude && !managedAgentId)
|
|
322
|
+
return { ok: false, error: "Enter the Claude agent's ID" };
|
|
323
|
+
if (claude && !environmentId)
|
|
324
|
+
return { ok: false, error: "Enter the Claude environment's ID" };
|
|
325
|
+
const money = /^\d+(\.\d{1,2})?$/;
|
|
326
|
+
const dollars = Number(form.budgetDollars);
|
|
327
|
+
if (!money.test(form.budgetDollars.trim()) || dollars < 0.01 || dollars > 20)
|
|
328
|
+
return { ok: false, error: "The spending limit per answer is between $0.01 and $20" };
|
|
329
|
+
const daily = Number(form.dailyDollars);
|
|
330
|
+
if (!money.test(form.dailyDollars.trim()) || daily < dollars || daily > 1000)
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
error: "The daily spending limit is between the limit per answer and $1,000",
|
|
334
|
+
};
|
|
335
|
+
const runs = Number(form.maxRunsPerHour);
|
|
336
|
+
if (!Number.isInteger(runs) || runs < 1 || runs > 100)
|
|
337
|
+
return { ok: false, error: "Answers per hour is a whole number from 1 to 100" };
|
|
338
|
+
const allowedTeams = form.allowedTeams;
|
|
339
|
+
const allowedPeople = splitList(form.allowedPeople);
|
|
340
|
+
const bad = allowedPeople.find((v) => !ID.test(v));
|
|
341
|
+
if (bad)
|
|
342
|
+
return { ok: false, error: `${bad} is not a Microsoft ID (like 0f3e…-…)` };
|
|
343
|
+
if (form.enabled && !form.anyoneInOrganization && !allowedPeople.length)
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
error: "Name the people who may ask it, or allow anyone in your organization",
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
value: {
|
|
351
|
+
enabled: form.enabled,
|
|
352
|
+
runner: form.runner,
|
|
353
|
+
settings: form.settings,
|
|
354
|
+
...(claude ? { managedAgentId, environmentId, vaultIds: splitList(form.vaultIds) } : {}),
|
|
355
|
+
budgetCents: Math.round(dollars * 100),
|
|
356
|
+
maxCentsPerDay: Math.round(daily * 100),
|
|
357
|
+
maxRunsPerHour: runs,
|
|
358
|
+
replyMode: form.replyMode,
|
|
359
|
+
allowedTeams,
|
|
360
|
+
allowedPeople,
|
|
361
|
+
anyoneInOrganization: form.anyoneInOrganization,
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** One answer in Recent Teams activity, in the admin's words. */
|
|
366
|
+
export function runSummary(run) {
|
|
367
|
+
const state = {
|
|
368
|
+
starting: "Starting",
|
|
369
|
+
running: "Answering",
|
|
370
|
+
idle: "Answered",
|
|
371
|
+
ended: "Finished",
|
|
372
|
+
failed: "Failed",
|
|
373
|
+
no_reply: "Finished without replying",
|
|
374
|
+
}[run.state];
|
|
375
|
+
const cost = run.costCents === null ? null : `$${(run.costCents / 100).toFixed(2)}`;
|
|
376
|
+
const turns = run.turns === 1 ? "1 message" : `${run.turns} messages`;
|
|
377
|
+
return [state, turns, cost].filter(Boolean).join(" · ");
|
|
378
|
+
}
|
|
379
|
+
/** An agent's address, limited to characters that are inert inside the commands it fills. */
|
|
380
|
+
export const AGENT_EMAIL = /^[A-Za-z0-9._+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
|
|
381
|
+
/** A Teams bot's name: the setup script hands it to az, which runs through cmd.exe on Windows,
|
|
382
|
+
* so only characters inert there (the same rule as the service's). */
|
|
383
|
+
export const BOT_NAME = /^[A-Za-z0-9 ._-]{1,30}$/;
|
|
384
|
+
/** A key's name as a starting bot name: other characters become spaces. */
|
|
385
|
+
export function botNameFrom(name) {
|
|
386
|
+
return name
|
|
387
|
+
.replace(/[^A-Za-z0-9 ._-]+/g, " ")
|
|
388
|
+
.replace(/\s+/g, " ")
|
|
389
|
+
.trim()
|
|
390
|
+
.slice(0, 30)
|
|
391
|
+
.trim();
|
|
392
|
+
}
|
|
393
|
+
/** {agentEmail} and {agentName} (the part before the @) for the add-an-agent steps. */
|
|
394
|
+
export function agentFills(email) {
|
|
395
|
+
const at = email.trim();
|
|
396
|
+
return AGENT_EMAIL.test(at) ? { agentEmail: at, agentName: at.split("@")[0] } : {};
|
|
397
|
+
}
|
|
398
|
+
/** The add-an-agent steps for the apps ticked on a new key. */
|
|
399
|
+
export function agentSteps(provider, on) {
|
|
400
|
+
return provider.addAgent.filter((s) => s.apps.some((a) => on.includes(a)));
|
|
401
|
+
}
|
|
402
|
+
/** What a new key's agent needs outside the service for the apps ticked on it: its email where an
|
|
403
|
+
* app opens the agent's own address (its mailbox), each provider's steps for those apps, and
|
|
404
|
+
* the apps that give each agent its own bot. Apps with nothing per agent add nothing. */
|
|
405
|
+
export function newKeySetup(ticked, providers) {
|
|
406
|
+
const ids = ticked.map((c) => c.id);
|
|
407
|
+
return {
|
|
408
|
+
addressed: ticked.filter((c) => c.agentAccess),
|
|
409
|
+
steps: providers.flatMap((provider) => agentSteps(provider, ids).map((step) => ({ provider: provider.id, step }))),
|
|
410
|
+
bots: ticked.filter((c) => c.signsInAs === "agentBot"),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/** The list limits a new key must be given for an app before it is made (e.g. SharePoint's
|
|
414
|
+
* sites): required, and not the agent's own address, which comes from its email. */
|
|
415
|
+
export function newKeyLimits(c) {
|
|
416
|
+
return c.limits.filter((l) => l.kind === "list" && l.required);
|
|
417
|
+
}
|
|
418
|
+
/** "App: Limit" for each required list limit still empty on a new key's ticked apps. */
|
|
419
|
+
export function missingNewKeyLimits(ticked, chosen) {
|
|
420
|
+
return ticked.flatMap((c) => newKeyLimits(c)
|
|
421
|
+
.filter((l) => {
|
|
422
|
+
const v = chosen[c.id]?.[l.id];
|
|
423
|
+
return !Array.isArray(v) || v.length === 0;
|
|
424
|
+
})
|
|
425
|
+
.map((l) => `${c.name}: ${l.label}`));
|
|
426
|
+
}
|
|
427
|
+
/** The grants a new key starts with: each ticked app at what its page allows (its ceiling),
|
|
428
|
+
* with the limits chosen on the form, and the agent's email as its address where the app
|
|
429
|
+
* has one. */
|
|
430
|
+
export function newKeyGrants(ticked, email, chosen) {
|
|
431
|
+
return ticked.map(({ connector: c, ceiling }) => ({
|
|
432
|
+
app: c.id,
|
|
433
|
+
capabilities: ceiling,
|
|
434
|
+
limits: {
|
|
435
|
+
...Object.fromEntries(newKeyLimits(c).map((l) => [l.id, chosen[c.id]?.[l.id] ?? []])),
|
|
436
|
+
...(c.agentAccess ? { [c.agentAccess.limit]: email.trim() } : {}),
|
|
437
|
+
},
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
/** The keys whose `limit` (an agent's own address) is `value`, in any app. */
|
|
441
|
+
export function keysWithAddress(agents, connectors, value) {
|
|
442
|
+
const wanted = value.trim().toLowerCase();
|
|
443
|
+
return agents.filter((a) => a.grants.some((g) => {
|
|
444
|
+
const limit = connectors.find((c) => c.id === g.connectorId)?.agentAccess?.limit;
|
|
445
|
+
const held = limit ? g.limits[limit] : undefined;
|
|
446
|
+
return typeof held === "string" && held.toLowerCase() === wanted;
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
/** The warning that active keys already hold an address, or null. */
|
|
450
|
+
export function holdersNotice(holders) {
|
|
451
|
+
const active = holders.filter((a) => a.status === "active").map((a) => a.name);
|
|
452
|
+
if (!active.length)
|
|
453
|
+
return null;
|
|
454
|
+
return `${active.join(", ")} already ${active.length === 1 ? "uses" : "use"} this address.`;
|
|
455
|
+
}
|
|
456
|
+
const status = (state, detail, extra = {}) => ({ state, detail, confirm: false, locked: false, ...extra });
|
|
457
|
+
/** Connecting a provider, before any app: its setup steps (registering its app, which only the
|
|
458
|
+
* sign-in can prove, so the admin marks it; the certificate, made here, its upload proven by
|
|
459
|
+
* the sign-in), then connecting, a live sign-in. Each step opens once the one before it
|
|
460
|
+
* passes. `marks`: the steps the admin marked done; `secret`: the admin chose a client secret
|
|
461
|
+
* over the certificate. */
|
|
462
|
+
export function providerSteps(setup, connection, marks, secret) {
|
|
463
|
+
const signedIn = connection?.status === "connected";
|
|
464
|
+
const certificate = Boolean(connection?.config.certificate);
|
|
465
|
+
const proven = `Proven by the sign-in in step ${setup.length + 1}.`;
|
|
466
|
+
const out = [];
|
|
467
|
+
let open = true;
|
|
468
|
+
for (const step of setup) {
|
|
469
|
+
let next;
|
|
470
|
+
let passed = true;
|
|
471
|
+
if (signedIn)
|
|
472
|
+
next = status("done", step.certificate && connection.config.auth === "client_secret"
|
|
473
|
+
? "Signs in with a client secret instead."
|
|
474
|
+
: proven);
|
|
475
|
+
else if (step.certificate) {
|
|
476
|
+
passed = certificate || secret;
|
|
477
|
+
next = status("todo", certificate
|
|
478
|
+
? "Certificate created. Upload it, then connect: the sign-in proves it."
|
|
479
|
+
: secret
|
|
480
|
+
? "Using a client secret instead: enter it when you connect."
|
|
481
|
+
: "Create the certificate here, then upload it to the app.");
|
|
482
|
+
}
|
|
483
|
+
else if (step.confirm) {
|
|
484
|
+
// Making the certificate came after registering, so that counts as the mark.
|
|
485
|
+
passed = marks.includes(step.confirm) || certificate;
|
|
486
|
+
next = status(passed ? "marked" : "todo", passed
|
|
487
|
+
? "Marked done. The sign-in proves it."
|
|
488
|
+
: "The app can be seen here only once you connect. Mark it done to go on.", { confirm: true });
|
|
489
|
+
}
|
|
490
|
+
else
|
|
491
|
+
next = status("todo", "");
|
|
492
|
+
out.push({ ...next, locked: !open });
|
|
493
|
+
open = open && passed;
|
|
494
|
+
}
|
|
495
|
+
out.push(status(signedIn ? "done" : connection?.status === "needs_reconnect" ? "failing" : "todo", signedIn
|
|
496
|
+
? "Signs in."
|
|
497
|
+
: connection?.status === "needs_reconnect"
|
|
498
|
+
? "The last sign-in failed."
|
|
499
|
+
: "Not connected yet.", { locked: !open }));
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
const settled = (s) => s.state === "done" || s.state === "marked";
|
|
503
|
+
const numbered = (ns) => ns.length === 1
|
|
504
|
+
? `step ${ns[0]}`
|
|
505
|
+
: `steps ${ns.slice(0, -1).join(", ")} and ${ns[ns.length - 1]}`;
|
|
506
|
+
export function appSteps(input) {
|
|
507
|
+
const { connector, connection, health, ceiling, marks } = input;
|
|
508
|
+
const setup = chosenSteps(connector, connector.setup, input.choices ?? {});
|
|
509
|
+
const afterConnect = chosenSteps(connector, connector.afterConnect, input.choices ?? {});
|
|
510
|
+
const on = connection !== null;
|
|
511
|
+
const changed = on && connection.capabilities.join(",") !== ceiling.join(",");
|
|
512
|
+
const before = input.providerReady ? {} : { locked: true };
|
|
513
|
+
const after = on ? {} : { locked: true };
|
|
514
|
+
const checks = health?.ok ? health.checks : null;
|
|
515
|
+
const permissions = checks?.find((c) => c.label === connector.verify.label);
|
|
516
|
+
const confirmStep = (step, extra) => marks.includes(step.confirm)
|
|
517
|
+
? status("marked", "You marked this done.", { confirm: true, ...extra })
|
|
518
|
+
: status("todo", "This cannot be checked from here. Mark it done once it is.", {
|
|
519
|
+
confirm: true,
|
|
520
|
+
...extra,
|
|
521
|
+
});
|
|
522
|
+
const checking = (extra) => status("checking", "Checking…", extra);
|
|
523
|
+
const ownCheck = (id, extra) => {
|
|
524
|
+
if (input.checking)
|
|
525
|
+
return checking(extra);
|
|
526
|
+
const found = input.live?.[id];
|
|
527
|
+
if (!found)
|
|
528
|
+
return status("failing", "This could not be checked. Reload the page.", extra);
|
|
529
|
+
return status(found.state === "done" ? "done" : found.state === "failing" ? "failing" : "todo", found.message, extra);
|
|
530
|
+
};
|
|
531
|
+
const liveStep = (step, extra) => step.live
|
|
532
|
+
? ownCheck(step.live, extra)
|
|
533
|
+
: step.confirm
|
|
534
|
+
? confirmStep(step, extra)
|
|
535
|
+
: !on
|
|
536
|
+
? status("todo", "Checked live when you turn it on.", extra)
|
|
537
|
+
: input.checking
|
|
538
|
+
? checking(extra)
|
|
539
|
+
: health && !health.ok
|
|
540
|
+
? status("failing", `The check failed: ${health.error}`, extra)
|
|
541
|
+
: permissions?.warning
|
|
542
|
+
? status("failing", permissions.warning, extra)
|
|
543
|
+
: status("done", "Checked live: the app's permissions match.", extra);
|
|
544
|
+
const setupStep = (step, i, extra, prefix) => ({
|
|
545
|
+
id: `${prefix}-${i}`,
|
|
546
|
+
kind: "setup",
|
|
547
|
+
title: step.title,
|
|
548
|
+
step,
|
|
549
|
+
status: liveStep(step, extra),
|
|
550
|
+
});
|
|
551
|
+
const steps = [
|
|
552
|
+
{
|
|
553
|
+
id: "capabilities",
|
|
554
|
+
kind: "capabilities",
|
|
555
|
+
title: "Choose what agents may do",
|
|
556
|
+
status: changed
|
|
557
|
+
? status("todo", "Changed. Save it.", before)
|
|
558
|
+
: on
|
|
559
|
+
? status("done", "Saved.", before)
|
|
560
|
+
: status("todo", "Saved when you turn it on.", before),
|
|
561
|
+
},
|
|
562
|
+
...(input.approval ? [] : [...setup, ...afterConnect]).map((s, i) => setupStep(s, i, before, "setup")),
|
|
563
|
+
];
|
|
564
|
+
const needsKey = connector.capabilities.filter((c) => c.claudeKey && ceiling.includes(c.id));
|
|
565
|
+
if (needsKey.length)
|
|
566
|
+
steps.push({
|
|
567
|
+
id: "claude-key",
|
|
568
|
+
kind: "claudeKey",
|
|
569
|
+
title: "Add your organization's Claude API key",
|
|
570
|
+
status: input.checking
|
|
571
|
+
? checking(before)
|
|
572
|
+
: input.claudeKey
|
|
573
|
+
? status("done", "Set.", before)
|
|
574
|
+
: status("todo", `Needed because ${needsKey.map((c) => c.label).join(" and ")} is ticked: each answer uses this key.`, before),
|
|
575
|
+
});
|
|
576
|
+
const later = input.approval
|
|
577
|
+
? [...setup.filter((s) => s.confirm), ...afterConnect].map((s, i) => setupStep(s, i, after, "after"))
|
|
578
|
+
: [];
|
|
579
|
+
if (connector.siteAccess) {
|
|
580
|
+
const sites = input.sites ?? [];
|
|
581
|
+
const open = sites.filter((s) => s.status === "granted").length;
|
|
582
|
+
later.push({
|
|
583
|
+
id: "sites",
|
|
584
|
+
kind: "sites",
|
|
585
|
+
title: "Add the sites agents may use",
|
|
586
|
+
status: on && input.checking
|
|
587
|
+
? checking(after)
|
|
588
|
+
: sites.length === 0
|
|
589
|
+
? status("todo", "No sites yet.", after)
|
|
590
|
+
: open === sites.length
|
|
591
|
+
? status("done", `${many(sites.length, "site")}, all open to the app.`, after)
|
|
592
|
+
: status("todo", `${open} of ${many(sites.length, "site")} open to the app.`, after),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
const turnOnAt = steps.length + 1;
|
|
596
|
+
// Confirm steps before turning on hold it back; live ones are checked by turning on.
|
|
597
|
+
const waiting = steps.flatMap((s, i) => (s.status.confirm && !settled(s.status) ? [i + 1] : []));
|
|
598
|
+
const unfinished = [
|
|
599
|
+
...steps.flatMap((s, i) => (s.kind !== "capabilities" && !settled(s.status) ? [i + 1] : [])),
|
|
600
|
+
...later.flatMap((s, i) => (settled(s.status) ? [] : [turnOnAt + 1 + i])),
|
|
601
|
+
];
|
|
602
|
+
const turnOn = !on
|
|
603
|
+
? waiting.length && input.providerReady
|
|
604
|
+
? status("todo", "Not on yet.", {
|
|
605
|
+
locked: true,
|
|
606
|
+
why: `Finish ${numbered(waiting)} first.`,
|
|
607
|
+
})
|
|
608
|
+
: status("todo", "Not on yet.", before)
|
|
609
|
+
: connection.status !== "connected"
|
|
610
|
+
? status("failing", "Needs reconnecting.")
|
|
611
|
+
: input.checking
|
|
612
|
+
? status("checking", "On. Checking…")
|
|
613
|
+
: health && !health.ok
|
|
614
|
+
? status("failing", `The check failed: ${health.error}`)
|
|
615
|
+
: permissions?.warning
|
|
616
|
+
? status("failing", `On, but the permission check fails: ${permissions.warning}`)
|
|
617
|
+
: unfinished.length
|
|
618
|
+
? status("todo", `On, but ${numbered(unfinished)} ${unfinished.length === 1 ? "is" : "are"} not done yet.`)
|
|
619
|
+
: status("done", "On. Every step is done and the check passed.");
|
|
620
|
+
return waitForSettings([
|
|
621
|
+
...steps,
|
|
622
|
+
{ id: "turn-on", kind: "turnOn", title: `Turn on ${connector.name}`, status: turnOn },
|
|
623
|
+
...later,
|
|
624
|
+
], input.saved ?? {});
|
|
625
|
+
}
|
|
626
|
+
/** A step whose text fills in a value saved at another step waits until it is saved: its
|
|
627
|
+
* words would show the raw {name} and its commands would not run. */
|
|
628
|
+
export function waitForSettings(steps, saved) {
|
|
629
|
+
const at = new Map();
|
|
630
|
+
steps.forEach((s, i) => s.step?.settings?.forEach((x) => at.set(x.id, { n: i + 1, label: x.label })));
|
|
631
|
+
return steps.map((s) => {
|
|
632
|
+
if (!s.step || s.step.settings)
|
|
633
|
+
return s;
|
|
634
|
+
const text = [s.step.body, ...(s.step.click ?? []), ...(s.step.copy ?? [])].join("\n");
|
|
635
|
+
const missing = [...at].filter(([id]) => text.includes(`{${id}}`) && !saved[id]?.trim());
|
|
636
|
+
if (!missing.length)
|
|
637
|
+
return s;
|
|
638
|
+
const labels = missing.map(([, x]) => x.label);
|
|
639
|
+
const where = [...new Set(missing.map(([, x]) => x.n))];
|
|
640
|
+
const what = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
|
|
641
|
+
return {
|
|
642
|
+
...s,
|
|
643
|
+
status: status("todo", `Waiting for ${what}.`, {
|
|
644
|
+
locked: true,
|
|
645
|
+
why: `Save the ${what} in ${numbered(where)} first.`,
|
|
646
|
+
}),
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
/** The status an app's page shows by its name: needing steps while it is on but any step is
|
|
651
|
+
* not done. */
|
|
652
|
+
export function appPageStatus(connection, healthy, steps) {
|
|
653
|
+
const base = appStatus(connection, healthy);
|
|
654
|
+
if (base !== "connected")
|
|
655
|
+
return base;
|
|
656
|
+
return steps.every((s) => s.kind === "capabilities" || settled(s.status))
|
|
657
|
+
? "connected"
|
|
658
|
+
: "needs_steps";
|
|
659
|
+
}
|
|
660
|
+
/** An app's live state before its checks answer: steps they decide show as checking, and its
|
|
661
|
+
* status is the stored one. */
|
|
662
|
+
export const CHECKING = {
|
|
663
|
+
health: null,
|
|
664
|
+
sites: null,
|
|
665
|
+
claudeKey: false,
|
|
666
|
+
live: null,
|
|
667
|
+
checking: true,
|
|
668
|
+
};
|
|
669
|
+
/** How a provider's app signs in: through the provider connection, or (nothing connected yet,
|
|
670
|
+
* where the shared app is offered) by approving that app when turning it on. */
|
|
671
|
+
export function signInRoute(definition, provider) {
|
|
672
|
+
const signsIn = providerReady(provider);
|
|
673
|
+
return { signsIn, approve: !signsIn && definition.approval };
|
|
674
|
+
}
|
|
675
|
+
/** A provider app's steps and status: the one computation behind its page's badge, its row on
|
|
676
|
+
* the provider's page and the provider's header and card, so none contradicts another. A
|
|
677
|
+
* check that could not run needs attention. `ceiling` and `choices`: as ticked and typed on
|
|
678
|
+
* the app's page, else as saved. */
|
|
679
|
+
export function appState(input) {
|
|
680
|
+
const { connector, connection, state } = input;
|
|
681
|
+
const { signsIn, approve } = signInRoute(input.definition, input.provider);
|
|
682
|
+
const steps = appSteps({
|
|
683
|
+
connector,
|
|
684
|
+
connection,
|
|
685
|
+
providerReady: signsIn || approve,
|
|
686
|
+
approval: approve,
|
|
687
|
+
health: state.health,
|
|
688
|
+
ceiling: input.ceiling ??
|
|
689
|
+
connection?.capabilities ??
|
|
690
|
+
connector.capabilities.filter((c) => c.required).map((c) => c.id),
|
|
691
|
+
sites: state.sites,
|
|
692
|
+
claudeKey: state.claudeKey,
|
|
693
|
+
marks: input.marks,
|
|
694
|
+
live: state.live,
|
|
695
|
+
checking: state.checking,
|
|
696
|
+
saved: input.provider?.config ?? {},
|
|
697
|
+
choices: input.choices ?? connection?.config ?? {},
|
|
698
|
+
});
|
|
699
|
+
const healthy = input.provider?.status === "connected" && state.health?.ok !== false;
|
|
700
|
+
if (state.checking)
|
|
701
|
+
return { steps, status: appStatus(connection, healthy) };
|
|
702
|
+
const unknown = state.health === null || (Boolean(connector.siteAccess) && state.sites === null);
|
|
703
|
+
const status = unknown && appStatus(connection, healthy) === "connected"
|
|
704
|
+
? "needs_attention"
|
|
705
|
+
: appPageStatus(connection, healthy, steps);
|
|
706
|
+
return { steps, status };
|
|
707
|
+
}
|
|
708
|
+
const many = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
709
|
+
/** A key's own addresses (its mailbox), per app that has one, e.g. for its access checks. */
|
|
710
|
+
export function agentAddresses(agent, connectors) {
|
|
711
|
+
return connectors.flatMap((connector) => {
|
|
712
|
+
const limit = connector.agentAccess?.limit;
|
|
713
|
+
const held = agent.grants.find((g) => g.connectorId === connector.id)?.limits[limit ?? ""];
|
|
714
|
+
return limit && typeof held === "string" && held ? [{ connector, value: held }] : [];
|
|
715
|
+
});
|
|
716
|
+
}
|