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:
1 parent
4d3007ec95
commit
286bf598c0
7 files changed
+257
-20
No files matched your search
@@ -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',
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user