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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ The other device installs missing plugins on its next sync. With auto-adoption e

SkillSync derives this union from separate per-device reports instead of having devices rewrite one profile file. Concurrent device syncs therefore update different files. Product-managed, disabled, cached, and hosted-session-only plugins never join the union.

Device reports contain only durable user-managed plugin selectors and whether each plugin is enabled. Codex-owned packages, package versions, authentication metadata, and temporary inspection failures stay local, so routine Codex updates do not create vault commits. A failed inspection also leaves the device's last successful report unchanged.

Automatic adoption is off unless `--auto-adopt` is supplied. Change it later with:

```bash
Expand Down
76 changes: 62 additions & 14 deletions src/core/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,17 @@ export function importablePluginSelectors(plugins) {
.sort();
}

function reportedPluginInventory(plugins) {
return plugins
.filter((plugin) => !PRODUCT_MANAGED_MARKETPLACES.has(plugin.marketplace))
.map(({ selector, name, marketplace, enabled }) => ({
selector,
name,
marketplace,
enabled,
}));
}

export function effectivePluginProfile(profile, {
assignments = [],
states = [],
Expand Down Expand Up @@ -244,27 +255,45 @@ async function installPlugin(selector, runCommand) {
);
}

function pluginState({ deviceId, assignment, profile, inspection, errors = [] }) {
function pluginReport({ deviceId, assignment, profile, inspection }) {
const desired = profile?.plugins || [];
const installedBySelector = new Map(
inspection.installed.map((plugin) => [plugin.selector, plugin]),
);
const missing = desired.filter((selector) => !installedBySelector.has(selector));
const disabled = desired.filter((selector) => installedBySelector.get(selector)?.enabled === false);
const profileHash = profile ? pluginProfileHash(profile) : null;
const applied = Boolean(profile && inspection.available && !missing.length && !disabled.length && !errors.length);
const applied = Boolean(profile && !missing.length && !disabled.length);
return {
version: 1,
device_id: assertSafePathSegment(deviceId, 'Device ID'),
provider: 'codex',
available: inspection.available,
profile: assignment?.profile || null,
profile_hash: profileHash,
applied_profile: applied ? assignment.profile : null,
applied_profile_hash: applied ? profileHash : null,
installed: inspection.installed,
installed: reportedPluginInventory(inspection.installed),
missing,
disabled,
};
}

function unavailablePluginStatus({ deviceId, assignment, profile, previousState, inspection, errors }) {
const profileHash = profile ? pluginProfileHash(profile) : null;
return {
...(previousState || {
version: 1,
device_id: assertSafePathSegment(deviceId, 'Device ID'),
provider: 'codex',
profile: assignment?.profile || null,
profile_hash: profileHash,
applied_profile: null,
applied_profile_hash: null,
installed: [],
missing: profile?.plugins || [],
disabled: [],
}),
available: false,
errors: [...new Set([
...(inspection.error ? [inspection.error] : []),
...errors,
Expand All @@ -291,20 +320,29 @@ export async function syncCodexPlugins({
}

let inspection = await inspectCodexPlugins({ commandAvailable, runCommand });
let previousState = null;
const previousState = await loadPluginState(vaultPath, deviceId);
if (profile) {
const [assignments, states] = await Promise.all([
listPluginAssignments(vaultPath),
listPluginStates(vaultPath),
]);
previousState = states.find((state) => state.device_id === deviceId) || null;
profile = effectivePluginProfile(profile, {
assignments,
states,
deviceId,
installed: inspection.available ? inspection.installed : null,
});
}
if (!inspection.available) {
return unavailablePluginStatus({
deviceId,
assignment,
profile,
previousState,
inspection,
errors,
});
}
if (profile && inspection.available) {
const installed = new Set(inspection.installed.map((plugin) => plugin.selector));
let attemptedInstall = false;
Expand All @@ -321,19 +359,29 @@ export async function syncCodexPlugins({
inspection = await inspectCodexPlugins({ commandAvailable, runCommand });
}
}
if (!inspection.available) {
return unavailablePluginStatus({
deviceId,
assignment,
profile,
previousState,
inspection,
errors,
});
}

const reportedInspection = !inspection.available && previousState?.installed
? { ...inspection, installed: previousState.installed }
: inspection;
const state = pluginState({
const report = pluginReport({
deviceId,
assignment,
profile,
inspection: reportedInspection,
errors,
inspection,
});
await writeJson(pluginStatePath(vaultPath, deviceId), state);
return state;
await writeJson(pluginStatePath(vaultPath, deviceId), report);
return {
...report,
available: true,
errors,
};
}

export async function loadPluginState(vaultPath, deviceId) {
Expand Down
144 changes: 137 additions & 7 deletions test/plugins.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,113 @@ test('plugin sync installs only missing selections and preserves extra plugins',
assert.deepEqual(state.missing, []);
assert.deepEqual(state.disabled, []);
assert.equal(state.installed.some(({ selector }) => selector === 'vercel@openai-curated'), true);
assert.equal(
state.installed.find(({ selector }) => selector === 'gmail@openai-curated').auth_policy,
'ON_USE',
);
assert.deepEqual(await loadPluginState(vaultPath, deviceId), state);
assert.equal(state.available, true);
assert.deepEqual(state.errors, []);
assert.deepEqual(await loadPluginState(vaultPath, deviceId), {
version: 1,
device_id: deviceId,
provider: 'codex',
profile: 'shared',
profile_hash: state.profile_hash,
applied_profile: 'shared',
applied_profile_hash: state.profile_hash,
installed: [
{
selector: 'github@openai-curated',
name: 'github',
marketplace: 'openai-curated',
enabled: true,
},
{
selector: 'gmail@openai-curated',
name: 'gmail',
marketplace: 'openai-curated',
enabled: true,
},
{
selector: 'vercel@openai-curated',
name: 'vercel',
marketplace: 'openai-curated',
enabled: true,
},
],
missing: [],
disabled: [],
});
});

test('plugin reports ignore package updates and product-managed inventory', async () => {
const vaultPath = await tempDir();
const deviceId = 'mac';
await savePluginProfile({
vaultPath,
name: 'shared',
plugins: ['github@openai-curated'],
});
await assignPluginProfile({ vaultPath, deviceId, profile: 'shared' });

let installed = [
{
pluginId: 'github@openai-curated',
version: '1.0.0',
enabled: true,
authPolicy: 'ON_INSTALL',
},
{
pluginId: 'sites@openai-bundled',
version: '1.0.0',
enabled: true,
authPolicy: 'ON_INSTALL',
},
];
const runCommand = async () => ({
stdout: JSON.stringify({ installed }),
stderr: '',
code: 0,
});

await syncCodexPlugins({
vaultPath,
deviceId,
commandAvailable: async () => true,
runCommand,
});
const first = await loadPluginState(vaultPath, deviceId);

installed = [
{
pluginId: 'github@openai-curated',
version: '2.0.0',
enabled: true,
authPolicy: 'ON_USE',
},
{
pluginId: 'sites@openai-bundled',
version: '2.0.0',
enabled: true,
authPolicy: 'ON_USE',
},
{
pluginId: 'codex-app-tools@openai-bundled',
version: '0.1.0',
enabled: true,
authPolicy: 'ON_INSTALL',
},
];
await syncCodexPlugins({
vaultPath,
deviceId,
commandAvailable: async () => true,
runCommand,
});

assert.deepEqual(await loadPluginState(vaultPath, deviceId), first);
assert.deepEqual(first.installed, [{
selector: 'github@openai-curated',
name: 'github',
marketplace: 'openai-curated',
enabled: true,
}]);
});

test('auto-adopted plugins propagate from any assigned device without rewriting the profile', async () => {
Expand Down Expand Up @@ -311,10 +413,12 @@ test('plugin sync never writes raw authentication failures to the vault', async
assert.deepEqual(state.errors, [
'Could not install gmail@openai-curated; install or authenticate it from Codex /plugins',
]);
assert.equal(JSON.stringify(await loadPluginState(vaultPath, deviceId)).includes('SECRET-123'), false);
const reported = await loadPluginState(vaultPath, deviceId);
assert.equal(JSON.stringify(reported).includes('SECRET-123'), false);
assert.equal(Object.hasOwn(reported, 'errors'), false);
});

test('failed inspection retains the last safe inventory for automatic adoption', async () => {
test('failed inspection leaves the last durable report unchanged', async () => {
const vaultPath = await tempDir();
const deviceId = 'vps';
await savePluginProfile({
Expand All @@ -339,6 +443,7 @@ test('failed inspection retains the last safe inventory for automatic adoption',
code: 0,
}),
});
const reported = await loadPluginState(vaultPath, deviceId);

const state = await syncCodexPlugins({
vaultPath,
Expand All @@ -347,10 +452,35 @@ test('failed inspection retains the last safe inventory for automatic adoption',
});

assert.equal(state.available, false);
assert.deepEqual(state.errors, ['Codex CLI is not installed']);
assert.deepEqual(state.installed.map(({ selector }) => selector), [
'github@openai-curated',
'notion@openai-curated',
]);
assert.deepEqual(await loadPluginState(vaultPath, deviceId), reported);
assert.equal(Object.hasOwn(reported, 'available'), false);
assert.equal(Object.hasOwn(reported, 'errors'), false);
});

test('failed first inspection does not create a device report', async () => {
const vaultPath = await tempDir();
const deviceId = 'new-device';
await savePluginProfile({
vaultPath,
name: 'shared',
plugins: ['github@openai-curated'],
});
await assignPluginProfile({ vaultPath, deviceId, profile: 'shared' });

const state = await syncCodexPlugins({
vaultPath,
deviceId,
commandAvailable: async () => false,
});

assert.equal(state.available, false);
assert.deepEqual(state.errors, ['Codex CLI is not installed']);
assert.equal(await loadPluginState(vaultPath, deviceId), null);
});

test('devices without a plugin assignment remain unmanaged', async () => {
Expand Down