diff --git a/README.md b/README.md index 66c9df8..634c797 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/plugins.js b/src/core/plugins.js index b1dae51..b555c3b 100644 --- a/src/core/plugins.js +++ b/src/core/plugins.js @@ -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 = [], @@ -244,7 +255,7 @@ 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]), @@ -252,19 +263,37 @@ function pluginState({ deviceId, assignment, profile, inspection, errors = [] }) 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, @@ -291,13 +320,12 @@ 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, @@ -305,6 +333,16 @@ export async function syncCodexPlugins({ 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; @@ -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) { diff --git a/test/plugins.test.js b/test/plugins.test.js index 991a034..6f150b1 100644 --- a/test/plugins.test.js +++ b/test/plugins.test.js @@ -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 () => { @@ -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({ @@ -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, @@ -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 () => {