feat(seats): the env file's settings count, and a machine's own seats file
ApiConfig and PoolConfig read the seat order, the budgets and the concurrency from the process environment alone, so a `.env` that said REVIEW_GATES_SEAT_ORDER=oauth-first ordered nothing: the Python driver reads those lines from the file under the process environment (config_env, 2026-10-17: a setting lives beside the seats it orders). SeatPool::SettingsOf does the same - the file's placement settings under the process environment, in a map handed to the placement readers alone (ApiConfig, PoolConfig, Seats::ConfigFromEnv) - and leaves the process environment untouched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one .env keep one board and one ledger. The placement settings are one list, SeatPool::PlacementSettings - the fifteen names those readers read - for the map, the run's log and what makes a working directory's .env a seats file; a REVIEW_GATES_* line of any other name stays out of the map and is named, by the run's log and on `seats`'s stderr. One dotenv grammar serves the tokens and the settings (Seats::DotenvLines). `run` and `seats` resolve the file from their own --env-file (SeatPool::ResolveSeatsFile): the --env-file, else $SEATS_ENV_FILE, else the working directory's .env - the operator's, as the Python driver and `seats` read it, not the repository under review's - when it is a file holding a seat or a placement setting, each with a value, or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application .env, its Vite or Supabase lines, one of blank placeholder lines or one that is no file is passed over, and the run says so - else the machine's own $XDG_CONFIG_HOME/crafter-review/env, else ~/.config/crafter-review/env: one file for every repository on a machine, a repository's seats .env winning over it. Each file is read once and the settings and the tokens are taken from that read (SeatsFile::Text), so a file that can be read only once - a pipe, `--env-file <(gpg -d seats.env.gpg)`, a FIFO - gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment: SeatPool::WithFileTokens, for `run` and `seats` alike, and LoadSeats takes the merged environment and no longer reads a file by path. An empty `seats --env-file` means no file now, as it did for `run`, and the `seats` report names the side that runs first (it said "api seats run first" whatever the order). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point XDG_CONFIG_HOME, and HOST_XDG_CONFIG_HOME for the Flatpak, at a path of their own that nothing creates, so a global file stays out of them; the grammar, the settings and their precedence, the lookup and the one read have their tests, a run with `--env` (a prefix) and with a repeated --env-file, and a `seats` with `--env`, take tokens and settings from the one file, an application's .env and a venv are passed over, a FIFO is read once, no line of the file reaches `stop`, every listed placement setting moves a reader, another REVIEW_GATES_* line is named and a .env of blank lines passed over, and the README, the environment table and the procedure say the new rule.
This commit is contained in:
parent
0e0983f278
commit
e215ad986e
16 changed files with 483 additions and 91 deletions
|
|
@ -71,7 +71,11 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
|
|||
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY");
|
||||
if (!review && !seat) Env[k] = v;
|
||||
}
|
||||
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
|
||||
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
|
||||
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
|
||||
// over it - a directory of this run's own, which nothing creates.
|
||||
const std::string noConfig = (base / "no-config").string();
|
||||
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
|
||||
{"REVIEW_GATES_BOARD", (base / "board.json").string()}, {"REVIEW_GATES_SCRATCH", (base / "scratch").string()}, {"TZ", "UTC"}});
|
||||
auto repo = [&](std::string_view name) {
|
||||
const fs::path dir = base / name;
|
||||
|
|
@ -196,6 +200,22 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
|
|||
Check("stop --hash: every live run of the hash on the board gets its stop file (the first 8 characters decide); another hash finds none (exit 1); the board is read, never written",
|
||||
byHash.returnCode == 0 && wroteHash && otherHash.returnCode == 1 && otherHash.err.contains("no live run of 00000000 on the board") && Read(base / "board.json") == boardBefore,
|
||||
byHash.out + byHash.err + otherHash.err);
|
||||
// Only `run` and `seats` read the seats file, and its lines reach the
|
||||
// placement readers alone (rounds 8 and 11 of the self-review): a
|
||||
// `stop` with the board path only in the working directory's .env
|
||||
// looks at the machine's default board, as every driver does - under
|
||||
// a HOME of the test's own here.
|
||||
std::map<std::string, std::string> envNoPaths = Env;
|
||||
envNoPaths.erase("REVIEW_GATES_BOARD");
|
||||
envNoPaths.erase("REVIEW_GATES_SCRATCH");
|
||||
envNoPaths["HOME"] = (base / "home").string();
|
||||
const fs::path fileRepo = base / "file-repo";
|
||||
fs::create_directories(fileRepo);
|
||||
Write(fileRepo / ".env", std::format("REVIEW_GATES_BOARD={}\nREVIEW_GATES_SCRATCH={}\n", (base / "file-board.json").string(), (base / "file-scratch").string()));
|
||||
const Process::Captured fromFile = Process::Run({Platform::SelfExe().string(), "stop", "--hash", "00000000"}, fileRepo, std::chrono::seconds(60), {}, envNoPaths);
|
||||
Check("no line of the seats file reaches `stop`: with the board's and the scratch root's paths only in the working directory's .env it reads the machine's default board", fromFile.returnCode == 1
|
||||
&& fromFile.err.contains(std::format("on the board ({})", (base / "home" / ".cache" / "review-gates" / "seat-board.json").string())) && !fromFile.err.contains("file-board"),
|
||||
fromFile.err);
|
||||
|
||||
// seats on the stand-in: a token from the env file, an API key, the
|
||||
// CLI's own login.
|
||||
|
|
@ -207,15 +227,41 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
|
|||
const std::map<std::string, std::string> withFake = {{"CLAUDE_BIN", fake.string()}};
|
||||
const Process::Captured report = Self({"seats", "--local", "--env-file", ".env"}, base / "seats", withFake);
|
||||
const Process::Captured json = Self({"seats", "--json", "--no-fable"}, base / "seats", withFake);
|
||||
// --env (a prefix of --env-file) names the file for the tokens and
|
||||
// the settings alike, with ./.env in the way (the API seat's section
|
||||
// is where the order shows).
|
||||
Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
|
||||
const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake);
|
||||
// An application's .env (no seat line, no setting) is passed over for
|
||||
// the machine's own seats file.
|
||||
Write(base / "app" / ".env", "VITE_API_URL=http://localhost:54321\n");
|
||||
Write(base / "app-xdg" / "crafter-review" / "env", "CLAUDE_CODE_OAUTH_TOKEN3=tok-three\n");
|
||||
const Process::Captured appSeats =
|
||||
Self({"seats", "--no-fable"}, base / "app", {{"CLAUDE_BIN", fake.string()}, {"XDG_CONFIG_HOME", (base / "app-xdg").string()}, {"HOST_XDG_CONFIG_HOME", (base / "app-xdg").string()}});
|
||||
// A seats file that can be read only once - a FIFO, as `--env-file
|
||||
// <(gpg -d seats.env.gpg)` gives one - is read once: its settings and
|
||||
// its tokens come from that one read (round 10 of the self-review).
|
||||
fs::create_directories(base / "fifo");
|
||||
const Process::Captured fromFifo = Process::Run(
|
||||
{"sh", "-c",
|
||||
std::format("mkfifo seats.fifo && (printf 'CLAUDE_CODE_OAUTH_TOKEN4=tok-four\\nANTHROPIC_API_KEY4=sk-ant-api-four\\nREVIEW_GATES_SEAT_ORDER=oauth-first\\n' > seats.fifo &) && exec '{}' seats --no-fable --env-file seats.fifo",
|
||||
Platform::SelfExe().string())},
|
||||
base / "fifo", std::chrono::seconds(60), {}, With(Env, withFake));
|
||||
Json parsed;
|
||||
try {
|
||||
parsed = Json::parse(json.out);
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board",
|
||||
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board; an application's .env "
|
||||
"is passed over for the machine's own file; a FIFO is read once, its settings and its seats alike",
|
||||
report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ")
|
||||
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running"),
|
||||
report.out + report.err);
|
||||
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running")
|
||||
&& other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | $0.00 of $200")
|
||||
&& other.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)") && !other.out.contains("tok-two") && !other.out.contains("sk-ant-api-two")
|
||||
&& appSeats.returnCode == 0 && appSeats.out.starts_with(std::format("seat pool: 1 seat(s) (3) from {}, probed ", (base / "app-xdg" / "crafter-review" / "env").string()))
|
||||
&& fromFifo.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ")
|
||||
&& fromFifo.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)"),
|
||||
report.out + report.err + other.out + other.err + appSeats.out + appSeats.err + fromFifo.out + fromFifo.err);
|
||||
Check("seats --json: the probe results as JSON - the seats, the API seats, the eligible ones and the next placement; ./.env read by default; no token, no key printed",
|
||||
json.returnCode == 0 && parsed.is_object() && parsed.at("seats").size() == 1 && parsed.at("seats").at(0).at("probedWith") == "haiku" && parsed.at("apiSeats").size() == 1
|
||||
&& parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z")
|
||||
|
|
|
|||
|
|
@ -150,7 +150,11 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
|
|||
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY") || k.starts_with("FAKE_");
|
||||
if (!review && !seat) Env[k] = v;
|
||||
}
|
||||
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()}, {"REVIEW_GATES_BOARD", (base / "board.json").string()}});
|
||||
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
|
||||
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
|
||||
// over it - a directory of this run's own, which nothing creates.
|
||||
const std::string noConfig = (base / "no-config").string();
|
||||
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()}, {"REVIEW_GATES_BOARD", (base / "board.json").string()}});
|
||||
|
||||
// ---- the inventory --------------------------------------------------------------
|
||||
const Inventory::Built draft = Inventory::Build("reply", (fix / "draft-camera.txt").string());
|
||||
|
|
|
|||
|
|
@ -60,15 +60,18 @@ namespace {
|
|||
|
||||
const fs::path envFile = td / ".env";
|
||||
Write(envFile, "CLAUDE_CODE_OAUTH_TOKEN1=tok\nOPENROUTER_KEY=sk-or-one\n");
|
||||
// The seats file as run and seats take it: one read, its tokens over
|
||||
// an environment (an empty one here).
|
||||
const std::map<std::string, std::string> fileEnv = SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(envFile.string(), td));
|
||||
auto ids = [](const std::vector<Json>& seats) {
|
||||
std::vector<std::string> out;
|
||||
for (const Json& s : seats) out.push_back(s.at("id").get<std::string>());
|
||||
return out;
|
||||
};
|
||||
Check("OPENROUTER_KEY is a seat only on request",
|
||||
ids(SeatPool::LoadSeats({}, envFile, "")) == std::vector<std::string>{"1"} && ids(SeatPool::LoadSeats({}, envFile, "openrouter")) == std::vector<std::string>{"or1"});
|
||||
ids(SeatPool::LoadSeats(fileEnv, "")) == std::vector<std::string>{"1"} && ids(SeatPool::LoadSeats(fileEnv, "openrouter")) == std::vector<std::string>{"or1"});
|
||||
try {
|
||||
SeatPool::LoadSeats({}, envFile, "or1,1");
|
||||
SeatPool::LoadSeats(fileEnv, "or1,1");
|
||||
Check("an OpenRouter seat never shares a pool with Anthropic seats", false);
|
||||
} catch (const std::invalid_argument& e) {
|
||||
Check("an OpenRouter seat never shares a pool with Anthropic seats", std::string_view(e.what()).contains("cannot share"), e.what());
|
||||
|
|
|
|||
|
|
@ -192,7 +192,11 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
|
|||
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY") || k.starts_with("FAKE_");
|
||||
if (!review && !seat) Env[k] = v;
|
||||
}
|
||||
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"GIT_AUTHOR_NAME", "Me"}, {"GIT_AUTHOR_EMAIL", "me@example.com"},
|
||||
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
|
||||
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
|
||||
// over it - a directory of this run's own, which nothing creates.
|
||||
const std::string noConfig = (base / "no-config").string();
|
||||
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"GIT_AUTHOR_NAME", "Me"}, {"GIT_AUTHOR_EMAIL", "me@example.com"},
|
||||
{"GIT_COMMITTER_NAME", "Me"}, {"GIT_COMMITTER_EMAIL", "me@example.com"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
|
||||
{"REVIEW_GATES_BOARD", (base / "board.json").string()}, {"FAKE_DIR", fakeDir.string()}});
|
||||
|
||||
|
|
@ -395,6 +399,38 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
|
|||
Check("preflight: an env file that is not UTF-8 stops the run, exit 3, with the decoder's own words",
|
||||
badEnv.returnCode == 3 && badEnv.err.contains("preflight: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte"), badEnv.err);
|
||||
|
||||
// One seats file for the settings and the tokens: the one --env-file
|
||||
// names as the run's own parser reads it - a unique prefix, the last
|
||||
// of a repeat - with the machine's own file as the decoy the usual
|
||||
// lookup would find; the log names it with the settings put in.
|
||||
const fs::path decoyXdg = base / "decoy-xdg";
|
||||
Write(decoyXdg / "crafter-review" / "env", "REVIEW_GATES_API_BUDGET_USD=5\nCLAUDE_CODE_OAUTH_TOKEN1=tok-decoy\nANTHROPIC_API_KEY1=sk-ant-decoy\n");
|
||||
Write(base / "other.env", "REVIEW_GATES_API_BUDGET_USD=7\nREVIEW_GATES_CLAUDE_BIN=/nowhere/claude\nCLAUDE_CODE_OAUTH_TOKEN2=tok-other\nANTHROPIC_API_KEY2=sk-ant-other\n");
|
||||
const std::map<std::string, std::string> withDecoy = {{"XDG_CONFIG_HOME", decoyXdg.string()}, {"HOST_XDG_CONFIG_HOME", decoyXdg.string()}};
|
||||
const std::string other = (base / "other.env").string();
|
||||
const Process::Captured prefixed = run({"--env", other, "--seats", "auto", "--dry-run", "--scratch", (base / "env-prefix").string()}, withDecoy);
|
||||
const Process::Captured repeated = run({"--env-file", (decoyXdg / "crafter-review" / "env").string(), "--env-file", other, "--seats", "auto", "--dry-run", "--scratch", (base / "env-repeat").string()}, withDecoy);
|
||||
auto oneFile = [&](const Process::Captured& dry) {
|
||||
return dry.returnCode == 2 && dry.out.contains(std::format("settings: REVIEW_GATES_API_BUDGET_USD from {} (under", other)) && dry.out.contains("api2 (api)") && dry.out.contains("2 (oauth)")
|
||||
&& dry.out.contains(std::format("seats file: REVIEW_GATES_CLAUDE_BIN in {} - no placement setting, read from the process environment alone", other))
|
||||
&& dry.out.contains(std::format("from {}; api-first, budget $7 per api seat per cycle", other)) && !dry.out.contains("api1") && !dry.out.contains("1 (oauth)");
|
||||
};
|
||||
Check("--env (a prefix) and the last of a repeated --env-file name the one file the settings are applied from and the tokens are loaded from, never the decoy",
|
||||
oneFile(prefixed) && oneFile(repeated), std::format("{} {}\n{} {}", prefixed.returnCode, prefixed.out + prefixed.err, repeated.returnCode, repeated.out + repeated.err));
|
||||
// A seats file that can be read only once - a FIFO, as `--env-file
|
||||
// <(gpg -d seats.env.gpg)` gives one - is read once: the run's
|
||||
// settings and its seats both come from that read (round 10).
|
||||
const fs::path fifo = base / "seats.fifo";
|
||||
std::vector<std::string> viaFifo = {
|
||||
"sh", "-c",
|
||||
std::format("mkfifo '{0}' && (printf 'REVIEW_GATES_API_BUDGET_USD=9\\nCLAUDE_CODE_OAUTH_TOKEN5=tok-five\\nANTHROPIC_API_KEY5=sk-ant-five\\n' > '{0}' &) && exec \"$@\"", fifo.string()), "sh"};
|
||||
std::ranges::move(runArgs({"--env-file", fifo.string(), "--seats", "auto", "--dry-run", "--scratch", (base / "env-fifo").string()}), std::back_inserter(viaFifo));
|
||||
const Process::Captured fromFifo = Process::Run(viaFifo, repo, std::chrono::seconds(60), {}, Env);
|
||||
Check("a seats file that can be read only once (a FIFO) is read once: the run's settings and its seats both come from it",
|
||||
fromFifo.returnCode == 2 && fromFifo.out.contains(std::format("settings: REVIEW_GATES_API_BUDGET_USD from {} (under", fifo.string())) && fromFifo.out.contains("api5 (api)")
|
||||
&& fromFifo.out.contains("5 (oauth)") && fromFifo.out.contains(std::format("from {}; api-first, budget $9 per api seat per cycle", fifo.string())),
|
||||
std::format("{} {}{}", fromFifo.returnCode, fromFifo.out, fromFifo.err));
|
||||
|
||||
// A filter that names nothing is refused: skipping its empty items
|
||||
// must not leave no filter, which is a full run.
|
||||
const Process::Captured noUnits = run({"--units", ","});
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ int main() {
|
|||
const Json acfg = SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", "10"}, {"REVIEW_GATES_API_LENS_COST_USD", "1"}, {"REVIEW_GATES_API_CONCURRENCY", "2"}});
|
||||
const Json cfg = Seats::ConfigFromEnv({});
|
||||
SeatPool::Ledger ledger(td / "ledger.json", acfg);
|
||||
std::vector<Json> seats = SeatPool::LoadSeats({}, envFile, "", &ledger, acfg);
|
||||
std::vector<Json> seats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(envFile.string(), td)), "", &ledger, acfg);
|
||||
Check(".env: api seats, oauth seats, never the bare ANTHROPIC_API_KEY, local only when nothing is configured",
|
||||
Ids(seats) == "api1,1,2" && Ids(SeatPool::LoadSeats({}, std::nullopt, "")) == "local", Ids(seats));
|
||||
Ids(seats) == "api1,1,2" && Ids(SeatPool::LoadSeats({}, "")) == "local", Ids(seats));
|
||||
const std::string summary = DumpSpaced(SeatPool::Summary(seats));
|
||||
Check("no secret in the summary or the table", !(summary + SeatPool::Table(seats)).contains("tok-one") && !summary.contains("sk-ant"));
|
||||
Check("the 5-hour share of a pass follows the run's effort (0.125 at xhigh, 0.5 at max and any other); the env overrides every effort",
|
||||
|
|
@ -208,6 +208,100 @@ int main() {
|
|||
SeatPool::Describe(described) == "ok: 5h 61.5%, 7d 18%, fable 22%, fable 3% (binding five_hour)" && SeatPool::Describe(paid) == "out (budget), $12.35 spent this cycle, $3.00 left on the key",
|
||||
std::format("{} | {}", SeatPool::Describe(described), SeatPool::Describe(paid)));
|
||||
|
||||
// The seats file (2026-11-01): one dotenv grammar for the tokens and the
|
||||
// REVIEW_GATES_* settings; the settings under the process environment in
|
||||
// the map the placement readers take (SettingsOf), the process
|
||||
// environment itself untouched; the file resolved as --env-file, then
|
||||
// $SEATS_ENV_FILE, <cwd>/.env, the machine's own under $XDG_CONFIG_HOME,
|
||||
// each read once.
|
||||
{
|
||||
const std::vector<std::pair<std::string, std::string>> lines =
|
||||
Seats::DotenvLines("export REVIEW_GATES_SEAT_ORDER='oauth-first'\n REVIEW_GATES_API_BUDGET_USD = \"50\"\nCLAUDE_CODE_OAUTH_TOKEN1=x\n# a comment\nnot a line\n=\nREVIEW_GATES=nope\n");
|
||||
const std::map<std::string, std::string> settings = Seats::SettingsFromEnvFile("export REVIEW_GATES_SEAT_ORDER='oauth-first'\n REVIEW_GATES_API_BUDGET_USD = \"50\"\nCLAUDE_CODE_OAUTH_TOKEN1=x\nREVIEW_GATES=nope\n");
|
||||
Check("the dotenv grammar: `[export ]NAME=value`, blanks around `=` and the value, one pair of matching quotes stripped, everything else skipped; the settings are the REVIEW_GATES_ names; the tokens as before",
|
||||
lines == std::vector<std::pair<std::string, std::string>> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}}
|
||||
&& settings == std::map<std::string, std::string> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}}
|
||||
&& Seats::SeatTokensFromEnvFile("ANTHROPIC_API_KEY=bare\nANTHROPIC_API_KEY2=k\nANTHROPIC_API_KEY2_GRANT_DAY=17\nCLAUDE_CODE_OAUTH_TOKEN=t\nCLAUDE_CODE_OAUTH_TOKEN7=no\n")
|
||||
== std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
|
||||
const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid());
|
||||
fs::remove_all(envTd);
|
||||
fs::create_directories(envTd / "repo");
|
||||
fs::create_directories(envTd / "xdg" / "crafter-review");
|
||||
std::ofstream(envTd / "settings.env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=oauth-first\nREVIEW_GATES_API_BUDGET_USD=50\nREVIEW_GATES_CLAUDE_BIN=./evil\nREVIEW_GATES_SEAT_POLICY=\n";
|
||||
unsetenv("REVIEW_GATES_SEAT_ORDER");
|
||||
unsetenv("REVIEW_GATES_CLAUDE_BIN");
|
||||
unsetenv("REVIEW_GATES_SEAT_POLICY");
|
||||
setenv("REVIEW_GATES_API_BUDGET_USD", "75", 1);
|
||||
const SeatPool::Settings applied = SeatPool::SettingsOf(SeatPool::ResolveSeatsFile((envTd / "settings.env").string(), envTd / "repo"));
|
||||
const Json api = SeatPool::ApiConfig(applied.env);
|
||||
const SeatPool::SeatsFile missing = SeatPool::ResolveSeatsFile((envTd / "none.env").string(), envTd / "repo");
|
||||
std::string raised;
|
||||
try {
|
||||
(void) missing.Text();
|
||||
} catch (const Dyn::Error& error) {
|
||||
raised = error.Kind();
|
||||
}
|
||||
Check("SettingsOf: the file's placement settings under the process environment - the process environment wins - in the map ApiConfig reads, a blank one no setting, its other REVIEW_GATES_* "
|
||||
"lines named and never in the map, the process environment itself untouched (no line of the file reaches another reader); no file gives nothing; one it cannot read gives nothing and Text() raises the "
|
||||
"read's own error",
|
||||
applied.fromFile == std::vector<std::string> {"REVIEW_GATES_SEAT_ORDER"} && applied.ignored == std::vector<std::string> {"REVIEW_GATES_CLAUDE_BIN"}
|
||||
&& !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0
|
||||
&& !Platform::GetEnv("REVIEW_GATES_SEAT_ORDER") && !Platform::GetEnv("REVIEW_GATES_CLAUDE_BIN") && Platform::GetEnv("REVIEW_GATES_API_BUDGET_USD") == "75"
|
||||
&& SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError",
|
||||
Dyn::Str(api) + " " + raised);
|
||||
// Every name on the list moves a placement reader (the list is the
|
||||
// readers' names, not a guess at them).
|
||||
const Json apiDefault = SeatPool::ApiConfig({});
|
||||
const Json poolDefault = SeatPool::PoolConfig({}, "xhigh");
|
||||
std::vector<std::string> idle;
|
||||
for (const std::string_view name : SeatPool::PlacementSettings) {
|
||||
const std::string value = name.ends_with("SEAT_ORDER") ? "oauth-first" : name.ends_with("SEAT_POLICY") ? "pack" : "3";
|
||||
const std::map<std::string, std::string> one {{std::string(name), value}};
|
||||
if (Dyn::Equal(SeatPool::ApiConfig(one), apiDefault) && Dyn::Equal(SeatPool::PoolConfig(one, "xhigh"), poolDefault)) idle.emplace_back(name);
|
||||
}
|
||||
Check("PlacementSettings: every name on the list moves ApiConfig or PoolConfig (which takes Seats::ConfigFromEnv)", idle.empty(), Dyn::Join(idle, ", "));
|
||||
unsetenv("REVIEW_GATES_API_BUDGET_USD");
|
||||
unsetenv("SEATS_ENV_FILE");
|
||||
setenv("XDG_CONFIG_HOME", (envTd / "xdg").string().c_str(), 1);
|
||||
const bool none = !SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path && !SeatPool::GlobalEnvFile();
|
||||
std::ofstream(envTd / "xdg" / "crafter-review" / "env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=oauth-first\n";
|
||||
const std::optional<fs::path> global = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
|
||||
// An application's .env (no seat, no placement setting), one whose only
|
||||
// REVIEW_GATES_* line is no placement setting, one of blank placeholder
|
||||
// lines (an empty token or setting is none), and a .env that is no
|
||||
// file (a Python venv) are passed over and recorded so; one with a
|
||||
// placement setting alone is a seats file, and so is one that is not
|
||||
// UTF-8 (its error is the command's).
|
||||
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "VITE_API_URL=http://localhost:54321\nSUPABASE_ANON_KEY=anon\n";
|
||||
const SeatPool::SeatsFile appFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
|
||||
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=api-first\n";
|
||||
const std::optional<fs::path> settingsOnly = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
|
||||
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "REVIEW_GATES_SCRATCH=/elsewhere\n";
|
||||
const SeatPool::SeatsFile otherSetting = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
|
||||
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "CLAUDE_CODE_OAUTH_TOKEN1=\nANTHROPIC_API_KEY1= \nOPENROUTER_KEY=\nREVIEW_GATES_SEAT_ORDER=\n";
|
||||
const SeatPool::SeatsFile blanks = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
|
||||
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << std::string("\xff\n", 2);
|
||||
const SeatPool::SeatsFile notTextFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
|
||||
fs::remove(envTd / "repo" / ".env");
|
||||
fs::create_directories(envTd / "repo" / ".env");
|
||||
const SeatPool::SeatsFile venv = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
|
||||
fs::remove_all(envTd / "repo" / ".env");
|
||||
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "CLAUDE_CODE_OAUTH_TOKEN1=x\n";
|
||||
const SeatPool::SeatsFile localFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
|
||||
setenv("SEATS_ENV_FILE", (envTd / "settings.env").string().c_str(), 1);
|
||||
const std::optional<fs::path> named = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
|
||||
const std::optional<fs::path> given = SeatPool::ResolveSeatsFile(std::string("explicit.env"), envTd / "repo").path;
|
||||
const std::optional<fs::path> givenNone = SeatPool::ResolveSeatsFile(std::string(), envTd / "repo").path;
|
||||
Check("the seats file resolves as --env-file (an empty one: none), else $SEATS_ENV_FILE, else <cwd>/.env when it is a file holding a seat or a placement setting with a value (an "
|
||||
"application's .env, one whose only REVIEW_GATES_* line is no placement setting, one of blank lines, and a venv passed over and recorded so; one that is not UTF-8 taken, its read error kept), else $XDG_CONFIG_HOME/crafter-review/env, else none; the global file's settings reach SettingsOf",
|
||||
none && global == envTd / "xdg" / "crafter-review" / "env" && appFile.path == global && appFile.passedOver == envTd / "repo" / ".env"
|
||||
&& SeatPool::SettingsOf(appFile).fromFile == std::vector<std::string> {"REVIEW_GATES_SEAT_ORDER"} && settingsOnly == envTd / "repo" / ".env" && otherSetting.path == global && otherSetting.passedOver == envTd / "repo" / ".env" && blanks.path == global && blanks.passedOver == envTd / "repo" / ".env"
|
||||
&& notTextFile.path == envTd / "repo" / ".env" && notTextFile.error && venv.path == global && venv.passedOver == envTd / "repo" / ".env" && !venv.error
|
||||
&& localFile.path == envTd / "repo" / ".env" && !localFile.passedOver && named == envTd / "settings.env" && given == fs::path("explicit.env") && !givenNone);
|
||||
unsetenv("SEATS_ENV_FILE");
|
||||
unsetenv("XDG_CONFIG_HOME");
|
||||
fs::remove_all(envTd);
|
||||
}
|
||||
fs::remove_all(td);
|
||||
return Failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue