Skip to content

Commit 265fe9b

Browse files
authored
Merge pull request #19 from vitry/fix/setup-role-normalized-defaults
fix: make setup compatible with Codex normalized Role config
2 parents 344d113 + fc3fb5e commit 265fe9b

10 files changed

Lines changed: 88 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ All notable changes follow Semantic Versioning.
44

55
## Unreleased
66

7+
- Fixed `$zcode:setup` managed Role reconciliation with Codex 0.147 effective configuration, which normalizes an otherwise exact Role registration with `nickname_candidates = null`.
8+
- Fixed local cachebuster reinstallations so SemVer build metadata keeps the installed plugin's marketplace-qualified data root valid.
79
- Fixed installed `$zcode:*` skills failing with `DATA_ROOT_REQUIRED` when Codex does not inject `PLUGIN_DATA` into ordinary skill commands.
810
- Added marketplace-qualified plugin-data discovery and a restart-safe `$zcode:setup` bootstrap that configures the data directory as a writable root before persisting state.
911
- Added ZCode CLI 0.16.1 compatibility for runtime-preference server requests with string IDs.

marketplace/.agents/plugins/provenance.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"packageVersion": "0.1.0",
33
"pluginVersion": "0.1.0",
4-
"sourceRef": "main",
5-
"sourceSha": "07d079a85624bc1cdfc5b37f659e4c1faa9547b2",
4+
"sourceRef": "17a60d543ae743fb6626517979641d5ae9508f25",
5+
"sourceSha": "17a60d543ae743fb6626517979641d5ae9508f25",
66
"dependencyLock": {
77
"file": "npm-shrinkwrap.json",
88
"sha256": "fa927194e6ca0b25c1d3f428859b2ab4798b8eabb4d31bc87188d73c630f9938"

marketplace/plugins/zcode/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ All notable changes follow Semantic Versioning.
44

55
## Unreleased
66

7+
- Fixed `$zcode:setup` managed Role reconciliation with Codex 0.147 effective configuration, which normalizes an otherwise exact Role registration with `nickname_candidates = null`.
8+
- Fixed local cachebuster reinstallations so SemVer build metadata keeps the installed plugin's marketplace-qualified data root valid.
79
- Fixed installed `$zcode:*` skills failing with `DATA_ROOT_REQUIRED` when Codex does not inject `PLUGIN_DATA` into ordinary skill commands.
810
- Added marketplace-qualified plugin-data discovery and a restart-safe `$zcode:setup` bootstrap that configures the data directory as a writable root before persisting state.
911
- Added ZCode CLI 0.16.1 compatibility for runtime-preference server requests with string IDs.

marketplace/plugins/zcode/scripts/lib/managed-agent-role.mjs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,11 @@ async function inspectPrepared(prepared, config) {
162162
const collision = nonTargetCollision(config, definitions, prepared.configTarget.filePath);
163163
if (collision !== null) return result(collision.status, prepared.paths.rolePath, undefined, collision.conflicts);
164164
if (receipt === null) {
165-
if (sameRegistration(effective, prepared.paths.rolePath) || roleBytes !== null) return result('drift', prepared.paths.rolePath);
165+
if (sameEffectiveRegistration(effective, prepared.paths.rolePath) || roleBytes !== null) return result('drift', prepared.paths.rolePath);
166166
return result('foreign-conflict', prepared.paths.rolePath);
167167
}
168168
if (!validReceiptBase(receipt, prepared.paths.rolePath, prepared.input.pluginIdentity)) return result('drift', prepared.paths.rolePath);
169-
if (!sameRegistration(effective, prepared.paths.rolePath)) return result('drift', prepared.paths.rolePath);
169+
if (!sameEffectiveRegistration(effective, prepared.paths.rolePath)) return result('drift', prepared.paths.rolePath);
170170
if (roleBytes === null || sha256(roleBytes) !== receipt.role.sha256) return result('drift', prepared.paths.rolePath);
171171
if (config.config?.features?.multi_agent_v2?.hide_spawn_agent_metadata !== false) return result('higher-precedence-conflict', prepared.paths.rolePath);
172172
if (receipt.schemaVersion !== 1
@@ -334,7 +334,7 @@ function selectedTargetVersion(config, filePath) {
334334
/** @param {AnyRecord} config @param {string} rolePath @param {string} targetFile */
335335
function verifyEffectiveConfig(config, rolePath, targetFile) {
336336
if (!validConfigRead(config)) return 'Codex returned Role configuration errors after installation.';
337-
if (!sameRegistration(config.config?.agents?.[MANAGED_ROLE_NAME], rolePath)) return 'The managed Role registration is overridden after installation.';
337+
if (!sameEffectiveRegistration(config.config?.agents?.[MANAGED_ROLE_NAME], rolePath)) return 'The managed Role registration is overridden after installation.';
338338
if (config.config?.features?.multi_agent_v2?.hide_spawn_agent_metadata !== false) return 'Spawn metadata remains hidden after installation.';
339339
const definitions = roleDefinitions(config);
340340
if (definitions.some((item) => item.type === 'project')) return 'A project Role shadows the managed Role.';
@@ -419,7 +419,7 @@ function targetLeaf(config, filePath, keyPath) {
419419
function configLeavesOwned(config, filePath, rolePath, desiredAdditional) {
420420
const registration = targetRegistration(config, filePath);
421421
const metadata = targetMetadata(config, filePath);
422-
if (!registration.present || !sameRegistration(registration.value, rolePath) || !metadata.present || metadata.value !== false) return false;
422+
if (!registration.present || !sameExactRegistration(registration.value, rolePath) || !metadata.present || metadata.value !== false) return false;
423423
return desiredAdditional.every((entry) => {
424424
const current = targetLeaf(config, filePath, entry.keyPath);
425425
return current.present && JSON.stringify(current.value) === JSON.stringify(entry.value);
@@ -532,7 +532,14 @@ function validReceiptBase(receipt, rolePath, identity) {
532532
/** @param {string} rolePath */
533533
function expectedRegistration(rolePath) { return { description: MANAGED_ROLE_DESCRIPTION, config_file: rolePath }; }
534534
/** @param {any} value @param {string} rolePath */
535-
function sameRegistration(value, rolePath) { return value?.description === MANAGED_ROLE_DESCRIPTION && value?.config_file === rolePath && Object.keys(value).length === 2; }
535+
function sameExactRegistration(value, rolePath) { return value?.description === MANAGED_ROLE_DESCRIPTION && value?.config_file === rolePath && Object.keys(value).length === 2; }
536+
/** Codex normalizes an effective Agent Role with this nullable default; the selected config layer remains exact. @param {any} value @param {string} rolePath */
537+
function sameEffectiveRegistration(value, rolePath) {
538+
if (value?.description !== MANAGED_ROLE_DESCRIPTION || value?.config_file !== rolePath) return false;
539+
const keys = Object.keys(value).sort();
540+
return sameKeys(keys, ['config_file', 'description'])
541+
|| value.nickname_candidates === null && sameKeys(keys, ['config_file', 'description', 'nickname_candidates']);
542+
}
536543
/** @param {string} status @param {string} rolePath @param {string} [reason] @param {AnyRecord[]} [conflicts] */
537544
function result(status, rolePath, reason, conflicts) { return { status, rolePath, ...(reason ? { reason } : {}), ...(conflicts?.length ? { conflicts } : {}) }; }
538545
/** @param {string|Buffer} bytes */

marketplace/plugins/zcode/scripts/lib/plugin-data.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ function installedIdentity(pluginRoot, codexHome) {
4343
const segments = relative(cache, canonical).split(sep);
4444
if (segments.length !== 3 || segments.some((segment) => !segment || segment === '.' || segment === '..' || hasControl(segment))) throw invalidRoot();
4545
const [marketplace, plugin, version] = segments;
46-
if (!/^[A-Za-z0-9_-]+$/.test(marketplace) || plugin !== 'zcode' || !/^[A-Za-z0-9._-]+$/.test(version)) throw invalidRoot();
46+
if (!/^[A-Za-z0-9_-]+$/.test(marketplace) || plugin !== 'zcode'
47+
|| !/^[A-Za-z0-9._-]+(?:\+[A-Za-z0-9._-]+)?$/.test(version)) throw invalidRoot();
4748
return { marketplace };
4849
}
4950

scripts/lib/managed-agent-role.mjs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,11 @@ async function inspectPrepared(prepared, config) {
162162
const collision = nonTargetCollision(config, definitions, prepared.configTarget.filePath);
163163
if (collision !== null) return result(collision.status, prepared.paths.rolePath, undefined, collision.conflicts);
164164
if (receipt === null) {
165-
if (sameRegistration(effective, prepared.paths.rolePath) || roleBytes !== null) return result('drift', prepared.paths.rolePath);
165+
if (sameEffectiveRegistration(effective, prepared.paths.rolePath) || roleBytes !== null) return result('drift', prepared.paths.rolePath);
166166
return result('foreign-conflict', prepared.paths.rolePath);
167167
}
168168
if (!validReceiptBase(receipt, prepared.paths.rolePath, prepared.input.pluginIdentity)) return result('drift', prepared.paths.rolePath);
169-
if (!sameRegistration(effective, prepared.paths.rolePath)) return result('drift', prepared.paths.rolePath);
169+
if (!sameEffectiveRegistration(effective, prepared.paths.rolePath)) return result('drift', prepared.paths.rolePath);
170170
if (roleBytes === null || sha256(roleBytes) !== receipt.role.sha256) return result('drift', prepared.paths.rolePath);
171171
if (config.config?.features?.multi_agent_v2?.hide_spawn_agent_metadata !== false) return result('higher-precedence-conflict', prepared.paths.rolePath);
172172
if (receipt.schemaVersion !== 1
@@ -334,7 +334,7 @@ function selectedTargetVersion(config, filePath) {
334334
/** @param {AnyRecord} config @param {string} rolePath @param {string} targetFile */
335335
function verifyEffectiveConfig(config, rolePath, targetFile) {
336336
if (!validConfigRead(config)) return 'Codex returned Role configuration errors after installation.';
337-
if (!sameRegistration(config.config?.agents?.[MANAGED_ROLE_NAME], rolePath)) return 'The managed Role registration is overridden after installation.';
337+
if (!sameEffectiveRegistration(config.config?.agents?.[MANAGED_ROLE_NAME], rolePath)) return 'The managed Role registration is overridden after installation.';
338338
if (config.config?.features?.multi_agent_v2?.hide_spawn_agent_metadata !== false) return 'Spawn metadata remains hidden after installation.';
339339
const definitions = roleDefinitions(config);
340340
if (definitions.some((item) => item.type === 'project')) return 'A project Role shadows the managed Role.';
@@ -419,7 +419,7 @@ function targetLeaf(config, filePath, keyPath) {
419419
function configLeavesOwned(config, filePath, rolePath, desiredAdditional) {
420420
const registration = targetRegistration(config, filePath);
421421
const metadata = targetMetadata(config, filePath);
422-
if (!registration.present || !sameRegistration(registration.value, rolePath) || !metadata.present || metadata.value !== false) return false;
422+
if (!registration.present || !sameExactRegistration(registration.value, rolePath) || !metadata.present || metadata.value !== false) return false;
423423
return desiredAdditional.every((entry) => {
424424
const current = targetLeaf(config, filePath, entry.keyPath);
425425
return current.present && JSON.stringify(current.value) === JSON.stringify(entry.value);
@@ -532,7 +532,14 @@ function validReceiptBase(receipt, rolePath, identity) {
532532
/** @param {string} rolePath */
533533
function expectedRegistration(rolePath) { return { description: MANAGED_ROLE_DESCRIPTION, config_file: rolePath }; }
534534
/** @param {any} value @param {string} rolePath */
535-
function sameRegistration(value, rolePath) { return value?.description === MANAGED_ROLE_DESCRIPTION && value?.config_file === rolePath && Object.keys(value).length === 2; }
535+
function sameExactRegistration(value, rolePath) { return value?.description === MANAGED_ROLE_DESCRIPTION && value?.config_file === rolePath && Object.keys(value).length === 2; }
536+
/** Codex normalizes an effective Agent Role with this nullable default; the selected config layer remains exact. @param {any} value @param {string} rolePath */
537+
function sameEffectiveRegistration(value, rolePath) {
538+
if (value?.description !== MANAGED_ROLE_DESCRIPTION || value?.config_file !== rolePath) return false;
539+
const keys = Object.keys(value).sort();
540+
return sameKeys(keys, ['config_file', 'description'])
541+
|| value.nickname_candidates === null && sameKeys(keys, ['config_file', 'description', 'nickname_candidates']);
542+
}
536543
/** @param {string} status @param {string} rolePath @param {string} [reason] @param {AnyRecord[]} [conflicts] */
537544
function result(status, rolePath, reason, conflicts) { return { status, rolePath, ...(reason ? { reason } : {}), ...(conflicts?.length ? { conflicts } : {}) }; }
538545
/** @param {string|Buffer} bytes */

scripts/lib/plugin-data.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ function installedIdentity(pluginRoot, codexHome) {
4343
const segments = relative(cache, canonical).split(sep);
4444
if (segments.length !== 3 || segments.some((segment) => !segment || segment === '.' || segment === '..' || hasControl(segment))) throw invalidRoot();
4545
const [marketplace, plugin, version] = segments;
46-
if (!/^[A-Za-z0-9_-]+$/.test(marketplace) || plugin !== 'zcode' || !/^[A-Za-z0-9._-]+$/.test(version)) throw invalidRoot();
46+
if (!/^[A-Za-z0-9_-]+$/.test(marketplace) || plugin !== 'zcode'
47+
|| !/^[A-Za-z0-9._-]+(?:\+[A-Za-z0-9._-]+)?$/.test(version)) throw invalidRoot();
4748
return { marketplace };
4849
}
4950

tests/fixtures/fake-codex-app-server.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ function applyConfigEdits(params) {
103103
setLeaf(copy.config, edit.keyPath, edit.value);
104104
if (layer) setLeaf(layer.config, edit.keyPath, edit.value);
105105
}
106+
if (process.env.FAKE_CODEX_EFFECTIVE_ROLE_DEFAULTS_JSON && copy.config?.agents?.['zcode-rescue']) {
107+
Object.assign(copy.config.agents['zcode-rescue'], JSON.parse(process.env.FAKE_CODEX_EFFECTIVE_ROLE_DEFAULTS_JSON));
108+
}
106109
currentConfig = copy;
107110
}
108111

tests/plugin-data.test.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ test('installed plugins derive a marketplace-qualified data root without injecte
2323
}), join(codexHome, 'plugins', 'data', 'zcode-vitry'));
2424
});
2525

26+
test('installed plugins accept the Codex cachebuster build metadata used for local updates', () => {
27+
const codexHome = resolve('codex-home-fixture');
28+
assert.equal(resolvePluginDataRoot({
29+
env: { CODEX_HOME: codexHome },
30+
pluginRoot: join(codexHome, 'plugins', 'cache', 'vitry', 'zcode', '0.1.0+codex.local-20260813-030655'),
31+
}), join(codexHome, 'plugins', 'data', 'zcode-vitry'));
32+
});
33+
2634
test('installed plugins accept only plugin-data injected for their active marketplace identity', () => {
2735
const codexHome = resolve('codex-home-fixture'); const pluginRoot = join(codexHome, 'plugins', 'cache', 'vitry', 'zcode', '0.1.0'); const expected = join(codexHome, 'plugins', 'data', 'zcode-vitry');
2836
assert.equal(resolvePluginDataRoot({ env: { CODEX_HOME: codexHome, PLUGIN_DATA: expected }, pluginRoot }), expected);
@@ -69,6 +77,9 @@ test('installed identity rejects malformed cache segments', () => {
6977
`${cache}${sep}..${sep}zcode${sep}0.1.0`,
7078
`${cache}${sep}vitry${sep}zcode${sep}..${sep}0.1.0`,
7179
join(cache, 'vitry', 'zcode', '0.1.0', 'unexpected'),
80+
join(cache, 'vitry', 'zcode', '0.1.0++bad'),
81+
join(cache, 'vitry', 'zcode', '+codex.local'),
82+
join(cache, 'vitry', 'zcode', '0.1.0+'),
7283
`${join(cache, 'vitry', 'zcode', '0.1.0')}\u0000bad`,
7384
]) assert.throws(() => resolvePluginDataRoot({ env: { CODEX_HOME: codexHome }, pluginRoot }), { code: 'PLUGIN_DATA_ROOT_INVALID' });
7485
});

tests/setup.test.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,46 @@ test('already enabled and trusted hooks still install the managed Rescue role be
109109
const zcodeCalls = (await readFile(ctx.zcodeRecord, 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse); assert.deepEqual(zcodeCalls.map((call) => call.method), ['session/create', 'session/stop']);
110110
});
111111

112+
test('setup accepts Codex normalized null Role defaults while preserving the exact managed layer', async () => {
113+
const ctx = await context({
114+
hooks: hookMetadata(root, 'trusted'),
115+
features: { hooks: true },
116+
codexEnv: { FAKE_CODEX_EFFECTIVE_ROLE_DEFAULTS_JSON: '{"nickname_candidates":null}' },
117+
});
118+
const report = await runSetup(ctx.options);
119+
assert.equal(report.status, 'restart-required', JSON.stringify(report));
120+
const paths = managedRolePaths(await realpath(ctx.dataRoot));
121+
assert.equal((await stat(paths.rolePath)).isFile(), true);
122+
assert.equal((await stat(paths.receiptPath)).isFile(), true);
123+
const calls = (await readFile(ctx.record, 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse);
124+
const batch = calls.find((call) => call.method === 'config/batchWrite');
125+
assert.deepEqual(batch.params.edits.find((edit) => edit.keyPath === 'agents.zcode-rescue').value, {
126+
description: MANAGED_ROLE_DESCRIPTION,
127+
config_file: paths.rolePath,
128+
});
129+
const fresh = await runSetup({ ...ctx.options, sessionStartedAt: '2999-01-01T00:00:00.000Z' });
130+
assert.equal(fresh.status, 'ready', JSON.stringify(fresh));
131+
const allCalls = (await readFile(ctx.record, 'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse);
132+
assert.equal(allCalls.filter((call) => call.method === 'config/batchWrite').length, 1);
133+
});
134+
135+
test('setup rejects non-null or unknown effective Role defaults', async (t) => {
136+
for (const [name, defaults] of [
137+
['non-null nickname candidates', { nickname_candidates: ['foreign'] }],
138+
['unknown normalized field', { foreign_default: null }],
139+
]) await t.test(name, async () => {
140+
const ctx = await context({
141+
hooks: hookMetadata(root, 'trusted'),
142+
features: { hooks: true },
143+
codexEnv: { FAKE_CODEX_EFFECTIVE_ROLE_DEFAULTS_JSON: JSON.stringify(defaults) },
144+
});
145+
await assert.rejects(runSetup(ctx.options), { code: 'MANAGED_ROLE_RECONCILE_FAILED' });
146+
const paths = managedRolePaths(await realpath(ctx.dataRoot));
147+
await assert.rejects(stat(paths.rolePath), { code: 'ENOENT' });
148+
await assert.rejects(stat(paths.receiptPath), { code: 'ENOENT' });
149+
});
150+
});
151+
112152
test('setup accepts Codex sha256-prefixed hook hashes and persists their trust state', async (t) => {
113153
const prefixed = hookMetadata(root).map((hook, index) => ({ ...hook, currentHash: `sha256:${index.toString(16).repeat(64)}` }));
114154
const ctx = await context({ hooks: prefixed });

0 commit comments

Comments
 (0)