@vellumai/assistant 0.12.1-dev.202609162016.c36c1d1 → 0.12.1-dev.202609162115.4edf01d

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.1-dev.202609162016.c36c1d1",
3
+ "version": "0.12.1-dev.202609162115.4edf01d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -698,22 +698,18 @@ describe("POST oauth/request", () => {
698
698
  expect(result.body).toEqual({ hello: "world" });
699
699
  });
700
700
 
701
- // The recovery hint on a 401/403 follows the credential's kind. A channel
702
- // bot's token is stored by the channel's setup, so the OAuth status and
703
- // connect commands cannot repair it; the hint must name the channel's own
704
- // diagnostics, through whichever door the request came.
705
- // A 403 carrying an HTML page is a resource refusing this identity, not the
706
- // credential failing: an API refuses with JSON, and the same token serves
707
- // it. The hint names the resource's access and never tells the caller to
708
- // reconnect; a JSON 403 from any host keeps the credential reading.
709
- test("an HTML 403 names the resource's access, not the credential", async () => {
701
+ // The hint's wording is pinned in oauth-request-hints.test.ts. Here only
702
+ // the facts the route hands the composer are proved to arrive: the
703
+ // response headers, the base the request resolved against, and the bot
704
+ // channel resolved from the provider key.
705
+ test("hands the composer the response, the resolved base, and the identity", async () => {
710
706
  mockProviders.slack_channel = seededProvider("slack_channel");
711
707
  mockResolveResponse = {
712
708
  status: 403,
713
709
  headers: { "content-type": "text/html; charset=utf-8" },
714
710
  body: "<html><title>Slack</title></html>",
715
711
  };
716
- const result = (await getRoute("POST", "oauth/request").handler(
712
+ const asBot = (await getRoute("POST", "oauth/request").handler(
717
713
  makeArgs({
718
714
  body: {
719
715
  provider: "slack_channel",
@@ -721,99 +717,28 @@ describe("POST oauth/request", () => {
721
717
  },
722
718
  }),
723
719
  )) as { ok: boolean; status: number; hint?: string };
724
- expect(result.ok).toBe(false);
725
- expect(result.hint).toContain("HTML page from files.slack.com");
726
- expect(result.hint).toContain("cannot see this resource");
727
- expect(result.hint).not.toContain("was rejected");
728
- expect(result.hint).not.toContain("setup skill");
720
+ expect(asBot.ok).toBe(false);
721
+ expect(asBot.status).toBe(403);
722
+ // An absolute URL's own origin is the resolved base.
723
+ expect(asBot.hint).toContain("from files.slack.com");
724
+ expect(asBot.hint).toContain("assistant channels get slack");
729
725
 
730
- // A JSON 403 is an API refusal and keeps the credential reading, on the
731
- // API host and on a provider's other API hosts alike.
726
+ // A relative path resolves against the provider's base.
732
727
  mockResolveResponse = {
733
- status: 403,
734
- headers: { "content-type": "application/json" },
735
- body: { ok: false, error: "missing_scope" },
736
- };
737
- const apiResult = (await getRoute("POST", "oauth/request").handler(
738
- makeArgs({
739
- body: { provider: "slack_channel", url: "/conversations.history" },
740
- }),
741
- )) as { hint?: string };
742
- expect(apiResult.hint).toContain("slack bot credential was rejected");
743
-
744
- mockProviders.google = {
745
- ...baseProvider,
746
- injectionTemplates: JSON.stringify([
747
- {
748
- hostPattern: "gmail.googleapis.com",
749
- injectionType: "header",
750
- headerName: "Authorization",
751
- valuePrefix: "Bearer ",
752
- },
753
- {
754
- hostPattern: "calendar.googleapis.com",
755
- injectionType: "header",
756
- headerName: "Authorization",
757
- valuePrefix: "Bearer ",
758
- },
759
- ]),
760
- baseUrl: "https://gmail.googleapis.com/gmail/v1/users/me",
728
+ status: 404,
729
+ headers: { "content-type": "text/html; charset=UTF-8" },
730
+ body: "<html><title>Error 404 (Not Found)</title></html>",
761
731
  };
762
- const otherApi = (await getRoute("POST", "oauth/request").handler(
732
+ const relative = (await getRoute("POST", "oauth/request").handler(
763
733
  makeArgs({
764
734
  body: {
765
735
  provider: "google",
766
- url: "https://calendar.googleapis.com/calendar/v3/calendars/primary",
736
+ url: "/calendar/v3/calendars/primary/events",
767
737
  },
768
738
  }),
769
- )) as { hint?: string };
770
- expect(otherApi.hint).toContain(
771
- "The OAuth token may be expired or revoked",
772
- );
773
- expect(otherApi.hint).not.toContain("cannot see this resource");
774
- });
775
-
776
- test("401 as a channel bot points at the channel's diagnostics, never the OAuth commands", async () => {
777
- mockProviders.slack_channel = {
778
- ...baseProvider,
779
- provider: "slack_channel",
780
- authorizeUrl: "urn:manual-token",
781
- managedServiceConfigKey: null,
782
- baseUrl: "https://slack.com/api",
783
- };
784
- mockResolveResponse = {
785
- status: 401,
786
- headers: { "content-type": "application/json" },
787
- body: { ok: false, error: "invalid_auth" },
788
- };
789
- const result = (await getRoute("POST", "oauth/request").handler(
790
- makeArgs({
791
- body: { provider: "slack_channel", url: "/conversations.history" },
792
- }),
793
- )) as { ok: boolean; status: number; hint?: string };
794
- expect(result.ok).toBe(false);
795
- expect(result.status).toBe(401);
796
- expect(result.hint).toContain("slack bot credential");
797
- expect(result.hint).toContain("assistant channels get slack");
798
- expect(result.hint).not.toContain("oauth status");
799
- expect(result.hint).not.toContain("oauth connect");
800
- });
801
-
802
- test("403 as a person's OAuth integration keeps the OAuth recovery steps", async () => {
803
- mockResolveResponse = {
804
- status: 403,
805
- headers: { "content-type": "application/json" },
806
- body: { error: "forbidden" },
807
- };
808
- const result = (await getRoute("POST", "oauth/request").handler(
809
- makeArgs({
810
- body: { provider: "google", url: "https://api.google.com/v1/me" },
811
- }),
812
- )) as { ok: boolean; status: number; hint?: string };
813
- expect(result.status).toBe(403);
814
- expect(result.hint).toContain("assistant oauth status google");
815
- expect(result.hint).toContain("oauth connect");
816
- expect(result.hint).not.toContain("channels get");
739
+ )) as { ok: boolean; hint?: string };
740
+ expect(relative.ok).toBe(false);
741
+ expect(relative.hint).toContain('base URL "https://api.google.com"');
817
742
  });
818
743
 
819
744
  test("passes a pre-parsed string body through to the connection unchanged", async () => {
@@ -1155,84 +1080,6 @@ describe("POST oauth/request", () => {
1155
1080
  ]);
1156
1081
  });
1157
1082
 
1158
- test("attaches reconnect hint on 401 response", async () => {
1159
- mockResolveResponse = { status: 401, headers: {}, body: { error: "no" } };
1160
- const result = (await getRoute("POST", "oauth/request").handler(
1161
- makeArgs({
1162
- body: { provider: "google", url: "https://api.google.com/v1/me" },
1163
- }),
1164
- )) as { ok: boolean; hint?: string };
1165
- expect(result.ok).toBe(false);
1166
- expect(result.hint).toContain("oauth status");
1167
- });
1168
-
1169
- test("attaches wrong-host/path hint on HTML 404 for a relative path", async () => {
1170
- mockResolveResponse = {
1171
- status: 404,
1172
- headers: { "content-type": "text/html; charset=UTF-8" },
1173
- body: "<html><title>Error 404 (Not Found)</title></html>",
1174
- };
1175
- const result = (await getRoute("POST", "oauth/request").handler(
1176
- makeArgs({
1177
- body: {
1178
- provider: "google",
1179
- url: "/calendar/v3/calendars/primary/events",
1180
- },
1181
- }),
1182
- )) as { ok: boolean; status: number; hint?: string };
1183
- expect(result.ok).toBe(false);
1184
- expect(result.status).toBe(404);
1185
- expect(result.hint).toContain("HTML");
1186
- // Reports the resolved base URL the relative path was joined onto.
1187
- expect(result.hint).toContain("https://api.google.com");
1188
- // Steers the caller toward an absolute URL.
1189
- expect(result.hint).toContain("absolute URL");
1190
- });
1191
-
1192
- test("does not attach HTML-404 hint when a 404 body is JSON", async () => {
1193
- mockResolveResponse = {
1194
- status: 404,
1195
- headers: { "content-type": "application/json" },
1196
- body: { error: "not found" },
1197
- };
1198
- const result = (await getRoute("POST", "oauth/request").handler(
1199
- makeArgs({
1200
- body: { provider: "google", url: "/v1/missing" },
1201
- }),
1202
- )) as { ok: boolean; status: number; hint?: string };
1203
- expect(result.ok).toBe(false);
1204
- expect(result.status).toBe(404);
1205
- expect(result.hint).toBeUndefined();
1206
- });
1207
-
1208
- test("HTML-404 hint reports an absolute URL's own host as the resolved base", async () => {
1209
- mockProviders.google = {
1210
- ...baseProvider,
1211
- injectionTemplates: JSON.stringify([
1212
- {
1213
- hostPattern: "www.googleapis.com",
1214
- injectionType: "header",
1215
- headerName: "Authorization",
1216
- valuePrefix: "Bearer ",
1217
- },
1218
- ]),
1219
- };
1220
- mockResolveResponse = {
1221
- status: 404,
1222
- headers: { "content-type": "text/html" },
1223
- body: "<html>nope</html>",
1224
- };
1225
- const result = (await getRoute("POST", "oauth/request").handler(
1226
- makeArgs({
1227
- body: {
1228
- provider: "google",
1229
- url: "https://www.googleapis.com/calendar/v3/nope",
1230
- },
1231
- }),
1232
- )) as { hint?: string };
1233
- expect(result.hint).toContain("https://www.googleapis.com");
1234
- });
1235
-
1236
1083
  test("rejects unregistered client_id in BYO mode", async () => {
1237
1084
  // No entry in mockApps for google:client-x
1238
1085
  await expect(
@@ -301,6 +301,6 @@ The provider-level rate limiter (`providers/ratelimit.ts`) also logs warnings (m
301
301
 
302
302
  ## HTTP-Only Transport
303
303
 
304
- HTTP is the sole transport for client-daemon communication. The runtime HTTP server (`assistant/src/runtime/http-server.ts`) is the canonical API surface. Clients connect via HTTP for request/response operations and SSE (`GET /v1/events`) for streaming server-to-client events.
304
+ HTTP is the sole transport for browser and desktop clients; the CLI and the gateway reach the daemon over the IPC socket (see `assistant/AGENTS.md`). The runtime HTTP server (`assistant/src/runtime/http-server.ts`) is the canonical API surface. Clients connect via HTTP for request/response operations and SSE (`GET /v1/events`) for streaming server-to-client events.
305
305
 
306
306
  A skill never calls the runtime HTTP API directly: every request from a skill targets the gateway, and `gateway-only-guard.test.ts` fails CI on a runtime-port URL in a skill. For configuration reads, use the CLI (`assistant config get`); for control-plane writes with no CLI verb, use `$INTERNAL_GATEWAY_BASE_URL`. See "Gateway-Only API Consumption" in `gateway/AGENTS.md` for the rule and its exceptions.
@@ -58,6 +58,7 @@ import {
58
58
  } from "../../util/oauth-request-body.js";
59
59
  import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
60
60
  import { BadRequestError, InternalError, NotFoundError } from "./errors.js";
61
+ import { composeRequestHint } from "./oauth-request-hints.js";
61
62
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
62
63
 
63
64
  const log = getLogger("oauth-commands-routes");
@@ -1040,79 +1041,23 @@ export async function handleRequest({ body = {} }: RouteHandlerArgs) {
1040
1041
  `used "${selected}". Pass --account to select a specific one.`;
1041
1042
  }
1042
1043
 
1043
- const botChannel = channelForBotProvider(b.provider);
1044
- if (verdict.reportedFailure) {
1045
- // The body carries the provider's own error code, so the hint says only
1046
- // why a 2xx is being reported as a failure.
1047
- result.hint = `${verdict.reportedFailure} in the response body. The body names the error.`;
1048
- } else if (response.status === 403 && isHtmlResponse(response.headers)) {
1049
- // An API refuses with JSON. A 403 carrying an HTML page is a resource
1050
- // host (a file host, a sign-in page) refusing this identity: the same
1051
- // token is what the API accepts, and the resource is simply not visible
1052
- // to it. Blaming the credential sends the caller off to reconnect one
1053
- // that works.
1054
- const identity = botChannel ? `${botChannel} bot` : "connected account";
1055
- const requestHost = parseUrl(baseUrl ?? providerRow.baseUrl)?.hostname;
1056
- result.hint =
1057
- `Request returned HTTP 403 with an HTML page${requestHost ? ` from ${requestHost}` : ""}, not an API error. ` +
1058
- `That usually means the ${identity} cannot see this resource: it is not shared with it, or the scope it needs is missing. ` +
1059
- `Check the resource's access before treating the credential as revoked; ` +
1060
- (botChannel
1061
- ? `'assistant channels get ${botChannel}' reports the credential itself.`
1062
- : `'assistant oauth status ${b.provider}' reports the credential itself.`);
1063
- } else if (response.status === 401 || response.status === 403) {
1064
- // The recovery steps follow the credential's kind, not the door the
1065
- // request came through: a channel bot's token was stored by the channel's
1066
- // setup, so the OAuth status and connect commands cannot repair it.
1067
- result.hint = botChannel
1068
- ? `Request returned HTTP ${response.status}. The ${botChannel} bot credential was rejected; it may have been revoked or reinstalled with fewer scopes.\n\n` +
1069
- `Run 'assistant channels get ${botChannel}' to re-probe the channel and see what it reports.\n` +
1070
- `To reconnect, run the channel's setup skill again.`
1071
- : managed
1072
- ? `Request returned HTTP ${response.status}. The OAuth token may be expired or revoked.\n\n` +
1073
- `Run 'assistant oauth status ${b.provider}' to check connection health.\n` +
1074
- `To reconnect, run 'assistant oauth connect --help'.`
1075
- : `Request returned HTTP ${response.status}. The OAuth token may be expired or revoked.\n\n` +
1076
- `Run 'assistant oauth status ${b.provider}' to check connection status.\n` +
1077
- `To reconnect, run 'assistant oauth connect --help'.`;
1078
- } else if (response.status === 404 && isHtmlResponse(response.headers)) {
1079
- // An HTML 404 (rather than a JSON API error) is the signature of a request
1080
- // reaching a valid host but a path that host does not serve — e.g. a
1081
- // relative path resolved against a base URL that points at the wrong
1082
- // product. Surface the resolved base so the caller can tell where the path
1083
- // landed, and steer them to an absolute URL for non-default services.
1084
- const resolvedBaseUrl =
1085
- baseUrl ?? providerRow.baseUrl ?? "(none configured)";
1086
- result.hint =
1087
- `Request returned HTTP ${response.status} with an HTML body, which usually means ` +
1088
- `the path does not exist on the base URL it resolved against.\n\n` +
1089
- `This request used base URL "${resolvedBaseUrl}" (relative paths are joined onto it). ` +
1090
- `If you meant a different service on this provider, pass an absolute URL ` +
1091
- `(e.g. https://host/full/path) so the host and full path are set explicitly.`;
1092
- }
1093
-
1094
- const missingScopes = missingScopesForConnection(providerRow, connection.id);
1095
- if (missingScopes.length > 0) {
1096
- const scopeHint =
1097
- `The ${b.provider} connection is missing required scopes: ${missingScopes.join(", ")}. ` +
1098
- `It was connected before they were required, so calls that need them fail. ` +
1099
- `Reconnect it from Integrations, or run 'assistant oauth connect ${b.provider}', to grant them.`;
1100
- result.hint = result.hint ? `${scopeHint}\n\n${result.hint}` : scopeHint;
1044
+ const hint = composeRequestHint({
1045
+ provider: b.provider,
1046
+ status: response.status,
1047
+ headers: response.headers,
1048
+ reportedFailure: verdict.reportedFailure,
1049
+ botChannel: channelForBotProvider(b.provider),
1050
+ managed,
1051
+ resolvedBaseUrl: baseUrl ?? providerRow.baseUrl ?? undefined,
1052
+ missingScopes: missingScopesForConnection(providerRow, connection.id),
1053
+ });
1054
+ if (hint) {
1055
+ result.hint = hint;
1101
1056
  }
1102
1057
 
1103
1058
  return result;
1104
1059
  }
1105
1060
 
1106
- /** True when the response's Content-Type header indicates an HTML body. */
1107
- function isHtmlResponse(headers: Record<string, string>): boolean {
1108
- for (const [key, value] of Object.entries(headers)) {
1109
- if (key.toLowerCase() === "content-type") {
1110
- return value.toLowerCase().includes("text/html");
1111
- }
1112
- }
1113
- return false;
1114
- }
1115
-
1116
1061
  // ---------------------------------------------------------------------------
1117
1062
  // Connect handler (managed path for platform OAuth)
1118
1063
  // ---------------------------------------------------------------------------
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The request doors' hint, asserted on the composer directly. Every case
3
+ * pins the rendered text in full, so a wording change is a visible diff here
4
+ * and never a silent one behind a route test's substring match.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ import {
10
+ composeRequestHint,
11
+ REQUEST_HINT_RULES,
12
+ type RequestHintFacts,
13
+ } from "./oauth-request-hints.js";
14
+
15
+ const JSON_HEADERS = { "content-type": "application/json" };
16
+ const HTML_HEADERS = { "content-type": "text/html; charset=utf-8" };
17
+
18
+ function asBot(overrides: Partial<RequestHintFacts> = {}): RequestHintFacts {
19
+ return {
20
+ provider: "slack_channel",
21
+ status: 200,
22
+ headers: JSON_HEADERS,
23
+ botChannel: "slack",
24
+ managed: false,
25
+ resolvedBaseUrl: "https://slack.com/api",
26
+ missingScopes: [],
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function asPerson(overrides: Partial<RequestHintFacts> = {}): RequestHintFacts {
32
+ return {
33
+ provider: "google",
34
+ status: 200,
35
+ headers: JSON_HEADERS,
36
+ managed: false,
37
+ resolvedBaseUrl: "https://api.google.com",
38
+ missingScopes: [],
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ describe("composeRequestHint", () => {
44
+ test("the rules are applied in this precedence order", () => {
45
+ // The generic 401/403 rule matches every 403, so the HTML 403 rule has
46
+ // to be judged before it; a provider's own reported failure outranks
47
+ // every status. The list is the order, and this pins it.
48
+ expect(REQUEST_HINT_RULES.map((rule) => rule.name)).toEqual([
49
+ "provider reported failure",
50
+ "resource not visible",
51
+ "credential rejected",
52
+ "path not served",
53
+ ]);
54
+ });
55
+
56
+ test("a 2xx is silent", () => {
57
+ expect(composeRequestHint(asBot())).toBeUndefined();
58
+ expect(composeRequestHint(asPerson())).toBeUndefined();
59
+ });
60
+
61
+ test("a provider's reported failure names only why the 2xx failed", () => {
62
+ expect(
63
+ composeRequestHint(
64
+ asBot({
65
+ reportedFailure:
66
+ "slack_channel answered HTTP 200 but reported ok: false",
67
+ }),
68
+ ),
69
+ ).toBe(
70
+ "slack_channel answered HTTP 200 but reported ok: false in the response body. The body names the error.",
71
+ );
72
+ });
73
+
74
+ test("a reported failure outranks every status rule", () => {
75
+ expect(
76
+ composeRequestHint(
77
+ asBot({
78
+ status: 403,
79
+ headers: HTML_HEADERS,
80
+ reportedFailure:
81
+ "slack_channel answered HTTP 403 but reported ok: false",
82
+ }),
83
+ ),
84
+ ).toBe(
85
+ "slack_channel answered HTTP 403 but reported ok: false in the response body. The body names the error.",
86
+ );
87
+ });
88
+
89
+ test("an HTML 403 as a bot names the resource's access and the channel's diagnostics", () => {
90
+ expect(
91
+ composeRequestHint(
92
+ asBot({
93
+ status: 403,
94
+ headers: HTML_HEADERS,
95
+ resolvedBaseUrl: "https://files.slack.com",
96
+ }),
97
+ ),
98
+ ).toBe(
99
+ "Request returned HTTP 403 with an HTML page from files.slack.com, not an API error. " +
100
+ "That usually means the slack bot cannot see this resource: it is not shared with it, or the scope it needs is missing. " +
101
+ "Check the resource's access before treating the credential as revoked; " +
102
+ "'assistant channels get slack' reports the credential itself.",
103
+ );
104
+ });
105
+
106
+ test("an HTML 403 as a person names the connected account and oauth status", () => {
107
+ expect(
108
+ composeRequestHint(asPerson({ status: 403, headers: HTML_HEADERS })),
109
+ ).toBe(
110
+ "Request returned HTTP 403 with an HTML page from api.google.com, not an API error. " +
111
+ "That usually means the connected account cannot see this resource: it is not shared with it, or the scope it needs is missing. " +
112
+ "Check the resource's access before treating the credential as revoked; " +
113
+ "'assistant oauth status google' reports the credential itself.",
114
+ );
115
+ });
116
+
117
+ test("an HTML 403 with no resolved base omits the host", () => {
118
+ expect(
119
+ composeRequestHint(
120
+ asPerson({
121
+ status: 403,
122
+ headers: HTML_HEADERS,
123
+ resolvedBaseUrl: undefined,
124
+ }),
125
+ ),
126
+ ).toBe(
127
+ "Request returned HTTP 403 with an HTML page, not an API error. " +
128
+ "That usually means the connected account cannot see this resource: it is not shared with it, or the scope it needs is missing. " +
129
+ "Check the resource's access before treating the credential as revoked; " +
130
+ "'assistant oauth status google' reports the credential itself.",
131
+ );
132
+ });
133
+
134
+ test("a JSON 403 keeps the credential reading on any host", () => {
135
+ // An API's own refusal, on the base host or on a provider's other API
136
+ // hosts alike.
137
+ const onApiHost = composeRequestHint(asBot({ status: 403 }));
138
+ expect(onApiHost).toContain("slack bot credential was rejected");
139
+ expect(onApiHost).not.toContain("cannot see this resource");
140
+
141
+ const onOtherApiHost = composeRequestHint(
142
+ asPerson({
143
+ status: 403,
144
+ resolvedBaseUrl: "https://calendar.googleapis.com",
145
+ }),
146
+ );
147
+ expect(onOtherApiHost).toContain(
148
+ "The OAuth token may be expired or revoked",
149
+ );
150
+ expect(onOtherApiHost).not.toContain("cannot see this resource");
151
+ });
152
+
153
+ test("a rejected bot credential points at the channel's diagnostics, never the OAuth commands", () => {
154
+ expect(composeRequestHint(asBot({ status: 401 }))).toBe(
155
+ "Request returned HTTP 401. The slack bot credential was rejected; it may have been revoked or reinstalled with fewer scopes.\n\n" +
156
+ "Run 'assistant channels get slack' to re-probe the channel and see what it reports.\n" +
157
+ "To reconnect, run the channel's setup skill again.",
158
+ );
159
+ });
160
+
161
+ test("a rejected managed credential points at connection health", () => {
162
+ expect(composeRequestHint(asPerson({ status: 403, managed: true }))).toBe(
163
+ "Request returned HTTP 403. The OAuth token may be expired or revoked.\n\n" +
164
+ "Run 'assistant oauth status google' to check connection health.\n" +
165
+ "To reconnect, run 'assistant oauth connect --help'.",
166
+ );
167
+ });
168
+
169
+ test("a rejected your-own credential points at connection status", () => {
170
+ expect(composeRequestHint(asPerson({ status: 401 }))).toBe(
171
+ "Request returned HTTP 401. The OAuth token may be expired or revoked.\n\n" +
172
+ "Run 'assistant oauth status google' to check connection status.\n" +
173
+ "To reconnect, run 'assistant oauth connect --help'.",
174
+ );
175
+ });
176
+
177
+ test("an HTML 404 reports the base the path resolved against", () => {
178
+ expect(
179
+ composeRequestHint(asPerson({ status: 404, headers: HTML_HEADERS })),
180
+ ).toBe(
181
+ "Request returned HTTP 404 with an HTML body, which usually means " +
182
+ "the path does not exist on the base URL it resolved against.\n\n" +
183
+ 'This request used base URL "https://api.google.com" (relative paths are joined onto it). ' +
184
+ "If you meant a different service on this provider, pass an absolute URL " +
185
+ "(e.g. https://host/full/path) so the host and full path are set explicitly.",
186
+ );
187
+ });
188
+
189
+ test("an HTML 404 with no base says so", () => {
190
+ expect(
191
+ composeRequestHint(
192
+ asPerson({
193
+ status: 404,
194
+ headers: HTML_HEADERS,
195
+ resolvedBaseUrl: undefined,
196
+ }),
197
+ ),
198
+ ).toContain('This request used base URL "(none configured)"');
199
+ });
200
+
201
+ test("a JSON 404 and every other status are silent", () => {
202
+ expect(composeRequestHint(asPerson({ status: 404 }))).toBeUndefined();
203
+ expect(composeRequestHint(asPerson({ status: 429 }))).toBeUndefined();
204
+ expect(composeRequestHint(asPerson({ status: 500 }))).toBeUndefined();
205
+ });
206
+
207
+ test("the Content-Type check is case-insensitive on key and value", () => {
208
+ expect(
209
+ composeRequestHint(
210
+ asPerson({ status: 404, headers: { "Content-Type": "TEXT/HTML" } }),
211
+ ),
212
+ ).toContain("with an HTML body");
213
+ });
214
+
215
+ test("a missing-scope hint stands alone on a successful call", () => {
216
+ expect(
217
+ composeRequestHint(
218
+ asPerson({ provider: "slack", missingScopes: ["files:read"] }),
219
+ ),
220
+ ).toBe(
221
+ "The slack connection is missing required scopes: files:read. " +
222
+ "It was connected before they were required, so calls that need them fail. " +
223
+ "Reconnect it from Integrations, or run 'assistant oauth connect slack', to grant them.",
224
+ );
225
+ });
226
+
227
+ test("a missing-scope hint is prepended to the status hint", () => {
228
+ expect(
229
+ composeRequestHint(
230
+ asPerson({
231
+ provider: "slack",
232
+ status: 401,
233
+ missingScopes: ["files:read", "search:read"],
234
+ }),
235
+ ),
236
+ ).toBe(
237
+ "The slack connection is missing required scopes: files:read, search:read. " +
238
+ "It was connected before they were required, so calls that need them fail. " +
239
+ "Reconnect it from Integrations, or run 'assistant oauth connect slack', to grant them." +
240
+ "\n\n" +
241
+ "Request returned HTTP 401. The OAuth token may be expired or revoked.\n\n" +
242
+ "Run 'assistant oauth status slack' to check connection status.\n" +
243
+ "To reconnect, run 'assistant oauth connect --help'.",
244
+ );
245
+ });
246
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The hint the request doors attach to a provider response, composed from
3
+ * the response and the connection facts alone. The route gathers the facts;
4
+ * nothing here reads a store, a config, or a connection, so a hint's wording
5
+ * is assertable without booting the route.
6
+ *
7
+ * The status hints are an ordered list and the first rule that applies wins.
8
+ * The order is precedence, not coincidence: a provider's own reported failure
9
+ * outranks every status, and an HTML 403 must be judged before the generic
10
+ * 401/403 rule or it is never reached. The missing-scope hint is not a rule
11
+ * in the list; it is prepended to whichever hint applied, or stands alone.
12
+ */
13
+
14
+ export interface RequestHintFacts {
15
+ /** The provider key the request was made under. */
16
+ provider: string;
17
+ status: number;
18
+ headers: Record<string, string>;
19
+ /**
20
+ * The provider's own one-line account of a refusal inside a 2xx, from its
21
+ * declared ok field; absent when the status is the whole verdict.
22
+ */
23
+ reportedFailure?: string;
24
+ /**
25
+ * The channel whose bot credential served the request. Absent when the
26
+ * request acted as a person through their OAuth integration.
27
+ */
28
+ botChannel?: string;
29
+ /** The provider runs in platform-managed mode. */
30
+ managed: boolean;
31
+ /**
32
+ * The base URL the request resolved against: an absolute URL's own origin,
33
+ * otherwise the provider's configured base. Absent when neither is set.
34
+ */
35
+ resolvedBaseUrl?: string;
36
+ /** Required scopes the resolved connection's stored grant lacks. */
37
+ missingScopes: readonly string[];
38
+ }
39
+
40
+ interface RequestHintRule {
41
+ name: string;
42
+ applies(facts: RequestHintFacts): boolean;
43
+ render(facts: RequestHintFacts): string;
44
+ }
45
+
46
+ /** The status hints in precedence order; the first rule that applies wins. */
47
+ export const REQUEST_HINT_RULES: readonly RequestHintRule[] = [
48
+ {
49
+ // The body carries the provider's own error code, so the hint says only
50
+ // why a 2xx is being reported as a failure.
51
+ name: "provider reported failure",
52
+ applies: (facts) => facts.reportedFailure !== undefined,
53
+ render: (facts) =>
54
+ `${facts.reportedFailure} in the response body. The body names the error.`,
55
+ },
56
+ {
57
+ // An API refuses with JSON. A 403 carrying an HTML page is a resource
58
+ // host (a file host, a sign-in page) refusing this identity: the same
59
+ // token is what the API accepts, and the resource is simply not visible
60
+ // to it. Blaming the credential sends the caller off to reconnect one
61
+ // that works.
62
+ name: "resource not visible",
63
+ applies: (facts) => facts.status === 403 && isHtmlResponse(facts.headers),
64
+ render: (facts) => {
65
+ const identity = facts.botChannel
66
+ ? `${facts.botChannel} bot`
67
+ : "connected account";
68
+ const requestHost = hostnameOf(facts.resolvedBaseUrl);
69
+ return (
70
+ `Request returned HTTP 403 with an HTML page${requestHost ? ` from ${requestHost}` : ""}, not an API error. ` +
71
+ `That usually means the ${identity} cannot see this resource: it is not shared with it, or the scope it needs is missing. ` +
72
+ `Check the resource's access before treating the credential as revoked; ` +
73
+ (facts.botChannel
74
+ ? `'assistant channels get ${facts.botChannel}' reports the credential itself.`
75
+ : `'assistant oauth status ${facts.provider}' reports the credential itself.`)
76
+ );
77
+ },
78
+ },
79
+ {
80
+ // The recovery steps follow the credential's kind, not the door the
81
+ // request came through: a channel bot's token was stored by the channel's
82
+ // setup, so the OAuth status and connect commands cannot repair it.
83
+ name: "credential rejected",
84
+ applies: (facts) => facts.status === 401 || facts.status === 403,
85
+ render: (facts) =>
86
+ facts.botChannel
87
+ ? `Request returned HTTP ${facts.status}. The ${facts.botChannel} bot credential was rejected; it may have been revoked or reinstalled with fewer scopes.\n\n` +
88
+ `Run 'assistant channels get ${facts.botChannel}' to re-probe the channel and see what it reports.\n` +
89
+ `To reconnect, run the channel's setup skill again.`
90
+ : facts.managed
91
+ ? `Request returned HTTP ${facts.status}. The OAuth token may be expired or revoked.\n\n` +
92
+ `Run 'assistant oauth status ${facts.provider}' to check connection health.\n` +
93
+ `To reconnect, run 'assistant oauth connect --help'.`
94
+ : `Request returned HTTP ${facts.status}. The OAuth token may be expired or revoked.\n\n` +
95
+ `Run 'assistant oauth status ${facts.provider}' to check connection status.\n` +
96
+ `To reconnect, run 'assistant oauth connect --help'.`,
97
+ },
98
+ {
99
+ // An HTML 404 (rather than a JSON API error) is the signature of a request
100
+ // reaching a valid host but a path that host does not serve, such as a
101
+ // relative path resolved against a base URL that points at the wrong
102
+ // product. Surface the resolved base so the caller can tell where the path
103
+ // landed, and steer them to an absolute URL for non-default services.
104
+ name: "path not served",
105
+ applies: (facts) => facts.status === 404 && isHtmlResponse(facts.headers),
106
+ render: (facts) =>
107
+ `Request returned HTTP ${facts.status} with an HTML body, which usually means ` +
108
+ `the path does not exist on the base URL it resolved against.\n\n` +
109
+ `This request used base URL "${facts.resolvedBaseUrl ?? "(none configured)"}" (relative paths are joined onto it). ` +
110
+ `If you meant a different service on this provider, pass an absolute URL ` +
111
+ `(e.g. https://host/full/path) so the host and full path are set explicitly.`,
112
+ },
113
+ ];
114
+
115
+ /**
116
+ * The hint for one provider response, or undefined when nothing applies.
117
+ * A missing-scope hint is prepended to the status hint, or stands alone: a
118
+ * connection made before a scope was required keeps working for every call
119
+ * that does not need it, so the gap is named on every request.
120
+ */
121
+ export function composeRequestHint(
122
+ facts: RequestHintFacts,
123
+ ): string | undefined {
124
+ const hint = REQUEST_HINT_RULES.find((rule) => rule.applies(facts))?.render(
125
+ facts,
126
+ );
127
+ if (facts.missingScopes.length === 0) {
128
+ return hint;
129
+ }
130
+ const scopeHint =
131
+ `The ${facts.provider} connection is missing required scopes: ${facts.missingScopes.join(", ")}. ` +
132
+ `It was connected before they were required, so calls that need them fail. ` +
133
+ `Reconnect it from Integrations, or run 'assistant oauth connect ${facts.provider}', to grant them.`;
134
+ return hint ? `${scopeHint}\n\n${hint}` : scopeHint;
135
+ }
136
+
137
+ /** True when the response's Content-Type header indicates an HTML body. */
138
+ function isHtmlResponse(headers: Record<string, string>): boolean {
139
+ for (const [key, value] of Object.entries(headers)) {
140
+ if (key.toLowerCase() === "content-type") {
141
+ return value.toLowerCase().includes("text/html");
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function hostnameOf(url: string | undefined): string | undefined {
148
+ return url && URL.canParse(url) ? new URL(url).hostname : undefined;
149
+ }