software-defence-factory 0.15.1 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -255,7 +255,7 @@ The binding contract supports at most 16 named, keyless HTTP(S) endpoints. URLs
255
255
  with user information, queries, fragments, escapes or malformed syntax are refused.
256
256
  Model identifiers cannot contain shell syntax, whitespace or traversal. Context
257
257
  must be 1,024–1,048,576 tokens; output must be 1–32,000 and smaller than context.
258
- The output ceiling follows pinned Pi 0.73.1's simple chat adapter. Range acceptance
258
+ The output ceiling remains 32,000 with the pinned Pi 0.87.1 chat adapter. Range acceptance
259
259
  is **not** evidence of server allocation or useful task capacity. Only the three
260
260
  shown compatibility fields are supported. Authentication, custom headers, shell
261
261
  credential commands, arbitrary request fields, sampling overrides, custom thinking
@@ -271,7 +271,7 @@ The optional binding `reasoningEffort` accepts only these OpenAI-style requests:
271
271
  | `low`, `medium`, `high` | `reasoning_effort` set to that exact value |
272
272
 
273
273
  This is a request choice, not a measured thinking budget or quality claim. The
274
- pinned Pi 0.73.1 adapter uses a fixed internal `thinkingLevelMap.off="none"` for
274
+ pinned Pi 0.87.1 adapter uses a fixed internal `thinkingLevelMap.off="none"` for
275
275
  `none`; `--thinking off` alone does **not** disable a server's default thinking.
276
276
  Low/medium/high use the corresponding explicit Pi thinking level. Existing
277
277
  bindings with no choice retain their omitted request and private policy shape;
@@ -295,7 +295,12 @@ For the selected local role only, the executor creates a private single-model
295
295
  `models.json` and deterministic launcher, mounted read-only at `/factory-local`.
296
296
  `PI_CODING_AGENT_DIR` selects that directory; HOME is still ephemeral. The launcher
297
297
  checks explicit provider/model argv, removes inherited credential/override inputs
298
- and propagates Pi JSON provider failures even if Pi exits zero. The registry uses
298
+ and requires a completed JSON turn even if Pi exits zero. Explicit successful
299
+ retry/overflow recovery can clear an assistant error; failed/aborted compaction,
300
+ exhausted retries, malformed/truncated JSONL and missing completion remain failures.
301
+ The original event stream is retained in the bounded private log. The launcher also
302
+ passes the fixed placeholder as Pi's supported runtime `--api-key`, avoiding a
303
+ credential-store refresh against the read-only registry. No credentials are added. The registry uses
299
304
  Pi's required fixed non-secret key placeholder (`factory-local-keyless`), which may
300
305
  be sent as a bearer value; this is not endpoint authentication. No host home, provider
301
306
  catalog or credential store is mounted. Repository files cannot replace this mount.
@@ -308,3 +313,9 @@ automatic skills/extensions/templates but explicitly adds `--skill /factory-skil
308
313
  preserving the six packaged skills. Recommendations remain guidance, not per-role
309
314
  access controls. Build still returns to Factory's independent Review and operator
310
315
  handoff; no duplicate review workflow is launched.
316
+
317
+
318
+ For long tool-use runs, use the [context-boundary qualification and image migration
319
+ recipe](setup.md#local-context-budget-and-worker-image-0152-100). Declared context
320
+ and output budgets are the same shared binding fields in CLI, API and dashboard;
321
+ there are no Factory compaction knobs or automatic larger-window/model choices.
package/docs/npm.md CHANGED
@@ -116,8 +116,8 @@ installation or retained attempt needs them.
116
116
 
117
117
  ## Protected evidence compatibility
118
118
 
119
- Factory 0.15.1 recognizes version-1 execution profiles emitted by native
120
- **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2, 0.12.0, 0.13.0, 0.13.1, 0.14.0, 0.15.0 and 0.15.1**. This is an exact allowlist in
119
+ Factory 0.15.2 recognizes version-1 execution profiles emitted by native
120
+ **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2, 0.12.0, 0.13.0, 0.13.1, 0.14.0, 0.15.0, 0.15.1 and 0.15.2**. This is an exact allowlist in
121
121
  `factory/execution-profile.mjs`, independent of the installed package version;
122
122
  it is not a semver range or an automatic promise for later releases. Unknown
123
123
  runtime strings, unknown profile formats and incomplete legacy acceptance
@@ -184,7 +184,7 @@ Verify evidence and cannot authorize acceptance or publication.
184
184
 
185
185
  Version 0.14.0 preserves the v1 writer only for unchanged inherited installations:
186
186
  policy bytes, candidate/check/review/acceptance bindings, mounts and credential
187
- rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0 and 0.15.1**, recording
187
+ rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0, 0.15.1 and 0.15.2**, recording
188
188
  role/provider/effort and an exact selection digest. The executor verifies the
189
189
  frozen common configuration before phase selection; continuation and both delivery
190
190
  validation paths require matching protected v2 evidence and private frozen config.
@@ -254,7 +254,7 @@ The 0.15.0 compatibility audit preserves unchanged v1/v2 policy construction,
254
254
  protected checks, candidate reconstruction, read-only review and acceptance guards.
255
255
  Historical 0.14.0 v2 validation compares its actual writer version rather than
256
256
  relabeling it as the installed version. Local registry selection is a new execution
257
- contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0 or 0.15.1** and its
257
+ contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1 or 0.15.2** and its
258
258
  exact protected frozen configuration. v1/v2 cannot attest local binding fields,
259
259
  providers or a common policy containing a selected local role. A selected endpoint,
260
260
  model, limit or compatibility change invalidates the current policy; explicit
@@ -268,3 +268,17 @@ writers, frozen configuration and policy bytes. The explicit allowlists include
268
268
  both patch versions; supported v3 readback retains the original writer version
269
269
  while still comparing every execution selection, phase and policy field. It does
270
270
  not rewrite historical evidence or allow v1/v2 to attest local bindings.
271
+
272
+
273
+ The 0.15.2 compatibility audit adds that exact writer to the v1/v2/v3 allowlists.
274
+ It changes the local launcher's interpretation of explicit recovery/completion
275
+ and the standard image recipe, without changing the admitted command, binding
276
+ schema, frozen config/image/selection/policy hashes, protected checks, candidate
277
+ reconstruction or independent review/acceptance guards. Historical 0.15.0 and
278
+ 0.15.1 evidence remains readable with its original bytes and writer version;
279
+ readability does not qualify those images for long local tasks. Regression cases
280
+ exercise inherited, cloud-role, hybrid and local profiles through all phases,
281
+ reject tampered selections/images/policies and unsupported future writers, and
282
+ verify that readback does not rewrite retained evidence. Image adoption is a
283
+ separate idle operator action; existing attempt images are not migrated by a CLI
284
+ update. See [the migration/rollback recipe](setup.md#local-context-budget-and-worker-image-0152-100).
package/docs/setup.md CHANGED
@@ -430,3 +430,106 @@ leaves Pi usage unknown; controlled protocol fixtures are not token or quality
430
430
  benchmarks. Include cloud Review in hybrid totals. No provider token billing for
431
431
  local inference does not mean zero electricity, hardware or operator cost. Only
432
432
  then qualify a suitable real Factory issue; no customer jobs are part of this slice.
433
+
434
+
435
+ ## Local Pi project resources (0.15.3, #103)
436
+
437
+ The local launcher gives each invocation a private `0700` directory under the
438
+ job's existing `/tmp` tmpfs for Pi runtime stores/locks. `PI_CODING_AGENT_DIR`
439
+ points there; it is never the read-only `/factory-local` binding mount. The
440
+ selected `models.json` is linked to that mount, and the launcher/context adapter,
441
+ policy and six Factory skills remain read-only. No host home, saved trust,
442
+ authentication or settings are copied. Normal exit removes this state; container
443
+ teardown removes interrupted state. Build and Review never share it. The root
444
+ filesystem and Review checkout remain read-only.
445
+
446
+ Pinned Pi 0.87.1 consults project trust even for an empty `.agents/skills`
447
+ directory. Factory explicitly passes `--no-approve` to deny project settings,
448
+ packages and executable resource loading, while retaining `--no-extensions`,
449
+ `--no-skills` and `--no-prompt-templates`. A writable Pi home does not authorize
450
+ project dependency installation. Only the explicit bundled context extension
451
+ and `--skill /factory-skills` catalog are supported; their loading and the
452
+ context-ready handshake are tested against actual Pi. Task stdin and ordinary
453
+ `AGENTS.md` context remain available as untrusted text. Repository Pi settings,
454
+ extensions/packages and automatically discovered repository skills are not a
455
+ supported configuration path. Explicit task-driven file reads are still possible
456
+ within the sandbox and grant no additional authority.
457
+
458
+ Before accepting this repair, the lead must qualify the exact installed 0.15.3
459
+ package/image through CLI/API, representative native local Build and separate
460
+ read-only Review, and one actual local-model run. `npm run qualify:pi` uses actual
461
+ Pi with **synthetic HTTP inference**, not model judgment. See [trust-store
462
+ proof and limits](proof.md#pi-project-trust-startup-0153-103). Retain the failed
463
+ #51 attempt unchanged and unaccepted; retry only through a newly authorized
464
+ attempt after qualification. Do not modify private installations from a job.
465
+
466
+ ## Local context budget and worker image (0.15.2, #100)
467
+
468
+ The standard worker recipe pins **`@earendil-works/pi-coding-agent@0.87.1`** from
469
+ [earendil-works/pi](https://github.com/earendil-works/pi/tree/v0.87.1), replacing
470
+ `@mariozechner/pi-coding-agent@0.73.1`. Pi requires **Node >=22.19.0**; the image
471
+ installation enforces dependency engines. Factory's controller Node requirement
472
+ is unchanged. The separate Codex pin remains **0.156.1**. A CLI upgrade does not
473
+ rebuild or replace an admitted job image. Source transport proof is documented in
474
+ [proof](proof.md#pi-context-boundary-first-slice-0152-100); installed image and real
475
+ local-model qualification are separate acceptance gates.
476
+
477
+ Set the selected private binding's `contextWindow` no higher than the server's
478
+ **actually allocated** context for that exact model/request, and set `maxTokens`
479
+ within both endpoint support and available headroom. These existing fields have
480
+ the same CLI/API/dashboard validation and frozen policy. An advertised registry
481
+ window allocates no server memory. For Ollama, configure allocation through its
482
+ supported server/model controls and inspect the running allocation with
483
+ `ollama ps`; see [Ollama context length](https://docs.ollama.com/context-length).
484
+ Factory does not pass an undocumented `num_ctx`, patch a model template or tune
485
+ the host. Unsupported endpoint controls require an explicit capability limit.
486
+
487
+ Pi 0.87.1's upstream defaults reserve 16,384 tokens and keep approximately 20,000
488
+ recent tokens. At a declared 65,536, its threshold is 49,152, checked **after tool
489
+ results and before the next assistant request in the same run**. This repairs a
490
+ missing check in 0.73.1; increasing its reserve alone cannot add that check. Output
491
+ reservation, tool-result growth, system/tools overhead and summarization must all
492
+ fit the allocation. Prefix compaction alone cannot shrink one large retained
493
+ assistant/tool-result group. The bundled local adapter therefore projects tool
494
+ text into a conservative byte budget before generation and summarization, keeping
495
+ the exact task and tool identities/order. Truncation notices tell the model to
496
+ re-read with offset/limit or narrower queries; raw events/files stay independent
497
+ of this projection. Every request reserves the full configured output, includes
498
+ schemas/arguments and adds template headroom. This is conservative estimation,
499
+ not measured token usage or an allocation/completion guarantee. Unshrinkable
500
+ instructions/arguments fail explicitly rather than being silently removed.
501
+ Pi estimates native history before the wire projection and can reduce output to
502
+ one token. The adapter restores the selected generation allowance only with a
503
+ fitting final payload; explicit upstream summary caps stay bounded separately.
504
+ Both admitted `max_tokens` and `max_completion_tokens` compatibility fields are
505
+ supported. The existing OpenAI `reasoning_effort` selection remains inside that
506
+ total output ceiling; no extra reasoning allowance is added. Numeric/nested
507
+ thinking-budget formats are unsupported and fail explicitly instead of being
508
+ guessed or silently rewritten. Do not add project Pi settings to bypass
509
+ the admitted binding or rely on custom tuning that has not been qualified.
510
+ See [pinned compaction behavior](https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/docs/compaction.md).
511
+
512
+ Before adoption, stop and reconcile all work, retain the old immutable image ID
513
+ and a stopped private-state backup. Build/qualify the exact candidate image, then
514
+ explicitly select it with `factory install --image LOCAL_IMAGE_REF --state PRIVATE_STATE`
515
+ while idle. The normal install path retains the previously selected image. Plain
516
+ `factory install` builds the standard recipe, now tagged `software-defence-factory-job:0.4.0`;
517
+ updating the CLI alone does neither. Confirm the selected image ID and `pi --version`
518
+ in that image. The protected local launcher requires exactly 0.87.1 and loads only
519
+ its bundled read-only adapter with automatic extension discovery disabled. Missing
520
+ adapter files or an older worker image fail explicitly; old evidence remains
521
+ readable and cloud profiles keep their existing wrapper. Roll back with the retained old image through the same idle install
522
+ path and restore the previously working role definition through revision-guarded
523
+ rollback. Do not change frozen attempts or relabel old failed results.
524
+
525
+ Lead's bounded actual-inference fixture must include enough tool results to trigger
526
+ summarization, resumed useful tool work, the required report, checks and independent
527
+ Review. Record the exact image/model digest, harness/server versions, allocated
528
+ window, output/reasoning request, tool growth, compaction ordering, elapsed time,
529
+ quality outcome and peak memory; unknown measurements remain null. Set an explicit
530
+ job deadline/resource budget and preserve any unfinished checkout and diagnostic
531
+ artifacts. A short successful prompt is insufficient. A larger actually allocated
532
+ window is a later measured comparison only if this evidence warrants it; do not
533
+ switch model or cloud provider automatically. The failed #83 local attempt is not
534
+ local delivery. An operator-selected fresh cloud retry and unfinished-checkpoint
535
+ recovery remain governed by [recovery](recovery.md) and #42.
@@ -22,16 +22,21 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
22
22
  // 0.15.0 keeps unchanged v1/v2 policies and writers; local selections require
23
23
  // v3 for every phase. Legacy evidence cannot attest the new registry/launcher.
24
24
  // 0.15.1 changes release downloads only; v1/v2/v3 writers and policy are unchanged.
25
+ // 0.15.2 updates the opt-in local launcher and standard image; schemas, frozen
26
+ // command/config/image hashes, protected checks and acceptance guards are unchanged.
27
+ // 0.15.3 isolates local Pi mutable state and denies project resource trust.
28
+ // Evidence schemas, candidate reconstruction and frozen profile guards are unchanged;
29
+ // compatibility does not qualify an old local startup or accept a failed attempt.
25
30
  // Older writers still require exact patch/tree reconstruction before publication.
26
31
  // Deliberately independent of VERSION: a release bump is not
27
32
  // evidence compatibility. Re-audit this list for every trust-relevant writer,
28
33
  // isolation or validation change; remove versions whose guarantees no longer
29
34
  // satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
30
- const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1']);
35
+ const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3']);
31
36
 
32
37
  export function isSupportedExecutionProfile(profile) {
33
- if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
34
- : ['0.14.0', '0.15.0', '0.15.1'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
38
+ if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
39
+ : ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
35
40
  && profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
36
41
  && (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
37
42
  && /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
@@ -110,9 +110,10 @@ async function container(mode, input, command, options = {}) {
110
110
  mkdirSync(localDirectory, { mode: 0o700 });
111
111
  const { reference, ...binding } = config.localBinding;
112
112
  writeFileSync(join(localDirectory, 'launch.mjs'), readFileSync(join(ROOT, 'factory/pi-local-launch.mjs')), { mode: 0o600, flag: 'wx' });
113
+ for (const file of ['pi-context-extension.mjs', 'pi-context-budget.mjs'])
114
+ writeFileSync(join(localDirectory, file), readFileSync(join(ROOT, 'factory', file)), { mode: 0o600, flag: 'wx' });
113
115
  writeFileSync(join(localDirectory, 'models.json'), JSON.stringify(localRegistry(binding)), { mode: 0o600, flag: 'wx' });
114
- args.push('--mount', `type=bind,source=${localDirectory},target=${LOCAL_AGENT_DIR},readonly`,
115
- '--env', `PI_CODING_AGENT_DIR=${LOCAL_AGENT_DIR}`);
116
+ args.push('--mount', `type=bind,source=${localDirectory},target=${LOCAL_AGENT_DIR},readonly`);
116
117
  }
117
118
  selectedModelEnvironment = writeSelectedModelEnvironment(join(state, 'model.env'), modelEnvironmentPath, {
118
119
  phase: mode, executor: execution.executor, inferenceProvider: config.inferenceProvider,
@@ -1,7 +1,7 @@
1
1
  FROM node:22-bookworm-slim@sha256:43ac6c60b8f89723f746e8a92ce91abd5017e627ce1ddfe4238355d3a30b772c
2
2
  RUN apt-get update && apt-get install -y --no-install-recommends git python3 python3-venv ca-certificates procps \
3
3
  && rm -rf /var/lib/apt/lists/* \
4
- && npm install --global @openai/codex@0.156.1 @mariozechner/pi-coding-agent@0.73.1 \
4
+ && npm install --global --engine-strict @openai/codex@0.156.1 @earendil-works/pi-coding-agent@0.87.1 \
5
5
  && npm cache clean --force
6
6
  COPY mock.mjs /opt/factory/mock.mjs
7
7
  USER node
@@ -0,0 +1,75 @@
1
+ // Request projection only. Never change raw tool events, files or session entries.
2
+ // UTF-8 bytes (not characters/4) conservatively cover text tokenization; explicit
3
+ // framing headroom also covers chat templates. This is a budget, not usage.
4
+ export const bytes = value => Buffer.byteLength(JSON.stringify(value), 'utf8');
5
+ export const projectedTokens = payload => bytes(payload) + 1024
6
+ + 64 * ((payload.messages?.length || 0) + (payload.tools?.length || 0));
7
+
8
+ // Pi 0.87.1 clamps against native history BEFORE onPayload. Recover the caller's
9
+ // allowance, not that obsolete clamp. Explicit summary caps remain explicit.
10
+ // Only the selected adapter's OpenAI effort control is supported: numeric or
11
+ // nested thinking budgets could also have been clamped and must not be guessed.
12
+ export function restoreOutputBudget(payload, configured, requested, reasoning) {
13
+ const field = configured.compat.maxTokensField;
14
+ const fields = ['max_tokens', 'max_completion_tokens'].filter(key => Object.hasOwn(payload, key));
15
+ if (!Number.isSafeInteger(requested) || requested < 1 || requested > configured.maxTokens
16
+ || fields.length !== 1 || fields[0] !== field
17
+ || !Number.isSafeInteger(payload[field]) || payload[field] < 1 || payload[field] > requested)
18
+ throw new Error('Unsupported local output budget; refusing to override selected limits');
19
+ if ((reasoning !== undefined && !['off', 'low', 'medium', 'high'].includes(reasoning))
20
+ || ['reasoning', 'thinking', 'thinking_token_budget', 'reasoning_budget', 'chat_template_kwargs', 'chat_template_args', 'enable_thinking']
21
+ .some(key => Object.hasOwn(payload, key)))
22
+ throw new Error('Unsupported local reasoning budget; qualify the endpoint controls before use');
23
+ const effort = configured.reasoning
24
+ ? (!reasoning || reasoning === 'off' ? configured.thinkingLevelMap?.off : reasoning) : undefined;
25
+ if (payload.reasoning_effort !== effort)
26
+ throw new Error('Local reasoning effort does not match the selected request');
27
+ payload[field] = requested;
28
+ }
29
+
30
+ function excerpt(text, limit) {
31
+ if (text.length <= limit) return text;
32
+ // Do not split a UTF-16 surrogate pair. JSON escaping is counted afterward.
33
+ const prefix = text.slice(0, limit).replace(/[\uD800-\uDBFF]$/, '');
34
+ const shortened = `${prefix}\n[Factory context projection: shortened tool result (${Buffer.byteLength(text, 'utf8')} original UTF-8 bytes). Raw tool event is unchanged; source files are unchanged by projection. Do not infer omitted contents. Re-read the original path with read offset/limit (start with limit: 40), or run a narrower query; keep subsequent batches small.]`;
35
+ return Buffer.byteLength(shortened) < Buffer.byteLength(text) ? shortened : text;
36
+ }
37
+
38
+ // Uniform cap shares space among large results without penalizing small ones.
39
+ // Calls, arguments, identities, ordering and all non-tool text are immutable.
40
+ export function fitToolText(value, budget, measure = bytes, native = false) {
41
+ const original = structuredClone(value);
42
+ if (measure(original) <= budget) return original;
43
+ const messages = Array.isArray(original) ? original : original.messages;
44
+ const slots = [];
45
+ for (const message of messages) {
46
+ if (message.role !== (native ? 'toolResult' : 'tool')) continue;
47
+ if (typeof message.content === 'string') slots.push([message, 'content', message.content]);
48
+ else if (Array.isArray(message.content)) {
49
+ for (const part of message.content) if (part.type === 'text') slots.push([part, 'text', part.text]);
50
+ }
51
+ }
52
+ const cap = limit => { for (const [object, key, text] of slots) object[key] = excerpt(text, limit); };
53
+ cap(0);
54
+ if (measure(original) > budget) throw new Error('Local Pi context budget cannot fit required instructions, tool pairs and truncation guidance; use smaller task/input or qualify the configured allocation. No fallback.');
55
+ let low = 0, high = Math.max(0, ...slots.map(([, , text]) => text.length));
56
+ while (low < high) {
57
+ const mid = Math.ceil((low + high) / 2); cap(mid);
58
+ if (measure(original) <= budget) low = mid; else high = mid - 1;
59
+ }
60
+ cap(low);
61
+ return original;
62
+ }
63
+
64
+ export function validatePairs(messages) {
65
+ const pending = new Set(), seen = new Set();
66
+ for (const message of messages) {
67
+ if (message.role !== 'tool' && pending.size) throw new Error('Local Pi context has interrupted tool pairs');
68
+ for (const call of message.tool_calls || []) {
69
+ if (!call.id || seen.has(call.id)) throw new Error('Local Pi context has duplicate tool identity');
70
+ pending.add(call.id); seen.add(call.id);
71
+ }
72
+ if (message.role === 'tool' && !pending.delete(message.tool_call_id)) throw new Error('Local Pi context has orphan tool result');
73
+ }
74
+ if (pending.size) throw new Error('Local Pi context has missing tool results');
75
+ }
@@ -0,0 +1,63 @@
1
+ // Explicitly mounted Factory adapter, never discovered from the host/repository.
2
+ import { readFileSync, writeSync } from 'node:fs';
3
+ import { openAICompletionsApi } from '@earendil-works/pi-ai';
4
+ import { bytes, fitToolText, projectedTokens, restoreOutputBudget, validatePairs } from './pi-context-budget.mjs';
5
+
6
+ export default function factoryLocalContext(pi) {
7
+ const registry = JSON.parse(readFileSync(new URL('./models.json', import.meta.url), 'utf8'));
8
+ const configured = registry.providers['factory-local'].models[0];
9
+ const window = configured.contextWindow, output = configured.maxTokens;
10
+ let task;
11
+ // Pi catches extension exceptions and can continue. Budget violations must
12
+ // stop before transport, even during summarization, with a durable diagnostic.
13
+ const fatal = error => {
14
+ writeSync(2, `Factory local Pi context protection failed: ${error.message}\n`);
15
+ process.exit(1);
16
+ };
17
+ pi.on('session_before_compact', event => {
18
+ try {
19
+ // These are request-local copies. Upstream still selects cut points and
20
+ // generates the summary. Reserve space for its pinned summary templates;
21
+ // the provider wrapper checks the exact serialized request as well.
22
+ const preparation = event.preparation;
23
+ const budget = window - output - 8192 - bytes(preparation.previousSummary || '');
24
+ for (const key of ['messagesToSummarize', 'turnPrefixMessages'])
25
+ preparation[key] = fitToolText(preparation[key], budget, bytes, true);
26
+ } catch (error) { fatal(error); }
27
+ });
28
+ pi.registerProvider('factory-local', {
29
+ api: 'openai-completions',
30
+ streamSimple(model, context, options) {
31
+ if (model.id !== configured.id || model.provider !== 'factory-local') fatal(new Error('Selected local model changed'));
32
+ // Capture before streamSimple/buildBaseOptions clamps against unprojected
33
+ // history. Generation defaults to the binding; summaries supply their cap.
34
+ const requestedOutput = options?.maxTokens ?? output;
35
+ return openAICompletionsApi().streamSimple(model, context, {
36
+ ...options,
37
+ onPayload: async (payload, requestModel) => {
38
+ try {
39
+ payload = await options?.onPayload?.(payload, requestModel) ?? payload;
40
+ payload = structuredClone(payload);
41
+ if (payload.model !== configured.id
42
+ || !payload.messages?.some(m => m.role === 'system')) throw new Error('Unsupported local request structure or output budget');
43
+ restoreOutputBudget(payload, configured, requestedOutput, options?.reasoning);
44
+ if (payload.tools?.length) {
45
+ task ??= structuredClone(payload.messages.find(m => m.role === 'user'));
46
+ if (!task) throw new Error('Missing required task');
47
+ if (!payload.messages.some(m => m.role === 'user' && JSON.stringify(m) === JSON.stringify(task)))
48
+ payload.messages.splice(1, 0, structuredClone(task));
49
+ validatePairs(payload.messages);
50
+ }
51
+ if (!payload.messages.some(m => m.role === 'user')) throw new Error('Missing required user message');
52
+ // Restore BEFORE measuring so the final output field is counted.
53
+ // No request is sent unless projection fits with the full configured
54
+ // reserve, even when a summary requests a smaller bounded output.
55
+ return fitToolText(payload, window - output, projectedTokens);
56
+ } catch (error) { fatal(error); }
57
+ },
58
+ });
59
+ },
60
+ });
61
+ // Launcher requires this handshake; an extension load failure cannot bypass it.
62
+ writeSync(1, `${JSON.stringify({ type: 'factory_context_ready', version: 1 })}\n`);
63
+ }
@@ -1,48 +1,151 @@
1
- // Mounted read-only beside the single selected registry. Pi 0.73.1 JSON mode
2
- // may exit zero after a provider error; preserve the stream but fail the job.
3
- import { readFileSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
1
+ // Mounted read-only beside the single selected registry. Keep all JSON evidence;
2
+ // process exit alone does not establish a successfully completed Pi turn.
3
+ import { mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { spawn } from 'node:child_process';
7
- const directory = dirname(fileURLToPath(import.meta.url));
8
- const registry = JSON.parse(readFileSync(join(directory, 'models.json'), 'utf8'));
9
- const provider = registry.providers?.['factory-local'];
10
- const args = process.argv.slice(2);
11
- const selected = flag => args.filter(arg => arg === flag).length === 1 ? args[args.indexOf(flag) + 1] : null;
12
- const model = provider?.models?.[0];
13
- const thinking = selected('--thinking');
14
- const validThinking = model?.reasoning && model?.thinkingLevelMap?.off !== 'none'
15
- ? ['low', 'medium', 'high'].includes(thinking) : thinking === 'off';
16
- if (Object.keys(registry.providers || {}).length !== 1 || provider?.models?.length !== 1
17
- || selected('--provider') !== 'factory-local' || selected('--model') !== provider.models[0].id
18
- || selected('--mode') !== 'json' || !validThinking)
19
- throw new Error('Local Pi registry and explicit selection do not match; refusing fallback');
20
- // Never inherit credential inputs, provider overrides, NODE_OPTIONS or external
21
- // Pi directories. The runtime image supplies binaries; the repository supplies data.
22
- const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'TMPDIR', 'FACTORY_PHASE'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
23
- env.PI_CODING_AGENT_DIR = directory;
24
- const child = spawn('pi', args, { env, stdio: ['inherit', 'pipe', 'inherit'] });
25
- let pending = '', failed = false, completed = false;
26
- const event = line => {
27
- if (!line.trim()) return;
28
- try {
29
- const value = JSON.parse(line);
30
- if (value.type === 'message_end' && value.message?.role === 'assistant'
31
- && ['error', 'aborted'].includes(value.message.stopReason)) failed = true;
32
- if (value.type === 'agent_end') completed = true;
33
- } catch { failed = true; }
34
- };
35
- child.stdout.setEncoding('utf8');
36
- child.stdout.on('data', chunk => {
37
- process.stdout.write(chunk);
38
- pending += chunk;
39
- let end;
40
- while ((end = pending.indexOf('\n')) !== -1) { event(pending.slice(0, end)); pending = pending.slice(end + 1); }
41
- if (pending.length > 1024 * 1024) { failed = true; pending = ''; }
42
- });
43
- child.on('error', error => { console.error(`Local Pi launch failed: ${error.message}`); process.exitCode = 1; });
44
- child.on('close', code => {
45
- if (pending) event(pending);
46
- if (failed || !completed) console.error('Local Pi did not complete successfully; inspect the private log. No fallback was selected.');
47
- process.exitCode = code === 0 && !failed && completed ? 0 : (Number.isInteger(code) && code > 0 ? code : 1);
48
- });
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+
8
+ // A long run can emit a multi-megabyte agent_end aggregate even after compaction.
9
+ // Bound parsing separately from the much smaller retained diagnostic log.
10
+ const MAX_EVENT_CHARACTERS = 8 * 1024 * 1024;
11
+
12
+ // Pi 0.73.1 and 0.87.1 print JSONL. Errors can precede explicit recovery, but
13
+ // aborted/failed compaction, invalid framing and incomplete recovery stay fatal.
14
+ export class PiCompletion {
15
+ pending = '';
16
+ failed = false;
17
+ error = false;
18
+ retry = false;
19
+ compacting = false;
20
+ compactionReason = null;
21
+ recovery = false;
22
+ completed = false;
23
+ modern = false;
24
+ settled = false;
25
+ lastAssistant = null;
26
+ contextReady = false;
27
+
28
+ write(chunk) {
29
+ this.pending += chunk;
30
+ let end;
31
+ while ((end = this.pending.indexOf('\n')) !== -1) {
32
+ const line = this.pending.slice(0, end);
33
+ this.pending = this.pending.slice(end + 1);
34
+ if (line.length > MAX_EVENT_CHARACTERS) this.failed = true;
35
+ else if (line.trim()) {
36
+ try { this.event(JSON.parse(line)); } catch { this.failed = true; }
37
+ }
38
+ }
39
+ if (this.pending.length > MAX_EVENT_CHARACTERS) { this.failed = true; this.pending = ''; }
40
+ }
41
+
42
+ event(value) {
43
+ if (!value || typeof value.type !== 'string') { this.failed = true; return; }
44
+ if (value.type === 'factory_context_ready' && value.version === 1) this.contextReady = true;
45
+ if (value.type === 'agent_start' || value.type === 'message_start') {
46
+ this.completed = false; this.settled = false;
47
+ }
48
+ if (value.type === 'message_end' && (!value.message || typeof value.message.role !== 'string')) this.failed = true;
49
+ if (value.type === 'message_end' && value.message?.role === 'assistant') {
50
+ this.completed = false;
51
+ this.settled = false;
52
+ this.lastAssistant = value.message;
53
+ if (!['stop', 'toolUse', 'length', 'error', 'aborted'].includes(value.message.stopReason) || !Array.isArray(value.message.content)) this.failed = true;
54
+ if (value.message.stopReason === 'aborted') this.failed = true;
55
+ else if (['error', 'length'].includes(value.message.stopReason)) { this.error = true; this.recovery = false; }
56
+ else if (['stop', 'toolUse'].includes(value.message.stopReason)) {
57
+ if (this.error && (this.retry || this.recovery)) this.error = false;
58
+ this.recovery = false;
59
+ }
60
+ }
61
+ if (value.type === 'auto_retry_start') { this.retry = true; this.completed = false; this.settled = false; }
62
+ if (value.type === 'auto_retry_end') {
63
+ if (!this.retry || value.success !== true) this.failed = true;
64
+ this.retry = false;
65
+ }
66
+ // Both audited versions use compaction_*; retain the older public alias
67
+ // defensively so failed compaction never disappears as an ignored event.
68
+ if (['compaction_start', 'auto_compaction_start'].includes(value.type)) {
69
+ if (this.compacting || !['manual', 'threshold', 'overflow'].includes(value.reason)) this.failed = true;
70
+ this.compactionReason = value.reason;
71
+ this.compacting = true; this.settled = false;
72
+ }
73
+ if (['compaction_end', 'auto_compaction_end'].includes(value.type)) {
74
+ if (!this.compacting || value.reason !== this.compactionReason || typeof value.willRetry !== 'boolean'
75
+ || value.aborted !== false || value.errorMessage
76
+ || !value.result || typeof value.result.summary !== 'string' || !value.result.summary.trim()) this.failed = true;
77
+ this.compacting = false;
78
+ if (value.reason === 'overflow' && value.willRetry === true && !this.failed) this.recovery = true;
79
+ }
80
+ if (value.type === 'agent_end') {
81
+ this.settled = false;
82
+ if (Object.hasOwn(value, 'willRetry')) {
83
+ this.modern = true;
84
+ if (typeof value.willRetry !== 'boolean') this.failed = true;
85
+ }
86
+ const last = value.messages?.at(-1);
87
+ this.completed = value.willRetry !== true && last?.role === 'assistant' && last.stopReason === 'stop'
88
+ && this.lastAssistant?.stopReason === 'stop'
89
+ && JSON.stringify(last) === JSON.stringify(this.lastAssistant)
90
+ && Array.isArray(last.content) && last.content.some(part => part.type === 'text' && part.text?.trim())
91
+ && !last.content.some(part => part.type === 'toolCall');
92
+ }
93
+ if (value.type === 'agent_settled') this.settled = true;
94
+ }
95
+
96
+ finish() {
97
+ return !this.pending && !this.failed && !this.error && !this.retry && !this.compacting && !this.recovery
98
+ && this.completed && (!this.modern || this.settled);
99
+ }
100
+ }
101
+
102
+ function launch() {
103
+ const directory = dirname(fileURLToPath(import.meta.url));
104
+ const registry = JSON.parse(readFileSync(join(directory, 'models.json'), 'utf8'));
105
+ const provider = registry.providers?.['factory-local'];
106
+ const args = process.argv.slice(2);
107
+ const selected = flag => args.filter(arg => arg === flag).length === 1 ? args[args.indexOf(flag) + 1] : null;
108
+ const model = provider?.models?.[0];
109
+ const thinking = selected('--thinking');
110
+ const validThinking = model?.reasoning && model?.thinkingLevelMap?.off !== 'none'
111
+ ? ['low', 'medium', 'high'].includes(thinking) : thinking === 'off';
112
+ if (Object.keys(registry.providers || {}).length !== 1 || provider?.models?.length !== 1
113
+ || selected('--provider') !== 'factory-local' || selected('--model') !== provider.models[0].id
114
+ || selected('--mode') !== 'json' || !validThinking)
115
+ throw new Error('Local Pi registry and explicit selection do not match; refusing fallback');
116
+ // Never inherit credential inputs, provider overrides, NODE_OPTIONS or external
117
+ // Pi directories. The runtime image supplies binaries; the repository supplies data.
118
+ const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'TMPDIR', 'FACTORY_PHASE'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
119
+ for (const file of ['pi-context-extension.mjs', 'pi-context-budget.mjs']) readFileSync(join(directory, file));
120
+ const version = spawnSync('pi', ['--version'], { env, encoding: 'utf8', timeout: 10000 });
121
+ if (version.status !== 0 || version.stdout.trim() !== '0.87.1')
122
+ throw new Error('Local context protection requires pinned Pi 0.87.1; qualify and select the worker image while idle. No fallback.');
123
+ // Pi's home is mutable runtime state (including trust-store locks), not the
124
+ // operator-owned binding. Each launch gets a private directory in the existing
125
+ // sandbox tmpfs; no host trust/auth/settings are copied. Keep model input linked
126
+ // to its read-only mount, and load the adapter directly from that mount.
127
+ const runtimeDirectory = mkdtempSync('/tmp/factory-pi-');
128
+ const cleanup = () => rmSync(runtimeDirectory, { recursive: true, force: true });
129
+ process.on('exit', cleanup); // Container teardown also clears interrupted runs.
130
+ symlinkSync(join(directory, 'models.json'), join(runtimeDirectory, 'models.json'));
131
+ env.PI_CODING_AGENT_DIR = runtimeDirectory;
132
+ // Denying project trust also prevents project settings/packages and dependency
133
+ // installation, which --no-extensions alone does not prevent. Explicit CLI
134
+ // resources remain available in pinned Pi: only our adapter and mounted skills.
135
+ const child = spawn('pi', [...args, '--no-approve', '--no-extensions', '--no-skills', '--no-prompt-templates',
136
+ '--extension', join(directory, 'pi-context-extension.mjs'), '--api-key', 'factory-local-keyless'], { env, stdio: ['inherit', 'pipe', 'inherit'] });
137
+ const completion = new PiCompletion();
138
+ child.stdout.setEncoding('utf8');
139
+ child.stdout.on('data', chunk => {
140
+ process.stdout.write(chunk);
141
+ completion.write(chunk);
142
+ });
143
+ child.on('error', error => { console.error(`Local Pi launch failed: ${error.message}`); process.exitCode = 1; });
144
+ child.on('close', code => {
145
+ const completed = completion.contextReady && completion.finish();
146
+ if (!completed) console.error('Local Pi did not complete successfully; inspect the private log. No fallback was selected.');
147
+ process.exitCode = code === 0 && completed ? 0 : (Number.isInteger(code) && code > 0 ? code : 1);
148
+ });
149
+ }
150
+
151
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) launch();
package/factory/pins.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "jobImage": "software-defence-factory-job:0.3.0"
2
+ "jobImage": "software-defence-factory-job:0.4.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "software-defence-factory",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Factory: scoped software delivery and defence investigations with isolated jobs, evidence and review",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,8 @@
57
57
  "test": "node --test tests/*.test.mjs",
58
58
  "check": "node scripts/check.mjs && node --test tests/*.test.mjs && npm run test:dashboard",
59
59
  "build:dashboard": "npm --prefix dashboard ci --ignore-scripts && npm --prefix dashboard run build",
60
- "test:dashboard": "npm --prefix dashboard test"
60
+ "test:dashboard": "npm --prefix dashboard test",
61
+ "qualify:pi": "FACTORY_REQUIRE_PI=1 node --test tests/pi-local-adapter.test.mjs tests/pi-trust.test.mjs tests/pi-context.test.mjs tests/pi-context-batch.test.mjs"
61
62
  },
62
63
  "dependencies": {
63
64
  "yaml": "2.9.1"