@agentshouse/kit 0.0.1 → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1696 -2
- package/bin/connect-linux.sh +127 -0
- package/bin/kit.js +1023 -0
- package/package.json +33 -3
- package/src/acp.js +99 -0
- package/src/adapter-mcp.js +44 -0
- package/src/adapter.js +252 -0
- package/src/agent-logins.js +65 -0
- package/src/agents.js +449 -0
- package/src/apply.js +29 -0
- package/src/attach.js +52 -0
- package/src/binding.js +90 -0
- package/src/blocks.js +156 -0
- package/src/bridge.js +449 -0
- package/src/bytes.js +89 -0
- package/src/channel.js +37 -0
- package/src/command.js +109 -0
- package/src/configuration.js +465 -0
- package/src/connector-login.js +91 -0
- package/src/connector.js +107 -0
- package/src/continuation.js +61 -0
- package/src/contract.js +363 -0
- package/src/conversation-originals.js +143 -0
- package/src/custom/authorization.js +63 -0
- package/src/declaration.js +35 -0
- package/src/delivery.js +191 -0
- package/src/directory.js +102 -0
- package/src/draft.js +89 -0
- package/src/google/calendar.js +177 -0
- package/src/google/declared.js +146 -0
- package/src/google/drive.js +435 -0
- package/src/google/gateway.js +166 -0
- package/src/google/gmail.js +249 -0
- package/src/google/id.js +1 -0
- package/src/google/oauth.js +273 -0
- package/src/google/source.js +25 -0
- package/src/hook.js +376 -0
- package/src/host.js +121 -0
- package/src/house-main.js +79 -0
- package/src/house.js +1085 -0
- package/src/inbound.js +175 -0
- package/src/instance/google.js +1 -0
- package/src/launcher-main.js +19 -0
- package/src/launcher.js +276 -0
- package/src/local-files.js +113 -0
- package/src/login.js +274 -0
- package/src/mcp.js +71 -0
- package/src/migrator.js +248 -0
- package/src/mount.js +582 -0
- package/src/operation.js +20 -0
- package/src/original.js +631 -0
- package/src/peer.js +124 -0
- package/src/plan.js +57 -0
- package/src/question.js +316 -0
- package/src/refusal.js +79 -0
- package/src/repository.js +521 -0
- package/src/session-bin/house +2 -0
- package/src/settlement.js +57 -0
- package/src/setup.js +185 -0
- package/src/source-originals.js +116 -0
- package/src/source.js +286 -0
- package/src/starter-set/grilling/LICENSE +21 -0
- package/src/starter-set/grilling/SKILL.md +30 -0
- package/src/starter-set/grilling/agents/openai.yaml +3 -0
- package/src/starter-set.js +17 -0
- package/src/state.js +157 -0
- package/src/sync-library.js +293 -0
- package/src/sync-state.js +116 -0
- package/src/sync.js +477 -0
- package/src/telegram.js +1045 -0
- package/src/terminal.js +57 -0
- package/src/tmux.js +119 -0
- package/src/worker.js +756 -0
- package/src/workspace.js +500 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { googleAccessToken, revokeGoogleAuthorization } from "./oauth.js";
|
|
2
|
+
import { KitRefusal } from "../refusal.js";
|
|
3
|
+
|
|
4
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
5
|
+
const RETRYABLE_REASONS = new Set(["rateLimitExceeded", "userRateLimitExceeded"]);
|
|
6
|
+
const RETRY_LIMIT = 3;
|
|
7
|
+
const RETRY_DELAY_MAX_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
function retryDelay(response, now) {
|
|
10
|
+
const declared = response.headers.get("retry-after");
|
|
11
|
+
if (declared === null) return 1000;
|
|
12
|
+
if (/^\d+$/.test(declared)) return Math.min(RETRY_DELAY_MAX_MS, Number(declared) * 1000);
|
|
13
|
+
const at = Date.parse(declared);
|
|
14
|
+
return Number.isNaN(at)
|
|
15
|
+
? 1000
|
|
16
|
+
: Math.min(RETRY_DELAY_MAX_MS, Math.max(0, at - now().getTime()));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function abortableSleep(milliseconds, signal) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
if (signal?.aborted === true) {
|
|
22
|
+
reject(new KitRefusal("kit_command_cancelled"));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const timer = setTimeout(finish, milliseconds);
|
|
26
|
+
function finish() {
|
|
27
|
+
signal?.removeEventListener("abort", cancel);
|
|
28
|
+
resolve();
|
|
29
|
+
}
|
|
30
|
+
function cancel() {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
signal.removeEventListener("abort", cancel);
|
|
33
|
+
reject(new KitRefusal("kit_command_cancelled"));
|
|
34
|
+
}
|
|
35
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function failureReasons(response) {
|
|
40
|
+
let answer = null;
|
|
41
|
+
try {
|
|
42
|
+
answer = await response.clone().json();
|
|
43
|
+
} catch {
|
|
44
|
+
answer = null;
|
|
45
|
+
}
|
|
46
|
+
const failures = answer?.error?.errors;
|
|
47
|
+
if (!Array.isArray(failures)) return [];
|
|
48
|
+
return failures.flatMap((failure) =>
|
|
49
|
+
typeof failure?.reason === "string" ? [failure.reason] : [],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function googleResponse(options, dependencies) {
|
|
54
|
+
const fetcher = dependencies.fetch ?? fetch;
|
|
55
|
+
const now = dependencies.now ?? (() => new Date());
|
|
56
|
+
const sleep = dependencies.sleep ?? abortableSleep;
|
|
57
|
+
let accessToken = await googleAccessToken(options, { fetch: fetcher, now });
|
|
58
|
+
let refreshedAfterUnauthorized = false;
|
|
59
|
+
for (let attempt = 0; attempt < RETRY_LIMIT; attempt += 1) {
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetcher(options.url, {
|
|
63
|
+
method: options.method ?? "GET",
|
|
64
|
+
headers: {
|
|
65
|
+
authorization: `Bearer ${accessToken}`,
|
|
66
|
+
...(options.headers ?? {}),
|
|
67
|
+
},
|
|
68
|
+
signal: options.signal,
|
|
69
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (options.signal?.aborted === true || error?.name === "AbortError") {
|
|
73
|
+
throw new KitRefusal("kit_command_cancelled");
|
|
74
|
+
}
|
|
75
|
+
throw new KitRefusal("google_provider_unavailable");
|
|
76
|
+
}
|
|
77
|
+
if (response.status === 401) {
|
|
78
|
+
if (!refreshedAfterUnauthorized) {
|
|
79
|
+
accessToken = await googleAccessToken(
|
|
80
|
+
{ ...options, forceRefresh: true },
|
|
81
|
+
{ fetch: fetcher, now },
|
|
82
|
+
);
|
|
83
|
+
refreshedAfterUnauthorized = true;
|
|
84
|
+
attempt -= 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
await revokeGoogleAuthorization(options.directory, options.instance, options.requiredScopes);
|
|
88
|
+
throw new KitRefusal("google_authority_revoked");
|
|
89
|
+
}
|
|
90
|
+
if (response.status === 403) {
|
|
91
|
+
const reasons = await failureReasons(response);
|
|
92
|
+
if (reasons.some((reason) => RETRYABLE_REASONS.has(reason))) {
|
|
93
|
+
if (attempt + 1 < RETRY_LIMIT) {
|
|
94
|
+
await sleep(retryDelay(response, now), options.signal);
|
|
95
|
+
if (options.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
throw new KitRefusal("google_provider_unavailable");
|
|
99
|
+
}
|
|
100
|
+
if (reasons.includes("insufficientPermissions")) {
|
|
101
|
+
await revokeGoogleAuthorization(
|
|
102
|
+
options.directory,
|
|
103
|
+
options.instance,
|
|
104
|
+
options.requiredScopes,
|
|
105
|
+
);
|
|
106
|
+
throw new KitRefusal("google_scope_revoked");
|
|
107
|
+
}
|
|
108
|
+
throw new KitRefusal("google_provider_unavailable");
|
|
109
|
+
}
|
|
110
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt + 1 < RETRY_LIMIT) {
|
|
111
|
+
await sleep(retryDelay(response, now), options.signal);
|
|
112
|
+
if (options.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (response.status === 404 && typeof options.notFoundCode === "string") {
|
|
116
|
+
throw new KitRefusal(options.notFoundCode);
|
|
117
|
+
}
|
|
118
|
+
if (response.status === 410 && typeof options.goneCode === "string") {
|
|
119
|
+
throw new KitRefusal(options.goneCode);
|
|
120
|
+
}
|
|
121
|
+
if (!response.ok) throw new KitRefusal("google_provider_unavailable");
|
|
122
|
+
return response;
|
|
123
|
+
}
|
|
124
|
+
throw new KitRefusal("google_provider_unavailable");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function googleJson(options, dependencies = {}) {
|
|
128
|
+
const response = await googleResponse(options, dependencies);
|
|
129
|
+
let answer = null;
|
|
130
|
+
try {
|
|
131
|
+
answer = await response.json();
|
|
132
|
+
} catch {
|
|
133
|
+
answer = null;
|
|
134
|
+
}
|
|
135
|
+
if (typeof answer !== "object" || answer === null || Array.isArray(answer)) {
|
|
136
|
+
throw new KitRefusal("google_provider_response_invalid");
|
|
137
|
+
}
|
|
138
|
+
return answer;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function googleBoundedText(options, limit, dependencies = {}) {
|
|
142
|
+
const response = await googleResponse(options, dependencies);
|
|
143
|
+
const declaredLength = response.headers.get("content-length");
|
|
144
|
+
if (declaredLength !== null && /^\d+$/.test(declaredLength) && Number(declaredLength) > limit) {
|
|
145
|
+
throw new KitRefusal("google_content_exceeded");
|
|
146
|
+
}
|
|
147
|
+
if (response.body === null) return "";
|
|
148
|
+
const reader = response.body.getReader();
|
|
149
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
let text = "";
|
|
152
|
+
try {
|
|
153
|
+
for (;;) {
|
|
154
|
+
const part = await reader.read();
|
|
155
|
+
if (part.done) break;
|
|
156
|
+
bytes += part.value.byteLength;
|
|
157
|
+
if (bytes > limit) throw new KitRefusal("google_content_exceeded");
|
|
158
|
+
text += decoder.decode(part.value, { stream: true });
|
|
159
|
+
}
|
|
160
|
+
return text + decoder.decode();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await reader.cancel().catch(() => undefined);
|
|
163
|
+
if (error instanceof KitRefusal) throw error;
|
|
164
|
+
throw new KitRefusal("google_provider_response_invalid");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { ITEM_BODY_MAX_BYTES } from "../contract.js";
|
|
2
|
+
import { KitRefusal } from "../refusal.js";
|
|
3
|
+
import { googleJson } from "./gateway.js";
|
|
4
|
+
import { GOOGLE_SCOPES } from "./oauth.js";
|
|
5
|
+
|
|
6
|
+
const GMAIL_ORIGIN = "https://gmail.googleapis.com";
|
|
7
|
+
|
|
8
|
+
function cursorOf(value) {
|
|
9
|
+
if (value === null) return { mode: "messages", pageToken: null, historyId: null };
|
|
10
|
+
let cursor;
|
|
11
|
+
try {
|
|
12
|
+
cursor = JSON.parse(value);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new KitRefusal("google_checkpoint_invalid", "gmail");
|
|
15
|
+
}
|
|
16
|
+
if (
|
|
17
|
+
typeof cursor !== "object" ||
|
|
18
|
+
cursor === null ||
|
|
19
|
+
(cursor.mode !== "messages" && cursor.mode !== "history") ||
|
|
20
|
+
(cursor.pageToken !== null && typeof cursor.pageToken !== "string") ||
|
|
21
|
+
(cursor.historyId !== null && typeof cursor.historyId !== "string") ||
|
|
22
|
+
(cursor.mode === "history" && cursor.historyId === null)
|
|
23
|
+
) {
|
|
24
|
+
throw new KitRefusal("google_checkpoint_invalid", "gmail");
|
|
25
|
+
}
|
|
26
|
+
return cursor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function textBody(part) {
|
|
30
|
+
if (part?.mimeType === "text/plain" && typeof part.body?.data === "string") {
|
|
31
|
+
try {
|
|
32
|
+
return Buffer.from(part.body.data, "base64url").toString("utf8");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(part?.parts)) return null;
|
|
38
|
+
for (const child of part.parts) {
|
|
39
|
+
const body = textBody(child);
|
|
40
|
+
if (body !== null) return body;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function header(message, name) {
|
|
46
|
+
if (!Array.isArray(message.payload?.headers)) return null;
|
|
47
|
+
const found = message.payload.headers.find(
|
|
48
|
+
(entry) => typeof entry?.name === "string" && entry.name.toLowerCase() === name,
|
|
49
|
+
);
|
|
50
|
+
return typeof found?.value === "string" ? found.value : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizedMessage(message) {
|
|
54
|
+
if (
|
|
55
|
+
typeof message?.id !== "string" ||
|
|
56
|
+
typeof message.historyId !== "string" ||
|
|
57
|
+
!/^\d+$/.test(message.historyId) ||
|
|
58
|
+
typeof message.internalDate !== "string" ||
|
|
59
|
+
!/^\d+$/.test(message.internalDate) ||
|
|
60
|
+
!Array.isArray(message.labelIds) ||
|
|
61
|
+
message.labelIds.some((label) => typeof label !== "string")
|
|
62
|
+
) {
|
|
63
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail message");
|
|
64
|
+
}
|
|
65
|
+
const observedAt = new Date(Number(message.internalDate));
|
|
66
|
+
if (Number.isNaN(observedAt.getTime())) {
|
|
67
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail internalDate");
|
|
68
|
+
}
|
|
69
|
+
const author = header(message, "from");
|
|
70
|
+
const subject = header(message, "subject");
|
|
71
|
+
const plain = textBody(message.payload);
|
|
72
|
+
const body = plain ?? (typeof message.snippet === "string" ? message.snippet : "");
|
|
73
|
+
const content = subject === null || body.startsWith(subject) ? body : `${subject}\n\n${body}`;
|
|
74
|
+
if (Buffer.byteLength(content, "utf8") > ITEM_BODY_MAX_BYTES) {
|
|
75
|
+
throw new KitRefusal("google_gmail_content_exceeded");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
namespace: "google/gmail",
|
|
79
|
+
nativeObjectId: message.id,
|
|
80
|
+
nativeVersion: message.historyId,
|
|
81
|
+
providerModifiedAt: observedAt.toISOString(),
|
|
82
|
+
observedAt: observedAt.toISOString(),
|
|
83
|
+
body: content,
|
|
84
|
+
author,
|
|
85
|
+
url: `https://mail.google.com/mail/u/0/#all/${encodeURIComponent(message.id)}`,
|
|
86
|
+
attachments: [],
|
|
87
|
+
container: `google/gmail/${message.labelIds[0] ?? "mail"}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function currentHistoryId(common, dependencies) {
|
|
92
|
+
const profile = await googleJson(
|
|
93
|
+
{ ...common, url: `${GMAIL_ORIGIN}/gmail/v1/users/me/profile` },
|
|
94
|
+
dependencies,
|
|
95
|
+
);
|
|
96
|
+
if (typeof profile.historyId !== "string" || !/^\d+$/.test(profile.historyId)) {
|
|
97
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail profile");
|
|
98
|
+
}
|
|
99
|
+
return profile.historyId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listUrl(selection, cursor, limit, backfillFloor) {
|
|
103
|
+
const url = new URL(
|
|
104
|
+
cursor.mode === "messages"
|
|
105
|
+
? "/gmail/v1/users/me/messages"
|
|
106
|
+
: "/gmail/v1/users/me/history",
|
|
107
|
+
GMAIL_ORIGIN,
|
|
108
|
+
);
|
|
109
|
+
url.searchParams.set("maxResults", `${limit}`);
|
|
110
|
+
if (cursor.pageToken !== null) url.searchParams.set("pageToken", cursor.pageToken);
|
|
111
|
+
if (cursor.mode === "messages") {
|
|
112
|
+
for (const label of selection.labels) url.searchParams.append("labelIds", label);
|
|
113
|
+
if (backfillFloor !== null && backfillFloor !== undefined) {
|
|
114
|
+
url.searchParams.set("q", `after:${Math.floor(Date.parse(backfillFloor) / 1000)}`);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
url.searchParams.set("startHistoryId", cursor.historyId);
|
|
118
|
+
url.searchParams.append("historyTypes", "messageAdded");
|
|
119
|
+
url.searchParams.append("historyTypes", "labelAdded");
|
|
120
|
+
}
|
|
121
|
+
return url.toString();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function referencesOf(answer, mode) {
|
|
125
|
+
if (mode === "messages") {
|
|
126
|
+
const messages = answer.messages ?? [];
|
|
127
|
+
if (!Array.isArray(messages) || messages.some((entry) => typeof entry?.id !== "string")) {
|
|
128
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail messages");
|
|
129
|
+
}
|
|
130
|
+
return messages.map((entry) => entry.id);
|
|
131
|
+
}
|
|
132
|
+
const history = answer.history ?? [];
|
|
133
|
+
if (!Array.isArray(history)) throw new KitRefusal("google_provider_response_invalid", "gmail history");
|
|
134
|
+
const identifiers = [];
|
|
135
|
+
for (const entry of history) {
|
|
136
|
+
const groups = [entry?.messagesAdded ?? [], entry?.labelsAdded ?? []];
|
|
137
|
+
if (groups.some((group) => !Array.isArray(group))) {
|
|
138
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail history entry");
|
|
139
|
+
}
|
|
140
|
+
for (const group of groups) {
|
|
141
|
+
for (const added of group) {
|
|
142
|
+
if (typeof added?.message?.id !== "string") {
|
|
143
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail message id");
|
|
144
|
+
}
|
|
145
|
+
identifiers.push(added.message.id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return [...new Set(identifiers)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function gmailPage(options, dependencies = {}) {
|
|
153
|
+
let cursor = cursorOf(options.cursor);
|
|
154
|
+
const common = {
|
|
155
|
+
directory: options.directory,
|
|
156
|
+
instance: options.instance,
|
|
157
|
+
client: options.client,
|
|
158
|
+
requiredScopes: [GOOGLE_SCOPES.gmail],
|
|
159
|
+
signal: options.signal,
|
|
160
|
+
};
|
|
161
|
+
if (cursor.mode === "messages" && cursor.historyId === null) {
|
|
162
|
+
cursor = { ...cursor, historyId: await currentHistoryId(common, dependencies) };
|
|
163
|
+
}
|
|
164
|
+
let answer;
|
|
165
|
+
try {
|
|
166
|
+
answer = await googleJson(
|
|
167
|
+
{
|
|
168
|
+
...common,
|
|
169
|
+
url: listUrl(options.selection, cursor, options.limit, options.backfillFloor),
|
|
170
|
+
...(cursor.mode === "history" ? { notFoundCode: "google_history_expired" } : {}),
|
|
171
|
+
},
|
|
172
|
+
dependencies,
|
|
173
|
+
);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!(error instanceof KitRefusal) || error.code !== "google_history_expired") throw error;
|
|
176
|
+
cursor = {
|
|
177
|
+
mode: "messages",
|
|
178
|
+
pageToken: null,
|
|
179
|
+
historyId: await currentHistoryId(common, dependencies),
|
|
180
|
+
};
|
|
181
|
+
answer = await googleJson(
|
|
182
|
+
{ ...common, url: listUrl(options.selection, cursor, options.limit, options.backfillFloor) },
|
|
183
|
+
dependencies,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
(answer.nextPageToken !== undefined && typeof answer.nextPageToken !== "string") ||
|
|
188
|
+
(answer.historyId !== undefined && typeof answer.historyId !== "string")
|
|
189
|
+
) {
|
|
190
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail cursor");
|
|
191
|
+
}
|
|
192
|
+
const identifiers = referencesOf(answer, cursor.mode);
|
|
193
|
+
const items = [];
|
|
194
|
+
const dropped = [];
|
|
195
|
+
for (const identifier of identifiers) {
|
|
196
|
+
let detail;
|
|
197
|
+
try {
|
|
198
|
+
detail = await googleJson(
|
|
199
|
+
{
|
|
200
|
+
...common,
|
|
201
|
+
notFoundCode: "google_gmail_item_missing",
|
|
202
|
+
url: `${GMAIL_ORIGIN}/gmail/v1/users/me/messages/${encodeURIComponent(identifier)}?format=full`,
|
|
203
|
+
},
|
|
204
|
+
dependencies,
|
|
205
|
+
);
|
|
206
|
+
const normalized = normalizedMessage(detail);
|
|
207
|
+
if (options.selection.labels.every((label) => detail.labelIds.includes(label))) {
|
|
208
|
+
items.push(normalized);
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (
|
|
212
|
+
error instanceof KitRefusal &&
|
|
213
|
+
["google_gmail_item_missing", "google_gmail_content_exceeded"].includes(error.code)
|
|
214
|
+
) {
|
|
215
|
+
dropped.push({
|
|
216
|
+
namespace: "google/gmail",
|
|
217
|
+
nativeObjectId: identifier,
|
|
218
|
+
nativeVersion: typeof detail?.historyId === "string" ? detail.historyId : null,
|
|
219
|
+
code: error.code,
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let next;
|
|
227
|
+
if (cursor.mode === "messages" && answer.nextPageToken !== undefined) {
|
|
228
|
+
next = { mode: "messages", pageToken: answer.nextPageToken, historyId: cursor.historyId };
|
|
229
|
+
} else if (cursor.mode === "messages") {
|
|
230
|
+
next = {
|
|
231
|
+
mode: "history",
|
|
232
|
+
pageToken: null,
|
|
233
|
+
historyId: cursor.historyId,
|
|
234
|
+
};
|
|
235
|
+
} else if (answer.nextPageToken !== undefined) {
|
|
236
|
+
next = { mode: "history", pageToken: answer.nextPageToken, historyId: cursor.historyId };
|
|
237
|
+
} else {
|
|
238
|
+
if (typeof answer.historyId !== "string" || !/^\d+$/.test(answer.historyId)) {
|
|
239
|
+
throw new KitRefusal("google_provider_response_invalid", "gmail history id");
|
|
240
|
+
}
|
|
241
|
+
next = { mode: "history", pageToken: null, historyId: answer.historyId };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
items,
|
|
245
|
+
dropped,
|
|
246
|
+
cursor: JSON.stringify(next),
|
|
247
|
+
more: cursor.mode === "messages" || answer.nextPageToken !== undefined,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/src/google/id.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const GOOGLE_DRIVE_ID = /^[A-Za-z0-9_-]{1,512}$/;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { KitRefusal } from "../refusal.js";
|
|
5
|
+
import { writeNote } from "../state.js";
|
|
6
|
+
|
|
7
|
+
export const GOOGLE_SCOPES = Object.freeze({
|
|
8
|
+
gmail: "https://www.googleapis.com/auth/gmail.readonly",
|
|
9
|
+
calendar: "https://www.googleapis.com/auth/calendar.readonly",
|
|
10
|
+
drive: "https://www.googleapis.com/auth/drive.readonly",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
14
|
+
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
15
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
16
|
+
const LOOPBACK_PORT_FLOOR = 49152;
|
|
17
|
+
const LOOPBACK_PORT_SPAN = 16384;
|
|
18
|
+
|
|
19
|
+
function credentialPath(directory, instance) {
|
|
20
|
+
return join(directory, "state", instance, "credentials", "google.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function rawAuthorization(directory, instance) {
|
|
24
|
+
const path = credentialPath(directory, instance);
|
|
25
|
+
let document;
|
|
26
|
+
try {
|
|
27
|
+
document = JSON.parse(await readFile(path, "utf8"));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === "ENOENT") return null;
|
|
30
|
+
throw new KitRefusal("google_authorization_unreadable", instance);
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
typeof document !== "object" ||
|
|
34
|
+
document === null ||
|
|
35
|
+
!Array.isArray(document.grants) ||
|
|
36
|
+
document.grants.some(
|
|
37
|
+
(grant) =>
|
|
38
|
+
typeof grant !== "object" ||
|
|
39
|
+
grant === null ||
|
|
40
|
+
typeof grant.access_token !== "string" ||
|
|
41
|
+
typeof grant.refresh_token !== "string" ||
|
|
42
|
+
typeof grant.expires_at !== "string" ||
|
|
43
|
+
Number.isNaN(Date.parse(grant.expires_at)) ||
|
|
44
|
+
!Array.isArray(grant.scopes) ||
|
|
45
|
+
grant.scopes.length === 0 ||
|
|
46
|
+
grant.scopes.some((scope) => typeof scope !== "string"),
|
|
47
|
+
)
|
|
48
|
+
) {
|
|
49
|
+
throw new KitRefusal("google_authorization_unreadable", instance);
|
|
50
|
+
}
|
|
51
|
+
return document;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function writeAuthorization(directory, instance, authorization) {
|
|
55
|
+
await writeNote(credentialPath(directory, instance), authorization);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function googleAuthorization(directory, instance) {
|
|
59
|
+
const authorization = await rawAuthorization(directory, instance);
|
|
60
|
+
if (authorization === null || authorization.grants.length === 0) return null;
|
|
61
|
+
return {
|
|
62
|
+
scopes: [...new Set(authorization.grants.flatMap((grant) => grant.scopes))].sort(),
|
|
63
|
+
grants: authorization.grants.map((grant) => ({
|
|
64
|
+
accessToken: grant.access_token,
|
|
65
|
+
refreshToken: grant.refresh_token,
|
|
66
|
+
expiresAt: grant.expires_at,
|
|
67
|
+
scopes: [...grant.scopes],
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requiredScopesOf(value) {
|
|
73
|
+
if (
|
|
74
|
+
!Array.isArray(value) ||
|
|
75
|
+
value.length === 0 ||
|
|
76
|
+
value.some((scope) => typeof scope !== "string" || scope.length === 0)
|
|
77
|
+
) {
|
|
78
|
+
throw new KitRefusal("google_scope_requirement_invalid");
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function revokeGoogleAuthorization(directory, instance, scopes) {
|
|
84
|
+
const requiredScopes = requiredScopesOf(scopes);
|
|
85
|
+
const authorization = await rawAuthorization(directory, instance);
|
|
86
|
+
if (authorization !== null) {
|
|
87
|
+
await writeAuthorization(directory, instance, {
|
|
88
|
+
grants: authorization.grants
|
|
89
|
+
.map((grant) => ({
|
|
90
|
+
...grant,
|
|
91
|
+
scopes: grant.scopes.filter((scope) => !requiredScopes.includes(scope)),
|
|
92
|
+
}))
|
|
93
|
+
.filter((grant) => grant.scopes.length > 0),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function oauthValue() {
|
|
99
|
+
return randomBytes(32).toString("base64url");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function authorizationUrl({ clientId, scopes, redirectUri, verifier, state }) {
|
|
103
|
+
const url = new URL(AUTHORIZATION_ENDPOINT);
|
|
104
|
+
url.searchParams.set("client_id", clientId);
|
|
105
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
106
|
+
url.searchParams.set("response_type", "code");
|
|
107
|
+
url.searchParams.set("scope", scopes.join(" "));
|
|
108
|
+
url.searchParams.set("access_type", "offline");
|
|
109
|
+
url.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url"));
|
|
110
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
111
|
+
url.searchParams.set("state", state);
|
|
112
|
+
url.searchParams.set("prompt", "consent");
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function loopbackRedirect(port) {
|
|
117
|
+
return `http://127.0.0.1:${port ?? LOOPBACK_PORT_FLOOR + randomInt(LOOPBACK_PORT_SPAN)}/`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function approvedCode(address, redirectUri, state) {
|
|
121
|
+
const pasted = typeof address === "string" ? address.trim() : "";
|
|
122
|
+
if (!URL.canParse(pasted)) throw new KitRefusal("google_authorization_rejected");
|
|
123
|
+
const returned = new URL(pasted);
|
|
124
|
+
const expected = new URL(redirectUri);
|
|
125
|
+
if (
|
|
126
|
+
returned.origin !== expected.origin ||
|
|
127
|
+
returned.pathname !== expected.pathname ||
|
|
128
|
+
returned.searchParams.get("state") !== state
|
|
129
|
+
) {
|
|
130
|
+
throw new KitRefusal("google_authorization_rejected");
|
|
131
|
+
}
|
|
132
|
+
if (returned.searchParams.has("error")) throw new KitRefusal("google_authorization_declined");
|
|
133
|
+
const code = returned.searchParams.get("code");
|
|
134
|
+
if (code === null || code === "") throw new KitRefusal("google_authorization_rejected");
|
|
135
|
+
return code;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function tokenRequest(fetcher, body) {
|
|
139
|
+
let response;
|
|
140
|
+
try {
|
|
141
|
+
response = await fetcher(TOKEN_ENDPOINT, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
144
|
+
body: new URLSearchParams(body).toString(),
|
|
145
|
+
});
|
|
146
|
+
} catch {
|
|
147
|
+
throw new KitRefusal("google_provider_unavailable");
|
|
148
|
+
}
|
|
149
|
+
let answer = null;
|
|
150
|
+
try {
|
|
151
|
+
answer = await response.json();
|
|
152
|
+
} catch {
|
|
153
|
+
answer = null;
|
|
154
|
+
}
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
if (answer?.error === "invalid_grant") throw new KitRefusal("google_authority_revoked");
|
|
157
|
+
throw new KitRefusal("google_provider_unavailable");
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
typeof answer?.access_token !== "string" ||
|
|
161
|
+
!Number.isInteger(answer.expires_in) ||
|
|
162
|
+
answer.expires_in < 1 ||
|
|
163
|
+
(answer.token_type !== undefined && answer.token_type !== "Bearer")
|
|
164
|
+
) {
|
|
165
|
+
throw new KitRefusal("google_provider_response_invalid");
|
|
166
|
+
}
|
|
167
|
+
return answer;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function configuredClient(client) {
|
|
171
|
+
if (
|
|
172
|
+
typeof client?.id !== "string" ||
|
|
173
|
+
client.id.length === 0 ||
|
|
174
|
+
typeof client.secret !== "string" ||
|
|
175
|
+
client.secret.length === 0
|
|
176
|
+
) {
|
|
177
|
+
throw new KitRefusal("google_oauth_client_unconfigured");
|
|
178
|
+
}
|
|
179
|
+
return client;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function authorizeGoogle(options, dependencies = {}) {
|
|
183
|
+
const client = configuredClient(options.client);
|
|
184
|
+
const stored = await rawAuthorization(options.directory, options.instance);
|
|
185
|
+
const scopes = [...new Set(options.scopes)];
|
|
186
|
+
const covered = stored?.grants.some((grant) =>
|
|
187
|
+
scopes.every((scope) => grant.scopes.includes(scope)),
|
|
188
|
+
);
|
|
189
|
+
if (covered === true) return { status: "skipped", requestedScopes: [] };
|
|
190
|
+
const verifier = oauthValue();
|
|
191
|
+
const state = oauthValue();
|
|
192
|
+
const redirectUri = loopbackRedirect(dependencies.loopbackPort);
|
|
193
|
+
const address = await options.approve({
|
|
194
|
+
url: authorizationUrl({ clientId: client.id, scopes, redirectUri, verifier, state }),
|
|
195
|
+
redirectUri,
|
|
196
|
+
});
|
|
197
|
+
const code = approvedCode(address, redirectUri, state);
|
|
198
|
+
const now = dependencies.now?.() ?? new Date();
|
|
199
|
+
const token = await tokenRequest(dependencies.fetch ?? fetch, {
|
|
200
|
+
client_id: client.id,
|
|
201
|
+
client_secret: client.secret,
|
|
202
|
+
code,
|
|
203
|
+
code_verifier: verifier,
|
|
204
|
+
grant_type: "authorization_code",
|
|
205
|
+
redirect_uri: redirectUri,
|
|
206
|
+
});
|
|
207
|
+
const refreshToken = token.refresh_token;
|
|
208
|
+
if (typeof refreshToken !== "string" || refreshToken.length === 0) {
|
|
209
|
+
throw new KitRefusal("google_authorization_rejected");
|
|
210
|
+
}
|
|
211
|
+
const returnedScopes =
|
|
212
|
+
typeof token.scope === "string" ? token.scope.split(/\s+/).filter(Boolean) : [];
|
|
213
|
+
if (returnedScopes.length > 0 && scopes.some((scope) => !returnedScopes.includes(scope))) {
|
|
214
|
+
throw new KitRefusal("google_authorization_rejected");
|
|
215
|
+
}
|
|
216
|
+
const grantedScopes = [...new Set(returnedScopes.length === 0 ? scopes : returnedScopes)].sort();
|
|
217
|
+
const key = grantedScopes.join("\n");
|
|
218
|
+
const grants = (stored?.grants ?? []).filter((grant) => [...grant.scopes].sort().join("\n") !== key);
|
|
219
|
+
await writeAuthorization(options.directory, options.instance, {
|
|
220
|
+
grants: [
|
|
221
|
+
...grants,
|
|
222
|
+
{
|
|
223
|
+
access_token: token.access_token,
|
|
224
|
+
refresh_token: refreshToken,
|
|
225
|
+
expires_at: new Date(now.getTime() + token.expires_in * 1000).toISOString(),
|
|
226
|
+
scopes: grantedScopes,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
});
|
|
230
|
+
return { status: "ready", requestedScopes: scopes };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function googleAccessToken(options, dependencies = {}) {
|
|
234
|
+
const stored = await rawAuthorization(options.directory, options.instance);
|
|
235
|
+
const requiredScopes = requiredScopesOf(options.requiredScopes);
|
|
236
|
+
const grant = stored?.grants
|
|
237
|
+
.filter((candidate) => requiredScopes.every((scope) => candidate.scopes.includes(scope)))
|
|
238
|
+
.sort((left, right) => left.scopes.length - right.scopes.length)[0];
|
|
239
|
+
if (grant === undefined) {
|
|
240
|
+
throw new KitRefusal("google_authorization_required", options.instance);
|
|
241
|
+
}
|
|
242
|
+
const now = dependencies.now?.() ?? new Date();
|
|
243
|
+
if (options.forceRefresh !== true && Date.parse(grant.expires_at) - now.getTime() > REFRESH_MARGIN_MS) {
|
|
244
|
+
return grant.access_token;
|
|
245
|
+
}
|
|
246
|
+
const client = configuredClient(options.client);
|
|
247
|
+
let token;
|
|
248
|
+
try {
|
|
249
|
+
token = await tokenRequest(dependencies.fetch ?? fetch, {
|
|
250
|
+
client_id: client.id,
|
|
251
|
+
client_secret: client.secret,
|
|
252
|
+
refresh_token: grant.refresh_token,
|
|
253
|
+
grant_type: "refresh_token",
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error instanceof KitRefusal && error.code === "google_authority_revoked") {
|
|
257
|
+
await revokeGoogleAuthorization(options.directory, options.instance, grant.scopes);
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
await writeAuthorization(options.directory, options.instance, {
|
|
262
|
+
grants: stored.grants.map((candidate) =>
|
|
263
|
+
candidate === grant
|
|
264
|
+
? {
|
|
265
|
+
...candidate,
|
|
266
|
+
access_token: token.access_token,
|
|
267
|
+
expires_at: new Date(now.getTime() + token.expires_in * 1000).toISOString(),
|
|
268
|
+
}
|
|
269
|
+
: candidate,
|
|
270
|
+
),
|
|
271
|
+
});
|
|
272
|
+
return token.access_token;
|
|
273
|
+
}
|