@hraness/wordcell 0.26.0 → 0.26.2

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
@@ -9,14 +9,26 @@ sources as Markdown files beside your code, and agents find them by exact
9
9
  words, by meaning with an optional local model, or from the file they are
10
10
  about to change.
11
11
 
12
- A new coding-agent session can read your code, but not the decisions that
13
- stayed in the last session's chat. Wordcell keeps those decisions as Markdown
14
- files beside the repository, with the plans that depend on them and the web
15
- pages and PDFs that informed them. Tie a note to the paths it explains, and an
16
- agent about to change that code runs one command to get the notes and plans
17
- for that path. Exact search, backlinks, and Git history run on your machine
18
- with no account or model, and every index rebuilds from files you can read in
19
- any editor. Wordcell is free and open source.
12
+ A new coding-agent session needs the reasons behind the code it is changing.
13
+ Wordcell keeps the decision, its supporting sources, and the plan that puts it
14
+ into practice in a connected set of Markdown files. Tie a note to a repository
15
+ path, and the next agent can recover that context before it edits the file.
16
+ When an assumption changes, update the decision and keep its earlier reasoning
17
+ in Git.
18
+
19
+ Captured sources preserve what you read, maintained notes explain what you
20
+ currently understand, and plans record the work and its results. Named
21
+ relationships such as `evidenced-by` and `supersedes` make those connections
22
+ available to the embedded Oh graph engine, whose results point back to the
23
+ supporting files. Optional local semantic search helps when the words in a
24
+ question differ from the note; Git history connects the explanation to its
25
+ commits and accompanying code changes.
26
+
27
+ Use the CLI, local MCP server, or TypeScript SDK from your existing agent and
28
+ editor. The public Agent Skill teaches agents to recover context, maintain
29
+ plans, and leave reusable decisions for later sessions. Exact search, backlinks,
30
+ and Git history need no account or model. Every index rebuilds from files you
31
+ can read in Obsidian or any text editor. Wordcell is free and open source.
20
32
 
21
33
  Web capture, optional hosted reranking, and your agent's provider reach other
22
34
  services; [Privacy and boundaries](#privacy-and-boundaries) says what each one
@@ -54,14 +66,14 @@ Wordcell keeps the record in Markdown files you own, rebuilds every index from t
54
66
  The CLI and TypeScript SDK run with Bun. Install the versioned GitHub archive:
55
67
 
56
68
  ```sh
57
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.26.0/hraness-wordcell-0.26.0.tgz
69
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.26.2/hraness-wordcell-0.26.2.tgz
58
70
  wordcell --help
59
71
  ```
60
72
 
61
73
  Prefer npm? The same release is [mirrored there](https://www.npmjs.com/package/@hraness/wordcell):
62
74
 
63
75
  ```sh
64
- npm install --global --ignore-scripts @hraness/wordcell@0.26.0
76
+ npm install --global --ignore-scripts @hraness/wordcell@0.26.2
65
77
  wordcell --help
66
78
  ```
67
79
 
@@ -125,7 +137,7 @@ After trying the CLI, install the public Agent Skill into a compatible agent,
125
137
  such as Claude Code, Codex, Cursor, or GitHub Copilot:
126
138
 
127
139
  ```sh
128
- bunx skills add hraness/wordcell#v0.26.0 --skill wordcell
140
+ bunx skills add hraness/wordcell#v0.26.2 --skill wordcell
129
141
  ```
130
142
 
131
143
  Then ask:
@@ -336,7 +348,7 @@ exist. [Graph queries and proof limits](https://github.com/hraness/wordcell/blob
336
348
  Add the same immutable release to a Bun project:
337
349
 
338
350
  ```sh
339
- bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.26.0/hraness-wordcell-0.26.0.tgz
351
+ bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.26.2/hraness-wordcell-0.26.2.tgz
340
352
  ```
341
353
 
342
354
  The SDK provides read-only vault sessions, metadata queries, search, graph
package/dist/capture.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  captureSummary,
6
6
  main,
7
7
  runCapture
8
- } from "./index-zbv70qmk.js";
8
+ } from "./index-h9vyd03z.js";
9
9
  import"./index-hacpnaew.js";
10
10
  import"./index-k86wepd8.js";
11
11
  import {
@@ -13,7 +13,7 @@ import {
13
13
  inspectClipEnvironment,
14
14
  renderDoctorReport
15
15
  } from "./index-nd8v7r0z.js";
16
- import"./index-5n05se68.js";
16
+ import"./index-hnvx7m1j.js";
17
17
  import"./index-ncwzmsge.js";
18
18
  import"./index-cd75vky9.js";
19
19
  import"./index-2gv8y733.js";
package/dist/cli-bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  runWordcellBin
5
- } from "./index-ncgdaja4.js";
5
+ } from "./index-82r6g4py.js";
6
6
  import"./index-3t0v457d.js";
7
7
  import"./index-8pabwzqg.js";
8
8
  import"./index-4knsp9qj.js";
@@ -409,12 +409,15 @@ function verifyAttestations(directory, manifest) {
409
409
  }
410
410
  var canonicalJobs = ["Authorize owner release tag", "Verify", "Attest verified artifact", "Publish"];
411
411
  var npmJobs = ["Publish exact npm package", "Admit the public npm package"];
412
+ function releaseActor(actor) {
413
+ return actor.id === 894119 && actor.type === "User" || actor.id === 337004703 && actor.type === "Bot";
414
+ }
412
415
  function canonicalRunIdentity(value, manifest, attempt) {
413
416
  const run = record(value, "Canonical release run");
414
417
  const owner = record(run.actor, "Canonical release actor");
415
418
  const triggering = record(run.triggering_actor, "Canonical triggering actor");
416
419
  const source = record(run.repository, "Canonical run repository");
417
- if (run.id !== manifest.runId || run.run_attempt !== attempt || run.workflow_id !== 320004141 || run.name !== "Release" || run.path !== manifest.workflow || run.status !== "completed" || !["success", "failure"].includes(String(run.conclusion)) || run.event !== "push" || run.head_branch !== manifest.tag || run.head_sha !== manifest.sourceSha || owner.id !== 894119 || owner.type !== "User" || triggering.id !== 894119 || triggering.type !== "User" || source.id !== repositoryId || source.full_name !== repository || source.private !== false) {
420
+ if (run.id !== manifest.runId || run.run_attempt !== attempt || run.workflow_id !== 320004141 || run.name !== "Release" || run.path !== manifest.workflow || run.status !== "completed" || !["success", "failure"].includes(String(run.conclusion)) || run.event !== "push" || run.head_branch !== manifest.tag || run.head_sha !== manifest.sourceSha || !releaseActor(owner) || !releaseActor(triggering) || source.id !== repositoryId || source.full_name !== repository || source.private !== false) {
418
421
  throw new Error("Canonical release does not have the exact completed source and owner identity");
419
422
  }
420
423
  return run;
package/dist/cli.js CHANGED
@@ -6,17 +6,17 @@ import {
6
6
  parseArguments,
7
7
  runExecutable,
8
8
  usage
9
- } from "./index-8w1zsgcm.js";
9
+ } from "./index-mp5y3ty3.js";
10
10
  import"./index-5h5awwh4.js";
11
11
  import"./index-m9p2a4mj.js";
12
12
  import"./index-9paxge84.js";
13
13
  import"./index-j4zgmzjr.js";
14
14
  import"./index-npg9z1a4.js";
15
- import"./index-zbv70qmk.js";
15
+ import"./index-h9vyd03z.js";
16
16
  import"./index-hacpnaew.js";
17
17
  import"./index-k86wepd8.js";
18
18
  import"./index-nd8v7r0z.js";
19
- import"./index-5n05se68.js";
19
+ import"./index-hnvx7m1j.js";
20
20
  import"./index-8pabwzqg.js";
21
21
  import"./index-ncwzmsge.js";
22
22
  import"./index-cd75vky9.js";
@@ -64,7 +64,7 @@ import {
64
64
 
65
65
  // src/cli.ts
66
66
  async function runStandaloneCli(args = process.argv.slice(2)) {
67
- const { standaloneSupportEnvironment, isUsefulSupportResult, runProductSupportCommand, showProductSupportInvitation } = await import("./support-ym4mpf7w.js");
67
+ const { standaloneSupportEnvironment, isUsefulSupportResult, runProductSupportCommand, showProductSupportInvitation } = await import("./support-9qdr4hb8.js");
68
68
  const env = standaloneSupportEnvironment();
69
69
  if (args[0] === "support") {
70
70
  return await runProductSupportCommand(args.slice(1), { env });
@@ -6,7 +6,7 @@ import {
6
6
  copyBoundedLocalState,
7
7
  isSafeNamedProfile,
8
8
  profilePath
9
- } from "../index-5n05se68.js";
9
+ } from "../index-hnvx7m1j.js";
10
10
  import"../index-z1w83f81.js";
11
11
  export {
12
12
  profilePath,
package/dist/clip/cli.js CHANGED
@@ -5,11 +5,11 @@ import {
5
5
  captureSucceeded,
6
6
  captureSummary,
7
7
  main
8
- } from "../index-zbv70qmk.js";
8
+ } from "../index-h9vyd03z.js";
9
9
  import"../index-hacpnaew.js";
10
10
  import"../index-k86wepd8.js";
11
11
  import"../index-nd8v7r0z.js";
12
- import"../index-5n05se68.js";
12
+ import"../index-hnvx7m1j.js";
13
13
  import"../index-ncwzmsge.js";
14
14
  import"../index-cd75vky9.js";
15
15
  import"../index-2gv8y733.js";
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  runWordcellBin
5
- } from "./index-ncgdaja4.js";
5
+ } from "./index-82r6g4py.js";
6
6
  import"./index-3t0v457d.js";
7
7
  import"./index-8pabwzqg.js";
8
8
  import"./index-4knsp9qj.js";
@@ -34,7 +34,7 @@ function wordcellUpdateOptions(bin, entrypoint, argv) {
34
34
  effectFree,
35
35
  suppressAutomatic: bin === "wordcell" && argv[0] === "support",
36
36
  async verifyArtifact(artifact) {
37
- const { verifyUpdateArtifact } = await import("./cli-release-c74c5m3k.js");
37
+ const { verifyUpdateArtifact } = await import("./cli-release-cf48w61z.js");
38
38
  await verifyUpdateArtifact(artifact);
39
39
  }
40
40
  };
@@ -21,7 +21,7 @@ import {
21
21
  } from "./index-nd8v7r0z.js";
22
22
  import {
23
23
  cloneBrowserProfile
24
- } from "./index-5n05se68.js";
24
+ } from "./index-hnvx7m1j.js";
25
25
  import {
26
26
  CONFIRM_LINE,
27
27
  acquireBrowser,
@@ -11,13 +11,15 @@ import {
11
11
  mkdirSync,
12
12
  openSync,
13
13
  readdirSync,
14
+ readlinkSync,
14
15
  readSync,
15
16
  realpathSync,
16
17
  rmSync,
17
18
  unlinkSync,
18
19
  writeSync
19
20
  } from "fs";
20
- import { homedir } from "os";
21
+ import { spawnSync } from "child_process";
22
+ import { homedir, hostname, tmpdir } from "os";
21
23
  import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
22
24
  var profileSkipNames = new Set([
23
25
  "Cache",
@@ -185,15 +187,82 @@ function localStateEntry(path) {
185
187
  throw new BrowserProfileSnapshotError("Chromium Local State is unavailable or unsafe");
186
188
  }
187
189
  }
190
+ var activeProfileMessage = "Chromium profile is active or retains a stale process lock; fully quit the browser and retry";
191
+ function assertPidAbsent(pid) {
192
+ try {
193
+ process.kill(pid, 0);
194
+ } catch (error) {
195
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH")
196
+ return;
197
+ }
198
+ throw new BrowserProfileSnapshotError(activeProfileMessage);
199
+ }
200
+ function assertSocketUnavailable(path) {
201
+ const script = `const fs = require("node:fs");
202
+ const net = require("node:net");
203
+ const socket = net.createConnection({ path: fs.readFileSync(0, "utf8") });
204
+ socket.setTimeout(500, () => { socket.destroy(); process.exit(1); });
205
+ socket.once("connect", () => { socket.destroy(); process.exit(1); });
206
+ socket.once("error", error => process.exit(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? 0 : 1));`;
207
+ const result = spawnSync(process.execPath, ["--no-env-file", "--no-install", "--no-macros", "--no-addons", "--eval", script], {
208
+ input: path,
209
+ cwd: tmpdir(),
210
+ env: {},
211
+ timeout: 2000,
212
+ maxBuffer: 1024,
213
+ stdio: ["pipe", "pipe", "pipe"]
214
+ });
215
+ if (result.error || result.status !== 0)
216
+ throw new BrowserProfileSnapshotError(activeProfileMessage);
217
+ }
188
218
  function assertChromiumUserDataIdle(userDataRoot) {
189
- let names;
190
219
  try {
191
- names = readdirSync(userDataRoot);
220
+ const root = lstatSync(userDataRoot);
221
+ if (!root.isDirectory() || root.isSymbolicLink() || root.uid !== process.getuid?.())
222
+ throw new Error;
223
+ const rootProof = { dev: root.dev, ino: root.ino, ctime: root.ctimeMs };
224
+ const names = readdirSync(userDataRoot).filter((name) => name.startsWith("Singleton") || name === "DevToolsActivePort").sort();
225
+ if (names.length === 0)
226
+ return JSON.stringify({ root: rootProof, locks: [] });
227
+ if (names.join(",") !== "SingletonCookie,SingletonLock,SingletonSocket")
228
+ throw new Error;
229
+ const proof = names.map((name) => {
230
+ const path = join(userDataRoot, name);
231
+ const entry = lstatSync(path);
232
+ if (!entry.isSymbolicLink() || entry.uid !== process.getuid?.())
233
+ throw new Error;
234
+ return { name, target: readlinkSync(path), dev: entry.dev, ino: entry.ino, ctime: entry.ctimeMs };
235
+ });
236
+ const lock = proof.find((entry) => entry.name === "SingletonLock").target;
237
+ const prefix = `${hostname()}-`;
238
+ if (!lock.startsWith(prefix) || !/^[1-9][0-9]*$/.test(lock.slice(prefix.length)))
239
+ throw new Error;
240
+ const pid = Number(lock.slice(prefix.length));
241
+ if (!Number.isSafeInteger(pid) || pid > 2147483647)
242
+ throw new Error;
243
+ const socket = proof.find((entry) => entry.name === "SingletonSocket").target;
244
+ if (!isAbsolute(socket) || resolve(socket) !== socket || basename(socket) !== "SingletonSocket" || !socket.startsWith(`${tmpdir()}${sep}`))
245
+ throw new Error;
246
+ if (!/^[0-9]{1,32}$/.test(proof.find((entry) => entry.name === "SingletonCookie").target))
247
+ throw new Error;
248
+ assertPidAbsent(pid);
249
+ assertSocketUnavailable(socket);
250
+ assertPidAbsent(pid);
251
+ const finalRoot = lstatSync(userDataRoot);
252
+ if (!sameEntry(root, finalRoot) || finalRoot.ctimeMs !== root.ctimeMs)
253
+ throw new Error;
254
+ const finalNames = readdirSync(userDataRoot).filter((name) => name.startsWith("Singleton") || name === "DevToolsActivePort").sort();
255
+ if (finalNames.join(",") !== names.join(","))
256
+ throw new Error;
257
+ for (const entry of proof) {
258
+ const path = join(userDataRoot, entry.name);
259
+ const current = lstatSync(path);
260
+ if (!current.isSymbolicLink() || current.dev !== entry.dev || current.ino !== entry.ino || current.ctimeMs !== entry.ctime || readlinkSync(path) !== entry.target)
261
+ throw new Error;
262
+ }
263
+ return JSON.stringify({ root: rootProof, locks: proof });
192
264
  } catch {
193
- throw new BrowserProfileSnapshotError("Chromium user-data root could not be inspected");
194
- }
195
- if (names.some((name) => name.startsWith("Singleton") || name === "DevToolsActivePort")) {
196
- throw new BrowserProfileSnapshotError("Chromium profile is active or retains a stale process lock; fully quit the browser and retry");
265
+ throw new BrowserProfileSnapshotError(activeProfileMessage);
197
266
  }
198
267
  }
199
268
  function copyBoundedLocalState(source, destination) {
@@ -289,7 +358,7 @@ function cloneBrowserProfile(source, privateDirectory) {
289
358
  if (stateEntry.size > maxLocalStateBytes) {
290
359
  throw new BrowserProfileSnapshotError("Chromium Local State exceeds its safety bound");
291
360
  }
292
- assertChromiumUserDataIdle(userDataRoot);
361
+ const idleProof = assertChromiumUserDataIdle(userDataRoot);
293
362
  const userData = join(privateDirectory, "profile-user-data");
294
363
  mkdirSync(userData, { mode: 448 });
295
364
  chmodSync(userData, 448);
@@ -297,6 +366,9 @@ function cloneBrowserProfile(source, privateDirectory) {
297
366
  try {
298
367
  cloneProfile(selectedSource, selectedProfile);
299
368
  copyBoundedLocalState(localState, join(userData, "Local State"));
369
+ if (assertChromiumUserDataIdle(userDataRoot) !== idleProof) {
370
+ throw new BrowserProfileSnapshotError("Chromium profile lock changed while it was copied");
371
+ }
300
372
  return { userDataPath: userData, profileDirectory: "Default" };
301
373
  } catch (error) {
302
374
  rmSync(userData, { recursive: true, force: true });
@@ -22,7 +22,7 @@ import {
22
22
  } from "./index-npg9z1a4.js";
23
23
  import {
24
24
  main
25
- } from "./index-zbv70qmk.js";
25
+ } from "./index-h9vyd03z.js";
26
26
  import {
27
27
  advancedHelp,
28
28
  closestMatch,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  parseArguments as parseArguments2,
4
4
  parseUrlMetadataArguments
5
- } from "./index-8w1zsgcm.js";
5
+ } from "./index-mp5y3ty3.js";
6
6
  import"./index-5h5awwh4.js";
7
7
  import {
8
8
  parsePdfArguments
@@ -10,11 +10,11 @@ import {
10
10
  import"./index-9paxge84.js";
11
11
  import"./index-j4zgmzjr.js";
12
12
  import"./index-npg9z1a4.js";
13
- import"./index-zbv70qmk.js";
13
+ import"./index-h9vyd03z.js";
14
14
  import"./index-hacpnaew.js";
15
15
  import"./index-k86wepd8.js";
16
16
  import"./index-nd8v7r0z.js";
17
- import"./index-5n05se68.js";
17
+ import"./index-hnvx7m1j.js";
18
18
  import"./index-8pabwzqg.js";
19
19
  import"./index-ncwzmsge.js";
20
20
  import"./index-cd75vky9.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/wordcell",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Wordcell keeps decisions, plans, and sources as Markdown beside your code, so coding agents can find them from the file they are about to change.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@ missing:
58
58
  ```sh
59
59
  command -v wordcell >/dev/null 2>&1 || {
60
60
  command -v bun >/dev/null 2>&1 || exit 1
61
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.26.0/hraness-wordcell-0.26.0.tgz
61
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.26.2/hraness-wordcell-0.26.2.tgz
62
62
  }
63
63
  wordcell --help
64
64
  ```
@@ -397,6 +397,11 @@ export function verifyAttestations(directory: string, manifest: ReleaseManifest)
397
397
  const canonicalJobs = ["Authorize owner release tag", "Verify", "Attest verified artifact", "Publish"];
398
398
  const npmJobs = ["Publish exact npm package", "Admit the public npm package"];
399
399
 
400
+ // The owner, or the hraness-release-tagger App bot that tags version bumps on main.
401
+ function releaseActor(actor: Record<string, unknown>): boolean {
402
+ return (actor.id === 894119 && actor.type === "User") || (actor.id === 337004703 && actor.type === "Bot");
403
+ }
404
+
400
405
  function canonicalRunIdentity(value: unknown, manifest: ReleaseManifest, attempt: number): Record<string, unknown> {
401
406
  const run = record(value, "Canonical release run");
402
407
  const owner = record(run.actor, "Canonical release actor");
@@ -406,7 +411,7 @@ function canonicalRunIdentity(value: unknown, manifest: ReleaseManifest, attempt
406
411
  || run.workflow_id !== 320004141 || run.name !== "Release" || run.path !== manifest.workflow
407
412
  || run.status !== "completed" || !["success", "failure"].includes(String(run.conclusion)) || run.event !== "push"
408
413
  || run.head_branch !== manifest.tag || run.head_sha !== manifest.sourceSha
409
- || owner.id !== 894119 || owner.type !== "User" || triggering.id !== 894119 || triggering.type !== "User"
414
+ || !releaseActor(owner) || !releaseActor(triggering)
410
415
  || source.id !== repositoryId || source.full_name !== repository || source.private !== false) {
411
416
  throw new Error("Canonical release does not have the exact completed source and owner identity");
412
417
  }
@@ -9,6 +9,7 @@ import {
9
9
  mkdirSync,
10
10
  openSync,
11
11
  readdirSync,
12
+ readlinkSync,
12
13
  readSync,
13
14
  realpathSync,
14
15
  rmSync,
@@ -16,7 +17,8 @@ import {
16
17
  unlinkSync,
17
18
  writeSync,
18
19
  } from "node:fs";
19
- import { homedir } from "node:os";
20
+ import { spawnSync } from "node:child_process";
21
+ import { homedir, hostname, tmpdir } from "node:os";
20
22
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
21
23
 
22
24
  const profileSkipNames = new Set([
@@ -236,17 +238,67 @@ function localStateEntry(path: string): Stats | null {
236
238
  }
237
239
  }
238
240
 
239
- function assertChromiumUserDataIdle(userDataRoot: string): void {
240
- let names: string[];
241
+ const activeProfileMessage = "Chromium profile is active or retains a stale process lock; fully quit the browser and retry";
242
+
243
+ function assertPidAbsent(pid: number): void {
241
244
  try {
242
- names = readdirSync(userDataRoot);
243
- } catch {
244
- throw new BrowserProfileSnapshotError("Chromium user-data root could not be inspected");
245
+ process.kill(pid, 0);
246
+ } catch (error) {
247
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") return;
245
248
  }
246
- if (names.some((name) => name.startsWith("Singleton") || name === "DevToolsActivePort")) {
247
- throw new BrowserProfileSnapshotError(
248
- "Chromium profile is active or retains a stale process lock; fully quit the browser and retry",
249
- );
249
+ throw new BrowserProfileSnapshotError(activeProfileMessage);
250
+ }
251
+
252
+ function assertSocketUnavailable(path: string): void {
253
+ const script = `const fs = require("node:fs");
254
+ const net = require("node:net");
255
+ const socket = net.createConnection({ path: fs.readFileSync(0, "utf8") });
256
+ socket.setTimeout(500, () => { socket.destroy(); process.exit(1); });
257
+ socket.once("connect", () => { socket.destroy(); process.exit(1); });
258
+ socket.once("error", error => process.exit(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? 0 : 1));`;
259
+ const result = spawnSync(process.execPath, ["--no-env-file", "--no-install", "--no-macros", "--no-addons", "--eval", script], {
260
+ input: path, cwd: tmpdir(), env: {}, timeout: 2000, maxBuffer: 1024, stdio: ["pipe", "pipe", "pipe"],
261
+ });
262
+ if (result.error || result.status !== 0) throw new BrowserProfileSnapshotError(activeProfileMessage);
263
+ }
264
+
265
+ function assertChromiumUserDataIdle(userDataRoot: string): string {
266
+ try {
267
+ const root = lstatSync(userDataRoot);
268
+ if (!root.isDirectory() || root.isSymbolicLink() || root.uid !== process.getuid?.()) throw new Error();
269
+ const rootProof = { dev: root.dev, ino: root.ino, ctime: root.ctimeMs };
270
+ const names = readdirSync(userDataRoot).filter((name) => name.startsWith("Singleton") || name === "DevToolsActivePort").sort();
271
+ if (names.length === 0) return JSON.stringify({ root: rootProof, locks: [] });
272
+ if (names.join(",") !== "SingletonCookie,SingletonLock,SingletonSocket") throw new Error();
273
+ const proof = names.map((name) => {
274
+ const path = join(userDataRoot, name);
275
+ const entry = lstatSync(path);
276
+ if (!entry.isSymbolicLink() || entry.uid !== process.getuid?.()) throw new Error();
277
+ return { name, target: readlinkSync(path), dev: entry.dev, ino: entry.ino, ctime: entry.ctimeMs };
278
+ });
279
+ const lock = proof.find((entry) => entry.name === "SingletonLock")!.target;
280
+ const prefix = `${hostname()}-`;
281
+ if (!lock.startsWith(prefix) || !/^[1-9][0-9]*$/.test(lock.slice(prefix.length))) throw new Error();
282
+ const pid = Number(lock.slice(prefix.length));
283
+ if (!Number.isSafeInteger(pid) || pid > 2147483647) throw new Error();
284
+ const socket = proof.find((entry) => entry.name === "SingletonSocket")!.target;
285
+ if (!isAbsolute(socket) || resolve(socket) !== socket || basename(socket) !== "SingletonSocket" || !socket.startsWith(`${tmpdir()}${sep}`)) throw new Error();
286
+ if (!/^[0-9]{1,32}$/.test(proof.find((entry) => entry.name === "SingletonCookie")!.target)) throw new Error();
287
+ assertPidAbsent(pid);
288
+ assertSocketUnavailable(socket);
289
+ assertPidAbsent(pid);
290
+ const finalRoot = lstatSync(userDataRoot);
291
+ if (!sameEntry(root, finalRoot) || finalRoot.ctimeMs !== root.ctimeMs) throw new Error();
292
+ const finalNames = readdirSync(userDataRoot).filter((name) => name.startsWith("Singleton") || name === "DevToolsActivePort").sort();
293
+ if (finalNames.join(",") !== names.join(",")) throw new Error();
294
+ for (const entry of proof) {
295
+ const path = join(userDataRoot, entry.name);
296
+ const current = lstatSync(path);
297
+ if (!current.isSymbolicLink() || current.dev !== entry.dev || current.ino !== entry.ino || current.ctimeMs !== entry.ctime || readlinkSync(path) !== entry.target) throw new Error();
298
+ }
299
+ return JSON.stringify({ root: rootProof, locks: proof });
300
+ } catch {
301
+ throw new BrowserProfileSnapshotError(activeProfileMessage);
250
302
  }
251
303
  }
252
304
 
@@ -356,7 +408,7 @@ export function cloneBrowserProfile(source: string, privateDirectory: string): C
356
408
  if (stateEntry.size > maxLocalStateBytes) {
357
409
  throw new BrowserProfileSnapshotError("Chromium Local State exceeds its safety bound");
358
410
  }
359
- assertChromiumUserDataIdle(userDataRoot);
411
+ const idleProof = assertChromiumUserDataIdle(userDataRoot);
360
412
  const userData = join(privateDirectory, "profile-user-data");
361
413
  mkdirSync(userData, { mode: 0o700 });
362
414
  chmodSync(userData, 0o700);
@@ -364,6 +416,9 @@ export function cloneBrowserProfile(source: string, privateDirectory: string): C
364
416
  try {
365
417
  cloneProfile(selectedSource, selectedProfile);
366
418
  copyBoundedLocalState(localState, join(userData, "Local State"));
419
+ if (assertChromiumUserDataIdle(userDataRoot) !== idleProof) {
420
+ throw new BrowserProfileSnapshotError("Chromium profile lock changed while it was copied");
421
+ }
367
422
  return { userDataPath: userData, profileDirectory: "Default" };
368
423
  } catch (error) {
369
424
  rmSync(userData, { recursive: true, force: true });