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 +26 -12
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +1 -0
- package/dist/cli/main.js +13 -11
- package/dist/cli/scope.d.ts +3 -1
- package/dist/cli/scope.js +52 -10
- package/dist/sqlite/database.d.ts +34 -0
- package/dist/sqlite/database.js +127 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/FORMAT.md +2 -2
- package/package.json +1 -1
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.
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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-
|
|
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.
|
|
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
|
-
|
|
221
|
-
(writes `bin/relembra`
|
|
222
|
-
|
|
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
|
-
`
|
|
308
|
-
|
|
309
|
-
git-ignored on `main` and
|
|
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
|
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/commands.js
CHANGED
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;
|
package/dist/cli/scope.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/sqlite/database.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|