business: row 199 agent identity, publish grant, project upstream with the D9c parse, store credential references; git.push.remote as a hard wait (#1697)

This commit is contained in:
jason.woltje committed 2026-10-20 14:45:44 -05:00
1 parent 4d3007ec95
commit 286bf598c0
7 files changed
+257 -20

No files matched your search

+2
View File
@@ -18,6 +18,8 @@ export const ACTIONS = Object.freeze([
'git.push.working', 'git.push.working',
'git.push.protected', 'git.push.protected',
'git.merge.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.request',
'review.verdict', 'review.verdict',
'record.write', 'record.write',
+1 -1
View File
@@ -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). // 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', 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 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 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) }); const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
+93 -8
View File
@@ -40,7 +40,7 @@ function checkBot(tracker, where) {
return { bot: tracker.bot, botId: tracker.botId }; return { bot: tracker.bot, botId: tracker.botId };
} }
function checkCredentialMap(map, services, where) { function checkCredentialMap(map, services, where, store = null) {
requireObject(map, where); requireObject(map, where);
const names = Object.keys(map).sort(); const names = Object.keys(map).sort();
const wanted = [...services].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"})`); refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
} }
const out = {}; 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; 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]/<user>/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) { function checkTracker(tracker, file) {
const where = `${file} tracker`; const where = `${file} tracker`;
requireObject(tracker, where); requireObject(tracker, where);
@@ -74,7 +138,7 @@ function checkTracker(tracker, file) {
return { sync, labels }; return { sync, labels };
} }
function checkRoles(roles, file, rolesDir) { function checkRoles(roles, file, rolesDir, business) {
const where = `${file} roles`; const where = `${file} roles`;
requireObject(roles, where); requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`); 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`); requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`; const at = `${where}.${instance}`;
requireObject(entry, at); 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`); requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition); const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition; definitions[entry.definition] = definition;
@@ -108,8 +172,18 @@ function checkRoles(roles, file, rolesDir) {
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`), holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`), vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker, 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 }; return { roles: out, definitions };
} }
@@ -159,7 +233,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
} }
const human = requireId(document.human, "business human"); const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`); 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`); requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${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)) { for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`); requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${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")) { 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`); 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"))) { normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`); 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); const tracker = checkTracker(document.tracker, file);
+29 -8
View File
@@ -3,7 +3,7 @@
// lstat and realpath only; nothing in this package opens a token file. // lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs"; 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 { refuse } from "./errors.mjs";
import { SERVICES } from "./vocabulary.mjs"; import { SERVICES } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs"; import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
@@ -14,21 +14,35 @@ const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env, // Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }. // rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
export function parseCredentialRef(ref, service, where) { // file at <configDir>/secrets/agents/<business>/<agent>/<service>, 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)}`); if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where); requireObject(ref, where);
const dateKey = DATE_KEY[service]; const dateKey = DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", dateKey], where); rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined; const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== 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 }; 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")) { 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`); refuse(`${where}.file must be a normalized absolute path`);
} }
out.file = ref.file; 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 { } else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`); if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env; out.env = ref.env;
@@ -38,6 +52,11 @@ export function parseCredentialRef(ref, service, where) {
return Object.freeze(out); 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) { function inside(root, path) {
const rel = relative(root, path); const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
@@ -55,8 +74,10 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
let stat = null; let stat = null;
try { try {
stat = lstatSync(ref.file); stat = lstatSync(ref.file);
} catch { } catch (e) {
problems.push(`${label}: not found`); // 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) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`); if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
+5 -2
View File
@@ -20,6 +20,9 @@ export const ACTIONS = Object.freeze([
"git.push.working", "git.push.working",
"git.push.protected", "git.push.protected",
"git.merge.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.request",
"review.verdict", "review.verdict",
"record.write", "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, // 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 // 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, // crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected and // account.create, credential.mint, message.external, git.push.protected,
// deploy; a test pins the list. // git.push.remote and deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action))); 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 // pi's documented built-in tools; the same list scripts/mosaic-task.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: "[email protected]", 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:[email protected]/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");
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
import { tmp } from "./helpers.mjs"; import { tmp } from "./helpers.mjs";
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external", 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 LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
const role = (authority) => { const role = (authority) => {
const dir = tmp(); const dir = tmp();