From 286bf598c07bdbac918e3bd5e4a03b79b3c9bf1f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 14:45:44 -0500 Subject: [PATCH] business: row 199 agent identity, publish grant, project upstream with the D9c parse, store credential references; git.push.remote as a hard wait (#1697) --- packages/bus/src/broker.mjs | 2 + packages/bus/tests/hard-waits.test.mjs | 2 +- packages/business/src/business.mjs | 101 ++++++++++++-- packages/business/src/credentials.mjs | 37 +++-- packages/business/src/vocabulary.mjs | 7 +- .../business/tests/agent-identity.test.mjs | 126 ++++++++++++++++++ packages/business/tests/hard-waits.test.mjs | 2 +- 7 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 packages/business/tests/agent-identity.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index f52a47386..33d30f058 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -18,6 +18,8 @@ export const ACTIONS = Object.freeze([ 'git.push.working', 'git.push.protected', 'git.merge.protected', + // Row 199 (#1697, mos-1027 Q-M2): publish to the agent's fork. Not in ROUTED, so a hard wait. + 'git.push.remote', 'review.request', 'review.verdict', 'record.write', diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs index 31e3e2d3a..205da8978 100644 --- a/packages/bus/tests/hard-waits.test.mjs +++ b/packages/bus/tests/hard-waits.test.mjs @@ -10,7 +10,7 @@ import { fixture } from './spec-fixtures.mjs'; // PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633). const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external', - 'git.push.protected', 'deploy']; + 'git.push.protected', 'git.push.remote', 'deploy']; const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke']; const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit }); const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }); diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs index aad1187bd..b780360da 100644 --- a/packages/business/src/business.mjs +++ b/packages/business/src/business.mjs @@ -40,7 +40,7 @@ function checkBot(tracker, where) { return { bot: tracker.bot, botId: tracker.botId }; } -function checkCredentialMap(map, services, where) { +function checkCredentialMap(map, services, where, store = null) { requireObject(map, where); const names = Object.keys(map).sort(); const wanted = [...services].sort(); @@ -48,10 +48,74 @@ function checkCredentialMap(map, services, where) { refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`); } const out = {}; - for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`); + for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store }); return out; } +// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user +// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to. +// Gitea's name rule for users and repositories (design D9c): letters, digits, "_", "." and "-", not "." +// or "..". +const giteaName = (name) => typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== ".."; +const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/; +const NAME = /^[^\0\n\r<>]{1,100}$/; + +// Row 199 (design D9c): an https URL that is already its own normalized form, so a dot segment, an +// encoded dot, userinfo, a query, a fragment, an upper-case host or an explicit :443 each refuse, and the +// path checked is the path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); +// without, it is "/". Returns {url, owner, repo}. +export function httpsUrl(value, where, { repo = false } = {}) { + let url = null; + try { + url = typeof value === "string" ? new URL(value) : null; + } catch { + url = null; + } + const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]); + if (!url || url.protocol !== "https:" || !normal.includes(value)) { + refuse(`${where} must be a normalized https URL with no user, query or fragment`); + } + if (!repo) { + if (url.pathname !== "/") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`); + return { url }; + } + const segments = url.pathname.split("/").slice(1); + const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1]; + if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) { + refuse(`${where} must name a repository, https://HOST/OWNER/REPO`); + } + return { url, owner: segments[0], repo: name }; +} + +// Row 199 (design D9 step 2): the agent's fork of an upstream, https://HOST[:PORT]//REPO. The +// owner is always the agent's own Gitea user; nothing else names it. URL.host drops a default port, +// so 443 counts the same on both sides. +export function forkUrl(upstream, user) { + const { url, repo } = httpsUrl(upstream, "remote", { repo: true }); + return `https://${url.host}/${user}/${repo}`; +} + +// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares +// names without case (lower_name), so the owner check does too. +export function forkProblem(upstream, identity) { + const { url, owner } = httpsUrl(upstream, "remote", { repo: true }); + if (url.host !== httpsUrl(identity.gitea.url, "identity.gitea.url").url.host) return "remote-host-mismatch"; + if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork"; + return null; +} + +function checkIdentity(identity, where) { + requireObject(identity, where); + rejectUnknownKeys(identity, ["name", "email", "gitea"], where); + if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`); + if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`); + requireObject(identity.gitea, `${where}.gitea`); + rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`); + httpsUrl(identity.gitea.url, `${where}.gitea.url`); + if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`); + return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } }; +} + function checkTracker(tracker, file) { const where = `${file} tracker`; requireObject(tracker, where); @@ -74,7 +138,7 @@ function checkTracker(tracker, file) { return { sync, labels }; } -function checkRoles(roles, file, rolesDir) { +function checkRoles(roles, file, rolesDir, business) { const where = `${file} roles`; requireObject(roles, where); if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`); @@ -86,7 +150,7 @@ function checkRoles(roles, file, rolesDir) { requireId(instance, `${where} instance name`); const at = `${where}.${instance}`; requireObject(entry, at); - rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at); + rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at); requireId(entry.definition, `${at}.definition`); const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition); definitions[entry.definition] = definition; @@ -108,8 +172,18 @@ function checkRoles(roles, file, rolesDir) { holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`), vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`), tracker, - credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`), + credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`, + { configDir: dirname(dirname(file)), business, agent: instance }), }; + // Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files + // load unchanged. publish needs both an identity and a gitea credential; the project ids it + // names are checked against projects once those are read. + if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`); + if (entry.publish !== undefined) { + // N10: one message for both, naming the grant. + if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`); + out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true }); + } } return { roles: out, definitions }; } @@ -159,7 +233,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) { } const human = requireId(document.human, "business human"); const vars = checkVars(document.vars ?? {}, "business", `${file} vars`); - const { roles, definitions } = checkRoles(document.roles, file, rolesDir); + const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id); requireObject(document.arbiters, `${file} arbiters`); rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`); @@ -183,7 +257,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) { for (const [id, entry] of Object.entries(document.projects)) { requireId(id, `${file} project id`); requireObject(entry, `${file} projects.${id}`); - rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`); + rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`); if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) { refuse(`${file} projects.${id}.root must be a normalized absolute path`); } @@ -191,7 +265,18 @@ export function validateBusinessDocument(document, file, { rolesDir }) { normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) { refuse(`${file} projects.${id}.repo must be a normalized absolute path`); } - projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) }; + // Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork. + if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true }); + projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}), + ...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) }; + } + for (const [instance, role] of Object.entries(roles)) { + for (const project of role.publish ?? []) { + if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`); + if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`); + const problem = forkProblem(projects[project].remote, role.identity); + if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`); + } } const tracker = checkTracker(document.tracker, file); diff --git a/packages/business/src/credentials.mjs b/packages/business/src/credentials.mjs index 1c6fcc250..23ee17125 100644 --- a/packages/business/src/credentials.mjs +++ b/packages/business/src/credentials.mjs @@ -3,7 +3,7 @@ // lstat and realpath only; nothing in this package opens a token file. import { lstatSync, realpathSync } from "node:fs"; -import { isAbsolute, normalize, relative, sep } from "node:path"; +import { isAbsolute, join, normalize, relative, sep } from "node:path"; import { refuse } from "./errors.mjs"; import { SERVICES } from "./vocabulary.mjs"; import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs"; @@ -14,21 +14,35 @@ const WARN_DAYS = 7; const DAY_MS = 24 * 60 * 60 * 1000; // Shape check for one reference. Returns a frozen { service, file | env, -// rotateBy | expires }. -export function parseCredentialRef(ref, service, where) { +// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the +// file at /secrets/agents///, derived +// here and never written, so an entry can't name another agent's file. A +// reference with no agent (the tracker's sync bot) passes store = null and +// can't use the form. A `file` reference into the agents' store must be the +// agent's own path (`credential-not-yours`). +export function parseCredentialRef(ref, service, where, { store = null } = {}) { if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`); requireObject(ref, where); const dateKey = DATE_KEY[service]; - rejectUnknownKeys(ref, ["file", "env", dateKey], where); + rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where); const hasFile = ref.file !== undefined; const hasEnv = ref.env !== undefined; - if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`); + const hasStore = ref.store !== undefined; + if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`); const out = { service }; - if (hasFile) { + if (hasStore) { + if (ref.store !== true) refuse(`${where}.store must be true`); + if (!store) refuse(`${where}: a "store" reference needs an agent`); + out.store = true; + out.file = storePath(store, service); + } else if (hasFile) { if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) { refuse(`${where}.file must be a normalized absolute path`); } out.file = ref.file; + if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) { + refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`); + } } else { if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`); out.env = ref.env; @@ -38,6 +52,11 @@ export function parseCredentialRef(ref, service, where) { return Object.freeze(out); } +// The store path for one agent and service (design D1). +export function storePath({ configDir, business, agent }, service) { + return join(configDir, "secrets", "agents", business, agent, service); +} + function inside(root, path) { const rel = relative(root, path); return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); @@ -55,8 +74,10 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), let stat = null; try { stat = lstatSync(ref.file); - } catch { - problems.push(`${label}: not found`); + } catch (e) { + // Row 199 (design D3a): a store reference with no file is an agent not issued yet. + if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`); + else problems.push(`${label}: not found`); } if (stat) { if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`); diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs index 32b93fa40..4e98468dd 100644 --- a/packages/business/src/vocabulary.mjs +++ b/packages/business/src/vocabulary.mjs @@ -20,6 +20,9 @@ export const ACTIONS = Object.freeze([ "git.push.working", "git.push.protected", "git.merge.protected", + // Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its + // business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it. + "git.push.remote", "review.request", "review.verdict", "record.write", @@ -83,8 +86,8 @@ export const ROUTED = Object.freeze([ // The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's, // cited spec or not. A role file may not list these under withinRole or // crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose, -// account.create, credential.mint, message.external, git.push.protected and -// deploy; a test pins the list. +// account.create, credential.mint, message.external, git.push.protected, +// git.push.remote and deploy; a test pins the list. export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action))); // pi's documented built-in tools; the same list scripts/mosaic-task.mjs diff --git a/packages/business/tests/agent-identity.test.mjs b/packages/business/tests/agent-identity.test.mjs new file mode 100644 index 000000000..bbbb62e1f --- /dev/null +++ b/packages/business/tests/agent-identity.test.mjs @@ -0,0 +1,126 @@ +// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the +// credential store reference. Design D1, D8, D9 step 2 and D9c. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs"; +import { forkProblem, forkUrl } from "../src/business.mjs"; +import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs"; + +function refuses(fn, pattern) { + assert.throws(fn, (error) => { + assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); + assert.equal(error.exitCode, 2); + assert.match(error.message, pattern); + return true; + }); +} + +const IDENTITY = { name: "Coder", email: "coder@example.com", gitea: { url: "https://git.example", user: "acme-coder" } }; + +// The fixture with coder granted publish on stack, whose upstream is mosaic/stack. +function check(mutate) { + const root = tmp(); + const doc = businessDoc(root); + doc.projects.stack.remote = "https://git.example/mosaic/stack"; + doc.roles.coder.identity = structuredClone(IDENTITY); + doc.roles.coder.publish = ["stack"]; + mutate?.(doc, root); + return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); +} + +test("today's files load unchanged: identity, publish and remote are optional", () => { + const root = tmp(); + const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); + assert.equal(business.roles.coder.identity, undefined); + assert.equal(business.roles.coder.publish, undefined); + assert.equal(business.projects.stack.remote, undefined); +}); + +test("a granted agent loads with its identity, grant and the project's upstream", () => { + const business = check(); + assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY); + assert.deepEqual([...business.roles.coder.publish], ["stack"]); + assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack"); +}); + +test("N10: publish needs identity and a gitea definition, and the error names the grant", () => { + refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/); + refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }), + /roles\.chief\.publish needs identity and a gitea definition/); +}); + +test("publish names declared projects that have a remote, each once", () => { + refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/); + refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/); + refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/); + refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/); +}); + +test("identity fields are checked", () => { + refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/); + refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/); + refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/); + refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/); + refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/); +}); + +test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => { + const bad = { + "a dot segment": "https://git.example/acme-coder/../mosaic/stack", + "an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack", + "an extra segment": "https://git.example/mosaic/stack/more", + "a missing segment": "https://git.example/stack", + "a trailing slash": "https://git.example/mosaic/stack/", + userinfo: "https://u:p@git.example/mosaic/stack", + "a query": "https://git.example/mosaic/stack?x=1", + "a fragment": "https://git.example/mosaic/stack#f", + "an explicit :443": "https://git.example:443/mosaic/stack", + "an upper-case host": "https://GIT.example/mosaic/stack", + "a backslash": "https://git.example\\mosaic\\stack", + "a name outside Gitea's rule": "https://git.example/mosaic/st%41ck", + "a dot-dot repo": "https://git.example/mosaic/...git", + http: "http://git.example/mosaic/stack", + }; + for (const [what, remote] of Object.entries(bad)) { + assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what); + } +}); + +test("D9c: one .git is stripped, and the fork is built from the stripped name", () => { + assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote, + "https://git.example/mosaic/stack.git"); + assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack"); + assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack"); +}); + +test("D9c: gitea.url names the instance only", () => { + for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) { + assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url); + } + check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; }); +}); + +test("the upstream's host and port must be the agent's Gitea's", () => { + refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/); + refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/); + assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null); +}); + +test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => { + refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/); + refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/); +}); + +test("store references: own path only (design D1, D3a)", () => { + const store = { configDir: "/cfg", business: "acme", agent: "coder" }; + const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store }); + assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea"); + assert.equal(ref.store, true); + refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/); + refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/); + refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }), + /credential-not-yours/); + assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file, + "/cfg/secrets/agents/acme/coder/gitea"); +}); diff --git a/packages/business/tests/hard-waits.test.mjs b/packages/business/tests/hard-waits.test.mjs index df0e104f1..e87a82cfa 100644 --- a/packages/business/tests/hard-waits.test.mjs +++ b/packages/business/tests/hard-waits.test.mjs @@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs"; import { tmp } from "./helpers.mjs"; const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external", - "git.push.protected", "deploy"]; + "git.push.protected", "deploy", "git.push.remote"]; const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"]; const role = (authority) => { const dir = tmp();