From b12f756c4e1076b0fba311c3768d2181906b6227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 11:31:52 +0200 Subject: [PATCH 1/7] Machine handover: re-pin the vendored schema to Machines v0.12.59 (two branches) The upstream identity schema now has a oneOf with a personal branch: machine.kind "personal-vm", owner {kind "principal", github_login, github_id}, host {kind "provider-estate", estate_id, custody_repository, record_path} and a required tailnet identity. Vendored byte-for-byte from tag v0.12.59 (commit dd0dbfaab4807f197f5f65f102fc74bd22496499); MachineContext is a union of the two branches and conformance tests cover both with real-shaped synthetic fixtures, including every cross-branch mix the schema refuses. Co-Authored-By: Claude Fable 5.1 --- src/machine/context.ts | 63 +++++++--- src/machine/lazurio-machine.v1.schema.json | 114 ++++++++++++++----- src/machine/schema-provenance.json | 7 +- tests/fixtures/machine-context-personal.json | 14 +++ tests/machine-context.test.ts | 95 +++++++++++++--- 5 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 tests/fixtures/machine-context-personal.json diff --git a/src/machine/context.ts b/src/machine/context.ts index 7de74082..3c1b3a41 100644 --- a/src/machine/context.ts +++ b/src/machine/context.ts @@ -8,7 +8,28 @@ import schema from "./lazurio-machine.v1.schema.json"; // Types describe the upstream wire contract; only the exact vendored schema // validates it. No coercion, defaults, reference downloads or extra properties. -export type MachineContext = Readonly<{ +// Exactly one branch applies, distinguished by machine.kind: an Organization +// workspace VM on a virtualization host, or the one hosted personal VM of a +// Principal on a provider estate. Owner and host kinds never mix across branches. +type MachineOperator = Readonly<{ + os_user: string; + home: string; + lazurio_root: string; +}>; +type MachineNetwork = Readonly<{ + headscale_server_url: string; + headscale_hostname: string; +}>; +type MachineInstalled = Readonly<{ + machines_release: Readonly<{ + repository: string; + version: string; + commit: string; + }>; + deployment_head: string; + recorded_at: string; +}>; +export type OrganizationWorkspaceContext = Readonly<{ schema_version: "lazurio.machine.v1"; machine: Readonly<{ id: string; @@ -22,28 +43,44 @@ export type MachineContext = Readonly<{ organization_key?: string; team?: string; }>; - operator: Readonly<{ os_user: string; home: string; lazurio_root: string }>; + operator: MachineOperator; host: Readonly<{ kind: "virtualization-host"; machine_id: string; custody_repository: string; provider: string; }>; - network?: Readonly<{ - headscale_server_url: string; - headscale_hostname: string; + network?: MachineNetwork; + installed: MachineInstalled; + account: null; +}>; +export type PersonalMachineContext = Readonly<{ + schema_version: "lazurio.machine.v1"; + machine: Readonly<{ + id: string; + kind: "personal-vm"; + name: string; + vmid: number; }>; - installed: Readonly<{ - machines_release: Readonly<{ - repository: string; - version: string; - commit: string; - }>; - deployment_head: string; - recorded_at: string; + owner: Readonly<{ + kind: "principal"; + github_login: string; + github_id: number; + }>; + operator: MachineOperator; + host: Readonly<{ + kind: "provider-estate"; + estate_id: string; + custody_repository: string; + record_path: string; }>; + network: MachineNetwork; + installed: MachineInstalled; account: null; }>; +export type MachineContext = + | OrganizationWorkspaceContext + | PersonalMachineContext; const validate = new Ajv2020({ strict: true }).compile(schema); export const machineContextPath = "/etc/lazurio/lazurio.machine.json"; diff --git a/src/machine/lazurio-machine.v1.schema.json b/src/machine/lazurio-machine.v1.schema.json index f2e5fb25..d558482b 100644 --- a/src/machine/lazurio-machine.v1.schema.json +++ b/src/machine/lazurio-machine.v1.schema.json @@ -2,33 +2,100 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://lazurio.io/schemas/lazurio-machine.v1.schema.json", "title": "lazurio.machine.json — identity of one Machine written at its handover", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "machine", "owner", "operator", "host", "installed", "account"], - "properties": { - "schema_version": { "const": "lazurio.machine.v1" }, - "machine": { + "description": "Exactly one branch applies: an Organization workspace VM on a virtualization host, or the one hosted personal VM of a Principal on a provider estate. The branches are distinguished by machine.kind and never mix owner or host kinds.", + "oneOf": [{ "$ref": "#/$defs/organization_workspace_vm" }, { "$ref": "#/$defs/personal_vm" }], + "$defs": { + "organization_workspace_vm": { "type": "object", "additionalProperties": false, - "required": ["id", "kind", "name", "vmid"], + "required": ["schema_version", "machine", "owner", "operator", "host", "installed", "account"], "properties": { - "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, - "kind": { "const": "workspace-vm" }, - "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$" }, - "vmid": { "type": "integer", "minimum": 100, "maximum": 999999999 } + "schema_version": { "const": "lazurio.machine.v1" }, + "machine": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "name", "vmid"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "kind": { "const": "workspace-vm" }, + "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$" }, + "vmid": { "$ref": "#/$defs/vmid" } + } + }, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "organization"], + "properties": { + "kind": { "const": "organization" }, + "organization": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "organization_key": { "type": "string", "minLength": 1, "maxLength": 128 }, + "team": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + } + }, + "operator": { "$ref": "#/$defs/operator" }, + "host": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "machine_id", "custody_repository", "provider"], + "properties": { + "kind": { "const": "virtualization-host" }, + "machine_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "custody_repository": { "$ref": "#/$defs/repository" }, + "provider": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + } + }, + "network": { "$ref": "#/$defs/network" }, + "installed": { "$ref": "#/$defs/installed" }, + "account": { "$ref": "#/$defs/account" } } }, - "owner": { + "personal_vm": { "type": "object", "additionalProperties": false, - "required": ["kind", "organization"], + "required": ["schema_version", "machine", "owner", "operator", "host", "network", "installed", "account"], "properties": { - "kind": { "const": "organization" }, - "organization": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, - "organization_key": { "type": "string", "minLength": 1, "maxLength": 128 }, - "team": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + "schema_version": { "const": "lazurio.machine.v1" }, + "machine": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "name", "vmid"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "kind": { "const": "personal-vm" }, + "name": { "description": "DNS slug of the Machine: ..lazurio.io", "type": "string", "maxLength": 32, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "vmid": { "$ref": "#/$defs/vmid" } + } + }, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "github_login", "github_id"], + "properties": { + "kind": { "const": "principal" }, + "github_login": { "type": "string", "maxLength": 39, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "github_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 } + } + }, + "operator": { "$ref": "#/$defs/operator" }, + "host": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "estate_id", "custody_repository", "record_path"], + "properties": { + "kind": { "const": "provider-estate" }, + "estate_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "custody_repository": { "$ref": "#/$defs/repository" }, + "record_path": { "type": "string", "maxLength": 256, "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$" } + } + }, + "network": { "$ref": "#/$defs/network" }, + "installed": { "$ref": "#/$defs/installed" }, + "account": { "$ref": "#/$defs/account" } } }, + "vmid": { "type": "integer", "minimum": 100, "maximum": 999999999 }, + "repository": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+$" }, "operator": { "type": "object", "additionalProperties": false, @@ -39,17 +106,6 @@ "lazurio_root": { "type": "string", "pattern": "^/home/[a-z][a-z0-9-]{0,31}/Lazurio$" } } }, - "host": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "machine_id", "custody_repository", "provider"], - "properties": { - "kind": { "const": "virtualization-host" }, - "machine_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, - "custody_repository": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+$" }, - "provider": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } - } - }, "network": { "type": "object", "additionalProperties": false, @@ -69,7 +125,7 @@ "additionalProperties": false, "required": ["repository", "version", "commit"], "properties": { - "repository": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+$" }, + "repository": { "$ref": "#/$defs/repository" }, "version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" }, "commit": { "type": "string", "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" } } diff --git a/src/machine/schema-provenance.json b/src/machine/schema-provenance.json index 6aac02d4..d87c3980 100644 --- a/src/machine/schema-provenance.json +++ b/src/machine/schema-provenance.json @@ -1,8 +1,9 @@ { "source_repository": "HumanAndMachine-ai/Machines", - "source_commit": "0f438363bed76ec169d62ba7ba527472042b20af", + "source_tag": "v0.12.59", + "source_commit": "dd0dbfaab4807f197f5f65f102fc74bd22496499", "source_path": "workloads/workspace-vm/lazurio-machine.v1.schema.json", - "sha256": "e058e67ee897f389cff3917844f22500e65a22dc8fbfb656e651ab99cd6ea341", + "sha256": "2dc6dd3dd3b62c35e2ece4d6b48f4ea65d6e2f9f838db1e684a7628a9b7e9da1", "publication_authorization": "https://github.com/HumanAndMachine-ai/Machines/pull/124#issuecomment-5688845105", - "pin_update": "https://github.com/HumanAndMachine-ai/Machines/pull/124#issuecomment-5688906823" + "pin_update": "https://github.com/HumanAndMachine-ai/Machines/releases/tag/v0.12.59" } diff --git a/tests/fixtures/machine-context-personal.json b/tests/fixtures/machine-context-personal.json new file mode 100644 index 00000000..ae8fd338 --- /dev/null +++ b/tests/fixtures/machine-context-personal.json @@ -0,0 +1,14 @@ +{ + "schema_version": "lazurio.machine.v1", + "machine": { "id": "example-personal", "kind": "personal-vm", "name": "example", "vmid": 901 }, + "owner": { "kind": "principal", "github_login": "example", "github_id": 12345 }, + "operator": { "os_user": "operator", "home": "/home/operator", "lazurio_root": "/home/operator/Lazurio" }, + "host": { "kind": "provider-estate", "estate_id": "example-estate", "custody_repository": "Example/infra", "record_path": "machines/personal/example.json" }, + "network": { "headscale_server_url": "https://network.example.invalid", "headscale_hostname": "example" }, + "installed": { + "machines_release": { "repository": "Example/Machines", "version": "v1.0.0", "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "deployment_head": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_at": "2026-01-01T00:00:00Z" + }, + "account": null +} diff --git a/tests/machine-context.test.ts b/tests/machine-context.test.ts index d16fb734..70087758 100644 --- a/tests/machine-context.test.ts +++ b/tests/machine-context.test.ts @@ -20,6 +20,7 @@ import { import provenance from "../src/machine/schema-provenance.json"; import { readCustodiedDeclarationBytes } from "../src/providers/owned-json"; import fixture from "./fixtures/machine-context.json"; +import personal from "./fixtures/machine-context-personal.json"; const bytes = (value: unknown) => Buffer.from(JSON.stringify(value)); test("vendored schema digest matches the exact upstream pin", async () => { @@ -36,27 +37,37 @@ test("vendored schema digest matches the exact upstream pin", async () => { .digest("hex"), ).toBe(provenance.sha256); }); -test("synthetic upstream conformance fixture is immutable descriptive context", () => { - const context = parseMachineContext(bytes(fixture)); - expect(JSON.stringify(context)).toBe(JSON.stringify(fixture)); - expect(context.account).toBeNull(); - expect(Object.isFrozen(context.operator)).toBe(true); - expect(Object.isFrozen(context.installed.machines_release)).toBe(true); - expect( - bindMachineOperator(context, { - platform: "linux", - uid: 1000, - username: "operator", - homedir: "/home/operator", - }), - ).toBe("/home/operator/Lazurio"); -}); -test("current upstream contract permits no network and SHA-256 Git object ids", () => { +for (const [branch, input] of [ + ["organization workspace VM", fixture], + ["personal VM", personal], +] as const) + test(`synthetic ${branch} conformance fixture is immutable descriptive context`, () => { + const context = parseMachineContext(bytes(input)); + expect(JSON.stringify(context)).toBe(JSON.stringify(input)); + expect(context.account).toBeNull(); + expect(Object.isFrozen(context.operator)).toBe(true); + expect(Object.isFrozen(context.installed.machines_release)).toBe(true); + expect( + bindMachineOperator(context, { + platform: "linux", + uid: 1000, + username: "operator", + homedir: "/home/operator", + }), + ).toBe("/home/operator/Lazurio"); + }); +test("organization branch permits no network and SHA-256 Git object ids", () => { const { network: _, ...input } = structuredClone(fixture); input.installed.deployment_head = "c".repeat(64); input.installed.machines_release.commit = "d".repeat(64); expect(parseMachineContext(bytes(input)).network).toBeUndefined(); }); +test("personal branch requires the tailnet identity", () => { + const { network: _, ...input } = structuredClone(personal); + expect(() => parseMachineContext(bytes(input))).toThrow( + "machine-context-invalid", + ); +}); for (const [name, change] of Object.entries({ "unknown top-level field": (input: Record) => { input.permissions = ["admin"]; @@ -83,7 +94,57 @@ for (const [name, change] of Object.entries({ input.installed = { ...fixture.installed, deployment_head: "c".repeat(41) }; }, })) - test(`schema refuses ${name}`, () => { + for (const [branch, base] of [ + ["organization", fixture], + ["personal", personal], + ] as const) + test(`schema refuses ${name} on the ${branch} branch`, () => { + const input: Record = structuredClone(base); + change(input); + expect(() => parseMachineContext(bytes(input))).toThrow( + "machine-context-invalid", + ); + }); +for (const [name, change] of Object.entries({ + "a personal-vm kind with an Organization owner": ( + input: Record, + ) => { + input.machine = { ...personal.machine }; + }, + "a workspace-vm kind with a Principal owner": ( + input: Record, + ) => { + input.owner = { ...personal.owner }; + }, + "a workspace-vm on a provider estate": (input: Record) => { + input.host = { ...personal.host }; + }, + "a personal VM on a virtualization host": ( + input: Record, + ) => { + Object.assign(input, structuredClone(personal), { host: fixture.host }); + }, + "a personal owner with a team": (input: Record) => { + Object.assign(input, structuredClone(personal), { + owner: { ...personal.owner, team: "sample-team" }, + }); + }, + "a personal owner without the immutable GitHub id": ( + input: Record, + ) => { + Object.assign(input, structuredClone(personal), { + owner: { kind: "principal", github_login: "example" }, + }); + }, + "a personal Machine name that is not a DNS slug": ( + input: Record, + ) => { + Object.assign(input, structuredClone(personal), { + machine: { ...personal.machine, name: "Example" }, + }); + }, +})) + test(`the two branches never mix: ${name}`, () => { const input: Record = structuredClone(fixture); change(input); expect(() => parseMachineContext(bytes(input))).toThrow( From d8fb1104a717c9c8567f2b6a4ef6572f02b27fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 11:44:00 +0200 Subject: [PATCH 2/7] Workspace presets: three hosted presets as data, derived from the handover Presets are data in src/folder/presets.ts: local, hosted-personal, hosted-organization-personal and hosted-organization-team, each a whole composition (fixed access/purpose axes, communication defaults, Personalspace policy, provider identity mode, surfaces, supervision). The preset is derived from the typed handover fields only: personal-vm -> hosted-personal, workspace-vm with owner.team -> hosted-organization-team, without -> hosted-organization-personal; a workstation is local. A handover allows exactly the presets of its Machine kind; a personal VM never takes an Organization preset and vice versa. Preferences schema 2 stores the preset reference (name, version 1, whether it was derived or an explicit choice) and the immutable Machine binding projected from the handover (null on a workstation). The whole composition is validated on every parse. The profile-change request is now {preset?, profile} on the same preview -> apply path for CLI and Launchpad; a disallowed preset or a profile whose fixed axes disagree with the preset is a blocked plan, never a write. `lazurio machine folder-init` derives the preset by default, accepts --preset within the allow-list and takes the preset's communication defaults. Co-Authored-By: Claude Fable 5.1 --- scripts/smoke-profile-transaction.ts | 16 +- src/cli.ts | 15 +- src/folder/apply-preparation.ts | 6 +- src/folder/change-profile.ts | 61 ++++++- src/folder/initialize-folder.ts | 57 ++++-- src/folder/inspect-preparation.ts | 4 +- src/folder/machine-binding.ts | 147 ++++++++++++++++ src/folder/presets.ts | 195 +++++++++++++++++++++ src/folder/preview.ts | 16 +- src/folder/render.ts | 44 ++++- src/folder/resume-initialization.ts | 3 +- src/folder/retire-preparation.ts | 6 +- src/folder/state-fields.ts | 30 ++++ src/folder/state.ts | 54 +++--- src/folder/validate-preparation.ts | 2 +- src/launchpad/server.ts | 8 +- src/machine/binding.ts | 55 ++++++ src/machine/cli.ts | 91 +++++++--- tests/cli.test.ts | 17 +- tests/fixtures/machine-bindings.ts | 21 +++ tests/folder-change-profile.test.ts | 114 ++++++++++-- tests/folder-handover.test.ts | 33 +++- tests/folder-initialize.test.ts | 12 +- tests/folder-inspect-change.test.ts | 35 ++-- tests/folder-prepare-change.test.ts | 115 +++++++----- tests/folder-presets.test.ts | 193 ++++++++++++++++++++ tests/folder-preview.test.ts | 35 ++-- tests/folder-render.test.ts | 64 +++++-- tests/folder-resume-initialization.test.ts | 12 +- tests/folder-state.test.ts | 10 +- tests/launchpad.test.ts | 2 +- tests/machine-context.test.ts | 30 +--- 32 files changed, 1254 insertions(+), 249 deletions(-) create mode 100644 src/folder/machine-binding.ts create mode 100644 src/folder/presets.ts create mode 100644 src/folder/state-fields.ts create mode 100644 src/machine/binding.ts create mode 100644 tests/fixtures/machine-bindings.ts create mode 100644 tests/folder-presets.test.ts diff --git a/scripts/smoke-profile-transaction.ts b/scripts/smoke-profile-transaction.ts index 978a8ffe..3e39fd27 100644 --- a/scripts/smoke-profile-transaction.ts +++ b/scripts/smoke-profile-transaction.ts @@ -36,17 +36,21 @@ try { detail: "concise", coordination: "direct", }; - const initial = await previewFolder(profile, null, async () => ({ - kind: "absent", - })); + const initial = await previewFolder( + { preset: "local", machine: null, profile }, + null, + async () => ({ kind: "absent" }), + ); await writeFile(join(folder, "AGENTS.md"), initial.desired.content, { mode: 0o600, }); await writeFile( join(state, "preferences.json"), JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, + machine: null, profile, customInstructions: "", }), @@ -66,7 +70,7 @@ try { await mkdir(join(folder, name)); await writeFile(join(folder, name, "owned-work"), `Synthetic ${name} work`); } - const requested = { ...profile, locale: "cs" }; + const requested = { profile: { ...profile, locale: "cs" } }; await assert.rejects( prepareProfileChange(folder, 1, requested, async (step) => { if (step === "manifest") @@ -106,7 +110,7 @@ try { "unchanged", ); assert.equal( - (await prepareProfileChange(folder, 2, profile)).kind, + (await prepareProfileChange(folder, 2, { profile })).kind, "prepared", ); await writeFile(join(folder, "AGENTS.md"), "Manual edit must survive"); diff --git a/src/cli.ts b/src/cli.ts index 426aeca0..89cc0da4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -217,6 +217,8 @@ No files are written by folder-preview. No command installs or migrates Lazurio. profile-preview uses the same profile choices and --folder, plus required --expected-revision . It reads existing .lazurio state and creates/removes only its operation lock. It does not apply the proposed change. +Both profile commands accept optional --preset to change the workspace preset +within what the recorded Machine handover allows; omitted keeps the current preset. profile-update takes the same inputs as profile-preview and APPLIES the change: it replaces owned instructions/preferences/manifest and archives the transaction. Use only an explicitly prepared development fixture, not your daily Lazurio. @@ -299,6 +301,7 @@ This is not a migration writer or authority to apply the draft. Exit 0 draft, 2 "previous-digest": { type: "string" }, "expected-revision": { type: "string" }, "target-revision": { type: "string" }, + preset: { type: "string" }, access: { type: "string" }, purpose: { type: "string" }, locale: { type: "string" }, @@ -438,6 +441,11 @@ This is not a migration writer or authority to apply the draft. Exit 0 draft, 2 values.base !== undefined ) throw new Error("Discovery option belongs only to Launchpad"); + if ( + values.preset !== undefined && + !["profile-preview", "profile-update"].includes(positionals[0] ?? "") + ) + throw new Error("Preset choice belongs only to profile commands"); if (positionals[0] === "folder-resume") { if (!values.folder || Object.keys(values).some((name) => name !== "folder")) throw new Error("Explicit initialization recovery folder required"); @@ -518,13 +526,16 @@ This is not a migration writer or authority to apply the draft. Exit 0 draft, 2 const result = await operation( folder, Number(values["expected-revision"]), - profile, + values.preset === undefined + ? { profile } + : { preset: values.preset, profile }, ); console.log(JSON.stringify(result)); return result.kind === "blocked" ? 2 : 0; } + // Stateless preview of a workstation Folder: the local preset, no Machine. const result = await previewFolder( - profile, + { preset: "local", machine: null, profile }, values["previous-digest"] ?? null, () => inspectInstructions(folder), ); diff --git a/src/folder/apply-preparation.ts b/src/folder/apply-preparation.ts index 09495094..c146d738 100644 --- a/src/folder/apply-preparation.ts +++ b/src/folder/apply-preparation.ts @@ -9,7 +9,7 @@ import { readOwnedStateFile, readStateJson, } from "./read-state"; -import { renderInstructions } from "./render"; +import { instructionSource, renderInstructions } from "./render"; import { parseFolderPreferences, parseInstructionManifest } from "./state"; import { validatePreparation } from "./validate-preparation"; @@ -99,12 +99,12 @@ async function inspectProgress(folder: string, archivedRevision?: number) { preferences, marker.manifest, marker, - renderInstructions(preferences.profile), + renderInstructions(instructionSource(preferences)), ); if (preferences.profile.os !== executionOs(process.platform)) throw new Error("Transaction execution OS mismatch"); const before = [ - renderInstructions(validated.previousPreferences.profile), + renderInstructions(instructionSource(validated.previousPreferences)), JSON.stringify(validated.previousPreferences), JSON.stringify(validated.previousManifest), ]; diff --git a/src/folder/change-profile.ts b/src/folder/change-profile.ts index e8cdaebc..54d33f50 100644 --- a/src/folder/change-profile.ts +++ b/src/folder/change-profile.ts @@ -1,8 +1,36 @@ +import { + allowedPresets, + type PresetName, + parsePresetName, + presetReference, + workspacePreset, +} from "./presets"; import { previewFolder } from "./preview"; -import { parseFolderProfile } from "./profile"; +import { type FolderProfile, parseFolderProfile } from "./profile"; import type { ObservedFile } from "./reconcile"; -import { instructionTemplateRevision } from "./render"; +import { instructionSource, instructionTemplateRevision } from "./render"; import { parseFolderPreferences, parseInstructionManifest } from "./state"; +import { ownDataValue, stateFields } from "./state-fields"; + +// One request shape for CLI, Launchpad and a future typed owner request: the +// preset (absent means keep the current one) and the full profile. The Machine +// binding is never part of a request; it is immutable. +export type ProfileRequest = Readonly<{ + preset: PresetName | undefined; + profile: FolderProfile; +}>; + +export function parseProfileRequest(input: unknown): ProfileRequest { + const withPreset = ownDataValue(input, "preset") !== undefined; + const value = stateFields( + input, + withPreset ? ["preset", "profile"] : ["profile"], + ); + return Object.freeze({ + preset: withPreset ? parsePresetName(value.preset) : undefined, + profile: parseFolderProfile(value.profile), + }); +} // Shared profile-change planning. The caller reads trusted current state under // the common lock and must revalidate before writing. This is not an apply token. @@ -10,12 +38,12 @@ export async function planProfileChange( currentPreferencesInput: unknown, currentManifestInput: unknown, expectedRevision: number, - requestedProfileInput: unknown, + requestedInput: unknown, inspect: () => Promise, ) { const current = parseFolderPreferences(currentPreferencesInput); const manifest = parseInstructionManifest(currentManifestInput); - const requested = parseFolderProfile(requestedProfileInput); + const requested = parseProfileRequest(requestedInput); if ( !Number.isSafeInteger(expectedRevision) || expectedRevision !== current.revision @@ -25,16 +53,28 @@ export async function planProfileChange( return { kind: "blocked", reason: "incomplete-state" } as const; if (manifest.templateRevision !== instructionTemplateRevision) return { kind: "blocked", reason: "template-upgrade-required" } as const; - if (requested.os !== current.profile.os) + if (requested.profile.os !== current.profile.os) return { kind: "blocked", reason: "execution-os-change" } as const; if (current.customInstructions !== "") return { kind: "blocked", reason: "custom-composition-unavailable", } as const; + // The preset may only change within what the recorded handover allows, and + // the fixed axes of the profile must match the requested preset's composition. + const presetName = requested.preset ?? current.preset.name; + if (!allowedPresets(current.machine).includes(presetName)) + return { kind: "blocked", reason: "preset-not-allowed" } as const; + const composition = workspacePreset(presetName).composition; + if ( + requested.profile.access !== composition.access || + requested.profile.purpose !== composition.purpose + ) + return { kind: "blocked", reason: "preset-composition" } as const; + const preset = presetReference(presetName, current.machine); const expectedCurrent = await previewFolder( - current.profile, + instructionSource(current), null, async () => ({ kind: "absent" }), ); @@ -42,7 +82,11 @@ export async function planProfileChange( return { kind: "blocked", reason: "incomplete-state" } as const; const preview = await previewFolder( - requested, + { + preset: preset.name, + machine: current.machine, + profile: requested.profile, + }, manifest.output.digest, inspect, ); @@ -53,7 +97,8 @@ export async function planProfileChange( const preferences = parseFolderPreferences({ ...current, revision: current.revision + 1, - profile: requested, + preset, + profile: requested.profile, }); const nextManifest = parseInstructionManifest({ schemaVersion: 1, diff --git a/src/folder/initialize-folder.ts b/src/folder/initialize-folder.ts index bfb09fc2..ef3fa735 100644 --- a/src/folder/initialize-folder.ts +++ b/src/folder/initialize-folder.ts @@ -11,10 +11,13 @@ import { recordInitializationCreation, } from "./initialization-receipt"; import { withFolderOperationLock } from "./lock"; +import type { MachineBinding } from "./machine-binding"; import { inspectOwnedDirectory } from "./owned-directory"; import { executionOs } from "./platform"; +import { type PresetName, presetReference } from "./presets"; import { previewFolder } from "./preview"; import { readOwnedStateFile } from "./read-state"; +import { instructionSource } from "./render"; import { parseFolderPreferences, parseInstructionManifest } from "./state"; export type InitializationStep = @@ -25,6 +28,14 @@ export type InitializationStep = | "manifest" | "layout"; +// What a hosted initialization records: the preset chosen against the handover +// and the immutable binding projected from it. A workstation has neither. +export type HandoverSource = Readonly<{ + preset: PresetName; + machine: MachineBinding; + profile: unknown; +}>; + // Fresh-path development initialization only. Never adopts an existing directory. // Failed attempts remain in place; no implicit retry, recursive cleanup or migration. export async function initializeFolder( @@ -32,21 +43,39 @@ export async function initializeFolder( profile: unknown, checkpoint: (step: InitializationStep) => Promise = async () => {}, ) { - return initialize(folder, profile, checkpoint, false); + return initialize( + folder, + { preset: presetReference("local", null), machine: null, profile }, + checkpoint, + false, + ); } // Only the empty, operator-owned layout prepared by Machines; not resident adoption. export async function initializeHandoverFolder( folder: string, - profile: unknown, + source: HandoverSource, checkpoint: (step: InitializationStep) => Promise = async () => {}, ) { - return initialize(folder, profile, checkpoint, true); + return initialize( + folder, + { + preset: presetReference(source.preset, source.machine), + machine: source.machine, + profile: source.profile, + }, + checkpoint, + true, + ); } async function initialize( folder: string, - profile: unknown, + source: Readonly<{ + preset: unknown; + machine: MachineBinding | null; + profile: unknown; + }>, checkpoint: (step: InitializationStep) => Promise, handover: boolean, ) { @@ -54,14 +83,18 @@ async function initialize( throw new Error("Canonical new Folder path required"); await inspectOwnedDirectory(dirname(folder)); const preferences = parseFolderPreferences({ - schemaVersion: 1, + schemaVersion: 2, revision: 1, - profile, + preset: source.preset, + machine: source.machine, + profile: source.profile, customInstructions: "", }); - const preview = await previewFolder(preferences.profile, null, async () => ({ - kind: "absent", - })); + const preview = await previewFolder( + instructionSource(preferences), + null, + async () => ({ kind: "absent" }), + ); if (preferences.profile.os !== executionOs(process.platform)) throw new Error("Initialization OS mismatch"); const manifest = parseInstructionManifest({ @@ -188,6 +221,10 @@ async function initialize( } await assertHeld(); if (layout) await verifyHandoverLayout(folder, layout); - return { kind: "initialized" as const, revision: 1 }; + return { + kind: "initialized" as const, + revision: 1, + preset: preferences.preset, + }; }); } diff --git a/src/folder/inspect-preparation.ts b/src/folder/inspect-preparation.ts index 7150026c..1baf00a3 100644 --- a/src/folder/inspect-preparation.ts +++ b/src/folder/inspect-preparation.ts @@ -8,7 +8,7 @@ import { readOwnedStateFile, readStateJson, } from "./read-state"; -import { renderInstructions } from "./render"; +import { instructionSource, renderInstructions } from "./render"; import { parseFolderPreferences, parseInstructionManifest } from "./state"; import { validatePreparation } from "./validate-preparation"; @@ -76,7 +76,7 @@ export async function readPreparedChange(folder: string) { JSON.stringify(currentManifest) !== JSON.stringify(validated.previousManifest) || current.content !== - renderInstructions(validated.previousPreferences.profile) || + renderInstructions(instructionSource(validated.previousPreferences)) || JSON.stringify(current.identity) !== JSON.stringify(validated.previousIdentity) || JSON.stringify(staged.identity) !== diff --git a/src/folder/machine-binding.ts b/src/folder/machine-binding.ts new file mode 100644 index 00000000..3e36105f --- /dev/null +++ b/src/folder/machine-binding.ts @@ -0,0 +1,147 @@ +import { ownDataValue, stateFields } from "./state-fields"; + +// The immutable part of a hosted Folder: a projection of the root-issued +// handover recorded at initialization. It is shown, never edited; the profile +// change flow carries it forward unchanged. A workstation Folder has none. +// Nothing here is a grant: owner, team and host describe context only. +export type MachineOwner = + | Readonly<{ kind: "principal"; githubLogin: string; githubId: number }> + | Readonly<{ + kind: "organization"; + organization: string; + team: string | null; + }>; + +// Rendered only when present. The upstream handover has no relationships field +// yet; this shape follows the zones of upstream decision 0155 so a future pin +// maps onto it without changing the renderer. +export type MachineRelationship = Readonly<{ + machine: string; + kind: "personal-client" | "personal-vm" | "workspace-vm" | "work-laptop"; + access: "inbound" | "outbound" | "both"; +}>; + +export type MachineBinding = Readonly<{ + contextDigest: string; + kind: "personal-vm" | "workspace-vm"; + name: string; + owner: MachineOwner; + network: Readonly<{ headscaleHostname: string }> | null; + host: Readonly<{ + kind: "virtualization-host" | "provider-estate"; + id: string; + }>; + relationships?: readonly MachineRelationship[]; +}>; + +const slug = /^[a-z0-9][a-z0-9-]{0,63}$/; +const keys = ["contextDigest", "kind", "name", "owner", "network", "host"]; + +function isSlug(value: unknown): value is string { + return typeof value === "string" && slug.test(value); +} + +function owner(input: unknown): MachineOwner { + if (ownDataValue(input, "kind") === "principal") { + const value = stateFields(input, ["kind", "githubLogin", "githubId"]); + if ( + !isSlug(value.githubLogin) || + value.githubLogin.length > 39 || + typeof value.githubId !== "number" || + !Number.isSafeInteger(value.githubId) || + value.githubId < 1 + ) + throw new Error("Invalid Machine owner"); + return Object.freeze({ + kind: "principal", + githubLogin: value.githubLogin, + githubId: value.githubId, + }); + } + const value = stateFields(input, ["kind", "organization", "team"]); + if ( + value.kind !== "organization" || + !isSlug(value.organization) || + (value.team !== null && !isSlug(value.team)) + ) + throw new Error("Invalid Machine owner"); + return Object.freeze({ + kind: "organization", + organization: value.organization, + team: value.team, + }); +} + +function relationships(input: unknown): readonly MachineRelationship[] { + if (!Array.isArray(input)) throw new Error("Invalid Machine relationships"); + return Object.freeze( + input.map((entry) => { + const value = stateFields(entry, ["machine", "kind", "access"]); + if ( + !isSlug(value.machine) || + ![ + "personal-client", + "personal-vm", + "workspace-vm", + "work-laptop", + ].includes(value.kind as string) || + !["inbound", "outbound", "both"].includes(value.access as string) + ) + throw new Error("Invalid Machine relationship"); + return Object.freeze({ + machine: value.machine, + kind: value.kind as MachineRelationship["kind"], + access: value.access as MachineRelationship["access"], + }); + }), + ); +} + +export function parseMachineBinding(input: unknown): MachineBinding | null { + if (input === null) return null; + const withRelationships = ownDataValue(input, "relationships") !== undefined; + const value = stateFields( + input, + withRelationships ? [...keys, "relationships"] : keys, + ); + const tailnet = + value.network === null + ? null + : stateFields(value.network, ["headscaleHostname"]).headscaleHostname; + const host = stateFields(value.host, ["kind", "id"]); + if ( + typeof value.contextDigest !== "string" || + !/^[a-f0-9]{64}$/.test(value.contextDigest) || + (value.kind !== "personal-vm" && value.kind !== "workspace-vm") || + !isSlug(value.name) || + (tailnet !== null && !isSlug(tailnet)) || + (host.kind !== "virtualization-host" && host.kind !== "provider-estate") || + !isSlug(host.id) + ) + throw new Error("Invalid Machine binding"); + const bound = owner(value.owner); + // The two upstream branches never mix: a personal VM belongs to a Principal on + // a provider estate; a workspace VM to an Organization on a virtualization host. + const personalVm = value.kind === "personal-vm"; + if ( + (bound.kind === "principal") !== personalVm || + (host.kind === "provider-estate") !== personalVm + ) + throw new Error("Machine binding mixes handover branches"); + if (personalVm && tailnet === null) + throw new Error("Personal Machine binding requires its tailnet identity"); + const binding: MachineBinding = { + contextDigest: value.contextDigest, + kind: value.kind, + name: value.name, + owner: bound, + network: + tailnet === null ? null : Object.freeze({ headscaleHostname: tailnet }), + host: Object.freeze({ kind: host.kind, id: host.id }), + }; + return Object.freeze( + withRelationships + ? { ...binding, relationships: relationships(value.relationships) } + : binding, + ); +} diff --git a/src/folder/presets.ts b/src/folder/presets.ts new file mode 100644 index 00000000..33edb70b --- /dev/null +++ b/src/folder/presets.ts @@ -0,0 +1,195 @@ +import type { MachineBinding } from "./machine-binding"; +import type { FolderProfile } from "./profile"; +import { stateFields } from "./state-fields"; + +// Workspace presets are data shipped with a release: no scripts, infrastructure +// or authority. A preset composes the fixed profile axes (access, purpose), +// defaults for the communication axes the Principal may change, the +// Personalspace policy, the provider identity mode, the offered surfaces and +// the supervision policy. Only whole presets are supported; editing a field +// does not create a new supported composition. +export const presetNames = [ + "local", + "hosted-personal", + "hosted-organization-personal", + "hosted-organization-team", +] as const; +export type PresetName = (typeof presetNames)[number]; +export const presetVersion = 1; +type MachineKind = "workstation" | MachineBinding["kind"]; + +export type WorkspacePreset = Readonly<{ + name: PresetName; + version: typeof presetVersion; + machineKinds: readonly MachineKind[]; + composition: Readonly<{ + access: FolderProfile["access"]; + purpose: FolderProfile["purpose"]; + }>; + defaults: Readonly<{ + locale: FolderProfile["locale"]; + detail: FolderProfile["detail"]; + coordination: FolderProfile["coordination"]; + }>; + personalspace: "present" | "never"; + providerIdentity: "own-sign-in" | "brokered-organization"; + surfaces: readonly ("launchpad" | "hosted-entry")[]; + supervision: "session" | "os-service-manager"; +}>; + +const defaults = Object.freeze({ + locale: "en", + detail: "concise", + coordination: "direct", +} as const); +const hosted = Object.freeze(["launchpad", "hosted-entry"] as const); + +const presets: Readonly> = Object.freeze({ + local: Object.freeze({ + name: "local", + version: presetVersion, + machineKinds: Object.freeze(["workstation"] as const), + composition: Object.freeze({ access: "local", purpose: "human" } as const), + defaults, + personalspace: "present", + providerIdentity: "own-sign-in", + surfaces: Object.freeze(["launchpad"] as const), + supervision: "session", + }), + "hosted-personal": Object.freeze({ + name: "hosted-personal", + version: presetVersion, + machineKinds: Object.freeze(["personal-vm"] as const), + composition: Object.freeze({ access: "remote", purpose: "human" } as const), + defaults, + personalspace: "present", + providerIdentity: "own-sign-in", + surfaces: hosted, + supervision: "os-service-manager", + }), + "hosted-organization-personal": Object.freeze({ + name: "hosted-organization-personal", + version: presetVersion, + machineKinds: Object.freeze(["workspace-vm"] as const), + composition: Object.freeze({ access: "remote", purpose: "human" } as const), + defaults, + personalspace: "never", + providerIdentity: "own-sign-in", + surfaces: hosted, + supervision: "os-service-manager", + }), + "hosted-organization-team": Object.freeze({ + name: "hosted-organization-team", + version: presetVersion, + machineKinds: Object.freeze(["workspace-vm"] as const), + composition: Object.freeze({ access: "remote", purpose: "human" } as const), + defaults, + personalspace: "never", + providerIdentity: "brokered-organization", + surfaces: hosted, + supervision: "os-service-manager", + }), +}); + +// The stored reference. `selection` records whether the Principal chose a +// preset other than the one derived from the handover at the time of choice. +export type PresetReference = Readonly<{ + name: PresetName; + version: typeof presetVersion; + selection: "derived" | "explicit"; +}>; + +export function parsePresetName(input: unknown): PresetName { + if ( + typeof input !== "string" || + !(presetNames as readonly string[]).includes(input) + ) + throw new Error("Unknown workspace preset"); + return input as PresetName; +} + +export function workspacePreset(name: PresetName): WorkspacePreset { + return presets[parsePresetName(name)]; +} + +export function parsePresetReference(input: unknown): PresetReference { + const value = stateFields(input, ["name", "version", "selection"]); + const name = parsePresetName(value.name); + if (value.version !== presetVersion) + throw new Error("Unsupported workspace preset version"); + if (value.selection !== "derived" && value.selection !== "explicit") + throw new Error("Invalid workspace preset selection"); + return Object.freeze({ + name, + version: presetVersion, + selection: value.selection, + }); +} + +// The preset is derived from the typed handover fields only: machine.kind and +// the presence of owner.team. Never from a name, hostname or operator account. +export function derivePreset(machine: MachineBinding | null): PresetName { + if (machine === null) return "local"; + if (machine.kind === "personal-vm") return "hosted-personal"; + return machine.owner.kind === "organization" && machine.owner.team !== null + ? "hosted-organization-team" + : "hosted-organization-personal"; +} + +// What the handover allows: a personal VM never takes an Organization preset +// and vice versa; a workstation is always `local`. +export function allowedPresets( + machine: MachineBinding | null, +): readonly PresetName[] { + const kind: MachineKind = machine === null ? "workstation" : machine.kind; + return presetNames.filter((name) => + presets[name].machineKinds.includes(kind), + ); +} + +export function presetReference( + name: PresetName, + machine: MachineBinding | null, +): PresetReference { + if (!allowedPresets(machine).includes(name)) + throw new Error("Workspace preset is not allowed by the Machine handover"); + return Object.freeze({ + name, + version: presetVersion, + selection: name === derivePreset(machine) ? "derived" : "explicit", + }); +} + +// Validates a stored or requested composition as a whole, before any mutation. +export function validatePresetComposition( + reference: PresetReference, + machine: MachineBinding | null, + profile: FolderProfile, +): WorkspacePreset { + const preset = workspacePreset(reference.name); + if (!allowedPresets(machine).includes(preset.name)) + throw new Error("Workspace preset is not allowed by the Machine handover"); + if ( + profile.access !== preset.composition.access || + profile.purpose !== preset.composition.purpose + ) + throw new Error("Profile does not match the workspace preset composition"); + return preset; +} + +// Profile defaults a preset supplies for initialization; the Principal may +// change the communication axes afterwards through the ordinary profile change. +export function presetProfile( + name: PresetName, + os: FolderProfile["os"], + choices: Partial = {}, +): FolderProfile { + const preset = workspacePreset(name); + return Object.freeze({ + os, + ...preset.composition, + locale: choices.locale ?? preset.defaults.locale, + detail: choices.detail ?? preset.defaults.detail, + coordination: choices.coordination ?? preset.defaults.coordination, + }); +} diff --git a/src/folder/preview.ts b/src/folder/preview.ts index 58dbdfad..e04561f4 100644 --- a/src/folder/preview.ts +++ b/src/folder/preview.ts @@ -1,7 +1,11 @@ import { createHash } from "node:crypto"; -import { parseFolderProfile } from "./profile"; import { type ObservedFile, planInstructions } from "./reconcile"; -import { instructionTemplateRevision, renderInstructions } from "./render"; +import { + instructionSource, + instructionTemplateRevision, + parseInstructionSource, + renderInstructions, +} from "./render"; import { parseFolderPreferences, parseInstructionManifest } from "./state"; // Adapter-supplied state must come from the trusted local owner, not arbitrary @@ -19,7 +23,7 @@ export async function previewConfiguredFolder( if (preferences.customInstructions !== "") throw new Error("Custom instruction composition is not implemented"); return previewFolder( - preferences.profile, + instructionSource(preferences), manifest?.output.digest ?? null, inspect, ); @@ -32,17 +36,17 @@ export async function previewFolder( previousDigest: string | null, inspect: () => Promise, ) { - const profile = parseFolderProfile(input); + const source = parseInstructionSource(input); if (previousDigest !== null && !/^[a-f0-9]{64}$/.test(previousDigest)) throw new Error("Invalid previous instruction digest"); - const content = renderInstructions(profile); + const content = renderInstructions(source); const digest = createHash("sha256").update(content).digest("hex"); const observed = await inspect(); const plan = planInstructions(previousDigest, digest, observed); return { kind: "folder-preview" as const, templateRevision: instructionTemplateRevision, - profile, + source, desired: { path: "AGENTS.md" as const, content, digest }, observed, plan, diff --git a/src/folder/render.ts b/src/folder/render.ts index 9a9641b6..4d8ca28f 100644 --- a/src/folder/render.ts +++ b/src/folder/render.ts @@ -1,10 +1,46 @@ -import { parseFolderProfile } from "./profile"; +import { type MachineBinding, parseMachineBinding } from "./machine-binding"; +import { + type PresetName, + parsePresetName, + presetReference, + validatePresetComposition, +} from "./presets"; +import { type FolderProfile, parseFolderProfile } from "./profile"; +import type { FolderPreferences } from "./state"; +import { stateFields } from "./state-fields"; // Version the template independently from future persisted preference schemas. -export const instructionTemplateRevision = "base-instructions-1"; +export const instructionTemplateRevision = "base-instructions-2"; + +// What the renderer needs and nothing else: the preset, the immutable Machine +// binding (null on a workstation) and the profile. Validated as one composition. +export type InstructionSource = Readonly<{ + preset: PresetName; + machine: MachineBinding | null; + profile: FolderProfile; +}>; + +export function parseInstructionSource(input: unknown): InstructionSource { + const value = stateFields(input, ["preset", "machine", "profile"]); + const preset = parsePresetName(value.preset); + const machine = parseMachineBinding(value.machine); + const profile = parseFolderProfile(value.profile); + validatePresetComposition(presetReference(preset, machine), machine, profile); + return Object.freeze({ preset, machine, profile }); +} + +export function instructionSource( + preferences: FolderPreferences, +): InstructionSource { + return Object.freeze({ + preset: preferences.preset.name, + machine: preferences.machine, + profile: preferences.profile, + }); +} export function renderInstructions(input: unknown): string { - const profile = parseFolderProfile(input); + const { preset, profile } = parseInstructionSource(input); const cs = profile.locale === "cs"; const purpose = { human: cs @@ -19,7 +55,7 @@ export function renderInstructions(input: unknown): string { }; return [ "# Lazurio", - ``, + ``, cs ? "Komunikuj česky, pokud uživatel nepožádá jinak." : "Communicate in English unless the user requests otherwise.", diff --git a/src/folder/resume-initialization.ts b/src/folder/resume-initialization.ts index a2794212..c8f5a47f 100644 --- a/src/folder/resume-initialization.ts +++ b/src/folder/resume-initialization.ts @@ -11,6 +11,7 @@ import { inspectOwnedDirectory } from "./owned-directory"; import { executionOs } from "./platform"; import { previewFolder } from "./preview"; import { readOwnedStateFile, readStateJson } from "./read-state"; +import { instructionSource } from "./render"; import { parseFolderPreferences, parseInstructionManifest, @@ -105,7 +106,7 @@ export async function resumeInitialization( ) throw new Error("Unsupported initialization preferences"); const preview = await previewFolder( - preferences.profile, + instructionSource(preferences), null, async () => ({ kind: "absent" }), ); diff --git a/src/folder/retire-preparation.ts b/src/folder/retire-preparation.ts index 8b936f31..ca24e0cc 100644 --- a/src/folder/retire-preparation.ts +++ b/src/folder/retire-preparation.ts @@ -10,7 +10,7 @@ import { readOwnedStateFile, readStateJson, } from "./read-state"; -import { renderInstructions } from "./render"; +import { instructionSource, renderInstructions } from "./render"; import { parseFolderPreferences, parseInstructionManifest, @@ -99,7 +99,7 @@ export async function retireIncompletePreparation( throw new Error("Recovery conflicts with active identity"); } if ( - active.content !== renderInstructions(preferences.profile) || + active.content !== renderInstructions(instructionSource(preferences)) || JSON.stringify(parseFolderPreferences(JSON.parse(prefs.content))) !== JSON.stringify(preferences) || JSON.stringify(parseInstructionManifest(JSON.parse(owned.content))) !== @@ -110,7 +110,7 @@ export async function retireIncompletePreparation( preferences, manifest, expectedRevision, - preferences.profile, + { preset: preferences.preset.name, profile: preferences.profile }, async () => ({ kind: "regular", digest: manifest.output.digest }), ); if (coherent.kind !== "unchanged") diff --git a/src/folder/state-fields.ts b/src/folder/state-fields.ts new file mode 100644 index 00000000..3df90056 --- /dev/null +++ b/src/folder/state-fields.ts @@ -0,0 +1,30 @@ +// Exact-key, data-only field extraction shared by every Folder state parser. +// Accessor and inherited fields are refused without being executed. +export function stateFields( + input: unknown, + keys: readonly string[], +): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) + throw new Error("Invalid Folder state"); + const ownKeys = Reflect.ownKeys(input); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== "string" || !keys.includes(key)) + ) + throw new Error("Unknown or missing Folder state field"); + const result: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor || !("value" in descriptor)) + throw new Error("Executable Folder state field"); + result[key] = descriptor.value; + } + return result; +} + +// The own data value of one key, or undefined; never executes an accessor. +export function ownDataValue(input: unknown, key: string): unknown { + if (typeof input !== "object" || input === null) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(input, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} diff --git a/src/folder/state.ts b/src/folder/state.ts index 64a411ad..485aa0b8 100644 --- a/src/folder/state.ts +++ b/src/folder/state.ts @@ -1,17 +1,31 @@ +import { type MachineBinding, parseMachineBinding } from "./machine-binding"; +import { + type PresetReference, + parsePresetReference, + validatePresetComposition, +} from "./presets"; import { type FolderProfile, parseFolderProfile } from "./profile"; +import { stateFields } from "./state-fields"; + +export { stateFields } from "./state-fields"; // Schema versions this product can read; a release declares them in its // artifact identity so staging can check backward read compatibility. export const folderStateSchemas = Object.freeze({ - preferences: Object.freeze([1]), + preferences: Object.freeze([2]), manifest: Object.freeze([1]), }); // Development schema for the single-output transaction. Parsing is not proof of // custody: a filesystem adapter must establish the state owner's trusted boundary. +// Schema 2 adds the workspace preset reference and the immutable Machine binding +// recorded from the handover (null on a workstation). The whole composition is +// validated: an unknown preset, version or disallowed combination never parses. export type FolderPreferences = Readonly<{ - schemaVersion: 1; + schemaVersion: 2; revision: number; + preset: PresetReference; + machine: MachineBinding | null; profile: FolderProfile; customInstructions: string; }>; @@ -23,28 +37,6 @@ export type InstructionManifest = Readonly<{ output: Readonly<{ path: "AGENTS.md"; digest: string }>; }>; -export function stateFields( - input: unknown, - keys: readonly string[], -): Record { - if (typeof input !== "object" || input === null || Array.isArray(input)) - throw new Error("Invalid Folder state"); - const ownKeys = Reflect.ownKeys(input); - if ( - ownKeys.length !== keys.length || - ownKeys.some((key) => typeof key !== "string" || !keys.includes(key)) - ) - throw new Error("Unknown or missing Folder state field"); - const result: Record = {}; - for (const key of keys) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (!descriptor || !("value" in descriptor)) - throw new Error("Executable Folder state field"); - result[key] = descriptor.value; - } - return result; -} - function revision(input: unknown): number { if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 1) throw new Error("Invalid Folder revision"); @@ -55,15 +47,23 @@ export function parseFolderPreferences(input: unknown): FolderPreferences { const value = stateFields(input, [ "schemaVersion", "revision", + "preset", + "machine", "profile", "customInstructions", ]); - if (value.schemaVersion !== 1 || typeof value.customInstructions !== "string") + if (value.schemaVersion !== 2 || typeof value.customInstructions !== "string") throw new Error("Unsupported Folder preferences"); + const preset = parsePresetReference(value.preset); + const machine = parseMachineBinding(value.machine); + const profile = parseFolderProfile(value.profile); + validatePresetComposition(preset, machine, profile); return Object.freeze({ - schemaVersion: 1, + schemaVersion: 2, revision: revision(value.revision), - profile: parseFolderProfile(value.profile), + preset, + machine, + profile, // Source is preserved verbatim. This schema neither executes it nor imports // effective mandates; composition/conflict handling is a separate consumer. customInstructions: value.customInstructions, diff --git a/src/folder/validate-preparation.ts b/src/folder/validate-preparation.ts index 3226d3d2..d51cfa80 100644 --- a/src/folder/validate-preparation.ts +++ b/src/folder/validate-preparation.ts @@ -67,7 +67,7 @@ export async function validatePreparation( previousPreferences, previousManifest, previousPreferences.revision, - preferences.profile, + { preset: preferences.preset.name, profile: preferences.profile }, async () => ({ kind: "regular", digest: previousManifest.output.digest }), ); if ( diff --git a/src/launchpad/server.ts b/src/launchpad/server.ts index 51d9a166..ae331430 100644 --- a/src/launchpad/server.ts +++ b/src/launchpad/server.ts @@ -146,11 +146,9 @@ export async function startLaunchpad( return response({ error: "invalid-revision" }, 400); const operation = url.pathname === "/api/update" ? updateProfile : inspectProfileChange; - const result = await operation( - folder, - value.expectedRevision, - value.profile, - ); + const result = await operation(folder, value.expectedRevision, { + profile: value.profile, + }); return response(result, result.kind === "blocked" ? 409 : 200); } catch { return response( diff --git a/src/machine/binding.ts b/src/machine/binding.ts new file mode 100644 index 00000000..ea9ba29a --- /dev/null +++ b/src/machine/binding.ts @@ -0,0 +1,55 @@ +import type { MachineBinding } from "../folder/machine-binding"; +import type { MachineContext, PersonalMachineContext } from "./context"; + +// The whole document is one branch; machine.kind names it. +function isPersonal( + context: MachineContext, +): context is PersonalMachineContext { + return context.machine.kind === "personal-vm"; +} + +// The projection of a validated handover that the Folder records and renders. +// Paths, release inventory and custody repositories stay in the handover file; +// the binding keeps what identifies the Machine and whose it is. +export function machineBinding( + context: MachineContext, + contextDigest: string, +): MachineBinding { + const network = + context.network === undefined + ? null + : Object.freeze({ + headscaleHostname: context.network.headscale_hostname, + }); + if (isPersonal(context)) + return Object.freeze({ + contextDigest, + kind: "personal-vm", + name: context.machine.name, + owner: Object.freeze({ + kind: "principal", + githubLogin: context.owner.github_login, + githubId: context.owner.github_id, + }), + network, + host: Object.freeze({ + kind: "provider-estate", + id: context.host.estate_id, + }), + }); + return Object.freeze({ + contextDigest, + kind: "workspace-vm", + name: context.machine.name, + owner: Object.freeze({ + kind: "organization", + organization: context.owner.organization, + team: context.owner.team ?? null, + }), + network, + host: Object.freeze({ + kind: "virtualization-host", + id: context.host.machine_id, + }), + }); +} diff --git a/src/machine/cli.ts b/src/machine/cli.ts index b0e39ee8..fa0685d2 100644 --- a/src/machine/cli.ts +++ b/src/machine/cli.ts @@ -1,6 +1,12 @@ import { parseArgs } from "node:util"; import { initializeHandoverFolder } from "../folder/initialize-folder"; -import { parseFolderProfile } from "../folder/profile"; +import { + allowedPresets, + derivePreset, + parsePresetName, + presetProfile, +} from "../folder/presets"; +import { machineBinding } from "./binding"; import { bindMachineOperator, MachineContextError, @@ -12,15 +18,25 @@ export const machineHelp = `machine inspect Read the root-issued /etc/lazurio/lazurio.machine.json on Linux only. Output contains private Machine/Organization context; do not publish it. The declaration grants no permissions, provider identity or access. -machine folder-init --locale --detail - --coordination -Initialize the declared operator's empty, precreated standard Lazurio Folder. -Limited pilot: Linux / remote / human. All three choices required. +machine folder-init [--preset ] [--locale ] + [--detail ] [--coordination ] +Initialize the declared operator's standard Lazurio Folder from the handover. +The workspace preset is derived from the handover (personal-vm -> hosted-personal; +workspace-vm with owner.team -> hosted-organization-team; without -> +hosted-organization-personal). --preset may pick another preset the handover +allows and is recorded as an explicit choice. Omitted communication choices +take the preset's defaults; all are changeable later in the Launchpad. Run as the declared operator, never root. No path or custody override. No Organization checkout, gateway change, resident removal or migration. An interrupted recognized journal can be completed using folder-resume; missing/damaged journals require operator diagnosis, never blanket cleanup.`; +const choices = { + locale: ["cs", "en"], + detail: ["concise", "technical"], + coordination: ["direct", "coordinator"], +} as const; + export async function runMachineCommand(args: string[]) { const [command, ...options] = args; if (command !== "inspect" && command !== "folder-init") @@ -29,33 +45,30 @@ export async function runMachineCommand(args: string[]) { args: options, strict: true, tokens: true, - options: - command === "inspect" - ? {} - : { - locale: { type: "string" }, - detail: { type: "string" }, - coordination: { type: "string" }, - }, + options: { + preset: { type: "string" }, + locale: { type: "string" }, + detail: { type: "string" }, + coordination: { type: "string" }, + }, }); if ( - tokens.length !== (command === "inspect" ? 0 : 3) || + (command === "inspect" && tokens.length !== 0) || + tokens.some((token) => token.kind !== "option") || new Set(tokens.map((token) => (token.kind === "option" ? token.name : ""))) .size !== tokens.length ) throw new Error("Explicit nonduplicate Machine options required"); - const profile = - command === "folder-init" - ? parseFolderProfile({ - os: "linux", - access: "remote", - purpose: "human", - ...values, - }) - : null; + for (const [name, allowed] of Object.entries(choices)) { + const value = values[name as keyof typeof choices]; + if (value !== undefined && !(allowed as readonly string[]).includes(value)) + throw new Error(`Invalid Machine choice: ${name}`); + } + const requestedPreset = + values.preset === undefined ? undefined : parsePresetName(values.preset); try { const observed = await readMachineContext(); - if (!profile) + if (command === "inspect") return { code: 0, result: { @@ -64,11 +77,39 @@ export async function runMachineCommand(args: string[]) { authority: "none", }, }; + const machine = machineBinding(observed.context, observed.digest); + const preset = requestedPreset ?? derivePreset(machine); + if (!allowedPresets(machine).includes(preset)) + return { + code: 2, + result: { + kind: "blocked", + reason: "preset-not-allowed", + derived: derivePreset(machine), + allowed: allowedPresets(machine), + next: "Choose a preset the handover allows, or omit --preset for the derived one.", + }, + }; + const profile = presetProfile(preset, "linux", { + ...(values.locale === undefined + ? {} + : { locale: values.locale as "cs" | "en" }), + ...(values.detail === undefined + ? {} + : { detail: values.detail as "concise" | "technical" }), + ...(values.coordination === undefined + ? {} + : { coordination: values.coordination as "direct" | "coordinator" }), + }); const folder = bindMachineOperator( observed.context, await readLinuxOperator(), ); - const result = await initializeHandoverFolder(folder, profile); + const result = await initializeHandoverFolder(folder, { + preset, + machine, + profile, + }); return { code: 0, result: { ...result, machineContextDigest: observed.digest }, diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b8311552..0d66a282 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -94,12 +94,18 @@ test.skipIf(process.platform === "win32")( ]); expect(code, stderr).toBe(0); expect(JSON.parse(stdout)).toEqual( - await previewFolder(profile, null, async () => ({ kind: "absent" })), + await previewFolder( + { preset: "local", machine: null, profile }, + null, + async () => ({ kind: "absent" }), + ), ); expect(await readdir(directory)).toEqual([]); - const initial = await previewFolder(profile, null, async () => ({ - kind: "absent", - })); + const initial = await previewFolder( + { preset: "local", machine: null, profile }, + null, + async () => ({ kind: "absent" }), + ); const initialize = async (path: string, extra: string[] = []) => { const child = Bun.spawn( [ @@ -132,6 +138,7 @@ test.skipIf(process.platform === "win32")( expect(JSON.parse(created.out)).toEqual({ kind: "initialized", revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, }); expect((await initialize(directory)).exit).toBe(1); const state = join(directory, ".lazurio"); @@ -195,7 +202,7 @@ test.skipIf(process.platform === "win32")( expect(unchanged.exit).toBe(0); expect(JSON.parse(unchanged.out)).toEqual({ kind: "unchanged" }); await expect( - updateProfile(directory, 2, profile, async (step) => { + updateProfile(directory, 2, { profile }, async (step) => { if (step === "prepared") throw new Error("Prepared interruption"); }), ).rejects.toThrow("Prepared interruption"); diff --git a/tests/fixtures/machine-bindings.ts b/tests/fixtures/machine-bindings.ts new file mode 100644 index 00000000..3d2559dd --- /dev/null +++ b/tests/fixtures/machine-bindings.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; +import { machineBinding } from "../../src/machine/binding"; +import { parseMachineContext } from "../../src/machine/context"; +import organization from "./machine-context.json"; +import personal from "./machine-context-personal.json"; + +// The three handover shapes the presets derive from, projected exactly as the +// machine CLI would project a validated root-issued document. +function binding(document: unknown) { + const bytes = Buffer.from(JSON.stringify(document)); + return machineBinding( + parseMachineContext(bytes), + createHash("sha256").update(bytes).digest("hex"), + ); +} +const { team: _, ...withoutTeam } = organization.owner; +export const bindings = Object.freeze({ + personal: binding(personal), + team: binding(organization), + organization: binding({ ...organization, owner: withoutTeam }), +}); diff --git a/tests/folder-change-profile.test.ts b/tests/folder-change-profile.test.ts index 0ebead98..518dc32c 100644 --- a/tests/folder-change-profile.test.ts +++ b/tests/folder-change-profile.test.ts @@ -1,29 +1,35 @@ import { expect, test } from "bun:test"; import { planProfileChange } from "../src/folder/change-profile"; import { previewFolder } from "../src/folder/preview"; +import { instructionSource } from "../src/folder/render"; import { parseFolderPreferences } from "../src/folder/state"; +import { bindings } from "./fixtures/machine-bindings"; const current = parseFolderPreferences({ - schemaVersion: 1, + schemaVersion: 2, revision: 7, customInstructions: "", + preset: { name: "local", version: 1, selection: "derived" }, + machine: null, profile: { os: "linux", - access: "remote", + access: "local", purpose: "human", locale: "en", detail: "concise", coordination: "direct", }, }); -async function fixture() { - const preview = await previewFolder(current.profile, null, async () => ({ - kind: "absent", - })); +async function fixture(preferences = current) { + const preview = await previewFolder( + instructionSource(preferences), + null, + async () => ({ kind: "absent" }), + ); return { manifest: { schemaVersion: 1, - preferenceRevision: current.revision, + preferenceRevision: preferences.revision, templateRevision: preview.templateRevision, output: { path: "AGENTS.md", digest: preview.desired.digest }, }, @@ -41,7 +47,7 @@ test("profile change binds one next revision and matching output without mutatin current, manifest, 7, - { ...current.profile, locale: "cs" }, + { profile: { ...current.profile, locale: "cs" } }, inspect, ); expect(result.kind).toBe("profile-change"); @@ -53,8 +59,92 @@ test("profile change binds one next revision and matching output without mutatin expect(result.previousDigest).toBe(manifest.output.digest); expect(JSON.stringify({ current, manifest })).toBe(snapshot); expect( - await planProfileChange(current, manifest, 7, current.profile, inspect), + await planProfileChange( + current, + manifest, + 7, + { profile: current.profile }, + inspect, + ), ).toEqual({ kind: "unchanged" }); + expect( + await planProfileChange( + current, + manifest, + 7, + { preset: "local", profile: current.profile }, + inspect, + ), + ).toEqual({ kind: "unchanged" }); +}); + +test("the preset changes only within the handover allow-list and keeps the binding", async () => { + const hosted = parseFolderPreferences({ + schemaVersion: 2, + revision: 3, + customInstructions: "", + preset: { + name: "hosted-organization-personal", + version: 1, + selection: "derived", + }, + machine: bindings.organization, + profile: { ...current.profile, access: "remote" }, + }); + const { manifest, inspect } = await fixture(hosted); + const changed = await planProfileChange( + hosted, + manifest, + 3, + { preset: "hosted-organization-team", profile: hosted.profile }, + inspect, + ); + expect(changed.kind).toBe("profile-change"); + if (changed.kind !== "profile-change") throw new Error("Expected change"); + expect(changed.preferences.preset).toEqual({ + name: "hosted-organization-team", + version: 1, + selection: "explicit", + }); + expect(changed.preferences.machine).toEqual(bindings.organization); + expect(changed.preferences.revision).toBe(4); + for (const preset of ["hosted-personal", "local"] as const) + expect( + await planProfileChange( + hosted, + manifest, + 3, + { preset, profile: hosted.profile }, + inspect, + ), + ).toEqual({ kind: "blocked", reason: "preset-not-allowed" }); + expect( + await planProfileChange( + hosted, + manifest, + 3, + { profile: { ...hosted.profile, access: "local" } }, + inspect, + ), + ).toEqual({ kind: "blocked", reason: "preset-composition" }); + await expect( + planProfileChange( + hosted, + manifest, + 3, + { preset: "hosted-team", profile: hosted.profile }, + inspect, + ), + ).rejects.toThrow("Unknown workspace preset"); + await expect( + planProfileChange( + hosted, + manifest, + 3, + { machine: null, profile: hosted.profile }, + inspect, + ), + ).rejects.toThrow(); }); test("stale, incomplete, incompatible and custom state blocks before inventory", async () => { @@ -62,7 +152,7 @@ test("stale, incomplete, incompatible and custom state blocks before inventory", const inspect = async (): Promise => { throw new Error("Inventory must not run"); }; - const changed = { ...current.profile, locale: "cs" }; + const changed = { profile: { ...current.profile, locale: "cs" } }; expect( await planProfileChange( current, @@ -108,7 +198,7 @@ test("stale, incomplete, incompatible and custom state blocks before inventory", current, manifest, 7, - { ...changed, os: "windows" }, + { profile: { ...changed.profile, os: "windows" } }, inspect, ), ).toEqual({ kind: "blocked", reason: "execution-os-change" }); @@ -116,7 +206,7 @@ test("stale, incomplete, incompatible and custom state blocks before inventory", test("drift and exhausted revisions never yield new writable state", async () => { const { manifest, inspect } = await fixture(); - const changed = { ...current.profile, locale: "cs" }; + const changed = { profile: { ...current.profile, locale: "cs" } }; expect( await planProfileChange(current, manifest, 7, changed, async () => ({ kind: "absent", diff --git a/tests/folder-handover.test.ts b/tests/folder-handover.test.ts index 775db03b..4a31ba0e 100644 --- a/tests/folder-handover.test.ts +++ b/tests/folder-handover.test.ts @@ -18,6 +18,7 @@ import { initializeHandoverFolder } from "../src/folder/initialize-folder"; import { executionOs } from "../src/folder/platform"; import { resumeInitialization } from "../src/folder/resume-initialization"; import { updateProfile } from "../src/folder/update-profile"; +import { bindings } from "./fixtures/machine-bindings"; const profile = { os: executionOs(process.platform), @@ -27,6 +28,20 @@ const profile = { detail: "technical", coordination: "direct", }; +const source = { + preset: "hosted-organization-personal", + machine: bindings.organization, + profile, +} as const; +const initialized = { + kind: "initialized", + revision: 1, + preset: { + name: "hosted-organization-personal", + version: 1, + selection: "derived", + }, +} as const; async function setup() { const parent = await realpath( await mkdtemp(join(tmpdir(), "handover-init-")), @@ -62,13 +77,13 @@ for (const stop of [ ); const result = initializeHandoverFolder( folder, - profile, + source, async (step) => { if (step === stop) throw new Error("interrupted"); }, ); if (stop) await expect(result).rejects.toThrow("interrupted"); - else expect(await result).toEqual({ kind: "initialized", revision: 1 }); + else expect(await result).toEqual(initialized); expect(await resumeInitialization(folder)).toEqual({ kind: "recovered", revision: 1, @@ -97,7 +112,7 @@ for (const stop of [ revision: 1, }); await expect( - initializeHandoverFolder(folder, profile), + initializeHandoverFolder(folder, source), ).rejects.toThrow(); expect( await readFile(join(folder, "organizations", "keep"), "utf8"), @@ -106,7 +121,9 @@ for (const stop of [ await readFile(join(folder, "personalspace", "keep"), "utf8"), ).toBe("synthetic personal work"); expect( - await updateProfile(folder, 1, { ...profile, locale: "en" }), + await updateProfile(folder, 1, { + profile: { ...profile, locale: "en" }, + }), ).toEqual({ kind: "updated", revision: 2 }); } finally { await rm(parent, { recursive: true, force: true }); @@ -146,7 +163,7 @@ for (const scenario of [ await chmod(join(folder, "organizations"), 0o777); const before = await readdir(folder); await expect( - initializeHandoverFolder(folder, profile), + initializeHandoverFolder(folder, source), ).rejects.toThrow(); expect(await readdir(folder)).toEqual(before); if (scenario !== "state") @@ -163,7 +180,7 @@ test.skipIf(process.platform === "win32")( const { parent, folder } = await setup(); try { await expect( - initializeHandoverFolder(folder, profile, async (step) => { + initializeHandoverFolder(folder, source, async (step) => { if (step === "journal") throw new Error("stop"); }), ).rejects.toThrow("stop"); @@ -184,8 +201,8 @@ test.skipIf(process.platform === "win32")( const { parent, folder } = await setup(); try { const results = await Promise.allSettled([ - initializeHandoverFolder(folder, profile), - initializeHandoverFolder(folder, profile), + initializeHandoverFolder(folder, source), + initializeHandoverFolder(folder, source), ]); expect( results.filter((result) => result.status === "fulfilled"), diff --git a/tests/folder-initialize.test.ts b/tests/folder-initialize.test.ts index 952bc8ae..77dd36e6 100644 --- a/tests/folder-initialize.test.ts +++ b/tests/folder-initialize.test.ts @@ -37,10 +37,14 @@ for (const stop of [ if (stop) { await expect(run).rejects.toThrow("Interrupted initialization"); await expect( - updateProfile(folder, 1, { ...profile, locale: "cs" }), + updateProfile(folder, 1, { profile: { ...profile, locale: "cs" } }), ).rejects.toThrow(); } else { - expect(await run).toEqual({ kind: "initialized", revision: 1 }); + expect(await run).toEqual({ + kind: "initialized", + revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, + }); expect((await readdir(folder)).sort()).toEqual([ ".lazurio", "AGENTS.md", @@ -48,7 +52,9 @@ for (const stop of [ "personalspace", ]); expect( - await updateProfile(folder, 1, { ...profile, locale: "cs" }), + await updateProfile(folder, 1, { + profile: { ...profile, locale: "cs" }, + }), ).toEqual({ kind: "updated", revision: 2 }); expect( JSON.parse( diff --git a/tests/folder-inspect-change.test.ts b/tests/folder-inspect-change.test.ts index 0f33ca78..8cda76b1 100644 --- a/tests/folder-inspect-change.test.ts +++ b/tests/folder-inspect-change.test.ts @@ -34,15 +34,20 @@ test.skipIf(process.platform === "win32")( coordination: "direct", }; const requested = { ...profile, locale: "cs" }; + const request = { profile: requested }; try { await mkdir(folder, { mode: 0o700 }); await mkdir(state, { mode: 0o700 }); - const preview = await previewFolder(profile, null, async () => ({ - kind: "absent", - })); + const preview = await previewFolder( + { preset: "local", machine: null, profile }, + null, + async () => ({ kind: "absent" }), + ); const preferences = JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, + machine: null, profile, customInstructions: "", }); @@ -63,7 +68,7 @@ test.skipIf(process.platform === "win32")( await writeFile(join(state, "instructions.json"), manifest, { mode: 0o600, }); - expect((await inspectProfileChange(folder, 1, requested)).kind).toBe( + expect((await inspectProfileChange(folder, 1, request)).kind).toBe( "profile-change", ); const runCli = async (revision: string) => { @@ -91,7 +96,7 @@ test.skipIf(process.platform === "win32")( const cli = await runCli("1"); expect(cli.code, cli.stderr).toBe(0); expect(JSON.parse(cli.stdout)).toEqual( - await inspectProfileChange(folder, 1, requested), + await inspectProfileChange(folder, 1, request), ); const staleCli = await runCli("2"); expect(staleCli.code).toBe(2); @@ -100,7 +105,7 @@ test.skipIf(process.platform === "win32")( reason: "stale-revision", }); expect((await runCli("1.5")).code).toBe(1); - expect(await inspectProfileChange(folder, 2, requested)).toEqual({ + expect(await inspectProfileChange(folder, 2, request)).toEqual({ kind: "blocked", reason: "stale-revision", }); @@ -119,15 +124,15 @@ test.skipIf(process.platform === "win32")( "preferences.json", ]); await withFolderOperationLock(state, async () => { - await expect( - inspectProfileChange(folder, 1, requested), - ).rejects.toThrow("busy"); + await expect(inspectProfileChange(folder, 1, request)).rejects.toThrow( + "busy", + ); }); await writeFile( join(state, "pending.json"), "preserve transaction evidence", ); - await expect(inspectProfileChange(folder, 1, requested)).rejects.toThrow( + await expect(inspectProfileChange(folder, 1, request)).rejects.toThrow( "pending", ); expect(await readFile(join(state, "pending.json"), "utf8")).toBe( @@ -135,12 +140,10 @@ test.skipIf(process.platform === "win32")( ); await rm(join(state, "pending.json")); await writeFile(join(state, "preferences.json"), "invalid JSON"); - await expect( - inspectProfileChange(folder, 1, requested), - ).rejects.toThrow(); + await expect(inspectProfileChange(folder, 1, request)).rejects.toThrow(); await rm(join(state, "preferences.json")); await symlink(join(folder, "own-notes"), join(state, "preferences.json")); - await expect(inspectProfileChange(folder, 1, requested)).rejects.toThrow( + await expect(inspectProfileChange(folder, 1, request)).rejects.toThrow( "Unsafe", ); expect(await readFile(join(folder, "own-notes"), "utf8")).toBe( @@ -154,7 +157,7 @@ test.skipIf(process.platform === "win32")( const externalState = join(temporary, "other-state"); await rename(state, externalState); await symlink(externalState, state); - await expect(inspectProfileChange(folder, 1, requested)).rejects.toThrow( + await expect(inspectProfileChange(folder, 1, request)).rejects.toThrow( "Canonical", ); expect((await readdir(externalState)).sort()).toEqual([ diff --git a/tests/folder-prepare-change.test.ts b/tests/folder-prepare-change.test.ts index 45ac53f9..e662787b 100644 --- a/tests/folder-prepare-change.test.ts +++ b/tests/folder-prepare-change.test.ts @@ -45,12 +45,16 @@ async function fixture() { detail: "concise", coordination: "direct", }; - const preview = await previewFolder(profile, null, async () => ({ - kind: "absent", - })); + const preview = await previewFolder( + { preset: "local", machine: null, profile }, + null, + async () => ({ kind: "absent" }), + ); const preferences = JSON.stringify({ - schemaVersion: 1, + schemaVersion: 2, revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, + machine: null, profile, customInstructions: "", }); @@ -92,27 +96,29 @@ test.skipIf(process.platform === "win32")( await updateProfile( f.folder, 1, - { ...f.profile, locale: "cs" }, + { profile: { ...f.profile, locale: "cs" } }, async (step) => { checkpoints.push(step); await expect( - updateProfile(f.folder, 1, f.profile), + updateProfile(f.folder, 1, { profile: f.profile }), ).rejects.toThrow(); await expect( - inspectProfileChange(f.folder, 1, f.profile), + inspectProfileChange(f.folder, 1, { profile: f.profile }), ).rejects.toThrow(); }, ), ).toEqual({ kind: "updated", revision: 2 }); expect(checkpoints).toEqual(["prepared", "applied", "finalized"]); - expect(await updateProfile(f.folder, 1, f.profile)).toEqual({ + expect(await updateProfile(f.folder, 1, { profile: f.profile })).toEqual({ kind: "blocked", reason: "stale-revision", }); expect( - await updateProfile(f.folder, 2, { ...f.profile, locale: "cs" }), + await updateProfile(f.folder, 2, { + profile: { ...f.profile, locale: "cs" }, + }), ).toEqual({ kind: "unchanged" }); - expect(await updateProfile(f.folder, 2, f.profile)).toEqual({ + expect(await updateProfile(f.folder, 2, { profile: f.profile })).toEqual({ kind: "updated", revision: 3, }); @@ -138,18 +144,20 @@ for (const stop of ["prepared", "applied"] as const) { updateProfile( f.folder, 1, - { ...f.profile, locale: "cs" }, + { profile: { ...f.profile, locale: "cs" } }, async (step) => { if (step === stop) throw new Error("Interrupted use case"); }, ), ).rejects.toThrow("Interrupted use case"); - await expect(updateProfile(f.folder, 1, f.profile)).rejects.toThrow( - "pending", - ); + await expect( + updateProfile(f.folder, 1, { profile: f.profile }), + ).rejects.toThrow("pending"); await applyPreparation(f.folder); await finalizePreparation(f.folder, 2); - expect(await updateProfile(f.folder, 2, f.profile)).toEqual({ + expect( + await updateProfile(f.folder, 2, { profile: f.profile }), + ).toEqual({ kind: "updated", revision: 3, }); @@ -176,7 +184,7 @@ for (const stop of [ prepareProfileChange( f.folder, 1, - { ...f.profile, locale: "cs" }, + { profile: { ...f.profile, locale: "cs" } }, async (step) => { if (step === stop) throw new Error("Interrupted"); }, @@ -211,8 +219,10 @@ for (const stop of [ expect( ( await prepareProfileChange(f.folder, 1, { - ...f.profile, - locale: "cs", + profile: { + ...f.profile, + locale: "cs", + }, }) ).kind, ).toBe("prepared"); @@ -233,15 +243,17 @@ for (const mode of ["empty", "prepared", "edited"] as const) { try { if (mode === "prepared") await prepareProfileChange(f.folder, 1, { - ...f.profile, - locale: "cs", + profile: { + ...f.profile, + locale: "cs", + }, }); else await expect( prepareProfileChange( f.folder, 1, - { ...f.profile, locale: "cs" }, + { profile: { ...f.profile, locale: "cs" } }, async (step) => { if (step === (mode === "empty" ? "directory" : "before")) throw new Error("Interrupted"); @@ -270,7 +282,9 @@ for (const stop of [null, "history", "archived"] as const) { async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); await expect(finalizePreparation(f.folder, 2)).rejects.toThrow( "not fully applied", ); @@ -300,10 +314,11 @@ for (const stop of [null, "history", "archived"] as const) { expect(await readFile(join(archive, "prepared.json"))).toEqual( prepared, ); - expect((await inspectProfileChange(f.folder, 2, f.profile)).kind).toBe( - "profile-change", - ); - await prepareProfileChange(f.folder, 2, f.profile); + expect( + (await inspectProfileChange(f.folder, 2, { profile: f.profile })) + .kind, + ).toBe("profile-change"); + await prepareProfileChange(f.folder, 2, { profile: f.profile }); expect( (await inspectPreparation(f.folder)).plan.preferences.revision, ).toBe(3); @@ -333,7 +348,9 @@ test.skipIf(process.platform === "win32")( async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); await applyPreparation(f.folder); const history = join(f.state, "history"); await mkdir(history, { mode: 0o700 }); @@ -366,7 +383,9 @@ for (const invalid of [ async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); const stage = join(f.state, "transaction"); if (invalid === "partial") { await rm(join(stage, "prepared.json")); @@ -417,7 +436,9 @@ for (const stop of [ async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); const desired = await readFile( join(f.state, "transaction", "AGENTS.md"), "utf8", @@ -457,7 +478,7 @@ for (const stop of [ "Preserve user work", ); await expect( - inspectProfileChange(f.folder, 2, f.profile), + inspectProfileChange(f.folder, 2, { profile: f.profile }), ).rejects.toThrow("pending"); } finally { await rm(f.folder, { recursive: true, force: true }); @@ -471,7 +492,9 @@ test.skipIf(process.platform === "win32")( async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); await expect( applyPreparation(f.folder, async () => { await writeFile( @@ -511,7 +534,7 @@ for (const stop of [ async () => { const f = await fixture(); try { - const requested = { ...f.profile, locale: "cs" }; + const requested = { profile: { ...f.profile, locale: "cs" } }; const checkpoint = async (step: PreparationStep) => { if (step === stop) throw new Error("Injected interruption"); }; @@ -655,7 +678,7 @@ test.skipIf(process.platform === "win32")( prepareProfileChange( f.folder, 1, - { ...f.profile, locale: "cs" }, + { profile: { ...f.profile, locale: "cs" } }, async (step) => { if (step === "instructions") await writeFile(join(f.folder, "AGENTS.md"), "New user edit"); @@ -682,11 +705,15 @@ test.skipIf(process.platform === "win32")( async () => { const f = await fixture(); try { - expect(await prepareProfileChange(f.folder, 2, f.profile)).toEqual({ + expect( + await prepareProfileChange(f.folder, 2, { profile: f.profile }), + ).toEqual({ kind: "blocked", reason: "stale-revision", }); - expect(await prepareProfileChange(f.folder, 1, f.profile)).toEqual({ + expect( + await prepareProfileChange(f.folder, 1, { profile: f.profile }), + ).toEqual({ kind: "unchanged", }); expect((await readdir(f.state)).sort()).toEqual([ @@ -708,8 +735,10 @@ for (const target of ["active", "staged"] as const) { const f = await fixture(); try { await prepareProfileChange(f.folder, 1, { - ...f.profile, - locale: "cs", + profile: { + ...f.profile, + locale: "cs", + }, }); const path = target === "active" @@ -734,7 +763,9 @@ for (const target of ["active", "staged"] as const) { async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); const path = target === "active" ? join(f.folder, "AGENTS.md") @@ -756,7 +787,9 @@ for (const target of ["active", "staged"] as const) { async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); const path = target === "active" ? join(f.folder, "AGENTS.md") @@ -784,7 +817,9 @@ test.skipIf(process.platform === "win32")( async () => { const f = await fixture(); try { - await prepareProfileChange(f.folder, 1, { ...f.profile, locale: "cs" }); + await prepareProfileChange(f.folder, 1, { + profile: { ...f.profile, locale: "cs" }, + }); const edited = JSON.stringify({ ...JSON.parse(f.preferences), revision: 2, diff --git a/tests/folder-presets.test.ts b/tests/folder-presets.test.ts new file mode 100644 index 00000000..98b43c08 --- /dev/null +++ b/tests/folder-presets.test.ts @@ -0,0 +1,193 @@ +import { expect, test } from "bun:test"; +import { parseMachineBinding } from "../src/folder/machine-binding"; +import { + allowedPresets, + derivePreset, + parsePresetReference, + presetNames, + presetProfile, + presetReference, + validatePresetComposition, + workspacePreset, +} from "../src/folder/presets"; +import { parseFolderPreferences } from "../src/folder/state"; +import { bindings } from "./fixtures/machine-bindings"; + +const machines = { + workstation: null, + "personal-vm": bindings.personal, + "workspace-vm with owner.team": bindings.team, + "workspace-vm without owner.team": bindings.organization, +} as const; + +test.each([ + ["workstation", "local", ["local"]], + ["personal-vm", "hosted-personal", ["hosted-personal"]], + [ + "workspace-vm with owner.team", + "hosted-organization-team", + ["hosted-organization-personal", "hosted-organization-team"], + ], + [ + "workspace-vm without owner.team", + "hosted-organization-personal", + ["hosted-organization-personal", "hosted-organization-team"], + ], +] as const)( + "%s derives %s and allows exactly %p", + (machine, derived, allowed) => { + const binding = machines[machine]; + expect(derivePreset(binding)).toBe(derived); + expect(allowedPresets(binding)).toEqual([...allowed]); + expect(presetReference(derived, binding)).toEqual({ + name: derived, + version: 1, + selection: "derived", + }); + for (const name of presetNames) { + if ((allowed as readonly string[]).includes(name)) { + const reference = presetReference(name, binding); + expect(reference.selection).toBe( + name === derived ? "derived" : "explicit", + ); + const profile = presetProfile(name, "linux"); + expect(validatePresetComposition(reference, binding, profile)).toBe( + workspacePreset(name), + ); + } else { + expect(() => presetReference(name, binding)).toThrow("not allowed"); + expect(() => + validatePresetComposition( + { name, version: 1, selection: "explicit" }, + binding, + presetProfile(name, "linux"), + ), + ).toThrow("not allowed"); + } + } + }, +); + +test("presets are complete compositions with defaults the Principal may override", () => { + for (const name of presetNames) { + const preset = workspacePreset(name); + expect(preset.version).toBe(1); + expect(Object.isFrozen(preset)).toBe(true); + expect(presetProfile(name, "macos")).toEqual({ + os: "macos", + ...preset.composition, + ...preset.defaults, + }); + expect(presetProfile(name, "linux", { locale: "cs" }).locale).toBe("cs"); + } + expect(workspacePreset("local").composition.access).toBe("local"); + expect(workspacePreset("hosted-personal").personalspace).toBe("present"); + expect(workspacePreset("hosted-organization-personal").personalspace).toBe( + "never", + ); + expect(workspacePreset("hosted-organization-team").providerIdentity).toBe( + "brokered-organization", + ); +}); + +test("a profile whose fixed axes disagree with the preset never validates", () => { + const reference = presetReference("local", null); + const profile = presetProfile("local", "linux"); + expect(() => + validatePresetComposition(reference, null, { + ...profile, + access: "remote", + }), + ).toThrow("composition"); + expect(() => + validatePresetComposition(reference, null, { + ...profile, + purpose: "buddy", + }), + ).toThrow("composition"); +}); + +test("unknown preset names, versions and selections fail before any use", () => { + for (const input of [ + { name: "hosted-private", version: 1, selection: "derived" }, + { name: "local", version: 2, selection: "derived" }, + { name: "local", version: 1, selection: "manual" }, + { name: "local", version: 1 }, + { name: "local", version: 1, selection: "derived", authority: "admin" }, + ]) + expect(() => parsePresetReference(input)).toThrow(); + expect(() => workspacePreset("hosted-team" as never)).toThrow("Unknown"); +}); + +test("preferences validate preset, Machine and profile as one composition", () => { + const base = { + schemaVersion: 2, + revision: 1, + preset: { + name: "hosted-organization-team", + version: 1, + selection: "derived", + }, + machine: bindings.team, + profile: presetProfile("hosted-organization-team", "linux"), + customInstructions: "", + }; + expect(parseFolderPreferences(base).machine).toEqual(bindings.team); + expect(parseFolderPreferences(JSON.parse(JSON.stringify(base)))).toEqual( + parseFolderPreferences(base), + ); + for (const change of [ + { machine: bindings.personal }, + { machine: null }, + { profile: { ...base.profile, access: "local" } }, + { preset: { ...base.preset, name: "local" } }, + { schemaVersion: 1 }, + ]) + expect(() => parseFolderPreferences({ ...base, ...change })).toThrow(); +}); + +test("Machine bindings are typed projections; branches never mix and relationships are optional", () => { + expect(parseMachineBinding(null)).toBeNull(); + for (const binding of Object.values(bindings)) { + expect(parseMachineBinding(binding)).toEqual(binding); + expect(parseMachineBinding(JSON.parse(JSON.stringify(binding)))).toEqual( + binding, + ); + } + const related = { + ...bindings.personal, + relationships: [ + { machine: "example-laptop", kind: "personal-client", access: "both" }, + { machine: "example-work", kind: "workspace-vm", access: "outbound" }, + ], + } as const; + expect(parseMachineBinding(related)).toEqual(related); + for (const change of [ + { kind: "workspace-vm" }, + { owner: bindings.team.owner }, + { host: bindings.team.host }, + { network: null }, + { contextDigest: "short" }, + { name: "Example" }, + { relationships: [{ machine: "x", kind: "phone", access: "both" }] }, + { relationships: [{ machine: "x", kind: "personal-vm" }] }, + { authority: "admin" }, + ]) + expect(() => + parseMachineBinding({ ...bindings.personal, ...change }), + ).toThrow(); + expect(() => + parseMachineBinding({ ...bindings.team, owner: bindings.personal.owner }), + ).toThrow(); + let invoked = false; + expect(() => + parseMachineBinding({ + ...bindings.team, + get name() { + invoked = true; + return "x"; + }, + }), + ).toThrow(); + expect(invoked).toBe(false); +}); diff --git a/tests/folder-preview.test.ts b/tests/folder-preview.test.ts index 1df08f40..05bcf84b 100644 --- a/tests/folder-preview.test.ts +++ b/tests/folder-preview.test.ts @@ -5,19 +5,25 @@ import { join } from "node:path"; import { inspectInstructions } from "../src/folder/inventory"; import { previewFolder } from "../src/folder/preview"; +const source = (profile: unknown) => ({ + preset: "local", + machine: null, + profile, +}); + test.skipIf(process.platform === "win32")( "real directory preview preserves existing and unknown files", async () => { const directory = await mkdtemp(join(tmpdir(), "folder-preview-")); try { const inspect = () => inspectInstructions(directory); - const fresh = await previewFolder(profile, null, inspect); + const fresh = await previewFolder(source(profile), null, inspect); expect(fresh.plan.kind).toBe("create"); expect(await readdir(directory)).toEqual([]); await writeFile(join(directory, "AGENTS.md"), fresh.desired.content); await writeFile(join(directory, "user-notes.txt"), "Keep my work"); const proposed = await previewFolder( - { ...profile, locale: "en" }, + source({ ...profile, locale: "en" }), fresh.desired.digest, inspect, ); @@ -34,7 +40,8 @@ test.skipIf(process.platform === "win32")( ]); await writeFile(join(directory, "AGENTS.md"), "My edits"); expect( - (await previewFolder(profile, fresh.desired.digest, inspect)).plan, + (await previewFolder(source(profile), fresh.desired.digest, inspect)) + .plan, ).toEqual({ kind: "blocked", reason: "drift" }); expect(await readFile(join(directory, "AGENTS.md"), "utf8")).toBe( "My edits", @@ -54,33 +61,35 @@ const profile = { coordination: "direct", }; test("shared preview produces equivalent proposals and refuses drift", async () => { - const first = await previewFolder(profile, null, async () => ({ + const first = await previewFolder(source(profile), null, async () => ({ kind: "absent", })); expect(first.plan).toEqual({ kind: "create", path: "AGENTS.md" }); expect( - await previewFolder(profile, null, async () => ({ kind: "absent" })), + await previewFolder(source(profile), null, async () => ({ + kind: "absent", + })), ).toEqual(first); const matching = async () => ({ kind: "regular" as const, digest: first.desired.digest, }); expect( - (await previewFolder(profile, first.desired.digest, matching)).plan, + (await previewFolder(source(profile), first.desired.digest, matching)).plan, ).toEqual({ kind: "unchanged" }); const changed = await previewFolder( - { ...profile, locale: "en" }, + source({ ...profile, locale: "en" }), first.desired.digest, matching, ); expect(changed.plan).toEqual({ kind: "replace", path: "AGENTS.md" }); - expect((await previewFolder(profile, null, matching)).plan).toEqual({ + expect((await previewFolder(source(profile), null, matching)).plan).toEqual({ kind: "blocked", reason: "unowned-file", }); expect( ( - await previewFolder(profile, first.desired.digest, async () => ({ + await previewFolder(source(profile), first.desired.digest, async () => ({ kind: "regular", digest: "b".repeat(64), })) @@ -93,11 +102,13 @@ test("invalid input stops before inventory; inventory failure is not success", a inspected = true; return { kind: "absent" as const }; }; - await expect(previewFolder({}, null, inspect)).rejects.toThrow(); - await expect(previewFolder(profile, "invalid", inspect)).rejects.toThrow(); + await expect(previewFolder(source({}), null, inspect)).rejects.toThrow(); + await expect( + previewFolder(source(profile), "invalid", inspect), + ).rejects.toThrow(); expect(inspected).toBe(false); await expect( - previewFolder(profile, null, async () => { + previewFolder(source(profile), null, async () => { throw new Error("Inventory unavailable"); }), ).rejects.toThrow("Inventory unavailable"); diff --git a/tests/folder-render.test.ts b/tests/folder-render.test.ts index a93c5a4f..e7749ba8 100644 --- a/tests/folder-render.test.ts +++ b/tests/folder-render.test.ts @@ -1,21 +1,30 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; +import { presetProfile } from "../src/folder/presets"; import { planInstructions } from "../src/folder/reconcile"; import { renderInstructions } from "../src/folder/render"; +import { bindings } from "./fixtures/machine-bindings"; + +// Every preset on every OS it is offered for, in both languages. +export const journeys = [ + { preset: "local", machine: null, os: "windows" }, + { preset: "local", machine: null, os: "macos" }, + { preset: "hosted-personal", machine: bindings.personal, os: "linux" }, + { + preset: "hosted-organization-personal", + machine: bindings.organization, + os: "linux", + }, + { preset: "hosted-organization-team", machine: bindings.team, os: "linux" }, +] as const; test("all launch journeys render both languages without undefined fragments", () => { - for (const journey of [ - { os: "windows", access: "local", purpose: "human" }, - { os: "macos", access: "local", purpose: "human" }, - { os: "linux", access: "remote", purpose: "human" }, - { os: "linux", access: "remote", purpose: "buddy" }, - ]) { - for (const locale of ["cs", "en"]) { + for (const journey of journeys) { + for (const locale of ["cs", "en"] as const) { const input = { - ...journey, - locale, - detail: "concise", - coordination: "direct", + preset: journey.preset, + machine: journey.machine, + profile: presetProfile(journey.preset, journey.os, { locale }), }; const output = renderInstructions(input); expect(output).not.toContain("undefined"); @@ -31,21 +40,26 @@ test("all launch journeys render both languages without undefined fragments", () }); test("localized profile output deterministically feeds reconciliation", () => { - const profile = { - os: "linux", - access: "remote", - purpose: "buddy", + const profile = presetProfile("hosted-organization-team", "linux", { locale: "cs", detail: "technical", coordination: "coordinator", + }); + const source = { + preset: "hosted-organization-team", + machine: bindings.team, + profile, }; - const cs = renderInstructions(profile); - const en = renderInstructions({ ...profile, locale: "en" }); + const cs = renderInstructions(source); + const en = renderInstructions({ + ...source, + profile: { ...profile, locale: "en" }, + }); expect(cs).toContain("Komunikuj česky"); expect(en).toContain("Communicate in English"); expect(cs).not.toEqual(en); const reordered = Object.fromEntries(Object.entries(profile).reverse()); - expect(renderInstructions(reordered)).toEqual(cs); + expect(renderInstructions({ ...source, profile: reordered })).toEqual(cs); for (const output of [cs, en]) { expect(output).toContain("Organizations"); expect(output).toContain("Personalspace"); @@ -58,5 +72,17 @@ test("localized profile output deterministically feeds reconciliation", () => { planInstructions(digest, digest, { kind: "regular", digest }), ).toEqual({ kind: "unchanged" }); } - expect(() => renderInstructions({ ...profile, locale: "invalid" })).toThrow(); + expect(() => + renderInstructions({ + ...source, + profile: { ...profile, locale: "invalid" }, + }), + ).toThrow(); + // A preset the Machine does not allow never renders. + expect(() => + renderInstructions({ ...source, preset: "hosted-personal" }), + ).toThrow("not allowed"); + expect(() => renderInstructions({ ...source, machine: null })).toThrow( + "not allowed", + ); }); diff --git a/tests/folder-resume-initialization.test.ts b/tests/folder-resume-initialization.test.ts index c8fd3339..5baefb07 100644 --- a/tests/folder-resume-initialization.test.ts +++ b/tests/folder-resume-initialization.test.ts @@ -31,9 +31,11 @@ for (const scenario of ["foreign-identical", "edit-during-resume"] as const) { }), ).rejects.toThrow(); if (scenario === "foreign-identical") { - const desired = await previewFolder(profile, null, async () => ({ - kind: "absent", - })); + const desired = await previewFolder( + { preset: "local", machine: null, profile }, + null, + async () => ({ kind: "absent" }), + ); await writeFile(join(folder, "AGENTS.md"), desired.desired.content); await expect(resumeInitialization(folder)).rejects.toThrow(); expect(await readFile(join(folder, "AGENTS.md"), "utf8")).toBe( @@ -112,7 +114,9 @@ for (const stop of [ await readFile(join(folder, "organizations", "keep"), "utf8"), ).toBe("synthetic work"); expect( - await updateProfile(folder, 1, { ...profile, locale: "cs" }), + await updateProfile(folder, 1, { + profile: { ...profile, locale: "cs" }, + }), ).toEqual({ kind: "updated", revision: 2 }); await expect(resumeInitialization(folder)).rejects.toThrow(); } finally { diff --git a/tests/folder-state.test.ts b/tests/folder-state.test.ts index 1c9dcde6..76fd910b 100644 --- a/tests/folder-state.test.ts +++ b/tests/folder-state.test.ts @@ -6,11 +6,13 @@ import { } from "../src/folder/state"; const preferences = { - schemaVersion: 1, + schemaVersion: 2, revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, + machine: null, profile: { os: "linux", - access: "remote", + access: "local", purpose: "human", locale: "cs", detail: "concise", @@ -21,7 +23,7 @@ const preferences = { const manifest = { schemaVersion: 1, preferenceRevision: 1, - templateRevision: "base-instructions-1", + templateRevision: "base-instructions-2", output: { path: "AGENTS.md", digest: "a".repeat(64) }, }; @@ -82,7 +84,7 @@ test("unknown schemas, fields, unsafe revisions and non-owned paths are rejected parseFolderPreferences({ ...preferences, revision }), ).toThrow(); expect(() => - parseFolderPreferences({ ...preferences, schemaVersion: 2 }), + parseFolderPreferences({ ...preferences, schemaVersion: 1 }), ).toThrow(); expect(() => parseFolderPreferences({ ...preferences, authority: "admin" }), diff --git a/tests/launchpad.test.ts b/tests/launchpad.test.ts index 3d2fd0bb..98d6cbda 100644 --- a/tests/launchpad.test.ts +++ b/tests/launchpad.test.ts @@ -58,7 +58,7 @@ test.skipIf(process.platform === "win32")( profile: { ...profile, locale: "cs" }, }; expect(await (await call("/api/preview", candidate)).json()).toEqual( - await inspectProfileChange(folder, 1, candidate.profile), + await inspectProfileChange(folder, 1, { profile: candidate.profile }), ); expect(await readFile(join(folder, "AGENTS.md"))).toEqual(before); expect( diff --git a/tests/machine-context.test.ts b/tests/machine-context.test.ts index 70087758..4ef78980 100644 --- a/tests/machine-context.test.ts +++ b/tests/machine-context.test.ts @@ -186,30 +186,16 @@ test("runtime operator binding is independent of schema validity", () => { "machine-operator-mismatch", ); }); -test("machine CLI refuses overrides and duplicate or missing choices before filesystem access", async () => { +test("machine CLI refuses overrides, unknown presets and duplicate or invalid choices before filesystem access", async () => { for (const args of [ ["inspect", "--file", "/tmp/identity"], - ["folder-init"], - [ - "folder-init", - "--locale", - "cs", - "--locale", - "en", - "--detail", - "technical", - ], - [ - "folder-init", - "--locale", - "cs", - "--detail", - "technical", - "--coordination", - "direct", - "--folder", - "/tmp/target", - ], + ["inspect", "--locale", "cs"], + ["folder-init", "--locale", "cs", "--locale", "en"], + ["folder-init", "--locale", "de"], + ["folder-init", "--preset", "hosted-private"], + ["folder-init", "--preset", "hosted-team"], + ["folder-init", "--folder", "/tmp/target"], + ["folder-init", "extra"], ]) await expect(runMachineCommand(args)).rejects.toThrow(); }); From 62a6e22a42937ced6ac69925e87704f64c87a3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 11:47:31 +0200 Subject: [PATCH 3/7] Machine handover: adopt the existing Folder instead of requiring an empty layout folder-init no longer demands an empty organizations/ and personalspace/. The Folder owns exactly AGENTS.md and .lazurio/ at the top level; the two work directories may hold content and are never traversed, listed beyond existence, moved or written; launchpad.gen3.json and launchpad.gen3.local.json are tolerated by name; any other top-level entry fails closed naming it. An Organization preset (Personalspace never present) accepts the empty personalspace/ that Machines precreates and refuses a used one without deleting anything. A re-run on an adopted Folder reports already-adopted and changes nothing; a different handover or unrecognized state is refused by name. Refusals surface from `lazurio machine folder-init` as blocked results with reason, entry and a next step. Co-Authored-By: Claude Fable 5.1 --- src/folder/handover-layout.ts | 185 +++++++++---- src/folder/initialize-folder.ts | 61 ++++- src/folder/resume-initialization.ts | 12 +- src/machine/cli.ts | 29 ++ tests/folder-handover.test.ts | 402 +++++++++++++++++++++------- 5 files changed, 538 insertions(+), 151 deletions(-) diff --git a/src/folder/handover-layout.ts b/src/folder/handover-layout.ts index 6cc108fe..f5ed11dd 100644 --- a/src/folder/handover-layout.ts +++ b/src/folder/handover-layout.ts @@ -1,42 +1,114 @@ -import { readdir } from "node:fs/promises"; +import { lstat, opendir, readdir } from "node:fs/promises"; import { join } from "node:path"; import { inspectOwnedDirectory } from "./owned-directory"; -import { stateFields } from "./state"; +import { stateFields } from "./state-fields"; -const names = ["root", "organizations", "personalspace"] as const; type Identity = Readonly<{ dev: string; ino: string }>; -export type HandoverLayout = Readonly>; +// `personalspace` is recorded when the directory exists at initialization; +// an Organization preset tolerates an empty one and refuses a used one. +export type HandoverLayout = Readonly<{ + root: Identity; + organizations: Identity; + personalspace?: Identity; +}>; +export type PersonalspacePolicy = "present" | "never"; + +// The Folder owns exactly these top-level entries; everything else is the +// operator's. The two legacy launchpad files are tolerated by name only. +const owned = ["AGENTS.md", ".lazurio"] as const; +const tolerated = [ + "organizations", + "personalspace", + "launchpad.gen3.json", + "launchpad.gen3.local.json", +] as const; + +// A refusal that names the top-level entry it concerns, so an operator can act +// without the initializer listing, reading or moving anything. +export class FolderAdoptionError extends Error { + constructor( + public readonly code: + | "foreign-entry" + | "layout-missing" + | "personalspace-conflict" + | "state-unrecognized" + | "binding-changed", + public readonly entry: string, + ) { + super(`${code}: ${entry}`); + } +} + +function identity(value: unknown): Identity { + const fields = stateFields(value, ["dev", "ino"]); + if ( + typeof fields.dev !== "string" || + typeof fields.ino !== "string" || + !/^\d+$/.test(fields.dev) || + !/^\d+$/.test(fields.ino) + ) + throw new Error("Invalid handover directory identity"); + return Object.freeze({ dev: fields.dev, ino: fields.ino }); +} export function parseHandoverLayout(value: unknown): HandoverLayout { - const fields = stateFields(value, names); - const result = {} as Record<(typeof names)[number], Identity>; - for (const name of names) { - const identity = stateFields(fields[name], ["dev", "ino"]); - if ( - typeof identity.dev !== "string" || - typeof identity.ino !== "string" || - !/^\d+$/.test(identity.dev) || - !/^\d+$/.test(identity.ino) - ) - throw new Error("Invalid handover directory identity"); - result[name] = Object.freeze({ dev: identity.dev, ino: identity.ino }); + const withPersonalspace = + typeof value === "object" && + value !== null && + Object.hasOwn(value, "personalspace"); + const fields = stateFields( + value, + withPersonalspace + ? ["root", "organizations", "personalspace"] + : ["root", "organizations"], + ); + const layout: HandoverLayout = { + root: identity(fields.root), + organizations: identity(fields.organizations), + }; + return Object.freeze( + withPersonalspace + ? { ...layout, personalspace: identity(fields.personalspace) } + : layout, + ); +} + +async function exists(path: string) { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; } - return Object.freeze(result); } +async function ownedIdentity(path: string): Promise { + const stat = await inspectOwnedDirectory(path); + return { dev: String(stat.dev), ino: String(stat.ino) }; +} + +// Existence and identity only; the work directories are never listed. export async function inspectHandoverLayout( folder: string, ): Promise { - const identities = {} as Record<(typeof names)[number], Identity>; - for (const name of names) { - const stat = await inspectOwnedDirectory( - name === "root" ? folder : join(folder, name), - ); - identities[name] = { dev: String(stat.dev), ino: String(stat.ino) }; - } - if (names.some((name) => identities[name].dev !== identities.root.dev)) + const root = await ownedIdentity(folder); + if (!(await exists(join(folder, "organizations")))) + throw new FolderAdoptionError("layout-missing", "organizations"); + const organizations = await ownedIdentity(join(folder, "organizations")); + const personalspace = (await exists(join(folder, "personalspace"))) + ? await ownedIdentity(join(folder, "personalspace")) + : undefined; + if ( + organizations.dev !== root.dev || + (personalspace !== undefined && personalspace.dev !== root.dev) + ) throw new Error("Cross-filesystem handover layout"); - return parseHandoverLayout(identities); + return parseHandoverLayout( + personalspace === undefined + ? { root, organizations } + : { root, organizations, personalspace }, + ); } export async function verifyHandoverLayout( @@ -44,33 +116,52 @@ export async function verifyHandoverLayout( receipt: HandoverLayout, ) { const observed = await inspectHandoverLayout(folder); - if ( - names.some( - (name) => - observed[name].dev !== receipt[name].dev || - observed[name].ino !== receipt[name].ino, - ) - ) + if (JSON.stringify(observed) !== JSON.stringify(receipt)) throw new Error("Handover layout changed; operator repair required"); } -// No traversal into Organization/Personalspace contents, no writes or cleanup. -export async function requireEmptyHandoverLayout( +// The directories the layout records, in initialization order. +export function handoverDirectories(layout: HandoverLayout) { + return layout.personalspace === undefined + ? (["organizations"] as const) + : (["organizations", "personalspace"] as const); +} + +// Empty or not, never which names are inside: one read of the first entry. +async function isEmptyDirectory(path: string) { + const directory = await opendir(path); + try { + return (await directory.read()) === null; + } finally { + await directory.close(); + } +} + +// Adoption rule. Work directories may be non-empty and are never traversed, +// listed beyond existence, moved or written. Any foreign top-level entry fails +// closed by name. A preset whose Personalspace policy is `never` tolerates an +// empty precreated personalspace/ and refuses a used one without touching it. +export async function requireAdoptableLayout( folder: string, + personalspace: PersonalspacePolicy, claimed = false, ) { - const entries = await readdir(folder); - const allowed = claimed - ? ["organizations", "personalspace", ".lazurio"] - : ["organizations", "personalspace"]; + const entries = (await readdir(folder)).sort(); + const allowed: readonly string[] = claimed + ? [...tolerated, ...owned] + : tolerated; + for (const entry of entries) + if (!allowed.includes(entry)) + throw new FolderAdoptionError("foreign-entry", entry); + if (!entries.includes("organizations")) + throw new FolderAdoptionError("layout-missing", "organizations"); + const hasPersonalspace = entries.includes("personalspace"); + if (personalspace === "present" && !hasPersonalspace) + throw new FolderAdoptionError("layout-missing", "personalspace"); if ( - entries.length !== allowed.length || - entries.some((entry) => !allowed.includes(entry)) + personalspace === "never" && + hasPersonalspace && + !(await isEmptyDirectory(join(folder, "personalspace"))) ) - throw new Error( - "Fresh handover requires exactly the empty standard layout", - ); - for (const name of ["organizations", "personalspace"]) - if ((await readdir(join(folder, name))).length !== 0) - throw new Error("Handover contains work; adoption is not supported"); + throw new FolderAdoptionError("personalspace-conflict", "personalspace"); } diff --git a/src/folder/initialize-folder.ts b/src/folder/initialize-folder.ts index ef3fa735..53153aff 100644 --- a/src/folder/initialize-folder.ts +++ b/src/folder/initialize-folder.ts @@ -1,9 +1,11 @@ import { constants } from "node:fs"; -import { mkdir, open, readdir, rename } from "node:fs/promises"; +import { lstat, mkdir, open, readdir, rename } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { + FolderAdoptionError, + handoverDirectories, inspectHandoverLayout, - requireEmptyHandoverLayout, + requireAdoptableLayout, verifyHandoverLayout, } from "./handover-layout"; import { @@ -14,9 +16,9 @@ import { withFolderOperationLock } from "./lock"; import type { MachineBinding } from "./machine-binding"; import { inspectOwnedDirectory } from "./owned-directory"; import { executionOs } from "./platform"; -import { type PresetName, presetReference } from "./presets"; +import { type PresetName, presetReference, workspacePreset } from "./presets"; import { previewFolder } from "./preview"; -import { readOwnedStateFile } from "./read-state"; +import { readFolderState, readOwnedStateFile } from "./read-state"; import { instructionSource } from "./render"; import { parseFolderPreferences, parseInstructionManifest } from "./state"; @@ -51,7 +53,10 @@ export async function initializeFolder( ); } -// Only the empty, operator-owned layout prepared by Machines; not resident adoption. +// Adopts the operator-owned Folder delivered by Machines: organizations/ and +// personalspace/ may already hold work and are never entered; the two legacy +// launchpad files are tolerated by name; anything else fails closed by name. +// An already adopted Folder is reported as such and left unchanged. export async function initializeHandoverFolder( folder: string, source: HandoverSource, @@ -103,16 +108,24 @@ async function initialize( templateRevision: preview.templateRevision, output: { path: "AGENTS.md", digest: preview.desired.digest }, }); + const state = join(folder, ".lazurio"); + const personalspace = workspacePreset(preferences.preset.name).personalspace; + if (handover) { + const adopted = await alreadyAdopted(folder, preferences.machine); + if (adopted) return adopted; + } const layout = handover ? await inspectHandoverLayout(folder) : null; - if (layout) await requireEmptyHandoverLayout(folder); + if (layout) await requireAdoptableLayout(folder, personalspace); else await mkdir(folder, { mode: 0o700 }); // Exclusive fresh-path initialization. await checkpoint("folder"); - const state = join(folder, ".lazurio"); await mkdir(state, { mode: 0o700 }); + const directories = layout + ? handoverDirectories(layout) + : (["organizations", "personalspace"] as const); return withFolderOperationLock(state, async (assertHeld) => { if (layout) { await verifyHandoverLayout(folder, layout); - await requireEmptyHandoverLayout(folder, true); + await requireAdoptableLayout(folder, personalspace, true); } const transaction = join(state, "transaction"); await mkdir(transaction, { mode: 0o700 }); @@ -162,7 +175,7 @@ async function initialize( for (const name of ["organizations", "personalspace"]) await mkdir(join(folder, name), { mode: 0o700 }); await checkpoint("layout"); - for (const name of ["organizations", "personalspace"]) + for (const name of directories) await inspectOwnedDirectory(join(folder, name)); if (layout) await verifyHandoverLayout(folder, layout); const transactionEntries = await readdir(transaction); @@ -228,3 +241,33 @@ async function initialize( }; }); } + +// Idempotent re-run: valid state recorded from the same handover reports the +// adopted Folder; a different handover or unrecognized state is refused by name. +// Only the ephemeral operation lock is written. +async function alreadyAdopted(folder: string, machine: MachineBinding | null) { + const state = join(folder, ".lazurio"); + try { + await lstat(state); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + let current: Awaited>; + try { + await inspectOwnedDirectory(state); + current = await withFolderOperationLock(state, () => + readFolderState(state), + ); + } catch (error) { + if (error instanceof Error && /busy/.test(error.message)) throw error; + throw new FolderAdoptionError("state-unrecognized", ".lazurio"); + } + if (JSON.stringify(current.preferences.machine) !== JSON.stringify(machine)) + throw new FolderAdoptionError("binding-changed", ".lazurio"); + return { + kind: "already-adopted" as const, + revision: current.preferences.revision, + preset: current.preferences.preset, + }; +} diff --git a/src/folder/resume-initialization.ts b/src/folder/resume-initialization.ts index c8f5a47f..df4d2e74 100644 --- a/src/folder/resume-initialization.ts +++ b/src/folder/resume-initialization.ts @@ -1,7 +1,11 @@ import { constants } from "node:fs"; import { lstat, mkdir, open, readdir, rename } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { parseHandoverLayout, verifyHandoverLayout } from "./handover-layout"; +import { + handoverDirectories, + parseHandoverLayout, + verifyHandoverLayout, +} from "./handover-layout"; import { initializationReceipts, recordInitializationCreation, @@ -121,7 +125,11 @@ export async function resumeInitialization( [state, "preferences.json", JSON.stringify(preferences)], [state, "instructions.json", JSON.stringify(manifest)], ] as const; - const layout = ["organizations", "personalspace"] as const; + // A handed-over layout records which work directories existed; an absent + // personalspace/ under an Organization preset is never created here. + const layout = handedLayout + ? handoverDirectories(handedLayout) + : (["organizations", "personalspace"] as const); const verifyFile = async ( directory: string, name: keyof typeof initializationReceipts, diff --git a/src/machine/cli.ts b/src/machine/cli.ts index fa0685d2..28f8b365 100644 --- a/src/machine/cli.ts +++ b/src/machine/cli.ts @@ -1,4 +1,5 @@ import { parseArgs } from "node:util"; +import { FolderAdoptionError } from "../folder/handover-layout"; import { initializeHandoverFolder } from "../folder/initialize-folder"; import { allowedPresets, @@ -26,6 +27,10 @@ workspace-vm with owner.team -> hosted-organization-team; without -> hosted-organization-personal). --preset may pick another preset the handover allows and is recorded as an explicit choice. Omitted communication choices take the preset's defaults; all are changeable later in the Launchpad. +Adopts the existing Folder: organizations/ and personalspace/ may hold work +and are never entered; launchpad.gen3.json and launchpad.gen3.local.json are +tolerated; any other top-level entry is refused by name. Re-running on an +adopted Folder reports already-adopted and changes nothing. Run as the declared operator, never root. No path or custody override. No Organization checkout, gateway change, resident removal or migration. An interrupted recognized journal can be completed using folder-resume; @@ -115,6 +120,8 @@ export async function runMachineCommand(args: string[]) { result: { ...result, machineContextDigest: observed.digest }, }; } catch (error) { + if (error instanceof FolderAdoptionError) + return { code: 2, result: describeFolderAdoption(error) }; if (error instanceof MachineContextError) return { code: 2, @@ -127,3 +134,25 @@ export async function runMachineCommand(args: string[]) { throw error; } } + +// The refusal names the entry; the operator decides what to do with it. +export function describeFolderAdoption(error: FolderAdoptionError) { + const next = { + "foreign-entry": + "Move this entry out of the Folder; only organizations/, personalspace/ and the two legacy launchpad files may be present.", + "layout-missing": + "Ask the Machines operator to deliver the standard Folder layout; nothing is created in its place.", + "personalspace-conflict": + "An Organization preset never has a Personalspace; move it away yourself, nothing is deleted.", + "state-unrecognized": + "Complete a recognized initialization with folder-resume or diagnose the state; nothing is reset.", + "binding-changed": + "The Folder was adopted from a different handover; verify the Machine identity with the Machines operator.", + } as const; + return { + kind: "blocked" as const, + reason: `folder-${error.code}` as const, + entry: error.entry, + next: next[error.code], + }; +} diff --git a/tests/folder-handover.test.ts b/tests/folder-handover.test.ts index 4a31ba0e..ea3cd885 100644 --- a/tests/folder-handover.test.ts +++ b/tests/folder-handover.test.ts @@ -14,44 +14,287 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { FolderAdoptionError } from "../src/folder/handover-layout"; import { initializeHandoverFolder } from "../src/folder/initialize-folder"; import { executionOs } from "../src/folder/platform"; +import { presetProfile } from "../src/folder/presets"; import { resumeInitialization } from "../src/folder/resume-initialization"; import { updateProfile } from "../src/folder/update-profile"; +import { describeFolderAdoption } from "../src/machine/cli"; import { bindings } from "./fixtures/machine-bindings"; -const profile = { - os: executionOs(process.platform), - access: "remote", - purpose: "human", - locale: "cs", - detail: "technical", - coordination: "direct", -}; -const source = { - preset: "hosted-organization-personal", - machine: bindings.organization, - profile, +const os = executionOs(process.platform); +const choices = { locale: "cs", detail: "technical" } as const; +const sources = { + personal: { + preset: "hosted-personal", + machine: bindings.personal, + profile: presetProfile("hosted-personal", os, choices), + }, + organization: { + preset: "hosted-organization-personal", + machine: bindings.organization, + profile: presetProfile("hosted-organization-personal", os, choices), + }, + team: { + preset: "hosted-organization-team", + machine: bindings.team, + profile: presetProfile("hosted-organization-team", os, choices), + }, } as const; -const initialized = { - kind: "initialized", +const initialized = (name: keyof typeof sources) => ({ + kind: "initialized" as const, revision: 1, preset: { - name: "hosted-organization-personal", - version: 1, - selection: "derived", + name: sources[name].preset, + version: 1 as const, + selection: "derived" as const, }, -} as const; -async function setup() { +}); +const legacy = ["launchpad.gen3.json", "launchpad.gen3.local.json"]; + +// A Folder as Machines delivers it and as real Machines already have it: +// organizations/ with work, an optional personalspace/, legacy launchpad files. +async function setup( + options: { + personalspace?: "absent" | "empty" | "used"; + work?: boolean; + legacy?: boolean; + } = {}, +) { const parent = await realpath( - await mkdtemp(join(tmpdir(), "handover-init-")), + await mkdtemp(join(tmpdir(), "handover-adopt-")), ); const folder = join(parent, "Lazurio"); await mkdir(folder, { mode: 0o700 }); await mkdir(join(folder, "organizations"), { mode: 0o755 }); - await mkdir(join(folder, "personalspace"), { mode: 0o700 }); + if (options.work !== false) { + await mkdir(join(folder, "organizations", "example"), { mode: 0o700 }); + await writeFile( + join(folder, "organizations", "example", "keep"), + "synthetic work", + ); + } + const personalspace = options.personalspace ?? "used"; + if (personalspace !== "absent") + await mkdir(join(folder, "personalspace"), { mode: 0o700 }); + if (personalspace === "used") + await writeFile( + join(folder, "personalspace", "keep"), + "synthetic personal work", + ); + if (options.legacy !== false) + for (const name of legacy) + await writeFile(join(folder, name), "legacy resident configuration"); return { parent, folder }; } +async function snapshot(folder: string) { + const entries = (await readdir(folder)).sort(); + const files: Record = {}; + for (const name of entries) { + const stat = await lstat(join(folder, name)); + files[name] = stat.isDirectory() + ? [Number(stat.dev), Number(stat.ino), stat.mode] + : await readFile(join(folder, name), "utf8"); + } + return files; +} +async function refusal(run: Promise) { + try { + await run; + } catch (error) { + if (error instanceof FolderAdoptionError) + return { code: error.code, entry: error.entry }; + throw error; + } + throw new Error("Expected an adoption refusal"); +} + +test.skipIf(process.platform === "win32")( + "adopts a used personal Folder with legacy launchpad files and reports a re-run as already adopted", + async () => { + const { parent, folder } = await setup(); + try { + const before = await snapshot(folder); + expect(await initializeHandoverFolder(folder, sources.personal)).toEqual( + initialized("personal"), + ); + const after = await snapshot(folder); + expect(Object.keys(after).sort()).toEqual( + [...Object.keys(before), ".lazurio", "AGENTS.md"].sort(), + ); + for (const name of Object.keys(before)) + expect(after[name]).toEqual(before[name]); + expect( + await readFile(join(folder, "personalspace", "keep"), "utf8"), + ).toBe("synthetic personal work"); + const agents = await readFile(join(folder, "AGENTS.md"), "utf8"); + expect(agents).toContain("hosted-personal"); + const state = await snapshot(join(folder, ".lazurio")); + expect(await initializeHandoverFolder(folder, sources.personal)).toEqual({ + kind: "already-adopted", + revision: 1, + preset: initialized("personal").preset, + }); + expect(await snapshot(join(folder, ".lazurio"))).toEqual(state); + expect(await readFile(join(folder, "AGENTS.md"), "utf8")).toBe(agents); + expect(await snapshot(folder)).toEqual(after); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, +); + +for (const name of ["organization", "team"] as const) + for (const personalspace of ["absent", "empty", "used"] as const) + test.skipIf(process.platform === "win32")( + `${sources[name].preset} with ${personalspace} personalspace ${personalspace === "used" ? "refuses without touching it" : "adopts and never creates one"}`, + async () => { + const { parent, folder } = await setup({ personalspace }); + try { + const before = await snapshot(folder); + if (personalspace === "used") { + expect( + await refusal(initializeHandoverFolder(folder, sources[name])), + ).toEqual({ + code: "personalspace-conflict", + entry: "personalspace", + }); + expect(await snapshot(folder)).toEqual(before); + expect( + await readFile(join(folder, "personalspace", "keep"), "utf8"), + ).toBe("synthetic personal work"); + return; + } + expect(await initializeHandoverFolder(folder, sources[name])).toEqual( + initialized(name), + ); + expect(await resumeInitialization(folder)).toEqual({ + kind: "recovered", + revision: 1, + }); + const after = await snapshot(folder); + expect(after.personalspace).toEqual(before.personalspace); + expect(Object.hasOwn(after, "personalspace")).toBe( + personalspace === "empty", + ); + expect(await initializeHandoverFolder(folder, sources[name])).toEqual( + { + kind: "already-adopted", + revision: 1, + preset: initialized(name).preset, + }, + ); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, + ); + +test.skipIf(process.platform === "win32")( + "a missing work directory is refused by name and never created", + async () => { + const { parent, folder } = await setup({ personalspace: "absent" }); + try { + expect( + await refusal(initializeHandoverFolder(folder, sources.personal)), + ).toEqual({ code: "layout-missing", entry: "personalspace" }); + await rename(join(folder, "organizations"), join(parent, "saved")); + for (const name of ["personal", "organization"] as const) + expect( + await refusal(initializeHandoverFolder(folder, sources[name])), + ).toEqual({ code: "layout-missing", entry: "organizations" }); + expect((await readdir(folder)).sort()).toEqual(legacy); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, +); + +for (const entry of ["AGENTS.md", "CLAUDE.md", "lazurio", "notes.txt"]) + test.skipIf(process.platform === "win32")( + `a foreign top-level entry ${entry} fails closed by name before any state exists`, + async () => { + const { parent, folder } = await setup(); + try { + if (entry === "lazurio") await mkdir(join(folder, entry)); + else await writeFile(join(folder, entry), "resident content"); + const before = await snapshot(folder); + expect( + await refusal(initializeHandoverFolder(folder, sources.personal)), + ).toEqual({ code: "foreign-entry", entry }); + expect(await snapshot(folder)).toEqual(before); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, + ); + +test.skipIf(process.platform === "win32")( + "a Folder adopted from another handover or with unrecognized state is refused, not rewritten", + async () => { + const { parent, folder } = await setup({ personalspace: "empty" }); + try { + await mkdir(join(folder, ".lazurio"), { mode: 0o700 }); + expect( + await refusal(initializeHandoverFolder(folder, sources.organization)), + ).toEqual({ code: "state-unrecognized", entry: ".lazurio" }); + await rm(join(folder, ".lazurio"), { recursive: true }); + expect( + await initializeHandoverFolder(folder, sources.organization), + ).toEqual(initialized("organization")); + const state = await snapshot(join(folder, ".lazurio")); + expect( + await refusal(initializeHandoverFolder(folder, sources.team)), + ).toEqual({ code: "binding-changed", entry: ".lazurio" }); + expect(await snapshot(join(folder, ".lazurio"))).toEqual(state); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, +); + +test.skipIf(process.platform === "win32")( + "an adopted Organization Folder changes its preset only within the allow-list", + async () => { + const { parent, folder } = await setup({ personalspace: "empty" }); + try { + await initializeHandoverFolder(folder, sources.organization); + const profile = sources.organization.profile; + expect( + await updateProfile(folder, 1, { preset: "hosted-personal", profile }), + ).toEqual({ kind: "blocked", reason: "preset-not-allowed" }); + expect( + await updateProfile(folder, 1, { + preset: "hosted-organization-team", + profile: { ...profile, locale: "en" }, + }), + ).toEqual({ kind: "updated", revision: 2 }); + const preferences = JSON.parse( + await readFile(join(folder, ".lazurio", "preferences.json"), "utf8"), + ); + expect(preferences.preset).toEqual({ + name: "hosted-organization-team", + version: 1, + selection: "explicit", + }); + expect(preferences.machine).toEqual(bindings.organization); + expect(await readFile(join(folder, "AGENTS.md"), "utf8")).toContain( + "hosted-organization-team", + ); + expect( + await initializeHandoverFolder(folder, sources.organization), + ).toEqual({ + kind: "already-adopted", + revision: 2, + preset: preferences.preset, + }); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, +); + for (const stop of [ null, "journal", @@ -61,68 +304,45 @@ for (const stop of [ "layout", ] as const) { test.skipIf(process.platform === "win32")( - `empty handover initializes/resumes at ${stop ?? "completion"} without moving work directories`, + `adoption initializes/resumes at ${stop ?? "completion"} without moving work directories`, async () => { - const { parent, folder } = await setup(); + const { parent, folder } = await setup({ personalspace: "empty" }); try { - const before = await Promise.all( - [ - folder, - join(folder, "organizations"), - join(folder, "personalspace"), - ].map(async (path) => { - const s = await lstat(path); - return [s.dev, s.ino, s.mode]; - }), - ); + const before = await snapshot(folder); const result = initializeHandoverFolder( folder, - source, + sources.organization, async (step) => { if (step === stop) throw new Error("interrupted"); }, ); if (stop) await expect(result).rejects.toThrow("interrupted"); - else expect(await result).toEqual(initialized); + else expect(await result).toEqual(initialized("organization")); expect(await resumeInitialization(folder)).toEqual({ kind: "recovered", revision: 1, }); - const after = await Promise.all( - [ - folder, - join(folder, "organizations"), - join(folder, "personalspace"), - ].map(async (path) => { - const s = await lstat(path); - return [s.dev, s.ino, s.mode]; - }), - ); - expect(after).toEqual(before); - await writeFile( - join(folder, "organizations", "keep"), - "synthetic work", - ); - await writeFile( - join(folder, "personalspace", "keep"), - "synthetic personal work", - ); + const after = await snapshot(folder); + for (const name of Object.keys(before)) + expect(after[name]).toEqual(before[name]); + await writeFile(join(folder, "organizations", "later"), "more work"); expect(await resumeInitialization(folder)).toEqual({ kind: "recovered", revision: 1, }); - await expect( - initializeHandoverFolder(folder, source), - ).rejects.toThrow(); expect( - await readFile(join(folder, "organizations", "keep"), "utf8"), - ).toBe("synthetic work"); + await initializeHandoverFolder(folder, sources.organization), + ).toEqual({ + kind: "already-adopted", + revision: 1, + preset: initialized("organization").preset, + }); expect( - await readFile(join(folder, "personalspace", "keep"), "utf8"), - ).toBe("synthetic personal work"); + await readFile(join(folder, "organizations", "later"), "utf8"), + ).toBe("more work"); expect( await updateProfile(folder, 1, { - profile: { ...profile, locale: "en" }, + profile: { ...sources.organization.profile, locale: "en" }, }), ).toEqual({ kind: "updated", revision: 2 }); } finally { @@ -131,56 +351,36 @@ for (const stop of [ }, ); } -for (const scenario of [ - "manifest", - "organization-work", - "personal-work", - "state", - "missing-directory", - "symlink", - "writable", -] as const) { + +for (const scenario of ["symlink", "writable"] as const) test.skipIf(process.platform === "win32")( - `handover refuses ${scenario} before creating state`, + `handover refuses a ${scenario} work directory before creating state`, async () => { const { parent, folder } = await setup(); try { - if (scenario === "manifest") - await writeFile( - join(folder, "launchpad.gen3.json"), - "existing resident", - ); - if (scenario === "organization-work") - await writeFile(join(folder, "organizations", "keep"), "work"); - if (scenario === "personal-work") - await writeFile(join(folder, "personalspace", "keep"), "work"); - if (scenario === "state") await mkdir(join(folder, ".lazurio")); - if (scenario === "missing-directory" || scenario === "symlink") + if (scenario === "symlink") { await rename(join(folder, "organizations"), join(parent, "saved")); - if (scenario === "symlink") await symlink(join(parent, "saved"), join(folder, "organizations")); - if (scenario === "writable") - await chmod(join(folder, "organizations"), 0o777); + } else await chmod(join(folder, "organizations"), 0o777); const before = await readdir(folder); await expect( - initializeHandoverFolder(folder, source), + initializeHandoverFolder(folder, sources.personal), ).rejects.toThrow(); expect(await readdir(folder)).toEqual(before); - if (scenario !== "state") - await expect(lstat(join(folder, ".lazurio"))).rejects.toThrow(); + await expect(lstat(join(folder, ".lazurio"))).rejects.toThrow(); } finally { await rm(parent, { recursive: true, force: true }); } }, ); -} + test.skipIf(process.platform === "win32")( "handover resume refuses replaced directories before completing generated files", async () => { const { parent, folder } = await setup(); try { await expect( - initializeHandoverFolder(folder, source, async (step) => { + initializeHandoverFolder(folder, sources.personal, async (step) => { if (step === "journal") throw new Error("stop"); }), ).rejects.toThrow("stop"); @@ -195,14 +395,15 @@ test.skipIf(process.platform === "win32")( } }, ); + test.skipIf(process.platform === "win32")( "concurrent handover initializers have exactly one winner", async () => { const { parent, folder } = await setup(); try { const results = await Promise.allSettled([ - initializeHandoverFolder(folder, source), - initializeHandoverFolder(folder, source), + initializeHandoverFolder(folder, sources.personal), + initializeHandoverFolder(folder, sources.personal), ]); expect( results.filter((result) => result.status === "fulfilled"), @@ -216,3 +417,18 @@ test.skipIf(process.platform === "win32")( } }, ); + +test("the machine CLI reports an adoption refusal by reason and entry", () => { + expect( + describeFolderAdoption(new FolderAdoptionError("foreign-entry", "notes")), + ).toMatchObject({ + kind: "blocked", + reason: "folder-foreign-entry", + entry: "notes", + }); + expect( + describeFolderAdoption( + new FolderAdoptionError("personalspace-conflict", "personalspace"), + ).next, + ).toContain("nothing is deleted"); +}); From e9ef5bd657a469c4ae4034260736c5723e691725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 11:51:10 +0200 Subject: [PATCH 4/7] Folder: render AGENTS.md from the preset and the recorded Machine binding The generated document is short context for every agent starting inside the Folder: which Machine this is and whose (owner, team, tailnet node, host), who the Principal is here (owner of a personal VM, the assigned operator of a work VM, the connected Team member on a shared team VM), the Personalspace boundary (present and intimate / never present), where Organization repositories live, the provider identity mode, and how work is done (Draft in a worktree and PR, the Organization's AGENTS.md and the Lazurio root rules). A relationships section renders only when the recorded binding carries one; upstream has no such field yet. Template revision base-instructions-2; snapshots per preset and locale. Co-Authored-By: Claude Fable 5.1 --- src/folder/render.ts | 240 ++++++++++++++---- .../__snapshots__/folder-render.test.ts.snap | 239 +++++++++++++++++ tests/folder-render.test.ts | 38 +++ 3 files changed, 472 insertions(+), 45 deletions(-) create mode 100644 tests/__snapshots__/folder-render.test.ts.snap diff --git a/src/folder/render.ts b/src/folder/render.ts index 4d8ca28f..e8dbc26a 100644 --- a/src/folder/render.ts +++ b/src/folder/render.ts @@ -1,9 +1,15 @@ -import { type MachineBinding, parseMachineBinding } from "./machine-binding"; +import { + type MachineBinding, + type MachineRelationship, + parseMachineBinding, +} from "./machine-binding"; import { type PresetName, parsePresetName, presetReference, + presetVersion, validatePresetComposition, + workspacePreset, } from "./presets"; import { type FolderProfile, parseFolderProfile } from "./profile"; import type { FolderPreferences } from "./state"; @@ -39,56 +45,200 @@ export function instructionSource( }); } +type Text = Readonly<{ cs: string; en: string }>; +const relationshipKinds: Readonly> = { + "personal-client": { cs: "osobní klient", en: "personal client" }, + "personal-vm": { cs: "osobní VM", en: "personal VM" }, + "workspace-vm": { cs: "pracovní VM", en: "work VM" }, + "work-laptop": { cs: "pracovní laptop", en: "work laptop" }, +}; +const relationshipAccess: Readonly< + Record +> = { + inbound: { cs: "smí sem", en: "may reach this Machine" }, + outbound: { cs: "odsud tam", en: "reachable from here" }, + both: { cs: "oběma směry", en: "both directions" }, +}; + +// One short factual document: context for every agent starting inside the +// Folder, not a manual. Everything about the Machine comes from the recorded +// handover; everything about behavior from the preset and the profile. +function machineSection( + preset: PresetName, + machine: MachineBinding | null, + pick: (text: Text) => string, +): string[] { + const lines = [ + pick({ cs: "## Tahle Mašina", en: "## This Machine" }), + pick({ + cs: `- Preset: \`${preset}\` (verze ${presetVersion}).`, + en: `- Preset: \`${preset}\` (version ${presetVersion}).`, + }), + ]; + if (machine === null) + return [ + ...lines, + pick({ + cs: "- Pracovní stanice Principála bez handoveru; Owner i Principál je přihlášený uživatel.", + en: "- The Principal's own workstation, no handover; the signed-in user is both Owner and Principal.", + }), + ]; + const owner = + machine.owner.kind === "principal" + ? pick({ + cs: `- Owner: Principál s GitHub loginem \`${machine.owner.githubLogin}\` (id ${machine.owner.githubId}). Je to jeho jediná osobní hostovaná Mašina.`, + en: `- Owner: the Principal with GitHub login \`${machine.owner.githubLogin}\` (id ${machine.owner.githubId}). This is their one personal hosted Machine.`, + }) + : machine.owner.team === null + ? pick({ + cs: `- Owner: Organizace \`${machine.owner.organization}\`.`, + en: `- Owner: Organization \`${machine.owner.organization}\`.`, + }) + : pick({ + cs: `- Owner: Organizace \`${machine.owner.organization}\`, Team \`${machine.owner.team}\`.`, + en: `- Owner: Organization \`${machine.owner.organization}\`, Team \`${machine.owner.team}\`.`, + }); + const principal = { + "hosted-personal": { + cs: "- Principál: Owner Mašiny. Agenti tu jednají za něj v jeho právech; Buddy je volitelný rezident téže Mašiny.", + en: "- Principal: the Machine's Owner. Agents here act for them within their rights; a Buddy is an optional resident of this same Machine.", + }, + "hosted-organization-personal": { + cs: "- Principál: jediný Operátor, kterému Organizace tuhle pracovní VM přiřadila. Agenti jednají za něj v jeho živých právech.", + en: "- Principal: the one operator the Organization assigned this work VM to. Agents act for them within their live rights.", + }, + "hosted-organization-team": { + cs: "- Principál: Kolega, který se právě připojil. OS účet je sdílený členy Teamu a není osoba; změny se připisují Teamu přes brokerovanou identitu Organizace.", + en: "- Principal: whichever Team member is connected now. The OS account is shared by the Team and is not a person; changes are attributed to the Team through the brokered Organization identity.", + }, + local: { cs: "", en: "" }, + }[preset]; + lines.push( + pick({ + cs: `- Mašina: \`${machine.name}\` (${machine.kind}).`, + en: `- Machine: \`${machine.name}\` (${machine.kind}).`, + }), + owner, + pick(principal), + machine.network === null + ? pick({ + cs: "- Tailnet: handover neuvádí identitu v tailnetu.", + en: "- Tailnet: the handover records no tailnet identity.", + }) + : pick({ + cs: `- Tailnet: Headscale node \`${machine.network.headscaleHostname}\`.`, + en: `- Tailnet: Headscale node \`${machine.network.headscaleHostname}\`.`, + }), + pick({ + cs: `- Host: ${machine.host.kind} \`${machine.host.id}\`; vyšší doména správy a obnovy než tahle Mašina.`, + en: `- Host: ${machine.host.kind} \`${machine.host.id}\`; a higher administration and recovery domain than this Machine.`, + }), + ); + if (machine.relationships !== undefined) + lines.push( + pick({ cs: "### Vztahy k dalším Mašinám", en: "### Related Machines" }), + ...machine.relationships.map((relation) => + pick({ + cs: `- \`${relation.machine}\` (${relationshipKinds[relation.kind].cs}): ${relationshipAccess[relation.access].cs}.`, + en: `- \`${relation.machine}\` (${relationshipKinds[relation.kind].en}): ${relationshipAccess[relation.access].en}.`, + }), + ), + ); + return lines; +} + +function boundarySection( + preset: PresetName, + pick: (text: Text) => string, +): string[] { + const { personalspace, providerIdentity } = workspacePreset(preset); + return [ + pick({ cs: "## Hranice", en: "## Boundaries" }), + personalspace === "present" + ? pick({ + cs: "- Personalspace: `personalspace/` je intimní prostor právě jednoho Principála a jeho volitelného Buddyho. Nikdo cizí ho nečte a nikdy se nesdílí.", + en: "- Personalspace: `personalspace/` is the intimate space of exactly one Principal and their optional Buddy. Nobody else reads it and it is never shared.", + }) + : pick({ + cs: "- Personalspace: na Mašině vlastněné Organizací nikdy není. Nezakládej ho, nemountuj ho a nekopíruj sem osobní data ani přihlášení.", + en: "- Personalspace: never present on an Organization-owned Machine. Do not create or mount one and never copy personal data or sign-ins here.", + }), + preset === "hosted-personal" + ? pick({ + cs: "- Organizace: na osobní Mašině nejsou namountovaná žádná Organization repa. Práce v Organizaci probíhá na Mašinách, které Organizace vlastní.", + en: "- Organizations: no Organization repositories are mounted on a personal Machine. Organization work happens on Machines the Organization owns.", + }) + : pick({ + cs: "- Organizace: repozitáře žijí v `organizations//`; každá Organizace je vlastní access hranice a vlastní git repozitář.", + en: "- Organizations: repositories live under `organizations//`; each Organization is its own access boundary and its own git repository.", + }), + providerIdentity === "own-sign-in" + ? pick({ + cs: "- Identita: Principálova vlastní přihlášení; GitHub je jediná autorita přístupů.", + en: "- Identity: the Principal's own sign-ins; GitHub is the only access authority.", + }) + : pick({ + cs: "- Identita: brokerovaná identita Organizace s krátkodobými tokeny; žádná osobní přihlášení, session ani credentials sem nikdy nepatří.", + en: "- Identity: the brokered Organization identity with short-lived tokens; no personal sign-ins, sessions or credentials ever belong here.", + }), + ]; +} + export function renderInstructions(input: unknown): string { - const { preset, profile } = parseInstructionSource(input); - const cs = profile.locale === "cs"; - const purpose = { - human: cs - ? "Pomáhej člověku s jeho prací." - : "Assist a human with their work.", - buddy: cs - ? "Buddy jedná za svého člověka; není samostatným Principálem." - : "Buddy acts for its human; it is not a separate Principal.", - ai_colleague: cs - ? "AI kolega vyžaduje vlastní identitu a lidského správce; tento profil je nevytváří." - : "An AI colleague requires its own identity and human custodian; this profile creates neither.", - }; + const { preset, machine, profile } = parseInstructionSource(input); + const pick = (text: Text) => (profile.locale === "cs" ? text.cs : text.en); return [ "# Lazurio", ``, - cs - ? "Komunikuj česky, pokud uživatel nepožádá jinak." - : "Communicate in English unless the user requests otherwise.", - purpose[profile.purpose], + ...machineSection(preset, machine, pick), + ...boundarySection(preset, pick), + pick({ cs: "## Jak se tu pracuje", en: "## How work is done here" }), + pick({ + cs: "- Komunikuj česky, pokud uživatel nepožádá jinak.", + en: "- Communicate in English unless the user requests otherwise.", + }), profile.detail === "concise" - ? cs - ? "Začni výsledkem a vysvětluj stručně." - : "Lead with the outcome and explain concisely." - : cs - ? "Začni výsledkem a připoj relevantní technické vysvětlení a důkazy." - : "Lead with the outcome and include relevant technical explanation and evidence.", + ? pick({ + cs: "- Začni výsledkem a vysvětluj stručně.", + en: "- Lead with the outcome and explain concisely.", + }) + : pick({ + cs: "- Začni výsledkem a připoj relevantní technické vysvětlení a důkazy.", + en: "- Lead with the outcome and include relevant technical explanation and evidence.", + }), profile.coordination === "coordinator" - ? cs - ? "Deleguj jen s dostupnými nástroji a v rozsahu zadání; ověř výsledek a práci dokonči. Bez delegace pokračuj lokálně, pokud to lze." - : "Delegate only with available tools and within task scope; verify results and finish the work. Without delegation, continue locally when possible." - : cs - ? "Pracuj přímo v rozsahu zadání a dostupných nástrojů." - : "Work directly within task scope and available tools.", - cs - ? `Ověř systém provádějící Mašiny (${profile.os}); přístup ${profile.access} nemění identitu ani oprávnění.` - : `Verify the execution Machine's OS (${profile.os}); ${profile.access} access changes neither identity nor permissions.`, - cs - ? "Před prací v Organizaci načti její aktuální AGENTS.md. Pro připojené operace ověř živou identitu a práva; lokální checkout není důkaz oprávnění." - : "Before Organization work, load its current AGENTS.md. Verify live identity and rights for connected operations; a local checkout is not proof of permission.", - cs - ? "Profil neuděluje přístup, publikační mandát ani oprávnění k práci na pozadí. Nečti cizí Personalspace a nekopíruj přihlašovací údaje." - : "A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials.", - cs - ? "Zachovej existující cesty a obsah Organizations a Personalspace. Jazyk profilu nepřejmenovává složky ani nepřekládá uživatelská data." - : "Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data.", - cs - ? "Chybějící nástroje, neověřená práva a neznámý stav přiznej; nevymýšlej dostupné schopnosti ani úspěšné dokončení." - : "Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion.", + ? pick({ + cs: "- Deleguj jen s dostupnými nástroji a v rozsahu zadání; ověř výsledek a práci dokonči. Bez delegace pokračuj lokálně, pokud to lze.", + en: "- Delegate only with available tools and within task scope; verify results and finish the work. Without delegation, continue locally when possible.", + }) + : pick({ + cs: "- Pracuj přímo v rozsahu zadání a dostupných nástrojů.", + en: "- Work directly within task scope and available tools.", + }), + pick({ + cs: "- Tvoje práce je Draft ve worktree a pull requestu; Publikace (merge, nasazení, odeslání) patří Principálovi a vyžaduje jeho explicitní pokyn v aktuálním threadu.", + en: "- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread.", + }), + pick({ + cs: "- Před prací v Organizaci načti její aktuální AGENTS.md; nadřazená pravidla drží root Lazurio (`HumanAndMachines/Lazurio`, AGENTS.md). Tenhle dokument je nenahrazuje.", + en: "- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (`HumanAndMachines/Lazurio`, AGENTS.md). This document does not replace them.", + }), + pick({ + cs: `- Ověř systém provádějící Mašiny (${profile.os}); přístup ${profile.access} nemění identitu ani oprávnění. Pro připojené operace ověř živou identitu a práva; lokální checkout není důkaz oprávnění.`, + en: `- Verify the execution Machine's OS (${profile.os}); ${profile.access} access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission.`, + }), + pick({ + cs: "- Profil neuděluje přístup, publikační mandát ani oprávnění k práci na pozadí. Nečti cizí Personalspace a nekopíruj přihlašovací údaje.", + en: "- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials.", + }), + pick({ + cs: "- Zachovej existující cesty a obsah Organizations a Personalspace. Jazyk profilu nepřejmenovává složky ani nepřekládá uživatelská data.", + en: "- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data.", + }), + pick({ + cs: "- Chybějící nástroje, neověřená práva a neznámý stav přiznej; nevymýšlej dostupné schopnosti ani úspěšné dokončení.", + en: "- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion.", + }), "", ].join("\n"); } diff --git a/tests/__snapshots__/folder-render.test.ts.snap b/tests/__snapshots__/folder-render.test.ts.snap new file mode 100644 index 00000000..a55a3be7 --- /dev/null +++ b/tests/__snapshots__/folder-render.test.ts.snap @@ -0,0 +1,239 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`rendered AGENTS.md snapshot: local / cs 1`] = ` +"# Lazurio + +## Tahle Mašina +- Preset: \`local\` (verze 1). +- Pracovní stanice Principála bez handoveru; Owner i Principál je přihlášený uživatel. +## Hranice +- Personalspace: \`personalspace/\` je intimní prostor právě jednoho Principála a jeho volitelného Buddyho. Nikdo cizí ho nečte a nikdy se nesdílí. +- Organizace: repozitáře žijí v \`organizations//\`; každá Organizace je vlastní access hranice a vlastní git repozitář. +- Identita: Principálova vlastní přihlášení; GitHub je jediná autorita přístupů. +## Jak se tu pracuje +- Komunikuj česky, pokud uživatel nepožádá jinak. +- Začni výsledkem a vysvětluj stručně. +- Pracuj přímo v rozsahu zadání a dostupných nástrojů. +- Tvoje práce je Draft ve worktree a pull requestu; Publikace (merge, nasazení, odeslání) patří Principálovi a vyžaduje jeho explicitní pokyn v aktuálním threadu. +- Před prací v Organizaci načti její aktuální AGENTS.md; nadřazená pravidla drží root Lazurio (\`HumanAndMachines/Lazurio\`, AGENTS.md). Tenhle dokument je nenahrazuje. +- Ověř systém provádějící Mašiny (macos); přístup local nemění identitu ani oprávnění. Pro připojené operace ověř živou identitu a práva; lokální checkout není důkaz oprávnění. +- Profil neuděluje přístup, publikační mandát ani oprávnění k práci na pozadí. Nečti cizí Personalspace a nekopíruj přihlašovací údaje. +- Zachovej existující cesty a obsah Organizations a Personalspace. Jazyk profilu nepřejmenovává složky ani nepřekládá uživatelská data. +- Chybějící nástroje, neověřená práva a neznámý stav přiznej; nevymýšlej dostupné schopnosti ani úspěšné dokončení. +" +`; + +exports[`rendered AGENTS.md snapshot: local / en 1`] = ` +"# Lazurio + +## This Machine +- Preset: \`local\` (version 1). +- The Principal's own workstation, no handover; the signed-in user is both Owner and Principal. +## Boundaries +- Personalspace: \`personalspace/\` is the intimate space of exactly one Principal and their optional Buddy. Nobody else reads it and it is never shared. +- Organizations: repositories live under \`organizations//\`; each Organization is its own access boundary and its own git repository. +- Identity: the Principal's own sign-ins; GitHub is the only access authority. +## How work is done here +- Communicate in English unless the user requests otherwise. +- Lead with the outcome and explain concisely. +- Work directly within task scope and available tools. +- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread. +- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (\`HumanAndMachines/Lazurio\`, AGENTS.md). This document does not replace them. +- Verify the execution Machine's OS (macos); local access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission. +- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials. +- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data. +- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion. +" +`; + +exports[`rendered AGENTS.md snapshot: hosted-personal / cs 1`] = ` +"# Lazurio + +## Tahle Mašina +- Preset: \`hosted-personal\` (verze 1). +- Mašina: \`example\` (personal-vm). +- Owner: Principál s GitHub loginem \`example\` (id 12345). Je to jeho jediná osobní hostovaná Mašina. +- Principál: Owner Mašiny. Agenti tu jednají za něj v jeho právech; Buddy je volitelný rezident téže Mašiny. +- Tailnet: Headscale node \`example\`. +- Host: provider-estate \`example-estate\`; vyšší doména správy a obnovy než tahle Mašina. +## Hranice +- Personalspace: \`personalspace/\` je intimní prostor právě jednoho Principála a jeho volitelného Buddyho. Nikdo cizí ho nečte a nikdy se nesdílí. +- Organizace: na osobní Mašině nejsou namountovaná žádná Organization repa. Práce v Organizaci probíhá na Mašinách, které Organizace vlastní. +- Identita: Principálova vlastní přihlášení; GitHub je jediná autorita přístupů. +## Jak se tu pracuje +- Komunikuj česky, pokud uživatel nepožádá jinak. +- Začni výsledkem a vysvětluj stručně. +- Pracuj přímo v rozsahu zadání a dostupných nástrojů. +- Tvoje práce je Draft ve worktree a pull requestu; Publikace (merge, nasazení, odeslání) patří Principálovi a vyžaduje jeho explicitní pokyn v aktuálním threadu. +- Před prací v Organizaci načti její aktuální AGENTS.md; nadřazená pravidla drží root Lazurio (\`HumanAndMachines/Lazurio\`, AGENTS.md). Tenhle dokument je nenahrazuje. +- Ověř systém provádějící Mašiny (linux); přístup remote nemění identitu ani oprávnění. Pro připojené operace ověř živou identitu a práva; lokální checkout není důkaz oprávnění. +- Profil neuděluje přístup, publikační mandát ani oprávnění k práci na pozadí. Nečti cizí Personalspace a nekopíruj přihlašovací údaje. +- Zachovej existující cesty a obsah Organizations a Personalspace. Jazyk profilu nepřejmenovává složky ani nepřekládá uživatelská data. +- Chybějící nástroje, neověřená práva a neznámý stav přiznej; nevymýšlej dostupné schopnosti ani úspěšné dokončení. +" +`; + +exports[`rendered AGENTS.md snapshot: hosted-personal / en 1`] = ` +"# Lazurio + +## This Machine +- Preset: \`hosted-personal\` (version 1). +- Machine: \`example\` (personal-vm). +- Owner: the Principal with GitHub login \`example\` (id 12345). This is their one personal hosted Machine. +- Principal: the Machine's Owner. Agents here act for them within their rights; a Buddy is an optional resident of this same Machine. +- Tailnet: Headscale node \`example\`. +- Host: provider-estate \`example-estate\`; a higher administration and recovery domain than this Machine. +## Boundaries +- Personalspace: \`personalspace/\` is the intimate space of exactly one Principal and their optional Buddy. Nobody else reads it and it is never shared. +- Organizations: no Organization repositories are mounted on a personal Machine. Organization work happens on Machines the Organization owns. +- Identity: the Principal's own sign-ins; GitHub is the only access authority. +## How work is done here +- Communicate in English unless the user requests otherwise. +- Lead with the outcome and explain concisely. +- Work directly within task scope and available tools. +- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread. +- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (\`HumanAndMachines/Lazurio\`, AGENTS.md). This document does not replace them. +- Verify the execution Machine's OS (linux); remote access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission. +- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials. +- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data. +- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion. +" +`; + +exports[`rendered AGENTS.md snapshot: hosted-organization-personal / cs 1`] = ` +"# Lazurio + +## Tahle Mašina +- Preset: \`hosted-organization-personal\` (verze 1). +- Mašina: \`workspace\` (workspace-vm). +- Owner: Organizace \`example\`. +- Principál: jediný Operátor, kterému Organizace tuhle pracovní VM přiřadila. Agenti jednají za něj v jeho živých právech. +- Tailnet: Headscale node \`example-workspace\`. +- Host: virtualization-host \`example-host\`; vyšší doména správy a obnovy než tahle Mašina. +## Hranice +- Personalspace: na Mašině vlastněné Organizací nikdy není. Nezakládej ho, nemountuj ho a nekopíruj sem osobní data ani přihlášení. +- Organizace: repozitáře žijí v \`organizations//\`; každá Organizace je vlastní access hranice a vlastní git repozitář. +- Identita: Principálova vlastní přihlášení; GitHub je jediná autorita přístupů. +## Jak se tu pracuje +- Komunikuj česky, pokud uživatel nepožádá jinak. +- Začni výsledkem a vysvětluj stručně. +- Pracuj přímo v rozsahu zadání a dostupných nástrojů. +- Tvoje práce je Draft ve worktree a pull requestu; Publikace (merge, nasazení, odeslání) patří Principálovi a vyžaduje jeho explicitní pokyn v aktuálním threadu. +- Před prací v Organizaci načti její aktuální AGENTS.md; nadřazená pravidla drží root Lazurio (\`HumanAndMachines/Lazurio\`, AGENTS.md). Tenhle dokument je nenahrazuje. +- Ověř systém provádějící Mašiny (linux); přístup remote nemění identitu ani oprávnění. Pro připojené operace ověř živou identitu a práva; lokální checkout není důkaz oprávnění. +- Profil neuděluje přístup, publikační mandát ani oprávnění k práci na pozadí. Nečti cizí Personalspace a nekopíruj přihlašovací údaje. +- Zachovej existující cesty a obsah Organizations a Personalspace. Jazyk profilu nepřejmenovává složky ani nepřekládá uživatelská data. +- Chybějící nástroje, neověřená práva a neznámý stav přiznej; nevymýšlej dostupné schopnosti ani úspěšné dokončení. +" +`; + +exports[`rendered AGENTS.md snapshot: hosted-organization-personal / en 1`] = ` +"# Lazurio + +## This Machine +- Preset: \`hosted-organization-personal\` (version 1). +- Machine: \`workspace\` (workspace-vm). +- Owner: Organization \`example\`. +- Principal: the one operator the Organization assigned this work VM to. Agents act for them within their live rights. +- Tailnet: Headscale node \`example-workspace\`. +- Host: virtualization-host \`example-host\`; a higher administration and recovery domain than this Machine. +## Boundaries +- Personalspace: never present on an Organization-owned Machine. Do not create or mount one and never copy personal data or sign-ins here. +- Organizations: repositories live under \`organizations//\`; each Organization is its own access boundary and its own git repository. +- Identity: the Principal's own sign-ins; GitHub is the only access authority. +## How work is done here +- Communicate in English unless the user requests otherwise. +- Lead with the outcome and explain concisely. +- Work directly within task scope and available tools. +- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread. +- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (\`HumanAndMachines/Lazurio\`, AGENTS.md). This document does not replace them. +- Verify the execution Machine's OS (linux); remote access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission. +- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials. +- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data. +- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion. +" +`; + +exports[`rendered AGENTS.md snapshot: hosted-organization-team / cs 1`] = ` +"# Lazurio + +## Tahle Mašina +- Preset: \`hosted-organization-team\` (verze 1). +- Mašina: \`workspace\` (workspace-vm). +- Owner: Organizace \`example\`, Team \`sample-team\`. +- Principál: Kolega, který se právě připojil. OS účet je sdílený členy Teamu a není osoba; změny se připisují Teamu přes brokerovanou identitu Organizace. +- Tailnet: Headscale node \`example-workspace\`. +- Host: virtualization-host \`example-host\`; vyšší doména správy a obnovy než tahle Mašina. +## Hranice +- Personalspace: na Mašině vlastněné Organizací nikdy není. Nezakládej ho, nemountuj ho a nekopíruj sem osobní data ani přihlášení. +- Organizace: repozitáře žijí v \`organizations//\`; každá Organizace je vlastní access hranice a vlastní git repozitář. +- Identita: brokerovaná identita Organizace s krátkodobými tokeny; žádná osobní přihlášení, session ani credentials sem nikdy nepatří. +## Jak se tu pracuje +- Komunikuj česky, pokud uživatel nepožádá jinak. +- Začni výsledkem a vysvětluj stručně. +- Pracuj přímo v rozsahu zadání a dostupných nástrojů. +- Tvoje práce je Draft ve worktree a pull requestu; Publikace (merge, nasazení, odeslání) patří Principálovi a vyžaduje jeho explicitní pokyn v aktuálním threadu. +- Před prací v Organizaci načti její aktuální AGENTS.md; nadřazená pravidla drží root Lazurio (\`HumanAndMachines/Lazurio\`, AGENTS.md). Tenhle dokument je nenahrazuje. +- Ověř systém provádějící Mašiny (linux); přístup remote nemění identitu ani oprávnění. Pro připojené operace ověř živou identitu a práva; lokální checkout není důkaz oprávnění. +- Profil neuděluje přístup, publikační mandát ani oprávnění k práci na pozadí. Nečti cizí Personalspace a nekopíruj přihlašovací údaje. +- Zachovej existující cesty a obsah Organizations a Personalspace. Jazyk profilu nepřejmenovává složky ani nepřekládá uživatelská data. +- Chybějící nástroje, neověřená práva a neznámý stav přiznej; nevymýšlej dostupné schopnosti ani úspěšné dokončení. +" +`; + +exports[`rendered AGENTS.md snapshot: hosted-organization-team / en 1`] = ` +"# Lazurio + +## This Machine +- Preset: \`hosted-organization-team\` (version 1). +- Machine: \`workspace\` (workspace-vm). +- Owner: Organization \`example\`, Team \`sample-team\`. +- Principal: whichever Team member is connected now. The OS account is shared by the Team and is not a person; changes are attributed to the Team through the brokered Organization identity. +- Tailnet: Headscale node \`example-workspace\`. +- Host: virtualization-host \`example-host\`; a higher administration and recovery domain than this Machine. +## Boundaries +- Personalspace: never present on an Organization-owned Machine. Do not create or mount one and never copy personal data or sign-ins here. +- Organizations: repositories live under \`organizations//\`; each Organization is its own access boundary and its own git repository. +- Identity: the brokered Organization identity with short-lived tokens; no personal sign-ins, sessions or credentials ever belong here. +## How work is done here +- Communicate in English unless the user requests otherwise. +- Lead with the outcome and explain concisely. +- Work directly within task scope and available tools. +- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread. +- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (\`HumanAndMachines/Lazurio\`, AGENTS.md). This document does not replace them. +- Verify the execution Machine's OS (linux); remote access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission. +- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials. +- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data. +- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion. +" +`; + +exports[`relationships render only when the recorded binding carries them 1`] = ` +"# Lazurio + +## This Machine +- Preset: \`hosted-personal\` (version 1). +- Machine: \`example\` (personal-vm). +- Owner: the Principal with GitHub login \`example\` (id 12345). This is their one personal hosted Machine. +- Principal: the Machine's Owner. Agents here act for them within their rights; a Buddy is an optional resident of this same Machine. +- Tailnet: Headscale node \`example\`. +- Host: provider-estate \`example-estate\`; a higher administration and recovery domain than this Machine. +### Related Machines +- \`example-laptop\` (personal client): both directions. +- \`example-work\` (work VM): reachable from here. +## Boundaries +- Personalspace: \`personalspace/\` is the intimate space of exactly one Principal and their optional Buddy. Nobody else reads it and it is never shared. +- Organizations: no Organization repositories are mounted on a personal Machine. Organization work happens on Machines the Organization owns. +- Identity: the Principal's own sign-ins; GitHub is the only access authority. +## How work is done here +- Communicate in English unless the user requests otherwise. +- Lead with the outcome and explain concisely. +- Work directly within task scope and available tools. +- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread. +- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (\`HumanAndMachines/Lazurio\`, AGENTS.md). This document does not replace them. +- Verify the execution Machine's OS (linux); remote access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission. +- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials. +- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data. +- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion. +" +`; diff --git a/tests/folder-render.test.ts b/tests/folder-render.test.ts index e7749ba8..03962122 100644 --- a/tests/folder-render.test.ts +++ b/tests/folder-render.test.ts @@ -86,3 +86,41 @@ test("localized profile output deterministically feeds reconciliation", () => { "not allowed", ); }); + +// The generated document per preset and language, on the OS each preset is +// offered for. Review the snapshot when the wording changes deliberately. +for (const journey of journeys.filter((entry) => entry.os !== "windows")) + for (const locale of ["cs", "en"] as const) + test(`rendered AGENTS.md snapshot: ${journey.preset} / ${locale}`, () => { + expect( + renderInstructions({ + preset: journey.preset, + machine: journey.machine, + profile: presetProfile(journey.preset, journey.os, { locale }), + }), + ).toMatchSnapshot(); + }); + +test("relationships render only when the recorded binding carries them", () => { + const profile = presetProfile("hosted-personal", "linux"); + const plain = renderInstructions({ + preset: "hosted-personal", + machine: bindings.personal, + profile, + }); + expect(plain).not.toContain("Related Machines"); + const related = renderInstructions({ + preset: "hosted-personal", + machine: { + ...bindings.personal, + relationships: [ + { machine: "example-laptop", kind: "personal-client", access: "both" }, + { machine: "example-work", kind: "workspace-vm", access: "outbound" }, + ], + }, + profile, + }); + expect(related).toContain("### Related Machines"); + expect(related).toContain("`example-work` (work VM): reachable from here."); + expect(related).toMatchSnapshot(); +}); From 29683dd60a477a2299612bd4cc55890052cb09f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 11:51:10 +0200 Subject: [PATCH 5/7] Launchpad: show the immutable handover, change preset and axes on the one flow /api/profile returns the preset reference, the presets the recorded handover allows, the immutable Machine binding and the profile. The panel renders the binding as text only, offers the preset from the allow-list and the three communication axes; access and purpose are no longer controls, they belong to the preset. Preview and apply take {expectedRevision, preset?, profile} on the same core use case as the CLI; a preset outside the allow-list is a 409 blocked plan and a request carrying a Machine binding is refused. Co-Authored-By: Claude Fable 5.1 --- src/launchpad/index.html | 8 +- src/launchpad/messages.ts | 52 ++++++++--- src/launchpad/server.ts | 25 +++++- src/launchpad/ui.ts | 108 +++++++++++++++++++++-- tests/launchpad.test.ts | 177 +++++++++++++++++++++++++++++++++++++- 5 files changed, 340 insertions(+), 30 deletions(-) diff --git a/src/launchpad/index.html b/src/launchpad/index.html index 6c2faa25..0d0cb654 100644 --- a/src/launchpad/index.html +++ b/src/launchpad/index.html @@ -5,11 +5,15 @@

Lazurio — Profile

Development fixture only. No Lazurio installation or migration.

+
+

This Machine

+

Recorded from the Machine handover; shown here, changed only by the Machines operator.

+
+
Machine profile -

-

+

diff --git a/src/launchpad/messages.ts b/src/launchpad/messages.ts index 47e0b12d..407b4774 100644 --- a/src/launchpad/messages.ts +++ b/src/launchpad/messages.ts @@ -68,13 +68,24 @@ const en = { title: "Lazurio — Profile", notice: "Development fixture only. No Lazurio installation or migration.", legend: "Machine profile", - access: "Access", - local: "Local", - remote: "Remote", - purpose: "Purpose", - human: "Human", - buddy: "Buddy", - ai_colleague: "AI colleague", + machineTitle: "This Machine", + machineNotice: + "Recorded from the Machine handover; shown here, changed only by the Machines operator.", + machineWorkstation: "Workstation of the signed-in Principal (no handover)", + machineKind: "Kind", + machineName: "Machine", + machineOwner: "Owner", + machineTeam: "Team", + machineTailnet: "Tailnet node", + machineHost: "Host", + machineNone: "not recorded", + preset: "Workspace preset", + presetDerived: "derived from the handover", + presetExplicit: "explicit choice", + presetLocal: "Local workstation", + presetHostedPersonal: "Hosted personal Machine", + presetHostedOrganizationPersonal: "Organization work Machine (one operator)", + presetHostedOrganizationTeam: "Organization team Machine (shared)", locale: "Language", detail: "Detail", concise: "Concise", @@ -164,13 +175,26 @@ const cs: Record = { notice: "Pouze vývojová testovací složka. Nejde o instalaci Lazuria ani migraci.", legend: "Profil mašiny", - access: "Přístup", - local: "Lokální", - remote: "Vzdálený", - purpose: "Účel", - human: "Člověk", - buddy: "Buddy", - ai_colleague: "AI kolega", + machineTitle: "Tahle Mašina", + machineNotice: + "Zaznamenáno z handoveru Mašiny; tady se jen zobrazuje, mění ho jen operátor Machines.", + machineWorkstation: + "Pracovní stanice přihlášeného Principála (bez handoveru)", + machineKind: "Druh", + machineName: "Mašina", + machineOwner: "Owner", + machineTeam: "Team", + machineTailnet: "Uzel tailnetu", + machineHost: "Host", + machineNone: "nezaznamenáno", + preset: "Preset pracovního prostředí", + presetDerived: "odvozeno z handoveru", + presetExplicit: "výslovná volba", + presetLocal: "Lokální pracovní stanice", + presetHostedPersonal: "Hostovaná osobní Mašina", + presetHostedOrganizationPersonal: + "Pracovní Mašina Organizace (jeden operátor)", + presetHostedOrganizationTeam: "Týmová Mašina Organizace (sdílená)", locale: "Jazyk", detail: "Podrobnost", concise: "Stručně", diff --git a/src/launchpad/server.ts b/src/launchpad/server.ts index ae331430..f4ac2549 100644 --- a/src/launchpad/server.ts +++ b/src/launchpad/server.ts @@ -4,8 +4,10 @@ import { join } from "node:path"; import { inspectProfileChange } from "../folder/inspect-profile-change"; import { withFolderOperationLock } from "../folder/lock"; import { inspectOwnedDirectory } from "../folder/owned-directory"; +import { allowedPresets } from "../folder/presets"; import { readFolderState } from "../folder/read-state"; import { stateFields } from "../folder/state"; +import { ownDataValue } from "../folder/state-fields"; import { updateProfile } from "../folder/update-profile"; import { createApplicationLifecycle } from "../modules/lifecycle"; import { readOrganizationApplications } from "../organizations/read-applications"; @@ -130,14 +132,25 @@ export async function startLaunchpad( const current = await withFolderOperationLock(state, () => readFolderState(state), ); + // The preset and the communication axes are changeable; the Machine + // binding is shown and never accepted back from the browser. return response({ revision: current.preferences.revision, + preset: current.preferences.preset, + allowedPresets: allowedPresets(current.preferences.machine), + machine: current.preferences.machine, profile: current.preferences.profile, }); } if (!["/api/preview", "/api/update"].includes(url.pathname)) return response({ error: "not-found" }, 404); - const value = stateFields(input, ["expectedRevision", "profile"]); + const withPreset = ownDataValue(input, "preset") !== undefined; + const value = stateFields( + input, + withPreset + ? ["expectedRevision", "preset", "profile"] + : ["expectedRevision", "profile"], + ); if ( typeof value.expectedRevision !== "number" || !Number.isSafeInteger(value.expectedRevision) || @@ -146,9 +159,13 @@ export async function startLaunchpad( return response({ error: "invalid-revision" }, 400); const operation = url.pathname === "/api/update" ? updateProfile : inspectProfileChange; - const result = await operation(folder, value.expectedRevision, { - profile: value.profile, - }); + const result = await operation( + folder, + value.expectedRevision, + withPreset + ? { preset: value.preset, profile: value.profile } + : { profile: value.profile }, + ); return response(result, result.kind === "blocked" ? 409 : 200); } catch { return response( diff --git a/src/launchpad/ui.ts b/src/launchpad/ui.ts index ebe8d2c0..0d968fd7 100644 --- a/src/launchpad/ui.ts +++ b/src/launchpad/ui.ts @@ -12,15 +12,77 @@ const choices = document.querySelector("#choices"); const apply = document.querySelector("#apply"); const status = document.querySelector("#status"); const result = document.querySelector("#result"); -if (!form || !choices || !apply || !status || !result) +const machine = document.querySelector("#machine"); +const presetSelect = document.querySelector("#preset"); +const presetSelection = + document.querySelector("#preset-selection"); +if ( + !form || + !choices || + !apply || + !status || + !result || + !machine || + !presetSelect || + !presetSelection +) throw new Error("Missing UI"); -const controls = { form, choices, apply, status, result }; +const controls = { + form, + choices, + apply, + status, + result, + machine, + presetSelect, + presetSelection, +}; let copy = messages("en"); -let current: { revision: number; profile: Record }; +type MachineBinding = { + kind: string; + name: string; + owner: + | { kind: "principal"; githubLogin: string; githubId: number } + | { kind: "organization"; organization: string; team: string | null }; + network: { headscaleHostname: string } | null; + host: { kind: string; id: string }; +}; +let current: { + revision: number; + preset: { name: string; version: number; selection: string }; + allowedPresets: string[]; + machine: MachineBinding | null; + profile: Record; +}; let pending: { expectedRevision: number; + preset: string; profile: Record; } | null = null; +const presetLabels: Record = { + local: "presetLocal", + "hosted-personal": "presetHostedPersonal", + "hosted-organization-personal": "presetHostedOrganizationPersonal", + "hosted-organization-team": "presetHostedOrganizationTeam", +}; +// The immutable part: rendered as text only, never as editable controls. +function machineRows(binding: MachineBinding | null): [MessageKey, string][] { + if (binding === null) return [["machineKind", copy.machineWorkstation]]; + const owner = + binding.owner.kind === "principal" + ? `${binding.owner.githubLogin} (GitHub id ${binding.owner.githubId})` + : binding.owner.organization; + return [ + ["machineKind", binding.kind], + ["machineName", binding.name], + ["machineOwner", owner], + ...(binding.owner.kind === "organization" && binding.owner.team !== null + ? ([["machineTeam", binding.owner.team]] as [MessageKey, string][]) + : []), + ["machineTailnet", binding.network?.headscaleHostname ?? copy.machineNone], + ["machineHost", `${binding.host.kind} ${binding.host.id}`], + ]; +} async function post(path: string, body: unknown) { const response = await fetch(path, { method: "POST", @@ -52,6 +114,30 @@ async function load() { if (key && Object.hasOwn(copy, key)) element.textContent = copy[key as MessageKey]; } + controls.machine.replaceChildren( + ...machineRows(current.machine).flatMap(([key, value]) => { + const term = document.createElement("dt"); + term.textContent = copy[key]; + const detail = document.createElement("dd"); + detail.textContent = value; + return [term, detail]; + }), + ); + controls.presetSelect.replaceChildren( + ...current.allowedPresets.map( + (name) => + new Option( + copy[presetLabels[name] ?? "preset"], + name, + false, + name === current.preset.name, + ), + ), + ); + controls.presetSelection.textContent = + current.preset.selection === "explicit" + ? copy.presetExplicit + : copy.presetDerived; for (const [key, value] of Object.entries(current.profile)) { const control = controls.form.elements.namedItem(key); if (control instanceof HTMLSelectElement) control.value = value; @@ -82,12 +168,18 @@ controls.form.addEventListener("submit", async (event) => { controls.choices.disabled = true; try { controls.choices.disabled = false; - const profile = { - ...current.profile, - ...Object.fromEntries(new FormData(controls.form).entries()), - } as Record; + const { preset, ...axes } = Object.fromEntries( + new FormData(controls.form).entries(), + ) as Record; + // Only the communication axes come from the form; the fixed axes stay + // with the preset and the Machine binding is never sent. + const profile = { ...current.profile, ...axes }; controls.choices.disabled = true; - const candidate = { expectedRevision: current.revision, profile }; + const candidate = { + expectedRevision: current.revision, + preset: preset ?? current.preset.name, + profile, + }; const preview = await request("/api/preview", candidate); if (preview.kind === "profile-change") { pending = candidate; diff --git a/tests/launchpad.test.ts b/tests/launchpad.test.ts index 98d6cbda..8e8f7b07 100644 --- a/tests/launchpad.test.ts +++ b/tests/launchpad.test.ts @@ -1,11 +1,60 @@ import { expect, test } from "bun:test"; -import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { initializeFolder } from "../src/folder/initialize-folder"; +import { + initializeFolder, + initializeHandoverFolder, +} from "../src/folder/initialize-folder"; import { inspectProfileChange } from "../src/folder/inspect-profile-change"; import { executionOs } from "../src/folder/platform"; +import { presetProfile } from "../src/folder/presets"; import { startLaunchpad } from "../src/launchpad/server"; +import { bindings } from "./fixtures/machine-bindings"; + +// A hosted Folder as folder-init adopts it, plus a Launchpad session on it. +async function hostedSession( + preset: "hosted-personal" | "hosted-organization-personal", + machine: (typeof bindings)[keyof typeof bindings], +) { + const parent = await realpath( + await mkdtemp(join(tmpdir(), "launchpad-hosted-")), + ); + const folder = join(parent, "Lazurio"); + await mkdir(folder, { mode: 0o700 }); + await mkdir(join(folder, "organizations"), { mode: 0o755 }); + await mkdir(join(folder, "personalspace"), { mode: 0o700 }); + const profile = presetProfile(preset, executionOs(process.platform)); + await initializeHandoverFolder(folder, { preset, machine, profile }); + const app = await startLaunchpad(folder); + const url = new URL(app.url); + const call = (path: string, body: unknown) => + fetch(new URL(path, url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: url.origin, + Authorization: `Bearer ${url.hash.slice(1)}`, + }, + body: JSON.stringify(body), + }); + return { + folder, + profile, + call, + async close() { + await app.server.stop(true); + await rm(parent, { recursive: true, force: true }); + }, + }; +} test.skipIf(process.platform === "win32")( "Launchpad profile API shares core and denies cross-origin or uncredentialed changes", @@ -51,6 +100,9 @@ test.skipIf(process.platform === "win32")( expect(await readFile(join(folder, "AGENTS.md"))).toEqual(before); expect(await (await call("/api/profile", {})).json()).toEqual({ revision: 1, + preset: { name: "local", version: 1, selection: "derived" }, + allowedPresets: ["local"], + machine: null, profile, }); const candidate = { @@ -78,6 +130,8 @@ test.skipIf(process.platform === "win32")( ); const html = await (await fetch(url.origin)).text(); expect(html).toContain("Lazurio — Profile"); + expect(html).toContain("This Machine"); + expect(html).not.toContain('name="access"'); expect(html).not.toContain(url.hash.slice(1)); } finally { await app.server.stop(true); @@ -85,3 +139,122 @@ test.skipIf(process.platform === "win32")( } }, ); + +test.skipIf(process.platform === "win32")( + "Launchpad shows the immutable handover, changes preset and axes through the same flow, and refuses a disallowed preset", + async () => { + const session = await hostedSession( + "hosted-organization-personal", + bindings.organization, + ); + try { + const shown = await (await session.call("/api/profile", {})).json(); + expect(shown).toEqual({ + revision: 1, + preset: { + name: "hosted-organization-personal", + version: 1, + selection: "derived", + }, + allowedPresets: [ + "hosted-organization-personal", + "hosted-organization-team", + ], + machine: bindings.organization, + profile: session.profile, + }); + const change = { + expectedRevision: 1, + preset: "hosted-organization-team", + profile: { ...session.profile, locale: "cs", detail: "technical" }, + }; + expect(await (await session.call("/api/preview", change)).json()).toEqual( + await inspectProfileChange(session.folder, 1, { + preset: change.preset, + profile: change.profile, + }), + ); + const refused = await session.call("/api/update", { + ...change, + preset: "hosted-personal", + }); + expect(refused.status).toBe(409); + expect(await refused.json()).toEqual({ + kind: "blocked", + reason: "preset-not-allowed", + }); + expect( + ( + await session.call("/api/update", { + ...change, + machine: null, + }) + ).status, + ).toBe(400); + expect(await (await session.call("/api/update", change)).json()).toEqual({ + kind: "updated", + revision: 2, + }); + const after = await (await session.call("/api/profile", {})).json(); + expect(after.preset).toEqual({ + name: "hosted-organization-team", + version: 1, + selection: "explicit", + }); + expect(after.machine).toEqual(bindings.organization); + expect(after.profile.locale).toBe("cs"); + expect( + await readFile(join(session.folder, "AGENTS.md"), "utf8"), + ).toContain("hosted-organization-team"); + } finally { + await session.close(); + } + }, +); + +test.skipIf(process.platform === "win32")( + "an Organization preset is refused on a personal VM handover", + async () => { + const session = await hostedSession("hosted-personal", bindings.personal); + try { + const shown = await (await session.call("/api/profile", {})).json(); + expect(shown.allowedPresets).toEqual(["hosted-personal"]); + expect(shown.machine.owner).toEqual(bindings.personal.owner); + for (const preset of [ + "hosted-organization-personal", + "hosted-organization-team", + "local", + ]) { + const refused = await session.call("/api/update", { + expectedRevision: 1, + preset, + profile: session.profile, + }); + expect(refused.status).toBe(409); + expect(await refused.json()).toEqual({ + kind: "blocked", + reason: "preset-not-allowed", + }); + } + expect( + ( + await session.call("/api/update", { + expectedRevision: 1, + preset: "hosted-team", + profile: session.profile, + }) + ).status, + ).toBe(400); + expect( + await ( + await session.call("/api/update", { + expectedRevision: 1, + profile: { ...session.profile, coordination: "coordinator" }, + }) + ).json(), + ).toEqual({ kind: "updated", revision: 2 }); + } finally { + await session.close(); + } + }, +); From 6481a4c62d7df2bd3705bbfdd1e24254621d2321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 11:54:02 +0200 Subject: [PATCH 6/7] Docs: three hosted presets, derivation from the handover, adoption, re-pin v0.12.59 workspace-presets.md records the implemented local preset model (names, derivation and allow-list, immutable/mutable split, storage, adoption) next to the typed-request direction that stays unimplemented. machine-handover.md records the byte-for-byte re-pin to Machines v0.12.59 (commit dd0dbfa), both schema branches, adoption instead of the empty layout, and the exact command a Machines resident role calls after handover with its JSON result shapes. Decision F10 is amended (2026-09-22, accepted by the Principal); F2, acceptance, ARCHITECTURE and the remaining references use the new names. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 6 +- ARCHITECTURE.md | 5 +- docs/acceptance.md | 10 +-- docs/decisions.md | 35 ++++++--- docs/development.md | 6 +- docs/environment-tools.md | 2 +- docs/machine-handover.md | 124 ++++++++++++++++++++--------- docs/module-adoption.md | 3 +- docs/workspace-presets.md | 161 +++++++++++++++++++++++++++----------- 9 files changed, 249 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f96f400d..122ef452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,10 @@ contract for product update, before any distribution work, and `docs/release-cyc for the build, qualification and promotion lifecycle and the two distinct test paths, `docs/profile-evidence.md` for optional measurement and `docs/hosted-assistance.md` before any hosted advice/execution work. Read `docs/content-sync.md`, `docs/workspace-presets.md` and `docs/hosted-entry.md` before -work on repository synchronization, presets or hosted entry; they record accepted -direction, not implemented behavior. This foundation is a proposal and bounded +work on repository synchronization, presets or hosted entry; content sync and hosted +entry record accepted direction, not implemented behavior, and workspace presets +record the implemented local preset model next to the accepted typed-request +direction. This foundation is a proposal and bounded proof, not authorization to migrate an installation, transfer a repository, publish a release or change access. Distinguish proposed contracts from executable evidence. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6dbfb108..5616f8a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -414,8 +414,9 @@ several Principals connect: it holds no personal credentials and no Personalspac provider identity is the brokered platform App identity, its changes go through pull requests, and attribution and revocation run through that brokered identity. A personal environment is never shared ad hoc. The kinds are selected by the -[workspace presets](docs/workspace-presets.md) `hosted-private` and `hosted-team`, -which configure the Environment and grant nothing. Buddy +[workspace presets](docs/workspace-presets.md) `hosted-personal`, +`hosted-organization-personal` and `hosted-organization-team`, derived from the +Machine handover; they configure the Environment and grant nothing. Buddy belongs to its human's private boundary and is not a new Principal. An AI Colleague has its own seat, identity, dedicated environment and one human custodian; custody does not create access to another Principal's Personalspace. Organization-owned diff --git a/docs/acceptance.md b/docs/acceptance.md index c02931bc..b99dabbb 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -39,7 +39,7 @@ implemented by the document that records it. identity, content and the selected version**. VM restart, failed-update preservation and the repeat on a second approved VM from the 2026-09-16 note remain required evidence of this journey; the reordering does not drop them. -9. **Team preset** with shared-use, conflict, attribution and revocation evidence +9. **`hosted-organization-team` preset** with shared-use, conflict, attribution and revocation evidence ([workspace presets](workspace-presets.md)). Live Team-grant verification in the identity broker is an external dependency. @@ -67,7 +67,7 @@ apply/restart or access mandate. | 1b — Distribution implementation | Accepted slice 1a contracts | Clean machine runs full installed CLI + Launchpad without source; the evidence list of the product update contract (attested, tampered, wrong-identity, below-floor and offline cases, the native activation journey, one real release candidate) and artifact secret scan | | 2 — Environment generation | Slice 1 and ownership/schema contract | Folder Factory produces only owned Lazurio Folder files through the shared CLI/Launchpad core; the shared core applies locally through CLI or Launchpad, starts full app; unknown/edited paths preserved; rollback drill | | 3 — Profile capability | Slice 2 and accepted behavior schema | CLI and Launchpad use the same profile use case; deterministic generation, stale revision refusal, session pin/restart and upgrade preservation | -| 4 — Environment purposes | Slices 2–3 and the validated hosted presets | Private human, team, Buddy and AI Colleague acceptance with correct Principal or Team attribution, Owner, custody and unavailable-capability behavior | +| 4 — Environment purposes | Slices 2–3 and the validated hosted presets (`hosted-personal`, `hosted-organization-personal`, `hosted-organization-team`) | Personal, Organization-personal, team and Buddy acceptance with correct Principal or Team attribution, Owner, custody and unavailable-capability behavior | | 5 — Migration rehearsal | Relevant slices 1–4, legacy compatibility and restore mapping | Faithful dirty legacy source-working fixtures plus legacy shared-workshop convergence to a private or team workspace prove preservation, interrupted recovery and no-op unknown state | | 6 — Opt-in cohorts | Qualified consumer slices; rehearsal and explicit migration approval for migrating cohorts | Small native cohort on each supported OS, user completion evidence, observation and recovery; halt on data loss/identity ambiguity | | 7 — General availability and retirement | Successful cohorts, public release approval | Published support matrix and release provenance; legacy install/update paths retired by declared criteria, backups retained by policy | @@ -130,7 +130,7 @@ fixes continue with their owners; selectively port proven contracts with provena | Application lifetime (Linux; implemented 2026-09-19 for Launchpad restart, [native evidence](evidence/app-services-linux-arm64-2026-09-19.md); product activation, reboot persistence and `linux-x64` not yet exercised) | A started application survives a Launchpad restart and a product activation; Stop stops the service; identity and readiness come from the service manager; reboot persistence only when explicitly set; CLI and Launchpad agree | Saved PID treated as identity, foreign service or process adopted or signalled, port collision accepted, dependency preparation beneath a running application, implicit persistence after Open | | Content synchronization (accepted direction) | Root first, manifest re-read, declared children; absent destination materialized through a verified temporary sibling; existing checkout fast-forwarded to the inspected commit; siblings continue past one failure | Dirty or wrong-branch checkout stashed, switched or reset; occupied destination replaced; divergence merged; Production Space, Personalspace, worktree or repository database touched; product update cloning or stashing anything | | Hosted entry (accepted direction) | Authenticated browser reaches Launchpad and application hostnames; session revalidated at the configured auth endpoint; WebSocket reconnect re-enters admission | Forged identity headers honoured, unknown host served, cross-origin state change accepted, Account login treated as admission or repository access | -| Workspace preset (accepted direction) | Immutable reference plus visible overrides survive upgrade; typed request with the expected revision is applied through the ordinary use case and reports the observed outcome | Unknown preset or version mutates; stale revision applied; remote intent overrides a concurrent local change; preset carries grants, tokens, mandates or analytics consent; preset derived from a name | +| Workspace preset (local model implemented; typed request accepted direction) | Preset derived from the typed handover fields, confirmed or explicitly chosen within the allow-list; immutable Machine binding shown, never edited; existing Folder adopted without entering work directories; typed request with the expected revision is applied through the ordinary use case and reports the observed outcome | Unknown preset or version mutates; a personal VM takes an Organization preset or vice versa; stale revision applied; remote intent overrides a concurrent local change; preset carries grants, tokens, mandates or analytics consent; preset derived from a name, hostname or operator account; adoption lists, moves or deletes anything in `organizations/` or `personalspace/` | | Access | Live identity and exact repo operation attributable to the correct Principal, or on a team workspace to the Team through the brokered identity | Revoked membership or Team grant, wrong account, unattributed brokered change, personal credential on a team workspace, peer credential access fail closed | | Rollback | Exact old compatible product/profile with verified checkpoint and retained work | New schema/data or new user writes block blind downgrade and produce forward-repair plan | | Retirement | Old route/process/credential/profile inventory empty after accepted cutover | Recovery dependencies or unattributed drafts prevent deletion | @@ -148,8 +148,8 @@ These are future acceptance scenarios, not completed tests or four template fork This matrix is the general-availability target. The [nearest-pilot sequence](#nearest-pilot-sequence) deliberately qualifies a narrower set -first (`linux-x64`, `darwin-arm64`, per-user install, one private hosted preset, then -the team preset). That narrowing orders the work; it does not retire a cell, a journey +first (`linux-x64`, `darwin-arm64`, per-user install, `hosted-personal` and +`hosted-organization-personal`, then `hosted-organization-team`). That narrowing orders the work; it does not retire a cell, a journey or a language from this matrix, and no canary result may be reported as matrix evidence beyond the cells it actually exercised. diff --git a/docs/decisions.md b/docs/decisions.md index 3de4b24d..7680de2e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -129,7 +129,7 @@ Machine without an assigned operator. Upstream wins. Both hosted kinds are first | Attribution | The Principal's own provider identity | Bot committer, Team author pseudo-identity and workspace trailer (upstream 0148) | | How changes land | The Principal's live rights | Pull requests; an authorized person reviews, merges and takes responsibility | | Revocation | The Principal's grants and sign-in | Live GitHub Team grant checked at each token issue (upstream 0149) | -| Workspace preset | `hosted-private` | `hosted-team` | +| Workspace preset | `hosted-personal` (the Principal's own personal VM) or `hosted-organization-personal` (an Organization work VM assigned to one operator) | `hosted-organization-team` | **Intent that remains.** A personal environment is never shared ad hoc. Nobody adds a second person to a private workspace, a workstation or a Buddy host; nobody copies a @@ -148,8 +148,8 @@ operator remains a higher compromise domain. Placement never proves the Git push verify the provider identity and exact repository grants at the operation boundary. On a team workspace, a change that cannot be attributed to the Team through the brokered identity fails closed. Live Team-grant verification in the broker is a target -contract upstream, not deployed behaviour; it is an external dependency of `hosted-team` -acceptance. Platform consequences: [workspace presets](workspace-presets.md), +contract upstream, not deployed behaviour; it is an external dependency of +`hosted-organization-team` acceptance. Platform consequences: [workspace presets](workspace-presets.md), [content synchronization](content-sync.md), [hosted entry](hosted-entry.md), [tools and sign-ins](environment-tools.md) and [machine handover](machine-handover.md). @@ -446,12 +446,29 @@ deliberate change from the legacy engine that requires the 0129 amendment above. ## F10 — Workspace presets and typed owner requests -**Accepted direction (2026-09-19), not implemented.** A named, versioned, declarative -[workspace preset](workspace-presets.md) composes purpose, collaboration defaults, -required capabilities, enabled surfaces and supervision policy. A preset does not -select product releases: F13 has no update channel to configure. -Exactly two hosted presets are validated first, `hosted-private` and `hosted-team`, -next to the existing local default. The Environment stores the immutable preset +**Accepted direction (2026-09-19); amended and accepted by the Principal 2026-09-22; +local preset model implemented, typed owner requests not.** A named, versioned, +declarative [workspace preset](workspace-presets.md) composes purpose, collaboration +defaults, required capabilities, enabled surfaces and supervision policy. A preset does +not select product releases: F13 has no update channel to configure. + +**Amendment 2026-09-22.** Three hosted presets, next to the `local` workstation +default: `hosted-personal` (a Principal's one personal VM: Personalspace present, no +Organization repositories mounted, the Principal's own sign-ins, Buddy optional), +`hosted-organization-personal` (an Organization-owned work VM assigned to one +operator; Organization repositories; Personalspace never present; formerly +`hosted-private`) and `hosted-organization-team` (an Organization-owned team VM, one +OS account, several Principals, brokered Organization identity; formerly +`hosted-team`). Nothing was implemented under the old names, so no compatibility. +The preset is **derived from the Machine handover** and can only be confirmed or +explicitly overridden within what the handover allows: `machine.kind: "personal-vm"` +→ `hosted-personal`; `"workspace-vm"` with `owner.team` → `hosted-organization-team`; +without → `hosted-organization-personal`; a personal VM never takes an Organization +preset and vice versa, and an explicit choice is recorded as such. What comes from the +handover (kind, name, owner, team, tailnet identity, host, relationships when present) +is immutable and only shown; the preset and the communication axes change through the +one existing preview → apply profile change. `folder-init` adopts an existing Folder +instead of requiring an empty layout. The Environment stores the immutable preset reference plus explicit local overrides under the existing environment-configuration owner; the instruction axes of the profile renderer are not extended into a universal infrastructure configuration. diff --git a/docs/development.md b/docs/development.md index 6cfd371a..ccce8adc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -176,7 +176,11 @@ Parser references verified for the regression fix: [Bun Transpiler scan](https:/ The experimental `folder-init` command accepts an absent canonical `--folder` path and the normal profile choices/JSON. It rejects every existing target, including an empty directory, and accepts no revision/digest inputs. Successful creation produces -revision 1 using the shared initializer. The compiled macOS CLI fixture now starts +revision 1 using the shared initializer under the `local` workspace preset (no +Machine binding); its fixed axes are therefore `access: local`, `purpose: human`. +`profile-preview` and `profile-update` accept an optional `--preset` within the +allow-list of the recorded Machine, which for a workstation is only `local`. The +hosted entry is `lazurio machine folder-init` ([machine handover](machine-handover.md)). The compiled macOS CLI fixture now starts through this command, then performs update, stale/no-op handling and prepared-update resume, without manually seeding preferences or ownership metadata. diff --git a/docs/environment-tools.md b/docs/environment-tools.md index 931e47d7..a19aebce 100644 --- a/docs/environment-tools.md +++ b/docs/environment-tools.md @@ -72,7 +72,7 @@ sign-in column above changes: - **Harness and model access is open.** A member's personal model subscription is a personal credential and does not belong on the shared account; what the Organization supplies instead, and how it is attributed and revoked, is undecided and is a - prerequisite of `hosted-team` acceptance. + prerequisite of `hosted-organization-team` acceptance. - **No Personalspace**, and no step of the preparation sequence may create one. Steps 1–3 and 5–6 of the sequence apply unchanged. Step 4 becomes: verify the brokered diff --git a/docs/machine-handover.md b/docs/machine-handover.md index 163b33d8..0ffcb614 100644 --- a/docs/machine-handover.md +++ b/docs/machine-handover.md @@ -8,12 +8,25 @@ does not authorize deployment, restart, access changes or resident removal. Machines writes `/etc/lazurio/lazurio.machine.json`, root-owned and non-shared, after successful managed handover. Platform only reads it. The exact upstream -JSON Schema is vendored in `src/machine/lazurio-machine.v1.schema.json`; adjacent -`schema-provenance.json` records the authorized source commit and byte digest. -Changes originate in Machines, then the consumer is re-pinned and conformance -tested. No runtime dependency on a private checkout. The test fixture is synthetic, -not a customer's rendered identity. The pin is a PR candidate, not evidence that -the Machines change has been merged/deployed. +JSON Schema is vendored byte-for-byte in `src/machine/lazurio-machine.v1.schema.json` +from Machines release **v0.12.59** (tag `v0.12.59`, commit +`dd0dbfaab4807f197f5f65f102fc74bd22496499`); adjacent `schema-provenance.json` +records the source tag, commit and byte digest, and a test fails when the vendored +bytes drift from it. Changes originate in Machines, then the consumer is re-pinned +and conformance tested. No runtime dependency on a private checkout. The test +fixtures are synthetic, not a customer's rendered identity. + +The schema is a `oneOf` with exactly two branches, distinguished by `machine.kind` +and never mixing owner or host kinds: + +| Branch | `machine.kind` | `owner` | `host` | `network` | +| --- | --- | --- | --- | --- | +| Organization workspace VM | `workspace-vm` | `{kind: "organization", organization, organization_key?, team?}` | `{kind: "virtualization-host", machine_id, custody_repository, provider}` | optional | +| Personal VM | `personal-vm`; `machine.name` is the DNS slug | `{kind: "principal", github_login, github_id}` | `{kind: "provider-estate", estate_id, custody_repository, record_path}` | required | + +`operator`, `installed` and `account` are shared; `operator.os_user` may differ from +`machine.name`. `MachineContext` in `src/machine/context.ts` is the union of the two +branches, and the conformance tests cover both with real-shaped fixtures. The consumer rejects duplicate keys, unknown fields, invalid UTF-8, documents over 1 MiB, unsafe file ownership/modes, links and noncanonical parent custody. The @@ -42,12 +55,15 @@ narrow Linux/remote/human pilot entrypoint. Personalspace. Its provider identity is the brokered Organization identity; `team` in the handover is context for that, not a grant and not a roster. -The handover has no selected-preset field. Platform does not derive `hosted-private` -or `hosted-team` from the presence of `team`, the Machine name, the hostname or the -operator name. The [workspace preset](workspace-presets.md) is chosen explicitly at -setup and stored in the Environment configuration. If preset provenance must appear in -this file, that is an upstream schema change in Machines followed by a re-pin and -conformance test here, exactly like any other field. +The handover has no selected-preset field and needs none. The +[workspace preset](workspace-presets.md) is derived from its typed fields only: +`personal-vm` → `hosted-personal`; `workspace-vm` with `owner.team` → +`hosted-organization-team`; without → `hosted-organization-personal`. Never from the +Machine name, the hostname or the operator account. `folder-init` records the derived +preset, or an explicit `--preset` the handover allows, in the Environment +configuration together with the immutable **Machine binding** (kind, name, owner, +team, tailnet node, host and the handover digest). Machines does not rewrite the +identity; a Folder adopted from a different handover is refused, never rewritten. A repeated infrastructure apply must preserve the Machine identity, the Folder content and the Platform-selected product version; Machines does not reselect the @@ -55,19 +71,29 @@ version after handover, and Platform does not rewrite the identity. ## Consumer commands -Run the installed CLI as the declared operator, not root: +Run the installed CLI as the declared operator, not root. This is the exact command a +Machines resident role calls after handover: ```sh -lazurio machine inspect -lazurio machine folder-init --locale cs --detail technical --coordination direct +lazurio machine folder-init ``` -The second command is the narrow Linux/remote/human pilot entrypoint. Locale, -detail and coordination are explicit choices, not inferred from identity. It binds -the declared user/home to the actual UID's Linux NSS record (not `$USER`/`$HOME`) and requires -`operator.lazurio_root` to be that user's `/home//Lazurio`. The wire name -remains `lazurio_root`; the product concept is **Lazurio Folder**. There is no CLI -override for the production identity path or UID. +It reads the handover, derives the preset, adopts the Folder and prints one JSON +object: `{"kind":"initialized","revision":1,"preset":{"name":…,"version":1,"selection":"derived"},"machineContextDigest":""}` +on first use; `{"kind":"already-adopted","revision":,"preset":{…},"machineContextDigest":…}` +on a re-run, which changes nothing; or exit status 2 with +`{"kind":"blocked","reason":…,"entry":…,"next":…}` where `reason` is one of +`folder-foreign-entry`, `folder-layout-missing`, `folder-personalspace-conflict`, +`folder-state-unrecognized`, `folder-binding-changed`, `preset-not-allowed` or a +Machine context code. Optional `--preset ` picks another preset the handover +allows (recorded as an explicit choice); optional `--locale`, `--detail` and +`--coordination` override the preset's defaults and stay changeable in the Launchpad. +`lazurio machine inspect` prints the validated handover and its digest. + +It binds the declared user/home to the actual UID's Linux NSS record (not +`$USER`/`$HOME`) and requires `operator.lazurio_root` to be that user's +`/home//Lazurio`. The wire name remains `lazurio_root`; the product concept is +**Lazurio Folder**. There is no CLI override for the production identity path or UID. The Linux base system must provide root-owned `/usr/bin/getent`; it is called without a shell, with a fixed `passwd ` query and sanitized environment. @@ -76,17 +102,42 @@ We deliberately do not use Bun 1.4.2 `os.userInfo()` here: native ARM64 qualific observed that its username depends on the ambient environment. No dependency on a separately installed Bun is introduced by the system account lookup. -The shared Folder initializer accepts exactly a canonical operator-owned Folder -with empty `organizations/` and `personalspace/`, both owned and non-shared. Their -paths, filesystem identities and modes remain unchanged. A manifest, checkout, -instructions, existing `.lazurio`, extra entry, symlink or nonempty work directory -stops initialization. No resident cleanup/adoption. Normal `folder-init` remains -fresh-path-only; this is a narrow explicit entry into the same core. +### Adoption of the delivered Folder + +The initializer adopts a canonical operator-owned Folder. The Folder owns exactly +`AGENTS.md` and `.lazurio/` at the top level. `organizations/` (required, owned, +non-shared) and `personalspace/` may already hold work: they are never traversed, +listed beyond existence, moved or written, and their paths, filesystem identities and +modes remain unchanged. `launchpad.gen3.json` and `launchpad.gen3.local.json` are +tolerated by name and never read. Any other top-level entry — a legacy `AGENTS.md`, +a checkout, a note — stops initialization and is named in the refusal. A symlinked or +group/world-writable work directory is refused as before. + +`hosted-personal` requires `personalspace/` to exist. The Organization presets never +have a Personalspace: the empty `personalspace/` that the `workspace_baseline` role +precreates is accepted and recorded, a used one is refused by name and nothing is +deleted or moved. Presence is checked by existence and emptiness only, never by +listing names. Initialization exclusively creates `.lazurio`, journals the preexisting layout identities and writes new instructions/preferences/manifest exclusively. Two -initializers cannot both claim state. No Organization is cloned yet: owner binding -and module delivery remain separate pilot gates. +initializers cannot both claim state. A re-run on an adopted Folder holds only the +ephemeral operation lock, compares the recorded Machine binding with the live +handover and reports `already-adopted`; a different handover is `binding-changed`, +pending or unrecognized state is `state-unrecognized` (complete it with +`folder-resume` or diagnose). No Organization is cloned yet: owner binding and +module delivery remain separate pilot gates. + +### What the Folder renders + +`AGENTS.md` is a deterministic projection of the preset, the recorded binding and +the profile: which Machine this is and whose, who the Principal is here, the +Personalspace boundary, where Organization repositories live, the provider identity +mode and how work is done, with a pointer to the Organization's `AGENTS.md` and the +Lazurio root rules. A relationships section is rendered only when the recorded +binding carries one; the handover has no such field yet, and no persona is rendered. +The Launchpad shows the binding and lets the Principal change the preset (within the +allow-list) and the communication axes through the ordinary preview → apply flow. ## Bounded diagnosis and repair @@ -121,9 +172,12 @@ Use `linux-arm64` for the local ARM64 VM. The target and artifact digest in build remains available without `--target`. Cross-compilation is only packaging; it does not qualify either architecture. See [Bun's executable targets](https://bun.sh/docs/bundler/executables). -Unit fixtures prove parsing/refusal, directory preservation and recognized -interruption completion. They do not prove actual Machines delivery, official -release hosting and attestation, native Linux x64 execution, Organization/module authorization, -gateway operation, agent work, VM restart or the second-VM repeat. Record those -separately at exact source/artifact revisions. The real pilot must exercise the -installed binary and root-issued file under the non-root operator account. +Unit fixtures prove parsing/refusal of both handover branches, preset derivation, +adoption (used work directories, legacy files, foreign entries, idempotence, the +Personalspace conflict), directory preservation and recognized interruption +completion. They do not prove actual Machines delivery, a native run of +`folder-init` on a real personal or work VM, official release hosting and +attestation, native Linux x64 execution, Organization/module authorization, gateway +operation, agent work, VM restart or the second-VM repeat. Record those separately +at exact source/artifact revisions. The real pilot must exercise the installed +binary and root-issued file under the non-root operator account. diff --git a/docs/module-adoption.md b/docs/module-adoption.md index fd135000..ddf92381 100644 --- a/docs/module-adoption.md +++ b/docs/module-adoption.md @@ -342,7 +342,8 @@ source commits. Repeat qualification after the integrated source is committed/re The presets in this section are **application presets**: versioned inputs that produce a conforming starting application inside an Organization. They are a different concept from [workspace presets](workspace-presets.md), which configure a Lazurio Environment -(`hosted-private`, `hosted-team`). Neither kind grants access. +(`hosted-personal`, `hosted-organization-personal`, `hosted-organization-team`). +Neither kind grants access. Keep three responsibilities separate: the Platform's shared operational install/lifecycle/Doctor contract, each application's concrete requirements, and diff --git a/docs/workspace-presets.md b/docs/workspace-presets.md index 6377cda7..3dcbb1b5 100644 --- a/docs/workspace-presets.md +++ b/docs/workspace-presets.md @@ -1,8 +1,8 @@ # Workspace presets -Status: **accepted direction of the Principal (2026-09-19); not implemented.** No -preset format, store, command or UI exists. See -[decision F10](decisions.md#f10--workspace-presets-and-typed-owner-requests). +Status: **local preset model implemented (2026-09-22, names, derivation and +adoption accepted by the Principal); typed owner requests remain accepted direction.** +See [decision F10](decisions.md#f10--workspace-presets-and-typed-owner-requests). A workspace preset is a different concept from the application presets in [module adoption](module-adoption.md#workspace-standards-and-versioned-presets--accepted-direction), @@ -11,46 +11,99 @@ configures one Lazurio Environment. ## What a preset is -A named, versioned, declarative composition of: +A named, versioned, declarative composition, shipped as data in +`src/folder/presets.ts`: | Field | Meaning | | --- | --- | -| Purpose | Human work, Buddy acting for a human, or AI Colleague seat | -| Collaboration defaults | Initial collaboration, detail and locale choices; the Principal may change them | -| Required capabilities | Tools, harness capabilities and the provider identity mode (own sign-in or brokered Organization identity) the Environment must diagnose as present | +| Machine kinds | Which handover kinds the preset is allowed on (`workstation`, `personal-vm`, `workspace-vm`) | +| Composition | The fixed profile axes the preset pins: `access` and `purpose` | +| Defaults | Initial `locale`, `detail` and `coordination`; the Principal may change them | +| Personalspace policy | `present` (intimate, one Principal) or `never` (Organization-owned Machine) | +| Provider identity | The Principal's own sign-in, or the brokered Organization identity | | Enabled surfaces | Which installed surfaces are offered, for example Launchpad and hosted entry | -| Supervision policy | How long-running applications are owned, for example OS service manager or session-scoped | +| Supervision policy | Session-scoped applications or the OS service manager | -A preset is data shipped with a release. It carries no scripts, no infrastructure and -no authority. An unknown preset, an unknown version or an unsupported combination -fails before any mutation. +A preset carries no scripts, no infrastructure and no authority. An unknown preset, +an unknown version or an unsupported combination fails before any mutation. Only whole +presets are supported: editing a field does not create a new supported preset. Today +the composition, the defaults, the Personalspace policy and the Machine kinds are +consumed by the Folder Factory; provider identity, surfaces and supervision are +declared for the consumers that own them (the identity broker, hosted entry, the +application runner) and are not enforced by the preset itself. -## The first validated presets +## The presets -Exactly two hosted presets are validated first, next to the existing local default: +| Preset | Machine | Principal here | Provider identity | Personalspace | Organization repositories | +| --- | --- | --- | --- | --- | --- | +| `local` | The Principal's own workstation, no handover | The signed-in user | Own sign-in | Present | `organizations//` | +| `hosted-personal` | A Principal's ONE personal VM (`machine.kind: personal-vm`) | The Machine's Owner; a Buddy is an optional resident of the same Machine | Own sign-in | Present and intimate | None mounted | +| `hosted-organization-personal` | An Organization-owned work VM assigned to ONE operator (`workspace-vm` without `owner.team`) | The assigned operator | Own sign-in | Never present | `organizations//` | +| `hosted-organization-team` | An Organization-owned team VM, one OS account, several Principals (`workspace-vm` with `owner.team`) | The connected Team member; the OS account is not a person | Brokered Organization identity; no personal credentials | Never present | `organizations//` | -| Preset | Use | Provider identity | Personalspace | -| --- | --- | --- | --- | -| `local` (existing default) | A Principal's own workstation | Principal's own sign-in | The Principal's own, under upstream rules | -| `hosted-private` | A hosted workspace dedicated to one Principal | That Principal's own sign-in | Not mounted on an Organization-owned Machine | -| `hosted-team` | An Organization-owned hosted workspace; one OS account; several Principals connect | Brokered Organization identity; no personal credentials | Never present | +The earlier names `hosted-private` and `hosted-team` were never implemented and were +renamed without compatibility. -Each preset is validated as a complete composition. Fields are not independently -supported combinations: a preset that has not passed its acceptance is not offered, -and editing individual fields does not create a new supported preset. +## Derived from the handover, confirmed or explicitly overridden + +The preset is derived from the typed fields of the root-issued handover +([machine handover](machine-handover.md)), never from a Machine name, hostname or +operator account: + +| Handover | Derived preset | Allowed presets | +| --- | --- | --- | +| No handover (workstation) | `local` | `local` | +| `machine.kind: "personal-vm"` | `hosted-personal` | `hosted-personal` | +| `"workspace-vm"` with `owner.team` | `hosted-organization-team` | both Organization presets | +| `"workspace-vm"` without `owner.team` | `hosted-organization-personal` | both Organization presets | + +`lazurio machine folder-init [--preset ]` records the derived preset by default. +A different `--preset` must be one the handover allows (a personal VM never takes an +Organization preset and vice versa) and is recorded as an explicit choice. The same +allow-list governs every later change. + +## Immutable and mutable + +| | Where it comes from | Launchpad | +| --- | --- | --- | +| Machine kind, name, Owner (Principal or Organization + Team), tailnet node, host, and relationships when the handover carries them | The handover, recorded once as the **Machine binding** | Shown only | +| Preset | Derived, confirmed or explicitly chosen within the allow-list | Changeable through the ordinary preview → apply profile change | +| Communication axes `locale`, `detail`, `coordination` | Preset defaults, then the Principal | Changeable through the same flow | +| Fixed axes `access`, `purpose` | The preset's composition | Not controls; a request whose fixed axes disagree with the preset is a blocked plan | + +There is one change path. CLI (`profile-preview`/`profile-update --preset`), Launchpad +and a future typed owner request all send `{expectedRevision, preset?, profile}` to the +same use case; a preset outside the allow-list is `preset-not-allowed`, a profile that +disagrees with the preset is `preset-composition`, both without a write. ## Storage and ownership -The Environment stores the **immutable preset reference** (name and version) together -with **explicit local overrides**, under the existing environment-configuration owner -and its revision discipline ([migration and recovery](migration-and-recovery.md)). -Overrides are visible, named and revisioned; a preset upgrade never silently drops or -reinterprets them. +`.lazurio/preferences.json` (schema 2) stores the **preset reference** (`name`, +`version`, `selection: derived | explicit`), the immutable **Machine binding** (`null` +on a workstation) and the profile, under the existing revision discipline +([migration and recovery](migration-and-recovery.md)). The whole composition is +validated on every parse: a preset the recorded handover does not allow never parses. +The rendered `AGENTS.md` is a deterministic projection of preset, binding and profile +([machine handover](machine-handover.md#what-the-folder-renders)). Do not extend the instruction axes in `src/folder/profile.ts` into a universal -infrastructure configuration. Those axes describe generated instructions. A preset may -supply their defaults; it does not turn the profile renderer into the owner of -supervision or surfaces. +infrastructure configuration. Those axes describe generated instructions. A preset +supplies their defaults and pins the fixed ones; it does not turn the profile renderer +into the owner of supervision or surfaces. The `purpose` axis keeps its values until +the upstream sweep of decision 0156 (automated Machine with a persona) lands. + +## Adoption of an existing Folder + +`folder-init` adopts the Folder Machines delivers and the one real Machines already +have. The Folder owns exactly `AGENTS.md` and `.lazurio/` at the top level. +`organizations/` and `personalspace/` may be non-empty and are never traversed, listed +beyond existence, moved or written; `launchpad.gen3.json` and +`launchpad.gen3.local.json` are tolerated by name; any other top-level entry fails +closed naming that entry. Machines precreates an empty `personalspace/` on every work +VM, so an Organization preset (Personalspace never present) accepts an empty one and +refuses a used one — it deletes nothing and names the path. A re-run on an adopted +Folder reports `already-adopted` and changes nothing; a Folder adopted from a different +handover or with unrecognized state is refused by name. ## One choice, two effects, two owners @@ -65,12 +118,15 @@ The hosting engine's reusable Machine profiles remain its own; Platform gains no authority to redefine them. A managed Dashboard may present one choice and dispatch a separate request to each owner. Neither owner applies the other's half. -## Typed requests with an expected revision +## Typed requests with an expected revision — accepted direction A managed service that wants to change Environment configuration sends a **typed, resource-specific request**: for example "set preset reference", "update the product" or "start this application". Each request carries the requester's identity, the -**expected local revision** of that resource and the requested change. +**expected local revision** of that resource and the requested change. The local state +already makes this possible: the preset reference and its revision live in +`.lazurio/preferences.json`, and "set preset reference" is exactly the +`{expectedRevision, preset, profile}` request above. The local core validates the request, applies it through the ordinary use case (the same one CLI and Launchpad use) and returns an accepted or rejected revision plus the @@ -81,31 +137,42 @@ never silently takes precedence over a local change. There is no generic desired-state-to-Machine pipeline, no reconciler that converges a Machine toward a remote document, and no second writer. Transport, authentication of the requester and freshness of displayed observations remain open; self-hosted -Environments need none of this. +Environments need none of this. No Dashboard request is built yet. ## Never part of a preset Access grants, rosters, team membership, tokens, credentials, effective mandates and analytics consent are never preset fields, defaults or side effects. A preset cannot enable measurement ([profile evidence](profile-evidence.md)), grant provider access or -supply a mandate. `hosted-team` requires the brokered identity *mode* as a capability; -the broker credential, its policy and the live GitHub Team grants stay with their -owners, and a missing capability is a diagnosis, never something the preset provisions. +supply a mandate. `hosted-organization-team` requires the brokered identity *mode* as +a capability; the broker credential, its policy and the live GitHub Team grants stay +with their owners, and a missing capability is a diagnosis, never something the preset +provisions. Recording a preset enforces no access: placement never proves the Git +pusher. ## Provenance in the Machine identity -The pinned Machine identity schema has no selected-preset field. Platform does not -derive a preset from a Machine name, Team slug, hostname or operator account. If -preset provenance must appear in `lazurio.machine.json`, that is an **upstream schema -change** in the hosting engine followed by a re-pin and conformance test here -([machine handover](machine-handover.md)). Until then the preset reference lives only -in the Environment configuration and is chosen explicitly at setup. +The handover has no selected-preset field and needs none: the preset is derived from +`machine.kind` and `owner.team`, and an explicit choice is recorded locally. A persona +(upstream decision 0156) is not carried by the handover or the preset and is not +rendered. If persona or relationships must appear in `lazurio.machine.json`, that is +an **upstream schema change** in Machines followed by a re-pin and conformance test +here; the renderer already renders a relationships section only when the recorded +binding carries one. ## Acceptance before a preset is offered -- `hosted-private`: the private canary journey in - [acceptance](acceptance.md#nearest-pilot-sequence). -- `hosted-team`: shared use by several Principals, conflict handling, attribution of - every change to the Team through the brokered identity, and revocation on GitHub - blocking the next provider operation. Live Team-grant verification in the broker is - an external dependency under upstream decisions 0147 and 0149. +- `hosted-personal`: the private canary journey in + [acceptance](acceptance.md#nearest-pilot-sequence) on a real personal VM. +- `hosted-organization-personal`: the same journey on a real work VM. +- `hosted-organization-team`: shared use by several Principals, conflict handling, + attribution of every change to the Team through the brokered identity, and + revocation on GitHub blocking the next provider operation. Live Team-grant + verification in the broker is an external dependency under upstream decisions 0147 + and 0149. + +Unit tests prove derivation, the allow-list, whole-composition validation, adoption +(non-empty work directories, legacy files, foreign entries, idempotence, the +Personalspace conflict), conformance of both handover branches, the rendered document +per preset and language, and the Launchpad flow. They do not prove a native run on a +real personal or work VM. From 41f04611f0cbe4791d7f303e90f2ffeecedfbf8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 22 Sep 2026 12:02:06 +0200 Subject: [PATCH 7/7] Machine CLI: name a group-writable Folder directory and treat wrong options as usage errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the native Linux ARM64 run of 2026-09-22 (docs/evidence). Ubuntu's default umask 0002 leaves a hand-created ~/Lazurio and its children 0775; the ownership rule refused correctly but reported only the generic "Folder operation failed" with exit 1. folder-init now refuses before any mutation with {"kind":"blocked","reason":"folder-directory-shared","entry":…} and exit 2 for the Folder root, organizations/ and personalspace/, and the resident precondition (mode 0755 or 0700) is documented. An unknown or malformed option is a MachineUsageError: the machine help on stderr and exit 2, not a failed Folder operation. The evidence file records the setting, the verbatim log and both defects as found-and-fixed. Co-Authored-By: Claude Fable 5.1 --- .../presets-linux-arm64-2026-09-22.md | 127 ++++++++++++++++++ docs/machine-handover.md | 27 +++- docs/workspace-presets.md | 6 +- src/cli.ts | 20 ++- src/folder/handover-layout.ts | 29 +++- src/machine/cli.ts | 93 +++++++++---- tests/folder-handover.test.ts | 57 ++++++-- tests/machine-context.test.ts | 36 ++++- 8 files changed, 334 insertions(+), 61 deletions(-) create mode 100644 docs/evidence/presets-linux-arm64-2026-09-22.md diff --git a/docs/evidence/presets-linux-arm64-2026-09-22.md b/docs/evidence/presets-linux-arm64-2026-09-22.md new file mode 100644 index 00000000..db2508be --- /dev/null +++ b/docs/evidence/presets-linux-arm64-2026-09-22.md @@ -0,0 +1,127 @@ +# Workspace presets: native run on Linux ARM64 (2026-09-22) + +Setting: fresh Tart clone of Ubuntu 24.04.4 (systemd 255), Linux ARM64. CLI compiled +from source commit `6481a4c62d7df2bd3705bbfdd1e24254621d2321` with +`scripts/build-candidate.ts --target linux-arm64` (development identity, fixture +build; not a release). Handover fixtures written by root to +`/etc/lazurio/lazurio.machine.json`, `root:root` mode `0644`, one per scenario, with +`operator.os_user: admin`; the CLI run as `admin` under umask `077`. Nothing below is +private: the handovers are the synthetic conformance fixtures of this repository +(`example`, `sample-team`, `Example/infra`). + +Scenarios and outcome (all as expected): derived presets for the three handover +kinds; explicit allowed override (`--preset hosted-organization-team --locale cs +--detail technical`) recorded as `explicit`; adoption of a non-empty +`organizations/` and `personalspace/` with `launchpad.gen3.json` present; re-run +reports `already-adopted` with `AGENTS.md` byte-identical and `personalspace/` +untouched; `preset-not-allowed` for an Organization preset on a personal VM; +`folder-foreign-entry` naming the entry; `folder-personalspace-conflict` with nothing +deleted. + +Two defects found on the way and fixed in the same PR, after this log was taken: + +1. With Ubuntu's default umask `0002` a hand-created `~/Lazurio` (and its + children) is `0775`; `folder-init` refused correctly but reported only the generic + "Folder operation failed…" with exit 1. It now refuses before any mutation with + `{"kind":"blocked","reason":"folder-directory-shared","entry":…}` and exit 2, and + the resident precondition is documented in `docs/machine-handover.md`. +2. An unknown option (`machine folder-init --json`) ended in the same generic + message. It is now a usage error: the `machine` help on stderr and exit 2. + +Not proven by this run: a real Machines-delivered VM (the handovers were fixtures +written by hand as root), a native Launchpad preset change, and the fixed reporting +above on the VM (covered by unit tests on the head that fixes it). + +## Verbatim log + +```text + +== A. personal-vm handover, personalspace with content, launchpad.gen3.json present +{"kind":"machine-context-observed","context":{"schema_version":"lazurio.machine.v1","machine":{"id":"example-personal","kind":"personal-vm","name":"example","vmid":901},"owner":{"kind":"principal","github_login":"example","github_id":12345},"operator":{"os_user":"admin","home":"/home/admin","lazurio +{"kind":"initialized","revision":1,"preset":{"name":"hosted-personal","version":1,"selection":"derived"},"machineContextDigest":"a8e5817d69ef8724c66dfd1cdeba58b35cae0c5b3d40441d3c66f1833be3529e"} +exit=0 +--- top level: +.lazurio +AGENTS.md +launchpad.gen3.json +organizations +personalspace +--- AGENTS.md head: +# Lazurio + +## This Machine +- Preset: `hosted-personal` (version 1). +- Machine: `example` (personal-vm). +- Owner: the Principal with GitHub login `example` (id 12345). This is their one personal hosted Machine. +- Principal: the Machine's Owner. Agents here act for them within their rights; a Buddy is an optional resident of this same Machine. +- Tailnet: Headscale node `example`. +- Host: provider-estate `example-estate`; a higher administration and recovery domain than this Machine. +## Boundaries +- Personalspace: `personalspace/` is the intimate space of exactly one Principal and their optional Buddy. Nobody else reads it and it is never shared. +- Organizations: no Organization repositories are mounted on a personal Machine. Organization work happens on Machines the Organization owns. +- Identity: the Principal's own sign-ins; GitHub is the only access authority. +## How work is done here +- Communicate in English unless the user requests otherwise. +- Lead with the outcome and explain concisely. +- Work directly within task scope and available tools. +- Your work is a Draft in a worktree and a pull request; Publication (merge, deploy, send) belongs to the Principal and needs their explicit instruction in the current thread. +- Before Organization work, load its current AGENTS.md; the overarching rules are the Lazurio root (`HumanAndMachines/Lazurio`, AGENTS.md). This document does not replace them. +- Verify the execution Machine's OS (linux); remote access changes neither identity nor permissions. Verify live identity and rights for connected operations; a local checkout is not proof of permission. +- A profile grants no access, publication mandate or background-work authority. Do not read another Principal's Personalspace or copy credentials. +- Preserve existing Organizations and Personalspace paths and content. Profile language neither renames folders nor translates user data. +- Report missing tools, unverified rights and unknown state; do not invent available capabilities or successful completion. +--- .lazurio: +history +instructions.json +preferences.json +{"schemaVersion":2,"revision":1,"preset":{"name":"hosted-personal","version":1,"selection":"derived"},"machine":{"contextDigest":"a8e5817d69ef8724c66dfd1cdeba58b35cae0c5b3d40441d3c66f1833be3529e","kind":"personal-vm","name":"example","owner":{"kind":"principal","githubLogin":"example","githubId":12345},"network":{"headscaleHostname":"example"},"host":{"kind":"provider-estate","id":"example-estate"}},"profile":{"os":"linux","access":"remote","purpose":"human","locale":"en","detail":"concise","coordination":"direct"},"customInstructions":""} +== A2. re-run: already-adopted, nothing changed +{"kind":"already-adopted","revision":1,"preset":{"name":"hosted-personal","version":1,"selection":"derived"},"machineContextDigest":"a8e5817d69ef8724c66dfd1cdeba58b35cae0c5b3d40441d3c66f1833be3529e"} +exit=0 +AGENTS.md unchanged +notes.md + +== A3. explicit organization preset on a personal-vm handover must be refused +{"kind":"blocked","reason":"preset-not-allowed","derived":"hosted-personal","allowed":["hosted-personal"],"next":"Choose a preset the handover allows, or omit --preset for the derived one."} +exit=2 + +== A4. foreign top-level entry refused by name +{"kind":"blocked","reason":"folder-foreign-entry","entry":"stray.txt","next":"Move this entry out of the Folder; only organizations/, personalspace/ and the two legacy launchpad files may be present."} +exit=2 + +== B. workspace-vm with team → hosted-organization-team; empty personalspace precreated +{"kind":"initialized","revision":1,"preset":{"name":"hosted-organization-team","version":1,"selection":"derived"},"machineContextDigest":"d0d020cec48833df53f17eb75f97e9e7b3a14e4a74f29f6dda58682aada87791"} +exit=0 + +- Preset: `hosted-organization-team` (version 1). +- Owner: Organization `example`, Team `sample-team`. +"name":"hosted-organization-team" +"name":"workspace" + +== B2. used personalspace on an organization preset → refused, nothing deleted +{"kind":"blocked","reason":"folder-personalspace-conflict","entry":"personalspace","next":"An Organization preset never has a Personalspace; move it away yourself, nothing is deleted."} +exit=2 +keep.md + +== C. workspace-vm without team → hosted-organization-personal; explicit team preset allowed +{"kind":"initialized","revision":1,"preset":{"name":"hosted-organization-personal","version":1,"selection":"derived"},"machineContextDigest":"dfcf8e659f8d250e0cdc0b0d73d59ff017a6bf40d0015d54183d4efcc5473932"} +exit=0 +"name":"hosted-organization-personal" +{"kind":"initialized","revision":1,"preset":{"name":"hosted-organization-team","version":1,"selection":"explicit"},"machineContextDigest":"dfcf8e659f8d250e0cdc0b0d73d59ff017a6bf40d0015d54183d4efcc5473932"} +exit=0 +# Lazurio + +## Tahle Mašina +- Preset: `hosted-organization-team` (verze 1). +- Mašina: `workspace` (workspace-vm). +- Owner: Organizace `example`. +- Principál: Kolega, který se právě připojil. OS účet je sdílený členy Teamu a není osoba; změny se připisují Teamu přes brokerovanou identitu Organizace. +- Tailnet: Headscale node `example-workspace`. +- Host: virtualization-host `example-host`; vyšší doména správy a obnovy než tahle Mašina. +## Hranice +- Personalspace: na Mašině vlastněné Organizací nikdy není. Nezakládej ho, nemountuj ho a nekopíruj sem osobní data ani přihlášení. +- Organizace: repozitáře žijí v `organizations//`; každá Organizace je vlastní access hranice a vlastní git repozitář. + +== D. --version +lazurio 0.0.0-development (commit 0000000000000000000000000000000000000000, target linux-arm64) +``` diff --git a/docs/machine-handover.md b/docs/machine-handover.md index 0ffcb614..d38cc683 100644 --- a/docs/machine-handover.md +++ b/docs/machine-handover.md @@ -84,12 +84,24 @@ on first use; `{"kind":"already-adopted","revision":,"preset":{…},"machineC on a re-run, which changes nothing; or exit status 2 with `{"kind":"blocked","reason":…,"entry":…,"next":…}` where `reason` is one of `folder-foreign-entry`, `folder-layout-missing`, `folder-personalspace-conflict`, -`folder-state-unrecognized`, `folder-binding-changed`, `preset-not-allowed` or a -Machine context code. Optional `--preset ` picks another preset the handover +`folder-state-unrecognized`, `folder-binding-changed`, `folder-directory-shared`, +`preset-not-allowed` or a Machine context code. Optional `--preset ` picks another preset the handover allows (recorded as an explicit choice); optional `--locale`, `--detail` and `--coordination` override the preset's defaults and stay changeable in the Launchpad. `lazurio machine inspect` prints the validated handover and its digest. +A wrong invocation (unknown option, duplicate or invalid choice, unknown preset) +prints the `machine` help on stderr and exits 2 before any filesystem access. + +**Precondition for the Machines resident role:** `~/Lazurio`, `organizations/` and +`personalspace/` must be owned by the operator and not group- or world-writable +(create them with mode `0755` or `0700`, as `workspace_baseline` does). Ubuntu's +default umask `0002` makes a directory created by hand `0775`; `folder-init` then +refuses before any mutation with `folder-directory-shared` naming the entry +(`.`, `organizations` or `personalspace`), and `chmod g-w,o-w` on that path +fixes it. Found on the native run of 2026-09-22 +([evidence](evidence/presets-linux-arm64-2026-09-22.md)). + It binds the declared user/home to the actual UID's Linux NSS record (not `$USER`/`$HOME`) and requires `operator.lazurio_root` to be that user's `/home//Lazurio`. The wire name remains `lazurio_root`; the product concept is @@ -175,9 +187,12 @@ it does not qualify either architecture. See [Bun's executable targets](https:// Unit fixtures prove parsing/refusal of both handover branches, preset derivation, adoption (used work directories, legacy files, foreign entries, idempotence, the Personalspace conflict), directory preservation and recognized interruption -completion. They do not prove actual Machines delivery, a native run of -`folder-init` on a real personal or work VM, official release hosting and -attestation, native Linux x64 execution, Organization/module authorization, gateway -operation, agent work, VM restart or the second-VM repeat. Record those separately +completion. A native run of `folder-init` with the compiled CLI on a fresh Ubuntu +24.04 ARM64 VM against root-issued fixture handovers of all three kinds is recorded +in [evidence](evidence/presets-linux-arm64-2026-09-22.md). They do not prove actual +Machines delivery, a real Machines-delivered VM, a native Launchpad preset change, +official release hosting and attestation, native Linux x64 execution, +Organization/module authorization, gateway operation, agent work, VM restart or the +second-VM repeat. Record those separately at exact source/artifact revisions. The real pilot must exercise the installed binary and root-issued file under the non-root operator account. diff --git a/docs/workspace-presets.md b/docs/workspace-presets.md index 3dcbb1b5..855e957a 100644 --- a/docs/workspace-presets.md +++ b/docs/workspace-presets.md @@ -174,5 +174,7 @@ binding carries one. Unit tests prove derivation, the allow-list, whole-composition validation, adoption (non-empty work directories, legacy files, foreign entries, idempotence, the Personalspace conflict), conformance of both handover branches, the rendered document -per preset and language, and the Launchpad flow. They do not prove a native run on a -real personal or work VM. +per preset and language, and the Launchpad flow. A native run of `folder-init` on a +fresh Ubuntu 24.04 ARM64 VM with fixture handovers of all three kinds is recorded in +[evidence](evidence/presets-linux-arm64-2026-09-22.md); a real Machines-delivered VM +and a native Launchpad preset change are not proven. diff --git a/src/cli.ts b/src/cli.ts index 89cc0da4..e46b9114 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,11 @@ import { requestApplication, } from "./launchpad/application-client"; import { startLaunchpad } from "./launchpad/server"; -import { machineHelp, runMachineCommand } from "./machine/cli"; +import { + MachineUsageError, + machineHelp, + runMachineCommand, +} from "./machine/cli"; import { createApplicationCoordination } from "./modules/application-coordination"; import { type ApplicationRunner, @@ -140,9 +144,17 @@ export async function runCli(args: string[]): Promise { async function runOtherCommand(args: string[]): Promise { if (args[0] === "machine") { - const { code, result } = await runMachineCommand(args.slice(1)); - console.log(JSON.stringify(result)); - return code; + try { + const { code, result } = await runMachineCommand(args.slice(1)); + console.log(JSON.stringify(result)); + return code; + } catch (error) { + // A wrong invocation is a usage error with the help text, not a failed + // Folder operation; nothing was read or written. + if (!(error instanceof MachineUsageError)) throw error; + console.error(`${error.message}\n${machineHelp}`); + return 2; + } } if (args[0] === "legacy-paths-inspect") { const { values, tokens } = parseArgs({ diff --git a/src/folder/handover-layout.ts b/src/folder/handover-layout.ts index f5ed11dd..378ca351 100644 --- a/src/folder/handover-layout.ts +++ b/src/folder/handover-layout.ts @@ -32,7 +32,8 @@ export class FolderAdoptionError extends Error { | "layout-missing" | "personalspace-conflict" | "state-unrecognized" - | "binding-changed", + | "binding-changed" + | "directory-shared", public readonly entry: string, ) { super(`${code}: ${entry}`); @@ -83,21 +84,35 @@ async function exists(path: string) { } } -async function ownedIdentity(path: string): Promise { - const stat = await inspectOwnedDirectory(path); - return { dev: String(stat.dev), ino: String(stat.ino) }; +// A caller-owned directory that is group- or world-writable (Ubuntu's default +// umask 0002 creates such directories) is refused by name: the ownership rule +// stands, the operator learns which path to fix. Symlinks and foreign owners +// keep the generic refusal of inspectOwnedDirectory. +async function ownedIdentity(path: string, entry: string): Promise { + const stat = await lstat(path); + if ( + stat.isDirectory() && + stat.uid === process.getuid?.() && + (stat.mode & 0o022) !== 0 + ) + throw new FolderAdoptionError("directory-shared", entry); + const owned = await inspectOwnedDirectory(path); + return { dev: String(owned.dev), ino: String(owned.ino) }; } // Existence and identity only; the work directories are never listed. export async function inspectHandoverLayout( folder: string, ): Promise { - const root = await ownedIdentity(folder); + const root = await ownedIdentity(folder, "."); if (!(await exists(join(folder, "organizations")))) throw new FolderAdoptionError("layout-missing", "organizations"); - const organizations = await ownedIdentity(join(folder, "organizations")); + const organizations = await ownedIdentity( + join(folder, "organizations"), + "organizations", + ); const personalspace = (await exists(join(folder, "personalspace"))) - ? await ownedIdentity(join(folder, "personalspace")) + ? await ownedIdentity(join(folder, "personalspace"), "personalspace") : undefined; if ( organizations.dev !== root.dev || diff --git a/src/machine/cli.ts b/src/machine/cli.ts index 28f8b365..2d8e75ca 100644 --- a/src/machine/cli.ts +++ b/src/machine/cli.ts @@ -42,35 +42,76 @@ const choices = { coordination: ["direct", "coordinator"], } as const; -export async function runMachineCommand(args: string[]) { +// A wrong invocation, before any filesystem access: the CLI prints the help +// text and exits 2. It is not a failed Folder operation. +export class MachineUsageError extends Error {} + +const machineOptions = { + preset: { type: "string" }, + locale: { type: "string" }, + detail: { type: "string" }, + coordination: { type: "string" }, +} as const; + +function parseMachineTokens(args: string[]) { + try { + return parseArgs({ + args, + strict: true, + tokens: true, + options: machineOptions, + }); + } catch { + throw new MachineUsageError("Unknown or malformed Machine option"); + } +} + +function parseMachineArguments(args: string[]) { const [command, ...options] = args; if (command !== "inspect" && command !== "folder-init") - throw new Error("Unknown Machine command"); - const { values, tokens } = parseArgs({ - args: options, - strict: true, - tokens: true, - options: { - preset: { type: "string" }, - locale: { type: "string" }, - detail: { type: "string" }, - coordination: { type: "string" }, - }, - }); + throw new MachineUsageError("Unknown Machine command"); + const parsed = parseMachineTokens(options); + const { values, tokens } = parsed; if ( (command === "inspect" && tokens.length !== 0) || tokens.some((token) => token.kind !== "option") || new Set(tokens.map((token) => (token.kind === "option" ? token.name : ""))) .size !== tokens.length ) - throw new Error("Explicit nonduplicate Machine options required"); - for (const [name, allowed] of Object.entries(choices)) { - const value = values[name as keyof typeof choices]; - if (value !== undefined && !(allowed as readonly string[]).includes(value)) - throw new Error(`Invalid Machine choice: ${name}`); + throw new MachineUsageError( + "Explicit nonduplicate Machine options required", + ); + const value = (name: keyof typeof choices) => { + const chosen = values[name]; + if ( + chosen !== undefined && + !(choices[name] as readonly string[]).includes(chosen) + ) + throw new MachineUsageError(`Invalid Machine choice: ${name}`); + return chosen; + }; + let preset: ReturnType | undefined; + try { + preset = + values.preset === undefined ? undefined : parsePresetName(values.preset); + } catch { + throw new MachineUsageError("Unknown workspace preset"); } - const requestedPreset = - values.preset === undefined ? undefined : parsePresetName(values.preset); + return { + command, + preset, + locale: value("locale") as "cs" | "en" | undefined, + detail: value("detail") as "concise" | "technical" | undefined, + coordination: value("coordination") as "direct" | "coordinator" | undefined, + }; +} + +export async function runMachineCommand(args: string[]) { + const { + command, + preset: requestedPreset, + ...values + } = parseMachineArguments(args); try { const observed = await readMachineContext(); if (command === "inspect") @@ -96,15 +137,11 @@ export async function runMachineCommand(args: string[]) { }, }; const profile = presetProfile(preset, "linux", { - ...(values.locale === undefined - ? {} - : { locale: values.locale as "cs" | "en" }), - ...(values.detail === undefined - ? {} - : { detail: values.detail as "concise" | "technical" }), + ...(values.locale === undefined ? {} : { locale: values.locale }), + ...(values.detail === undefined ? {} : { detail: values.detail }), ...(values.coordination === undefined ? {} - : { coordination: values.coordination as "direct" | "coordinator" }), + : { coordination: values.coordination }), }); const folder = bindMachineOperator( observed.context, @@ -148,6 +185,8 @@ export function describeFolderAdoption(error: FolderAdoptionError) { "Complete a recognized initialization with folder-resume or diagnose the state; nothing is reset.", "binding-changed": "The Folder was adopted from a different handover; verify the Machine identity with the Machines operator.", + "directory-shared": + "chmod g-w,o-w this path under the Lazurio Folder (Ubuntu's default umask 0002 makes new directories group-writable); nothing was changed.", } as const; return { kind: "blocked" as const, diff --git a/tests/folder-handover.test.ts b/tests/folder-handover.test.ts index ea3cd885..c7b657e4 100644 --- a/tests/folder-handover.test.ts +++ b/tests/folder-handover.test.ts @@ -352,22 +352,55 @@ for (const stop of [ ); } -for (const scenario of ["symlink", "writable"] as const) +test.skipIf(process.platform === "win32")( + "handover refuses a symlinked work directory before creating state", + async () => { + const { parent, folder } = await setup(); + try { + await rename(join(folder, "organizations"), join(parent, "saved")); + await symlink(join(parent, "saved"), join(folder, "organizations")); + const before = await readdir(folder); + await expect( + initializeHandoverFolder(folder, sources.personal), + ).rejects.toThrow(); + expect(await readdir(folder)).toEqual(before); + await expect(lstat(join(folder, ".lazurio"))).rejects.toThrow(); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }, +); + +// Ubuntu's default umask 0002 leaves ~/Lazurio and its children 0775. +for (const [entry, mode] of [ + [".", 0o775], + ["organizations", 0o775], + ["personalspace", 0o777], +] as const) test.skipIf(process.platform === "win32")( - `handover refuses a ${scenario} work directory before creating state`, + `a group- or world-writable ${entry} is refused by name before creating state`, async () => { const { parent, folder } = await setup(); try { - if (scenario === "symlink") { - await rename(join(folder, "organizations"), join(parent, "saved")); - await symlink(join(parent, "saved"), join(folder, "organizations")); - } else await chmod(join(folder, "organizations"), 0o777); - const before = await readdir(folder); - await expect( - initializeHandoverFolder(folder, sources.personal), - ).rejects.toThrow(); - expect(await readdir(folder)).toEqual(before); - await expect(lstat(join(folder, ".lazurio"))).rejects.toThrow(); + await chmod(join(folder, entry), mode); + const before = await snapshot(folder); + expect( + await refusal(initializeHandoverFolder(folder, sources.personal)), + ).toEqual({ code: "directory-shared", entry }); + expect(await snapshot(folder)).toEqual(before); + expect( + describeFolderAdoption( + new FolderAdoptionError("directory-shared", entry), + ), + ).toMatchObject({ + kind: "blocked", + reason: "folder-directory-shared", + entry, + }); + await chmod(join(folder, entry), 0o700); + expect( + await initializeHandoverFolder(folder, sources.personal), + ).toEqual(initialized("personal")); } finally { await rm(parent, { recursive: true, force: true }); } diff --git a/tests/machine-context.test.ts b/tests/machine-context.test.ts index 4ef78980..f5308481 100644 --- a/tests/machine-context.test.ts +++ b/tests/machine-context.test.ts @@ -12,7 +12,8 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runMachineCommand } from "../src/machine/cli"; +import { runCli } from "../src/cli"; +import { MachineUsageError, runMachineCommand } from "../src/machine/cli"; import { bindMachineOperator, parseMachineContext, @@ -186,7 +187,7 @@ test("runtime operator binding is independent of schema validity", () => { "machine-operator-mismatch", ); }); -test("machine CLI refuses overrides, unknown presets and duplicate or invalid choices before filesystem access", async () => { +test("machine CLI refuses overrides, unknown presets and duplicate or invalid choices as usage errors before filesystem access", async () => { for (const args of [ ["inspect", "--file", "/tmp/identity"], ["inspect", "--locale", "cs"], @@ -195,9 +196,38 @@ test("machine CLI refuses overrides, unknown presets and duplicate or invalid ch ["folder-init", "--preset", "hosted-private"], ["folder-init", "--preset", "hosted-team"], ["folder-init", "--folder", "/tmp/target"], + ["folder-init", "--json"], ["folder-init", "extra"], + ["reset"], ]) - await expect(runMachineCommand(args)).rejects.toThrow(); + await expect(runMachineCommand(args)).rejects.toBeInstanceOf( + MachineUsageError, + ); +}); +test("a wrong machine invocation prints the help on stderr with exit 2, not a failed Folder operation", async () => { + const child = Bun.spawn( + [process.execPath, "src/cli.ts", "machine", "folder-init", "--json"], + { env: {}, stdout: "pipe", stderr: "pipe" }, + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(code).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("machine folder-init [--preset ]"); + expect(stderr).not.toContain("Folder operation failed"); + // The in-process entry agrees and stays typed. + const original = console.error; + const lines: string[] = []; + console.error = (line: string) => void lines.push(line); + try { + expect(await runCli(["machine", "inspect", "--json"])).toBe(2); + } finally { + console.error = original; + } + expect(lines[0]).toContain("machine inspect"); }); test.skipIf(process.platform === "linux")( "production consumer does not invent a context on another OS",