From d2c4b0e0ca9e85b65e36950f315af6d2e871f10b Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Sun, 24 May 2026 21:09:40 -0400 Subject: [PATCH] fix: address 17 open issues across auth, isolation, idempotency, and docs Security & isolation: - BearerAuth ignores client-supplied principal; only the server-side token mapping is authoritative (#34). - Resume replay is scoped to the calling session via EventLog::replayAfterForSession (#44). - Subscriptions with no session_id filter default to the calling session instead of fanning out across sessions (#45). - Artifact fetch/release reject cross-session artifact ids with PERMISSION_DENIED (#46). - LeaseManager records the granting session; tool.invoke rejects leases granted to another session (#47). Correctness: - Idempotent retries now replay the original tool.result/tool.error instead of returning a bare Ack (#35). - ResultChunkAssembler enforces sequence contiguity, terminal-chunk delivery, and duplicate consistency (#36). - CostBudget tolerates small float costs (scientific notation), rejects NaN/Inf, and rejects over-precise budget patterns (#37). - ArtifactPut validates the supplied sha256 digest against payload bytes; client putArtifact() accepts and surfaces digest errors (#38). - ARCPClient ping/putArtifact map Nack responses through ErrorMapper consistently with the other helpers (#39). - StdioTransport drains and decodes a final unterminated frame on EOF instead of dropping it (#48). - InMemoryCredentialStore now reports supportsDurableRevocation() as false; runtime correctly refuses to pair it with a CredentialProvisioner (#49). - JobManager retains terminal jobs for a bounded retention window so session.list_jobs can surface recent history (#50). Documentation: - README event type names match the actual catalog (#40). - Auth guide notes server-authoritative bearer principal (#41). - @throws docs added to ARCPClient, JobContext, ARCPRuntime, and Transport public APIs (#42). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/guides/auth.md | 9 +- src/Auth/BearerAuth.php | 5 +- src/Client/ARCPClient.php | 98 +++++++++++++- src/Client/ResultChunkAssembler.php | 51 ++++++- src/Internal/Runtime/ArtifactDispatcher.php | 34 ++++- src/Internal/Runtime/CredentialLifecycle.php | 28 +++- src/Internal/Runtime/LifecycleHandler.php | 7 +- src/Internal/Runtime/SubscriptionRouter.php | 6 +- .../Runtime/ToolInvocationHandler.php | 46 +++++-- src/Runtime/ARCPRuntime.php | 6 +- src/Runtime/ArtifactStore.php | 31 ++++- src/Runtime/CostBudget.php | 30 ++++- .../Credentials/InMemoryCredentialStore.php | 9 +- src/Runtime/Job.php | 2 + src/Runtime/JobContext.php | 16 ++- src/Runtime/JobManager.php | 37 ++++- src/Runtime/LeaseManager.php | 32 ++++- src/Runtime/SubscriptionManager.php | 8 ++ src/Store/EventLog.php | 88 ++++++++++++ src/Transport/StdioTransport.php | 21 ++- src/Transport/Transport.php | 12 +- tests/Integration/ArtifactTest.php | 123 +++++++++++++++++ tests/Integration/CredentialLifecycleTest.php | 14 ++ tests/Integration/HandshakeTest.php | 16 ++- tests/Integration/JobLifecycleTest.php | 48 +++++-- tests/Integration/ResumeTest.php | 126 ++++++++++++++++++ tests/Integration/StdioTransportTest.php | 25 ++++ tests/Integration/SubscriptionTest.php | 49 +++++++ tests/Support/FakeDurableCredentialStore.php | 59 ++++++++ tests/Unit/AuthTest.php | 11 ++ .../Unit/Client/ResultChunkAssemblerTest.php | 80 +++++++++++ tests/Unit/LeaseManagerTest.php | 23 ++++ tests/Unit/Runtime/CostBudgetTest.php | 43 ++++++ .../InMemoryCredentialStoreTest.php | 6 + 35 files changed, 1141 insertions(+), 60 deletions(-) create mode 100644 tests/Support/FakeDurableCredentialStore.php create mode 100644 tests/Unit/Client/ResultChunkAssemblerTest.php diff --git a/README.md b/README.md index cc50e2e..538e5fd 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ printf("resolved value = %s\n", json_encode($result->value)); ### Consuming events -Iterate the ordered event stream — `log`, `thought`, `tool_call`, `tool_result`, `status`, `metric`, `artifact_ref`, `progress`, `result_chunk` — and optionally acknowledge progress so the runtime can release buffered events early. +Iterate the ordered event stream — `log`, `metric`, `event.emit`, `tool.invoke`, `tool.result`, `job.progress`, `job.result_chunk`, `artifact.ref` — and optionally acknowledge progress so the runtime can release buffered events early. ```php use Arcp\Envelope\Envelope; diff --git a/docs/guides/auth.md b/docs/guides/auth.md index 4820f96..a3e4eb3 100644 --- a/docs/guides/auth.md +++ b/docs/guides/auth.md @@ -30,6 +30,10 @@ $runtime = new ARCPRuntime( ); ``` +`BearerAuth` always resolves the principal from the token → principal +map. The `principal` field a client may set in `PeerInfo` is ignored on +the runtime side — only the server-side mapping is authoritative. + ## Custom verifier Implement `Arcp\Auth\AuthScheme`: @@ -54,8 +58,9 @@ final class HeaderAuth implements AuthScheme ## Where the principal lives After handshake, the runtime stores the resolved principal on -`Session::$principal`. The client stores its own principal from -`PeerInfo`. +`Session::$principal` — server-authoritative, derived from the auth +scheme, not from any client-supplied `PeerInfo`. The client stores its +own principal from `PeerInfo` purely for local logging. ## Sessions, resume, and auth diff --git a/src/Auth/BearerAuth.php b/src/Auth/BearerAuth.php index cb326f7..0a31fe0 100644 --- a/src/Auth/BearerAuth.php +++ b/src/Auth/BearerAuth.php @@ -36,7 +36,8 @@ public function verify(Auth $auth, PeerInfo $client): AuthResult if (!isset($this->tokens[$auth->token])) { return AuthResult::reject('invalid token'); } - $principal = $client->principal ?? $this->tokens[$auth->token]; - return AuthResult::accept($principal); + // Always use the server-side principal mapped to the token; do not + // trust the principal supplied in the untrusted PeerInfo block. + return AuthResult::accept($this->tokens[$auth->token]); } } diff --git a/src/Client/ARCPClient.php b/src/Client/ARCPClient.php index 4dc7f87..2588b39 100644 --- a/src/Client/ARCPClient.php +++ b/src/Client/ARCPClient.php @@ -31,6 +31,8 @@ use Arcp\Messages\Artifacts\ArtifactFetch; use Arcp\Messages\Artifacts\ArtifactPut; use Arcp\Messages\Artifacts\ArtifactRef; +use Arcp\Messages\Artifacts\ArtifactRelease; +use Arcp\Messages\Control\Ack; use Arcp\Messages\Control\Cancel; use Arcp\Messages\Control\Nack; use Arcp\Messages\Control\Ping; @@ -141,6 +143,11 @@ public static function withConfig(ClientConfig $config): self /** * Send `session.open`, await `session.accepted`, and start the read-loop. * + * @throws \Arcp\Errors\UnauthenticatedException when the runtime rejects credentials. + * @throws \Arcp\Errors\UnimplementedException for capability mismatches. + * @throws \Arcp\Errors\ARCPExceptionInterface for other handshake errors. + * @throws \Arcp\Errors\TransportClosedException if the transport drops. + * * @size-check-suppress public BC; mirrors RFC §8.3 session.open shape. */ public function open( @@ -169,6 +176,14 @@ public function open( * * @param array $arguments * + * @throws \Arcp\Errors\ARCPExceptionInterface mapped from `tool.error` + * or correlated `nack` (e.g. `PermissionDeniedException`, + * `BudgetExhaustedException`, `NotFoundException`). + * @throws \Arcp\Errors\DeadlineExceededException when `deadlineSeconds` + * elapses before a terminal response arrives. + * @throws \Arcp\Errors\CancelledException when `$cancellation` fires. + * @throws InvalidArgumentException for unexpected response shapes. + * * @size-check-suppress public BC; tool.invoke options are RFC §10 wire fields. */ public function invokeTool( @@ -208,6 +223,11 @@ public function invokeTool( * @param array $filter * @param \Closure(Envelope): void $onEvent Called with the unwrapped envelope. * + * @throws \Arcp\Errors\PermissionDeniedException when the filter + * crosses session-scope boundaries. + * @throws \Arcp\Errors\ARCPExceptionInterface for other runtime errors. + * @throws InvalidArgumentException for unexpected response shapes. + * * @size-check-suppress public BC; subscribe is the RFC §13 entry-point. */ public function subscribe( @@ -249,7 +269,13 @@ public function unsubscribe(SubscriptionId $id): void } /** + * Page through jobs visible to this session. + * * @param array $filter + * + * @throws \Arcp\Errors\ARCPExceptionInterface for runtime errors mapped + * from a correlated `nack`. + * @throws InvalidArgumentException for unexpected response shapes. */ public function listJobs( array $filter = [], @@ -289,6 +315,13 @@ public function cancelJob( $this->session->transport->send($env); } + /** + * Round-trip a ping/pong heartbeat. + * + * @throws \Arcp\Errors\ARCPExceptionInterface when the runtime + * returns a Nack instead of a Pong. + * @throws InvalidArgumentException for an unexpected response type. + */ public function ping(?string $nonce = null, float $deadlineSeconds = 5.0): Pong { $id = MessageId::random(); @@ -299,29 +332,60 @@ public function ping(?string $nonce = null, float $deadlineSeconds = 5.0): Pong sessionId: $this->session->sessionId, ); $this->session->transport->send($env); - /** @var Pong $resp */ $resp = $this->pending->awaitResponse($id, $deadlineSeconds); + if ($resp instanceof Nack) { + throw $this->errorMapper->raise($resp->error); + } + if (!$resp instanceof Pong) { + throw new InvalidArgumentException('expected pong as ping response'); + } return $resp; } + /** + * Upload an artifact and receive its server-issued reference. + * + * @param string|null $sha256 hex-encoded SHA-256 digest of `$bytes`. + * When supplied, the runtime rejects the upload if the digest does + * not match the decoded payload. + * + * @throws \Arcp\Errors\InvalidArgumentException on digest mismatch or + * malformed payload. + * @throws \Arcp\Errors\ARCPExceptionInterface on other runtime errors. + */ public function putArtifact( string $mediaType, string $bytes, ?int $retentionSeconds = null, + ?string $sha256 = null, ): ArtifactRef { $id = MessageId::random(); $env = new Envelope( id: $id, - payload: new ArtifactPut($mediaType, base64_encode($bytes), $retentionSeconds), + payload: new ArtifactPut($mediaType, base64_encode($bytes), $retentionSeconds, $sha256), timestamp: $this->clock->now(), sessionId: $this->session->sessionId, ); $this->session->transport->send($env); - /** @var ArtifactRef $resp */ $resp = $this->pending->awaitResponse($id, 30.0); + if ($resp instanceof Nack) { + throw $this->errorMapper->raise($resp->error); + } + if (!$resp instanceof ArtifactRef) { + throw new InvalidArgumentException('expected artifact.ref as put response'); + } return $resp; } + /** + * Fetch the bytes of an artifact owned by this session. + * + * @throws \Arcp\Errors\PermissionDeniedException for cross-session ids. + * @throws \Arcp\Errors\NotFoundException if the artifact is unknown + * or has expired. + * @throws \Arcp\Errors\ARCPExceptionInterface for other runtime errors. + * @throws InvalidArgumentException for malformed payload or response. + */ public function fetchArtifact(ArtifactId $artifactId): string { $id = MessageId::random(); @@ -345,6 +409,34 @@ public function fetchArtifact(ArtifactId $artifactId): string : throw new InvalidArgumentException('artifact data not base64'); } + /** + * Release an artifact owned by this session. Returns true if the + * runtime confirmed deletion, false if it was already unknown. + * + * @throws \Arcp\Errors\PermissionDeniedException when the artifact + * belongs to a different session. + * @throws \Arcp\Errors\ARCPExceptionInterface on other runtime errors. + */ + public function releaseArtifact(ArtifactId $artifactId): bool + { + $id = MessageId::random(); + $env = new Envelope( + id: $id, + payload: new ArtifactRelease($artifactId), + timestamp: $this->clock->now(), + sessionId: $this->session->sessionId, + ); + $this->session->transport->send($env); + $resp = $this->pending->awaitResponse($id, 30.0); + if ($resp instanceof Nack) { + throw $this->errorMapper->raise($resp->error); + } + if (!$resp instanceof Ack) { + throw new InvalidArgumentException('expected ack as artifact.release response'); + } + return $resp->note === 'released'; + } + public function close(): void { if ($this->session->state === SessionState::Closed) { diff --git a/src/Client/ResultChunkAssembler.php b/src/Client/ResultChunkAssembler.php index 5459a13..db3e305 100644 --- a/src/Client/ResultChunkAssembler.php +++ b/src/Client/ResultChunkAssembler.php @@ -7,7 +7,12 @@ use Arcp\Errors\InvalidArgumentException; use Arcp\Messages\Execution\ResultChunk; -/** Collects `job.result_chunk` messages by result id and assembles final bytes. */ +/** + * Collects `job.result_chunk` messages by result id and assembles final + * bytes. Enforces sequence contiguity (0..N), terminal-chunk delivery, + * and duplicate consistency so a truncated or out-of-order stream never + * silently produces a corrupted result. + */ final class ResultChunkAssembler { /** @var array> */ @@ -18,6 +23,13 @@ final class ResultChunkAssembler public function push(ResultChunk $chunk): void { + $existing = $this->chunks[$chunk->resultId][$chunk->chunkSeq] ?? null; + if ($existing instanceof ResultChunk && !$this->sameChunkPayload($existing, $chunk)) { + throw new InvalidArgumentException( + 'result_chunk duplicate with conflicting payload', + ['result_id' => $chunk->resultId, 'chunk_seq' => $chunk->chunkSeq], + ); + } $this->chunks[$chunk->resultId][$chunk->chunkSeq] = $chunk; if (!$chunk->more) { $this->complete[$chunk->resultId] = true; @@ -35,7 +47,14 @@ public function assemble(string $resultId): string if ($chunks === []) { throw new InvalidArgumentException('unknown result_id: ' . $resultId); } + if (!isset($this->complete[$resultId])) { + throw new InvalidArgumentException( + 'result_chunk stream incomplete: terminal chunk not yet received', + ['result_id' => $resultId], + ); + } ksort($chunks); + $this->assertContiguous($resultId, $chunks); $out = ''; foreach ($chunks as $chunk) { $out .= $chunk->encoding === 'base64' @@ -45,6 +64,36 @@ public function assemble(string $resultId): string return $out; } + /** @param array $chunks */ + private function assertContiguous(string $resultId, array $chunks): void + { + $expected = 0; + $terminal = null; + foreach ($chunks as $seq => $chunk) { + if ($seq !== $expected) { + throw new InvalidArgumentException( + 'result_chunk sequence not contiguous from 0', + ['result_id' => $resultId, 'expected_seq' => $expected, 'actual_seq' => $seq], + ); + } + ++$expected; + $terminal = $chunk; + } + if ($terminal instanceof ResultChunk && $terminal->more) { + throw new InvalidArgumentException( + 'result_chunk highest sequence has more=true', + ['result_id' => $resultId], + ); + } + } + + private function sameChunkPayload(ResultChunk $a, ResultChunk $b): bool + { + return $a->data === $b->data + && $a->encoding === $b->encoding + && $a->more === $b->more; + } + private function decodeBase64(ResultChunk $chunk): string { $decoded = base64_decode($chunk->data, strict: true); diff --git a/src/Internal/Runtime/ArtifactDispatcher.php b/src/Internal/Runtime/ArtifactDispatcher.php index 3019b27..a0454c4 100644 --- a/src/Internal/Runtime/ArtifactDispatcher.php +++ b/src/Internal/Runtime/ArtifactDispatcher.php @@ -40,6 +40,28 @@ public function put(Session $session, Envelope $env, ArtifactPut $msg): void ); return; } + if ($msg->sha256 !== null) { + $normalized = strtolower(trim($msg->sha256)); + if (preg_match('/^[0-9a-f]{64}$/', $normalized) !== 1) { + $this->lifecycle->nack( + $session, + $env, + 'INVALID_ARGUMENT', + 'artifact.put sha256 must be 64 lowercase hex chars', + ); + return; + } + $computed = hash('sha256', $bytes); + if (!hash_equals($computed, $normalized)) { + $this->lifecycle->nack( + $session, + $env, + 'INVALID_ARGUMENT', + 'artifact.put sha256 does not match payload', + ); + return; + } + } $ref = $this->runtime->artifacts->put( $session, new ArtifactBlob($msg->mediaType, $bytes, $msg->retentionSeconds), @@ -50,13 +72,14 @@ public function put(Session $session, Envelope $env, ArtifactPut $msg): void public function fetch(Session $session, Envelope $env, ArtifactFetch $msg): void { try { - $bytes = $this->runtime->artifacts->fetch($msg->artifactId); + $bytes = $this->runtime->artifacts->fetch($msg->artifactId, $session); + $mediaType = $this->runtime->artifacts->ref($msg->artifactId, $session)->mediaType; } catch (ARCPException $e) { $this->lifecycle->nack($session, $env, $e->code()->value, $e->getMessage()); return; } $put = new ArtifactPut( - mediaType: $this->runtime->artifacts->ref($msg->artifactId)->mediaType, + mediaType: $mediaType, data: base64_encode($bytes), ); $this->runtime->emit($session, $put, ['correlation_id' => $env->id]); @@ -64,7 +87,12 @@ public function fetch(Session $session, Envelope $env, ArtifactFetch $msg): void public function release(Session $session, Envelope $env, ArtifactRelease $msg): void { - $ok = $this->runtime->artifacts->release($msg->artifactId); + try { + $ok = $this->runtime->artifacts->release($msg->artifactId, $session); + } catch (ARCPException $e) { + $this->lifecycle->nack($session, $env, $e->code()->value, $e->getMessage()); + return; + } $this->runtime->emit($session, new Ack($ok ? 'released' : 'unknown'), [ 'correlation_id' => $env->id, ]); diff --git a/src/Internal/Runtime/CredentialLifecycle.php b/src/Internal/Runtime/CredentialLifecycle.php index 47df4f1..f3514df 100644 --- a/src/Internal/Runtime/CredentialLifecycle.php +++ b/src/Internal/Runtime/CredentialLifecycle.php @@ -30,12 +30,15 @@ public function __construct(private ARCPRuntime $runtime) } /** @param array $arguments */ - public function leaseFromArguments(array $arguments, ResolvedTool $resolved): ?LeaseGranted - { + public function leaseFromArguments( + array $arguments, + ResolvedTool $resolved, + ?Session $session = null, + ): ?LeaseGranted { $modelUse = ModelUse::fromInvocationArguments($arguments); $costBudget = CostBudget::fromInvocationArguments($arguments); $leaseArg = $arguments['lease'] ?? null; - $base = $this->baseLease($leaseArg); + $base = $this->baseLease($leaseArg, $session); if ($base instanceof LeaseGranted) { return $this->overlayLease($base, $leaseArg, $modelUse, $costBudget); } @@ -96,13 +99,26 @@ public function revoke(Job $job): void } } - private function baseLease(mixed $leaseArg): ?LeaseGranted + private function baseLease(mixed $leaseArg, ?Session $session): ?LeaseGranted + { + $leaseId = $this->extractLeaseId($leaseArg); + if ($leaseId === null) { + return null; + } + $sessionId = $session?->sessionId; + if ($sessionId !== null) { + return $this->runtime->leases->getForSession($leaseId, $sessionId); + } + return $this->runtime->leases->get($leaseId); + } + + private function extractLeaseId(mixed $leaseArg): ?LeaseId { if (\is_string($leaseArg)) { - return $this->runtime->leases->get(new LeaseId($leaseArg)); + return new LeaseId($leaseArg); } if (\is_array($leaseArg) && isset($leaseArg['lease_id']) && \is_string($leaseArg['lease_id'])) { - return $this->runtime->leases->get(new LeaseId($leaseArg['lease_id'])); + return new LeaseId($leaseArg['lease_id']); } return null; } diff --git a/src/Internal/Runtime/LifecycleHandler.php b/src/Internal/Runtime/LifecycleHandler.php index 647421a..7abf02d 100644 --- a/src/Internal/Runtime/LifecycleHandler.php +++ b/src/Internal/Runtime/LifecycleHandler.php @@ -115,9 +115,14 @@ public function handleResume(Session $session, Envelope $env, Resume $msg): void $this->nack($session, $env, 'UNIMPLEMENTED', 'checkpoint resume deferred (RFC §19)'); return; } + $sessionId = $session->sessionId; + if ($sessionId === null) { + $this->nack($session, $env, 'FAILED_PRECONDITION', 'resume requires an authenticated session'); + return; + } $after = $msg->afterMessageId ?? ''; try { - foreach ($this->runtime->eventLog->replayAfter($after) as $past) { + foreach ($this->runtime->eventLog->replayAfterForSession($after, $sessionId) as $past) { $session->transport->send($past); } } catch (InvalidArgumentException) { diff --git a/src/Internal/Runtime/SubscriptionRouter.php b/src/Internal/Runtime/SubscriptionRouter.php index dc0d1a9..c53e5e6 100644 --- a/src/Internal/Runtime/SubscriptionRouter.php +++ b/src/Internal/Runtime/SubscriptionRouter.php @@ -108,8 +108,12 @@ private function denyOutOfScope(Session $session, Envelope $env): bool private function backfill(Session $session, Subscription $sub, Subscribe $msg): bool { $after = $msg->sinceMessageId ?? ''; + $sessionId = $session->sessionId; try { - foreach ($this->runtime->eventLog->replayAfter($after) as $past) { + $stream = $sessionId instanceof SessionId + ? $this->runtime->eventLog->replayAfterForSession($after, $sessionId) + : $this->runtime->eventLog->replayAfter($after); + foreach ($stream as $past) { if (!$sub->matches($past)) { continue; } diff --git a/src/Internal/Runtime/ToolInvocationHandler.php b/src/Internal/Runtime/ToolInvocationHandler.php index 90cbad4..434112e 100644 --- a/src/Internal/Runtime/ToolInvocationHandler.php +++ b/src/Internal/Runtime/ToolInvocationHandler.php @@ -69,7 +69,15 @@ public function handle(Session $session, Envelope $env, ToolInvoke $msg): void if ($this->handledByIdempotencyReplay($session, $env)) { return; } - $lease = $this->credentials->leaseFromArguments($msg->arguments, $resolved); + try { + $lease = $this->credentials->leaseFromArguments($msg->arguments, $resolved, $session); + } catch (\Arcp\Errors\PermissionDeniedException $e) { + $this->runtime->emit($session, new ToolError(ErrorPayload::fromException($e)), [ + 'correlation_id' => $env->id, + 'trace_id' => $env->traceId, + ]); + return; + } $job = $this->runtime->jobs->start( $session, $env, @@ -104,7 +112,8 @@ private function emitNotFound(Session $session, Envelope $env, string $tool): vo /** * Logical idempotency replay (RFC §6.4). Returns true when the request - * has already been processed and the runtime has emitted a replay Ack. + * has already been processed and the runtime has re-emitted the + * original terminal correlated response for the duplicate. */ private function handledByIdempotencyReplay(Session $session, Envelope $env): bool { @@ -117,10 +126,22 @@ private function handledByIdempotencyReplay(Session $session, Envelope $env): bo if ($prior === null) { return false; } - $this->runtime->emit($session, new Ack('replay'), [ + $original = $this->runtime->eventLog->findByMessageId($prior); + $hints = [ 'correlation_id' => $env->id, 'trace_id' => $env->traceId, - ]); + ]; + if ($original instanceof Envelope) { + if ($original->jobId !== null) { + $hints['job_id'] = $original->jobId; + } + $this->runtime->emit($session, $original->payload, $hints); + } else { + // Fallback: if the original outcome envelope is no longer in the + // log (e.g. retention purged it), still ack the duplicate so + // synchronous callers stop waiting. + $this->runtime->emit($session, new Ack('replay'), $hints); + } return true; } @@ -176,7 +197,7 @@ private function runHandler(ToolJobContextSpec $spec, ToolHandler $handler): voi private function completeJob(Session $session, Envelope $env, Job $job, mixed $value): void { $this->runtime->jobs->transition($job, JobState::Completed); - $this->runtime->emit($session, new ToolResult($value), [ + $outcomeId = $this->runtime->emit($session, new ToolResult($value), [ 'correlation_id' => $env->id, 'job_id' => $job->id, 'trace_id' => $env->traceId, @@ -186,10 +207,10 @@ private function completeJob(Session $session, Envelope $env, Job $job, mixed $v 'trace_id' => $env->traceId, ]); $this->credentials->revoke($job); - $this->rememberIdempotent($session, $env); + $this->rememberIdempotent($session, $env, (string) $outcomeId); } - private function rememberIdempotent(Session $session, Envelope $env): void + private function rememberIdempotent(Session $session, Envelope $env, string $outcomeMessageId): void { $key = $env->idempotencyKey; $principal = $session->principal; @@ -199,7 +220,7 @@ private function rememberIdempotent(Session $session, Envelope $env): void $this->runtime->eventLog->rememberIdempotent(new IdempotencyRecord( $principal, (string) $key, - (string) $env->id, + $outcomeMessageId, $this->runtime->clock->now()->modify('+24 hours'), )); } @@ -226,7 +247,7 @@ private function failJob(ToolJobContextSpec $spec, ErrorPayload $payload): void $env = $spec->env; $job = $spec->job; $this->runtime->jobs->transition($job, JobState::Failed); - $this->runtime->emit($session, new ToolError($payload), [ + $outcomeId = $this->runtime->emit($session, new ToolError($payload), [ 'correlation_id' => $env->id, 'job_id' => $job->id, 'trace_id' => $env->traceId, @@ -236,5 +257,12 @@ private function failJob(ToolJobContextSpec $spec, ErrorPayload $payload): void 'trace_id' => $env->traceId, ]); $this->credentials->revoke($job); + // Retryable failures intentionally do not consume the idempotency + // key — the client is expected to retry. Non-retryable failures + // (including the default `null` case) are part of the recorded + // outcome and must replay identically on duplicate submission. + if ($payload->retryable !== true) { + $this->rememberIdempotent($session, $env, (string) $outcomeId); + } } } diff --git a/src/Runtime/ARCPRuntime.php b/src/Runtime/ARCPRuntime.php index 17c7e81..8ae1804 100644 --- a/src/Runtime/ARCPRuntime.php +++ b/src/Runtime/ARCPRuntime.php @@ -102,7 +102,7 @@ public function __construct( $this->leases = new LeaseManager($this->clock); $this->artifacts = new ArtifactStore($this->clock); $this->subscriptions = new SubscriptionManager($this->serializer); - $this->jobs = new JobManager(); + $this->jobs = new JobManager($this->clock); $this->credentials = $credentialStore ?? new InMemoryCredentialStore(); if ( $this->credentialProvisioner instanceof CredentialProvisioner @@ -167,6 +167,10 @@ public function registerToolVersion(string $name, string $version, ToolHandler $ $this->tools[$ref->name][$ref->version ?? ''] = $handler; } + /** + * @throws \Arcp\Errors\AgentVersionNotAvailableException if the + * requested `(name, version)` pair has not been registered. + */ public function setDefaultToolVersion(string $name, string $version): void { $ref = new AgentRef($name, $version); diff --git a/src/Runtime/ArtifactStore.php b/src/Runtime/ArtifactStore.php index ad77c26..cf4666c 100644 --- a/src/Runtime/ArtifactStore.php +++ b/src/Runtime/ArtifactStore.php @@ -8,6 +8,7 @@ use Arcp\Clock\SystemClock; use Arcp\Errors\InvalidArgumentException; use Arcp\Errors\NotFoundException; +use Arcp\Errors\PermissionDeniedException; use Arcp\Ids\ArtifactId; use Arcp\Messages\Artifacts\ArtifactRef; @@ -63,10 +64,11 @@ public function put(Session $session, ArtifactBlob $blob): ArtifactRef return $ref; } - public function fetch(ArtifactId $id): string + public function fetch(ArtifactId $id, ?Session $session = null): string { $row = $this->artifacts[(string) $id] ?? throw new NotFoundException(\sprintf('artifact %s not found', $id)); + $this->assertOwnership($id, $row, $session); $expiresAt = $row['ref']->expiresAt; if ($expiresAt !== null && $expiresAt <= $this->clock->now()) { unset($this->artifacts[(string) $id]); @@ -75,22 +77,43 @@ public function fetch(ArtifactId $id): string return $row['bytes']; } - public function ref(ArtifactId $id): ArtifactRef + public function ref(ArtifactId $id, ?Session $session = null): ArtifactRef { $row = $this->artifacts[(string) $id] ?? throw new NotFoundException(\sprintf('artifact %s not found', $id)); + $this->assertOwnership($id, $row, $session); return $row['ref']; } - public function release(ArtifactId $id): bool + public function release(ArtifactId $id, ?Session $session = null): bool { - if (!isset($this->artifacts[(string) $id])) { + $row = $this->artifacts[(string) $id] ?? null; + if ($row === null) { return false; } + $this->assertOwnership($id, $row, $session); unset($this->artifacts[(string) $id]); return true; } + /** + * @param StoredArtifact $row + */ + private function assertOwnership(ArtifactId $id, array $row, ?Session $session): void + { + if ($session === null) { + return; // Trusted internal caller (no session context). + } + $sessionId = $session->sessionId; + if ($sessionId === null || (string) $sessionId !== $row['session_key']) { + throw new PermissionDeniedException( + permission: 'artifact.access', + resource: (string) $id, + message: \sprintf('artifact %s does not belong to this session', $id), + ); + } + } + /** Remove every expired artifact. */ public function sweep(): int { diff --git a/src/Runtime/CostBudget.php b/src/Runtime/CostBudget.php index 75b6095..54f0eec 100644 --- a/src/Runtime/CostBudget.php +++ b/src/Runtime/CostBudget.php @@ -61,7 +61,13 @@ public function consume(string $metricName, int|float $value, string $unit): ?st if (!isset($this->remaining[$unit]) || $value < 0) { return null; } - $this->remaining[$unit] -= self::decimalToScaled((string) $value); + if (\is_float($value) && (!is_finite($value) || is_nan($value))) { + throw new InvalidArgumentException('cost metric value must be finite', [ + 'metric' => $metricName, + 'value' => $value, + ]); + } + $this->remaining[$unit] -= self::numericToScaled($value); if ($this->remaining[$unit] <= 0) { throw new BudgetExhaustedException($unit, $this->format($this->remaining[$unit])); } @@ -107,13 +113,33 @@ private static function parse(string $pattern): array return [$m[1], self::decimalToScaled($m[2])]; } + /** + * Normalize an int|float metric value into the fixed six-decimal + * representation. PHP stringifies small floats as scientific notation + * (e.g. `0.000001` → `1.0E-6`), which the decimal parser rejects; + * format explicitly to side-step that. + */ + private static function numericToScaled(int|float $value): int + { + if (\is_int($value)) { + return self::decimalToScaled((string) $value); + } + return self::decimalToScaled(rtrim(rtrim(\sprintf('%.6F', $value), '0'), '.')); + } + private static function decimalToScaled(string $decimal): int { if (preg_match('/^(\d+)(?:\.(\d+))?$/', $decimal, $m) !== 1) { throw new InvalidArgumentException('invalid decimal amount: ' . $decimal); } + $rawFraction = $m[2] ?? ''; + if (\strlen($rawFraction) > 6 && rtrim(substr($rawFraction, 6), '0') !== '') { + throw new InvalidArgumentException( + 'decimal amount exceeds six-place precision: ' . $decimal, + ); + } $whole = (int) $m[1] * self::SCALE; - $fraction = substr(str_pad($m[2] ?? '', 6, '0'), 0, 6); + $fraction = substr(str_pad($rawFraction, 6, '0'), 0, 6); return $whole + (int) $fraction; } diff --git a/src/Runtime/Credentials/InMemoryCredentialStore.php b/src/Runtime/Credentials/InMemoryCredentialStore.php index cc7c37f..9babd11 100644 --- a/src/Runtime/Credentials/InMemoryCredentialStore.php +++ b/src/Runtime/Credentials/InMemoryCredentialStore.php @@ -44,9 +44,16 @@ public function outstanding(): array return $out; } + /** + * Process-local storage cannot survive a restart, so revocation + * decisions recorded here would be lost. Returning false prevents + * {@see \Arcp\Runtime\ARCPRuntime} from accepting this store alongside + * a {@see CredentialProvisioner}, which expects a durable backing + * store. + */ #[\Override] public function supportsDurableRevocation(): bool { - return true; + return false; } } diff --git a/src/Runtime/Job.php b/src/Runtime/Job.php index 1b33e14..163a9c3 100644 --- a/src/Runtime/Job.php +++ b/src/Runtime/Job.php @@ -20,6 +20,8 @@ final class Job public JobState $state = JobState::Accepted; public int $heartbeatSequence = 0; public readonly \DateTimeImmutable $createdAt; + /** Wall-clock timestamp of the most recent terminal transition, if any. */ + public ?\DateTimeImmutable $terminatedAt = null; /** @var array */ private array $resultChunkSeq = []; diff --git a/src/Runtime/JobContext.php b/src/Runtime/JobContext.php index 5abde27..38e0e8e 100644 --- a/src/Runtime/JobContext.php +++ b/src/Runtime/JobContext.php @@ -177,6 +177,10 @@ private function makeCloser(StreamId $sid, int &$sequence): \Closure * @param array $responseSchema * @param array|null $default * + * @throws \Arcp\Errors\DeadlineExceededException when `$expiresAt` + * elapses without a response and no `$default` is provided. + * @throws \Arcp\Errors\CancelledException when `$cancellation` fires. + * * @size-check-suppress public BC; mirrors RFC §12.1 human.input.request. */ public function requestHumanInput( @@ -216,6 +220,10 @@ public function requestHumanInput( /** * @param list $options * + * @throws \Arcp\Errors\DeadlineExceededException when `$expiresAt` + * elapses before a choice arrives. + * @throws \Arcp\Errors\CancelledException when `$cancellation` fires. + * * @size-check-suppress public BC; mirrors RFC §12.1 human.choice.request. */ public function requestHumanChoice( @@ -240,6 +248,12 @@ public function requestHumanChoice( } /** + * @throws \Arcp\Errors\PermissionDeniedException when the client + * denies the permission request. + * @throws \Arcp\Errors\DeadlineExceededException when the request + * times out before any decision arrives. + * @throws \Arcp\Errors\CancelledException when `$cancellation` fires. + * * @size-check-suppress public BC; protocol-level permission request fields. */ public function requestPermission( @@ -309,7 +323,7 @@ private function registerGrantedLease( operation: $spec->operation, expiresAt: $expiresAt, ); - $this->runtime->leases->register($granted); + $this->runtime->leases->register($granted, $this->session->sessionId); $this->runtime->emit($this->session, $granted, [ 'job_id' => $this->jobId, 'trace_id' => $this->traceId, diff --git a/src/Runtime/JobManager.php b/src/Runtime/JobManager.php index 5a9c08d..a060d13 100644 --- a/src/Runtime/JobManager.php +++ b/src/Runtime/JobManager.php @@ -6,6 +6,8 @@ use Amp\CancelledException; use Amp\DeferredCancellation; +use Arcp\Clock\ClockInterface; +use Arcp\Clock\SystemClock; use Arcp\Envelope\Envelope; use Arcp\Errors\NotFoundException; use Arcp\Ids\JobId; @@ -21,6 +23,18 @@ final class JobManager /** @var array */ private array $jobs = []; + /** + * @param int $terminalRetentionSeconds how long terminal jobs remain + * visible to `session.list_jobs`. Operators rely on the list path + * to reconstruct recent history; we keep terminal entries around + * for a bounded window so cursors keep working past completion. + */ + public function __construct( + private readonly ClockInterface $clock = new SystemClock(), + public readonly int $terminalRetentionSeconds = 900, + ) { + } + public function start( Session $session, Envelope $invocation, @@ -58,7 +72,11 @@ public function transition(Job $job, JobState $next): void { $job->state = $next; if ($next->isTerminal()) { - unset($this->jobs[(string) $job->id]); + $job->terminatedAt = $this->clock->now(); + // Retain terminal jobs in the map; sweep evicts them once the + // retention window has elapsed so `session.list_jobs` can + // surface recent history (RFC §10.5). + $this->sweepTerminal(); } } @@ -75,11 +93,28 @@ public function cancel(JobId $id, string $reason = 'user_aborted'): bool /** @return list */ public function all(): array { + $this->sweepTerminal(); return array_values($this->jobs); } public function count(): int { + $this->sweepTerminal(); return \count($this->jobs); } + + /** Drop terminal jobs whose retention window has elapsed. */ + private function sweepTerminal(): void + { + $now = $this->clock->now(); + foreach ($this->jobs as $key => $job) { + if (!$job->state->isTerminal() || !$job->terminatedAt instanceof \DateTimeImmutable) { + continue; + } + $expires = $job->terminatedAt->modify('+' . $this->terminalRetentionSeconds . ' seconds'); + if ($expires <= $now) { + unset($this->jobs[$key]); + } + } + } } diff --git a/src/Runtime/LeaseManager.php b/src/Runtime/LeaseManager.php index 255e64b..2e7848e 100644 --- a/src/Runtime/LeaseManager.php +++ b/src/Runtime/LeaseManager.php @@ -12,6 +12,7 @@ use Arcp\Errors\NotFoundException; use Arcp\Errors\PermissionDeniedException; use Arcp\Ids\LeaseId; +use Arcp\Ids\SessionId; use Arcp\Messages\Permissions\LeaseGranted; use Arcp\Messages\Permissions\LeaseRevoked; @@ -24,6 +25,9 @@ final class LeaseManager /** @var array */ private array $byId = []; + /** @var array lease id → granting session id */ + private array $owners = []; + /** @var array revoked lease id → reason */ private array $revoked = []; @@ -31,9 +35,12 @@ public function __construct(private readonly ClockInterface $clock = new SystemC { } - public function register(LeaseGranted $lease): void + public function register(LeaseGranted $lease, ?SessionId $sessionId = null): void { $this->byId[(string) $lease->leaseId] = $lease; + if ($sessionId instanceof SessionId) { + $this->owners[(string) $lease->leaseId] = (string) $sessionId; + } } public function get(LeaseId $id): LeaseGranted @@ -50,6 +57,28 @@ public function get(LeaseId $id): LeaseGranted return $lease; } + /** + * Like {@see get()}, but refuses to return a lease that was granted to + * a different session. Returns the lease only when the requesting + * session is the registered owner (or when the lease has no recorded + * owner, for legacy registrations). + * + * @throws PermissionDeniedException when the lease belongs to another session. + */ + public function getForSession(LeaseId $id, SessionId $sessionId): LeaseGranted + { + $lease = $this->get($id); + $owner = $this->owners[(string) $id] ?? null; + if ($owner !== null && $owner !== (string) $sessionId) { + throw new PermissionDeniedException( + permission: 'lease.use', + resource: (string) $id, + message: \sprintf('lease %s does not belong to this session', $id), + ); + } + return $lease; + } + public function ensureUsable(LeaseId $id, LeaseScope $scope): LeaseGranted { $lease = $this->get($id); @@ -129,6 +158,7 @@ public function revoke(LeaseId $id, string $reason = ''): LeaseRevoked if (isset($this->byId[$key])) { unset($this->byId[$key]); } + unset($this->owners[$key]); $this->revoked[$key] = $reason; return new LeaseRevoked($id, $reason); } diff --git a/src/Runtime/SubscriptionManager.php b/src/Runtime/SubscriptionManager.php index d7914cc..5851535 100644 --- a/src/Runtime/SubscriptionManager.php +++ b/src/Runtime/SubscriptionManager.php @@ -8,6 +8,7 @@ use Arcp\Envelope\Priority; use Arcp\Errors\InvalidArgumentException; use Arcp\Ids\MessageId; +use Arcp\Ids\SessionId; use Arcp\Ids\SubscriptionId; use Arcp\Json\EnvelopeSerializer; use Arcp\Messages\Subscriptions\Subscribe; @@ -31,6 +32,13 @@ public function __construct(private readonly EnvelopeSerializer $serializer) public function compile(Session $session, Subscribe $msg): Subscription { $sessionIds = $this->extractStringList($msg->filter, 'session_id'); + // Default the session_id constraint to the calling session if no + // filter was supplied. Without this, an empty list means "match any + // session" and a subscriber would observe every other session's + // envelopes (RFC §13 — subscriptions are session-scoped). + if ($sessionIds === [] && $session->sessionId instanceof SessionId) { + $sessionIds = [(string) $session->sessionId]; + } $traceIds = $this->extractStringList($msg->filter, 'trace_id'); $jobIds = $this->extractStringList($msg->filter, 'job_id'); $streamIds = $this->extractStringList($msg->filter, 'stream_id'); diff --git a/src/Store/EventLog.php b/src/Store/EventLog.php index 8817b2b..8915fcb 100644 --- a/src/Store/EventLog.php +++ b/src/Store/EventLog.php @@ -113,6 +113,23 @@ public function hasMessageId(string $messageId): bool return $stmt->fetchColumn() !== false; } + /** Fetch a single envelope by message id, or null if unknown. */ + public function findByMessageId(string $messageId): ?Envelope + { + $stmt = $this->pdo->prepare( + 'SELECT payload_json FROM events WHERE message_id = :id LIMIT 1', + ); + $stmt->execute([':id' => $messageId]); + $json = $stmt->fetchColumn(); + if ($json === false) { + return null; + } + if (!\is_string($json)) { + throw new InternalException('event log row has non-string payload_json'); + } + return $this->serializer->decode($json); + } + /** * Replay envelopes after `$afterMessageId` in insertion order. Pass an * empty string to start from the beginning. @@ -131,6 +148,77 @@ public function replayAfter(string $afterMessageId, ?int $limit = null): iterabl } } + /** + * Replay envelopes after `$afterMessageId` scoped to a single session. + * This is the resume primitive: each session may only see its own + * envelopes (RFC §19 invariant: only resume sessions under the same + * authenticated principal). + * + * If `$afterMessageId` is non-empty and references an envelope that + * does not belong to `$sessionId`, throws `InvalidArgumentException`. + * + * @return iterable + */ + public function replayAfterForSession( + string $afterMessageId, + SessionId $sessionId, + ?int $limit = null, + ): iterable { + if ($afterMessageId !== '') { + $rowSessionId = $this->sessionIdFor($afterMessageId); + if ($rowSessionId !== (string) $sessionId) { + throw new InvalidArgumentException( + 'after_message_id does not belong to the requesting session', + ['after_message_id' => $afterMessageId], + ); + } + } + $startRowId = $afterMessageId === '' ? 0 : $this->rowIdFor($afterMessageId); + $stmt = $this->prepareReplayQueryForSession($startRowId, (string) $sessionId, $limit); + while (($json = $stmt->fetchColumn()) !== false) { + if (!\is_string($json)) { + throw new InternalException('event log row has non-string payload_json'); + } + yield $this->serializer->decode($json); + } + } + + private function sessionIdFor(string $messageId): ?string + { + $stmt = $this->pdo->prepare('SELECT session_id FROM events WHERE message_id = :id LIMIT 1'); + $stmt->execute([':id' => $messageId]); + /** @var string|false|null $value */ + $value = $stmt->fetchColumn(); + if ($value === false) { + throw new InvalidArgumentException( + 'after_message_id not present in log', + ['after_message_id' => $messageId], + ); + } + return $value; + } + + private function prepareReplayQueryForSession( + int $startRowId, + string $sessionId, + ?int $limit, + ): \PDOStatement { + $sql = 'SELECT payload_json FROM events + WHERE rowid > :rowid AND session_id = :session_id + ORDER BY rowid ASC'; + if ($limit !== null) { + $sql .= ' LIMIT :limit'; + } + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':rowid', $startRowId, \PDO::PARAM_INT); + $stmt->bindValue(':session_id', $sessionId, \PDO::PARAM_STR); + if ($limit !== null) { + $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); + } + $stmt->execute(); + return $stmt; + } + private function rowIdFor(string $messageId): int { $stmt = $this->pdo->prepare('SELECT rowid FROM events WHERE message_id = :id LIMIT 1'); diff --git a/src/Transport/StdioTransport.php b/src/Transport/StdioTransport.php index 5b9da6b..bc54649 100644 --- a/src/Transport/StdioTransport.php +++ b/src/Transport/StdioTransport.php @@ -83,20 +83,33 @@ public function receive(?Cancellation $cancellation = null): ?Envelope return $this->serializer->decode($line); } if ($this->closed) { - return null; + return $this->drainResidualBuffer(); } $chunk = $this->reader->read($cancellation); if ($chunk === null) { $this->closed = true; - if ($this->readBuffer === '') { - return null; - } continue; } $this->readBuffer .= $chunk; } } + /** + * Decode and return any unterminated frame still in the buffer at EOF. + * A strict-NDJSON peer that closes its stream without a final newline + * would otherwise lose its last envelope (typically tool.result or + * session.close). + */ + private function drainResidualBuffer(): ?Envelope + { + $residual = rtrim($this->readBuffer, "\r"); + $this->readBuffer = ''; + if ($residual === '') { + return null; + } + return $this->serializer->decode($residual); + } + #[\Override] public function close(): void { diff --git a/src/Transport/Transport.php b/src/Transport/Transport.php index 61d5d0d..5ad5707 100644 --- a/src/Transport/Transport.php +++ b/src/Transport/Transport.php @@ -18,13 +18,21 @@ interface Transport { /** - * Push `$env` onto the wire. Throws on transport errors. + * Push `$env` onto the wire. + * + * @throws \Arcp\Errors\TransportClosedException when the transport is + * no longer writable. + * @throws \Amp\CancelledException when `$cancellation` fires before + * the write completes. */ public function send(Envelope $env, ?Cancellation $cancellation = null): void; /** * Pull the next envelope. Returns `null` if the peer closed cleanly. - * Throws on transport errors or cancellation. + * + * @throws \Arcp\Errors\TransportClosedException on unexpected + * transport failure (distinct from a clean close, which returns null). + * @throws \Amp\CancelledException when `$cancellation` fires. */ public function receive(?Cancellation $cancellation = null): ?Envelope; diff --git a/tests/Integration/ArtifactTest.php b/tests/Integration/ArtifactTest.php index 8a89b74..add5920 100644 --- a/tests/Integration/ArtifactTest.php +++ b/tests/Integration/ArtifactTest.php @@ -8,6 +8,7 @@ use Arcp\Auth\NoneAuth; use Arcp\Client\ARCPClient; use Arcp\Clock\FakeClock; +use Arcp\Errors\PermissionDeniedException; use Arcp\Messages\Session\Auth; use Arcp\Messages\Session\Capabilities; use Arcp\Messages\Session\PeerInfo; @@ -37,6 +38,128 @@ public function testPutFetchReleaseRoundTrip(): void $serverFuture->await(); } + public function testPutWithMatchingSha256Succeeds(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + [$serverT, $clientT] = MemoryTransport::pair(); + $serverFuture = $runtime->serveAsync($serverT); + $client = new ARCPClient($clientT); + $client->open(Auth::none(), new PeerInfo('cli', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + + $bytes = 'verified-content'; + $digest = hash('sha256', $bytes); + $ref = $client->putArtifact('text/plain', $bytes, sha256: $digest); + self::assertSame($digest, $ref->sha256); + + $client->close(); + $serverFuture->await(); + } + + public function testPutWithMismatchedSha256IsRejected(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + [$serverT, $clientT] = MemoryTransport::pair(); + $serverFuture = $runtime->serveAsync($serverT); + $client = new ARCPClient($clientT); + $client->open(Auth::none(), new PeerInfo('cli', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + + $caught = null; + try { + $client->putArtifact( + 'text/plain', + 'real-content', + sha256: hash('sha256', 'different-content'), + ); + } catch (\Arcp\Errors\InvalidArgumentException $e) { + $caught = $e; + } + self::assertInstanceOf(\Arcp\Errors\InvalidArgumentException::class, $caught); + self::assertStringContainsString('sha256', $caught->getMessage()); + self::assertSame(0, $runtime->artifacts->count()); + + $client->close(); + $serverFuture->await(); + } + + public function testPutWithMalformedSha256IsRejected(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + [$serverT, $clientT] = MemoryTransport::pair(); + $serverFuture = $runtime->serveAsync($serverT); + $client = new ARCPClient($clientT); + $client->open(Auth::none(), new PeerInfo('cli', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + + $caught = null; + try { + $client->putArtifact('text/plain', 'real-content', sha256: 'not-a-real-hex-digest'); + } catch (\Arcp\Errors\InvalidArgumentException $e) { + $caught = $e; + } + self::assertInstanceOf(\Arcp\Errors\InvalidArgumentException::class, $caught); + + $client->close(); + $serverFuture->await(); + } + + public function testCrossSessionFetchIsDenied(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + [$serverTA, $clientTA] = MemoryTransport::pair(); + [$serverTB, $clientTB] = MemoryTransport::pair(); + $serverFutureA = $runtime->serveAsync($serverTA); + $serverFutureB = $runtime->serveAsync($serverTB); + $clientA = new ARCPClient($clientTA); + $clientB = new ARCPClient($clientTB); + $clientA->open(Auth::none(), new PeerInfo('cli-a', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + $clientB->open(Auth::none(), new PeerInfo('cli-b', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + + $ref = $clientA->putArtifact('text/plain', 'secret'); + + $caught = null; + try { + $clientB->fetchArtifact($ref->artifactId); + } catch (PermissionDeniedException $e) { + $caught = $e; + } + self::assertInstanceOf(PermissionDeniedException::class, $caught); + + $clientA->close(); + $clientB->close(); + $serverFutureA->await(); + $serverFutureB->await(); + } + + public function testCrossSessionReleaseIsDenied(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + [$serverTA, $clientTA] = MemoryTransport::pair(); + [$serverTB, $clientTB] = MemoryTransport::pair(); + $serverFutureA = $runtime->serveAsync($serverTA); + $serverFutureB = $runtime->serveAsync($serverTB); + $clientA = new ARCPClient($clientTA); + $clientB = new ARCPClient($clientTB); + $clientA->open(Auth::none(), new PeerInfo('cli-a', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + $clientB->open(Auth::none(), new PeerInfo('cli-b', '0.1'), new Capabilities(artifacts: true, anonymous: true)); + + $ref = $clientA->putArtifact('text/plain', 'secret'); + self::assertSame(1, $runtime->artifacts->count()); + + $caught = null; + try { + $clientB->releaseArtifact($ref->artifactId); + } catch (PermissionDeniedException $e) { + $caught = $e; + } + self::assertInstanceOf(PermissionDeniedException::class, $caught); + // Artifact is still around for the owner. + self::assertSame(1, $runtime->artifacts->count()); + + $clientA->close(); + $clientB->close(); + $serverFutureA->await(); + $serverFutureB->await(); + } + public function testRetentionSweepRemovesExpiredArtifacts(): void { $clock = new FakeClock(new \DateTimeImmutable('2026-05-09T12:00:00Z')); diff --git a/tests/Integration/CredentialLifecycleTest.php b/tests/Integration/CredentialLifecycleTest.php index e182b72..e16599e 100644 --- a/tests/Integration/CredentialLifecycleTest.php +++ b/tests/Integration/CredentialLifecycleTest.php @@ -27,6 +27,7 @@ use Arcp\Runtime\Credentials\InMemoryCredentialProvisioner; use Arcp\Runtime\JobContext; use Arcp\Runtime\ToolHandler; +use Arcp\Tests\Support\FakeDurableCredentialStore; use Arcp\Transport\MemoryTransport; use Arcp\Transport\Transport; use PHPUnit\Framework\TestCase; @@ -188,6 +189,18 @@ public function testSubscribersSeeRedactedAcceptedCredentials(): void $serverFuture->await(); } + public function testRuntimeRejectsInMemoryCredentialStoreWithProvisioner(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('durable revocation'); + new ARCPRuntime( + authRouter: new AuthRouter([new NoneAuth()]), + credentialProvisioner: new InMemoryCredentialProvisioner(), + // No credentialStore given → default InMemoryCredentialStore, + // which now (correctly) reports no durable revocation. + ); + } + /** * @return array{0: ARCPRuntime, 1: ARCPClient, 2: RecordingTransport, 3: \Amp\Future} */ @@ -196,6 +209,7 @@ private function runtimeClient(InMemoryCredentialProvisioner $provisioner): arra $runtime = new ARCPRuntime( authRouter: new AuthRouter([new NoneAuth()]), credentialProvisioner: $provisioner, + credentialStore: new FakeDurableCredentialStore(), ); [$serverT, $clientT] = MemoryTransport::pair(); $recording = new RecordingTransport($clientT); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index c04b3d4..fcb2862 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -30,17 +30,29 @@ public function testBearerHandshakeSucceeds(): void $client = new ARCPClient($clientT); $accepted = $client->open( Auth::bearer('t-good'), - new PeerInfo('test-cli', '0.1', principal: 'alice@example.com'), + new PeerInfo('test-cli', '0.1'), new Capabilities(streaming: true, humanInput: true), ); self::assertNotEmpty((string) $accepted->sessionId); - self::assertSame('alice@example.com', $client->session->principal); $client->close(); $serverFuture->await(); } + public function testBearerTokenIgnoresClientSuppliedPrincipal(): void + { + // Server-side regression: even when the client claims a different + // principal in PeerInfo, BearerAuth must use the token-mapped one. + $scheme = new BearerAuth(['t-good' => 'alice']); + $result = $scheme->verify( + Auth::bearer('t-good'), + new PeerInfo('test-cli', '0.1', principal: 'mallory@example.com'), + ); + self::assertTrue($result->accepted); + self::assertSame('alice', $result->principal); + } + public function testBearerWithBadTokenIsRejected(): void { $runtime = new ARCPRuntime( diff --git a/tests/Integration/JobLifecycleTest.php b/tests/Integration/JobLifecycleTest.php index e450010..40ee6ba 100644 --- a/tests/Integration/JobLifecycleTest.php +++ b/tests/Integration/JobLifecycleTest.php @@ -15,7 +15,6 @@ use Arcp\Client\ARCPClient; use Arcp\Errors\AgentVersionNotAvailableException; use Arcp\Errors\BudgetExhaustedException; -use Arcp\Errors\DeadlineExceededException; use Arcp\Errors\InvalidArgumentException; use Arcp\Errors\NotFoundException; use Arcp\Ids\IdempotencyKey; @@ -121,17 +120,10 @@ public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancell $first = $client->invokeTool('once', [], idempotencyKey: $key); self::assertSame(['ran' => 1], $first->value); - // Second call with the same idempotency key: runtime returns ack - // (not a new execution). The client's invoke contract waits for - // the next response, which on replay is an ack — we don't assert - // a value, only that no second execution happened. - try { - $client->invokeTool('once', [], deadlineSeconds: 0.5, idempotencyKey: $key); - } catch (DeadlineExceededException) { - // Expected: ack is not a ToolResult, so awaitResponse times out. - } catch (InvalidArgumentException) { - // Same expected outcome. - } + // Second call with the same idempotency key: runtime replays the + // original terminal ToolResult so the client sees the same value. + $second = $client->invokeTool('once', [], idempotencyKey: $key); + self::assertSame(['ran' => 1], $second->value); self::assertSame(1, $count, 'idempotency cache must prevent re-execution'); $client->close(); @@ -234,6 +226,38 @@ public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancell $serverFuture->await(); } + public function testListJobsReturnsCompletedJobsWithinRetentionWindow(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + $runtime->registerTool('fast', new class () implements ToolHandler { + #[\Override] + public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancellation = null): mixed + { + return ['ok' => true]; + } + }); + [$serverT, $clientT] = MemoryTransport::pair(); + $serverFuture = $runtime->serveAsync($serverT); + $client = new ARCPClient($clientT); + $client->open(Auth::none(), new PeerInfo('cli', '0.1', principal: 'alice'), new Capabilities(anonymous: true)); + + // Run two short jobs that complete immediately. With the pre-fix + // behavior, these would vanish from list_jobs the moment they + // transitioned to `completed`. + $client->invokeTool('fast'); + $client->invokeTool('fast'); + + $page = $client->listJobs(['status' => ['completed']]); + self::assertGreaterThanOrEqual(2, \count($page->jobs)); + foreach ($page->jobs as $entry) { + self::assertSame('completed', $entry['status'] ?? null); + self::assertSame('fast', $entry['agent'] ?? null); + } + + $client->close(); + $serverFuture->await(); + } + public function testResultChunksAreAssembledByClient(): void { $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); diff --git a/tests/Integration/ResumeTest.php b/tests/Integration/ResumeTest.php index 4b8f590..426720b 100644 --- a/tests/Integration/ResumeTest.php +++ b/tests/Integration/ResumeTest.php @@ -4,13 +4,23 @@ namespace Arcp\Tests\Integration; +use Arcp\Auth\AuthRouter; +use Arcp\Auth\NoneAuth; +use Arcp\Client\ARCPClient; use Arcp\Envelope\Envelope; use Arcp\Envelope\MessageCatalog; +use Arcp\Errors\InvalidArgumentException; use Arcp\Ids\MessageId; use Arcp\Ids\SessionId; use Arcp\Json\EnvelopeSerializer; +use Arcp\Messages\Control\Resume; +use Arcp\Messages\Session\Auth; +use Arcp\Messages\Session\Capabilities; +use Arcp\Messages\Session\PeerInfo; use Arcp\Messages\Telemetry\EventEmit; +use Arcp\Runtime\ARCPRuntime; use Arcp\Store\EventLog; +use Arcp\Transport\MemoryTransport; use PHPUnit\Framework\TestCase; /** @@ -60,4 +70,120 @@ public function testEventLogReplayProducesDeterministicOrder(): void } self::assertSame($replay1, $replay3); } + + public function testReplayAfterForSessionScopedToOneSession(): void + { + $registry = MessageCatalog::create(); + $serializer = new EnvelopeSerializer($registry); + $log = EventLog::inMemory($serializer); + + $sessA = new SessionId('sess_a'); + $sessB = new SessionId('sess_b'); + + $i = 0; + foreach ([$sessA, $sessB, $sessA, $sessB, $sessA] as $sid) { + ++$i; + $log->append(new Envelope( + id: new MessageId('msg_' . $i), + payload: new EventEmit('demo', ['n' => $i]), + timestamp: new \DateTimeImmutable('now'), + sessionId: $sid, + )); + } + + $aReplay = []; + foreach ($log->replayAfterForSession('', $sessA) as $env) { + $aReplay[] = (string) $env->id; + } + self::assertSame(['msg_1', 'msg_3', 'msg_5'], $aReplay); + + $bReplay = []; + foreach ($log->replayAfterForSession('', $sessB) as $past) { + $bReplay[] = (string) $past->id; + } + self::assertSame(['msg_2', 'msg_4'], $bReplay); + } + + public function testReplayAfterForSessionRejectsCrossSessionAfterId(): void + { + $registry = MessageCatalog::create(); + $serializer = new EnvelopeSerializer($registry); + $log = EventLog::inMemory($serializer); + + $sessA = new SessionId('sess_a'); + $sessB = new SessionId('sess_b'); + $log->append(new Envelope( + id: new MessageId('m_a1'), + payload: new EventEmit('demo'), + timestamp: new \DateTimeImmutable('now'), + sessionId: $sessA, + )); + + $this->expectException(InvalidArgumentException::class); + // Drain the generator; the exception fires on the prelude check. + iterator_to_array($log->replayAfterForSession('m_a1', $sessB)); + } + + public function testResumeOnlyReplaysCallingSessionEnvelopes(): void + { + $runtime = new ARCPRuntime( + authRouter: new AuthRouter([new NoneAuth('public')]), + ); + [$serverTA, $clientTA] = MemoryTransport::pair(); + [$serverTB, $clientTB] = MemoryTransport::pair(); + $serverFutureA = $runtime->serveAsync($serverTA); + $serverFutureB = $runtime->serveAsync($serverTB); + + $clientA = new ARCPClient($clientTA); + $clientB = new ARCPClient($clientTB); + $clientA->open(Auth::none(), new PeerInfo('cli-a', '0.1'), new Capabilities(anonymous: true)); + $clientB->open(Auth::none(), new PeerInfo('cli-b', '0.1'), new Capabilities(anonymous: true)); + + // Both sessions push some events that get recorded in the event log. + $clientA->ping(); + $clientB->ping(); + $clientA->ping(); + + // Session A asks for a full replay from the beginning. The bug + // pre-fix would forward session B's envelopes too. + $resumeEnv = new Envelope( + id: MessageId::random(), + payload: new Resume(afterMessageId: ''), + timestamp: new \DateTimeImmutable('now'), + sessionId: $clientA->session->sessionId, + ); + $clientTA->send($resumeEnv); + + $foreignSessionId = (string) $clientB->session->sessionId; + $sawForeign = false; + $sawAck = false; + $loops = 0; + while (!$sawAck && $loops < 200) { + ++$loops; + $env = $clientTA->receive(); + if (!$env instanceof Envelope) { + break; + } + if ( + $env->sessionId instanceof SessionId + && (string) $env->sessionId === $foreignSessionId + ) { + $sawForeign = true; + } + if ( + $env->correlationId instanceof MessageId + && (string) $env->correlationId === (string) $resumeEnv->id + ) { + $sawAck = true; + } + } + + self::assertTrue($sawAck, 'expected Resume ack from runtime'); + self::assertFalse($sawForeign, 'resume must not leak envelopes from another session'); + + $clientA->close(); + $clientB->close(); + $serverFutureA->await(); + $serverFutureB->await(); + } } diff --git a/tests/Integration/StdioTransportTest.php b/tests/Integration/StdioTransportTest.php index 74ebd09..267c48d 100644 --- a/tests/Integration/StdioTransportTest.php +++ b/tests/Integration/StdioTransportTest.php @@ -59,6 +59,31 @@ public function testRoundTripOverPipePair(): void $b->close(); } + public function testFinalUnterminatedFrameIsDecoded(): void + { + $pair = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); + self::assertIsArray($pair); + $serializer = new EnvelopeSerializer(MessageCatalog::create()); + + $writer = new WritableResourceStream($pair[0]); + $reader = new ReadableResourceStream($pair[1]); + $transport = new StdioTransport($reader, new WritableResourceStream($pair[0]), $serializer); + + $env = new Envelope( + id: new MessageId('msg_eof'), + payload: new EventEmit('demo'), + timestamp: new \DateTimeImmutable('2026-05-09T12:00:00Z'), + ); + // Note: NO trailing newline. + $writer->write($serializer->encode($env)); + $writer->end(); + + $received = $transport->receive(); + self::assertNotNull($received); + self::assertSame('msg_eof', (string) $received->id); + self::assertNull($transport->receive()); + } + public function testNewlineDelimitedFraming(): void { $pair = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP); diff --git a/tests/Integration/SubscriptionTest.php b/tests/Integration/SubscriptionTest.php index 35aa35e..da39882 100644 --- a/tests/Integration/SubscriptionTest.php +++ b/tests/Integration/SubscriptionTest.php @@ -109,4 +109,53 @@ public function testCannotSubscribeToOtherSession(): void } self::assertInstanceOf(PermissionDeniedException::class, $caught); } + + public function testEmptyFilterDoesNotObserveOtherSessions(): void + { + $runtime = new ARCPRuntime(authRouter: new AuthRouter([new NoneAuth()])); + $runtime->registerTool('emitProgress', new class () implements ToolHandler { + #[\Override] + public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancellation = null): mixed + { + $ctx->reportProgress(50); + $ctx->emitLog('info', 'midway'); + return null; + } + }); + [$serverTA, $clientTA] = MemoryTransport::pair(); + [$serverTB, $clientTB] = MemoryTransport::pair(); + $serverFutureA = $runtime->serveAsync($serverTA); + $serverFutureB = $runtime->serveAsync($serverTB); + $clientA = new ARCPClient($clientTA); + $clientB = new ARCPClient($clientTB); + $clientA->open(Auth::none(), new PeerInfo('cli-a', '0.1'), new Capabilities(subscriptions: true, anonymous: true)); + $clientB->open(Auth::none(), new PeerInfo('cli-b', '0.1'), new Capabilities(subscriptions: true, anonymous: true)); + + $aObservedSessions = []; + $clientA->subscribe( + [], + function (Envelope $env) use (&$aObservedSessions): void { + if ($env->sessionId !== null) { + $aObservedSessions[(string) $env->sessionId] = true; + } + }, + ); + delay(0.01); + + // Drive activity on B; A's empty-filter subscription must NOT see it. + $clientB->invokeTool('emitProgress'); + delay(0.05); + + $aSessionId = (string) $clientA->session->sessionId; + $bSessionId = (string) $clientB->session->sessionId; + self::assertArrayNotHasKey($bSessionId, $aObservedSessions); + // Allow A to see its own envelopes (e.g. backfill marker). + $foreign = array_diff(array_keys($aObservedSessions), [$aSessionId]); + self::assertEmpty($foreign); + + $clientA->close(); + $clientB->close(); + $serverFutureA->await(); + $serverFutureB->await(); + } } diff --git a/tests/Support/FakeDurableCredentialStore.php b/tests/Support/FakeDurableCredentialStore.php new file mode 100644 index 0000000..d539ade --- /dev/null +++ b/tests/Support/FakeDurableCredentialStore.php @@ -0,0 +1,59 @@ +> */ + private array $byJob = []; + + #[\Override] + public function add(JobId $jobId, Credential $cred): void + { + $this->byJob[(string) $jobId][$cred->id] = $cred; + } + + #[\Override] + public function remove(JobId $jobId, string $credentialId): void + { + unset($this->byJob[(string) $jobId][$credentialId]); + if (($this->byJob[(string) $jobId] ?? []) === []) { + unset($this->byJob[(string) $jobId]); + } + } + + #[\Override] + public function forJob(JobId $jobId): array + { + return array_values($this->byJob[(string) $jobId] ?? []); + } + + #[\Override] + public function outstanding(): array + { + $out = []; + foreach ($this->byJob as $jobId => $credentials) { + foreach (array_keys($credentials) as $credentialId) { + $out[] = ['job_id' => $jobId, 'credential_id' => $credentialId]; + } + } + return $out; + } + + #[\Override] + public function supportsDurableRevocation(): bool + { + return true; + } +} diff --git a/tests/Unit/AuthTest.php b/tests/Unit/AuthTest.php index f37344a..371bb48 100644 --- a/tests/Unit/AuthTest.php +++ b/tests/Unit/AuthTest.php @@ -48,6 +48,17 @@ public function testBearerRejectsWrongScheme(): void self::assertFalse($result->accepted); } + public function testBearerIgnoresClientSuppliedPrincipal(): void + { + $scheme = new BearerAuth(['t1' => 'alice']); + $result = $scheme->verify( + Auth::bearer('t1'), + new PeerInfo('c', '0', principal: 'mallory@example.com'), + ); + self::assertTrue($result->accepted); + self::assertSame('alice', $result->principal); + } + public function testNoneAccepts(): void { $scheme = new NoneAuth('public'); diff --git a/tests/Unit/Client/ResultChunkAssemblerTest.php b/tests/Unit/Client/ResultChunkAssemblerTest.php new file mode 100644 index 0000000..4149363 --- /dev/null +++ b/tests/Unit/Client/ResultChunkAssemblerTest.php @@ -0,0 +1,80 @@ +push(new ResultChunk('res_x', 0, 'hello, ')); + $a->push(new ResultChunk('res_x', 1, 'world', more: false)); + self::assertTrue($a->isComplete('res_x')); + self::assertSame('hello, world', $a->assemble('res_x')); + } + + public function testOutOfOrderAssemblyStillContiguous(): void + { + $a = new ResultChunkAssembler(); + $a->push(new ResultChunk('res_x', 1, 'world', more: false)); + $a->push(new ResultChunk('res_x', 0, 'hello, ')); + self::assertSame('hello, world', $a->assemble('res_x')); + } + + public function testAssembleWithoutTerminalChunkFails(): void + { + $a = new ResultChunkAssembler(); + $a->push(new ResultChunk('res_x', 0, 'partial')); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('terminal chunk'); + $a->assemble('res_x'); + } + + public function testMissingMiddleChunkIsRejected(): void + { + $a = new ResultChunkAssembler(); + $a->push(new ResultChunk('res_x', 0, 'a')); + $a->push(new ResultChunk('res_x', 2, 'c', more: false)); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('contiguous'); + $a->assemble('res_x'); + } + + public function testDuplicateConflictingChunkIsRejected(): void + { + $a = new ResultChunkAssembler(); + $a->push(new ResultChunk('res_x', 0, 'hello')); + $this->expectException(InvalidArgumentException::class); + $a->push(new ResultChunk('res_x', 0, 'HELLO')); + } + + public function testDuplicateIdenticalChunkIsAccepted(): void + { + $a = new ResultChunkAssembler(); + $a->push(new ResultChunk('res_x', 0, 'hello, ')); + $a->push(new ResultChunk('res_x', 0, 'hello, ')); + $a->push(new ResultChunk('res_x', 1, 'world', more: false)); + self::assertSame('hello, world', $a->assemble('res_x')); + } + + public function testBase64ChunkDecoded(): void + { + $a = new ResultChunkAssembler(); + $a->push(new ResultChunk('res_x', 0, base64_encode('hello'), encoding: 'base64', more: false)); + self::assertSame('hello', $a->assemble('res_x')); + } + + public function testUnknownResultIdIsRejected(): void + { + $a = new ResultChunkAssembler(); + $this->expectException(InvalidArgumentException::class); + $a->assemble('res_missing'); + } +} diff --git a/tests/Unit/LeaseManagerTest.php b/tests/Unit/LeaseManagerTest.php index f4b74f2..0efaeca 100644 --- a/tests/Unit/LeaseManagerTest.php +++ b/tests/Unit/LeaseManagerTest.php @@ -10,6 +10,7 @@ use Arcp\Errors\NotFoundException; use Arcp\Errors\PermissionDeniedException; use Arcp\Ids\LeaseId; +use Arcp\Ids\SessionId; use Arcp\Messages\Permissions\LeaseGranted; use Arcp\Runtime\LeaseManager; use Arcp\Runtime\LeaseScope; @@ -91,6 +92,28 @@ public function testExtendUpdatesExpiry(): void self::assertEquals($newExp, $extended->expiresAt); } + public function testGetForSessionAllowsOwner(): void + { + $clock = new FakeClock(); + $mgr = new LeaseManager($clock); + $lease = $this->makeLease($clock); + $owner = new SessionId('sess_owner'); + $mgr->register($lease, $owner); + self::assertSame($lease, $mgr->getForSession($lease->leaseId, $owner)); + } + + public function testGetForSessionRejectsForeignSession(): void + { + $clock = new FakeClock(); + $mgr = new LeaseManager($clock); + $lease = $this->makeLease($clock); + $owner = new SessionId('sess_owner'); + $other = new SessionId('sess_other'); + $mgr->register($lease, $owner); + $this->expectException(PermissionDeniedException::class); + $mgr->getForSession($lease->leaseId, $other); + } + public function testAllReturnsCurrentLeases(): void { $clock = new FakeClock(); diff --git a/tests/Unit/Runtime/CostBudgetTest.php b/tests/Unit/Runtime/CostBudgetTest.php index 0f8d2fb..947c794 100644 --- a/tests/Unit/Runtime/CostBudgetTest.php +++ b/tests/Unit/Runtime/CostBudgetTest.php @@ -36,4 +36,47 @@ public function testParseRejectsBadPattern(): void $this->expectException(InvalidArgumentException::class); CostBudget::fromPatterns(['USD']); } + + public function testConsumeAcceptsSmallFloatCost(): void + { + $budget = CostBudget::fromPatterns(['USD:1.000000']); + // 0.000001 stringifies as "1.0E-6" in PHP; must still consume. + self::assertSame('0.999999', $budget->consume('cost.usd', 0.000001, 'USD')); + } + + public function testConsumeAcceptsScientificStringificationOfFloat(): void + { + // Sanity check on PHP's stringification of small floats; this is + // what regressed in the bug report. + self::assertSame('1.0E-6', (string) 0.000001); + $budget = CostBudget::fromPatterns(['USD:0.000010']); + self::assertSame('0.000009', $budget->consume('cost.usd', 0.000001, 'USD')); + } + + public function testConsumeRejectsInfiniteFloat(): void + { + $budget = CostBudget::fromPatterns(['USD:1.00']); + $this->expectException(InvalidArgumentException::class); + $budget->consume('cost.usd', \INF, 'USD'); + } + + public function testConsumeRejectsNanFloat(): void + { + $budget = CostBudget::fromPatterns(['USD:1.00']); + $this->expectException(InvalidArgumentException::class); + $budget->consume('cost.usd', \NAN, 'USD'); + } + + public function testPatternRejectsOverPrecision(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('six-place'); + CostBudget::fromPatterns(['USD:0.0000009']); + } + + public function testPatternAcceptsExactlySixDecimals(): void + { + $budget = CostBudget::fromPatterns(['USD:0.000001']); + self::assertSame(['USD' => '0.000001'], $budget->remaining()); + } } diff --git a/tests/Unit/Runtime/Credentials/InMemoryCredentialStoreTest.php b/tests/Unit/Runtime/Credentials/InMemoryCredentialStoreTest.php index 26c9ff6..69a8b96 100644 --- a/tests/Unit/Runtime/Credentials/InMemoryCredentialStoreTest.php +++ b/tests/Unit/Runtime/Credentials/InMemoryCredentialStoreTest.php @@ -35,4 +35,10 @@ public function testOutstandingReportsAllJobs(): void ['job_id' => 'job_b', 'credential_id' => 'cred_b'], ], $store->outstanding()); } + + public function testDoesNotAdvertiseDurableRevocation(): void + { + $store = new InMemoryCredentialStore(); + self::assertFalse($store->supportsDurableRevocation()); + } }