@agentshouse/kit 0.1.0-alpha.42 → 0.1.0-alpha.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,13 +13,13 @@ The Skill set Kit installs is Matt Pocock's
13
13
  Linux (`amd64`, `arm64`) or macOS (Apple silicon, Intel):
14
14
 
15
15
  ```sh
16
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.42/connect-linux.sh)
16
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.44/connect-linux.sh)
17
17
  ```
18
18
 
19
19
  Windows 11 (`amd64`), in PowerShell:
20
20
 
21
21
  ```powershell
22
- & ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.42/connect-windows.ps1)))
22
+ & ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.44/connect-windows.ps1)))
23
23
  ```
24
24
 
25
25
  The command sets up Docker if it is missing, creates `~/.house-kit` (Kit home;
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.42",
3
+ "version": "0.1.0-alpha.44",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.42/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.44/connect-linux.sh)",
7
7
  "asset": {
8
8
  "name": "connect-linux.sh",
9
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.42/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.44/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
@@ -14,10 +14,10 @@
14
14
  }
15
15
  },
16
16
  "windows": {
17
- "command": "& ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.42/connect-windows.ps1)))",
17
+ "command": "& ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.44/connect-windows.ps1)))",
18
18
  "asset": {
19
19
  "name": "connect-windows.ps1",
20
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.42/connect-windows.ps1"
20
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.44/connect-windows.ps1"
21
21
  },
22
22
  "workspace": {
23
23
  "argument": "--workspace",
@@ -76,7 +76,7 @@ It must finish within `timeout_ms` (at most 120000).
76
76
  Kit writes one request line to its standard input:
77
77
 
78
78
  ```json
79
- { "contract": "2026-08-22", "instance": "acme", "cursor": null, "limit": 100 }
79
+ { "contract": "4", "instance": "acme", "cursor": null, "limit": 100 }
80
80
  ```
81
81
 
82
82
  The command prints NDJSON: `item` lines, then at most one `cursor` line.
@@ -87,10 +87,12 @@ The command prints NDJSON: `item` lines, then at most one `cursor` line.
87
87
  {"type":"cursor","cursor":"page-2"}
88
88
  ```
89
89
 
90
- `namespace`, `native_object_id`, `observed_at` and `body` are required. An
91
- attachment is a `url`, or a local `file` with its exact `path`, `bytes`,
92
- `sha256` and optional `media_type`. A malformed line, a non-zero exit or a
93
- timeout rejects the whole run and nothing is delivered.
90
+ `namespace`, `native_object_id`, `observed_at` and `body` are required. The
91
+ optional `container` and `name` are the folder path and file name the item
92
+ reads at below its Source in House. An attachment is a `url`, or a local `file`
93
+ with its exact `path`, `bytes`, `sha256` and optional `media_type`. A malformed
94
+ line, a non-zero exit or a timeout rejects the whole run and nothing is
95
+ delivered.
94
96
 
95
97
  `backfill` bounds the first capture: `{ "days": 30 }`, `{ "count": 500 }` or
96
98
  `{ "all": true }`.
package/docs/google.md CHANGED
@@ -10,6 +10,15 @@ Each service captures, read-only:
10
10
  - Calendar: the account's primary calendar.
11
11
  - Drive: the files the account owns.
12
12
 
13
+ Each capture is a file below its Source in House:
14
+
15
+ - Gmail: `<label>/<thread subject>/<YYYY-MM-DD>-<subject>.md`. The label is the
16
+ message's first own label by name, else `INBOX`, `SENT` or `DRAFT`, else
17
+ `All Mail`.
18
+ - Drive: `<folder path>/<title>.md`.
19
+ - Calendar: `<calendar>/<YYYY-MM>/<YYYY-MM-DD>-<event title>.md`, by the
20
+ event's start day.
21
+
13
22
  ## Signing in
14
23
 
15
24
  Kit ships no Google OAuth client. Each account signs in with your own:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.42",
3
+ "version": "0.1.0-alpha.44",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/connector.js CHANGED
@@ -21,6 +21,8 @@ const ITEM_FIELDS = [
21
21
  "author",
22
22
  "url",
23
23
  "attachments",
24
+ "container",
25
+ "name",
24
26
  ];
25
27
  const ATTACHMENT_FIELDS = ["url", "file", "name"];
26
28
  const FILE_FIELDS = ["path", "bytes", "sha256", "media_type"];
@@ -60,6 +62,7 @@ function itemOf(line) {
60
62
  if (typeof line.body !== "string" || Buffer.byteLength(line.body, "utf8") > ITEM_BODY_MAX_BYTES) {
61
63
  refuse("body");
62
64
  }
65
+ const name = optionalText(line.name, "name", Number.MAX_SAFE_INTEGER);
63
66
  return {
64
67
  namespace,
65
68
  nativeObjectId: text(line.native_object_id, "native_object_id", 512),
@@ -69,6 +72,8 @@ function itemOf(line) {
69
72
  author: optionalText(line.author, "author", 512),
70
73
  url: optionalText(line.url, "url", 2048),
71
74
  attachments: attachmentsOf(line.attachments),
75
+ container: optionalText(line.container, "container", Number.MAX_SAFE_INTEGER),
76
+ claimed: name === null ? {} : { name },
72
77
  };
73
78
  }
74
79
 
package/src/contract.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CLAUDE_PASTED_CODE, CODEX_DEVICE_CODE, GROK_DEVICE_CODE } from "./agent-logins.js";
2
2
 
3
- export const KIT_CONTRACT_VERSION = "3";
3
+ export const KIT_CONTRACT_VERSION = "4";
4
4
  export const KIT_CAPTURE_BATCH_MAX = 100;
5
5
  export const COMMAND_INPUT_MAX_BYTES = 4 * 1024;
6
6
  export const AUTHORIZATION_OUTPUT_MAX_BYTES = 4 * 1024;
package/src/delivery.js CHANGED
@@ -128,7 +128,8 @@ export function deliveredItem(binding, item) {
128
128
  dedup_key: key,
129
129
  observed_at: item.observedAt,
130
130
  provider: item.namespace,
131
- container: item.container ?? `${item.namespace}/${item.nativeObjectId}`,
131
+ ...(item.container === null ? {} : { container: item.container }),
132
+ ...(Object.keys(item.claimed).length === 0 ? {} : { claimed: item.claimed }),
132
133
  native_object_id: item.nativeObjectId,
133
134
  ...(item.nativeVersion === null ? {} : { native_version: item.nativeVersion }),
134
135
  ...(item.providerModifiedAt === null || item.providerModifiedAt === undefined
@@ -5,6 +5,7 @@ import { GOOGLE_SCOPES } from "./oauth.js";
5
5
 
6
6
  const CALENDAR_ORIGIN = "https://www.googleapis.com";
7
7
  const CALENDAR = "primary";
8
+ const DAY = /^\d{4}-\d{2}-\d{2}/;
8
9
 
9
10
  function cursorOf(value) {
10
11
  if (value === null) return { pageToken: null, syncToken: null };
@@ -31,29 +32,56 @@ function eventBody(event) {
31
32
  .join("\n\n");
32
33
  }
33
34
 
34
- function authorOf(event) {
35
- if (typeof event.organizer?.email !== "string") return null;
36
- return typeof event.organizer.displayName === "string"
37
- ? `${event.organizer.displayName} <${event.organizer.email}>`
38
- : event.organizer.email;
35
+ function person(entry) {
36
+ if (typeof entry?.email !== "string") return null;
37
+ return typeof entry.displayName === "string" ? `${entry.displayName} <${entry.email}>` : entry.email;
39
38
  }
40
39
 
41
- function normalizedEvent(event) {
40
+ function momentOf(value) {
41
+ if (typeof value?.dateTime === "string") return value.dateTime;
42
+ return typeof value?.date === "string" ? value.date : null;
43
+ }
44
+
45
+ function claimedEvent(event, start) {
46
+ const day = start.slice(0, 10);
47
+ const end = momentOf(event.end);
48
+ const attendees = (event.attendees ?? []).map(person).filter((attendee) => attendee !== null);
49
+ const titled = typeof event.summary === "string" && event.summary.length > 0;
50
+ return {
51
+ name: titled ? `${day}-${event.summary}.md` : `${day}.md`,
52
+ start,
53
+ ...(end === null ? {} : { end }),
54
+ ...(attendees.length === 0 ? {} : { attendees }),
55
+ ...(typeof event.location === "string" && event.location.length > 0
56
+ ? { location: event.location }
57
+ : {}),
58
+ };
59
+ }
60
+
61
+ function normalizedEvent(event, calendar) {
42
62
  if (
43
63
  typeof event?.id !== "string" ||
44
64
  typeof event.etag !== "string" ||
45
65
  typeof event.updated !== "string" ||
46
66
  Number.isNaN(Date.parse(event.updated)) ||
47
67
  (event.status !== undefined && typeof event.status !== "string") ||
48
- (event.htmlLink !== undefined && typeof event.htmlLink !== "string")
68
+ (event.htmlLink !== undefined && typeof event.htmlLink !== "string") ||
69
+ (event.attendees !== undefined && !Array.isArray(event.attendees))
49
70
  ) {
50
71
  throw new KitRefusal("google_provider_response_invalid", "calendar event");
51
72
  }
52
73
  if (event.status === "cancelled") throw new KitRefusal("google_calendar_item_cancelled");
74
+ const start = momentOf(event.start);
75
+ if (start === null || !DAY.test(start)) {
76
+ throw new KitRefusal("google_provider_response_invalid", "calendar event start");
77
+ }
53
78
  const body = eventBody(event);
54
79
  if (Buffer.byteLength(body, "utf8") > ITEM_BODY_MAX_BYTES) {
55
80
  throw new KitRefusal("google_calendar_content_exceeded");
56
81
  }
82
+ if (typeof calendar !== "string") {
83
+ throw new KitRefusal("google_provider_response_invalid", "calendar summary");
84
+ }
57
85
  return {
58
86
  namespace: "google/calendar",
59
87
  nativeObjectId: event.id,
@@ -61,10 +89,11 @@ function normalizedEvent(event) {
61
89
  providerModifiedAt: new Date(event.updated).toISOString(),
62
90
  observedAt: new Date(event.updated).toISOString(),
63
91
  body,
64
- author: authorOf(event),
92
+ author: person(event.organizer),
65
93
  url: event.htmlLink ?? null,
66
94
  attachments: [],
67
- container: `google/calendar/${CALENDAR}`,
95
+ container: `${calendar}/${start.slice(0, 7)}`,
96
+ claimed: claimedEvent(event, start),
68
97
  };
69
98
  }
70
99
 
@@ -145,7 +174,7 @@ export async function calendarPage(options, dependencies = {}) {
145
174
  const dropped = [];
146
175
  for (const event of items) {
147
176
  try {
148
- normalized.push(normalizedEvent(event));
177
+ normalized.push(normalizedEvent(event, answer.summary));
149
178
  } catch (error) {
150
179
  if (
151
180
  error instanceof KitRefusal &&
@@ -67,34 +67,51 @@ function fileMetadata(value) {
67
67
  return value;
68
68
  }
69
69
 
70
- async function parentPath(options, file, dependencies, cache) {
71
- const names = [];
72
- const seen = new Set();
73
- let parent = file.parents?.[0] ?? null;
74
- while (parent !== null && parent !== "root") {
75
- if (seen.has(parent)) throw new KitRefusal("google_provider_response_invalid", "drive parents");
76
- seen.add(parent);
77
- let metadata = cache.get(parent);
78
- if (metadata === undefined) {
79
- metadata = fileMetadata(
80
- await googleJson(
81
- {
82
- ...common(options),
83
- url: fileUrl(parent, { fields: FILE_FIELDS }),
84
- notFoundCode: "google_drive_item_missing",
85
- },
86
- dependencies,
87
- ),
88
- );
89
- cache.set(parent, metadata);
90
- }
91
- if (metadata.mimeType !== FOLDER_MIME) {
92
- throw new KitRefusal("google_provider_response_invalid", "drive parent");
93
- }
94
- names.unshift(metadata.name);
95
- parent = metadata.parents?.[0] ?? null;
70
+ async function rootId(options, dependencies) {
71
+ const root = await googleJson(
72
+ { ...common(options), url: fileUrl("root", { fields: "id" }) },
73
+ dependencies,
74
+ );
75
+ if (typeof root.id !== "string") {
76
+ throw new KitRefusal("google_provider_response_invalid", "drive root");
96
77
  }
97
- return names;
78
+ return root.id;
79
+ }
80
+
81
+ function folderPaths(options, dependencies) {
82
+ const folders = new Map();
83
+ let root = null;
84
+ return async (file) => {
85
+ const names = [];
86
+ const seen = new Set();
87
+ let parent = file.parents?.[0] ?? null;
88
+ if (parent === null || parent === "root") return names;
89
+ root ??= await rootId(options, dependencies);
90
+ while (parent !== null && parent !== "root" && parent !== root) {
91
+ if (seen.has(parent)) throw new KitRefusal("google_provider_response_invalid", "drive parents");
92
+ seen.add(parent);
93
+ let metadata = folders.get(parent);
94
+ if (metadata === undefined) {
95
+ metadata = fileMetadata(
96
+ await googleJson(
97
+ {
98
+ ...common(options),
99
+ url: fileUrl(parent, { fields: FILE_FIELDS }),
100
+ notFoundCode: "google_drive_item_missing",
101
+ },
102
+ dependencies,
103
+ ),
104
+ );
105
+ folders.set(parent, metadata);
106
+ }
107
+ if (metadata.mimeType !== FOLDER_MIME) {
108
+ throw new KitRefusal("google_provider_response_invalid", "drive parent");
109
+ }
110
+ names.unshift(metadata.name);
111
+ parent = metadata.parents?.[0] ?? null;
112
+ }
113
+ return names;
114
+ };
98
115
  }
99
116
 
100
117
  async function contentOf(options, file, dependencies) {
@@ -132,7 +149,7 @@ function authorOf(file) {
132
149
  : owner.emailAddress;
133
150
  }
134
151
 
135
- async function normalizedFile(options, file, dependencies, cache) {
152
+ async function normalizedFile(options, file, dependencies, pathOf) {
136
153
  const metadata = fileMetadata(file);
137
154
  if (
138
155
  metadata.mimeType === FOLDER_MIME ||
@@ -144,7 +161,7 @@ async function normalizedFile(options, file, dependencies, cache) {
144
161
  throw new KitRefusal("google_provider_response_invalid", "drive content");
145
162
  }
146
163
  const body = await contentOf(options, metadata, dependencies);
147
- const folders = await parentPath(options, metadata, dependencies, cache);
164
+ const folders = await pathOf(metadata);
148
165
  return {
149
166
  namespace: "google/drive",
150
167
  nativeObjectId: metadata.id,
@@ -155,7 +172,8 @@ async function normalizedFile(options, file, dependencies, cache) {
155
172
  author: authorOf(metadata),
156
173
  url: metadata.webViewLink ?? null,
157
174
  attachments: [],
158
- container: `google/drive${folders.length === 0 ? "" : `/${folders.join("/")}`}`,
175
+ container: folders.length === 0 ? null : folders.join("/"),
176
+ claimed: { name: `${metadata.name}.md` },
159
177
  };
160
178
  }
161
179
 
@@ -168,13 +186,14 @@ function droppedFile(file, code) {
168
186
  };
169
187
  }
170
188
 
171
- async function normalizeFiles(options, files, dependencies, cache) {
189
+ async function normalizeFiles(options, files, dependencies) {
190
+ const pathOf = folderPaths(options, dependencies);
172
191
  const items = [];
173
192
  const dropped = [];
174
193
  for (const file of files) {
175
194
  if (file.mimeType === FOLDER_MIME) continue;
176
195
  try {
177
- items.push(await normalizedFile(options, file, dependencies, cache));
196
+ items.push(await normalizedFile(options, file, dependencies, pathOf));
178
197
  } catch (error) {
179
198
  if (
180
199
  error instanceof KitRefusal &&
@@ -225,12 +244,7 @@ async function snapshotPage(options, cursor, dependencies) {
225
244
  ) {
226
245
  throw new KitRefusal("google_provider_response_invalid", "drive files");
227
246
  }
228
- const normalized = await normalizeFiles(
229
- options,
230
- answer.files.map(fileMetadata),
231
- dependencies,
232
- new Map(),
233
- );
247
+ const normalized = await normalizeFiles(options, answer.files.map(fileMetadata), dependencies);
234
248
  const next =
235
249
  answer.nextPageToken === undefined
236
250
  ? { phase: "changes", pageToken: cursor.changeToken }
@@ -268,7 +282,7 @@ async function changesPage(options, cursor, dependencies) {
268
282
  .filter((change) => change?.removed !== true && change?.file !== undefined)
269
283
  .map((change) => fileMetadata(change.file))
270
284
  .filter((file) => file.ownedByMe === true && file.trashed !== true);
271
- const normalized = await normalizeFiles(options, files, dependencies, new Map());
285
+ const normalized = await normalizeFiles(options, files, dependencies);
272
286
  const pageToken = answer.nextPageToken ?? answer.newStartPageToken;
273
287
  if (typeof pageToken !== "string") {
274
288
  throw new KitRefusal("google_provider_response_invalid", "drive change token");
@@ -5,6 +5,9 @@ import { GOOGLE_SCOPES } from "./oauth.js";
5
5
 
6
6
  const GMAIL_ORIGIN = "https://gmail.googleapis.com";
7
7
  const EXCLUDED_LABELS = ["SPAM", "TRASH"];
8
+ const PLACES = ["INBOX", "SENT", "DRAFT"];
9
+ const ARCHIVED = "All Mail";
10
+ const CLAIMED_HEADERS = ["from", "to", "cc", "subject"];
8
11
 
9
12
  function cursorOf(value) {
10
13
  if (value === null) return { mode: "messages", pageToken: null, historyId: null };
@@ -54,6 +57,7 @@ function header(message, name) {
54
57
  function normalizedMessage(message) {
55
58
  if (
56
59
  typeof message?.id !== "string" ||
60
+ typeof message.threadId !== "string" ||
57
61
  typeof message.historyId !== "string" ||
58
62
  !/^\d+$/.test(message.historyId) ||
59
63
  typeof message.internalDate !== "string" ||
@@ -85,7 +89,87 @@ function normalizedMessage(message) {
85
89
  author,
86
90
  url: `https://mail.google.com/mail/u/0/#all/${encodeURIComponent(message.id)}`,
87
91
  attachments: [],
88
- container: `google/gmail/${message.labelIds[0] ?? "mail"}`,
92
+ };
93
+ }
94
+
95
+ function claimedMessage(message, observedAt) {
96
+ const day = observedAt.slice(0, 10);
97
+ const subject = header(message, "subject");
98
+ const claimed = { name: subject === null || subject === "" ? `${day}.md` : `${day}-${subject}.md` };
99
+ for (const field of CLAIMED_HEADERS) {
100
+ const value = header(message, field);
101
+ if (value !== null) claimed[field] = value;
102
+ }
103
+ return claimed;
104
+ }
105
+
106
+ function labelOf(labelIds, labels) {
107
+ const [user] = labelIds
108
+ .map((id) => labels.get(id))
109
+ .filter((label) => label?.type === "user")
110
+ .map((label) => label.name)
111
+ .sort();
112
+ return user ?? PLACES.find((id) => labelIds.includes(id)) ?? ARCHIVED;
113
+ }
114
+
115
+ async function labelsOf(shared, dependencies) {
116
+ const answer = await googleJson(
117
+ { ...shared, url: `${GMAIL_ORIGIN}/gmail/v1/users/me/labels` },
118
+ dependencies,
119
+ );
120
+ if (
121
+ !Array.isArray(answer.labels) ||
122
+ answer.labels.some(
123
+ (label) =>
124
+ typeof label?.id !== "string" ||
125
+ typeof label.name !== "string" ||
126
+ typeof label.type !== "string",
127
+ )
128
+ ) {
129
+ throw new KitRefusal("google_provider_response_invalid", "gmail labels");
130
+ }
131
+ return new Map(answer.labels.map((label) => [label.id, label]));
132
+ }
133
+
134
+ async function threadSubject(shared, threadId, dependencies) {
135
+ const url = new URL(`/gmail/v1/users/me/threads/${encodeURIComponent(threadId)}`, GMAIL_ORIGIN);
136
+ url.searchParams.set("format", "metadata");
137
+ url.searchParams.set("metadataHeaders", "Subject");
138
+ const thread = await googleJson(
139
+ { ...shared, url: url.toString(), notFoundCode: "google_gmail_item_missing" },
140
+ dependencies,
141
+ );
142
+ if (
143
+ !Array.isArray(thread.messages) ||
144
+ thread.messages.length === 0 ||
145
+ thread.messages.some(
146
+ (message) => typeof message?.internalDate !== "string" || !/^\d+$/.test(message.internalDate),
147
+ )
148
+ ) {
149
+ throw new KitRefusal("google_provider_response_invalid", "gmail thread");
150
+ }
151
+ const [opening] = [...thread.messages].sort(
152
+ (left, right) => Number(left.internalDate) - Number(right.internalDate),
153
+ );
154
+ const subject = header(opening, "subject");
155
+ return subject === "" ? null : subject;
156
+ }
157
+
158
+ function placement(shared, dependencies) {
159
+ let labels = null;
160
+ const subjects = new Map();
161
+ return async (message, item) => {
162
+ labels ??= await labelsOf(shared, dependencies);
163
+ if (!subjects.has(message.threadId)) {
164
+ subjects.set(message.threadId, await threadSubject(shared, message.threadId, dependencies));
165
+ }
166
+ const label = labelOf(message.labelIds, labels);
167
+ const subject = subjects.get(message.threadId);
168
+ return {
169
+ ...item,
170
+ container: subject === null ? label : `${label}/${subject}`,
171
+ claimed: claimedMessage(message, item.observedAt),
172
+ };
89
173
  };
90
174
  }
91
175
 
@@ -202,6 +286,7 @@ export async function gmailPage(options, dependencies = {}) {
202
286
  throw new KitRefusal("google_provider_response_invalid", "gmail cursor");
203
287
  }
204
288
  const identifiers = referencesOf(answer, cursor.mode);
289
+ const placed = placement(shared, dependencies);
205
290
  const items = [];
206
291
  const dropped = [];
207
292
  for (const identifier of identifiers) {
@@ -216,7 +301,9 @@ export async function gmailPage(options, dependencies = {}) {
216
301
  dependencies,
217
302
  );
218
303
  const normalized = normalizedMessage(detail);
219
- if (!detail.labelIds.some((label) => EXCLUDED_LABELS.includes(label))) items.push(normalized);
304
+ if (!detail.labelIds.some((label) => EXCLUDED_LABELS.includes(label))) {
305
+ items.push(await placed(detail, normalized));
306
+ }
220
307
  } catch (error) {
221
308
  if (
222
309
  error instanceof KitRefusal &&
package/src/workspace.js CHANGED
@@ -201,6 +201,13 @@ export async function workspacePrerequisites(host, image) {
201
201
  if (version.status !== 0 || version.stdout.trim() === "") {
202
202
  return { supported: false, refusal: "the container runtime daemon did not answer a version" };
203
203
  }
204
+ const swap = await docker(host, ["info", "--format", "{{.SwapLimit}}"]);
205
+ if (swap.status !== 0 || swap.stdout.trim() !== "true") {
206
+ return {
207
+ supported: false,
208
+ refusal: "the container runtime cannot enforce the workspace memory and swap ceiling",
209
+ };
210
+ }
204
211
  const formatter = await docker(host, [
205
212
  "run",
206
213
  "--rm",