diff --git a/.github/workflows/prearm-source-controls.yml b/.github/workflows/prearm-source-controls.yml new file mode 100644 index 0000000..ec15285 --- /dev/null +++ b/.github/workflows/prearm-source-controls.yml @@ -0,0 +1,103 @@ +name: prearm-source-controls + +on: + pull_request: + paths: + - .github/workflows/prearm-source-controls.yml + - Directory.Build.props + - .config/dotnet-tools.json + - eng/** + - src/Broker.Contracts/** + - src/Broker.Core/** + - src/Broker.Protocol/** + - src/Broker.Browser.Contracts/** + - src/Broker.Browser.Live/** + - src/Broker.Browser.Gateway/** + - src/Broker.Browser.Client/live-runtime.js + - tests/Broker.Browser.Live.Tests/** + - tests/Broker.NativeProof/tactical-native-journey.spec.js + - tests/Broker.NativeProof/tactical-native-readiness.test.mjs + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: prearm-source-controls-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + prearm-source-controls: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + CI: 'true' + DOTNET_PROCESSOR_COUNT: '1' + steps: + - name: Check out the exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 2 + persist-credentials: false + + - name: Bind the checked-out candidate + shell: bash + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + + - name: Set up the exact .NET SDK + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: 10.0.401 + + - name: Set up the exact Node.js runtime + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.8.0 + + - name: Run the fourteen JavaScript source controls + shell: bash + run: node --test tests/Broker.NativeProof/tactical-native-readiness.test.mjs + + - name: Restore and build the live boundary source + shell: bash + env: + # grpc-fsharp 0.2 targets net6.0; retain the LatestMajor policy in + # specs/001-tui-grpc-broker/readiness/feature-baseline.md. + DOTNET_ROLL_FORWARD: LatestMajor + run: | + set -euo pipefail + project=tests/Broker.Browser.Live.Tests/Broker.Browser.Live.Tests.fsproj + dotnet restore "$project" --disable-parallel + dotnet build "$project" --configuration Release --no-restore --maxcpucount:1 + + - name: Run exactly three synthetic compiled controls + shell: bash + run: | + set -euo pipefail + results=$(mktemp -d) + trap 'rm -rf "$results"' EXIT + dotnet test tests/Broker.Browser.Live.Tests/Broker.Browser.Live.Tests.fsproj \ + --configuration Release --no-restore --no-build \ + --filter 'FullyQualifiedName~pre-arm pairing|FullyQualifiedName~native report, authenticated WebSocket, authority ACK, and ID0 command share exact identities' \ + --results-directory "$results" --logger 'trx;LogFileName=controls.trx' + python3 - "$results/controls.trx" <<'PY' + import sys + import xml.etree.ElementTree as ET + ns = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"} + root = ET.parse(sys.argv[1]).getroot() + expected = { + "gateway diagnostic classification.pre-arm pairing reasons are finite and callbacks cannot expose private material", + "production live boundary.pre-arm pairing emits closed reasons and preserves refusal without authority", + "production live boundary.native report, authenticated WebSocket, authority ACK, and ID0 command share exact identities", + } + results = root.findall("t:Results/t:UnitTestResult", ns) + if len(results) != 3 or {r.attrib["testName"] for r in results} != expected or any(r.attrib["outcome"] != "Passed" for r in results): + raise SystemExit("Expected exactly the three passing synthetic controls; zero, missing, skipped or extra tests refuse qualification") + print("PASS: exact three synthetic compiled source controls") + PY diff --git a/docs/roadmaps/evidence/barc-01.5f-pairing-observability-20261003.md b/docs/roadmaps/evidence/barc-01.5f-pairing-observability-20261003.md new file mode 100644 index 0000000..a5b16b2 --- /dev/null +++ b/docs/roadmaps/evidence/barc-01.5f-pairing-observability-20261003.md @@ -0,0 +1,21 @@ +# BARC-01.5f pairing observability + +The retained selected Count1 browser failure reached “Tactical catalogue unavailable”. Its evidence did not retain authentication, bootstrap, or client close classifications, so the causal branch remains **Unknown**. This change adds evidence for a future qualified attempt; it does not repair the catalogue or qualify native play. + +The existing Gateway diagnostic callback now reports four closed pairing reasons: `malformed-auth`, `missing-auth`, `auth-refusal`, and `bootstrap-unavailable`. The last reason covers bootstrap provisioning or the first bootstrap send only. Callback exceptions remain contained, and original authentication, frame bounds, closes, and later output behavior remain intact. The additive `LiveDiagnostic` cases are declared in the signature; package publication remains a separate operation. + +The existing browser fixture writes a separate `fsbar.barc-pairing-diagnostic/v1` sidecar. It contains only bounded counters, boolean status, the exact client close classification, and `nativeAcceptance: false`. Authentication and bootstrap payloads are discarded. Counters stop at 64; missing observation or overflow is Unknown. The exact `1008` / `invalid live frame` observer forwards the original client close once and contains diagnostic callback failures. That code identifies the client handler's refusal, not a particular invalid field or backend cause. + +The sidecar is at most 4096 bytes, owner-private mode 0600 in an existing owner-private 0700 parent. Publication uses an exclusive temporary file, fsync, exclusive atomic linking, and directory fsync; an existing destination, symlink parent, or unsafe parent is refused. Sidecar failure does not replace an existing pairing failure. An absent sidecar therefore remains Unknown. Capture v1 and arm-failure v1 content grammars are unchanged. + +Ordinary source qualification passed with private SDK 10.0.401 and signed-package FSharp.Core 10.1.401 (netstandard2.1 SHA256 `39b0b7f06c11bedd93f94b6f101fffd645f1c4437a5a6d3ee79259f855aeb11a`). A full private rebuild passed with no warnings. Three compiled controls passed: finite callback projection, genuine synthetic loopback authentication/provisioning refusals, and the existing positive native-report/WebSocket authority flow with a controlled first-bootstrap send-bound refusal. Fourteen exact JavaScript source-block controls passed, including payload omission, bounded counters, close forwarding, private/exclusive publication, unsafe-parent refusal, and preservation of the original pairing failure. Routine eligibility and operation-boundary fixtures passed. + +The first compiler harness inherited a global compiler host and failed; those outputs were superseded by the full private rebuild. Initial CLI filters selected zero tests and are not counted. A missing-auth fixture initially sent an empty frame and correctly hit the existing malformed-frame check; the final control sends a valid non-auth envelope. + +The protected source baseline is `9682516a8ebec133d33c1f8069f46f66477ea54a`. Historical scoped production reuse of compiled `3d723bcab5b25efc8d3467dcbc63790936f2add6` remains historical; this source change does not relabel those binaries or change the sealed candidate. Remote delivery, protected activation, a new qualified helper/capsule configuration, and any further Count1 effect remain pending separate root admission. Generated runtime bytes and SC2 authority claims remain Unknown. + +PR 31 initially had no native checks because the existing RuntimeEvidence workflow did not select these changed paths. The focused `prearm-source-controls` workflow now covers Gateway, the live tests, their project/tool dependencies, and both JavaScript control inputs. It binds checkout to the exact PR head, runs the fourteen JavaScript controls, restores/builds once, and requires the exact three passing synthetic compiled controls in the test receipt. Missing, empty, skipped, or unexpected results refuse qualification. The existing RuntimeEvidence workflow is byte-identical. + +Pipeline preflight is static: this is one linear job with no matrix, shared artifacts, reuse, or publication. A custom model would add maintenance without an interaction to check. The assessment is limited to configuration, trigger coverage, shell syntax, and exact test selection; reuse is expected on later changes to the same inputs, with a ten-minute assessment cap and a ten-minute hosted timeout. Local workflow commands passed: build with zero warnings, fourteen JavaScript tests, and three exact compiled tests. Result-refusal controls and compiled reflection of preserved union tags 0–14 also passed. Native exact-head acceptance remains pending; historical cause remains Unknown and useful native play remains 0/6. + +Native run `37145445532` at head `ab81ef61eaf43571b3ddc7aba4619f8c6b778328` passed the JavaScript controls, then failed during protobuf generation: pinned `grpc-fsharp` 0.2.0 targets .NET 6 while the runner has .NET 8/9/10. Compiled controls were skipped and do not qualify that head. The build step now supplies `DOTNET_ROLL_FORWARD=LatestMajor`, matching the existing generator policy in `specs/001-tui-grpc-broker/readiness/feature-baseline.md` and `us1-progress.md`, through the step environment. No package runtimeconfig or dependency pin is changed. An isolated .NET-10-only setup control reproduced exit 150 without the setting and returned exit 0 with it using the unchanged generator and empty protobuf request. YAML, environment scope, preserved steps/pins, and shell syntax passed static checks; the repaired native run remains pending. diff --git a/src/Broker.Browser.Gateway/Gateway.fs b/src/Broker.Browser.Gateway/Gateway.fs index 77c3629..af07c83 100644 --- a/src/Broker.Browser.Gateway/Gateway.fs +++ b/src/Broker.Browser.Gateway/Gateway.fs @@ -68,9 +68,14 @@ module Gateway = | ReceiveTaskFailed | ReceiveTaskCompleted | ReceiveTaskCancelled | OutputTaskFailed | OutputTaskCompleted | OutputTaskCancelled | RenewalTaskFailed | RenewalTaskCompleted | RenewalTaskCancelled + | MalformedAuth | MissingAuth | AuthRefusal | BootstrapUnavailable let diagnosticNames diagnostic = match diagnostic with + | MalformedAuth -> struct("pairing","malformed-auth") + | MissingAuth -> struct("pairing","missing-auth") + | AuthRefusal -> struct("pairing","auth-refusal") + | BootstrapUnavailable -> struct("pairing","bootstrap-unavailable") | SubmitAccepted -> struct("submit","accepted") | SubmitRefused reason -> let name = @@ -418,22 +423,35 @@ module Gateway = try let! received=receiveOne socket config.maxFrameBytes authCts.Token match received with - | Error detail -> do! close socket WebSocketCloseStatus.InvalidPayloadData detail config.closeTimeout + | Error detail -> + diagnostic MalformedAuth + do! close socket WebSocketCloseStatus.InvalidPayloadData detail config.closeTimeout | Ok bytes -> let parsed=try Ok(LiveClientEnvelope.Parser.ParseFrom(bytes.ToArray())) with :? InvalidProtocolBufferException -> Error "malformed live authentication" match parsed with - | Error detail -> do! close socket WebSocketCloseStatus.InvalidPayloadData detail config.closeTimeout - | Ok message when isNull message.Authenticate -> do! close socket WebSocketCloseStatus.PolicyViolation "live authentication required" config.closeTimeout + | Error detail -> + diagnostic MalformedAuth + do! close socket WebSocketCloseStatus.InvalidPayloadData detail config.closeTimeout + | Ok message when isNull message.Authenticate -> + diagnostic MissingAuth + do! close socket WebSocketCloseStatus.PolicyViolation "live authentication required" config.closeTimeout | Ok message -> match authenticateLive config origin message.Authenticate hub with - | Error detail -> do! close socket WebSocketCloseStatus.PolicyViolation detail config.closeTimeout + | Error detail -> + diagnostic AuthRefusal + do! close socket WebSocketCloseStatus.PolicyViolation detail config.closeTimeout | Ok(sessionId,profile) -> let state=BrokerState.liveControl hub match LiveBoundary.provisionBootstrapForProfile profile sessionId config.perspectiveId state with - | Error detail -> do! close socket WebSocketCloseStatus.PolicyViolation detail config.closeTimeout + | Error detail -> + diagnostic BootstrapUnavailable + do! close socket WebSocketCloseStatus.PolicyViolation detail config.closeTimeout | Ok bootstrap -> use connectionCts=CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted) - do! send config.maxFrameBytes socket bootstrap connectionCts.Token + try do! send config.maxFrameBytes socket bootstrap connectionCts.Token + with error -> + diagnostic BootstrapUnavailable + return raise error let mutable provisionalControllerId = bytesGuid bootstrap.Bootstrap.Controller.ControllerId let outputs=Channel.CreateBounded(int (LiveControl.maxRetainedResults state) + 16) let outputGate=obj() diff --git a/src/Broker.Browser.Gateway/Gateway.fsi b/src/Broker.Browser.Gateway/Gateway.fsi index 2b9db06..cc09f37 100644 --- a/src/Broker.Browser.Gateway/Gateway.fsi +++ b/src/Broker.Browser.Gateway/Gateway.fsi @@ -52,6 +52,7 @@ module Gateway = | ReceiveTaskFailed | ReceiveTaskCompleted | ReceiveTaskCancelled | OutputTaskFailed | OutputTaskCompleted | OutputTaskCancelled | RenewalTaskFailed | RenewalTaskCompleted | RenewalTaskCancelled + | MalformedAuth | MissingAuth | AuthRefusal | BootstrapUnavailable val diagnosticNames : diagnostic:LiveDiagnostic -> struct(string * string) val internal emitDiagnostic : diagnostics:(LiveDiagnostic -> unit) -> diagnostic:LiveDiagnostic -> unit diff --git a/tests/Broker.Browser.Live.Tests/LiveIntegrationTests.fs b/tests/Broker.Browser.Live.Tests/LiveIntegrationTests.fs index 1c69c7d..68c86d8 100644 --- a/tests/Broker.Browser.Live.Tests/LiveIntegrationTests.fs +++ b/tests/Broker.Browser.Live.Tests/LiveIntegrationTests.fs @@ -45,6 +45,47 @@ let private reporter matchId = [] let tests = testList "production live boundary" [ + testTask "pre-arm pairing emits closed reasons and preserves refusal without authority" { + let grpcPort = freePort() + let! (handle: ServerHost.ServerHandle) = + ServerHost.start { ServerHost.defaultOptions with listenAddress = sprintf "127.0.0.1:%d" grpcPort } + (System.Version(1, 0)) ignore CancellationToken.None + let channel = GrpcChannel.ForAddress(sprintf "http://127.0.0.1:%d" grpcPort) + let coordinator = HighBarCoordinator.HighBarCoordinatorClient(channel) + let heartbeat = HeartbeatRequest.empty() + heartbeat.PluginId <- "pairing-control" + heartbeat.SchemaVersion <- "1.0.0" + let! _ = coordinator.HeartbeatAsync(heartbeat).ResponseAsync + let sessionId = Session.id (BrokerState.session handle.Hub).Value + let origin = "http://127.0.0.1:4181" + let browserPort = freePort() + let reasons = System.Collections.Concurrent.ConcurrentQueue() + let config = Gateway.defaultLiveConfig (sprintf "http://127.0.0.1:%d" browserPort) origin "fixture-private" sessionId + let! (gateway: Microsoft.Extensions.Hosting.IHost) = Gateway.startLiveAsyncWithDiagnostics handle.Hub config reasons.Enqueue CancellationToken.None + for bytes, expected in [ + [| 255uy |], Gateway.MalformedAuth + (LiveClientEnvelope(Submit=SubmitLiveIntent())).ToByteArray(), Gateway.MissingAuth + (LiveClientEnvelope(Authenticate=ClientAuth(Game="bar",ProtocolVersion="1.0.0",Profile="barc-live-v1",Credential="wrong",Origin=origin,ExpectedSessionId=ByteString.CopyFrom(sessionId.ToByteArray())))).ToByteArray(), Gateway.AuthRefusal + (LiveClientEnvelope(Authenticate=ClientAuth(Game="bar",ProtocolVersion="1.0.0",Profile="barc-live-v1",Credential="fixture-private",Origin=origin,ExpectedSessionId=ByteString.CopyFrom(sessionId.ToByteArray())))).ToByteArray(), Gateway.BootstrapUnavailable ] do + let socket = new ClientWebSocket() + socket.Options.SetRequestHeader("Origin", origin) + let deadline = new CancellationTokenSource(TimeSpan.FromSeconds 5.) + do! socket.ConnectAsync(Uri(sprintf "ws://127.0.0.1:%d/barc-live" browserPort), deadline.Token) + do! socket.SendAsync(ReadOnlyMemory(bytes), WebSocketMessageType.Binary, true, deadline.Token).AsTask() + let buffer = Array.zeroCreate 256 + let! (response: ValueWebSocketReceiveResult) = socket.ReceiveAsync(Memory(buffer), deadline.Token).AsTask() + Expect.equal response.MessageType WebSocketMessageType.Close "original refusal still closes before bootstrap" + let mutable actual = Unchecked.defaultof + Expect.isTrue (reasons.TryDequeue(&actual)) "one bounded classification is observed" + Expect.equal actual expected "the genuine compiled branch supplies its closed reason" + Expect.isNone (LiveControl.currentBinding (BrokerState.liveControl handle.Hub)) "failed pairing never provisions authority" + deadline.Dispose() + socket.Dispose() + do! gateway.StopAsync() + (gateway :> IDisposable).Dispose() + do! handle.DisposeAsync().AsTask() + channel.Dispose() + } testTask "gateway refuses unselected origin and wrong broker session before provisioning authority" { let grpcPort = freePort() let! (handle: ServerHost.ServerHandle) = @@ -292,6 +333,7 @@ let tests = testList "production live boundary" [ let! (observation: LiveServerEnvelope) = receive socket Expect.equal bootstrap.Bootstrap.LiveProfile "barc-live-tactical-v1" "production WebSocket negotiates tactical profile" Expect.equal bootstrap.Bootstrap.TacticalCatalogue.Definitions.Count 1 "complete native catalogue crosses production WebSocket" + Expect.isFalse (diagnostics |> Seq.exists (function Gateway.MalformedAuth | Gateway.MissingAuth | Gateway.AuthRefusal | Gateway.BootstrapUnavailable -> true | _ -> false)) "successful authentication and bootstrap emit no pre-arm refusal" Expect.isNotNull observation.Observation.Tactical "paired tactical observation crosses production WebSocket" Expect.equal observation.Observation.Tactical.Features.Count 342 "all negotiated features cross production gRPC and WebSocket" Expect.equal observation.Observation.Tactical.Features[341].Reference.Id 341UL "feature beyond index 256 retains its id" @@ -611,6 +653,25 @@ let tests = testList "production live boundary" [ do! send replacementSocket (LiveClientEnvelope(Authenticate=auth.Clone())) let! (_: LiveServerEnvelope) = receive replacementSocket let! (_: LiveServerEnvelope) = receive replacementSocket + let boundedPort = freePort() + let sendDiagnostics = System.Collections.Concurrent.ConcurrentQueue() + let boundedConfig = { Gateway.defaultLiveConfig (sprintf "http://127.0.0.1:%d" boundedPort) origin "secret-live" sessionId with maxFrameBytes = 128 } + Expect.isLessThanOrEqual ((LiveClientEnvelope(Authenticate=auth.Clone())).ToByteArray().Length) 128 "controlled authentication fits the receive bound" + Expect.isGreaterThan (bootstrap.ToByteArray().Length) 128 "genuine bootstrap exceeds only the controlled first-send bound" + let! (boundedGateway: Microsoft.Extensions.Hosting.IHost) = Gateway.startLiveAsyncWithDiagnostics handle.Hub boundedConfig sendDiagnostics.Enqueue CancellationToken.None + let boundedSocket = new ClientWebSocket() + boundedSocket.Options.SetRequestHeader("Origin",origin) + let boundedDeadline = new CancellationTokenSource(TimeSpan.FromSeconds 5.) + do! boundedSocket.ConnectAsync(Uri(sprintf "ws://127.0.0.1:%d/barc-live" boundedPort),boundedDeadline.Token) + do! send boundedSocket (LiveClientEnvelope(Authenticate=auth.Clone())) + let boundedBuffer = Array.zeroCreate 256 + let! (boundedResponse: ValueWebSocketReceiveResult) = boundedSocket.ReceiveAsync(Memory(boundedBuffer),boundedDeadline.Token).AsTask() + Expect.equal boundedResponse.MessageType WebSocketMessageType.Close "oversized first bootstrap retains original close refusal" + Expect.equal (sendDiagnostics.ToArray()) [| Gateway.BootstrapUnavailable |] "first bootstrap send failure has only its closed stage code" + boundedDeadline.Dispose() + boundedSocket.Dispose() + do! boundedGateway.StopAsync() + (boundedGateway :> IDisposable).Dispose() BrokerState.closeSession Session.OperatorTerminated DateTimeOffset.UtcNow handle.Hub let replacementCloseTimeout = new CancellationTokenSource(TimeSpan.FromSeconds 3.0) let replacementCloseBuffer = Array.zeroCreate 128 diff --git a/tests/Broker.Browser.Live.Tests/LiveTests.fs b/tests/Broker.Browser.Live.Tests/LiveTests.fs index 34b4b31..e843a92 100644 --- a/tests/Broker.Browser.Live.Tests/LiveTests.fs +++ b/tests/Broker.Browser.Live.Tests/LiveTests.fs @@ -35,6 +35,11 @@ let gatewayDiagnosticTests = testList "gateway diagnostic classification" [ Expect.equal (Gateway.feedbackDiagnostic LiveControl.Unknown LiveControl.UnknownStatus) (Some Gateway.UnknownForwarded) "unknown stage is classified" Expect.equal (Gateway.feedbackDiagnostic LiveControl.Unknown LiveControl.Expired) (Some Gateway.ExpiredForwarded) "expired takes precedence over unknown stage" Expect.equal (Gateway.feedbackDiagnostic LiveControl.NativeAdmission LiveControl.Accepted) None "unselected feedback does not invert into a diagnostic" + testCase "pre-arm pairing reasons are finite and callbacks cannot expose private material" <| fun _ -> + for value,name in [Gateway.MalformedAuth,"malformed-auth";Gateway.MissingAuth,"missing-auth";Gateway.AuthRefusal,"auth-refusal";Gateway.BootstrapUnavailable,"bootstrap-unavailable"] do + Expect.equal (Gateway.diagnosticNames value) (struct("pairing",name)) "pre-arm diagnostic has only a fixed reason" + Gateway.emitDiagnostic (fun _ -> raise(InvalidOperationException "credential PRIVATE_SENTINEL /private/path session")) value + Expect.equal (Gateway.diagnosticNames Gateway.SubmitAccepted) (struct("submit","accepted")) "existing diagnostics are unchanged" testCase "throwing diagnostic callbacks cannot escape" <| fun _ -> Gateway.emitDiagnostic (fun _ -> raise(InvalidOperationException "private detail")) Gateway.SubmitAccepted testCase "submit refusal classes are closed and never echo detail" <| fun _ -> diff --git a/tests/Broker.NativeProof/tactical-native-journey.spec.js b/tests/Broker.NativeProof/tactical-native-journey.spec.js index 5024534..3ffe742 100644 --- a/tests/Broker.NativeProof/tactical-native-journey.spec.js +++ b/tests/Broker.NativeProof/tactical-native-journey.spec.js @@ -1,9 +1,9 @@ import { expect, test } from "@playwright/test"; import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { closeSync, constants, existsSync, fsyncSync, openSync, readFileSync, writeSync } from "node:fs"; -import { createHash } from "node:crypto"; +import { closeSync, constants, existsSync, fsyncSync, openSync, readFileSync, writeSync, lstatSync, fstatSync, linkSync, unlinkSync } from "node:fs"; +import { createHash, randomBytes } from "node:crypto"; import { pathToFileURL } from "node:url"; -import { basename, resolve } from "node:path"; +import { basename, dirname, resolve, parse, join } from "node:path"; import { assertCanonicalLifecycle, assertObservedEffects, isExactStaleBasisRefusal, isFreshPhysicalReplacement, loadStockSmokeHandoff, loadTacticalHandoff, plannedChildCount, sanitizedLifecycle, waitForTerminalOrClose } from "./tactical-native-evidence.mjs"; const enabled=process.env.BARC_RUN_TACTICAL_NATIVE==="1",stockEnabled=process.env.BARC_RUN_STOCK_NATIVE_SMOKE==="1";if(enabled&&stockEnabled)throw new Error("full-six and selected stock smoke routes are mutually exclusive"); @@ -28,6 +28,40 @@ function captureJournal(path,release,runId,source){ return{append,complete(){append("complete",{selectedCase:"stock-smoke-count1"});fsyncSync(fd);closed=true},async hold(){await expect.poll(()=>existsSync(release),{timeout:30000,intervals:[25,50,100,250]}).toBe(true)},close(){closeSync(fd)}}; } // Readiness controls execute this exact source block without loading native handoffs. +const pairingState=()=>({observed:false,authSentCount:0,bootstrapDecodedCount:0,decodeFailureCount:0,clientCloseCount:0,closed:false,error:false,overflow:false}); +function notePairing(state,kind){ + if(kind==="socket"){state.observed=true;return} + const field={auth:"authSentCount",bootstrap:"bootstrapDecodedCount",decode:"decodeFailureCount","clientdecode-bootstrap-close":"clientCloseCount"}[kind]; + if(field){if(state[field]<64)state[field]++;else state.overflow=true} + else if(kind==="close")state.closed=true;else if(kind==="error")state.error=true; +} +function pairingSummary(state,paired=null){ + const count=name=>state?.observed===true&&Number.isInteger(state[name])&&state[name]>=0&&state[name]<=64?state[name]:null; + const counts={authSentCount:count("authSentCount"),bootstrapDecodedCount:count("bootstrapDecodedCount"),decodeFailureCount:count("decodeFailureCount"),clientCloseCount:count("clientCloseCount")}; + const known=state?.observed===true&&state.overflow===false&&Object.values(counts).every(x=>x!==null)&&typeof state.closed==="boolean"&&typeof state.error==="boolean"; + return{schema:"fsbar.barc-pairing-diagnostic/v1",nativeAcceptance:false,state:known?"observed":"unknown",...counts,socketClosed:known?state.closed:null,socketError:known?state.error:null,clientCloseClass:counts.clientCloseCount>0?"clientdecode-bootstrap-close":null,paired:typeof paired==="boolean"?paired:null,counterOverflow:state?.overflow===true}; +} +function installPairingCloseObserver(){ + const original=WebSocket.prototype.close; + WebSocket.prototype.close=function(...args){ + if(args[0]===1008&&args[1]==="invalid live frame")try{window.__barcPairingClose("clientdecode-bootstrap-close")?.catch?.(()=>{})}catch{} + return Reflect.apply(original,this,args); + }; +} +function writePairingDiagnostic(j,state,paired){ + const path=`${j.outputPath}.pairing-diagnostic.json`,parent=dirname(resolve(path)),parsed=parse(parent);let current=parsed.root,fd=null,temp=null; + try{ + for(const part of parent.slice(parsed.root.length).split("/").filter(Boolean)){current=join(current,part);if(lstatSync(current).isSymbolicLink())throw new Error()} + const anchor=lstatSync(parent);if(!anchor.isDirectory()||anchor.uid!==process.geteuid()||(anchor.mode&0o777)!==0o700)throw new Error(); + const data=Buffer.from(JSON.stringify(pairingSummary(state,paired))+"\n");if(data.byteLength>4096)throw new Error(); + temp=join(parent,`.barc-pairing-${randomBytes(16).toString("hex")}`);fd=openSync(temp,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW|constants.O_CLOEXEC,0o600); + const pin=fstatSync(fd);if(!pin.isFile()||pin.uid!==process.geteuid()||(pin.mode&0o777)!==0o600||pin.nlink!==1)throw new Error(); + for(let offset=0;offset({phase:"unrequested",moduleName:null,moduleSha256:null,armCount:0,armSha256:null,controllerCount:0,controllerStage:null,failureCode:null}); const moduleHash=value=>createHash("sha256").update(value).digest("hex"); function safeArmText(value,j){ @@ -59,16 +93,17 @@ async function loadSelectedGuest(page,live,j,guest,state){ state.phase="module-ready"; } // End readiness source block. -async function capture(page,journal=null){const rows=[],connection={closed:false,error:false},readiness=readinessState();Object.defineProperties(rows,{connection:{value:connection},readiness:{value:readiness}});const retain=row=>{rows.push(row);journal?.append(row.kind,row.value)};page.on("websocket",socket=>{socket.on("framesent",event=>{try{const e=codec.canonicalObject(codec.v1.LiveClientEnvelope,Uint8Array.from(event.payload));if(e.body==="arm"){readiness.armCount++;const hash=Buffer.from(e.arm.module?.sha256??"","base64");readiness.armSha256=hash.length===32?hash.toString("hex"):null}if(e.body==="submit")retain({direction:"client",kind:"submit",value:e.submit})}catch{}});socket.on("framereceived",event=>{try{const e=codec.canonicalObject(codec.v1.LiveServerEnvelope,Uint8Array.from(event.payload));if(e.body==="controllerState"){readiness.controllerCount++;const stage=e.controllerState.stage;readiness.controllerStage=/^CONTROLLER_STAGE_[A-Z_]{1,40}$/.test(stage)?stage:"invalid"}if(["result","observation","controllerState"].includes(e.body))retain({direction:"server",kind:e.body,value:e[e.body]})}catch{}});socket.on("close",()=>{connection.closed=true});socket.on("socketerror",()=>{connection.error=true})});return rows} +async function capture(page,journal=null){const rows=[],connection={closed:false,error:false},readiness=readinessState(),pairing=pairingState();Object.defineProperties(rows,{connection:{value:connection},readiness:{value:readiness},pairing:{value:pairing}});await page.exposeBinding("__barcPairingClose",(_,code)=>{if(code==="clientdecode-bootstrap-close")notePairing(pairing,code)});await page.addInitScript(installPairingCloseObserver);const retain=row=>{rows.push(row);journal?.append(row.kind,row.value)};page.on("websocket",socket=>{notePairing(pairing,"socket");socket.on("framesent",event=>{try{const e=codec.canonicalObject(codec.v1.LiveClientEnvelope,Uint8Array.from(event.payload));if(e.body==="authenticate")notePairing(pairing,"auth");if(e.body==="arm"){readiness.armCount++;const hash=Buffer.from(e.arm.module?.sha256??"","base64");readiness.armSha256=hash.length===32?hash.toString("hex"):null}if(e.body==="submit")retain({direction:"client",kind:"submit",value:e.submit})}catch{}});socket.on("framereceived",event=>{try{const e=codec.canonicalObject(codec.v1.LiveServerEnvelope,Uint8Array.from(event.payload));if(e.body==="bootstrap")notePairing(pairing,"bootstrap");if(e.body==="controllerState"){readiness.controllerCount++;const stage=e.controllerState.stage;readiness.controllerStage=/^CONTROLLER_STAGE_[A-Z_]{1,40}$/.test(stage)?stage:"invalid"}if(["result","observation","controllerState"].includes(e.body))retain({direction:"server",kind:e.body,value:e[e.body]})}catch{notePairing(pairing,"decode")}});socket.on("close",()=>{connection.closed=true;notePairing(pairing,"close")});socket.on("socketerror",()=>{connection.error=true;notePairing(pairing,"error")})});return rows} async function pair(page,j,guest="Manual guest",rows){ - const live=page.locator(".barc-preview.barc-live"),state=rows.readiness; + const live=page.locator(".barc-preview.barc-live"),state=rows.readiness;let pairingFailed=false; try{ await page.goto(j.receiverUrl);await live.getByLabel("Gateway").fill(j.gatewayUrl);await live.getByLabel("Session UUID").fill(j.expectedSessionId);await live.getByLabel("One-time credential").fill(j.credential);await live.getByRole("button",{name:"Pair"}).click(); await expect(live.locator(".catalogue")).not.toContainText("unavailable");await expect(live.locator(".economy")).toContainText("Economy"); await loadSelectedGuest(page,live,j,guest,state); state.phase="arm-clicked";await live.getByRole("button",{name:"Arm live"}).click(); await expect.poll(()=>state.armSha256).toBe(state.moduleSha256);await expect(live.locator(".authority")).toContainText("arm native confirmed");state.phase="native-confirmed";return live; - }catch(error){state.failureCode??=state.phase==="arm-clicked"?"authority-not-confirmed":"pair-or-module-not-ready";await writeArmFailure(live,j,state);throw error} + }catch(error){pairingFailed=true;state.failureCode??=state.phase==="arm-clicked"?"authority-not-confirmed":"pair-or-module-not-ready";await writeArmFailure(live,j,state);throw error} + finally{let paired=null;try{paired=(await live.locator(".status").textContent({timeout:500}))==="current: live session"}catch{}try{writePairingDiagnostic(j,rows.pairing,paired)}catch(error){if(!pairingFailed)throw error}} } async function target(live,p){await live.getByLabel("Target X").fill(String(p.x));await live.getByLabel("Target Z").fill(String(p.z))} async function keyboardSelect(page,live,refs){const map=live.getByLabel(/Live tactical map/);await map.focus();for(let i=0;i<64&&!((await live.locator(".selection").textContent())??"").includes(key(refs[0]));i++)await page.keyboard.press("Tab");expect(await live.locator(".selection").textContent()).toContain(key(refs[0]));for(const ref of refs.slice(1)){for(let i=0;i<64&&!((await live.locator(".selection").textContent())??"").includes(key(ref));i++)await page.keyboard.press("Control+Tab");expect(await live.locator(".selection").textContent()).toContain(key(ref))}} diff --git a/tests/Broker.NativeProof/tactical-native-readiness.test.mjs b/tests/Broker.NativeProof/tactical-native-readiness.test.mjs index 329b598..c95d94d 100644 --- a/tests/Broker.NativeProof/tactical-native-readiness.test.mjs +++ b/tests/Broker.NativeProof/tactical-native-readiness.test.mjs @@ -1,12 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile, writeFile, mkdtemp, rm, stat } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { basename, resolve } from "node:path"; +import { randomBytes, createHash } from "node:crypto"; +import { basename, resolve, dirname, parse, join } from "node:path"; import { tmpdir } from "node:os"; import vm from "node:vm"; +import { constants, lstatSync, fstatSync, openSync, writeSync, fsyncSync, closeSync, linkSync, unlinkSync, chmodSync, symlinkSync } from "node:fs"; const spec=await readFile(new URL("./tactical-native-journey.spec.js",import.meta.url),"utf8"); +const pairingSource=spec.slice(spec.indexOf("const pairingState="),spec.indexOf("const readinessState=")); +const pairingContext={process,Buffer,constants,lstatSync,fstatSync,openSync,writeSync,fsyncSync,closeSync,linkSync,unlinkSync,randomBytes,dirname,resolve,parse,join}; +const pairing=vm.runInNewContext(`${pairingSource};({pairingState,notePairing,pairingSummary,writePairingDiagnostic,installPairingCloseObserver})`,pairingContext); const readiness=spec.slice(spec.indexOf("const readinessState="),spec.indexOf("// End readiness source block.")); const client=await readFile(new URL("../../src/Broker.Browser.Client/live-runtime.js",import.meta.url),"utf8"); const guestSource=client.slice(client.indexOf(" async function initializeGuest()"),client.indexOf("\n\n function handleEnvelope")); @@ -76,13 +80,16 @@ test("canonical pair cannot click Arm until the selected-module wait resolves",a const pair=spec.slice(spec.indexOf("async function pair("),spec.indexOf("async function target("));const ready=deferred(),events=[]; const state={moduleSha256:hash,armSha256:hash};const expect=()=>({not:{toContainText:async()=>{}},toContainText:async()=>{}});expect.poll=()=>({toBe:async()=>{}}); const live={getByLabel:()=>({fill:async()=>{}}),getByRole:(_,o)=>({click:async()=>{events.push(o.name)}}),locator:()=>({})}; - const run=vm.runInNewContext(`${pair};pair`,{expect,loadSelectedGuest:()=>ready.promise,writeArmFailure:async()=>{throw new Error("unexpected failure")}}); + const run=vm.runInNewContext(`${pair};pair`,{writePairingDiagnostic:()=>{},expect,loadSelectedGuest:()=>ready.promise,writeArmFailure:async()=>{throw new Error("unexpected failure")}}); const pending=run({locator:()=>live,goto:async()=>{}},{},"Manual guest",{readiness:state});await turn();assert.deepEqual(events,["Pair"]);ready.resolve();await pending;assert.deepEqual(events,["Pair","Arm live"]);assert.equal(state.phase,"native-confirmed"); }); test("arm lifecycle records only module digest/count and controller stage outside accepted capture",async()=>{ const captureSource=spec.slice(spec.indexOf("async function capture("),spec.indexOf("async function pair("));let onSocket;const callbacks={},journalRows=[];let envelope; - const api=vm.runInNewContext(`${readiness};${captureSource};({capture})`,{createHash,readFile,writeFile,basename,URL,Buffer,Uint8Array,codec:{v1:{},canonicalObject:()=>envelope}}); - const rows=await api.capture({on:(_,fn)=>{onSocket=fn}},{append:(kind,value)=>journalRows.push({kind,value})});onSocket({on:(name,fn)=>{callbacks[name]=fn}}); + const api=vm.runInNewContext(`${pairingSource};${readiness};${captureSource};({capture})`,{createHash,readFile,writeFile,basename,URL,Buffer,Uint8Array,codec:{v1:{},canonicalObject:()=>envelope}}); + const rows=await api.capture({exposeBinding:async()=>{},addInitScript:async()=>{},on:(_,fn)=>{onSocket=fn}},{append:(kind,value)=>journalRows.push({kind,value})});onSocket({on:(name,fn)=>{callbacks[name]=fn}}); + envelope={body:"authenticate",authenticate:{credential:"PRIVATE_AUTH",session:"PRIVATE_SESSION"}};callbacks.framesent({payload:[]}); + envelope={body:"bootstrap",bootstrap:{credential:"PRIVATE_BOOTSTRAP"}};callbacks.framereceived({payload:[]}); + assert.equal(rows.pairing.authSentCount,1);assert.equal(rows.pairing.bootstrapDecodedCount,1);assert.equal(rows.length,0);assert.equal(journalRows.length,0);assert.equal(JSON.stringify(rows.pairing).includes("PRIVATE"),false); envelope={body:"arm",arm:{module:{sha256:Buffer.from(hash,"hex").toString("base64")},controller:{sessionId:"do-not-retain"}}};callbacks.framesent({payload:[]}); assert.equal(rows.readiness.armCount,1);assert.equal(rows.readiness.armSha256,hash);assert.equal(rows.length,0);assert.equal(journalRows.length,0);assert.equal(JSON.stringify(rows.readiness).includes("do-not-retain"),false); envelope={body:"controllerState",controllerState:{stage:"CONTROLLER_STAGE_REFUSED",reason:"untrusted-private-reason"}};callbacks.framereceived({payload:[]});assert.equal(rows.readiness.controllerCount,1);assert.equal(rows.readiness.controllerStage,"CONTROLLER_STAGE_REFUSED");assert.equal(JSON.stringify(rows.readiness).includes("untrusted-private-reason"),false); @@ -94,8 +101,40 @@ test("canonical failed pair retains its safe refusal code and rethrows the origi const expect=()=>({not:{toContainText:async()=>{}},toContainText:async()=>{}}); const live={getByLabel:()=>({fill:async()=>{}}),getByRole:(_,o)=>({click:async()=>{if(o.name==="Arm live")arms++}}),locator:()=>({textContent:async()=>"Live gameplay input is fenced."})}; // Swap only the external readiness operation; canonical catch and actual artifact writer execute. - const context=vm.createContext({expect,createHash,readFile,writeFile,basename,URL});vm.runInContext(readiness,context);context.loadSelectedGuest=async()=>{throw failure};vm.runInContext(pair,context); + const context=vm.createContext({writePairingDiagnostic:()=>{throw new Error("private pairing diagnostic refused")},expect,createHash,readFile,writeFile,basename,URL});vm.runInContext(readiness,context);context.loadSelectedGuest=async()=>{throw failure};vm.runInContext(pair,context); const path=resolve(dir,"capture.arm-failure.json");await assert.rejects(context.pair({locator:()=>live,goto:async()=>{}},{armFailurePath:path},"Manual guest",{readiness:state}),e=>e===failure);assert.equal(arms,0); const value=JSON.parse(await readFile(path,"utf8"));assert.equal(value.readiness.failureCode,"module-download-unavailable");assert.equal(value.ui.diagnostic,"Live gameplay input is fenced.");assert.equal(value.nativeAcceptance,false); }finally{await rm(dir,{recursive:true,force:true})} }); + + +test("pairing projection is finite, Unknown without observation, and discards private material",()=>{ + assert.equal(pairing.pairingSummary(null).state,"unknown"); + const state=pairing.pairingState();state.payload="PRIVATE_CREDENTIAL /private/path session"; + pairing.notePairing(state,"socket");pairing.notePairing(state,"auth");pairing.notePairing(state,"bootstrap"); + const positive=pairing.pairingSummary(state,true);assert.equal(positive.state,"observed");assert.equal(positive.authSentCount,1);assert.equal(positive.bootstrapDecodedCount,1);assert.equal(positive.nativeAcceptance,false);assert.equal(JSON.stringify(positive).includes("PRIVATE"),false); + for(let i=0;i<65;i++)pairing.notePairing(state,"decode");assert.equal(state.decodeFailureCount,64);assert.equal(pairing.pairingSummary(state).state,"unknown"); + state.authSentCount=Infinity;assert.equal(pairing.pairingSummary(state).authSentCount,null); +}); +test("exact client close observer forwards original once and contains diagnostic callback errors",()=>{ + const calls=[],codes=[];class Socket{close(...args){calls.push({self:this,args});return 7}} + const context={WebSocket:Socket,window:{__barcPairingClose:code=>{codes.push(code);throw new Error("PRIVATE")}}}; + vm.runInNewContext(`${pairingSource};installPairingCloseObserver()`,context);const socket=new Socket(); + assert.equal(socket.close(1008,"invalid live frame"),7);assert.equal(calls.length,1);assert.equal(calls[0].self,socket);assert.deepEqual(calls[0].args,[1008,"invalid live frame"]);assert.deepEqual(codes,["clientdecode-bootstrap-close"]); + socket.close(1008,"generic private close");assert.equal(codes.length,1);assert.equal(calls.length,2); +}); +test("pairing sidecar publishes exclusive private bounded bytes and refuses unsafe parents",async()=>{ + const dir=await mkdtemp(resolve(tmpdir(),"bar-pairing-projection-"));try{ + chmodSync(dir,0o700);const j={outputPath:resolve(dir,"capture")},state=pairing.pairingState();state.secret="PRIVATE";pairing.notePairing(state,"socket"); + pairing.writePairingDiagnostic(j,state,null);const path=j.outputPath+".pairing-diagnostic.json",text=await readFile(path,"utf8"),pin=await stat(path);assert.equal(pin.mode&0o777,0o600);assert.equal(pin.nlink,1);assert.ok(Buffer.byteLength(text)<=4096);assert.equal(text.includes("PRIVATE"),false); + assert.throws(()=>pairing.writePairingDiagnostic(j,state,null),/private pairing diagnostic refused/);assert.equal(await readFile(path,"utf8"),text); + chmodSync(dir,0o755);assert.throws(()=>pairing.writePairingDiagnostic({outputPath:resolve(dir,"world")},state,null),/private pairing diagnostic refused/);chmodSync(dir,0o700); + const link=dir+"-link";symlinkSync(dir,link);try{assert.throws(()=>pairing.writePairingDiagnostic({outputPath:resolve(link,"linked")},state,null),/private pairing diagnostic refused/)}finally{unlinkSync(link)} + }finally{await rm(dir,{recursive:true,force:true})} +}); + +test("exact close observer contains asynchronous diagnostic rejection",async()=>{ + let calls=0;class Socket{close(){calls++}} + vm.runInNewContext(`${pairingSource};installPairingCloseObserver()`,{WebSocket:Socket,window:{__barcPairingClose:()=>Promise.reject(new Error("PRIVATE"))}}); + new Socket().close(1008,"invalid live frame");await turn();assert.equal(calls,1); +});