Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/design/artifact-harness-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Agentwheel also accepts `transport: "local-run-as"` on a named Linux agent with
required `runAs` account. It resolves `root: "home"` to that account's passwd home
and reads runtime files and install manifests through a path-checked, service-user
worker. In the first release this transport is **read-only**: plans, status, and
ownership recovery reports work; installs, uninstalls, journal recovery, and
ownership recovery reports and legacy adoption previews work; installs, uninstalls, journal recovery, and
other target mutations fail before writes. An absolute target root must be owned
by the account and equal its home for home-rooted mappings. SSH and ordinary
local transports retain their existing layouts.
Expand All @@ -89,6 +89,12 @@ operator ALL=(service) NOPASSWD: AGENTWHEEL_RUN_AS_ID, AGENTWHEEL_RUN_AS_READ
`ownership recovery-plan` requires `--fleet` for registered Fleet targets;
it may omit `--fleet` for a nested workspace outside any Fleet.

Legacy adoption previews read runtime claims and full ownership snapshots through
the worker. Their digest includes the run-as endpoint identity. Workspace source
locks remain caller-side. Adoption apply remains disabled, and previews do not
advertise an executable apply command for this transport. This does not enable
fleet normalization or ownership migration.

| Harness | Artifact | `local` | `user` | Notes |
|---|---|---|---|---|
| Codex | `instructions` | `supported-native`: `AGENTS.md` managed block | `supported-native`: `~/.codex/AGENTS.md` managed block | Project/user custom instructions. |
Expand Down
11 changes: 7 additions & 4 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,7 +1313,7 @@ ownershipCommand
throw new Error("Legacy ownership adoption targets a nested workspace; a Fleet target uses fleet normalize or ownership retire-stale.");
}
if (target.transport === "ssh") throw new Error("Legacy ownership adoption does not support SSH targets.");
if (target.transport === "local-run-as") throw new Error("Legacy ownership adoption cannot mutate local-run-as targets in PR1.");
if (target.transport === "local-run-as" && options.apply) throw new Error("Legacy ownership adoption cannot mutate local-run-as targets in PR1.");
const config = await readMergedWorkspaceConfig(target.workspaceRoot);
const groups = new Map<string, PackageGraphGroup>();
for (const pkg of packagesForCliTarget(config.packages, target, plainInstallOptions)) graphGroupForPackage(groups, target, pkg, plainInstallOptions).packages.push(pkg);
Expand Down Expand Up @@ -1386,6 +1386,7 @@ ownershipCommand
? await applyAdoptLegacyOwnership(request)
: await planAdoptLegacyOwnership(request);
const destinationRevision = result.destination.revision ?? "absent";
const applySupported = target.transport !== "local-run-as";
const applyArgs = [
"agentwheel", "ownership", "adopt-legacy",
"--source-state-key", result.source.stateKey,
Expand All @@ -1402,9 +1403,9 @@ ownershipCommand
"--expected-inventory-revision", result.manifestInventoryRevision,
"--apply",
];
const applyCommand = applyArgs.map(shellQuoteArgument).join(" ");
const applyCommand = applySupported ? applyArgs.map(shellQuoteArgument).join(" ") : null;
if (options.json) {
console.log(JSON.stringify({ ...result, applyCommand }, null, 2));
console.log(JSON.stringify({ ...result, ...(applySupported ? {} : { applySupported }), applyCommand }, null, 2));
return;
}
console.log(`${options.apply ? "Adopted legacy ownership" : "Legacy ownership adoption plan"}: ${result.selected.length} entr${result.selected.length === 1 ? "y" : "ies"}`);
Expand All @@ -1420,7 +1421,9 @@ ownershipCommand
for (const entry of result.retained) console.log(` retained ${entry.path} (${entry.reason}, ${entry.owner})`);
for (const entry of result.remainingDuplicates) console.log(` duplicate claim remains in ${entry.stateKey}: ${entry.path}`);
console.log(`Plan digest: ${result.planDigest}`);
if (!options.apply) console.log(`Apply from ${target.workspaceRoot}: ${applyCommand}`);
if (!options.apply) console.log(applySupported
? `Apply from ${target.workspaceRoot}: ${applyCommand}`
: "Read-only plan: local-run-as ownership adoption is not yet enabled.");
});

const mcpCommand = program
Expand Down
22 changes: 2 additions & 20 deletions src/install/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,26 +100,8 @@ export async function assertNoSymlinkPath(path: string): Promise<void> {
}
}

/** Includes ignored/cache files too: a reviewed release must detect every runtime byte change. */
export async function ownershipRuntimeSnapshot(path: string): Promise<string> {
await assertNoSymlinkPath(path);
const hash = createHash("sha256");
async function visit(candidate: string): Promise<void> {
const info = await lstat(candidate);
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
throw new Error(`Unsupported ownership runtime path: ${candidate}`);
}
hash.update(JSON.stringify([relative(path, candidate), info.isDirectory() ? "dir" : "file", info.mode & 0o7777]));
if (info.isDirectory()) {
for (const name of (await readdir(candidate)).sort()) await visit(join(candidate, name));
} else {
const content = await readFile(candidate);
hash.update(String(content.length)).update("\0").update(content);
}
}
await visit(path);
return hash.digest("hex");
}
export { ownershipRuntimeSnapshot } from "../transport/ownership-snapshot.js";
import { ownershipRuntimeSnapshot } from "../transport/ownership-snapshot.js";

export function metadataAuditPath(audit: MetadataAuditTransaction, path: string): string {
mutationOperationIdSchema.parse(audit.operationId);
Expand Down
12 changes: 7 additions & 5 deletions src/lifecycle/ownership-adopt-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,6 @@ async function observe(request: AdoptLegacyOwnershipRequest, transport: TargetTr
if (transport.kind === "ssh") {
throw new Error("Legacy ownership adoption does not support SSH targets; legacy SSH state does not prove its endpoint.");
}
if (transport.kind === "local-run-as") {
throw new Error("local-run-as is read-only in PR1; legacy ownership adoption is not supported.");
}
const input = await normalizeRequest(request);
const { targetRoot, adapter, installationType } = input;
await assertNoPendingJournals(targetRoot, adapter, transport);
Expand Down Expand Up @@ -291,6 +288,9 @@ async function observe(request: AdoptLegacyOwnershipRequest, transport: TargetTr
const selectedPaths = new Set(selectedEntries.map((entry) => entry.path));
const hasManagedBlock = selectedEntries.some((entry) => entry.mode === "managed-block");
if (hasManagedBlock && request.carryDrift) throw new Error("--carry-drift is forbidden for managed instruction block adoption.");
if (hasManagedBlock && transport.kind !== "local" && !transport.ownershipSnapshot) {
throw new Error("Legacy ownership planning requires a service-user runtime snapshot for managed blocks.");
}
const selected: AdoptLegacyOwnershipEntry[] = [];
for (const entry of selectedEntries) {
const runtimePath = containedArtifactPath(targetRoot, entry.path);
Expand All @@ -301,7 +301,7 @@ async function observe(request: AdoptLegacyOwnershipRequest, transport: TargetTr
if (managedBlock && managedBlock.hash !== entry.hash) {
throw new Error(`Managed instruction block hash differs from its recorded manifest hash at ${entry.path}; adoption is refused.`);
}
const runtimeSnapshotDigest = hasManagedBlock ? await ownershipRuntimeSnapshot(runtimePath) : undefined;
const runtimeSnapshotDigest = hasManagedBlock ? (transport.kind === "local" ? await ownershipRuntimeSnapshot(runtimePath) : await transport.ownershipSnapshot!(runtimePath)) : undefined;
const runtimeHash = await transport.pathExists(runtimePath) ? await transport.hashPath(runtimePath) : null;
const drift = managedBlock ? false : runtimeHash !== entry.hash;
if (drift && request.carryDrift !== true) {
Expand Down Expand Up @@ -337,7 +337,8 @@ async function observe(request: AdoptLegacyOwnershipRequest, transport: TargetTr
const guardedInventory = hasManagedBlock ? await listInstallManifests(targetRoot, undefined, transport) : undefined;
if (guardedInventory) {
for (const item of guardedInventory) {
await assertNoSymlinkPath(item.path);
// Run-as manifest reads already reject symlink components inside the worker.
if (transport.kind === "local") await assertNoSymlinkPath(item.path);
for (const other of item.manifest.entries) {
const otherPath = containedArtifactPath(targetRoot, other.path);
for (const entry of selectedEntries) {
Expand Down Expand Up @@ -373,6 +374,7 @@ async function observe(request: AdoptLegacyOwnershipRequest, transport: TargetTr
}

const withoutDigest = {
...(transport.kind === "local-run-as" ? { transport: { kind: transport.kind, description: transport.description } } : {}),
adapter,
installationType,
targetRoot,
Expand Down
35 changes: 35 additions & 0 deletions src/transport/ownership-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createHash } from "node:crypto";
import { lstat, readdir, readFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";

async function assertSnapshotAncestors(path: string): Promise<void> {
let current = resolve(path);
while (true) {
if ((await lstat(current)).isSymbolicLink()) throw new Error(`Symlink path is unsupported: ${current}`);
const parent = dirname(current);
if (parent === current) return;
current = parent;
}
}

/** Includes ignored/cache files too: a reviewed release must detect every runtime byte change. */
export async function ownershipRuntimeSnapshot(path: string): Promise<string> {
await assertSnapshotAncestors(path);
const hash = createHash("sha256");
async function visit(candidate: string): Promise<void> {
const info = await lstat(candidate);
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
throw new Error(`Unsupported ownership runtime path: ${candidate}`);
}
hash.update(JSON.stringify([relative(path, candidate), info.isDirectory() ? "dir" : "file", info.mode & 0o7777]));
if (info.isDirectory()) {
for (const name of (await readdir(candidate)).sort()) await visit(join(candidate, name));
} else {
const content = await readFile(candidate);
hash.update(String(content.length)).update("\0").update(content);
}
}
await visit(path);
return hash.digest("hex");
}

6 changes: 4 additions & 2 deletions src/transport/run-as-worker.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import { ownershipRuntimeSnapshot } from "./ownership-snapshot.js";
import { hashPath } from "../utils/fs.js";

export type ReadOperation = "pathExists" | "hashPath" | "readFile" | "listDir";
export type ReadOperation = "pathExists" | "hashPath" | "readFile" | "listDir" | "ownershipSnapshot";
export interface ReadRequest { version: 1; operation: ReadOperation; path: string }

/** Resolve a relative target path without following symlink components. */
Expand Down Expand Up @@ -41,7 +42,7 @@ async function rejectNestedLinks(path: string): Promise<void> {
}

export async function executeRead(root: string, request: ReadRequest): Promise<unknown> {
if (request.version !== 1 || !["pathExists", "hashPath", "readFile", "listDir"].includes(request.operation)) {
if (request.version !== 1 || !["pathExists", "hashPath", "readFile", "listDir", "ownershipSnapshot"].includes(request.operation)) {
throw new Error("Unsupported run-as read protocol request.");
}
const path = await containedReadPath(root, request.path);
Expand All @@ -54,6 +55,7 @@ export async function executeRead(root: string, request: ReadRequest): Promise<u
if (request.operation === "pathExists") return exists;
if (request.operation === "listDir" && !exists) return [];
if (!exists) throw new Error("Run-as target path does not exist.");
if (request.operation === "ownershipSnapshot") return ownershipRuntimeSnapshot(path);
if (request.operation === "hashPath") {
await rejectNestedLinks(path);
return hashPath(path);
Expand Down
1 change: 1 addition & 0 deletions src/transport/run-as.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export function createRunAsTransport(identity: RunAsIdentity, runner: RunAsRunne
pathExists: async (path) => Boolean(await run("pathExists", path)),
hashPath: async (path) => String(await run("hashPath", path)),
readFile: async (path) => String(await run("readFile", path)),
ownershipSnapshot: async (path) => String(await run("ownershipSnapshot", path)),
listDir: async (path) => (await run("listDir", path)) as string[],
mkdirExclusive: async () => { throw new Error(readonlyError); },
writeFileAtomic: async () => { throw new Error(readonlyError); },
Expand Down
1 change: 1 addition & 0 deletions src/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface TargetTransport {
mkdirExclusive(path: string): Promise<void>;
hashPath(path: string): Promise<string>;
readFile(path: string): Promise<string>;
ownershipSnapshot?(path: string): Promise<string>;
listDir(path: string): Promise<string[]>;
writeFileAtomic(path: string, content: string): Promise<void>;
writeJsonAtomic(path: string, data: unknown): Promise<void>;
Expand Down
35 changes: 35 additions & 0 deletions test/ownership-adopt-legacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,27 @@ describe("legacy ownership adoption", () => {
.rejects.toThrow(/Fleet target/i);
});

it("plans run-as legacy claims through the transport without enabling adoption", async () => {
const fixture = await createFixture({ ownLegacy: false });
const request = await adoptRequest(fixture, fixture.scratch);
const reads: string[] = [];
const noWrite = async () => { throw new Error("unexpected target write"); };
const transport: TargetTransport = {
...localTransport, kind: "local-run-as", description: "local-run-as://service:998@fixture",
readFile: async (path) => { reads.push(path); return localTransport.readFile(path); },
mkdirExclusive: noWrite, writeFileAtomic: noWrite, writeJsonAtomic: noWrite, atomicCopy: noWrite, rm: noWrite,
};
const local = await planAdoptLegacyOwnership(request);
const plan = await planAdoptLegacyOwnership({ ...request, transport });
expect(plan.selected).toEqual(local.selected);
expect(plan.planDigest).not.toBe(local.planDigest);
expect(reads).toContain(fixture.legacyManifestPath);
const otherIdentity = await planAdoptLegacyOwnership({ ...request, transport: { ...transport, description: "local-run-as://service:999@fixture" } });
expect(otherIdentity.planDigest).not.toBe(plan.planDigest);
await expect(applyAdoptLegacyOwnership({ ...applyRequest(request, plan), transport }))
.rejects.toThrow(/requires a local filesystem transport/);
});

it("refuses unsupported source state before selecting entries", async () => {
const fixture = await createFixture({ ownLegacy: false });
const request = await adoptRequest(fixture, fixture.scratch);
Expand Down Expand Up @@ -661,6 +682,20 @@ async function tempRoot(prefix: string): Promise<string> {
}

describe("coherent managed-block legacy adoption", () => {
it("uses a worker snapshot for run-as managed blocks and refuses a missing snapshot capability", async () => {
const f = await managedFixture();
const snapshots: string[] = [];
const transport: TargetTransport = {
...localTransport, kind: "local-run-as", description: "local-run-as://service:998@fixture",
ownershipSnapshot: async (path) => { snapshots.push(path); return ownershipRuntimeSnapshot(path); },
};
const plan = await planAdoptLegacyOwnership({ ...f.request, transport });
expect(snapshots).toEqual([f.file]);
expect(plan.selected[0]!.runtimeSnapshotDigest).toBe(await ownershipRuntimeSnapshot(f.file));
await expect(planAdoptLegacyOwnership({ ...f.request, transport: { ...transport, ownershipSnapshot: undefined } }))
.rejects.toThrow(/service-user runtime snapshot/);
});

it("moves metadata while preserving block hash, full file, prefix, suffix, mode and legacy lock", async () => {
const f = await managedFixture();
const before = await readFile(f.file);
Expand Down
16 changes: 16 additions & 0 deletions test/run-as.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ describe("local run-as read transport", () => {
}
});

it("computes ownership snapshots in the worker, including ignored bytes and file modes", async () => {
const root = await fixture();
await mkdir(join(root, "__pycache__"));
const file = join(root, "__pycache__", "cache.pyc");
await writeFile(file, "first", { mode: 0o600 });
const request = { version: 1 as const, operation: "ownershipSnapshot" as const, path: "." };
const before = await executeRead(root, request);
await writeFile(file, "second");
const changed = await executeRead(root, request);
expect(changed).not.toBe(before);
await chmod(file, 0o640);
expect(await executeRead(root, request)).not.toBe(changed);
await symlink("/etc/passwd", join(root, "__pycache__", "escape"));
await expect(executeRead(root, request)).rejects.toThrow(/Unsupported ownership runtime path/);
});

it("rejects directory reads through the worker readFile operation", async () => {
const root = await fixture();
await mkdir(join(root, "directory"));
Expand Down
Loading