@nylorun/runtime 0.9.0-beta → 0.10.0-beta

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +96 -25
  3. package/dist/adapters/media.d.ts +2 -2
  4. package/dist/adapters/media.js +2 -2
  5. package/dist/adapters/observe.d.ts +2 -1
  6. package/dist/adapters/observe.js +2 -3
  7. package/dist/assets.d.ts +2 -2
  8. package/dist/assets.js +5 -3
  9. package/dist/core/executors.d.ts +11 -2
  10. package/dist/core/executors.js +20 -4
  11. package/dist/core/flow-host.d.ts +264 -0
  12. package/dist/core/flow-host.js +783 -0
  13. package/dist/core/limits.d.ts +40 -0
  14. package/dist/core/limits.js +76 -0
  15. package/dist/core/runtime.d.ts +5 -90
  16. package/dist/core/runtime.js +3 -1447
  17. package/dist/core/sandbox-routes.d.ts +35 -0
  18. package/dist/core/sandbox-routes.js +106 -0
  19. package/dist/core/store.d.ts +3 -1
  20. package/dist/core/store.js +14 -73
  21. package/dist/core/turn-manifest.d.ts +50 -0
  22. package/dist/core/turn-manifest.js +66 -0
  23. package/dist/host/config-for.d.ts +16 -0
  24. package/dist/host/config-for.js +45 -0
  25. package/dist/host/config.d.ts +24 -0
  26. package/dist/host/create-host.d.ts +21 -0
  27. package/dist/host/create-host.js +396 -0
  28. package/dist/host/environment.d.ts +13 -0
  29. package/dist/host/environment.js +53 -0
  30. package/dist/host/http.d.ts +35 -0
  31. package/dist/host/http.js +136 -0
  32. package/dist/host/index.d.ts +6 -0
  33. package/dist/host/index.js +5 -0
  34. package/dist/host/logger.d.ts +3 -0
  35. package/dist/host/logger.js +18 -0
  36. package/dist/host/main.d.ts +3 -0
  37. package/dist/host/main.js +142 -0
  38. package/dist/index.d.ts +7 -2
  39. package/dist/index.js +4 -2
  40. package/dist/launcher/commands.d.ts +24 -0
  41. package/dist/launcher/commands.js +260 -0
  42. package/dist/launcher/ensure-config.d.ts +16 -0
  43. package/dist/launcher/ensure-config.js +79 -0
  44. package/dist/launcher/errors.d.ts +12 -0
  45. package/dist/launcher/errors.js +22 -0
  46. package/dist/launcher/host-config.d.ts +17 -0
  47. package/dist/launcher/host-config.js +64 -0
  48. package/dist/launcher/host-state.d.ts +17 -0
  49. package/dist/launcher/host-state.js +61 -0
  50. package/dist/launcher/lifecycle.d.ts +66 -0
  51. package/dist/launcher/lifecycle.js +407 -0
  52. package/dist/launcher/locks.d.ts +26 -0
  53. package/dist/launcher/locks.js +117 -0
  54. package/dist/launcher/logs.d.ts +18 -0
  55. package/dist/launcher/logs.js +117 -0
  56. package/dist/launcher/main.d.ts +2 -0
  57. package/dist/launcher/main.js +66 -0
  58. package/dist/launcher/output.d.ts +11 -0
  59. package/dist/launcher/output.js +68 -0
  60. package/dist/launcher/paths.d.ts +20 -0
  61. package/dist/launcher/paths.js +36 -0
  62. package/dist/launcher/probe.d.ts +23 -0
  63. package/dist/launcher/probe.js +72 -0
  64. package/dist/launcher/protocol.d.ts +62 -0
  65. package/dist/launcher/protocol.js +1 -0
  66. package/dist/launcher/schema-guard.d.ts +6 -0
  67. package/dist/launcher/schema-guard.js +36 -0
  68. package/dist/launcher/spawn.d.ts +22 -0
  69. package/dist/launcher/spawn.js +85 -0
  70. package/dist/launcher/status.d.ts +6 -0
  71. package/dist/launcher/status.js +65 -0
  72. package/dist/mcp/connect.d.ts +1 -0
  73. package/dist/mcp/connect.js +2 -0
  74. package/dist/mcp/pool.d.ts +3 -1
  75. package/dist/mcp/pool.js +2 -1
  76. package/dist/mcp/stdio.d.ts +2 -0
  77. package/dist/mcp/stdio.js +3 -1
  78. package/dist/model/auth-store.d.ts +1 -1
  79. package/dist/model/auth-store.js +2 -2
  80. package/dist/model/models.d.ts +4 -0
  81. package/dist/model/models.js +22 -26
  82. package/dist/model/pi-model.js +1 -1
  83. package/dist/model/settings.d.ts +5 -1
  84. package/dist/model/settings.js +6 -2
  85. package/dist/sandbox/select.d.ts +4 -3
  86. package/dist/sandbox/select.js +15 -9
  87. package/dist/sandbox/share.d.ts +23 -0
  88. package/dist/sandbox/share.js +58 -0
  89. package/dist/tenant/envelope.d.ts +10 -0
  90. package/dist/tenant/envelope.js +54 -0
  91. package/dist/tenant/ephemeral.d.ts +31 -0
  92. package/dist/tenant/ephemeral.js +156 -0
  93. package/dist/tenant/logger.d.ts +9 -0
  94. package/dist/tenant/logger.js +25 -0
  95. package/dist/tenant/migration.d.ts +16 -0
  96. package/dist/tenant/migration.js +116 -0
  97. package/dist/tenant/module.d.ts +17 -0
  98. package/dist/tenant/module.js +315 -0
  99. package/dist/tenant/paths.d.ts +24 -0
  100. package/dist/tenant/paths.js +65 -0
  101. package/dist/tenant/pool.d.ts +10 -0
  102. package/dist/tenant/pool.js +54 -0
  103. package/dist/tenant/principals.d.ts +17 -0
  104. package/dist/tenant/principals.js +44 -0
  105. package/dist/tenant/quarantine-error.d.ts +6 -0
  106. package/dist/tenant/quarantine-error.js +15 -0
  107. package/dist/tenant/quarantine.d.ts +35 -0
  108. package/dist/tenant/quarantine.js +94 -0
  109. package/dist/tenant/reset.d.ts +21 -0
  110. package/dist/tenant/reset.js +125 -0
  111. package/dist/tenant/runtime.d.ts +80 -0
  112. package/dist/tenant/runtime.js +2256 -0
  113. package/dist/tenant/schema.d.ts +23 -0
  114. package/dist/tenant/schema.js +151 -0
  115. package/dist/tenant/status.d.ts +28 -0
  116. package/dist/tenant/status.js +95 -0
  117. package/dist/tenant/store-fs.d.ts +16 -0
  118. package/dist/tenant/store-fs.js +205 -0
  119. package/dist/tenant/store-memory.d.ts +19 -0
  120. package/dist/tenant/store-memory.js +100 -0
  121. package/dist/tenant/types.d.ts +125 -0
  122. package/dist/tenant/types.js +1 -0
  123. package/dist/vault/kek.d.ts +5 -2
  124. package/dist/vault/kek.js +7 -10
  125. package/dist/version.d.ts +1 -1
  126. package/dist/version.js +1 -1
  127. package/package.json +9 -6
  128. package/dist/core/main.js +0 -35
  129. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -0,0 +1,23 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ /** Tenant schema version (D4). v2 adds tenant_settings; v3 adds workflow links. */
3
+ export declare const TENANT_SCHEMA_VERSION = 3;
4
+ export declare function schemaVersionOf(db: DatabaseSync): number;
5
+ /**
6
+ * Migrates a Tenant database to `TENANT_SCHEMA_VERSION`.
7
+ * Carries vault-scope DDL as a migration step, not ad hoc (D3, D4).
8
+ */
9
+ export declare function migrateTenantDatabase(db: DatabaseSync): {
10
+ from: number;
11
+ to: number;
12
+ };
13
+ export type SandboxBackendSetting = "auto" | "microsandbox" | "virtual";
14
+ /** Non-secret Tenant settings persisted for seed / configFor (A18). */
15
+ export declare function readTenantSetting(db: DatabaseSync, key: string): string | undefined;
16
+ export declare function writeTenantSetting(db: DatabaseSync, key: string, value: string): void;
17
+ /**
18
+ * Read non-secret Tenant configuration from the open database.
19
+ * Used by Host `configFor` at Integration I1.
20
+ */
21
+ export declare function readTenantConfig(db: DatabaseSync): {
22
+ sandboxBackend?: SandboxBackendSetting;
23
+ };
@@ -0,0 +1,151 @@
1
+ /** Tenant schema version (D4). v2 adds tenant_settings; v3 adds workflow links. */
2
+ export const TENANT_SCHEMA_VERSION = 3;
3
+ export function schemaVersionOf(db) {
4
+ const row = db.prepare("PRAGMA user_version").get();
5
+ return Number(row.user_version);
6
+ }
7
+ /**
8
+ * Migrates a Tenant database to `TENANT_SCHEMA_VERSION`.
9
+ * Carries vault-scope DDL as a migration step, not ad hoc (D3, D4).
10
+ */
11
+ export function migrateTenantDatabase(db) {
12
+ const from = schemaVersionOf(db);
13
+ if (from > TENANT_SCHEMA_VERSION) {
14
+ throw new Error(`Tenant schema version ${from} is newer than Host ${TENANT_SCHEMA_VERSION}`);
15
+ }
16
+ if (from === TENANT_SCHEMA_VERSION) {
17
+ return { from, to: from };
18
+ }
19
+ db.exec("BEGIN IMMEDIATE");
20
+ try {
21
+ if (from < 1) {
22
+ db.exec(`
23
+ CREATE TABLE IF NOT EXISTS definitions(id TEXT PRIMARY KEY, body TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY, body TEXT NOT NULL);
25
+ CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY, body TEXT NOT NULL);
26
+ CREATE TABLE IF NOT EXISTS checkpoints(id TEXT PRIMARY KEY, body TEXT NOT NULL);
27
+ CREATE TABLE IF NOT EXISTS effects(id TEXT PRIMARY KEY, body TEXT NOT NULL);
28
+ CREATE TABLE IF NOT EXISTS actions(id TEXT PRIMARY KEY, body TEXT NOT NULL);
29
+ CREATE TABLE IF NOT EXISTS sandboxes(id TEXT PRIMARY KEY, body TEXT NOT NULL);
30
+ CREATE TABLE IF NOT EXISTS events(
31
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ session_id TEXT NOT NULL,
33
+ body TEXT NOT NULL
34
+ );
35
+ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, sequence);
36
+ CREATE TABLE IF NOT EXISTS vaults(
37
+ id TEXT PRIMARY KEY,
38
+ name TEXT NOT NULL,
39
+ owner_user_id TEXT NOT NULL,
40
+ metadata_json TEXT,
41
+ created_at TEXT NOT NULL,
42
+ scope TEXT NOT NULL DEFAULT 'user'
43
+ );
44
+ CREATE INDEX IF NOT EXISTS vaults_owner ON vaults(owner_user_id);
45
+ CREATE TABLE IF NOT EXISTS vault_credentials(
46
+ id TEXT PRIMARY KEY,
47
+ vault_id TEXT NOT NULL REFERENCES vaults(id) ON DELETE CASCADE,
48
+ name TEXT NOT NULL,
49
+ type TEXT NOT NULL,
50
+ binding_json TEXT NOT NULL,
51
+ expires_at TEXT,
52
+ created_at TEXT NOT NULL,
53
+ rotated_at TEXT,
54
+ kek_id TEXT NOT NULL,
55
+ nonce BLOB NOT NULL,
56
+ ciphertext BLOB NOT NULL,
57
+ wrapped_dek BLOB NOT NULL
58
+ );
59
+ CREATE INDEX IF NOT EXISTS vault_credentials_vault ON vault_credentials(vault_id);
60
+ CREATE TABLE IF NOT EXISTS vault_audit(
61
+ id TEXT PRIMARY KEY,
62
+ at TEXT NOT NULL,
63
+ actor TEXT NOT NULL,
64
+ action TEXT NOT NULL,
65
+ vault_id TEXT,
66
+ credential_id TEXT,
67
+ session_id TEXT,
68
+ target TEXT,
69
+ outcome TEXT NOT NULL
70
+ );
71
+ CREATE TABLE IF NOT EXISTS vault_idempotency(
72
+ id TEXT PRIMARY KEY,
73
+ body_hash TEXT NOT NULL,
74
+ response TEXT NOT NULL
75
+ );
76
+ CREATE TABLE IF NOT EXISTS executors(
77
+ agent_id TEXT PRIMARY KEY,
78
+ token_hash TEXT NOT NULL UNIQUE,
79
+ implementation_version TEXT NOT NULL,
80
+ manifest_hash TEXT,
81
+ principal_id TEXT,
82
+ created_at TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL
84
+ );
85
+ CREATE TABLE IF NOT EXISTS principals(
86
+ id TEXT PRIMARY KEY,
87
+ role TEXT NOT NULL CHECK(role = 'application'),
88
+ token_hash TEXT NOT NULL UNIQUE,
89
+ idempotency_key TEXT,
90
+ created_at TEXT NOT NULL
91
+ );
92
+ `);
93
+ migrateVaultScope(db);
94
+ ensureExecutorPrincipalColumn(db);
95
+ }
96
+ if (from < 2) {
97
+ db.exec(`
98
+ CREATE TABLE IF NOT EXISTS tenant_settings(
99
+ key TEXT PRIMARY KEY,
100
+ value TEXT NOT NULL
101
+ );
102
+ `);
103
+ }
104
+ if (from < 3) {
105
+ db.exec(`
106
+ CREATE TABLE IF NOT EXISTS links(id TEXT PRIMARY KEY, body TEXT NOT NULL);
107
+ `);
108
+ }
109
+ db.exec(`PRAGMA user_version = ${TENANT_SCHEMA_VERSION}`);
110
+ db.exec("COMMIT");
111
+ }
112
+ catch (error) {
113
+ db.exec("ROLLBACK");
114
+ throw error;
115
+ }
116
+ return { from, to: TENANT_SCHEMA_VERSION };
117
+ }
118
+ /** Existing vault-scope step, now owned by the Tenant migration. */
119
+ function migrateVaultScope(db) {
120
+ const columns = db.prepare("PRAGMA table_info(vaults)").all();
121
+ if (!columns.some((column) => column.name === "scope"))
122
+ db.exec("ALTER TABLE vaults ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'");
123
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS vaults_one_host ON vaults(scope) WHERE scope = 'host'");
124
+ }
125
+ /** Additive column for registration credential attribution (A6). */
126
+ function ensureExecutorPrincipalColumn(db) {
127
+ const columns = db.prepare("PRAGMA table_info(executors)").all();
128
+ if (!columns.some((column) => column.name === "principal_id"))
129
+ db.exec("ALTER TABLE executors ADD COLUMN principal_id TEXT");
130
+ }
131
+ /** Non-secret Tenant settings persisted for seed / configFor (A18). */
132
+ export function readTenantSetting(db, key) {
133
+ const row = db
134
+ .prepare("SELECT value FROM tenant_settings WHERE key=?")
135
+ .get(key);
136
+ return row ? String(row.value) : undefined;
137
+ }
138
+ export function writeTenantSetting(db, key, value) {
139
+ db.prepare(`INSERT INTO tenant_settings(key,value) VALUES(?,?)
140
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
141
+ }
142
+ /**
143
+ * Read non-secret Tenant configuration from the open database.
144
+ * Used by Host `configFor` at Integration I1.
145
+ */
146
+ export function readTenantConfig(db) {
147
+ const raw = readTenantSetting(db, "sandbox.backend");
148
+ if (raw === "auto" || raw === "microsandbox" || raw === "virtual")
149
+ return { sandboxBackend: raw };
150
+ return {};
151
+ }
@@ -0,0 +1,28 @@
1
+ import type { SeedTenantConfigRequest, SeedTenantConfigResponse, TenantStatus } from "@nylorun/core/contracts";
2
+ import type { Store } from "../core/store.js";
3
+ import type { ExecutorRegistry } from "../core/executors.js";
4
+ import type { VaultService } from "../vault/service.js";
5
+ import type { SandboxManager } from "../sandbox/manager.js";
6
+ import type { TenantConfig } from "./types.js";
7
+ import type { TenantEnvelope } from "@nylorun/core/contracts";
8
+ export interface TenantStatusContext {
9
+ envelope: TenantEnvelope;
10
+ config: TenantConfig;
11
+ store: Store;
12
+ registry: ExecutorRegistry;
13
+ vault: VaultService;
14
+ sandbox: SandboxManager;
15
+ closing: boolean;
16
+ modelConfigured: boolean;
17
+ executorStreams: Map<string, Set<unknown>>;
18
+ }
19
+ /** Build `TenantStatusSchema` with secrets redacted (A14). */
20
+ export declare function buildTenantStatus(ctx: TenantStatusContext): Promise<TenantStatus>;
21
+ /**
22
+ * Insert-if-absent Tenant configuration seed (A18).
23
+ * Response lists field names only; never secret values.
24
+ */
25
+ export declare function seedTenantConfig(ctx: {
26
+ store: Store;
27
+ vault: VaultService;
28
+ }, body: SeedTenantConfigRequest): SeedTenantConfigResponse;
@@ -0,0 +1,95 @@
1
+ import { readTenantSetting, schemaVersionOf, TENANT_SCHEMA_VERSION, writeTenantSetting, } from "./schema.js";
2
+ /** Build `TenantStatusSchema` with secrets redacted (A14). */
3
+ export async function buildTenantStatus(ctx) {
4
+ const sessions = ctx.store.all("sessions");
5
+ const runningSessions = sessions.filter((s) => s.status === "running" || s.status === "runnable").length;
6
+ const pendingActions = ctx.store
7
+ .all("actions")
8
+ .filter((a) => a.status === "pending" || a.status === "claimed").length;
9
+ const uncertainEffects = ctx.store
10
+ .all("effects")
11
+ .filter((e) => e.status === "uncertain").length;
12
+ let sqlite = false;
13
+ try {
14
+ ctx.store.db.prepare("SELECT 1").get();
15
+ sqlite = true;
16
+ }
17
+ catch {
18
+ sqlite = false;
19
+ }
20
+ const schemaOk = schemaVersionOf(ctx.store.db) === TENANT_SCHEMA_VERSION;
21
+ const modelView = ctx.vault.getHostModel();
22
+ const sandboxReport = await ctx.sandbox.report();
23
+ const retained = ctx.store.all("sandboxes").length;
24
+ const definitionIds = new Set(ctx.store
25
+ .all("definitions")
26
+ .map((d) => d.manifest?.id)
27
+ .filter((id) => typeof id === "string"));
28
+ const executorIds = new Set(ctx.registry.list().map((e) => e.agentId));
29
+ const agentIds = new Set([...definitionIds, ...executorIds]);
30
+ const agents = [...agentIds].sort().map((agentId) => {
31
+ const record = ctx.registry.get(agentId);
32
+ return {
33
+ agentId,
34
+ registered: definitionIds.has(agentId),
35
+ connected: !!record &&
36
+ (ctx.executorStreams.get(record.tokenHash)?.size ?? 0) > 0,
37
+ };
38
+ });
39
+ return {
40
+ tenant: ctx.envelope,
41
+ path: ctx.config.paths.root,
42
+ checks: {
43
+ sqlite,
44
+ scheduler: !ctx.closing,
45
+ model: ctx.modelConfigured || modelView.configured,
46
+ executors: true,
47
+ schema: schemaOk,
48
+ },
49
+ model: modelView,
50
+ agents,
51
+ counts: {
52
+ sessions: sessions.length,
53
+ runningSessions,
54
+ pendingActions,
55
+ uncertainEffects,
56
+ },
57
+ sandbox: {
58
+ backend: sandboxReport.backend,
59
+ retained,
60
+ },
61
+ };
62
+ }
63
+ /**
64
+ * Insert-if-absent Tenant configuration seed (A18).
65
+ * Response lists field names only; never secret values.
66
+ */
67
+ export function seedTenantConfig(ctx, body) {
68
+ const applied = [];
69
+ const kept = [];
70
+ if (body.sandbox?.backend) {
71
+ const existing = readTenantSetting(ctx.store.db, "sandbox.backend");
72
+ if (existing === undefined) {
73
+ writeTenantSetting(ctx.store.db, "sandbox.backend", body.sandbox.backend);
74
+ applied.push("sandbox.backend");
75
+ }
76
+ else {
77
+ kept.push("sandbox.backend");
78
+ }
79
+ }
80
+ if (body.model) {
81
+ const current = ctx.vault.getHostModel();
82
+ if (current.configured) {
83
+ kept.push("model");
84
+ }
85
+ else {
86
+ ctx.vault.putHostModel({
87
+ requestId: body.requestId,
88
+ idempotencyKey: `seed-model:${body.requestId}`,
89
+ ...body.model,
90
+ });
91
+ applied.push("model");
92
+ }
93
+ }
94
+ return { applied, kept };
95
+ }
@@ -0,0 +1,16 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { BootstrapPrincipal, Logger, OpenTenantRuntime, TenantConfig, TenantStore } from "./types.js";
3
+ import type { MigrationHooks } from "./migration.js";
4
+ export interface FsTenantStoreOptions {
5
+ hostRoot: string;
6
+ openRuntime: OpenTenantRuntime;
7
+ configFor: (tenantId: string) => TenantConfig;
8
+ logger?: Logger;
9
+ migration?: MigrationHooks;
10
+ /** Writes the application principal into a fresh Tenant database. */
11
+ writeBootstrap?: (db: DatabaseSync, bootstrap: BootstrapPrincipal, now: Date) => void;
12
+ }
13
+ export declare function createFsTenantStore(options: FsTenantStoreOptions): TenantStore;
14
+ /** Claim the Tenant lock file (used by fake openRuntime in tests). */
15
+ export declare function claimTenantLock(lockPath: string, pid?: number): void;
16
+ export declare function releaseTenantLock(lockPath: string): void;
@@ -0,0 +1,205 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { isTenantId } from "@nylorun/core/compatibility";
5
+ import { hostPaths, tenantPaths } from "./paths.js";
6
+ import { readEnvelopeFile, writeEnvelopeFile } from "./envelope.js";
7
+ import { migrateTenantWithSnapshot } from "./migration.js";
8
+ import { quarantine } from "./quarantine.js";
9
+ import { bootstrapPrincipal } from "./principals.js";
10
+ import { TENANT_SCHEMA_VERSION, migrateTenantDatabase as defaultMigrate, schemaVersionOf as defaultSchemaVersionOf, } from "./schema.js";
11
+ function defaultWriteBootstrap(db, bootstrap, _now) {
12
+ defaultMigrate(db);
13
+ bootstrapPrincipal(db, bootstrap);
14
+ }
15
+ function assertLiveLock(lockPath, tenantId) {
16
+ if (!existsSync(lockPath))
17
+ return;
18
+ let raw;
19
+ try {
20
+ raw = readFileSync(lockPath, "utf8").trim();
21
+ }
22
+ catch {
23
+ throw quarantine("corrupt", "could not read .runtime-lock", { tenantId });
24
+ }
25
+ const lockPid = Number(raw);
26
+ if (!Number.isSafeInteger(lockPid) || lockPid < 1) {
27
+ throw quarantine("locked", "invalid .runtime-lock contents", {
28
+ tenantId,
29
+ lockPath,
30
+ });
31
+ }
32
+ try {
33
+ process.kill(lockPid, 0);
34
+ }
35
+ catch (error) {
36
+ const err = error;
37
+ if (err.code === "ESRCH") {
38
+ rmSync(lockPath, { force: true });
39
+ return;
40
+ }
41
+ // EPERM: process exists but we cannot signal it — treat as live owner.
42
+ if (err.code !== "EPERM")
43
+ throw error;
44
+ }
45
+ throw quarantine("locked", "another process holds this Tenant lock", {
46
+ tenantId,
47
+ lockPath,
48
+ lockPid,
49
+ });
50
+ }
51
+ function trashStamp(now) {
52
+ return now.toISOString().replaceAll(":", "-");
53
+ }
54
+ export function createFsTenantStore(options) {
55
+ const host = hostPaths(options.hostRoot);
56
+ mkdirSync(host.tenants, { recursive: true });
57
+ mkdirSync(host.trash, { recursive: true });
58
+ const writeBootstrap = options.writeBootstrap ?? defaultWriteBootstrap;
59
+ const migrationHooks = {
60
+ schemaVersionOf: options.migration?.schemaVersionOf ?? defaultSchemaVersionOf,
61
+ migrateTenantDatabase: options.migration?.migrateTenantDatabase ?? defaultMigrate,
62
+ targetVersion: options.migration?.targetVersion ?? TENANT_SCHEMA_VERSION,
63
+ };
64
+ return {
65
+ async enumerate() {
66
+ if (!existsSync(host.tenants))
67
+ return [];
68
+ return readdirSync(host.tenants, { withFileTypes: true })
69
+ .filter((d) => d.isDirectory() && isTenantId(d.name))
70
+ .map((d) => d.name);
71
+ },
72
+ async readEnvelope(id) {
73
+ const paths = tenantPaths(options.hostRoot, id);
74
+ return readEnvelopeFile(paths.envelope, id);
75
+ },
76
+ async create(envelope, bootstrap) {
77
+ const paths = tenantPaths(options.hostRoot, envelope.id);
78
+ if (existsSync(paths.root) || existsSync(paths.envelope)) {
79
+ return "exists";
80
+ }
81
+ const now = new Date();
82
+ try {
83
+ mkdirSync(paths.root, { recursive: true, mode: 0o700 });
84
+ for (const dir of [
85
+ paths.home,
86
+ paths.tmp,
87
+ paths.sandboxes,
88
+ paths.pluginData,
89
+ paths.logs,
90
+ paths.migration,
91
+ ]) {
92
+ mkdirSync(dir, { recursive: true });
93
+ }
94
+ writeEnvelopeFile(paths.envelope, envelope);
95
+ const db = new DatabaseSync(paths.database);
96
+ try {
97
+ writeBootstrap(db, bootstrap, now);
98
+ }
99
+ finally {
100
+ db.close();
101
+ }
102
+ // Exclusive create marker for the lock file is owned by openRuntime (WS-A);
103
+ // we only ensure the parent exists.
104
+ return "created";
105
+ }
106
+ catch (error) {
107
+ rmSync(paths.root, { recursive: true, force: true });
108
+ throw error;
109
+ }
110
+ },
111
+ async bootstrapMatches(id, bootstrap) {
112
+ const paths = tenantPaths(options.hostRoot, id);
113
+ if (!existsSync(paths.database))
114
+ return false;
115
+ const db = new DatabaseSync(paths.database, { readOnly: true });
116
+ try {
117
+ const row = db
118
+ .prepare(`SELECT id, token_hash, idempotency_key FROM principals
119
+ WHERE role = 'application' LIMIT 1`)
120
+ .get();
121
+ if (!row)
122
+ return false;
123
+ return (row.id === bootstrap.principalId &&
124
+ row.token_hash === bootstrap.credentialHash &&
125
+ row.idempotency_key === bootstrap.idempotencyKey);
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ finally {
131
+ db.close();
132
+ }
133
+ },
134
+ async open(id) {
135
+ const paths = tenantPaths(options.hostRoot, id);
136
+ if (!existsSync(paths.root)) {
137
+ throw quarantine("open-failed", `Tenant directory missing`, {
138
+ tenantId: id,
139
+ });
140
+ }
141
+ assertLiveLock(paths.lock, id);
142
+ let envelope;
143
+ try {
144
+ envelope = readEnvelopeFile(paths.envelope, id);
145
+ }
146
+ catch (error) {
147
+ throw error;
148
+ }
149
+ if (!existsSync(paths.database)) {
150
+ throw quarantine("corrupt", "tenant.sqlite is missing", {
151
+ tenantId: id,
152
+ });
153
+ }
154
+ // Probe schema before openRuntime so schema-too-new never mutates.
155
+ const probe = new DatabaseSync(paths.database);
156
+ let version;
157
+ try {
158
+ version = migrationHooks.schemaVersionOf(probe);
159
+ }
160
+ finally {
161
+ probe.close();
162
+ }
163
+ const target = migrationHooks.targetVersion ?? TENANT_SCHEMA_VERSION;
164
+ if (version > target) {
165
+ throw quarantine("schema-too-new", `Tenant schema version ${version} is newer than Host ${target}`, { tenantId: id });
166
+ }
167
+ if (version < target) {
168
+ envelope = migrateTenantWithSnapshot(paths, envelope, migrationHooks);
169
+ }
170
+ const config = options.configFor(id);
171
+ return options.openRuntime({
172
+ ...config,
173
+ tenantId: id,
174
+ paths: config.paths.root ? config.paths : paths,
175
+ });
176
+ },
177
+ async trash(id, now) {
178
+ const paths = tenantPaths(options.hostRoot, id);
179
+ if (!existsSync(paths.root))
180
+ return;
181
+ mkdirSync(host.trash, { recursive: true });
182
+ const dest = join(host.trash, `${id}-${trashStamp(now)}`);
183
+ renameSync(paths.root, dest);
184
+ },
185
+ async removePartial(id) {
186
+ const paths = tenantPaths(options.hostRoot, id);
187
+ if (existsSync(paths.root)) {
188
+ rmSync(paths.root, { recursive: true, force: true });
189
+ }
190
+ },
191
+ };
192
+ }
193
+ /** Claim the Tenant lock file (used by fake openRuntime in tests). */
194
+ export function claimTenantLock(lockPath, pid = process.pid) {
195
+ const fd = openSync(lockPath, "wx");
196
+ try {
197
+ writeFileSync(fd, String(pid));
198
+ }
199
+ finally {
200
+ closeSync(fd);
201
+ }
202
+ }
203
+ export function releaseTenantLock(lockPath) {
204
+ rmSync(lockPath, { force: true });
205
+ }
@@ -0,0 +1,19 @@
1
+ import type { TenantEnvelope } from "@nylorun/core/contracts";
2
+ import type { BootstrapPrincipal, Logger, OpenTenantRuntime, TenantConfig, TenantStore } from "./types.js";
3
+ export interface MemoryTenantStoreOptions {
4
+ openRuntime: OpenTenantRuntime;
5
+ configFor: (tenantId: string) => TenantConfig;
6
+ logger?: Logger;
7
+ /** Schema version recorded for new tenants (default TENANT_SCHEMA_VERSION). */
8
+ schemaVersion?: number;
9
+ }
10
+ /**
11
+ * In-memory TenantStore for tests. No filesystem; openRuntime is still injected
12
+ * so conformance can exercise the same module path as the FS adapter.
13
+ */
14
+ export declare function createMemoryTenantStore(options: MemoryTenantStoreOptions): TenantStore & {
15
+ /** Test helper: insert a raw record without opening. */
16
+ seed(envelope: TenantEnvelope, bootstrap: BootstrapPrincipal): void;
17
+ /** Test helper: current live ids. */
18
+ ids(): string[];
19
+ };
@@ -0,0 +1,100 @@
1
+ import { isTenantId } from "@nylorun/core/compatibility";
2
+ import { TENANT_SCHEMA_VERSION } from "./schema.js";
3
+ import { quarantine } from "./quarantine.js";
4
+ import { parseEnvelope } from "./envelope.js";
5
+ /**
6
+ * In-memory TenantStore for tests. No filesystem; openRuntime is still injected
7
+ * so conformance can exercise the same module path as the FS adapter.
8
+ */
9
+ export function createMemoryTenantStore(options) {
10
+ const records = new Map();
11
+ const schemaVersion = options.schemaVersion ?? TENANT_SCHEMA_VERSION;
12
+ const store = {
13
+ seed(envelope, bootstrap) {
14
+ const env = parseEnvelope(envelope, envelope.id);
15
+ records.set(env.id, {
16
+ envelope: env,
17
+ bootstrap: { ...bootstrap },
18
+ schemaVersion: env.schemaVersion,
19
+ });
20
+ },
21
+ ids() {
22
+ return [...records.keys()].filter((id) => !records.get(id)?.trashed);
23
+ },
24
+ async enumerate() {
25
+ return [...records.entries()]
26
+ .filter(([, r]) => !r.trashed)
27
+ .map(([id]) => id)
28
+ .filter(isTenantId);
29
+ },
30
+ async readEnvelope(id) {
31
+ const record = records.get(id);
32
+ if (!record || record.trashed) {
33
+ throw quarantine("envelope-invalid", "tenant envelope not found", {
34
+ tenantId: id,
35
+ });
36
+ }
37
+ return parseEnvelope(record.envelope, id);
38
+ },
39
+ async create(envelope, bootstrap) {
40
+ const env = parseEnvelope(envelope, envelope.id);
41
+ const existing = records.get(env.id);
42
+ if (existing && !existing.trashed)
43
+ return "exists";
44
+ records.set(env.id, {
45
+ envelope: env,
46
+ bootstrap: { ...bootstrap },
47
+ schemaVersion: env.schemaVersion || schemaVersion,
48
+ });
49
+ return "created";
50
+ },
51
+ async bootstrapMatches(id, bootstrap) {
52
+ const record = records.get(id);
53
+ if (!record || record.trashed)
54
+ return false;
55
+ return (record.bootstrap.principalId === bootstrap.principalId &&
56
+ record.bootstrap.credentialHash === bootstrap.credentialHash &&
57
+ record.bootstrap.idempotencyKey === bootstrap.idempotencyKey);
58
+ },
59
+ async open(id) {
60
+ const record = records.get(id);
61
+ if (!record || record.trashed) {
62
+ throw quarantine("open-failed", `Tenant ${id} not found`, {
63
+ tenantId: id,
64
+ });
65
+ }
66
+ if (record.schemaVersion > TENANT_SCHEMA_VERSION) {
67
+ throw quarantine("schema-too-new", `Tenant schema version ${record.schemaVersion} is newer than Host ${TENANT_SCHEMA_VERSION}`, { tenantId: id });
68
+ }
69
+ const config = options.configFor(id);
70
+ const opened = await options.openRuntime({
71
+ ...config,
72
+ tenantId: id,
73
+ });
74
+ const handle = {
75
+ ...opened,
76
+ envelope: record.envelope,
77
+ handle: opened.handle.bind(opened),
78
+ summary: opened.summary.bind(opened),
79
+ drain: opened.drain.bind(opened),
80
+ close: opened.close.bind(opened),
81
+ };
82
+ record.handle = handle;
83
+ return handle;
84
+ },
85
+ async trash(id, _now) {
86
+ const record = records.get(id);
87
+ if (!record)
88
+ return;
89
+ if (record.handle) {
90
+ await record.handle.close().catch(() => undefined);
91
+ }
92
+ record.trashed = true;
93
+ record.handle = undefined;
94
+ },
95
+ async removePartial(id) {
96
+ records.delete(id);
97
+ },
98
+ };
99
+ return store;
100
+ }