fix(fleet): preserve managed link provenance
AMD1213-B3: record Mosaic-owned links and refuse foreign or retargeted symlink mutations. Out-of-scope review follow-up: settings output/snapshot apply-time TOCTOU remains reported, not patched.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
readlinkSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -382,32 +383,138 @@ describe('A3 credential validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('managed plugin and skill links', () => {
|
describe('managed plugin and skill links', () => {
|
||||||
it('installs listed entries and prunes only stale managed symlinks', () => {
|
it('refuses an unrecorded foreign symlink without mutating it', () => {
|
||||||
const fx = fixture({
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
plugins: ['keep'],
|
|
||||||
skills: ['mosaic-tools'],
|
|
||||||
});
|
|
||||||
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
|
|
||||||
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
|
|
||||||
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
|
|
||||||
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
||||||
|
const foreign = join(fx.root, 'foreign-plugin');
|
||||||
mkdirSync(pluginHome, { recursive: true });
|
mkdirSync(pluginHome, { recursive: true });
|
||||||
symlinkSync(join(fx.userHome, 'plugins', 'old'), join(pluginHome, 'old'), 'dir');
|
mkdirSync(foreign, { recursive: true });
|
||||||
|
symlinkSync(foreign, join(pluginHome, 'foreign'), 'dir');
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
systemHome: fx.systemHome,
|
systemHome: fx.systemHome,
|
||||||
userHome: fx.userHome,
|
userHome: fx.userHome,
|
||||||
});
|
});
|
||||||
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||||
|
/unrecorded or retargeted symlink/,
|
||||||
|
);
|
||||||
|
expect(readlinkSync(join(pluginHome, 'foreign'))).toBe(foreign);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes a recorded matching stale symlink', () => {
|
||||||
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
|
||||||
|
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
|
||||||
|
const initial = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
applyFleetLaunchComposition(initial);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(fx.agentDir, 'profile.json'),
|
||||||
|
'{"schema":1,"harness":"claude","plugins":[]}\n',
|
||||||
|
);
|
||||||
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
||||||
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
|
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
|
||||||
applyFleetLaunchComposition(plan);
|
applyFleetLaunchComposition(plan);
|
||||||
|
|
||||||
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
|
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
|
||||||
expect(lstatSync(join(pluginHome, 'keep')).isSymbolicLink()).toBe(true);
|
});
|
||||||
expect(lstatSync(join(fx.agentDir, '.claude', 'skills', 'mosaic-tools')).isSymbolicLink()).toBe(
|
|
||||||
true,
|
it('refuses a recorded link retargeted after composition and leaves it intact', () => {
|
||||||
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
|
||||||
|
const managedTarget = join(fx.userHome, 'plugins', 'old');
|
||||||
|
const foreignTarget = join(fx.root, 'foreign-plugin');
|
||||||
|
mkdirSync(managedTarget, { recursive: true });
|
||||||
|
mkdirSync(foreignTarget, { recursive: true });
|
||||||
|
const initial = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
applyFleetLaunchComposition(initial);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(fx.agentDir, 'profile.json'),
|
||||||
|
'{"schema":1,"harness":"claude","plugins":[]}\n',
|
||||||
);
|
);
|
||||||
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
const link = join(fx.agentDir, '.claude', 'plugins', 'old');
|
||||||
|
rmSync(link);
|
||||||
|
symlinkSync(foreignTarget, link, 'dir');
|
||||||
|
|
||||||
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||||
|
/unrecorded or retargeted symlink/,
|
||||||
|
);
|
||||||
|
expect(readlinkSync(link)).toBe(foreignTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a symlinked manifest temporary path without modifying its target', () => {
|
||||||
|
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
|
||||||
|
const target = join(fx.userHome, 'plugins', 'keep');
|
||||||
|
const sentinel = join(fx.root, 'sentinel.json');
|
||||||
|
mkdirSync(target, { recursive: true });
|
||||||
|
mkdirSync(join(fx.agentDir, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(sentinel, 'unchanged\n', { mode: 0o600 });
|
||||||
|
symlinkSync(sentinel, join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'), 'file');
|
||||||
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(/cannot be created exclusively/);
|
||||||
|
expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts a pre-manifest credential link inside auth root before retargeting it', () => {
|
||||||
|
const fx = fixture({ schema: 1, harness: 'claude', bundle: 'next' });
|
||||||
|
const previousBundle = join(fx.userHome, 'auth', 'claude', 'previous');
|
||||||
|
const nextBundle = join(fx.userHome, 'auth', 'claude', 'next');
|
||||||
|
const seatHome = join(fx.agentDir, '.claude');
|
||||||
|
mkdirSync(previousBundle, { recursive: true });
|
||||||
|
mkdirSync(nextBundle, { recursive: true });
|
||||||
|
writeFileSync(join(previousBundle, '.credentials.json'), '{}\n', { mode: 0o600 });
|
||||||
|
writeFileSync(join(nextBundle, '.credentials.json'), '{}\n', { mode: 0o600 });
|
||||||
|
mkdirSync(seatHome, { recursive: true });
|
||||||
|
symlinkSync(
|
||||||
|
join(previousBundle, '.credentials.json'),
|
||||||
|
join(seatHome, '.credentials.json'),
|
||||||
|
'file',
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
applyFleetLaunchComposition(plan);
|
||||||
|
|
||||||
|
expect(readlinkSync(join(seatHome, '.credentials.json'))).toBe(
|
||||||
|
join(nextBundle, '.credentials.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unrecorded mismatched credential symlink', () => {
|
||||||
|
const fx = fixture();
|
||||||
|
const seatHome = join(fx.agentDir, '.claude');
|
||||||
|
const foreignCredential = join(fx.root, 'foreign-credential.json');
|
||||||
|
mkdirSync(seatHome, { recursive: true });
|
||||||
|
writeFileSync(foreignCredential, '{}\n', { mode: 0o600 });
|
||||||
|
symlinkSync(foreignCredential, join(seatHome, '.credentials.json'), 'file');
|
||||||
|
const plan = resolveFleetLaunchComposition('fred', {
|
||||||
|
systemHome: fx.systemHome,
|
||||||
|
userHome: fx.userHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||||
|
/unrecorded or retargeted symlink/,
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(seatHome, '.credentials.json'), 'utf8')).toBe('{}\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
|
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
closeSync,
|
||||||
lstatSync,
|
lstatSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readlinkSync,
|
readlinkSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
|
renameSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
|
writeSync,
|
||||||
type Stats,
|
type Stats,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||||
@@ -91,6 +95,16 @@ interface PlannedLink {
|
|||||||
readonly target: string;
|
readonly target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ManagedLinkManifest {
|
||||||
|
readonly links: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedLinkState {
|
||||||
|
readonly path: string;
|
||||||
|
readonly links: Map<string, string>;
|
||||||
|
readonly existed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FleetLaunchComposition {
|
export interface FleetLaunchComposition {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly profilePath: string;
|
readonly profilePath: string;
|
||||||
@@ -113,6 +127,7 @@ export interface FleetLaunchComposition {
|
|||||||
readonly link: string;
|
readonly link: string;
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
};
|
};
|
||||||
|
readonly managedLinks: ManagedLinkState;
|
||||||
readonly installs: readonly PlannedLink[];
|
readonly installs: readonly PlannedLink[];
|
||||||
readonly prune: readonly string[];
|
readonly prune: readonly string[];
|
||||||
readonly env: Readonly<Record<string, string>>;
|
readonly env: Readonly<Record<string, string>>;
|
||||||
@@ -374,6 +389,7 @@ function resolveCredential(
|
|||||||
profile: FleetAgentLaunchProfile,
|
profile: FleetAgentLaunchProfile,
|
||||||
userHome: string,
|
userHome: string,
|
||||||
seatHome: string,
|
seatHome: string,
|
||||||
|
managedLinks: ManagedLinkState,
|
||||||
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
|
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
|
||||||
assertRealDirectory(userHome, 'user Mosaic root');
|
assertRealDirectory(userHome, 'user Mosaic root');
|
||||||
const realUserHome = realpathSync(userHome);
|
const realUserHome = realpathSync(userHome);
|
||||||
@@ -425,6 +441,16 @@ function resolveCredential(
|
|||||||
|
|
||||||
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
|
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
|
||||||
const seatInfo = lstatIfPresent(credentialLink);
|
const seatInfo = lstatIfPresent(credentialLink);
|
||||||
|
if (seatInfo?.isSymbolicLink() && !managedLinks.existed) {
|
||||||
|
const existingTarget = currentLinkTarget(credentialLink);
|
||||||
|
try {
|
||||||
|
assertContained(resolvedAuthRoot, realpathSync(existingTarget), 'credential migration');
|
||||||
|
// A pre-manifest seat may adopt only a credential link inside the central auth root.
|
||||||
|
managedLinks.links.set(credentialLink, existingTarget);
|
||||||
|
} catch {
|
||||||
|
// Foreign links remain unrecorded and are refused by apply before any writes.
|
||||||
|
}
|
||||||
|
}
|
||||||
if (seatInfo && !seatInfo.isSymbolicLink()) {
|
if (seatInfo && !seatInfo.isSymbolicLink()) {
|
||||||
throw new FleetLaunchError(
|
throw new FleetLaunchError(
|
||||||
'FIRST_AUTH_REFUSAL',
|
'FIRST_AUTH_REFUSAL',
|
||||||
@@ -449,11 +475,16 @@ function resolveCredential(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentLinkTarget(link: string): string {
|
||||||
|
return resolve(dirname(link), readlinkSync(link));
|
||||||
|
}
|
||||||
|
|
||||||
function resolveManagedLinks(
|
function resolveManagedLinks(
|
||||||
kind: PlannedLink['kind'],
|
kind: PlannedLink['kind'],
|
||||||
names: readonly string[],
|
names: readonly string[],
|
||||||
userHome: string,
|
userHome: string,
|
||||||
seatHome: string,
|
seatHome: string,
|
||||||
|
managedLinks: ManagedLinkState,
|
||||||
): { installs: PlannedLink[]; prune: string[] } {
|
): { installs: PlannedLink[]; prune: string[] } {
|
||||||
const plural = kind === 'plugin' ? 'plugins' : 'skills';
|
const plural = kind === 'plugin' ? 'plugins' : 'skills';
|
||||||
const storeRoot = join(userHome, plural);
|
const storeRoot = join(userHome, plural);
|
||||||
@@ -494,7 +525,6 @@ function resolveManagedLinks(
|
|||||||
if (installRootInfo?.isDirectory()) {
|
if (installRootInfo?.isDirectory()) {
|
||||||
for (const entry of readdirSync(installRoot, { withFileTypes: true })) {
|
for (const entry of readdirSync(installRoot, { withFileTypes: true })) {
|
||||||
const path = join(installRoot, entry.name);
|
const path = join(installRoot, entry.name);
|
||||||
if (desired.has(entry.name)) continue;
|
|
||||||
if (!entry.isSymbolicLink()) {
|
if (!entry.isSymbolicLink()) {
|
||||||
// The harness writes its own metadata files (e.g. installed_plugins.json)
|
// The harness writes its own metadata files (e.g. installed_plugins.json)
|
||||||
// beside the managed links; only a real directory is an unmanaged entry
|
// beside the managed links; only a real directory is an unmanaged entry
|
||||||
@@ -507,12 +537,107 @@ function resolveManagedLinks(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
prune.push(path);
|
const currentTarget = currentLinkTarget(path);
|
||||||
|
const recordedTarget = managedLinks.links.get(path);
|
||||||
|
if (recordedTarget === undefined) {
|
||||||
|
if (
|
||||||
|
!managedLinks.existed &&
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
assertContained(
|
||||||
|
realpathSync(storeRoot),
|
||||||
|
realpathSync(currentTarget),
|
||||||
|
`${kind} migration`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
) {
|
||||||
|
// A pre-manifest seat may adopt only links to the central Mosaic store; foreign links refuse.
|
||||||
|
managedLinks.links.set(path, currentTarget);
|
||||||
|
}
|
||||||
|
} else if (recordedTarget !== currentTarget) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`managed ${kind} symlink target changed since composition: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!desired.has(entry.name)) prune.push(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { installs, prune };
|
return { installs, prune };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readManagedLinkState(seatHome: string): ManagedLinkState {
|
||||||
|
const path = join(seatHome, '.mosaic-managed-links.json');
|
||||||
|
const info = lstatIfPresent(path);
|
||||||
|
if (!info) return { path, links: new Map(), existed: false };
|
||||||
|
if (!info.isFile() || info.isSymbolicLink()) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`managed link manifest must be a real, non-symlink JSON file: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`managed link manifest is invalid JSON: ${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isPlainObject(parsed) || !isPlainObject(parsed['links'])) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`managed link manifest has invalid shape: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const links = new Map<string, string>();
|
||||||
|
for (const [link, target] of Object.entries(parsed['links'])) {
|
||||||
|
if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`managed link manifest has invalid entry: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
links.set(link, target);
|
||||||
|
}
|
||||||
|
return { path, links, existed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedManagedLinkManifest {
|
||||||
|
readonly path: string;
|
||||||
|
readonly descriptor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareManagedLinkManifest(managedLinks: ManagedLinkState): PreparedManagedLinkManifest {
|
||||||
|
const path = `${managedLinks.path}.tmp`;
|
||||||
|
try {
|
||||||
|
return { path, descriptor: openSync(path, 'wx', 0o600) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`managed link manifest temporary file cannot be created exclusively: ${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeManagedLinkState(
|
||||||
|
managedLinks: ManagedLinkState,
|
||||||
|
prepared: PreparedManagedLinkManifest,
|
||||||
|
): void {
|
||||||
|
const entries = [...managedLinks.links.entries()].sort(([left], [right]) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
);
|
||||||
|
const manifest: ManagedLinkManifest = { links: Object.fromEntries(entries) };
|
||||||
|
writeSync(prepared.descriptor, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildArgv(
|
function buildArgv(
|
||||||
profile: FleetAgentLaunchProfile,
|
profile: FleetAgentLaunchProfile,
|
||||||
seatHome: string,
|
seatHome: string,
|
||||||
@@ -594,9 +719,22 @@ export function resolveFleetLaunchComposition(
|
|||||||
: readSettingsLayer('agent', overlayPath, false),
|
: readSettingsLayer('agent', overlayPath, false),
|
||||||
];
|
];
|
||||||
const merged = deepMergeSettings(...layers.map((layer) => layer.value));
|
const merged = deepMergeSettings(...layers.map((layer) => layer.value));
|
||||||
const credential = resolveCredential(profile, roots.userHome, seatHome);
|
const managedLinks = readManagedLinkState(seatHome);
|
||||||
const plugins = resolveManagedLinks('plugin', profile.plugins, roots.userHome, seatHome);
|
const credential = resolveCredential(profile, roots.userHome, seatHome, managedLinks);
|
||||||
const skills = resolveManagedLinks('skill', profile.skills, roots.userHome, seatHome);
|
const plugins = resolveManagedLinks(
|
||||||
|
'plugin',
|
||||||
|
profile.plugins,
|
||||||
|
roots.userHome,
|
||||||
|
seatHome,
|
||||||
|
managedLinks,
|
||||||
|
);
|
||||||
|
const skills = resolveManagedLinks(
|
||||||
|
'skill',
|
||||||
|
profile.skills,
|
||||||
|
roots.userHome,
|
||||||
|
seatHome,
|
||||||
|
managedLinks,
|
||||||
|
);
|
||||||
const homeEnvName: Record<RuntimeName, string> = {
|
const homeEnvName: Record<RuntimeName, string> = {
|
||||||
claude: 'CLAUDE_CONFIG_DIR',
|
claude: 'CLAUDE_CONFIG_DIR',
|
||||||
pi: 'PI_CODING_AGENT_DIR',
|
pi: 'PI_CODING_AGENT_DIR',
|
||||||
@@ -621,6 +759,7 @@ export function resolveFleetLaunchComposition(
|
|||||||
snapshot: settingsSnapshot,
|
snapshot: settingsSnapshot,
|
||||||
},
|
},
|
||||||
...credential,
|
...credential,
|
||||||
|
managedLinks,
|
||||||
installs: [...plugins.installs, ...skills.installs],
|
installs: [...plugins.installs, ...skills.installs],
|
||||||
prune: [...plugins.prune, ...skills.prune],
|
prune: [...plugins.prune, ...skills.prune],
|
||||||
env,
|
env,
|
||||||
@@ -628,11 +767,20 @@ export function resolveFleetLaunchComposition(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureSymlink(link: string, target: string): void {
|
function ensureSymlink(link: string, target: string, managedLinks: ManagedLinkState): void {
|
||||||
const info = lstatIfPresent(link);
|
const info = lstatIfPresent(link);
|
||||||
if (info?.isSymbolicLink()) {
|
if (info?.isSymbolicLink()) {
|
||||||
const current = resolve(dirname(link), readlinkSync(link));
|
const current = currentLinkTarget(link);
|
||||||
if (current === target) return;
|
if (current === target) {
|
||||||
|
managedLinks.links.set(link, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (managedLinks.links.get(link) !== current) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`unrecorded or retargeted symlink occupies managed path: ${link}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
rmSync(link);
|
rmSync(link);
|
||||||
} else if (info) {
|
} else if (info) {
|
||||||
throw new FleetLaunchError(
|
throw new FleetLaunchError(
|
||||||
@@ -642,6 +790,30 @@ function ensureSymlink(link: string, target: string): void {
|
|||||||
}
|
}
|
||||||
mkdirSync(dirname(link), { recursive: true });
|
mkdirSync(dirname(link), { recursive: true });
|
||||||
symlinkSync(target, link, 'file');
|
symlinkSync(target, link, 'file');
|
||||||
|
managedLinks.links.set(link, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertManagedLinkMutationAllowed(
|
||||||
|
link: string,
|
||||||
|
target: string | undefined,
|
||||||
|
managedLinks: ManagedLinkState,
|
||||||
|
): void {
|
||||||
|
const info = lstatIfPresent(link);
|
||||||
|
if (!info) return;
|
||||||
|
if (!info.isSymbolicLink()) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`real object occupies managed symlink path ${link}; refusing to delete it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const current = currentLinkTarget(link);
|
||||||
|
if (target !== undefined && current === target) return;
|
||||||
|
if (managedLinks.links.get(link) !== current) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`unrecorded or retargeted symlink occupies managed path: ${link}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalJson(value: unknown): unknown {
|
function canonicalJson(value: unknown): unknown {
|
||||||
@@ -657,21 +829,57 @@ function canonicalJson(value: unknown): unknown {
|
|||||||
/** Apply a previously resolved plan. No caller should apply a dry-run plan. */
|
/** Apply a previously resolved plan. No caller should apply a dry-run plan. */
|
||||||
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
|
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
|
||||||
mkdirSync(plan.seatHome, { recursive: true });
|
mkdirSync(plan.seatHome, { recursive: true });
|
||||||
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
|
const preparedManifest = prepareManagedLinkManifest(plan.managedLinks);
|
||||||
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
|
let committedManifest = false;
|
||||||
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
|
try {
|
||||||
ensureSymlink(plan.credential.link, plan.credential.target);
|
assertManagedLinkMutationAllowed(
|
||||||
for (const path of plan.prune) {
|
plan.credential.link,
|
||||||
const info = lstatIfPresent(path);
|
plan.credential.target,
|
||||||
if (info?.isSymbolicLink()) rmSync(path);
|
plan.managedLinks,
|
||||||
else if (info) {
|
);
|
||||||
throw new FleetLaunchError(
|
for (const path of plan.prune) {
|
||||||
'COMPOSITION_FAILED',
|
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
|
||||||
`real object replaced managed symlink before prune: ${path}`,
|
}
|
||||||
);
|
for (const install of plan.installs) {
|
||||||
|
assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
|
||||||
|
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
|
||||||
|
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
|
||||||
|
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
|
||||||
|
for (const path of plan.prune) {
|
||||||
|
const info = lstatIfPresent(path);
|
||||||
|
if (info?.isSymbolicLink()) {
|
||||||
|
const current = currentLinkTarget(path);
|
||||||
|
if (plan.managedLinks.links.get(path) !== current) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`unrecorded or retargeted symlink cannot be pruned: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rmSync(path);
|
||||||
|
plan.managedLinks.links.delete(path);
|
||||||
|
} else if (info) {
|
||||||
|
throw new FleetLaunchError(
|
||||||
|
'COMPOSITION_FAILED',
|
||||||
|
`real object replaced managed symlink before prune: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const install of plan.installs) {
|
||||||
|
ensureSymlink(install.link, install.target, plan.managedLinks);
|
||||||
|
}
|
||||||
|
writeManagedLinkState(plan.managedLinks, preparedManifest);
|
||||||
|
closeSync(preparedManifest.descriptor);
|
||||||
|
renameSync(preparedManifest.path, plan.managedLinks.path);
|
||||||
|
committedManifest = true;
|
||||||
|
} finally {
|
||||||
|
if (!committedManifest) {
|
||||||
|
closeSync(preparedManifest.descriptor);
|
||||||
|
rmSync(preparedManifest.path, { force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const install of plan.installs) ensureSymlink(install.link, install.target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable, auditable text representation used by --dry-run and snapshot tests. */
|
/** Stable, auditable text representation used by --dry-run and snapshot tests. */
|
||||||
|
|||||||
Reference in New Issue
Block a user