relembra 1.0.0-beta.1 → 1.0.0-beta.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
@@ -25,7 +25,7 @@ and storage (an adapter). relembra enforces the rules:
25
25
  - **Measured.** An evaluation harness with leak, scope, retrieval and wake-coverage gates, plus
26
26
  controls that must fail. Results: [docs/eval/](docs/eval/).
27
27
 
28
- Version: 1.0.0-beta.1, a beta ([changelog](CHANGELOG.md),
28
+ Version: 1.0.0-beta.2, a beta ([changelog](CHANGELOG.md),
29
29
  [releases](https://github.com/mneves75/relembra/releases)). Release procedure:
30
30
  [docs/RELEASE.md](docs/RELEASE.md).
31
31
  Design: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
@@ -203,23 +203,36 @@ scope. Outside a repository it is the scope itself. The wake gives each of the t
203
203
  its budget and passes the room one leaves unused to the other, so neither can crowd the other
204
204
  out. Set `RELEMBRA_SCOPE` to the global scope to change what it holds.
205
205
 
206
- Each [GitHub release](https://github.com/mneves75/relembra/releases) carries the macOS arm64
207
- binary `relembra-darwin-arm64`, `SHA256SUMS` and the library archive npm serves. Download the
208
- binary and checksum file, then install them with `install.sh` from a checkout of the same tag:
206
+ Each [GitHub release](https://github.com/mneves75/relembra/releases) carries a native binary
207
+ per platform, `SHA256SUMS` and the library archive npm serves:
208
+
209
+ | Platform | Binary |
210
+ |---|---|
211
+ | macOS arm64 | `relembra-darwin-arm64` |
212
+ | Linux x64 (glibc) | `relembra-linux-x64` |
213
+ | Linux arm64 (glibc) | `relembra-linux-arm64` |
214
+ | Windows x64 | `relembra-windows-x64.exe` |
215
+ | Windows arm64 | `relembra-windows-arm64.exe` |
216
+
217
+ On macOS and Linux, download the binary and checksum file, then install them with `install.sh`
218
+ from a checkout of the same tag:
209
219
 
210
220
  ```sh
211
- sh install.sh relembra-darwin-arm64 SHA256SUMS # verifies the checksum, installs to ~/.local/bin
221
+ sh install.sh relembra-linux-x64 SHA256SUMS # verifies the checksum, installs to ~/.local/bin
212
222
  ```
213
223
 
224
+ On Windows, check the hash with `Get-FileHash` and copy the binary onto `PATH` as
225
+ `relembra.exe`; the session hook needs Git Bash ([docs/CLI.md](docs/CLI.md#windows)).
226
+
214
227
  Before replacing an installed executable, retain a backup and its hash. Verify installed bytes
215
228
  against the release checksum and export metadata from a disposable store
216
- (`relembra: "1.0.0-beta.1"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
229
+ (`relembra: "1.0.0-beta.2"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
217
230
  rollback without opening a live memory store. After verification, `relembra init` prints the
218
231
  session hook for your agent.
219
232
 
220
- On other platforms, build it from a clone with an AOT-capable Bun using `bun run build:cli`
221
- (writes `bin/relembra` and `bin/relembra.sha256`) and install that pair the same way. A local Mac
222
- check does not attest another platform's binary.
233
+ To build it yourself, run `bun run build:cli` from a clone with the pinned Bun
234
+ (`build/bun-toolchain.txt`); it writes `bin/relembra` (`relembra.exe` on Windows) and
235
+ `bin/relembra.sha256`.
223
236
 
224
237
  Commands, flags, exit codes and the JSON envelope: [docs/CLI.md](docs/CLI.md).
225
238
 
@@ -304,9 +317,10 @@ The landing page is `site/`: static files, no build step besides `bun run build:
304
317
  the library into `site/assets/relembra.js` so the page's demos run the real code in the browser
305
318
  (`test/site.test.ts` fails while that file is stale). Preview it with
306
319
  `python3 -m http.server --directory site`, or open `site/index.html` directly.
307
- `scripts/site/publish.sh` publishes a committed `site/` to the `gh-pages` branch for GitHub Pages
308
- (`--dry-run` shows the tree without pushing); the explainer video in `site/assets/video/` is
309
- git-ignored on `main` and ships only there.
320
+ The `pages` workflow (`gh workflow run pages.yml --ref main`) publishes it to GitHub Pages with
321
+ OIDC, no stored token: `scripts/site/assemble.sh <new directory>` builds what ships from the
322
+ commit's `site/` plus the explainer video, which is git-ignored on `main` and kept on the
323
+ `gh-pages` branch, checked against `scripts/site/video.sha256`.
310
324
 
311
325
  Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: [SECURITY.md](SECURITY.md).
312
326
 
@@ -36,5 +36,5 @@ export declare const COMMANDS: readonly CommandSpec[];
36
36
  /** Flags every command takes. */
37
37
  export declare const GLOBAL_FLAGS: readonly ["--json", "--help"];
38
38
  /** The environment the CLI reads, in the order docs/CLI.md lists it. */
39
- export declare const ENV_VARS: readonly ["RELEMBRA_HOME", "RELEMBRA_SCOPE", "RELEMBRA_GLOBAL_SCOPE", "RELEMBRA_AGENT", "RELEMBRA_HARNESS", "RELEMBRA_MODEL", "BUN_OPTIONS", "BUN_BYTECODE_ORDER_OUT", "PATH", "HOME", "XDG_CONFIG_HOME"];
39
+ export declare const ENV_VARS: readonly ["RELEMBRA_HOME", "RELEMBRA_SCOPE", "RELEMBRA_GLOBAL_SCOPE", "RELEMBRA_AGENT", "RELEMBRA_HARNESS", "RELEMBRA_MODEL", "BUN_OPTIONS", "BUN_BYTECODE_ORDER_OUT", "PATH", "HOME", "XDG_CONFIG_HOME", "SystemRoot"];
40
40
  export declare function usageText(): string;
@@ -99,6 +99,7 @@ export const ENV_VARS = [
99
99
  "PATH",
100
100
  "HOME",
101
101
  "XDG_CONFIG_HOME",
102
+ "SystemRoot",
102
103
  ];
103
104
  export function usageText() {
104
105
  return [
package/dist/cli/main.js CHANGED
@@ -21,6 +21,7 @@ import { LATEST_EVENT_FORMAT, } from "../model.js";
21
21
  import { importOptmem } from "../optmem.js";
22
22
  import { createRelembra } from "../relembra.js";
23
23
  import { identityEgress, renderMemory } from "../render.js";
24
+ import { OWNER_MODES } from "../sqlite/database.js";
24
25
  import { openSqliteStore } from "../sqlite/index.js";
25
26
  import { exportJsonl, importJsonl, MAX_IMPORT_BYTES } from "../transfer.js";
26
27
  import { flagValue, hasFlag, intFlag, parseArgs, wantsJson } from "./args.js";
@@ -38,10 +39,10 @@ const MAX_BUDGET = 1_000_000;
38
39
  const MAX_AGE_DAYS = 36_500;
39
40
  const MAX_SOURCES = 100;
40
41
  /** Reads the session's scope and the global one; writes, pins and owns the session's only. */
41
- function principalFor(scopes, trustCeiling) {
42
+ function principalFor(env, scopes, trustCeiling) {
42
43
  const scope = scopes.session;
43
44
  return {
44
- id: osUser(),
45
+ id: osUser(env),
45
46
  readable: scopes.global === undefined ? [scope] : [scope, scopes.global],
46
47
  writable: [scope],
47
48
  promotable: [],
@@ -57,8 +58,8 @@ function principalFor(scopes, trustCeiling) {
57
58
  * (`denied:above-ceiling`), as it would be without the owner's confirmation. Export needs
58
59
  * ownership only, not trust.
59
60
  */
60
- function transferPrincipal(scope) {
61
- return principalFor({ session: scope }, "agent");
61
+ function transferPrincipal(env, scope) {
62
+ return principalFor(env, { session: scope }, "agent");
62
63
  }
63
64
  function provenanceFrom(context) {
64
65
  const { parsed, env } = context;
@@ -189,7 +190,7 @@ function hookCommand() {
189
190
  * with harness `mcp` by default. Log lines go to stderr; stdout carries protocol messages only.
190
191
  */
191
192
  async function serveStdio(env, scopes, store) {
192
- const principal = principalFor(scopes, "agent");
193
+ const principal = principalFor(env, scopes, "agent");
193
194
  const egress = identityEgress;
194
195
  const relembra = createRelembra({ store, policy: () => principal, egress });
195
196
  const pick = (variable, fallback) => variable === undefined || variable === "" ? fallback : variable;
@@ -249,7 +250,7 @@ async function runStoreCommand(context, scopes, store, path) {
249
250
  });
250
251
  const relembra = createRelembra({
251
252
  store,
252
- policy: () => principalFor(scopes, ceiling),
253
+ policy: () => principalFor(context.env, scopes, ceiling),
253
254
  ...(maxAgeDays === undefined ? {} : { retention: [{ scope, maxAgeDays }] }),
254
255
  });
255
256
  switch (name) {
@@ -399,7 +400,7 @@ async function runStoreCommand(context, scopes, store, path) {
399
400
  case "check":
400
401
  return check(scope, store, path);
401
402
  case "export": {
402
- const jsonl = await exportJsonl(store, transferPrincipal(scope), { scope });
403
+ const jsonl = await exportJsonl(store, transferPrincipal(context.env, scope), { scope });
403
404
  const [header, ...events] = jsonl
404
405
  .split("\n")
405
406
  .slice(0, -1)
@@ -412,7 +413,7 @@ async function runStoreCommand(context, scopes, store, path) {
412
413
  throw usage("missing-argument", "import takes a file, or --optmem <dir>, not both");
413
414
  }
414
415
  const report = optmem === undefined
415
- ? await importJsonl(store, transferPrincipal(scope), readImportFile(context, first))
416
+ ? await importJsonl(store, transferPrincipal(context.env, scope), readImportFile(context, first))
416
417
  : await importFromOptmem(context, scope, store, optmem);
417
418
  return { data: report, text: reportText(report) };
418
419
  }
@@ -453,7 +454,7 @@ async function importFromOptmem(context, scope, store, dir) {
453
454
  }
454
455
  const log = readRegularFile(join(real, "LOG.txt"), "the OptMem log");
455
456
  const { agent, harness, model, host } = provenanceFrom(context);
456
- return importOptmem(store, transferPrincipal(scope), {
457
+ return importOptmem(store, transferPrincipal(context.env, scope), {
457
458
  scope,
458
459
  store: createHash("sha256").update(real).digest("hex").slice(0, 16),
459
460
  log,
@@ -540,7 +541,7 @@ function parseSource(value) {
540
541
  */
541
542
  async function check(scope, store, path) {
542
543
  const homeMode = Number(lstatSync(dirname(path)).mode) & 0o777;
543
- if ((homeMode & 0o077) !== 0) {
544
+ if (OWNER_MODES && (homeMode & 0o077) !== 0) {
544
545
  throw new CliError("store-file", "the store's directory grants group or others access", "mode");
545
546
  }
546
547
  const db = new DatabaseSync(path, { readOnly: true });
@@ -747,7 +748,7 @@ function fail(error, json, write) {
747
748
  }
748
749
  if (import.meta.main) {
749
750
  // Only the variables docs/CLI.md lists are read.
750
- const { RELEMBRA_HOME, RELEMBRA_SCOPE, RELEMBRA_GLOBAL_SCOPE, RELEMBRA_AGENT, RELEMBRA_HARNESS, RELEMBRA_MODEL, BUN_OPTIONS, BUN_BYTECODE_ORDER_OUT, PATH, HOME, XDG_CONFIG_HOME, } = process.env;
751
+ const { RELEMBRA_HOME, RELEMBRA_SCOPE, RELEMBRA_GLOBAL_SCOPE, RELEMBRA_AGENT, RELEMBRA_HARNESS, RELEMBRA_MODEL, BUN_OPTIONS, BUN_BYTECODE_ORDER_OUT, PATH, HOME, XDG_CONFIG_HOME, SystemRoot, } = process.env;
751
752
  const env = {
752
753
  RELEMBRA_HOME,
753
754
  RELEMBRA_SCOPE,
@@ -760,6 +761,7 @@ if (import.meta.main) {
760
761
  PATH,
761
762
  HOME,
762
763
  XDG_CONFIG_HOME,
764
+ SystemRoot,
763
765
  };
764
766
  const code = await main(process.argv.slice(2), env, process.cwd(), process.execArgv);
765
767
  process.exitCode = code;
@@ -14,13 +14,15 @@ export interface CliEnv {
14
14
  readonly PATH?: string | undefined;
15
15
  readonly HOME?: string | undefined;
16
16
  readonly XDG_CONFIG_HOME?: string | undefined;
17
+ /** Windows only: the Windows folder, whose System32 holds whoami.exe; default `C:\Windows`. */
18
+ readonly SystemRoot?: string | undefined;
17
19
  }
18
20
  /**
19
21
  * The OS user, as `uid-<uid>`. Not the user name: Bun's os.userInfo().username reads $USER and
20
22
  * answers "unknown" when it is unset (measured on Bun 1.4.4; Node asks getpwuid), so a hook run
21
23
  * without USER would land in a second scope. The uid is the same in every environment.
22
24
  */
23
- export declare function osUser(): string;
25
+ export declare function osUser(env: CliEnv): string;
24
26
  /** The store file: RELEMBRA_HOME/relembra.db, RELEMBRA_HOME defaulting to ~/.relembra. */
25
27
  export declare function storePath(env: CliEnv, cwd: string): string;
26
28
  /** The global scope RELEMBRA_GLOBAL_SCOPE names, checked like RELEMBRA_SCOPE; undefined unset. */
package/dist/cli/scope.js CHANGED
@@ -2,21 +2,41 @@ import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
6
6
  import { findGuardViolation } from "../guard.js";
7
7
  import { MAX_SCOPE_BYTES, utf8Bytes } from "../render.js";
8
+ import { windowsUserSid } from "../sqlite/database.js";
8
9
  import { CliError } from "./errors.js";
9
10
  /**
10
11
  * The OS user, as `uid-<uid>`. Not the user name: Bun's os.userInfo().username reads $USER and
11
12
  * answers "unknown" when it is unset (measured on Bun 1.4.4; Node asks getpwuid), so a hook run
12
13
  * without USER would land in a second scope. The uid is the same in every environment.
13
14
  */
14
- export function osUser() {
15
+ export function osUser(env) {
16
+ if (process.platform === "win32")
17
+ return windowsUser(env.SystemRoot);
15
18
  const uid = process.getuid?.();
16
19
  if (uid === undefined)
17
20
  throw new CliError("no-scope", "this platform has no OS user id");
18
21
  return `uid-${uid}`;
19
22
  }
23
+ /**
24
+ * Windows has no uid: the user is `sid-<SID>`, the logon account's security identifier, which,
25
+ * like a uid, is one per account (`PC\alex` and `CORP\alex` differ) and survives a rename. It
26
+ * comes from the process token through whoami, never from os.userInfo(): Bun reads that from
27
+ * %USERNAME% on Windows (seen on the hosted runners), which the hook's `env -i` clears and any
28
+ * caller can set. An account whoami cannot name is refused, never mapped to a shared scope.
29
+ * windowsUserSid runs whoami once per process, though the policy builds a principal for every
30
+ * operation.
31
+ */
32
+ function windowsUser(systemRoot) {
33
+ try {
34
+ return `sid-${windowsUserSid(systemRoot)}`;
35
+ }
36
+ catch {
37
+ throw new CliError("no-scope", "no scope: the OS did not name the user; set RELEMBRA_SCOPE");
38
+ }
39
+ }
20
40
  /** The store file: RELEMBRA_HOME/relembra.db, RELEMBRA_HOME defaulting to ~/.relembra. */
21
41
  export function storePath(env, cwd) {
22
42
  const home = env.RELEMBRA_HOME;
@@ -46,7 +66,7 @@ export function resolveScope(env, cwd) {
46
66
  if (found === undefined)
47
67
  return global;
48
68
  const hash = createHash("sha256").update(found.key).digest("hex").slice(0, 16);
49
- return checkScope(`${osUser()}/${found.name}-${hash}`);
69
+ return checkScope(`${osUser(env)}/${found.name}-${hash}`);
50
70
  }
51
71
  /**
52
72
  * The scopes the session reads (src/cli/wake.ts): its own (resolveScope) and the global scope
@@ -59,6 +79,9 @@ export function resolveScopes(env, cwd) {
59
79
  const global = resolveGlobalScope(env);
60
80
  return global === undefined || global === session ? { session } : { session, global };
61
81
  }
82
+ const WINDOWS = process.platform === "win32";
83
+ /** Git's file name: Windows runs only files named .exe. */
84
+ const GIT = WINDOWS ? "git.exe" : "git";
62
85
  /** Git gets this long to answer each question before the scope is refused. */
63
86
  const GIT_TIMEOUT_MS = 2_000;
64
87
  /** Characters a path or URL from git may not hold: control characters, a newline among them. */
@@ -82,18 +105,20 @@ function hostGit(env, cwd) {
82
105
  if (dirname(dir) === dir)
83
106
  break;
84
107
  }
85
- const inside = (dir) => fences.some((f) => dir === f || dir.startsWith(`${f}/`));
108
+ // Windows paths ignore case, so a fence compared by bytes would let C:\REPO\bin past C:\repo.
109
+ const fold = (path) => (WINDOWS ? path.toLowerCase() : path);
110
+ const inside = (dir) => fences.some((f) => fold(dir) === fold(f) || fold(dir).startsWith(fold(`${f}${sep}`)));
86
111
  const entries = (env.PATH ?? "")
87
- .split(":")
112
+ .split(delimiter)
88
113
  .filter((entry) => isAbsolute(entry) && !inside(safeRealpath(entry)));
89
114
  for (const entry of entries) {
90
115
  try {
91
- const candidate = realpathSync(join(entry, "git"));
116
+ const candidate = realpath(join(entry, GIT));
92
117
  if (inside(candidate))
93
118
  continue;
94
119
  if (statSync(candidate).isFile()) {
95
120
  accessSync(candidate, constants.X_OK);
96
- return { git: candidate, path: entries.join(":") };
121
+ return { git: candidate, path: entries.join(delimiter) };
97
122
  }
98
123
  }
99
124
  catch {
@@ -217,6 +242,10 @@ export function normalizeRemote(url, base) {
217
242
  if (port === DEFAULT_PORTS[name])
218
243
  port = "";
219
244
  }
245
+ else if (WINDOWS && /^[A-Za-z]:[\\/]/.test(rest)) {
246
+ // A drive path, which git on Windows reads as local, never as an scp-like host "C".
247
+ return localKey(rest, base);
248
+ }
220
249
  else {
221
250
  // scp-like `[user@]host:path`, as git reads it: a colon before any slash.
222
251
  const scp = /^(?:[^@/]+@)?(\[[^\]]*\]|[^/:]+):(.*)$/.exec(rest);
@@ -259,15 +288,28 @@ function filePath(afterScheme) {
259
288
  if (UNSUPPORTED.test(path)) {
260
289
  throw new CliError("no-scope", "no scope: the origin's file URL decodes to a control character; set RELEMBRA_SCOPE", "unsupported-path");
261
290
  }
262
- return path;
291
+ // On Windows a drive path is written /C:/x in the URL; the path is C:/x.
292
+ return WINDOWS && /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
263
293
  }
294
+ /**
295
+ * The key of a local origin: `file:` and its real path, with forward slashes on Windows too, so
296
+ * the project name beside the hash is the last folder, never the whole path.
297
+ */
264
298
  function localKey(path, base) {
265
299
  const absolute = isAbsolute(path) ? path : resolve(base(), path);
266
- return `file:${safeRealpath(absolute).replace(/(?<=.)\/+$/, "")}`;
300
+ const real = safeRealpath(absolute);
301
+ return `file:${(WINDOWS ? real.replaceAll("\\", "/") : real).replace(/(?<=.)\/+$/, "")}`;
302
+ }
303
+ /**
304
+ * The real path. On Windows the native call, which also gives the canonical case and long names,
305
+ * so one folder reached as C:\Repo and c:\repo is one project and one fence.
306
+ */
307
+ function realpath(path) {
308
+ return WINDOWS ? realpathSync.native(path) : realpathSync(path);
267
309
  }
268
310
  function safeRealpath(path) {
269
311
  try {
270
- return realpathSync(path);
312
+ return realpath(path);
271
313
  }
272
314
  catch {
273
315
  return resolve(path);
@@ -30,12 +30,46 @@ export declare class StoreUnverifiedError extends Error {
30
30
  export declare function isBusy(error: unknown): boolean;
31
31
  /** Refuses a busy timeout that would make waits unbounded or meaningless. */
32
32
  export declare function checkBusyTimeout(busyTimeoutMs: number): number;
33
+ /** Whether file modes say who may read a file: every platform but Windows. */
34
+ export declare const OWNER_MODES: boolean;
33
35
  /**
34
36
  * Creates missing directories as 0700 and a missing file as 0600, then checks the files with
35
37
  * checkOwnerOnly. A symbolic link at the path is refused before anything is created, so no file
36
38
  * appears behind it.
37
39
  */
38
40
  export declare function createOwnerOnly(path: string): void;
41
+ /**
42
+ * Windows' System32 folder under `systemRoot` (%SystemRoot%). An empty or relative value falls
43
+ * back to `C:\Windows`: a relative executable path would be resolved against the working
44
+ * directory, which may be a repository's.
45
+ */
46
+ export declare function windowsSystem32(systemRoot: string | undefined): string;
47
+ /**
48
+ * The logon account's SID, from the process token as whoami prints it. Thrown when none. The
49
+ * account cannot change while the process runs, so whoami runs once: the CLI's scope and a new
50
+ * store's ACL both ask.
51
+ */
52
+ export declare function windowsUserSid(systemRoot: string | undefined): string;
53
+ /**
54
+ * Windows: makes the current user the folder's owner, then replaces its ACL with full control for
55
+ * the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
56
+ * always rewrite an ACL, so a folder another account made would otherwise give that account its
57
+ * access back; `/setowner` fails, and no store is created, unless the user may take ownership.
58
+ * `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
59
+ * that never widens access: another process may already have made the store here (two first
60
+ * opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
61
+ * store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
62
+ * ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
63
+ * Accounts are named by SID, so no account name or system language is involved. Any failure is
64
+ * thrown: a store is never created in a folder others may read.
65
+ */
66
+ export declare function restrictFolderToOwner(dir: string): void;
67
+ /**
68
+ * The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
69
+ * A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
70
+ * three would let a foreign grant pass as the third.
71
+ */
72
+ export declare function storeFolderAccounts(userSid: string): string[];
39
73
  /**
40
74
  * The store file must exist. It and any -wal or -shm file must be regular files (lstat never
41
75
  * follows a symbolic link) that grant group and others nothing. A loose file is refused, never
@@ -1,5 +1,6 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
+ import { dirname, win32 } from "node:path";
3
4
  import { DatabaseSync } from "node:sqlite";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import { StoreBusyError } from "../errors.js";
@@ -52,6 +53,8 @@ export function checkBusyTimeout(busyTimeoutMs) {
52
53
  }
53
54
  return busyTimeoutMs;
54
55
  }
56
+ /** Whether file modes say who may read a file: every platform but Windows. */
57
+ export const OWNER_MODES = process.platform !== "win32";
55
58
  /**
56
59
  * Creates missing directories as 0700 and a missing file as 0600, then checks the files with
57
60
  * checkOwnerOnly. A symbolic link at the path is refused before anything is created, so no file
@@ -61,6 +64,21 @@ export function createOwnerOnly(path) {
61
64
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
65
  if (isSymbolicLink(path))
63
66
  throw new StoreFileTypeError(path);
67
+ // Windows ignores the modes: before a new store's first byte, its folder gets an ACL of its own,
68
+ // so the store and the -wal and -shm files SQLite makes beside it inherit owner-only access.
69
+ if (!OWNER_MODES && !existsSync(path)) {
70
+ restrictFolderToOwner(dirname(path));
71
+ // A sidecar without its store was put there by someone: it keeps its own ACL, and Windows
72
+ // opens a file by its full path whatever the folder grants, so SQLite would write into a file
73
+ // another account can read. Checked after the ACL, when no other account can add one. A
74
+ // sidecar beside a store that exists by now belongs to another process that created the
75
+ // store first (its folder got the same three grants), so it is no plant.
76
+ for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) {
77
+ if (hasEntry(sidecar) && !hasEntry(path)) {
78
+ throw new Error(`${sidecar} exists without its store; no store is created beside it`);
79
+ }
80
+ }
81
+ }
64
82
  try {
65
83
  // O_EXCL never follows a link, so a link made since the check fails here with EEXIST.
66
84
  closeSync(openSync(path, "wx", 0o600));
@@ -71,6 +89,89 @@ export function createOwnerOnly(path) {
71
89
  }
72
90
  checkOwnerOnly(path);
73
91
  }
92
+ /**
93
+ * Windows' System32 folder under `systemRoot` (%SystemRoot%). An empty or relative value falls
94
+ * back to `C:\Windows`: a relative executable path would be resolved against the working
95
+ * directory, which may be a repository's.
96
+ */
97
+ export function windowsSystem32(systemRoot) {
98
+ const root = systemRoot !== undefined && win32.isAbsolute(systemRoot) && /^[A-Za-z]:/.test(systemRoot)
99
+ ? systemRoot
100
+ : "C:\\Windows";
101
+ return win32.join(root, "System32");
102
+ }
103
+ /** Runs a System32 tool; any failure is thrown with its output. */
104
+ function runSystemTool(systemRoot, exe, args) {
105
+ const result = spawnSync(win32.join(windowsSystem32(systemRoot), exe), args, {
106
+ encoding: "utf8",
107
+ shell: false,
108
+ windowsHide: true,
109
+ timeout: 10_000,
110
+ });
111
+ if (result.status !== 0) {
112
+ throw new Error(`${exe} failed (exit ${result.status}): ${result.stderr || result.stdout}`);
113
+ }
114
+ return result.stdout;
115
+ }
116
+ let userSid;
117
+ /**
118
+ * The logon account's SID, from the process token as whoami prints it. Thrown when none. The
119
+ * account cannot change while the process runs, so whoami runs once: the CLI's scope and a new
120
+ * store's ACL both ask.
121
+ */
122
+ export function windowsUserSid(systemRoot) {
123
+ if (userSid !== undefined)
124
+ return userSid;
125
+ // `"HOST\user","S-1-5-21-..."`: the SID is the last quoted field.
126
+ const printed = runSystemTool(systemRoot, "whoami.exe", ["/user", "/fo", "csv", "/nh"]);
127
+ const sid = /"(S-1-[0-9-]+)"\s*$/.exec(printed)?.[1];
128
+ if (sid === undefined)
129
+ throw new Error("whoami did not print the user's SID");
130
+ userSid = sid;
131
+ return sid;
132
+ }
133
+ /**
134
+ * Windows: makes the current user the folder's owner, then replaces its ACL with full control for
135
+ * the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
136
+ * always rewrite an ACL, so a folder another account made would otherwise give that account its
137
+ * access back; `/setowner` fails, and no store is created, unless the user may take ownership.
138
+ * `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
139
+ * that never widens access: another process may already have made the store here (two first
140
+ * opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
141
+ * store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
142
+ * ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
143
+ * Accounts are named by SID, so no account name or system language is involved. Any failure is
144
+ * thrown: a store is never created in a folder others may read.
145
+ */
146
+ export function restrictFolderToOwner(dir) {
147
+ // icacls follows a junction or symbolic link: the owner and ACL of its target would change.
148
+ if (lstatSync(dir).isSymbolicLink())
149
+ throw new Error(`${dir} is a link; no store is created in it`);
150
+ const systemRoot = process.env["SystemRoot"];
151
+ const sid = windowsUserSid(systemRoot);
152
+ const accounts = storeFolderAccounts(sid);
153
+ runSystemTool(systemRoot, "icacls.exe", [dir, "/setowner", `*${sid}`]);
154
+ runSystemTool(systemRoot, "icacls.exe", [
155
+ dir,
156
+ "/inheritance:r",
157
+ ...accounts.flatMap((who) => ["/grant:r", `*${who}:(OI)(CI)F`]),
158
+ ]);
159
+ // icacls prints one `name:(flags)` entry per line; the path, on the first, holds no `:(`.
160
+ const entries = runSystemTool(systemRoot, "icacls.exe", [dir])
161
+ .split(/\r?\n/)
162
+ .filter((line) => line.includes(":("));
163
+ if (entries.length !== accounts.length) {
164
+ throw new Error(`${dir} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created in it: remove those grants or use another folder`);
165
+ }
166
+ }
167
+ /**
168
+ * The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
169
+ * A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
170
+ * three would let a foreign grant pass as the third.
171
+ */
172
+ export function storeFolderAccounts(userSid) {
173
+ return [...new Set([userSid, "S-1-5-18", "S-1-5-32-544"])];
174
+ }
74
175
  /**
75
176
  * The store file must exist. It and any -wal or -shm file must be regular files (lstat never
76
177
  * follows a symbolic link) that grant group and others nothing. A loose file is refused, never
@@ -90,9 +191,14 @@ export function checkOwnerOnly(path) {
90
191
  }
91
192
  if (!stats.isFile())
92
193
  throw new StoreFileTypeError(file);
93
- const mode = Number(stats.mode) & 0o777;
94
- if ((mode & 0o077) !== 0)
95
- throw new StoreFileModeError(file, mode);
194
+ // Windows has no mode bits: Node and Bun report 0o666 for every writable file, whatever its
195
+ // access control list grants. There the store relies on its folder's ACL, which under the
196
+ // user profile grants only the user, SYSTEM and Administrators (docs/CLI.md, Windows).
197
+ if (OWNER_MODES) {
198
+ const mode = Number(stats.mode) & 0o777;
199
+ if ((mode & 0o077) !== 0)
200
+ throw new StoreFileModeError(file, mode);
201
+ }
96
202
  }
97
203
  }
98
204
  function isSymbolicLink(path) {
@@ -105,6 +211,18 @@ function isSymbolicLink(path) {
105
211
  throw error;
106
212
  }
107
213
  }
214
+ /** Whether a directory entry exists at `path`: a link counts, even one whose target is missing. */
215
+ function hasEntry(path) {
216
+ try {
217
+ lstatSync(path);
218
+ return true;
219
+ }
220
+ catch (error) {
221
+ if (error.code === "ENOENT")
222
+ return false;
223
+ throw error;
224
+ }
225
+ }
108
226
  /**
109
227
  * True for SQLITE_CANTOPEN (14) and SQLITE_READONLY (8), with their extended codes: what a
110
228
  * read-only connection answers when only a write-capable one could proceed, for example
@@ -150,6 +268,11 @@ function isTorn(error) {
150
268
  function readOnlySchemaVersion(path, busyTimeoutMs) {
151
269
  const deadline = Date.now() + busyTimeoutMs;
152
270
  for (let delay = 1;; delay = Math.min(delay * 2, 32)) {
271
+ // A -wal without its -shm: only a writer could rebuild the index, so the version comes from
272
+ // the image straight away. A read-only connection would refuse it on POSIX, but on Windows it
273
+ // creates the -shm or answers SQLITE_PROTOCOL.
274
+ if (existsSync(`${path}-wal`) && !existsSync(`${path}-shm`))
275
+ return imageSchemaVersion(path);
153
276
  const sidecars = existsSync(`${path}-wal`) || existsSync(`${path}-shm`);
154
277
  const parameters = sidecars ? "mode=ro&readonly_shm=1" : "mode=ro&immutable=1";
155
278
  let db;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This relembra's version, as package.json gives it. An export's header carries it. */
2
- export declare const RELEMBRA_VERSION = "1.0.0-beta.1";
2
+ export declare const RELEMBRA_VERSION = "1.0.0-beta.2";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This relembra's version, as package.json gives it. An export's header carries it. */
2
- export const RELEMBRA_VERSION = "1.0.0-beta.1";
2
+ export const RELEMBRA_VERSION = "1.0.0-beta.2";
package/docs/FORMAT.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # relembra storage format
2
2
 
3
- Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.1.
3
+ Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.2.
4
4
 
5
5
  This document is the contract for what relembra stores. A reader that follows it needs no
6
6
  relembra code.
@@ -175,7 +175,7 @@ the events above, so a reader needs only this document.
175
175
  |---|---|---|
176
176
  | `export` | string | Always `relembra`. What makes the file an export. |
177
177
  | `format` | integer | The newest event format of the lines after it: `1` when none is a `promote` line, so a format 1 reader still reads the file, and `2` when one is. |
178
- | `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.1`. Informational: a reader decides by `format` alone. |
178
+ | `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.2`. Informational: a reader decides by `format` alone. |
179
179
  | `scopes` | array of strings | The scopes exported. Every event's `scope` is one of them. |
180
180
 
181
181
  ### Events (lines 2 onward)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relembra",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "A curated, scoped, bounded, durable, erasable and measured memory for AI agents.",
5
5
  "license": "MIT",
6
6
  "author": "mneves75",