diff --git a/.torch/install-manifest.json b/.torch/install-manifest.json index 7817677..59b1033 100644 --- a/.torch/install-manifest.json +++ b/.torch/install-manifest.json @@ -69,11 +69,60 @@ ".torch/backlog", ".torch/prompts" ], - "patched": [], + "patched": [ + { + "type": "git-info-exclude-line", + "path": "/home/user/Documents/GitHub/TORCH/.git/info/exclude", + "line": ".task", + "installationId": "73b013e7-41d2-493b-a3a7-022c086f059e" + } + ], "external": [ { "type": "local-state", "path": "/home/user/.local/share/torch/projects/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54" + }, + { + "type": "worktree", + "area": "session-manager", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/session-manager", + "branch": "torch/session-manager" + }, + { + "type": "worktree", + "area": "project-kernel", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/project-kernel", + "branch": "torch/project-kernel" + }, + { + "type": "worktree", + "area": "provider-runtime", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/provider-runtime", + "branch": "torch/provider-runtime" + }, + { + "type": "worktree", + "area": "work-integration", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/work-integration", + "branch": "torch/work-integration" + }, + { + "type": "worktree", + "area": "owner-console", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/owner-console", + "branch": "torch/owner-console" + }, + { + "type": "worktree", + "area": "release-self-host", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/release-self-host", + "branch": "torch/release-self-host" + }, + { + "type": "worktree", + "area": "qa", + "path": "/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/qa", + "branch": "torch/qa" } ] } diff --git a/.torch/torch.yaml b/.torch/torch.yaml index f080ffe..98cfba4 100644 --- a/.torch/torch.yaml +++ b/.torch/torch.yaml @@ -32,6 +32,7 @@ "runtimes": { "default": "codex", "codex": { + "updatePolicy": { "mode": "auto", "max_age_hours": 24 }, "model": "gpt-6-luna", "reasoning": "high", "launchPolicy": { @@ -237,8 +238,8 @@ ], "resources": [], "runtime": "codex", - "model": null, - "reasoning": null, + "model": "gpt-5.6-terra", + "reasoning": "high", "branch": null, "worktree_name": null }, @@ -279,8 +280,8 @@ ], "resources": [], "runtime": "codex", - "model": null, - "reasoning": null, + "model": "gpt-5.6-terra", + "reasoning": "high", "branch": null, "worktree_name": null }, @@ -322,8 +323,8 @@ ], "resources": [], "runtime": "codex", - "model": null, - "reasoning": null, + "model": "gpt-5.6-terra", + "reasoning": "high", "branch": null, "worktree_name": null }, @@ -479,7 +480,7 @@ "browser" ], "runtime": "codex", - "model": "gpt-6-luna", + "model": "gpt-5.6-terra", "reasoning": "high", "branch": null, "worktree_name": null diff --git a/README.md b/README.md index bb8657f..7c580df 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,37 @@ Requirements: - Node.js 22 or later - Git +### Provider version preflight + +Opt in during installation with `torch install ... --update-runtimes --yes`, +or on an existing project with `torch runtimes update-policy --mode auto --yes`. +Approved `torch up --yes` (including resume) refreshes the selected built-in +providers against the official npm `latest` release, at most once every 24 +hours. Change the interval with `--max-age-hours 1` through `168`; disable future +downloads with `--mode off`. Dry runs never query the registry or install code. +Existing projects stay opt-in; `--yes` alone does not authorize new downloads. + +Preview or force a manual update: + +```bash +torch runtimes update --runtime codex,claude,pi --dry-run --json +torch runtimes update --runtime codex,claude,pi --force --yes --json +``` + +Updates install exact resolved versions into XDG-local TORCH-owned prefixes, +verify `--version` and `--help`, then atomically select their absolute executable +paths. Global installations, login files and provider settings are untouched; +old versions remain available to already-running processes. Codex and Claude +packages can execute installation scripts; Pi uses its current official +`@earendil-works/pi-coding-agent` distribution with scripts disabled. Custom +adapters require their own reviewed updater. Concurrent updates stop at a lock; +failed downloads or verification retain the prior selection and block automatic +startup rather than silently claiming the latest version. Interrupted updates +leave the lock for inspection, not automatic deletion. Model/account access and +adapter flag compatibility remain separate live qualification gates. Persistent +schedule and hierarchy-start updater integration remains pending; use `torch up` +for this first trial. + Run the current verification suite: ```bash diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 0e20481..e8d325d 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1225,6 +1225,187 @@ ownership unless it is represented by an allowed durable operation. ## 14. Runtime adapter contract +### 14.0 Provider distribution freshness + +Owners MAY approve automatic built-in provider updates at installation with +`--update-runtimes`, or later through `torch runtimes update-policy --mode auto +--yes`. Without that policy, installation/startup MUST NOT silently acquire +new code. Approved normal `torch up --yes`, including resume, MUST refresh the +selected providers against their official npm latest stable releases when the +last successful check is older than the configured 1–168-hour window (24 hours +by default). Preview MUST be offline. Codex, Claude Code and Pi MUST be covered; +custom adapters require a separately reviewed distribution strategy. + +TORCH MUST use versioned machine-local prefixes and exact selected executable +paths, not assume the first PATH entry is current. Downloads MUST preserve +global installs, login state, running processes and user provider settings. +The updater MUST serialize writes, validate registry versions, verify the +installed version and parser startup, and atomically publish each provider's +activation record only after success. Failed or interrupted updates MUST NOT +silently fall back while claiming freshness; prior activations and failed +installation evidence remain available. This does not establish model access, +live adapter compatibility, update-script safety or off-machine recoverability. +Package lifecycle execution MUST be visible in the approval preview. Persistent +schedule/hierarchy launch paths and platform-specific installers require their +own qualification; initial automatic integration is the normal `up` path. + +### 14.0a Traceable production pipelines (planned) + +TORCH MUST support optional project-specific multi-stage production flows, +without hard-coding game-art stages or requiring a pipeline in simple projects. +Session Architect and managers SHOULD identify recurring handoff failures, +repeated stage sequences, approval waits and integrated deliverables as evidence +that a pipeline may help. They MUST propose, not silently activate, a definition. +Prefer a simple checklist where no durable multi-stage state is justified. + +A pipeline is a versioned contract for producing a deliverable; it is neither +a second backlog nor a mandatory new manager. Each definition MUST describe: + +- a concrete outcome and one accountable flow coordinator; +- a bounded stage DAG with explicit parallel stages and join prerequisites; +- existing specialist implementation owners for each stage; +- required input types, immutable versions/hashes, and output contracts; +- project invariants, applicable checks, test conditions and scarce resources; +- machine acceptance criteria separately from creative/owner approvals; +- retry safety, failure escalation, rework routes and stall thresholds; +- permitted model/tool profiles and separate spending/asset-generation approval; +- external dependencies and honest reproducibility limitations. + +Project constraints such as zero stock primitives, a locked model sheet or an +exact UV template MUST be explicit stage invariants. They MUST NOT become +generic defaults or be relaxed because a downstream tool finds them inconvenient. +Human taste approval MUST NOT be replaced by an automated geometry gate. + +Each deliverable MUST have one persistent pipeline-instance record (for example +one rig, character, voice pack, migration, research packet or release). The record +MUST pin the definition revision and identify its current accepted outputs, +stage attempts, responsible identities, existing backlog task IDs, dependencies, +receipts, approval revisions, provenance and unresolved waits. Stage work MUST +use the existing authoritative backlog. A pipeline card is a view of that work +and its deliverable lineage, not another independently assignable task system. + +A stage becomes eligible only when all predecessor outputs match their pinned +contracts and required checks/approvals are valid. Running, output-produced, +machine-verified, awaiting-approval, accepted, failed and invalidated MUST remain +distinguishable. Task completion or a file's existence MUST NOT imply acceptance. +Advancement MUST use revision checks so duplicate handoffs, parallel attempts +and concurrent managers cannot advance the same instance twice. + +Changed upstream content MUST invalidate dependent evidence and approvals, not +silently reuse them. A definition edit MUST NOT rewrite a running instance's +history. Explicit owner-reviewed migration or a new versioned instance is +required. Rework MUST retain the rejected outputs, reasons and attempt lineage; +rollback MUST preserve history and respect irreversible external effects. + +Provenance MUST include exact code commits where relevant, artifact content +digests, reference/UV-map/model-sheet versions, tool/model versions, non-secret +generation parameters and check conditions. Prompts and resource-sensitive +metadata MUST remain private with redaction and deliberate export. Seeds alone +MUST NOT be represented as guaranteeing reproducibility of hosted image/voice +models. Generated outputs are immutable recorded evidence; regeneration is a +new attempt, even with identical inputs. No generative provider or chargeable +service is invoked merely because a definition names it. + +Debugging MUST answer: what is waiting, why, on whom, since when, which input or +gate failed, what changed between attempts, and what exact evidence permitted +the last transition. Evidence SHOULD support before/after visual review where +useful. Checks MAY be reused through explicit input/output capability contracts; +TORCH MUST NOT claim a mesh/UV/transcription checker exists just because it is +named in a template. Each project supplies and qualifies domain-specific checks. + +Managers MUST surface stalled instances and handoff waits even when individual +tasks have recent commits. Waiting on owner, specialist, check resource, external +service and upstream rework MUST be separate reasons, not one generic stale +clock. Manager direct-report check-ins SHOULD consult these exception views. +They MUST NOT approve their own outputs, create approval pressure loops, or +invent tasks to keep a pipeline busy. + +The Console SHOULD show deliverable cards, stage timeline/DAG, accepted versions, +current owner, age/reason of waits, failed checks, approval actions and evidence. +Owners MUST be able to comment on an exact output version, approve within their +authority and request rework. The hierarchy coordinates outcomes; specialists +retain ownership and direct communication. Existing Art/Production leads MAY +coordinate a flow; a new lead requires the existing evidence/owner approval path. + +Initial implementation order (not implemented by this specification update): + +Console design requirements: + +- Show a Pipelines navigation entry only when approved definitions/instances + exist. Otherwise show a contextual proposal entry in setup, not an empty + production board or an automatically created generic pipeline. +- Group instances by pipeline revision. Each row shows deliverable name, + current stage(s), coordinator, stage owner, working/waiting duration, waiting + reason/party, latest evidence and attention status. A pipeline-stage strip + communicates actual accepted, active, waiting and invalidated stages. Parallel + active stages MUST NOT be flattened into a falsely linear progress percentage. +- Filter by pipeline, deliverable, coordinator, stage owner, wait reason and + attention state. Save views through the existing project-scoped view mechanism. + A linked backlog task can open its deliverable view; ordinary tasks remain on + the Work board. Show a reciprocal link instead of copying task state. +- Opening a deliverable shows its actual DAG/timeline, inputs and output + previews, acceptance metrics, approval subject/revision, rejection reason, + attempt history and upstream/downstream lineage. A "Why is this waiting?" + summary identifies the exact unmet prerequisite and permitted next action. +- Owner actions use the existing preview/confirmation and authority boundaries: + comment on an exact artifact, review an approval, request rework, pause or + resume a permitted instance, or review a proposed definition migration. + No drag gesture may bypass a stage gate; raw state editing is not an owner + convenience feature. A definition/role proposal is never a creation approval. +- Retain the existing dark TORCH visual identity, keyboard accessibility, + mobile layout, draft/focus preservation and protected refresh. Text/icons + must distinguish stage states without relying on color. On mobile show the + current stage and blocker first, then expand the full flow. Failed snapshot + loads preserve the last evidence with an explicit stale timestamp. +- Aggregate per-stage active versus waiting time, rework count, categorized + check/approval failures, measured usage/cost and evidence coverage. Unknown + cost remains unknown; wall-clock time is not active processing time. Compare + metrics only for compatible definition versions and explain denominators. +- After a configurable sample (five completed instances is a starting point, + not a universal rule), a manager MAY propose one bottleneck improvement with + baseline, expected metric change and review window. Changes require the same + definition-review boundary; existing instances stay pinned. Avoid claiming + causal improvements from small or incompatible samples. +- The shared Console demo MUST use the same renderer with clearly labelled + fixture evidence. It MUST NOT call live provider APIs or represent fixture + approvals and metrics as real project measurements. + +Example only (project-defined stages, not a built-in rig template): + +```text +Pipelines / Rig production +Deliverable Stage Waiting on Since Owner +Bastion Model sheet review Art approval 2 days Concept Art +Wayfarer Texture check UV coverage 18 min Textures + +Bastion / definition v2 / attempt 3 +Spec -> Concept -> Model sheet -> Mesh -> Texture -> Integration + awaiting approval +Inputs: approved concept digest / locked proportions digest +Output: model-sheet digest + preview +Required: proportion check passed; Art Director approval pending +[Review evidence] [Comment] [Request rework] +History: attempt 2 rejected, silhouette too wide; retained for comparison +``` + +This view is planned, not a currently available Console feature. + +1. Definition/instance schemas, validation, read-only pipeline assessment and + owner-reviewed activation; reject cycles, unknown owners/checks and overlaps. +2. Backlog-linked durable transitions, pinned artifacts, checks/approvals and + output-specific feedback, with rework and idempotent crash recovery. +3. Why-waiting and stall diagnostics plus manager exception reporting. +4. Console deliverable cards and attempt/lineage comparison. +5. TORCH self-host pilot on a docs/software pipeline; COMBATRIG art integration + remains a separate project-authorized adoption. + +Required acceptance scenarios MUST prove that a stale UV/reference input blocks +downstream advancement; changed output invalidates approval; duplicate handoffs +advance once; a parallel join waits for every accepted branch; rejection preserves +history; restart resumes the same stage attempt; resource waits are not mistaken +for productive work; and an owner decision cannot be replaced by an AI manager. +No pipeline runtime or COMBATRIG mutation is authorized by documenting this plan. + Each adapter SHOULD implement capabilities equivalent to: ```text diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index c88024f..f10b4f2 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2195,3 +2195,45 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` +# Provider update preflight (2026-09-30) + +Self-host packaging follow-up: extended the existing SCN-candidate-isolation +fixture with a tracked `.torch/torch.yaml`; assert it is absent from the engine +candidate while committed engine files and dependency symlinks remain present. +This adds a stricter no-project-state-shipping invariant without changing any +existing assertion. Update policy is validated under `runtimes..updatePolicy`, +which older readers already treat as extensible runtime data; no incompatible +top-level project key or permanent global provider setting is introduced. + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-provider-updates + given: approved selected built-in providers and deterministic registry + when: latest versions install and pass parser/version smoke checks + then: registry launches exact managed binaries and fresh startup is offline + - id: SCN-provider-update-cli + given: installed project with owner-enabled update policy + when: dry run, actual startup and failed stale refresh execute + then: preview is offline, actual launch uses updated binary, failure cannot launch + - id: SCN-provider-updates-recovery + given: prior verified activation + when: download fails, another updater owns lock or executable changes + then: prior selection survives failure and unsafe or concurrent use is refused + - id: SCN-provider-update-safety + given: fixed official package allowlist and owned prefixes + when: arbitrary name, injected version, escaped prefix or symlink store appears + then: updater refuses without arbitrary install execution + observable_outcomes: + - persisted selected versions and exact launch commands + - refusal codes and absence of downloads during preview or fresh-cache use + determinism_controls: + - isolated XDG roots, local Git fixtures and boundary installer runner + - no provider accounts or live registry in regression tests + anti_cheat_rationale: + prevents: [unverified activation, global overwrite, silent stale fallback, arbitrary command injection] + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/docs/TODO.md b/docs/TODO.md index a1b20a4..7ec4258 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,60 @@ # TORCH remaining work +## First real self-host run (2026-09-30) + +### Portable production pipelines (new roadmap element) + +- [ ] Detect justified recurring multi-stage flows from repo/spec and handoff + evidence; propose owner-reviewed pipeline definitions without auto-creation. +- [ ] Define versioned stage DAGs with one flow coordinator, specialist owners, + pinned inputs/outputs, project invariants, checks, resources and approval roles. +- [ ] Track deliverable instances through the existing backlog/artifact/check/ + approval systems; do not create another independently scheduled task queue. +- [ ] Implement revision-checked transitions, provenance, attempt lineage, + invalidation, parallel joins, rework and idempotent interrupted-stage recovery. +- [ ] Add why-waiting/stall diagnostics and manager exception check-ins for + handoffs and deliverables, not just stale task commits. +- [ ] Add Console deliverable cards, stage timeline, exact-output feedback, + approval/rework actions and before/after evidence. +- [ ] Add conditional Pipelines navigation, per-definition deliverable rows, + reciprocal Work-board links, why-waiting drilldown, parallel stage view, + accessible mobile/current-blocker presentation and protected refresh. +- [ ] Add evidence-backed stage metrics and configurable sample-based reviews; + keep unknown costs explicit and preserve definition-version comparisons. + Use the same renderer for private live state and clearly labelled demo fixtures. +- [ ] Pilot a TORCH software/docs pipeline, then separately qualify project- + supplied mesh, UV-paintback, transcription or other domain checks as needed. + Canonical contract/acceptance plan: spec section 14.0a. No runtime implemented yet. + +- [x] Install accepted `0.1.0-alpha.0` into the normal version store, activate + stable launcher, install six-domain configuration and create seven worktrees. +- [x] Verify ChatGPT sign-in, Luna specialist startup and Sol 6.1 manager startup + using the current installed Codex 0.159.2 binary. Durable specialist message, + manager acknowledgement and runtime IDs observed. No development dispatch, + pushes, deployments or host timers enabled. +- [x] Add owner-approved install/startup provider version preflight for Codex, + Claude Code and Pi: exact managed binaries, 24-hour freshness, serialized + updates, retained previous installs, offline previews and fail-closed errors. + Four deterministic scenarios added; live download qualification recorded in + the first-run audit, not implied by mocked installer tests. +- [ ] Fix `torch brief` invoked from a managed worktree: both live sessions hit + `LOCAL_STATE_METADATA_MISMATCH`. Resolve canonical project binding with + checked worktree membership, never weaken root/installation identity checks. +- [ ] Surface Codex JSON stdout failure events: first Sol launch hid the actual + model rejection behind an unrelated inherited Vercel MCP authentication error. +- [ ] Add exact executable/version/model/auth preflight to owner Console and + setup flow. PATH selected CLI 0.158.0 while the current session used 0.159.2; + the older child rejected Sol 6.1 and required manual investigation/retry. +- [ ] Make configuration checkpoint/worktree/manifest handoffs explicit or + guided: generated tracked files and provisioning manifest updates require + deliberate commits; a healthy doctor does not prove startup readiness. +- [ ] Isolate irrelevant inherited global MCP servers without removing user + settings. Vercel auth noise appeared in both failed and successful Codex runs. +- [ ] Integrate approved update policy into hierarchy activation and persistent + schedule wakes; qualify interrupted-update recovery and Windows npm execution. +- [ ] Qualify current Pi flag/extension/login compatibility after its official + package migration; a verified binary `--help` is not a full adapter proof. + ## Review corrections (2026-09-29) ## Fresh readiness audit (2026-09-30) diff --git a/package-lock.json b/package-lock.json index 2547d15..b4afe31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "bundleDependencies": [ "@modelcontextprotocol/server", "typebox", diff --git a/package.json b/package.json index f59ded6..f2b1ad6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "torch-agent-fleet", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "type": "module", "description": "TORCH — a portable, provider-independent AI development fleet for Git repositories", "main": "src/cli.mjs", diff --git a/reports/audit/first-self-host-run-2026-09-30.md b/reports/audit/first-self-host-run-2026-09-30.md new file mode 100644 index 0000000..b0bbe7b --- /dev/null +++ b/reports/audit/first-self-host-run-2026-09-30.md @@ -0,0 +1,114 @@ +# TORCH first real installation and startup + +## Outcome + +The tool is installed into TORCH itself. The accepted alpha.0 engine and stable +launcher were activated, six specialist domains plus Session Manager received +isolated worktrees, and ChatGPT-authenticated Codex turns ran for Owner Console +(Luna/high) and Session Manager (Sol 6.1/high). The specialist sent a durable +message; the manager acknowledged it and sent a reply. Both durable runtime IDs +were captured. Empty backlog and idle are preserved; no development dispatched. + +This is an installation/communication smoke test, not full release qualification. +Both sessions reported a worktree CLI binding problem rather than bypassing it. +No provider API key was read or installed. No pushes, deployment, COMBATRIG +changes, persistent host timers or automatic claims were enabled. + +## Configuration + +- Project ID: `3dc11932-6e3c-42aa-9a8a-ca7b8f818b54`. +- Launcher: `/home/user/.local/bin/torch`. +- Project state: `.torch/` plus XDG-local project history. +- Worktrees: `/home/user/TORCHWorktrees/3dc11932-6e3c-42aa-9a8a-ca7b8f818b54/`. +- Session Manager: `gpt-6.1-sol`, high. +- Kernel, Runtime, Work/Integration and QA: `gpt-5.6-terra`, high, not launched. + Interpreted "Tera" as the account's exact Terra model; owner clarification + can change those project profiles without code/global-settings changes. +- Owner Console and Release/Self-host: `gpt-6-luna`, high. +- Proposed domain leads remain inactive; this was not a two-domain-manager trial. +- Dashboard: `http://127.0.0.1:4174/console`, localhost only. HTML and actual + `/api/snapshot` returned HTTP 200 with installed project identity. + +The protected fixture is intact. A reversible anchored `.git/info/exclude` +entry keeps it outside ownership and clean-checkout scheduling. No tracked +global ignore or user-file deletion was used. Initial `.torch` checkpoint: +`2a8ec35dac1c4b603297176fd1a8dcce36300d2b`. + +## Real friction and its cost + +1. PATH selected Codex 0.158.0 while this parent session used bundled 0.159.2. + The older child rejected `gpt-6.1-sol` with an account-support error. One + failed launch and a diagnostic replay preceded the correct executable retry. + This did not establish a subscription-wide lack of Sol access. Current CLI + 0.159.2 subsequently ran the requested model successfully. +2. Startup errors omit Codex JSON stdout events. The displayed error was a + global Vercel MCP authentication failure, hiding the actual model rejection. + Manual capture of provider stdout was required. Unrelated MCP noise also + appeared in successful startup; it is not TORCH's communication server. +3. `torch brief` from both managed worktrees returned + `LOCAL_STATE_METADATA_MISMATCH`. Bound MCP queries/identity/ownership/inbox/ + backlog worked, and the agents correctly reported the failure and stopped. + Fix must prove canonical-root/worktree membership rather than relaxing checks. +4. Generated `.torch` state requires a local commit before worktree creation; + provisioning then changes the tracked installation manifest. This requires + another checkpoint. Current docs don't guide that complete handoff clearly. +5. `doctor` is healthy despite an unavailable requested model or worktree-brief + failure. Installation integrity is not live startup/model qualification. +6. Manager should not receive specialist-only next-task instructions. In this + smoke policy the manager query was correctly refused; separate role-specific + startup steps would avoid an unnecessary error. + +The plumbing works, but setup still requires engineering knowledge. It is not +yet an effortless end-user onboarding experience. The above incidents are in +`docs/TODO.md`, with distinct evidence and no claims of fixes not yet delivered. + +## Provider-update improvement implemented during the first run + +Owner explicitly requested current provider harnesses at install/startup. +New update preflight stages exact versions into TORCH-owned prefixes, verifies +version and parser startup, serializes updates, retains prior installs, and +selects absolute executable paths. Login/global settings are unchanged. +Automatic policy is owner-approved and per configured runtime, 24-hour freshness; +offline previews and failed refreshes do not launch stale providers silently. + +Actual official-registry install/parser receipts: + +- Codex `0.159.2`. +- Claude Code `2.1.285`. +- Pi `0.99.1` via current `@earendil-works/pi-coding-agent` package. + +Receipt: `/tmp/torch-first-run-provider-update-result.json`. All three verified +`--version` and `--help`. Only Codex has live session qualification in this run. +Current Pi extension/flags and Claude lifecycle remain separately unqualified; +a newer binary doesn't establish adapter compatibility or model access. + +Four new deterministic scenarios exercise official allowlist, exact executable, +fresh-cache/no-network behavior, fail-closed refresh, locking, tamper detection, +path safety and real CLI policy/startup boundaries. Existing self-host packaging +scenario now also excludes tracked project `.torch` state from engine candidates. +All 39 source acceptance groups PASS before checkpoint; installed candidate +qualification is recorded separately after build/activation. + +## Pipeline addition: planned, not implemented + +Spec section 14.0a now defines versioned optional project production flows, +deliverable instances linked to the existing backlog, pinned artifact/check/ +approval lineage, safe rework, explicit waits, and a conditional Console view. +Frontend-design guidance shaped the row-to-detail progression, mobile blocker- +first presentation and evidence hierarchy without changing existing Console CSS. +There is no live pipeline runtime/view yet and no COMBATRIG pipeline alteration. + +## Evidence receipts + +- `/tmp/torch-real-candidate-build-20260930.json` +- `/tmp/torch-real-first-run-install.json` +- `/tmp/torch-first-run-model-list.json` +- `/tmp/torch-first-run-worktrees.json` +- `/tmp/torch-first-run-manager-start.json` (old CLI failure) +- `/tmp/torch-first-run-manager-diagnostic.json` (actual model error) +- `/tmp/torch-first-run-manager-current-cli-start.json` (Sol success) +- `/tmp/torch-first-run-luna-start.json` (Luna success) +- `/tmp/torch-first-run-alpha1-final-acceptance.json` + +No cache savings, token cost or active-time metric is claimed: no attributable +runtime usage samples have been submitted. Pipeline metrics remain a design. diff --git a/src/adapters/registry.mjs b/src/adapters/registry.mjs index aead37e..296e067 100644 --- a/src/adapters/registry.mjs +++ b/src/adapters/registry.mjs @@ -4,13 +4,14 @@ import { createCodexAdapter } from './codex.mjs'; import { createPiAdapter } from './pi.mjs'; import { validateRuntimeAdapter } from './runtime.mjs'; import { inspectRuntimePluginTrust, loadTrustedRuntimeAdapters } from './plugins.mjs'; +import { managedProvider } from './updates.mjs'; export class RuntimeAdapterRegistry { constructor({ env = process.env, adapters = [], loadPlugins = false, pluginNames } = {}) { this.adapters = new Map([ - ['claude', createClaudeAdapter({ env })], - ['codex', createCodexAdapter({ env })], - ['pi', createPiAdapter({ env })], + ['claude', createClaudeAdapter({ env, executable: managedProvider('claude', { env })?.executable ?? 'claude' })], + ['codex', createCodexAdapter({ env, executable: managedProvider('codex', { env })?.executable ?? 'codex' })], + ['pi', createPiAdapter({ env, executable: managedProvider('pi', { env })?.executable ?? 'pi' })], ]); this.pluginErrors = []; this.trustedPlugins = []; diff --git a/src/adapters/updates.mjs b/src/adapters/updates.mjs new file mode 100644 index 0000000..86c2276 --- /dev/null +++ b/src/adapters/updates.mjs @@ -0,0 +1,147 @@ +import { spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { accessSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { torchDataHome } from '../kernel/paths.mjs'; +import { TorchError } from '../kernel/errors.mjs'; + +// Fixed official distribution names. Project input cannot supply npm packages, +// registries, scripts, prefixes or alternate update commands. +export const PROVIDER_PACKAGES = Object.freeze({ + codex: '@openai/codex', claude: '@anthropic-ai/claude-code', + pi: '@earendil-works/pi-coding-agent', +}); +const REGISTRY = 'https://registry.npmjs.org'; +const VERSION = /^\d+\.\d+\.\d+$/; + +function fail(message, details) { + return new TorchError(message, { code: 'RUNTIME_UPDATE_FAILED', details }); +} + +function safePath(path) { + let current = resolve(path); + for (;;) { + if (existsSync(current) && lstatSync(current).isSymbolicLink()) { + throw fail('Provider store must not traverse symlink directories', { path: current }); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } +} + +export function providerStore(env = process.env) { + return join(torchDataHome(env), 'provider-runtimes'); +} + +function pointer(root, name) { return join(root, `${name}.json`); } +function hash(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); } + +export function managedProvider(name, { env = process.env } = {}) { + if (!Object.hasOwn(PROVIDER_PACKAGES, name)) return null; + const root = providerStore(env); + safePath(root); + const path = pointer(root, name); + if (!existsSync(path)) return null; + safePath(path); + let record; + try { record = JSON.parse(readFileSync(path, 'utf8')); } catch { + throw fail('Provider activation record is unreadable', { name }); + } + const prefix = resolve(record.prefix ?? ''); + if (record.name !== name || record.package !== PROVIDER_PACKAGES[name] + || !VERSION.test(record.version ?? '') || dirname(prefix) !== resolve(root) + || !prefix.startsWith(join(root, `${name}-${record.version}-`))) { + throw fail('Provider activation record is invalid', { name }); + } + safePath(prefix); + const executable = join(prefix, 'node_modules', '.bin', name); + try { + const target = realpathSync(executable); + if (relative(prefix, target).startsWith(`..${sep}`) || !target.startsWith(`${prefix}${sep}`)) throw Error('outside prefix'); + accessSync(executable, constants.X_OK); + if (hash(target) !== record.sha256) throw Error('changed executable'); + } catch { + throw fail('Managed provider executable is missing or changed', { name, executable }); + } + return { ...record, executable }; +} + +export function planProviderUpdates(names, { env = process.env } = {}) { + return { + action: 'provider-update', scope: 'torch-managed-only', registry: REGISTRY, + providers: [...new Set(names)].map((name) => { + if (!Object.hasOwn(PROVIDER_PACKAGES, name)) { + throw fail('Automatic updates support only built-in providers', { name }); + } + return { name, package: PROVIDER_PACKAGES[name], active: managedProvider(name, { env }) }; + }), + root: providerStore(env), networkRequired: true, executesPackageInstallScripts: true, + modifiesGlobalInstallations: false, mutationPerformed: false, + }; +} + +export function updateProviders(names, { env = process.env, runner = spawnSync, + authorized = false, force = false, maxAgeHours = 24, now = () => new Date() } = {}) { + if (!authorized) throw new TorchError('Provider downloads require owner approval', { code: 'APPROVAL_REQUIRED' }); + if (!Number.isInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 168) { + throw fail('Provider freshness must be 1–168 hours'); + } + const plan = planProviderUpdates(names, { env }); + safePath(plan.root); + mkdirSync(plan.root, { recursive: true, mode: 0o700 }); + const lock = join(plan.root, 'update.lock'); + try { mkdirSync(lock); } catch { + throw new TorchError('Provider update already running; inspect update.lock before recovery', { code: 'RUNTIME_UPDATE_BUSY' }); + } + const run = (command, args, cwd, timeout) => { + const result = runner(command, args, { cwd, env, encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], timeout, maxBuffer: 8 * 1024 * 1024 }); + if (result.status !== 0 || result.error || result.signal) { + throw fail('Provider update command failed; previous activation retained', { + command, args, status: result.status, signal: result.signal, + message: result.error?.message, stderr: String(result.stderr ?? '').slice(-2000), + }); + } + return String(result.stdout ?? '').trim(); + }; + const results = []; + try { + for (const provider of plan.providers) { + const { name, package: pkg, active } = provider; + const age = active ? now().getTime() - Date.parse(active.checkedAt) : NaN; + if (!force && Number.isFinite(age) && age >= 0 && age < maxAgeHours * 3600000) { + results.push({ ...active, outcome: 'fresh' }); continue; + } + const version = JSON.parse(run('npm', ['view', `${pkg}@latest`, 'version', '--json', '--registry', REGISTRY], plan.root, 30000)); + if (typeof version !== 'string' || !VERSION.test(version)) throw fail('Registry returned an invalid stable version', { name }); + let record = active; + if (!active || active.version !== version) { + const prefix = mkdtempSync(join(plan.root, `${name}-${version}-`)); + const args = ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--save-exact', '--registry', REGISTRY]; + if (name === 'pi') args.push('--ignore-scripts'); + args.push(`${pkg}@${version}`); + run('npm', args, prefix, 180000); + const executable = join(prefix, 'node_modules', '.bin', name); + const target = realpathSync(executable); + if (!target.startsWith(`${prefix}${sep}`)) throw fail('Provider binary escaped its owned prefix', { name }); + const actual = run(executable, ['--version'], prefix, 20000); + if (!actual.match(/\b\d+\.\d+\.\d+\b/) || actual.match(/\b\d+\.\d+\.\d+\b/)[0] !== version) { + throw fail('Installed provider version does not match the resolved release', { name, expected: version, actual }); + } + // Smoke-check parser startup before activation. Real model/account and + // adapter-specific compatibility still require their own live gates. + run(executable, ['--help'], prefix, 20000); + record = { name, package: pkg, version, prefix, sha256: hash(target), previous: active?.prefix ?? null }; + } + record = { ...record, checkedAt: now().toISOString() }; + const temporary = join(plan.root, `${name}-${randomUUID()}.tmp`); + writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(temporary, pointer(plan.root, name)); + results.push({ ...managedProvider(name, { env }), outcome: active?.version === version ? 'current' : 'updated' }); + } + return { ...plan, results, mutationPerformed: true }; + } finally { + rmdirSync(lock); + } +} diff --git a/src/cli.mjs b/src/cli.mjs index 6a009c5..361ea7d 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -3,6 +3,7 @@ import { BacklogService } from './backlog/service.mjs'; import { ArtifactService } from './artifacts/service.mjs'; import { createClaudeAdapter } from './adapters/claude.mjs'; import { createRuntimeAdapterRegistry } from './adapters/registry.mjs'; +import { planProviderUpdates, updateProviders } from './adapters/updates.mjs'; import { profileCapabilityIssues, resolveLaunchPolicy } from './adapters/runtime.mjs'; import { inspectRuntimePluginTrust, planRuntimePluginRevoke, planRuntimePluginTrust, @@ -69,7 +70,7 @@ Usage: torch architect validate --brief --response [--json] torch architect run --brief --provider --output [--model ] [--max-budget-usd ] --yes [--json] torch domains [--repo ] [--spec ] [--output ] [--json] - torch install --proposal [--runtime ] [--default-runtime ] [--dry-run] [--yes] [--json] + torch install --proposal [--runtime ] [--default-runtime ] [--update-runtimes] [--dry-run] [--yes] [--json] torch worktrees [--parent ] [--dry-run] [--yes] [--json] torch up [--fresh] [--only ] [--dry-run] [--yes] [--json] torch down [--dry-run] [--yes] [--json] @@ -91,6 +92,8 @@ Usage: torch profile set --area [--runtime ] [--model |--inherit-model] [--reasoning ] [--launch-policy ...] [--reset|--reset-launch-policy] [--dry-run|--yes] [--json] torch profile defaults --runtime [--model ] [--reasoning ] [--launch-policy ...] [--reset|--reset-launch-policy] --yes [--json] torch runtimes list [--json] + torch runtimes update --runtime [--force] [--dry-run|--yes] [--json] + torch runtimes update-policy --mode [--max-age-hours <1-168>] --yes [--json] torch runtimes trust --name --module [--yes --sha256 ] [--json] torch runtimes revoke --name [--yes --sha256 ] [--json] torch complete --area --summary [--task ] [--evidence ] [--commit ] [--json] @@ -415,6 +418,7 @@ export const CANDIDATE_ACCEPTANCE_EVIDENCE = Object.freeze({ 'branches-worktrees': ['SCN-worktree-bootstrap'], 'runtime-identities': ['SCN-mixed-runtime'], 'runtime-profiles': ['SCN-runtime-profiles', 'SCN-runtime-cost-ceiling', 'SCN-runtime-profile-cli'], + 'provider-updates': ['SCN-provider-updates', 'SCN-provider-updates-recovery', 'SCN-provider-update-safety', 'SCN-provider-update-cli'], 'console-runtime-profile': ['SCN-console-runtime-profile'], 'console-schedule-launcher': ['SCN-console-schedule-launcher'], 'runtime-adapters': [ @@ -552,6 +556,38 @@ export async function runCli(argv = process.argv.slice(2), { } if (command === 'runtimes') { const operation = argv[1] ?? 'list'; + if (operation === 'update') { + const names = commaList(optionValue(argv, '--runtime')); + if (!names.length) throw new TorchError('Select --runtime codex,claude,pi', { code: 'RUNTIME_UPDATE_INVALID' }); + const plan = planProviderUpdates(names, { env }); + if (argv.includes('--dry-run') || !argv.includes('--yes')) { + print({ ...plan, requiresApproval: true }, { json }); return 0; + } + print(updateProviders(names, { env, runner: spawn, authorized: true, force: argv.includes('--force') }), { json }); + return 0; + } + if (operation === 'update-policy') { + const repository = inspectRepository(optionValue(argv, '--repo') ?? cwd); + const config = loadProjectConfig(repository.root); + const mode = optionValue(argv, '--mode'); + const maxAge = Number(optionValue(argv, '--max-age-hours') ?? 24); + const policy = { mode, max_age_hours: maxAge }; + const names = optionValue(argv, '--runtime') === undefined + ? Object.keys(config.runtimes).filter(name => name !== 'default') + : commaList(optionValue(argv, '--runtime')); + planProviderUpdates(names, { env }); + for (const name of names) { + if (!Object.hasOwn(config.runtimes, name)) throw new TorchError('Runtime is not configured', { code: 'RUNTIME_UPDATE_INVALID', details: { name } }); + config.runtimes[name].updatePolicy = policy; + } + validateProjectConfig(config); + if (argv.includes('--dry-run') || !argv.includes('--yes')) { + print({ policy, runtimes: names, requiresApproval: true, mutationPerformed: false }, { json }); return 0; + } + print({ path: persistProfileConfig(repository.root, config), policy, runtimes: names, + sessionsStarted: false, mutationPerformed: true }, { json }); + return 0; + } if (operation === 'list') { const builtIns = createRuntimeAdapterRegistry({ env, loadPlugins: false }).names(); let trust; @@ -967,12 +1003,15 @@ export async function runCli(argv = process.argv.slice(2), { const runtimes = runtimeOption === undefined ? undefined : commaList(runtimeOption); if (argv.includes('--dry-run')) { const proposal = loadProposal(cwd, proposalPath); - print({ ...planInstall({ + const plan = planInstall({ repository, proposal, env, runtimes, defaultRuntime, runtimeRegistry: createRuntimeAdapterRegistry({ env, loadPlugins: true, pluginNames: [...(runtimes ?? proposalRuntimeNames(proposal)), ...[defaultRuntime].filter(Boolean)], }), - }), proposal: resolve(cwd, proposalPath) }, { json }); + }); + print({ ...plan, proposal: resolve(cwd, proposalPath), + ...(argv.includes('--update-runtimes') ? { providerUpdates: planProviderUpdates(plan.runtimes, { env }) } : {}), + }, { json }); return 0; } if (!argv.includes('--yes')) { @@ -981,12 +1020,25 @@ export async function runCli(argv = process.argv.slice(2), { }); } const proposal = loadProposal(cwd, proposalPath); - print(installProject({ + if (argv.includes('--update-runtimes')) { + const installPlan = planInstall({ repository, proposal, env, runtimes, defaultRuntime }); + if (!installPlan.canProceed) throw new TorchError('Install plan has blockers', { code: 'INSTALL_BLOCKED', details: installPlan.blockers }); + updateProviders(installPlan.runtimes, { env, runner: spawn, authorized: true }); + } + const installed = installProject({ repository, proposal, env, runtimes, defaultRuntime, runtimeRegistry: createRuntimeAdapterRegistry({ env, loadPlugins: true, pluginNames: [...(runtimes ?? proposalRuntimeNames(proposal)), ...[defaultRuntime].filter(Boolean)], }), - }), { json }); + }); + if (argv.includes('--update-runtimes')) { + const config = loadProjectConfig(repository.root); + for (const name of Object.keys(config.runtimes).filter(name => name !== 'default')) { + config.runtimes[name].updatePolicy = { mode: 'auto', max_age_hours: 24 }; + } + persistProfileConfig(repository.root, config); + } + print(installed, { json }); return 0; } if (command === 'doctor') { @@ -1010,6 +1062,16 @@ export async function runCli(argv = process.argv.slice(2), { return 0; } if (command === 'up') { + const updateConfig = loadProjectConfig(repository.root); + if (argv.includes('--yes') && !argv.includes('--dry-run')) { + const selected = optionValue(argv, '--only') === undefined ? undefined : commaList(optionValue(argv, '--only')); + for (const name of configuredRuntimeNames(repository.root, selected)) { + const policy = updateConfig.runtimes[name]?.updatePolicy; + if (policy?.mode === 'auto') updateProviders([name], { + env, runner: spawn, authorized: true, maxAgeHours: policy.max_age_hours, + }); + } + } const control = openControlPlane({ repositoryRoot: repository.root, env }); try { const only = optionValue(argv, '--only') === undefined ? undefined : commaList(optionValue(argv, '--only')); diff --git a/src/kernel/config.mjs b/src/kernel/config.mjs index 236a1a4..c78f20a 100644 --- a/src/kernel/config.mjs +++ b/src/kernel/config.mjs @@ -320,6 +320,13 @@ export const projectConfigSchema = z.object({ } } for (const [runtime, profile] of Object.entries(config.runtimes)) { + if (runtime !== 'default' && profile?.updatePolicy !== undefined) { + const policy = z.object({ mode: z.enum(['auto', 'off']), + max_age_hours: z.number().int().min(1).max(168) }).strict().safeParse(profile.updatePolicy); + if (!policy.success) for (const issue of policy.error.issues) { + context.addIssue({ ...issue, path: ['runtimes', runtime, 'updatePolicy', ...issue.path] }); + } + } if (runtime === 'default' || !profile || typeof profile !== 'object' || Array.isArray(profile)) continue; if (profile.launchPolicy !== undefined) { const result = launchPolicy.safeParse(profile.launchPolicy); diff --git a/src/self-host/service.mjs b/src/self-host/service.mjs index 6326374..c37dba8 100644 --- a/src/self-host/service.mjs +++ b/src/self-host/service.mjs @@ -23,6 +23,7 @@ export const CANDIDATE_ACCEPTANCE_SCENARIOS = Object.freeze([ 'branches-worktrees', 'runtime-identities', 'runtime-profiles', + 'provider-updates', 'console-runtime-profile', 'console-schedule-launcher', 'runtime-adapters', @@ -150,10 +151,10 @@ function gitSourceDescriptor(root) { const excludedUntracked = execFileSync('git', ['-C', root, 'ls-files', '--others', '--exclude-standard', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).split('\0').filter(Boolean).sort(); - const include = (name, entry) => name === 'node_modules' + const include = (name, entry) => name !== '.torch' && !name.startsWith('.torch/') && (name === 'node_modules' || name.startsWith('node_modules/') || tracked.has(name) - || (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`))); + || (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`)))); return { mode: 'git-commit', commit, include, excludedUntracked }; } diff --git a/test/fleet/provider-updates.test.mjs b/test/fleet/provider-updates.test.mjs new file mode 100644 index 0000000..b79b8ba --- /dev/null +++ b/test/fleet/provider-updates.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { managedProvider, planProviderUpdates, providerStore, updateProviders } from '../../src/adapters/updates.mjs'; +import { createRuntimeAdapterRegistry } from '../../src/adapters/registry.mjs'; +import { execFileSync } from 'node:child_process'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { createWorktrees } from '../../src/kernel/worktrees.mjs'; +import { runCli } from '../../src/cli.mjs'; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-provider-update-')); + const env = { ...process.env, XDG_DATA_HOME: root }; + const calls = []; + let version = '1.2.3'; + let failInstall = false; + const runner = (command, args, options) => { + calls.push({ command, args, options }); + if (args[0] === 'view') return { status: 0, stdout: JSON.stringify(version) }; + if (args[0] === 'install') { + if (failInstall) return { status: 1, stderr: 'offline' }; + const pkg = args.at(-1); + const name = pkg.startsWith('@openai/') ? 'codex' : pkg.startsWith('@anthropic-ai/') ? 'claude' : 'pi'; + const prefix = args[args.indexOf('--prefix') + 1]; + mkdirSync(join(prefix, 'node_modules', '.bin'), { recursive: true }); + const path = join(prefix, 'node_modules', '.bin', name); + writeFileSync(path, `#!/bin/sh\n# ${version}\n`); + chmodSync(path, 0o755); + return { status: 0, stdout: 'installed' }; + } + return { status: 0, stdout: args[0] === '--version' ? `provider ${version}` + : args.includes('exec') ? '{"type":"thread.started","thread_id":"provider-update-fixture-session"}\n' : 'help' }; + }; + return { env, calls, runner, next: v => { version = v; }, breakInstall: () => { failInstall = true; } }; +} + +test('SCN-provider-updates: all built-ins resolve official latest, verify and launch the managed binary', () => { + const f = fixture(); + const plan = planProviderUpdates(['codex', 'claude', 'pi'], f); + assert.equal(f.calls.length, 0); + assert.equal(plan.modifiesGlobalInstallations, false); + assert.throws(() => updateProviders(['codex'], f), { code: 'APPROVAL_REQUIRED' }); + assert.equal(existsSync(providerStore(f.env)), false); + const result = updateProviders(['codex', 'claude', 'pi'], { ...f, authorized: true }); + assert.deepEqual(result.results.map(r => r.outcome), ['updated', 'updated', 'updated']); + const registry = createRuntimeAdapterRegistry({ env: f.env }); + for (const name of ['codex', 'claude', 'pi']) { + const selected = managedProvider(name, f); + assert.equal(registry.get(name).executable, selected.executable); + assert.equal(selected.version, '1.2.3'); + assert.equal(f.calls.some(c => c.command === selected.executable && c.args[0] === '--version'), true); + assert.equal(f.calls.some(c => c.command === selected.executable && c.args[0] === '--help'), true); + } + for (const call of f.calls.filter(c => c.command === 'npm')) { + assert.equal(call.args.includes('-g'), false); + assert.equal(call.args.includes('--registry'), true); + assert.equal(call.options.timeout > 0, true); + } + assert.equal(f.calls.find(c => c.args.at(-1) === '@earendil-works/pi-coding-agent@1.2.3').args.includes('--ignore-scripts'), true); + const count = f.calls.length; + assert.equal(updateProviders(['codex'], { ...f, authorized: true }).results[0].outcome, 'fresh'); + assert.equal(f.calls.length, count, 'fresh startup performs no network/install calls'); +}); + +test('SCN-provider-update-cli: opted-in startup updates selected provider, dry runs stay offline, failed refresh cannot launch', async () => { + const f = fixture(); + const root = join(f.env.XDG_DATA_HOME, 'repo'); + mkdirSync(root); + const git = args => execFileSync('git', ['-C', root, ...args], { stdio: 'pipe' }); + git(['init', '-b', 'main']); git(['config', 'user.email', 'fixture@example.invalid']); git(['config', 'user.name', 'Fixture']); + writeFileSync(join(root, 'README.md'), '# Test project\n'); + git(['add', '.']); git(['commit', '-m', 'fixture']); + const repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { status: 'approved', reviewedAt: '2026-09-30T00:00:00Z', reviewedBy: 'fixture-owner', notes: [] }; + installProject({ repository, proposal, env: f.env, runtimes: ['codex'] }); + const stdout = process.stdout.write; + const outputs = []; + process.stdout.write = chunk => { outputs.push(String(chunk)); return true; }; + try { + const run = args => runCli(args, { cwd: root, env: f.env, spawn: f.runner }); + assert.equal(await run(['runtimes', 'update-policy', '--mode', 'auto', '--yes', '--json']), 0); + const config = JSON.parse(readFileSync(join(root, '.torch', 'torch.yaml'))); + assert.deepEqual(config.runtimes.codex.updatePolicy, { mode: 'auto', max_age_hours: 24 }); + git(['add', '.torch']); git(['commit', '-m', 'config']); + createWorktrees({ repository: inspectRepository(root), parentOverride: join(f.env.XDG_DATA_HOME, 'worktrees') }); + assert.equal(await run(['up', '--fresh', '--only', 'session-manager', '--dry-run', '--json']), 0); + assert.equal(f.calls.length, 0, 'dry-run never downloads or launches'); + assert.equal(await run(['up', '--fresh', '--only', 'session-manager', '--yes', '--json']), 0); + const selected = managedProvider('codex', f); + const launches = f.calls.filter(c => c.args.includes('exec')); + assert.equal(launches.length, 1); + assert.equal(launches[0].command, selected.executable); + assert.equal(f.calls.some(c => c.args.at(-1)?.startsWith('@anthropic-ai/')), false); + const pointer = join(providerStore(f.env), 'codex.json'); + const stale = JSON.parse(readFileSync(pointer)); stale.checkedAt = '2000-01-01T00:00:00Z'; + writeFileSync(pointer, JSON.stringify(stale)); f.next('1.3.0'); f.breakInstall(); + assert.equal(await run(['up', '--only', 'session-manager', '--yes', '--json']), 2); + assert.equal(f.calls.filter(c => c.args.includes('exec')).length, 1, 'failed refresh must not launch the old provider silently'); + assert.equal(outputs.some(s => s.includes('RUNTIME_UPDATE_FAILED')), true); + } finally { process.stdout.write = stdout; } +}); + +test('SCN-provider-updates-recovery: failed updates retain last activation; tampering and concurrent updates stop', () => { + const f = fixture(); + updateProviders(['codex'], { ...f, authorized: true }); + const active = managedProvider('codex', f); + f.next('1.3.0'); f.breakInstall(); + assert.throws(() => updateProviders(['codex'], { ...f, authorized: true, force: true }), { code: 'RUNTIME_UPDATE_FAILED' }); + assert.equal(managedProvider('codex', f).executable, active.executable); + assert.equal(managedProvider('codex', f).version, '1.2.3'); + mkdirSync(join(providerStore(f.env), 'update.lock')); + assert.throws(() => updateProviders(['codex'], { ...f, authorized: true }), { code: 'RUNTIME_UPDATE_BUSY' }); + writeFileSync(active.executable, 'tampered'); + assert.throws(() => managedProvider('codex', f), { code: 'RUNTIME_UPDATE_FAILED' }); +}); + +test('SCN-provider-update-safety: arbitrary packages, unsafe registry versions, path escape and symlink stores fail closed', () => { + const f = fixture(); + assert.throws(() => planProviderUpdates(['untrusted-command'], f), { code: 'RUNTIME_UPDATE_FAILED' }); + f.next('1.2.3;touch /tmp/evil'); + assert.throws(() => updateProviders(['codex'], { ...f, authorized: true }), { code: 'RUNTIME_UPDATE_FAILED' }); + assert.equal(f.calls.some(c => c.args[0] === 'install'), false); + f.next('1.2.3'); updateProviders(['codex'], { ...f, authorized: true }); + const path = join(providerStore(f.env), 'codex.json'); + const record = JSON.parse(readFileSync(path)); + record.prefix = tmpdir(); writeFileSync(path, JSON.stringify(record)); + assert.throws(() => managedProvider('codex', f), { code: 'RUNTIME_UPDATE_FAILED' }); + const other = fixture(); + mkdirSync(join(other.env.XDG_DATA_HOME, 'torch')); + symlinkSync(tmpdir(), providerStore(other.env)); + assert.throws(() => planProviderUpdates(['pi'], other), { code: 'RUNTIME_UPDATE_FAILED' }); +}); diff --git a/test/fleet/self-host.test.mjs b/test/fleet/self-host.test.mjs index 34974fb..8ab21a0 100644 --- a/test/fleet/self-host.test.mjs +++ b/test/fleet/self-host.test.mjs @@ -159,6 +159,8 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch writeFileSync(join(checkout, 'node_modules', 'fixture-dependency', 'index.js'), 'export const dependency = true;\n'); symlinkSync('../fixture-dependency/index.js', join(checkout, 'node_modules', '.bin', 'fixture-dependency')); writeFileSync(join(checkout, '.gitignore'), 'node_modules/\nlocal-secret.env\n'); + mkdirSync(join(checkout, '.torch')); + writeFileSync(join(checkout, '.torch', 'torch.yaml'), '{"machineSpecificProject":true}\n'); execFileSync('git', ['init', '-b', 'main', checkout]); execFileSync('git', ['-C', checkout, 'config', 'user.email', 'torch-test@example.invalid']); execFileSync('git', ['-C', checkout, 'config', 'user.name', 'TORCH Test']); @@ -173,6 +175,8 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch assert.deepEqual(boundedPlan.excludedUntracked, ['new-runtime-adapter.mjs'], 'the reviewed candidate plan names untracked files that will not ship'); const bounded = service.installCandidate({ source: checkout, validate: accepted }); + assert.equal(existsSync(join(bounded.destination, '.torch')), false, + 'tracked self-host project state must not ship inside the installed engine'); assert.equal(existsSync(join(bounded.destination, 'local-secret.env')), false); assert.equal(existsSync(join(bounded.destination, 'new-runtime-adapter.mjs')), false); assert.equal(existsSync(join(bounded.destination, 'node_modules', '.bin', 'fixture-dependency')), true); diff --git a/torch-release.json b/torch-release.json index 22d894d..00b9ad6 100644 --- a/torch-release.json +++ b/torch-release.json @@ -1,6 +1,6 @@ { "schema": "torch.dev/release/v1alpha1", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "state": { "reads": ["torch.dev/state/v1alpha1"], "writes": "torch.dev/state/v1alpha1",