diff --git a/packages/bus/tests/copy-revoke.test.mjs b/packages/bus/tests/copy-revoke.test.mjs index 40356a836..d7b5865b4 100644 --- a/packages/bus/tests/copy-revoke.test.mjs +++ b/packages/bus/tests/copy-revoke.test.mjs @@ -62,11 +62,13 @@ async function world(t) { // they stand, as a restart of the bus process would; boot() runs the gate's boot proof and promotes. // stopRuns acknowledges each run the broker told the launcher to kill, as the launcher's exit does, // unless `ack` is false. The revoke's remote step goes to the organization's admin slot. -async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null } = {}) { +async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'read', definition = 'research', admin = true, adminRotateBy = ahead(60), stopMs = 2000, project = null, gitOnly = false } = {}) { const { root, rolesDir, api } = w; const owner = id === 'demo' ? 'owner' : id; const doc = { ...businessDoc(root, id), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } }; doc.projects.stack.remote = `${api.url}/${owner}/stack`; + // A project set up in git remote mode names its repository there, with no top-level remote. + if (gitOnly) { doc.projects.stack.git = { mode: 'remote', remote: doc.projects.stack.remote }; delete doc.projects.stack.remote; } // The project root the organization service derives (archive and delete check it). doc.projects.stack.root = join(w.data, 'org-runtime', id, 'projects', 'stack'); if (project) doc.projects.stack.agents = project; @@ -651,6 +653,17 @@ test('CB4 an owner update cannot change the repository of a project a copy bindi await orgService.updateOrg(f.ctx, f.id, other, { ifRevision: revision }); }); +test('CB4 the owner\'s project.remote.set cannot change the repository of a project a copy binding still grants; the revoke then finishes at Gitea', async (t) => { + const w = await world(t); + const { f, r } = await live(t, w, { gitOnly: true }); + const { revision } = f.writer.read(f.id); + assert.throws(() => f.writer.setProjectRemote(f.id, { revision, project: 'stack', remote: `${w.api.url}/owner/elsewhere` }, 'jason'), refuses('project-repo-bound')); + assert.equal(f.writer.read(f.id).revision, revision); + const out = await f.call(f.human, 'credentials.revoke', { agent: f.name, service: 'gitea', reason: 'Owner revoke' }); + assert.equal(out.state, 'unbound'); + await assertRevoked(f, r, { cause: 'revoke', code: 'credential-revoked' }); +}); + test('CB4 a remote step whose bound server cannot be read, or whose record names another incarnation, fails and the copy stays fenced', async (t) => { const w = await world(t); const { f } = await live(t, w); diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index 3a8668802..3cf449fa8 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -12,6 +12,7 @@ import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs"; import { checkReplyLimits } from "../../library/src/limits.mjs"; import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs"; import { remoteProblem, remoteTarget } from "./project-remotes.mjs"; +import { REPO_BOUND, projectBindings, repoBoundMessage } from "./repo-remote.mjs"; import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs"; import { REMOTE_OUTCOMES, REVOKE_CAUSES, allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs"; import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs"; @@ -654,13 +655,21 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( // The only exception to the projects fence for an existing software mapping. // Build from the locked document and check both the candidate and serialized bytes. function setProjectRemote(id, { revision, project, remote }, actor) { + // #1873 CB4 (F7): never under a copy binding that still grants the project, as in project repo set + // and the owner's update: a changed repository would strand that binding's remote revoke. + const refuseBound = (old) => { + const bound = projectBindings(old, project); + if (bound.length) fail(REPO_BOUND, repoBoundMessage(project, bound)); + }; const guard = (old, document) => { const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]); if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required"); + refuseBound(old); const problem = remoteProblem(old, document, project, remote); if (problem) fail(problem); }; return commit(id, old => { + refuseBound(old); remoteTarget(old, project, remote); return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } }; }, { revision, actor, guard, detail: { verb: "project.remote.set", project } });