Add fixture-only execution materialization and refresh (#1500)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Increment 2 charter (#1500)
|
# Increment 2 charter (#1500)
|
||||||
|
|
||||||
Status: draft, independent review requested before owner implementation approval.
|
Status: owner APPROVED the fixture-only implementation after prerequisite correction was independently verified and published. Implementation baseline: 27e4873accd4179acad61f1c4673fc65a4fa5f8b. Original draft and prerequisite context follow.
|
||||||
Baseline b3fa221060abb820cd809f15c3a54ba84518b7a1. Owner requested faster continuation of charter drafting. Author/coordinator Darkwing; independent reviewer Filbert; Jason approves implementation. No source changes are authorized by this document yet.
|
Baseline b3fa221060abb820cd809f15c3a54ba84518b7a1. Owner requested faster continuation of charter drafting. Author/coordinator Darkwing; independent reviewer Filbert; Jason approves implementation. The subsequent owner approval authorizes only the fixture-only stages and allowed paths below; no live activation is included.
|
||||||
|
|
||||||
## Outcome and boundary
|
## Outcome and boundary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# M20-I2-ISOLATION-ROCKO-1 — pi auth check/print isolation findings
|
||||||
|
|
||||||
|
Bounded read-only investigation (#1500). Brief:
|
||||||
|
`docs/plans/reviews/2026-09-10_m20-refresh-isolation-request.md`. Package
|
||||||
|
under investigation: `@earendil-works/[email protected]` (registry),
|
||||||
|
the same version currently pinned at the repo root (`package.json:8`;
|
||||||
|
verified directly for this correction — see section 0). Method: `npm
|
||||||
|
pack`/`npm view` against the public registry, local re-hash, static
|
||||||
|
reading of the extracted public package only. No CLI execution, no
|
||||||
|
credentials, no network calls, no installs.
|
||||||
|
|
||||||
|
## 0. Correction receipt (post-review, 2026-09-10)
|
||||||
|
|
||||||
|
Darkwing's review identified three evidence issues in the version of this
|
||||||
|
file at hash `89a9578c1a5982a2ef085e1ebff3990d66e8c237732397e2aba1c3b84d081ce9`.
|
||||||
|
Original claims are quoted verbatim below, followed by the correction.
|
||||||
|
Nothing in the original file has been deleted; corrected passages are
|
||||||
|
updated in place in the sections referenced.
|
||||||
|
|
||||||
|
**(1) Package pin.** Original (section 11): *"Did not compare 0.85.1's
|
||||||
|
auth-check isolation surface against the currently pinned 0.84.4
|
||||||
|
(`package.json:8`) line-by-line."* This was stale. Re-checked directly
|
||||||
|
against the current working tree: `package.json:8` now reads
|
||||||
|
`"@earendil-works/pi-coding-agent": "0.85.1"`, and `package-lock.json:12,533`
|
||||||
|
agree. `git log --oneline -- package.json` shows the pin was moved from
|
||||||
|
`0.84.4` to `0.85.1` by commit `557aba0f` ("Add packages/mosaic registry
|
||||||
|
schemas, validation, read-only CLI; pin pi 0.85.1 (#1499)"), which landed
|
||||||
|
after my original PI-REFRESH-ROCKO-1 read of `package.json:8` (when it
|
||||||
|
still said `0.84.4`) and before this review. Corrected in section 11
|
||||||
|
below: there is now no version delta between the pinned package and the
|
||||||
|
package under investigation — both are `0.85.1`.
|
||||||
|
|
||||||
|
**(2) Snapshot path.** Original (section 10): a timestamp placeholder,
|
||||||
|
`/tmp/m20-pi0851-isolation-<timestamp>/`. Corrected to the concrete,
|
||||||
|
still-present path used for every citation in this file:
|
||||||
|
`/tmp/m20-pi0851-isolation-20260910T222007Z/` (verified present with
|
||||||
|
`ls` at the time of this correction). Section 10 updated below.
|
||||||
|
|
||||||
|
**(3) `allowModelNetwork:false` scope.** Original claims (sections 5 and
|
||||||
|
9) stated flatly that "no live network call occurs" for any of the three
|
||||||
|
auth subcommands because `allowModelNetwork` is hard-coded `false`. This
|
||||||
|
was **incorrect as a general claim** — `allowModelNetwork`/`allowNetwork`
|
||||||
|
only gates `ModelRuntime.refresh()`'s **model-catalog** refresh path
|
||||||
|
(`dist/core/model-runtime.js`). It does **not** reach OAuth **credential**
|
||||||
|
refresh, which is a separate, unconditional path traced fresh for this
|
||||||
|
correction in section 5a below and was not part of the original findings.
|
||||||
|
Sections 5, 8, and 9 are corrected below to bound the no-network claim to
|
||||||
|
model-catalog traffic only, and to state the OAuth-refresh network
|
||||||
|
exposure explicitly.
|
||||||
|
|
||||||
|
No CLI or network credential experiment was run to produce this
|
||||||
|
correction — the finding is from static reading of
|
||||||
|
`dist/bundle/chunks/chunk-IDDQWTHI.js` only, in the same snapshot already
|
||||||
|
cited.
|
||||||
|
|
||||||
|
## 1. Package identity/integrity (re-verified for this task)
|
||||||
|
|
||||||
|
Reused method from `docs/plans/reviews/2026-09-10_pi0851-refresh-investigation.md`
|
||||||
|
(hash `cfab6c006a5aeba1734390c01efa8231e201e6560bf422938385448b24054ac6`).
|
||||||
|
The original session-scratchpad extraction from that task was no longer
|
||||||
|
present (cleaned up at that task's close-out); Darkwing acknowledged this
|
||||||
|
before I re-fetched (reaction message, see brief context).
|
||||||
|
|
||||||
|
Two independent fetches into the shared snapshot (path below):
|
||||||
|
|
||||||
|
| Fetch | Command | Tarball shasum (npm pack manifest) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `npm pack @earendil-works/[email protected] --json` | `4cd00f653c3dabeb193b46f511044e7fbfe0f947` |
|
||||||
|
| 2 (addendum: lifecycle scripts disabled) | `npm pack @earendil-works/[email protected] --ignore-scripts --json` | `4cd00f653c3dabeb193b46f511044e7fbfe0f947` |
|
||||||
|
|
||||||
|
Identical shasum both times, and identical to the shasum recorded in the
|
||||||
|
prior PI-REFRESH-ROCKO-1 findings — the "0.85.1" identity used here is
|
||||||
|
byte-for-byte the same package. Also cross-checked against
|
||||||
|
`registry-dist.json` (`npm view @earendil-works/[email protected] dist
|
||||||
|
--json`), whose `shasum`/`integrity` match the local re-hash.
|
||||||
|
|
||||||
|
Note on lifecycle scripts: `npm pack <spec>` against a remote registry
|
||||||
|
package resolves and downloads the tarball directly — it does not run an
|
||||||
|
`npm install` and therefore never executes install lifecycle scripts,
|
||||||
|
independent of `--ignore-scripts`. The identical shasum with and without
|
||||||
|
the flag is expected and is not by itself proof the package has no
|
||||||
|
lifecycle scripts (`extracted/package/package.json` `scripts` block was not
|
||||||
|
separately audited here, out of scope); it is proof the fetch method used
|
||||||
|
for this snapshot did not execute any.
|
||||||
|
|
||||||
|
## 2. Isolation lever: `PI_CODING_AGENT_DIR`
|
||||||
|
|
||||||
|
`extracted/package/dist/config.js:406-427`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
|
||||||
|
export function getAgentDir() {
|
||||||
|
const envDir = process.env[ENV_AGENT_DIR];
|
||||||
|
if (envDir) {
|
||||||
|
return expandTildePath(envDir);
|
||||||
|
}
|
||||||
|
return join(homedir(), CONFIG_DIR_NAME, "agent");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`APP_NAME` is `"pi"` (`dist/config.js:401`, `piConfigName || "pi"`), so
|
||||||
|
`ENV_AGENT_DIR` resolves to the literal string `PI_CODING_AGENT_DIR`.
|
||||||
|
`CONFIG_DIR_NAME` is `.pi` by default (`dist/config.js:403`). With the env
|
||||||
|
var unset, the fallback is `homedir()` (Node's `os.homedir()`, itself
|
||||||
|
`HOME`-driven on POSIX) joined with `.pi/agent` — so `HOME` also moves the
|
||||||
|
default location, but `PI_CODING_AGENT_DIR` is the explicit, single-purpose
|
||||||
|
override and does not disturb any other `HOME`-dependent behavior.
|
||||||
|
|
||||||
|
Every agent-state path is derived from `getAgentDir()` and therefore moves
|
||||||
|
together under one env var (`dist/config.js:433-461`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function getModelsPath() { return join(getAgentDir(), "models.json"); }
|
||||||
|
export function getAuthPath() { return join(getAgentDir(), "auth.json"); }
|
||||||
|
export function getSettingsPath() { return join(getAgentDir(), "settings.json"); }
|
||||||
|
export function getToolsDir() { return join(getAgentDir(), "tools"); }
|
||||||
|
export function getBinDir() { return join(getAgentDir(), "bin"); }
|
||||||
|
export function getPromptsDir() { return join(getAgentDir(), "prompts"); }
|
||||||
|
export function getSessionsDir() { return join(getAgentDir(), "sessions"); }
|
||||||
|
export function getDebugLogPath() { return join(getAgentDir(), `${APP_NAME}-debug.log`); }
|
||||||
|
```
|
||||||
|
|
||||||
|
`getPackageDir()` (`dist/config.js:311-313`) is a **separate**,
|
||||||
|
unrelated env var (`PI_PACKAGE_DIR`) that locates the *installed package
|
||||||
|
itself* (themes, docs, README, CHANGELOG bundled with the npm package) —
|
||||||
|
it has no bearing on agent state and is not part of the isolation
|
||||||
|
boundary being evaluated.
|
||||||
|
|
||||||
|
**No dedicated CLI flag exists for auth path override.** `auth-command.js`
|
||||||
|
(`dist/cli/auth-command.js`) defines only `--provider`, `--model`,
|
||||||
|
`--json`, `--credentials`, `--no-refresh` (check) and `--min-expiry`
|
||||||
|
(print-bearer-token) — see `AUTH_COMMAND_USAGE` at
|
||||||
|
`dist/cli/auth-command.js:4-8` and the flag parser at
|
||||||
|
`dist/cli/auth-command.js:27-75`. There is no `--auth-path`,
|
||||||
|
`--config-dir`, or similar. `PI_CODING_AGENT_DIR` (optionally combined
|
||||||
|
with a scratch `HOME`) is the only supported isolation mechanism.
|
||||||
|
|
||||||
|
## 3. CLI dispatch order — auth commands run before cwd/settings/extension/session/trust setup
|
||||||
|
|
||||||
|
Confirmed directly from the bundled entry point, not inferred from naming.
|
||||||
|
`dist/bundle/cli.js` is the npm `bin.pi` shim; it calls
|
||||||
|
`main(process.argv.slice(2))` from the single bundled chunk
|
||||||
|
`dist/bundle/chunks/chunk-JVUZSMYM.js`. That chunk is a single minified
|
||||||
|
line (4,006,956 bytes); functions were located by string search
|
||||||
|
(`indexOf`) rather than line numbers — anchors below are function names,
|
||||||
|
which are preserved (not mangled) in this build.
|
||||||
|
|
||||||
|
`async function main(args, options)` (found at byte offset 3990752),
|
||||||
|
head of body, statement order preserved verbatim except formatting:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function main(args,options){
|
||||||
|
resetTimings();
|
||||||
|
let extensionFactories=[...builtInExtensions,...options?.extensionFactories??[]],
|
||||||
|
offlineMode=args.includes("--offline")||isTruthyEnvFlag2(process.env.PI_OFFLINE);
|
||||||
|
if(offlineMode&&(process.env.PI_OFFLINE="1",process.env.PI_SKIP_VERSION_CHECK="1"),
|
||||||
|
await runAuthCommand(args))
|
||||||
|
return;
|
||||||
|
process.platform==="win32"&&cleanupWindowsSelfUpdateQuarantine(getPackageDir()),
|
||||||
|
cleanupManagedInstall();
|
||||||
|
let cwd=process.cwd(),
|
||||||
|
agentDir=getAgentDir(),
|
||||||
|
bootstrapSettingsManager=SettingsManager.create(cwd,agentDir,{projectTrusted:!1});
|
||||||
|
if(applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy),
|
||||||
|
configureHttpDispatcher(),
|
||||||
|
await handlePackageCommand(args,{extensionFactories})){ ... }
|
||||||
|
if(await handleConfigCommand(args,{extensionFactories}))return;
|
||||||
|
let parsed=parseArgs(args);
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This settles the brief's explicit question with evidence, not an inference
|
||||||
|
from command naming: `await runAuthCommand(args)` is the **first**
|
||||||
|
awaited call in `main()`, and a truthy return **exits `main()` before**:
|
||||||
|
|
||||||
|
- `process.cwd()` is read (`cwd` is assigned only after the auth branch),
|
||||||
|
- `SettingsManager.create(cwd, agentDir, {projectTrusted:false})` runs
|
||||||
|
(project trust / project settings discovery),
|
||||||
|
- `handlePackageCommand`/`handleConfigCommand` run,
|
||||||
|
- `parseArgs(args)` runs (the general CLI arg parser, session/model/tool
|
||||||
|
flags),
|
||||||
|
- any session manager, migrations, or interactive-mode setup is reached.
|
||||||
|
|
||||||
|
The only thing evaluated *before* `runAuthCommand` is the
|
||||||
|
`extensionFactories` array assembly: `builtInExtensions` is a static
|
||||||
|
in-memory literal —
|
||||||
|
`builtInExtensions=[{name:"llama.cpp",factory:llamaExtension,hidden:!0}]`
|
||||||
|
— with no disk I/O at construction, and `options?.extensionFactories` are
|
||||||
|
factories the *host process* passes in (not loaded from a project
|
||||||
|
directory or config file). So no project-scoped extension or config
|
||||||
|
discovery occurs ahead of auth dispatch either.
|
||||||
|
|
||||||
|
`runAuthCommand(args)` itself (byte offset 3981368) calls `getAgentDir()`
|
||||||
|
indirectly (via `AuthStorage.create()` / `ReadOnlyAuthStorage` /
|
||||||
|
`ModelRuntime.create()` default paths — section 4) but never reads `cwd`,
|
||||||
|
never touches `SettingsManager`, and never resolves project-level
|
||||||
|
extensions.
|
||||||
|
|
||||||
|
## 4. Auth storage isolation specifics
|
||||||
|
|
||||||
|
`dist/core/auth-storage.js`:
|
||||||
|
|
||||||
|
- `AuthStorage.create(authPath = join(getAgentDir(), "auth.json"))`
|
||||||
|
(line 281) — used by `check` unless `--no-refresh` is given.
|
||||||
|
- `ReadOnlyAuthStorage` constructor:
|
||||||
|
`constructor(authPath = join(getAgentDir(), "auth.json"))` (line 161) —
|
||||||
|
used by `check --no-refresh`.
|
||||||
|
- `FileAuthStorageBackend` (line 17) is the backing store for
|
||||||
|
`AuthStorage`; its lock file is acquired directly on the auth path via
|
||||||
|
`proper-lockfile`: `lockfile.lockSync(path, {realpath:false})` (line 39)
|
||||||
|
and `lockfile.lock(this.authPath, ...)` (line 85). `proper-lockfile`
|
||||||
|
places its lock next to the target file, so the lock stays inside
|
||||||
|
`getAgentDir()` — no isolation-breaking temp path outside the agent dir.
|
||||||
|
- Neither constructor, nor any auth-path CLI flag, exists to point
|
||||||
|
`check`/`print-*` at a path outside `getAgentDir()` independent of the
|
||||||
|
env var — confirming `PI_CODING_AGENT_DIR` (optionally with a scratch
|
||||||
|
`HOME`) is the complete and only isolation lever for credentials.
|
||||||
|
|
||||||
|
`print-api-key`/`print-bearer-token` (the `command.kind!=="check"` branch
|
||||||
|
inside `runAuthCommand`, same byte-offset function) call
|
||||||
|
`ModelRuntime.create({allowModelNetwork:false, signal})` with no explicit
|
||||||
|
`credentials`/`authPath` option, so `ModelRuntime.create`
|
||||||
|
(`dist/core/model-runtime.js:74`) falls back to its own default:
|
||||||
|
`DefaultAuthStorage.create(options.authPath)` with `options.authPath`
|
||||||
|
undefined — which resolves to the same `getAgentDir()`-derived
|
||||||
|
`auth.json` path. Credential *file location* is identical across all
|
||||||
|
three subcommands; the models-catalog behavior differs (section 5), and
|
||||||
|
so does credential-refresh network exposure across `check`/`check
|
||||||
|
--no-refresh`/`print-*` (section 5a).
|
||||||
|
|
||||||
|
## 5. `check` vs `print-*`: models.json / models-store.json asymmetry, and model-catalog network scope (corrected)
|
||||||
|
|
||||||
|
`dist/cli/auth-check.js:42-49`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export async function createAuthCheckModelRuntime(credentials) {
|
||||||
|
return ModelRuntime.create({
|
||||||
|
credentials,
|
||||||
|
modelsStore: new InMemoryCodingAgentModelsStore(),
|
||||||
|
allowModelNetwork: false,
|
||||||
|
refreshOnCreate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`check` passes an explicit in-memory models store and disables
|
||||||
|
`refreshOnCreate` — it never reads `models.json`/`models-store.json` from
|
||||||
|
disk and never runs `runtime.refresh()` at construction.
|
||||||
|
|
||||||
|
`print-api-key`/`print-bearer-token`, by contrast, call
|
||||||
|
`ModelRuntime.create({allowModelNetwork:false, signal})` with no
|
||||||
|
`modelsStore`/`modelsPath`/`refreshOnCreate` override. `ModelRuntime.create`
|
||||||
|
defaults (`dist/core/model-runtime.js:74-108`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
static async create(options = {}) {
|
||||||
|
const credentials = new RuntimeCredentials(options.credentials ?? DefaultAuthStorage.create(options.authPath));
|
||||||
|
const modelsPath = options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
|
||||||
|
const config = await ModelConfig.load(modelsPath);
|
||||||
|
const modelsStore = options.modelsStore ??
|
||||||
|
(modelsPath
|
||||||
|
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
|
||||||
|
: new InMemoryCodingAgentModelsStore());
|
||||||
|
...
|
||||||
|
const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true;
|
||||||
|
...
|
||||||
|
if (options.refreshOnCreate !== false) {
|
||||||
|
await runtime.refresh({ allowNetwork: refreshFromNetwork, signal });
|
||||||
|
}
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
So `print-*` **does** read `models.json` and `models-store.json` from
|
||||||
|
`getAgentDir()` (both env-overridable together with everything else), and
|
||||||
|
**does** call `runtime.refresh()` at construction (since
|
||||||
|
`refreshOnCreate` is not passed as `false` here) — this is a real
|
||||||
|
behavioral difference from `check`. `allowModelNetwork` is fixed to
|
||||||
|
`false` for both `check`'s in-memory runtime and `print-*`'s runtime, so
|
||||||
|
`refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true`
|
||||||
|
evaluates to `false` regardless of `modelNetworkEnabled`. `refresh()`
|
||||||
|
(`dist/core/model-runtime.js:504-517`) forwards this as
|
||||||
|
`allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled` into the
|
||||||
|
underlying `this.models.refresh(refreshOptions)` (the `pi-ai` models
|
||||||
|
store) — **this specific call path** performs no network-based
|
||||||
|
**model-catalog** refresh for any of the three auth subcommands. The
|
||||||
|
only disk reads triggered by it are local: `ModelConfig.load(modelsPath)`
|
||||||
|
(`models.json`) and whatever `FileModelsStore` does with
|
||||||
|
`models-store.json`, both under `getAgentDir()`.
|
||||||
|
|
||||||
|
**Practical isolation consequence (catalog only):** an isolated
|
||||||
|
`PI_CODING_AGENT_DIR` needs, at minimum, a valid (possibly
|
||||||
|
empty-provider) `models.json` to exist or be absent-tolerant for
|
||||||
|
`print-*` to construct cleanly, since it does load that file; `check`
|
||||||
|
has no such dependency.
|
||||||
|
|
||||||
|
**This "no network" finding is scoped to model-catalog refresh only and
|
||||||
|
does not extend to OAuth credential refresh — see section 5a, added on
|
||||||
|
review correction.** The original text here claimed "no live network
|
||||||
|
call occurs under an isolated, offline-style setup" without that scope
|
||||||
|
qualifier; that unqualified claim was incorrect and is retracted in favor
|
||||||
|
of section 5a.
|
||||||
|
|
||||||
|
### 5a. OAuth credential refresh is a *separate*, unconditional network path (correction — traced 2026-09-10)
|
||||||
|
|
||||||
|
`ModelRuntime.getAuth()`/`checkAuth()` (`dist/core/model-runtime.js:280,339`)
|
||||||
|
delegate to `this.models.getAuth`/`checkAuth` — methods on the
|
||||||
|
`pi-ai`-level models store, implemented in
|
||||||
|
`dist/bundle/chunks/chunk-IDDQWTHI.js` (same snapshot,
|
||||||
|
`extracted/package/dist/bundle/chunks/chunk-IDDQWTHI.js`), **not** in
|
||||||
|
`dist/core/model-runtime.js`. Neither `allowModelNetwork` nor
|
||||||
|
`allowNetwork` is threaded into this call at all — `getAuth`
|
||||||
|
(offset 15594) calls `resolveProviderAuth(provider, this.credentials,
|
||||||
|
this.authContext, {...overrides, signal})` with no network flag among
|
||||||
|
`overrides`.
|
||||||
|
|
||||||
|
`resolveProviderAuth` → `resolveStoredOAuth` (offset 4923 in that chunk)
|
||||||
|
is where an OAuth credential's freshness is enforced, **unconditionally,
|
||||||
|
with no network gate anywhere in the function**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
var DEFAULT_OAUTH_MINIMUM_VALIDITY_MS=300*1e3, DEFAULT_OAUTH_REFRESH_TIMEOUT_MS=15e3;
|
||||||
|
async function resolveStoredOAuth(credentials,providerId,oauth,stored,signal,minOAuthValidityMs){
|
||||||
|
let minimumValidityMs=Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS,minOAuthValidityMs??0),
|
||||||
|
expiresSoon=credential2=>Date.now()+minimumValidityMs>=credential2.expires,
|
||||||
|
credential=stored;
|
||||||
|
if(expiresSoon(credential)){
|
||||||
|
let post;
|
||||||
|
try{
|
||||||
|
post=await credentials.modify(providerId,async current=>{
|
||||||
|
if(current?.type==="oauth"&&expiresSoon(current))
|
||||||
|
try{
|
||||||
|
let refreshSignal=AbortSignal.any([signal,AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS)]);
|
||||||
|
return await oauth.refresh(current,refreshSignal)
|
||||||
|
}catch(error){throw new ModelsError("oauth",`OAuth refresh failed for ${providerId}`,{cause:error})}
|
||||||
|
},{signal})
|
||||||
|
}catch(error){ ... }
|
||||||
|
...
|
||||||
|
}
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a stored OAuth credential's `expires` timestamp is within
|
||||||
|
`DEFAULT_OAUTH_MINIMUM_VALIDITY_MS` (300,000 ms / 5 minutes) of `Date.now()`
|
||||||
|
— including already expired — this path calls `oauth.refresh(current,
|
||||||
|
refreshSignal)` **regardless of `allowModelNetwork`/`allowNetwork`, which
|
||||||
|
this function never receives**. `oauth.refresh` is the per-provider
|
||||||
|
implementation; for Anthropic it is `refreshAnthropicToken` posting to
|
||||||
|
`TOKEN_URL="https://platform.claude.com/v1/oauth/token"`
|
||||||
|
(`dist/bundle/chunks/anthropic.js`, confirmed present in this snapshot
|
||||||
|
with that exact literal), and equivalent live token endpoints exist for
|
||||||
|
other bundled providers (`openai-codex.js` → `auth.openai.com/oauth/token`,
|
||||||
|
`openrouter.js` → `openrouter.ai/api/v1/auth/keys`, `xai.js` →
|
||||||
|
`auth.x.ai/oauth2/token`). This is a real outbound HTTPS call, not a
|
||||||
|
local operation.
|
||||||
|
|
||||||
|
**Which subcommands are exposed, and the one mitigation that exists:**
|
||||||
|
|
||||||
|
- `check` (default, no `--no-refresh`): uses `AuthStorage.create()`
|
||||||
|
(mutable) → `credentials.modify()` succeeds → `oauth.refresh()` runs if
|
||||||
|
a stored OAuth credential is near/at expiry.
|
||||||
|
- `check --no-refresh`: uses `ReadOnlyAuthStorage`, whose `modify()`
|
||||||
|
**throws immediately** (`dist/core/auth-storage.js:227`,
|
||||||
|
`"Read-only credential storage cannot modify auth.json"`) *before*
|
||||||
|
`oauth.refresh()` can be reached. The thrown error propagates out of
|
||||||
|
`resolveStoredOAuth`/`getAuth` and is caught by `checkProviderAuth`'s
|
||||||
|
try/catch (`dist/cli/auth-check.js:32-34`), which reports
|
||||||
|
`{status:"invalid", reason:"invalid_state"}` instead. **This is the
|
||||||
|
only one of the three subcommands with a supported way to guarantee no
|
||||||
|
OAuth-refresh network attempt.**
|
||||||
|
- `print-api-key` / `print-bearer-token`: always construct
|
||||||
|
`ModelRuntime.create({allowModelNetwork:false, signal})` with no
|
||||||
|
`credentials` override, which resolves to `DefaultAuthStorage.create()`
|
||||||
|
— the same **mutable** `AuthStorage` as default `check`. There is
|
||||||
|
**no `--no-refresh` equivalent flag** for either print command
|
||||||
|
(`AUTH_COMMAND_USAGE`/flag parser, `dist/cli/auth-command.js:4-8,27-75`
|
||||||
|
restrict `--no-refresh` to `kind==="check"`). So both print commands
|
||||||
|
will attempt a live OAuth-refresh network call whenever the isolated
|
||||||
|
`auth.json` holds a stored OAuth credential within 5 minutes of expiry
|
||||||
|
(or already expired), with no way to suppress it via flags.
|
||||||
|
|
||||||
|
**Bounded conclusion:** for an *empty* isolated `auth.json` (or one
|
||||||
|
containing only `api_key`-type credentials, which never enter
|
||||||
|
`resolveStoredOAuth` at all — `readCredential` in
|
||||||
|
`resolveProviderAuthWithSignal` branches on `stored.type==="oauth"`
|
||||||
|
before this path is reached), none of the three subcommands makes any
|
||||||
|
network call, matching the original (uncorrected) intent. The risk is
|
||||||
|
specific and load-bearing: **never place a real, or a synthetic
|
||||||
|
near-expiry, OAuth-type credential in an isolated `PI_CODING_AGENT_DIR`
|
||||||
|
used for `print-api-key`/`print-bearer-token` testing, or in `check`
|
||||||
|
without `--no-refresh`** — doing so can trigger a genuine outbound
|
||||||
|
request to the provider's live OAuth token endpoint, independent of
|
||||||
|
`allowModelNetwork`. No such credential or request was created, read, or
|
||||||
|
attempted in this investigation; this is a static-source-derived bound,
|
||||||
|
not an observed network call.
|
||||||
|
|
||||||
|
## 6. `models.json` custom-provider schema (Darkwing addendum)
|
||||||
|
|
||||||
|
`docs/models.md` (top-level `docs/` in the extracted 0.85.1 tarball,
|
||||||
|
included in the shared snapshot) documents the format; the authoritative
|
||||||
|
schema is `dist/core/model-config.js:139-187` (TypeBox), reproduced in
|
||||||
|
relevant part:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ModelDefinitionSchema = Type.Object({
|
||||||
|
id: Type.String({ minLength: 1 }),
|
||||||
|
name: Type.Optional(...), api: Type.Optional(...), baseUrl: Type.Optional(...),
|
||||||
|
reasoning: Type.Optional(...), thinkingLevelMap: Type.Optional(...),
|
||||||
|
input: Type.Optional(...), cost: Type.Optional(...),
|
||||||
|
contextWindow: Type.Optional(...), maxTokens: Type.Optional(...),
|
||||||
|
samplingParams: Type.Optional(...), headers: Type.Optional(...),
|
||||||
|
compat: Type.Optional(...),
|
||||||
|
});
|
||||||
|
const ProviderConfigSchema = Type.Object({
|
||||||
|
name: Type.Optional(...), baseUrl: Type.Optional(Type.String({minLength:1})),
|
||||||
|
apiKey: Type.Optional(Type.String({minLength:1})), api: Type.Optional(Type.String({minLength:1})),
|
||||||
|
oauth: Type.Optional(Type.Literal("radius")), headers: Type.Optional(...),
|
||||||
|
compat: Type.Optional(...), authHeader: Type.Optional(...),
|
||||||
|
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
|
||||||
|
modelOverrides: Type.Optional(...),
|
||||||
|
});
|
||||||
|
const ModelsConfigSchema = Type.Object({
|
||||||
|
providers: Type.Record(Type.String(), ProviderConfigSchema),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This confirms Darkwing's proposed minimal shape is schema-valid against
|
||||||
|
0.85.1's actual `models.json` validator:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"<id>": {
|
||||||
|
"api": "...",
|
||||||
|
"baseUrl": "...",
|
||||||
|
"models": [{ "id": "..." }],
|
||||||
|
"apiKey": "... (optional)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`models.id` is the only field in the entire schema with no
|
||||||
|
`Type.Optional` wrapper anywhere in the object graph — every other field
|
||||||
|
at every level is optional. `docs/models.md`'s own "Minimal Example" uses
|
||||||
|
exactly this shape (an Ollama provider with `api`, `baseUrl`, `apiKey`,
|
||||||
|
and a `models` array of bare `{id}` objects), matching the schema
|
||||||
|
reading. `apiKey` is confirmed optional both in the schema and in
|
||||||
|
`docs/models.md`'s notes on keyless local servers.
|
||||||
|
|
||||||
|
## 7. Non-secret output and exit codes
|
||||||
|
|
||||||
|
Source: `runAuthCommand` (byte offset 3981368) and
|
||||||
|
`resolveCredentialForPrint` (found by name in the same chunk), plus
|
||||||
|
`dist/cli/auth-command.js` / `dist/cli/auth-check.js`.
|
||||||
|
|
||||||
|
**Parse-level errors (any subcommand, before dispatch):**
|
||||||
|
- Unknown `auth` subcommand or unparseable args → `console.error` in red
|
||||||
|
(`Error: <message>`), `process.exitCode = 1`.
|
||||||
|
- Unknown flag → `console.error` (`Unknown option --<flag> for "<command
|
||||||
|
name>".`) + a dim usage hint, `process.exitCode = 1`.
|
||||||
|
- `--help`/`-h`/bare `auth` → `printAuthCommandHelp()` prints the usage
|
||||||
|
block to stdout via `console.log`, no exit code forced (defaults to 0).
|
||||||
|
|
||||||
|
**`print-api-key` / `print-bearer-token`:**
|
||||||
|
- Success: the resolved credential/token string is written to stdout via
|
||||||
|
`process.stdout.write(`${credential}\n`)`. No JSON mode, no structured
|
||||||
|
status — bare secret value only. `process.exitCode` is left at its
|
||||||
|
default (0) on success.
|
||||||
|
- Failure (`AuthCommandError` or unexpected throw, e.g. unknown provider,
|
||||||
|
no credential configured, model not found): `console.error` in red
|
||||||
|
(`Error: <message>`), `process.exitCode = 1` (kind is not `"check"`, so
|
||||||
|
the kind-dependent exit branch sets `1`).
|
||||||
|
|
||||||
|
**`check`:**
|
||||||
|
- `result.status` is one of `"ready" | "not_ready" | "invalid"`
|
||||||
|
(`checkProviderAuth`, `dist/cli/auth-check.js:5-35`), with a `reason`
|
||||||
|
code among `invalid_state | provider_not_found |
|
||||||
|
credentials_not_configured | credential_not_available` when not ready.
|
||||||
|
- Default (non-`--json`) output: `credential ?? result.status` written to
|
||||||
|
stdout (i.e. the bare status word unless `--credentials` requested and
|
||||||
|
status is `ready`, in which case the raw credential is printed instead —
|
||||||
|
operators should not pass `--credentials` in a context where stdout is
|
||||||
|
logged/shared if avoiding secret material matters).
|
||||||
|
- `--json` output: `JSON.stringify({...result, ...(credential ? {credentials: credential} : {})})`
|
||||||
|
— i.e. `{"status":"ready","provider":"...","authType":"..."}` (plus a
|
||||||
|
`credentials` field only when `--credentials` was also passed).
|
||||||
|
- Exit codes: `result.status==="ready" ? 0 : result.status==="not_ready" ? 1 : 2`.
|
||||||
|
- On a thrown error during `check` (e.g. missing `--provider`/`--model`):
|
||||||
|
`console.error` (`Error: <message>`), `process.exitCode = 2` (kind is
|
||||||
|
`"check"`).
|
||||||
|
|
||||||
|
No path observed prints raw OAuth `refresh` tokens or API keys unless the
|
||||||
|
operator explicitly passes `--credentials` (`check`) or invokes
|
||||||
|
`print-api-key`/`print-bearer-token` directly, which is their documented
|
||||||
|
purpose. This finding itself contains no credential material — all
|
||||||
|
values above are format descriptions only.
|
||||||
|
|
||||||
|
## 8. Direct answers to the brief
|
||||||
|
|
||||||
|
- **Exact supported environment/flags for isolation:** set
|
||||||
|
`PI_CODING_AGENT_DIR` to a dedicated empty directory (this alone
|
||||||
|
isolates `auth.json`, `models.json`, `models-store.json`,
|
||||||
|
`settings.json`, sessions, tools, bin, prompts, and the debug log).
|
||||||
|
Optionally also set a scratch `HOME` as defense-in-depth, since it is
|
||||||
|
the fallback base if `PI_CODING_AGENT_DIR` were ever unset by mistake,
|
||||||
|
but it is not required given `PI_CODING_AGENT_DIR` takes precedence
|
||||||
|
unconditionally. No CLI flag exists for this; it is env-only.
|
||||||
|
- **Working directory relevance:** none, for auth commands specifically —
|
||||||
|
proven in section 3 (`cwd` is not read until after `runAuthCommand`
|
||||||
|
returns).
|
||||||
|
- **Does auth-check load extensions/project packages/settings before
|
||||||
|
handling the command?** No — proven in section 3 by direct reading of
|
||||||
|
`main()`'s statement order, not inferred from naming.
|
||||||
|
- **Is `PI_CODING_AGENT_DIR` (or another override) supported?** Yes,
|
||||||
|
confirmed at `dist/config.js:406-427`; it is the sole override and
|
||||||
|
controls the entire agent directory as one unit.
|
||||||
|
- **Non-secret output and exit codes:** enumerated in section 7.
|
||||||
|
- **Network scope (corrected):** `allowModelNetwork:false` blocks only
|
||||||
|
the model-catalog refresh path (section 5); it does not gate OAuth
|
||||||
|
credential refresh, which is a separate, unconditional path that can
|
||||||
|
make a live call to the provider's OAuth token endpoint if a stored
|
||||||
|
OAuth credential is near/at expiry (section 5a).
|
||||||
|
|
||||||
|
## 9. Blocker determination (corrected)
|
||||||
|
|
||||||
|
**No blocker identified for the isolation mechanism itself**, with one
|
||||||
|
load-bearing constraint on credential content that was missing from the
|
||||||
|
original version of this section. `PI_CODING_AGENT_DIR` fully isolates
|
||||||
|
the auth/config directory:
|
||||||
|
|
||||||
|
- Credential resolution (`auth.json`) is isolated identically across all
|
||||||
|
three subcommands (section 4).
|
||||||
|
- No project/cwd/extension/settings discovery occurs before or during
|
||||||
|
auth-command handling (section 3).
|
||||||
|
- Model-catalog refresh performs no network call under
|
||||||
|
`allowModelNetwork:false`, which is hard-coded for all three
|
||||||
|
subcommands, not operator-controlled (section 5).
|
||||||
|
- The one residual gap for `print-*` is that it also touches
|
||||||
|
`models.json`/`models-store.json` under the same isolated directory
|
||||||
|
(section 5) — this is a same-directory read, not an isolation leak, but
|
||||||
|
callers should ensure the isolated `PI_CODING_AGENT_DIR` either omits
|
||||||
|
`models.json` (tolerated — `ModelConfig.load` handles a missing file) or
|
||||||
|
contains only the intended synthetic provider (section 6 shape) if
|
||||||
|
`print-*` isolation tests need deterministic provider resolution.
|
||||||
|
- **Corrected/added:** credential *content* placed in the isolated
|
||||||
|
`auth.json` is load-bearing for network isolation, independent of
|
||||||
|
`PI_CODING_AGENT_DIR`. An empty `auth.json`, or one containing only
|
||||||
|
`api_key`-type entries, triggers no network call from any of the three
|
||||||
|
subcommands. A stored `oauth`-type credential within 5 minutes of its
|
||||||
|
`expires` timestamp (or already expired) will trigger a live refresh
|
||||||
|
request to the provider's real OAuth token endpoint for default `check`
|
||||||
|
and for both `print-*` commands (no flag suppresses this for `print-*`;
|
||||||
|
`check --no-refresh` is the one subcommand/flag combination that
|
||||||
|
provably cannot reach the network for this reason — section 5a). This
|
||||||
|
is a constraint on test fixture design, not a defect in
|
||||||
|
`PI_CODING_AGENT_DIR` isolation, but it must be honored for any
|
||||||
|
isolation test that populates `auth.json` with OAuth-shaped data.
|
||||||
|
|
||||||
|
## 10. Shared snapshot (corrected — concrete path)
|
||||||
|
|
||||||
|
`/tmp/m20-pi0851-isolation-20260910T222007Z/` (verified present on this
|
||||||
|
host at the time of this correction; coordinator/reviewer-accessible for
|
||||||
|
inspection). Contents:
|
||||||
|
tarball, `pack.json`/`pack-ignore-scripts.json` (npm pack manifests),
|
||||||
|
`registry-dist.json` (npm view dist metadata), `tarball.sha256`, and
|
||||||
|
`extracted/package/` (full public tarball contents: `dist/`, `docs/`
|
||||||
|
including `docs/models.md`, `CHANGELOG.md`, `README.md`, `package.json`).
|
||||||
|
See `SNAPSHOT-INDEX.md` inside the snapshot for a file-by-file map to the
|
||||||
|
citations above.
|
||||||
|
|
||||||
|
## 11. What this investigation did not do
|
||||||
|
|
||||||
|
- Did not run `pi` (any subcommand), install the package, or use real or
|
||||||
|
fake credentials.
|
||||||
|
- Did not audit `extracted/package/package.json`'s `scripts` block for
|
||||||
|
the presence/absence of lifecycle scripts (noted as out of scope in
|
||||||
|
section 1's caveat).
|
||||||
|
- Did not trace `FileModelsStore`/`ModelConfig.load`'s exact disk-miss
|
||||||
|
behavior line-by-line beyond confirming it is called and is local
|
||||||
|
(no network) under `allowModelNetwork:false` for the model-catalog path
|
||||||
|
specifically (section 5).
|
||||||
|
- Did not verify behavior on Windows (`getAgentDir`'s `expandTildePath`/
|
||||||
|
path-join behavior assumed POSIX-equivalent per this host).
|
||||||
|
- **Corrected:** the package pin comparison this bullet originally
|
||||||
|
described is moot — `package.json:8` now pins `0.85.1`, the same
|
||||||
|
version investigated throughout this file (see section 0(1)). The pin
|
||||||
|
was `0.84.4` when read during the prior PI-REFRESH-ROCKO-1 task and was
|
||||||
|
moved to `0.85.1` by commit `557aba0f` (#1499) before this task ran.
|
||||||
|
There is no version delta left to compare for the auth/isolation
|
||||||
|
surface; PI-REFRESH-ROCKO-1's changelog analysis (0.84.4→0.85.1, no
|
||||||
|
auth-related changes) remains the relevant prior evidence for anything
|
||||||
|
that predates the pin bump.
|
||||||
|
- Did not run any command, real or synthetic, that would populate
|
||||||
|
`auth.json` with an OAuth-type credential to observe the refresh path
|
||||||
|
in section 5a directly — that section is derived entirely from static
|
||||||
|
reading of `chunk-IDDQWTHI.js`, per the brief's "no CLI/network
|
||||||
|
credential experiment authorized" constraint from the review.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Registry metadata validation
|
# Registry metadata validation
|
||||||
|
|
||||||
Read-only metadata CLI. No materialization, live credential handling or refresh service.
|
Read-only metadata CLI plus fixture-only resolution, generation and refresh APIs. No live credential handling or refresh service.
|
||||||
|
|
||||||
```
|
```
|
||||||
node packages/mosaic/src/cli/main.mjs validate --registry-root /absolute/fixture-root
|
node packages/mosaic/src/cli/main.mjs validate --registry-root /absolute/fixture-root
|
||||||
@@ -12,7 +12,7 @@ node --test packages/mosaic/tests/
|
|||||||
|
|
||||||
The reader currently supports Linux with procfs and descriptor-relative paths. Other platforms refuse explicitly rather than falling back to unsafe traversal. Root and all traversed registry directories must belong to the invoking user and have mode 0700. Metadata files must be regular, owned by that user and mode 0600. Symlinks are rejected in the root path, directories and metadata files. Required directories are auth/providers, auth/accounts, auth/settings and harnesses. A missing or empty registry is invalid, not an empty successful listing.
|
The reader currently supports Linux with procfs and descriptor-relative paths. Other platforms refuse explicitly rather than falling back to unsafe traversal. Root and all traversed registry directories must belong to the invoking user and have mode 0700. Metadata files must be regular, owned by that user and mode 0600. Symlinks are rejected in the root path, directories and metadata files. Required directories are auth/providers, auth/accounts, auth/settings and harnesses. A missing or empty registry is invalid, not an empty successful listing.
|
||||||
|
|
||||||
Metadata is limited to 1 MiB per file. Directory handles anchor child access while paths are being checked, preventing a renamed ancestor from redirecting later opens. Files are opened without following symlinks and checked against their prior inode/device and permissions. This is not a claim of transactionally consistent multi-record snapshots under concurrent writers; registry mutation and materialization are outside this package slice.
|
Metadata is limited to 1 MiB per file. Directory handles anchor child access while paths are being checked, preventing a renamed ancestor from redirecting later opens. Files are opened without following symlinks and checked against their prior inode/device and permissions. This is not a claim of transactionally consistent multi-record snapshots under concurrent writers; registry mutation and production materialization remain outside this package slice.
|
||||||
|
|
||||||
Account metadata lives at auth/accounts/<provider>/<account>/account.json. The credential.json sibling is never opened or inspected. No real credentials are needed for tests. Public Git fixtures do not preserve private modes: the tests copy them into temporary directories and set 0700/0600 before invoking the reader. Do not run the CLI against your real credential stores for a smoke test.
|
Account metadata lives at auth/accounts/<provider>/<account>/account.json. The credential.json sibling is never opened or inspected. No real credentials are needed for tests. Public Git fixtures do not preserve private modes: the tests copy them into temporary directories and set 0700/0600 before invoking the reader. Do not run the CLI against your real credential stores for a smoke test.
|
||||||
|
|
||||||
@@ -22,4 +22,18 @@ Record version fields are numeric 1. Unknown versions, top-level/nested unknown
|
|||||||
|
|
||||||
Profiles may only default to an enrolled account. Account types must be supported by their providers, and references must resolve. Failed validation returns no partial entries or CLI listing. Diagnostics use fixed paths/codes, never input values, unknown keys or JSON parser excerpts. Invalid input exits 1; command usage errors exit 2.
|
Profiles may only default to an enrolled account. Account types must be supported by their providers, and references must resolve. Failed validation returns no partial entries or CLI listing. Diagnostics use fixed paths/codes, never input values, unknown keys or JSON parser excerpts. Invalid input exits 1; command usage errors exit 2.
|
||||||
|
|
||||||
The safety regression suite covers independently reproduced findings D1-D10 from issue #1500. Passing it establishes those tested properties, not a production security certification or authorization to build materialization on unreviewed code.
|
The safety regression suite covers independently reproduced findings D1-D10 from issue #1500. Passing it establishes those tested properties, not a production security certification.
|
||||||
|
|
||||||
|
## Fixture-only execution and refresh
|
||||||
|
|
||||||
|
`resolveFixtureExecution`, `createFixtureCredentialStore` and `createFixtureWorkspace` are exported from src/index.mjs. Supply a validated registry snapshot, explicit scope and marker-only synthetic credentials. Scope is fixture input, not proof of actual project membership. Fork pins and enrollment constrain selection; native model ceilings that cannot be enforced are refused.
|
||||||
|
|
||||||
|
`workspace.generate(registry, request, store, options)` creates distinct private execution generations in a dedicated /tmp root. It accepts only the branded in-memory store. No production backend or output directory can be injected. Call `workspace.close()` when done. Published files are never rewritten by this API. Exclusive claims survive failures and prohibit retrying the same execution ID.
|
||||||
|
|
||||||
|
Expired or near-expiry OAuth fixtures automatically run the fixed fake refresh program under the store transaction. Each invocation has isolated PI_CODING_AGENT_DIR, HOME and cwd, with no inherited environment. No real Pi executable, shell, print command or network client is used. Both credential fields rotate together; successful generation commits the in-memory draft. Child output is discarded, the child is killed on timeout, and cleanup waits for child closure. Output size, type, mode and fixture markers are validated before use.
|
||||||
|
|
||||||
|
Tests can supply `refresh: { mode, timeoutMs }`. Modes are rotate, unchanged, failure, timeout and malformed. Timeout is 10–10000 ms, default 2000. Supplying refresh forces the fake check even for currently valid credentials. Generation fault hooks are after-auth, before-publish and after-publish. No arbitrary executable, environment or callback can be supplied.
|
||||||
|
|
||||||
|
Limits: store locks are in-process, not a production distributed lock. Credential-store commit and filesystem publication are not crash-atomic together. A post-publication failure records uncertainty, retains the generation and rolls back the in-memory draft; this simulator has no external token issuer to reconcile. A real refresh backend would need a separate reconciliation protocol before reuse. Filesystem ownership does not defend against arbitrary same-UID tampering or establish hardlink provenance.
|
||||||
|
|
||||||
|
Exact Pi 0.85.1 isolation is static-source evidence only. Its model-catalog network flag does NOT suppress OAuth refresh networking. Never substitute real Pi in these tests, even with expired synthetic OAuth tokens. Final whole-increment review is required before publication.
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Fixture-only execution resolution. Not a membership/registration authority.
|
||||||
|
import { validId, validateProvider, validateAccount, validateSettingsProfile,
|
||||||
|
validateHarnessManifest } from './records.mjs';
|
||||||
|
export class FixtureError extends Error {
|
||||||
|
constructor(code) { super(code); this.name = 'FixtureError'; this.code = code; }
|
||||||
|
}
|
||||||
|
export const refuse = code => { throw new FixtureError(code); };
|
||||||
|
const obj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
export function exact(v, required, optional = []) {
|
||||||
|
if (!obj(v) || required.some(k => !Object.hasOwn(v, k)) ||
|
||||||
|
Object.keys(v).some(k => ![...required, ...optional].includes(k))) refuse('invalid-fixture-input');
|
||||||
|
}
|
||||||
|
function checkRegistry(result) {
|
||||||
|
if (!obj(result) || !Array.isArray(result.errors) || result.errors.length || !obj(result.entries)) refuse('invalid-registry');
|
||||||
|
const entries = result.entries;
|
||||||
|
for (const [name, validate] of [['providers', validateProvider], ['accounts', validateAccount],
|
||||||
|
['profiles', validateSettingsProfile], ['harnesses', validateHarnessManifest]]) {
|
||||||
|
if (!obj(entries[name])) refuse('invalid-registry');
|
||||||
|
for (const [key, record] of Object.entries(entries[name])) {
|
||||||
|
if (validate(record).length) refuse('invalid-registry');
|
||||||
|
if (key !== (name === 'accounts' ? `${record.provider}/${record.id}` : record.id)) refuse('invalid-registry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
export function resolveFixtureExecution(result, request) {
|
||||||
|
// Clone immediately: later caller mutation cannot change this resolution.
|
||||||
|
let r, registry;
|
||||||
|
try { r = structuredClone(request); registry = structuredClone(result); }
|
||||||
|
catch { refuse('invalid-fixture-input'); }
|
||||||
|
const entries = checkRegistry(registry);
|
||||||
|
exact(r, ['fixtureOnly', 'agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId', 'profile'], ['accounts', 'fork']);
|
||||||
|
if (r.fixtureOnly !== true) refuse('fixture-only');
|
||||||
|
for (const key of ['agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId', 'profile'])
|
||||||
|
if (!validId(r[key])) refuse('invalid-scope');
|
||||||
|
if (!Object.hasOwn(entries.profiles, r.profile)) refuse('missing-profile');
|
||||||
|
const profile = entries.profiles[r.profile];
|
||||||
|
const overrides = r.accounts ?? {};
|
||||||
|
if (!obj(overrides)) refuse('invalid-selection');
|
||||||
|
const providers = new Set([...(profile.providers ?? []), ...profile.allowedAccounts.map(ref => ref.split('/')[0])]);
|
||||||
|
for (const key of Object.keys(overrides)) if (!providers.has(key)) refuse('provider-not-enrolled');
|
||||||
|
if (Object.hasOwn(r, 'fork')) {
|
||||||
|
exact(r.fork, ['sourceSessionId', 'accounts']);
|
||||||
|
if (!validId(r.fork.sourceSessionId) || r.fork.sourceSessionId === r.sessionId || !obj(r.fork.accounts)) refuse('invalid-fork-pin');
|
||||||
|
for (const id of Object.keys(r.fork.accounts)) if (!providers.has(id)) refuse('revoked-fork-pin');
|
||||||
|
}
|
||||||
|
const accounts = Object.create(null), models = { providers: Object.create(null) }, types = Object.create(null);
|
||||||
|
for (const id of [...providers].sort()) {
|
||||||
|
if (!Object.hasOwn(entries.providers, id)) refuse('missing-provider');
|
||||||
|
const provider = entries.providers[id], cfg = provider.harnesses.pi;
|
||||||
|
if (!cfg || !entries.harnesses.pi) refuse('unsupported-harness');
|
||||||
|
let ref;
|
||||||
|
if (r.fork) {
|
||||||
|
ref = r.fork.accounts[id];
|
||||||
|
if (Object.hasOwn(overrides, id) && overrides[id] !== ref) refuse('fork-account-change');
|
||||||
|
} else ref = Object.hasOwn(overrides, id) ? overrides[id] : profile.defaultAccounts?.[id];
|
||||||
|
if (ref === undefined) {
|
||||||
|
if (!provider.credentialTypes.includes('none')) refuse(r.fork ? 'missing-fork-pin' : 'missing-account-selection');
|
||||||
|
} else {
|
||||||
|
if (typeof ref !== 'string' || !profile.allowedAccounts.includes(ref) || ref.split('/')[0] !== id)
|
||||||
|
refuse('account-not-enrolled');
|
||||||
|
if (!Object.hasOwn(entries.accounts, ref)) refuse('missing-account');
|
||||||
|
const account = entries.accounts[ref];
|
||||||
|
if (account.provider !== id || !provider.credentialTypes.includes(account.type)) refuse('credential-type-not-supported');
|
||||||
|
accounts[id] = ref; types[id] = account.type;
|
||||||
|
}
|
||||||
|
if (provider.kind === 'native') {
|
||||||
|
// No aliasing to another provider's credential slot.
|
||||||
|
if (cfg.providerId !== id) refuse('provider-alias-unsupported');
|
||||||
|
if (profile.models?.[id]) refuse('native-model-enforcement-unavailable');
|
||||||
|
} else {
|
||||||
|
if (!['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'].includes(cfg.api)) refuse('unsupported-model-api');
|
||||||
|
const selected = profile.models?.[id] ?? cfg.models;
|
||||||
|
if (!selected.length || selected.some(m => !cfg.models.includes(m))) refuse('model-not-enrolled');
|
||||||
|
models.providers[id] = { api: cfg.api, baseUrl: cfg.baseUrl, models: selected.map(model => ({ id: model })) };
|
||||||
|
if (!ref || types[id] === 'none') models.providers[id].apiKey = 'FIXTURE_NONE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scope = Object.fromEntries(['agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId'].map(k => [k, r[k]]));
|
||||||
|
return { fixtureOnly: true, scope, profile: r.profile, accounts, types, models,
|
||||||
|
fork: r.fork ? { sourceSessionId: r.fork.sourceSessionId, accounts: { ...accounts } } : null };
|
||||||
|
}
|
||||||
|
// Real keys/tokens are deliberately outside this slice's accepted input language.
|
||||||
|
const marker = v => typeof v === 'string' && /^FIXTURE_[A-Z0-9_-]{1,128}$/.test(v);
|
||||||
|
export function validateFixtureCredential(value, type) {
|
||||||
|
if (type === 'none') { if (value !== null) refuse('invalid-fixture-credential'); return null; }
|
||||||
|
if (type === 'api_key') {
|
||||||
|
exact(value, ['type', 'key']);
|
||||||
|
if (value.type !== type || !marker(value.key)) refuse('invalid-fixture-credential');
|
||||||
|
} else if (type === 'oauth') {
|
||||||
|
exact(value, ['type', 'access', 'refresh', 'expires']);
|
||||||
|
if (value.type !== type || !marker(value.access) || !marker(value.refresh) ||
|
||||||
|
!Number.isSafeInteger(value.expires) || value.expires < 0) refuse('invalid-fixture-credential');
|
||||||
|
} else refuse('unsupported-fixture-credential');
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// In-memory synthetic store only; no filesystem or production backend.
|
||||||
|
import { refuse, validateFixtureCredential } from './execution.mjs';
|
||||||
|
const stores = new WeakSet();
|
||||||
|
export const isFixtureStore = store => stores.has(store);
|
||||||
|
export function createFixtureCredentialStore(initial) {
|
||||||
|
const records = new Map(), locks = new Set();
|
||||||
|
for (const [ref, value] of Object.entries(initial)) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(ref)) refuse('invalid-account-ref');
|
||||||
|
records.set(ref, validateFixtureCredential(value, value?.type ?? 'none'));
|
||||||
|
}
|
||||||
|
const store = Object.freeze({
|
||||||
|
fixtureOnly: true,
|
||||||
|
async transaction(refs, callback) {
|
||||||
|
const keys = [...new Set(refs)].sort();
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!records.has(key)) refuse('missing-fixture-credential');
|
||||||
|
if (locks.has(key)) refuse('credential-busy');
|
||||||
|
}
|
||||||
|
keys.forEach(key => locks.add(key));
|
||||||
|
try {
|
||||||
|
const draft = new Map(keys.map(key => [key, structuredClone(records.get(key))]));
|
||||||
|
const result = await callback(draft);
|
||||||
|
for (const key of keys) validateFixtureCredential(draft.get(key), records.get(key)?.type ?? 'none');
|
||||||
|
keys.forEach(key => records.set(key, structuredClone(draft.get(key))));
|
||||||
|
return result;
|
||||||
|
} finally { keys.forEach(key => locks.delete(key)); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
stores.add(store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
@@ -2,3 +2,6 @@ import { validateProvider, validateAccount, validateSettingsProfile, validateSea
|
|||||||
import { loadRegistry } from "./registry.mjs";
|
import { loadRegistry } from "./registry.mjs";
|
||||||
|
|
||||||
export { validateProvider, validateAccount, validateSettingsProfile, validateSeatSelection, validateHarnessManifest, ValidationError, loadRegistry };
|
export { validateProvider, validateAccount, validateSettingsProfile, validateSeatSelection, validateHarnessManifest, ValidationError, loadRegistry };
|
||||||
|
export { resolveFixtureExecution, FixtureError } from './execution.mjs';
|
||||||
|
export { createFixtureCredentialStore } from './fixture-store.mjs';
|
||||||
|
export { createFixtureWorkspace } from './materialize-fixture.mjs';
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Synthetic-only publication simulator. No caller-selected filesystem root,
|
||||||
|
// production credential adapter, real Pi runner, service or live activation.
|
||||||
|
import { mkdtemp, mkdir, open, rename, rm, lstat } from 'node:fs/promises';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { FixtureError, exact, refuse, resolveFixtureExecution, validateFixtureCredential } from './execution.mjs';
|
||||||
|
import { isFixtureStore } from './fixture-store.mjs';
|
||||||
|
import { refreshFixtureCredential, validateRefreshOptions } from './refresh-fixture.mjs';
|
||||||
|
const anchor = h => `/proc/self/fd/${h.fd}`;
|
||||||
|
async function openDir(path) { return open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
|
||||||
|
async function verifyJson(dir, name, value) {
|
||||||
|
const expected = Buffer.from(JSON.stringify(value, null, 2) + '\n');
|
||||||
|
const h = await open(`${anchor(dir)}/${name}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||||
|
try {
|
||||||
|
const s = await h.stat();
|
||||||
|
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size !== expected.length) refuse('generation-validation-failed');
|
||||||
|
const actual = Buffer.alloc(expected.length + 1);
|
||||||
|
let size = 0;
|
||||||
|
while (size < actual.length) {
|
||||||
|
const { bytesRead } = await h.read(actual, size, actual.length - size, null);
|
||||||
|
if (!bytesRead) break;
|
||||||
|
size += bytesRead;
|
||||||
|
}
|
||||||
|
if (size !== expected.length || !actual.subarray(0, size).equals(expected)) refuse('generation-validation-failed');
|
||||||
|
} finally { await h.close(); }
|
||||||
|
}
|
||||||
|
async function writeJson(dir, name, value) {
|
||||||
|
const content = JSON.stringify(value, null, 2) + '\n';
|
||||||
|
if (Buffer.byteLength(content) > 1024 * 1024) refuse('generation-too-large');
|
||||||
|
const h = await open(`${anchor(dir)}/${name}`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||||
|
try { await h.writeFile(content); await h.sync(); }
|
||||||
|
finally { await h.close(); }
|
||||||
|
}
|
||||||
|
export async function createFixtureWorkspace() {
|
||||||
|
if (process.platform !== 'linux') refuse('unsupported-platform');
|
||||||
|
const path = await mkdtemp('/tmp/mosaic-materialization-fixture-');
|
||||||
|
const root = await openDir(path), identity = await root.stat();
|
||||||
|
let closed = false, active = 0;
|
||||||
|
const handles = {};
|
||||||
|
try {
|
||||||
|
for (const name of ['claims', 'pending', 'generations']) {
|
||||||
|
await mkdir(`${anchor(root)}/${name}`, { mode: 0o700 });
|
||||||
|
handles[name] = await openDir(`${anchor(root)}/${name}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
for (const h of Object.values(handles)) await h.close();
|
||||||
|
await root.close(); await rm(path, { recursive: true, force: true }); throw e;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
fixtureOnly: true, path,
|
||||||
|
async generate(registry, request, store, options = {}) {
|
||||||
|
if (closed) refuse('workspace-closed');
|
||||||
|
if (!isFixtureStore(store)) refuse('fixture-store-required');
|
||||||
|
exact(options, [], ['fault', 'refresh']);
|
||||||
|
if (options.fault !== undefined && !['after-auth', 'before-publish', 'after-publish'].includes(options.fault)) refuse('invalid-fixture-option');
|
||||||
|
options = { fault: options.fault, refresh: options.refresh === undefined ? undefined : validateRefreshOptions(options.refresh) };
|
||||||
|
const plan = resolveFixtureExecution(registry, request), id = plan.scope.executionId;
|
||||||
|
const pending = `${anchor(handles.pending)}/${id}`, claimPath = `${anchor(handles.claims)}/${id}`;
|
||||||
|
let claim, generation, claimed = false, published = false;
|
||||||
|
active++;
|
||||||
|
try {
|
||||||
|
try { await mkdir(claimPath, { mode: 0o700 }); claimed = true; }
|
||||||
|
catch { refuse('execution-already-claimed'); }
|
||||||
|
claim = await openDir(claimPath);
|
||||||
|
await writeJson(claim, 'started.json', { fixtureOnly: true, executionId: id, state: 'started' });
|
||||||
|
const result = await store.transaction(Object.values(plan.accounts), async draft => {
|
||||||
|
const auth = Object.create(null);
|
||||||
|
for (const [provider, ref] of Object.entries(plan.accounts)) {
|
||||||
|
let credential = validateFixtureCredential(draft.get(ref), plan.types[provider]);
|
||||||
|
if (credential !== null && (options.refresh !== undefined ||
|
||||||
|
(credential.type === 'oauth' && credential.expires <= Date.now() + 300000))) {
|
||||||
|
credential = await refreshFixtureCredential(provider, credential, options.refresh ?? {});
|
||||||
|
draft.set(ref, credential);
|
||||||
|
}
|
||||||
|
if (credential !== null) auth[provider] = credential;
|
||||||
|
}
|
||||||
|
await mkdir(pending, { mode: 0o700 }); generation = await openDir(pending);
|
||||||
|
await writeJson(generation, 'auth.json', auth);
|
||||||
|
if (options.fault === 'after-auth') refuse('injected-generation-failure');
|
||||||
|
await writeJson(generation, 'models.json', plan.models);
|
||||||
|
const manifest = { manifestVersion: 1, fixtureOnly: true, manifestId: randomUUID(),
|
||||||
|
scope: plan.scope, profile: plan.profile, accounts: plan.accounts, fork: plan.fork,
|
||||||
|
artifacts: ['auth.json', 'models.json'], state: 'ready' };
|
||||||
|
// Manifest contains no token, expiry or credential-content digest.
|
||||||
|
await writeJson(generation, 'manifest.json', manifest);
|
||||||
|
if (options.fault === 'before-publish') refuse('injected-generation-failure');
|
||||||
|
await verifyJson(generation, 'auth.json', auth);
|
||||||
|
await verifyJson(generation, 'models.json', plan.models);
|
||||||
|
await verifyJson(generation, 'manifest.json', manifest);
|
||||||
|
await generation.sync(); await generation.close(); generation = undefined;
|
||||||
|
const target = `${anchor(handles.generations)}/${id}`;
|
||||||
|
try { await lstat(target); refuse('generation-exists'); }
|
||||||
|
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||||
|
// Retained exclusive claim serializes this execution ID, including
|
||||||
|
// failed attempts. Published generations are never replaced by API.
|
||||||
|
await rename(pending, target); published = true;
|
||||||
|
if (options.fault === 'after-publish') refuse('injected-generation-failure');
|
||||||
|
await handles.generations.sync();
|
||||||
|
await writeJson(claim, 'result.json', { fixtureOnly: true, state: 'published', manifestId: manifest.manifestId });
|
||||||
|
return { fixtureOnly: true, manifest, generationPath: `${path}/generations/${id}` };
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (generation) { await generation.close(); generation = undefined; }
|
||||||
|
if (claimed && !published) await rm(pending, { recursive: true, force: true });
|
||||||
|
const code = published ? 'publication-uncertain' : e instanceof FixtureError ? e.code : 'generation-failed';
|
||||||
|
if (claim) {
|
||||||
|
try { await writeJson(claim, 'failure.json', { fixtureOnly: true, state: published ? 'uncertain' : 'failed', code }); }
|
||||||
|
catch { throw new FixtureError('recording-failed'); }
|
||||||
|
}
|
||||||
|
throw new FixtureError(code);
|
||||||
|
} finally {
|
||||||
|
if (claim) await claim.close(); active--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
if (closed) return;
|
||||||
|
if (active) refuse('workspace-busy');
|
||||||
|
closed = true;
|
||||||
|
for (const h of Object.values(handles)) await h.close();
|
||||||
|
await root.close();
|
||||||
|
const current = await lstat(path);
|
||||||
|
if (current.dev !== identity.dev || current.ino !== identity.ino || current.isSymbolicLink()) refuse('workspace-path-changed');
|
||||||
|
await rm(path, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Deliberately executes a fixed fake program, never Pi or caller-supplied code.
|
||||||
|
// PI_CODING_AGENT_DIR matches the statically verified 0.85.1 auth boundary.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtemp, mkdir, writeFile, open, rm } from 'node:fs/promises';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { validId } from './records.mjs';
|
||||||
|
import { exact, refuse, validateFixtureCredential } from './execution.mjs';
|
||||||
|
const fake = `
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
const [command, subcommand, flag, provider] = process.argv.slice(1);
|
||||||
|
if (command !== 'auth' || subcommand !== 'check' || flag !== '--provider') process.exit(2);
|
||||||
|
if (Object.keys(process.env).some(k => !['HOME','PI_CODING_AGENT_DIR','FIXTURE_MODE'].includes(k))) process.exit(2);
|
||||||
|
const path = join(process.env.PI_CODING_AGENT_DIR, 'auth.json');
|
||||||
|
const mode = process.env.FIXTURE_MODE;
|
||||||
|
if (mode === 'timeout') await new Promise(() => setInterval(() => {}, 1000));
|
||||||
|
if (mode === 'failure') { process.stderr.write('FIXTURE_PRIVATE_DIAGNOSTIC'); process.exit(1); }
|
||||||
|
if (mode === 'malformed') { await writeFile(path, '{', { mode: 0o600 }); process.exit(0); }
|
||||||
|
const auth = JSON.parse(await readFile(path, 'utf8'));
|
||||||
|
const c = auth[provider];
|
||||||
|
if (mode === 'rotate' && c.type === 'oauth') {
|
||||||
|
c.access = 'FIXTURE_ROTATED_ACCESS'; c.refresh = 'FIXTURE_ROTATED_REFRESH';
|
||||||
|
c.expires = Date.now() + 3600000;
|
||||||
|
}
|
||||||
|
await writeFile(path, JSON.stringify(auth), { mode: 0o600 });
|
||||||
|
process.stdout.write('ready');
|
||||||
|
`;
|
||||||
|
export function validateRefreshOptions(options = {}) {
|
||||||
|
exact(options, [], ['mode', 'timeoutMs']);
|
||||||
|
const mode = options.mode ?? 'rotate', timeoutMs = options.timeoutMs ?? 2000;
|
||||||
|
if (!['rotate', 'unchanged', 'failure', 'timeout', 'malformed'].includes(mode) ||
|
||||||
|
!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 10000) refuse('invalid-refresh-option');
|
||||||
|
return { mode, timeoutMs };
|
||||||
|
}
|
||||||
|
export async function refreshFixtureCredential(provider, credential, options = {}) {
|
||||||
|
if (!validId(provider)) refuse('invalid-provider');
|
||||||
|
const { mode, timeoutMs } = validateRefreshOptions(options);
|
||||||
|
const input = validateFixtureCredential(credential, credential?.type);
|
||||||
|
const root = await mkdtemp('/tmp/mosaic-refresh-fixture-');
|
||||||
|
try {
|
||||||
|
const agent = `${root}/agent`, home = `${root}/home`, cwd = `${root}/cwd`;
|
||||||
|
for (const dir of [agent, home, cwd]) await mkdir(dir, { mode: 0o700 });
|
||||||
|
const file = `${agent}/auth.json`;
|
||||||
|
await writeFile(file, JSON.stringify({ [provider]: input }), { mode: 0o600, flag: 'wx' });
|
||||||
|
const outcome = await new Promise(resolve => {
|
||||||
|
let timedOut = false, spawnFailed = false;
|
||||||
|
const child = spawn(process.execPath, ['--input-type=module', '-e', fake, 'auth', 'check', '--provider', provider], {
|
||||||
|
cwd, env: { HOME: home, PI_CODING_AGENT_DIR: agent, FIXTURE_MODE: mode },
|
||||||
|
// Child output is discarded, never buffered, parsed, logged or returned.
|
||||||
|
stdio: 'ignore', shell: false,
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
|
||||||
|
child.on('error', () => { spawnFailed = true; });
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
clearTimeout(timer); resolve({ code, signal, timedOut, spawnFailed });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (outcome.timedOut) refuse('refresh-timeout');
|
||||||
|
if (outcome.spawnFailed || outcome.code !== 0 || outcome.signal) refuse('refresh-failed');
|
||||||
|
const h = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const s = await h.stat();
|
||||||
|
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 4096) refuse('invalid-refresh-output');
|
||||||
|
const buf = Buffer.alloc(4097); let size = 0;
|
||||||
|
while (size < buf.length) {
|
||||||
|
const { bytesRead } = await h.read(buf, size, buf.length - size, null);
|
||||||
|
if (!bytesRead) break;
|
||||||
|
size += bytesRead;
|
||||||
|
}
|
||||||
|
if (size > 4096) refuse('invalid-refresh-output');
|
||||||
|
try { result = JSON.parse(buf.subarray(0, size).toString('utf8')); }
|
||||||
|
catch { refuse('invalid-refresh-output'); }
|
||||||
|
} finally { await h.close(); }
|
||||||
|
exact(result, [provider]);
|
||||||
|
const output = validateFixtureCredential(result[provider], input.type);
|
||||||
|
if (output.type === 'oauth' && output.expires <= Date.now() + 300000) refuse('refresh-not-ready');
|
||||||
|
return output;
|
||||||
|
} finally { await rm(root, { recursive: true, force: true }); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile, readdir, stat, lstat, writeFile, mkdir, symlink } from 'node:fs/promises';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { resolveFixtureExecution } from '../src/execution.mjs';
|
||||||
|
import { createFixtureCredentialStore } from '../src/fixture-store.mjs';
|
||||||
|
import { createFixtureWorkspace } from '../src/materialize-fixture.mjs';
|
||||||
|
const base = resolve(import.meta.dirname, 'fixtures/valid');
|
||||||
|
const json = name => JSON.parse(readFileSync(`${base}/${name}`, 'utf8'));
|
||||||
|
function registry() {
|
||||||
|
const account = json('auth/accounts/openai-codex/homelab-openai/account.json');
|
||||||
|
const profile = json('auth/settings/research-default.json');
|
||||||
|
profile.allowedAccounts.push('openai-codex/personal');
|
||||||
|
return { errors: [], entries: {
|
||||||
|
providers: { 'openai-codex': json('auth/providers/openai-codex.json'), 'ollama-remote': json('auth/providers/ollama-remote.json') },
|
||||||
|
profiles: { 'research-default': profile }, harnesses: { pi: json('harnesses/pi.json') }, selections: {},
|
||||||
|
accounts: { 'openai-codex/homelab-openai': account, 'openai-codex/personal': { ...account, id: 'personal', name: 'Personal' } },
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
const credential = suffix => ({ type: 'oauth', access: `FIXTURE_ACCESS_${suffix}`, refresh: `FIXTURE_REFRESH_${suffix}`, expires: 9000000000000 });
|
||||||
|
const store = () => createFixtureCredentialStore({ 'openai-codex/homelab-openai': credential('A'), 'openai-codex/personal': credential('B') });
|
||||||
|
const request = (executionId = 'exec-a') => ({ fixtureOnly: true, agentId: 'rocko', projectId: 'project-a', workspaceId: 'workspace-a', sessionId: 'session-a', executionId, profile: 'research-default' });
|
||||||
|
const read = async p => JSON.parse(await readFile(p, 'utf8'));
|
||||||
|
async function workspace(t) { const w = await createFixtureWorkspace(); t.after(() => w.close()); return w; }
|
||||||
|
|
||||||
|
test('pure resolution selects current default or explicit enrolled account', () => {
|
||||||
|
const r = registry(), req = request();
|
||||||
|
const p = resolveFixtureExecution(r, req);
|
||||||
|
assert.equal(p.accounts['openai-codex'], 'openai-codex/homelab-openai');
|
||||||
|
assert.equal(p.models.providers['ollama-remote'].models[0].id, 'qwen2.5-coder:7b');
|
||||||
|
r.entries.profiles['research-default'].defaultAccounts['openai-codex'] = 'openai-codex/personal';
|
||||||
|
assert.equal(resolveFixtureExecution(r, req).accounts['openai-codex'], 'openai-codex/personal');
|
||||||
|
assert.equal(p.accounts['openai-codex'], 'openai-codex/homelab-openai');
|
||||||
|
});
|
||||||
|
test('scope is explicit, bounded and never inferred', () => {
|
||||||
|
for (const key of ['agentId', 'projectId', 'workspaceId', 'sessionId', 'executionId']) {
|
||||||
|
const r = request(); delete r[key]; assert.throws(() => resolveFixtureExecution(registry(), r), /invalid-fixture-input/);
|
||||||
|
r[key] = '../outside'; assert.throws(() => resolveFixtureExecution(registry(), r), /invalid-scope/);
|
||||||
|
}
|
||||||
|
assert.throws(() => resolveFixtureExecution(registry(), { ...request(), fixtureOnly: false }), /fixture-only/);
|
||||||
|
});
|
||||||
|
test('fork pin is preserved against default change, override, missing account and revocation', () => {
|
||||||
|
const r = registry(), q = { ...request(), fork: { sourceSessionId: 'source', accounts: { 'openai-codex': 'openai-codex/homelab-openai' } } };
|
||||||
|
r.entries.profiles['research-default'].defaultAccounts['openai-codex'] = 'openai-codex/personal';
|
||||||
|
assert.equal(resolveFixtureExecution(r, q).accounts['openai-codex'], 'openai-codex/homelab-openai');
|
||||||
|
assert.throws(() => resolveFixtureExecution(r, { ...q, accounts: { 'openai-codex': 'openai-codex/personal' } }), /fork-account-change/);
|
||||||
|
assert.throws(() => resolveFixtureExecution(r, { ...q, fork: { sourceSessionId: 'source', accounts: {} } }), /missing-fork-pin/);
|
||||||
|
delete r.entries.accounts['openai-codex/homelab-openai'];
|
||||||
|
assert.throws(() => resolveFixtureExecution(r, q), /missing-account/);
|
||||||
|
r.entries.profiles['research-default'].allowedAccounts = ['openai-codex/personal'];
|
||||||
|
assert.throws(() => resolveFixtureExecution(r, q), /account-not-enrolled/);
|
||||||
|
});
|
||||||
|
test('unenrolled account/provider, missing harness, model expansion and native model ceiling refuse', () => {
|
||||||
|
assert.throws(() => resolveFixtureExecution(registry(), { ...request(), accounts: { unknown: 'unknown/a' } }), /provider-not-enrolled/);
|
||||||
|
assert.throws(() => resolveFixtureExecution(registry(), { ...request(), accounts: { 'openai-codex': 'other/a' } }), /account-not-enrolled/);
|
||||||
|
const r = registry(); delete r.entries.harnesses.pi;
|
||||||
|
assert.throws(() => resolveFixtureExecution(r, request()), /unsupported-harness/);
|
||||||
|
const s = registry(); s.entries.profiles['research-default'].models['ollama-remote'] = ['not-allowed'];
|
||||||
|
assert.throws(() => resolveFixtureExecution(s, request()), /model-not-enrolled/);
|
||||||
|
s.entries.profiles['research-default'].models = { 'openai-codex': ['not-enforceable'] };
|
||||||
|
assert.throws(() => resolveFixtureExecution(s, request()), /native-model-enforcement-unavailable/);
|
||||||
|
});
|
||||||
|
test('only explicit synthetic credential forms and internal fixture stores admitted', async t => {
|
||||||
|
assert.throws(() => createFixtureCredentialStore({ 'openai-codex/homelab-openai': { type: 'oauth', access: 'not-fixture', refresh: 'not-fixture', expires: 3 } }), /invalid-fixture-credential/);
|
||||||
|
const w = await workspace(t);
|
||||||
|
await assert.rejects(w.generate(registry(), request(), { fixtureOnly: true, transaction() { throw Error('must not execute'); } }), /fixture-store-required/);
|
||||||
|
});
|
||||||
|
test('two concurrent workspaces of the same agent publish distinct complete private generations', async t => {
|
||||||
|
const w = await workspace(t), s = store();
|
||||||
|
const a = request('one'), b = { ...request('two'), sessionId: 'session-b', workspaceId: 'workspace-b', accounts: { 'openai-codex': 'openai-codex/personal' } };
|
||||||
|
const [x, y] = await Promise.all([w.generate(registry(), a, s), w.generate(registry(), b, s)]);
|
||||||
|
assert.notEqual(x.generationPath, y.generationPath);
|
||||||
|
assert.equal((await read(`${x.generationPath}/auth.json`))['openai-codex'].access, 'FIXTURE_ACCESS_A');
|
||||||
|
assert.equal((await read(`${y.generationPath}/auth.json`))['openai-codex'].access, 'FIXTURE_ACCESS_B');
|
||||||
|
for (const g of [x, y]) {
|
||||||
|
assert.deepEqual((await readdir(g.generationPath)).sort(), ['auth.json', 'manifest.json', 'models.json']);
|
||||||
|
assert.equal((await stat(g.generationPath)).mode & 0o777, 0o700);
|
||||||
|
for (const file of ['auth.json', 'manifest.json', 'models.json']) assert.equal((await stat(`${g.generationPath}/${file}`)).mode & 0o777, 0o600);
|
||||||
|
const manifest = await readFile(`${g.generationPath}/manifest.json`, 'utf8');
|
||||||
|
assert.ok(!/FIXTURE_ACCESS|FIXTURE_REFRESH|expires|sha256|credentialHash/.test(manifest));
|
||||||
|
assert.equal(g.manifest.state, 'ready');
|
||||||
|
}
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/pending`), []);
|
||||||
|
});
|
||||||
|
test('same execution ID is exclusively claimed and cannot overwrite a published generation', async t => {
|
||||||
|
const w = await workspace(t), s = store();
|
||||||
|
const attempts = await Promise.allSettled([w.generate(registry(), request(), s), w.generate(registry(), request(), s)]);
|
||||||
|
assert.equal(attempts.filter(a => a.status === 'fulfilled').length, 1);
|
||||||
|
const g = attempts.find(a => a.status === 'fulfilled').value;
|
||||||
|
const before = await readFile(`${g.generationPath}/auth.json`);
|
||||||
|
await assert.rejects(w.generate(registry(), request(), s), /execution-already-claimed/);
|
||||||
|
assert.deepEqual(await readFile(`${g.generationPath}/auth.json`), before);
|
||||||
|
});
|
||||||
|
for (const fault of ['after-auth', 'before-publish']) test(`failed generation ${fault} preserves prior files, records failure and refuses blind same-ID retry`, async t => {
|
||||||
|
const w = await workspace(t), s = store(); const first = await w.generate(registry(), request('good'), s);
|
||||||
|
const before = await readFile(`${first.generationPath}/auth.json`);
|
||||||
|
await assert.rejects(w.generate(registry(), request('bad'), s, { fault }), /injected-generation-failure/);
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/generations`), ['good']);
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/pending`), []);
|
||||||
|
assert.deepEqual(await readFile(`${first.generationPath}/auth.json`), before);
|
||||||
|
assert.equal((await read(`${w.path}/claims/bad/failure.json`)).state, 'failed');
|
||||||
|
await assert.rejects(w.generate(registry(), request('bad'), s), /execution-already-claimed/);
|
||||||
|
});
|
||||||
|
test('credential lock contention refuses without duplicate side effects', async t => {
|
||||||
|
const w = await workspace(t), s = store(); let release, entered;
|
||||||
|
const ready = new Promise(r => { entered = r; });
|
||||||
|
const held = s.transaction(['openai-codex/homelab-openai'], async () => { entered(); await new Promise(r => { release = r; }); });
|
||||||
|
await ready;
|
||||||
|
try { await assert.rejects(w.generate(registry(), request(), s), /credential-busy/); }
|
||||||
|
finally { release(); await held; }
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/generations`), []);
|
||||||
|
});
|
||||||
|
test('symlinked pre-existing final target is refused and never followed', async t => {
|
||||||
|
const w = await workspace(t); await mkdir(`${w.path}/outside`, { mode: 0o700 });
|
||||||
|
await writeFile(`${w.path}/outside/sentinel`, 'unchanged');
|
||||||
|
await symlink(`${w.path}/outside`, `${w.path}/generations/exec-a`);
|
||||||
|
await assert.rejects(w.generate(registry(), request(), store()), /generation-exists/);
|
||||||
|
assert.equal(await readFile(`${w.path}/outside/sentinel`, 'utf8'), 'unchanged');
|
||||||
|
assert.ok((await lstat(`${w.path}/generations/exec-a`)).isSymbolicLink());
|
||||||
|
});
|
||||||
|
test('invalid registry cannot resolve; no fallback to supplied partial entries', () => {
|
||||||
|
const r = registry(); r.errors = [{ code: 'refused' }];
|
||||||
|
assert.throws(() => resolveFixtureExecution(r, request()), /invalid-registry/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('post-publication failure records uncertainty, preserves complete generation and prevents replay', async t => {
|
||||||
|
const w = await workspace(t), s = store();
|
||||||
|
await assert.rejects(w.generate(registry(), request('uncertain'), s, { fault: 'after-publish' }), /publication-uncertain/);
|
||||||
|
const dir = `${w.path}/generations/uncertain`;
|
||||||
|
assert.deepEqual((await readdir(dir)).sort(), ['auth.json', 'manifest.json', 'models.json']);
|
||||||
|
const before = await readFile(`${dir}/auth.json`);
|
||||||
|
assert.equal((await read(`${w.path}/claims/uncertain/failure.json`)).state, 'uncertain');
|
||||||
|
await assert.rejects(w.generate(registry(), request('uncertain'), s), /execution-already-claimed/);
|
||||||
|
assert.deepEqual(await readFile(`${dir}/auth.json`), before);
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/pending`), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('expired credentials refresh under transaction and subsequent generation reuses rotation', async t => {
|
||||||
|
const w = await workspace(t), s = createFixtureCredentialStore({
|
||||||
|
'openai-codex/homelab-openai': { ...credential('OLD'), expires: 1 },
|
||||||
|
});
|
||||||
|
const first = await w.generate(registry(), request('rotate'), s);
|
||||||
|
const auth = await read(`${first.generationPath}/auth.json`);
|
||||||
|
assert.equal(auth['openai-codex'].access, 'FIXTURE_ROTATED_ACCESS');
|
||||||
|
assert.equal(auth['openai-codex'].refresh, 'FIXTURE_ROTATED_REFRESH');
|
||||||
|
const second = await w.generate(registry(), request('reuse'), s);
|
||||||
|
assert.deepEqual(await read(`${second.generationPath}/auth.json`), auth);
|
||||||
|
await s.transaction(['openai-codex/homelab-openai'], draft => {
|
||||||
|
assert.equal(draft.get('openai-codex/homelab-openai').refresh, 'FIXTURE_ROTATED_REFRESH');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (const mode of ['failure', 'timeout', 'malformed']) test(`refresh ${mode} retains prior generation and store state`, async t => {
|
||||||
|
const w = await workspace(t), s = store();
|
||||||
|
const prior = await w.generate(registry(), request('prior'), s);
|
||||||
|
const bytes = await readFile(`${prior.generationPath}/auth.json`);
|
||||||
|
await assert.rejects(w.generate(registry(), request('failed-refresh'), s,
|
||||||
|
{ refresh: { mode, timeoutMs: mode === 'timeout' ? 100 : 2000 } }));
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/generations`), ['prior']);
|
||||||
|
assert.deepEqual(await readFile(`${prior.generationPath}/auth.json`), bytes);
|
||||||
|
await s.transaction(['openai-codex/homelab-openai'], draft => {
|
||||||
|
assert.equal(draft.get('openai-codex/homelab-openai').access, 'FIXTURE_ACCESS_A');
|
||||||
|
});
|
||||||
|
await assert.rejects(w.generate(registry(), request('failed-refresh'), s), /execution-already-claimed/);
|
||||||
|
});
|
||||||
|
test('concurrent refresh on same account refuses contention while unrelated account proceeds', async t => {
|
||||||
|
const w = await workspace(t), s = store();
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
w.generate(registry(), request('busy-a'), s, { refresh: { mode: 'timeout', timeoutMs: 200 } }),
|
||||||
|
w.generate(registry(), request('busy-b'), s, { refresh: { mode: 'timeout', timeoutMs: 200 } }),
|
||||||
|
w.generate(registry(), { ...request('independent'), accounts: { 'openai-codex': 'openai-codex/personal' } }, s),
|
||||||
|
]);
|
||||||
|
const codes = results.slice(0, 2).map(r => r.reason?.code).sort();
|
||||||
|
assert.deepEqual(codes, ['credential-busy', 'refresh-timeout']);
|
||||||
|
assert.equal(results[2].status, 'fulfilled');
|
||||||
|
assert.deepEqual(await readdir(`${w.path}/generations`), ['independent']);
|
||||||
|
});
|
||||||
|
test('invalid refresh options refuse before burning claim', async t => {
|
||||||
|
const w = await workspace(t), s = store();
|
||||||
|
await assert.rejects(w.generate(registry(), request(), s, { refresh: { executable: '/bin/false' } }), /invalid-fixture-input/);
|
||||||
|
await w.generate(registry(), request(), s);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { refreshFixtureCredential } from '../src/refresh-fixture.mjs';
|
||||||
|
const expired = () => ({ type: 'oauth', access: 'FIXTURE_OLD_ACCESS', refresh: 'FIXTURE_OLD_REFRESH', expires: 1 });
|
||||||
|
test('fixed fake process rotates both OAuth fields without mutating caller input', async () => {
|
||||||
|
const input = expired(), result = await refreshFixtureCredential('openai-codex', input);
|
||||||
|
assert.ok(result.access !== input.access && result.refresh !== input.refresh);
|
||||||
|
assert.ok(result.expires > Date.now() + 300000);
|
||||||
|
assert.equal(input.expires, 1);
|
||||||
|
});
|
||||||
|
test('concurrent isolated processes preserve separate provider credentials', async () => {
|
||||||
|
const a = { type: 'api_key', key: 'FIXTURE_ACCOUNT_A' }, b = { type: 'api_key', key: 'FIXTURE_ACCOUNT_B' };
|
||||||
|
const results = await Promise.all([refreshFixtureCredential('provider-a', a), refreshFixtureCredential('provider-b', b)]);
|
||||||
|
assert.deepEqual(results, [a, b]);
|
||||||
|
});
|
||||||
|
for (const [mode, code] of [['failure', 'refresh-failed'], ['malformed', 'invalid-refresh-output'], ['timeout', 'refresh-timeout'], ['unchanged', 'refresh-not-ready']]) {
|
||||||
|
test(`fake ${mode} is refused with fixed diagnostics`, async () => {
|
||||||
|
await assert.rejects(refreshFixtureCredential('openai-codex', expired(), { mode, timeoutMs: mode === 'timeout' ? 100 : 2000 }), e => e.message === code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('caller executable/environment injection is rejected before spawning', async () => {
|
||||||
|
await assert.rejects(refreshFixtureCredential('openai-codex', expired(), { executable: '/bin/false' }), /invalid-fixture-input/);
|
||||||
|
await assert.rejects(refreshFixtureCredential('openai-codex', expired(), { env: {} }), /invalid-fixture-input/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user