Skip to content

Commit 314c483

Browse files
authored
Merge branch 'main' into dependabot/github_actions/codecov/codecov-action-7.0.0
2 parents 62a78b1 + 9e05378 commit 314c483

82 files changed

Lines changed: 1920 additions & 320 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bin/arcp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ declare(strict_types=1);
66
/*
77
* arcp — CLI entry point (RFC §22 transports + Phase 7 commands).
88
*
9-
* arcp serve ws://host:port Run an empty runtime accepting connections.
10-
* arcp tail <session-id> Subscribe to all events from a session.
11-
* arcp send <type> --payload Send a single envelope and print the reply.
12-
* arcp replay <after-id> Replay an event log file from a message id.
9+
* arcp serve --host H --port P Run a runtime accepting WebSocket connections.
10+
* arcp tail <ws-uri> Subscribe to a runtime and print every envelope.
11+
* arcp send <ws-uri> <tool> --arguments '<json>' Invoke a tool and print the reply.
12+
* arcp replay <db-path> --after <message-id> Replay an event log database.
1313
*
1414
* Production deployments should subclass these commands with real auth
1515
* configuration; the defaults assume `none` (anonymous) for development.

docs/conformance.md

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,26 @@ The PHP SDK targets ARCP v1.1.
44

55
## v1.1 coverage
66

7-
| Area | Status |
8-
| --- | --- |
9-
| Envelope JSON and typed message catalog | implemented |
10-
| Session open/accepted/rejected/close | implemented |
11-
| Ping/pong, ack, resume replay | implemented |
12-
| `session.list_jobs` / `session.jobs` | implemented |
13-
| Tool invocation and job lifecycle | implemented |
14-
| Agent `name@version` resolution | implemented |
15-
| Progress, streams, and `job.result_chunk` | implemented |
16-
| Permissions and leases | implemented |
17-
| `cost.budget` counters | implemented |
18-
| `model.use` leases | implemented |
19-
| Provisioned credentials | implemented |
20-
| `LEASE_SUBSET_VIOLATION` | implemented |
21-
| Artifacts | implemented |
22-
| Subscriptions and backfill | implemented |
23-
| Vendor extensions | implemented |
7+
Status legend: **Full** — matches the v1.1 wire shape; **Partial** — works
8+
but still diverges from the spec wire shape (tracked by the linked issues).
9+
10+
| Area | Status | Notes |
11+
| --- | --- | --- |
12+
| Envelope JSON and typed message catalog | Partial | no top-level `event_seq` yet (#132, #152) |
13+
| Session hello/welcome/rejected/close | Partial | uses `session.open`/`session.accepted` not `session.hello`/`session.welcome`; no `session.closed` ack (#121, #122, #123, #130) |
14+
| Ping/pong, ack, resume | Partial | `ping`/`pong` not `session.ping`/`session.pong`; `ack` is advisory-only; resume uses `after_message_id` not `session.resume` + rotating token (#127, #128, #146, #55, #125) |
15+
| `session.list_jobs` / `session.jobs` | Partial | entries omit `lease`/`parent_job_id`/`last_event_seq`; credentials redacted from the inventory (#143) |
16+
| Tool invocation and job lifecycle | Partial | submission uses `tool.invoke` not `job.submit`; terminal states are `completed`/`failed` not `success`/`error`/`timed_out` (#134, #137) |
17+
| Agent `name@version` resolution | Full | deterministic resolution; ambiguous unversioned names are rejected |
18+
| Progress, streams, and `job.result_chunk` | Partial | progress body uses `percent` not `current`/`total`; inline/chunk mixing not yet prevented (#63, #147, #64, #153, #154) |
19+
| Permissions and leases | Partial | `expires_at` UTC/future validation and runtime expiry enforcement pending (#60, #156) |
20+
| `cost.budget` counters | Partial | negative metrics rejected and exact-zero allowed; no pre-dispatch budget check (#158) |
21+
| `model.use` leases | Full | pattern grammar matches the spec examples |
22+
| Provisioned credentials | Partial | per-job scoping and retried revocation in place; no startup revocation replay (#160) |
23+
| `LEASE_SUBSET_VIOLATION` | Full | model.use, cost.budget, and `expires_at` containment enforced |
24+
| Artifacts | Full | `ref()`/`fetch()` agree on expiry |
25+
| Subscriptions and backfill | Partial | uses `subscribe`/`subscribe.accepted` not `job.subscribe`/`job.subscribed`; principal authorization pending (#138, #151, #139) |
26+
| Vendor extensions | Full | core-type classification and `x-` rejection match the spec |
2427

2528
## v1.1 features
2629

src/Auth/AuthRouter.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
*/
1616
final class AuthRouter
1717
{
18+
/**
19+
* Schemes reserved by RFC §8.2 but not yet implemented; presenting one
20+
* surfaces UNIMPLEMENTED rather than a generic unknown-scheme rejection.
21+
*
22+
* @var list<string>
23+
*/
24+
private const array RESERVED_SCHEMES = ['mtls', 'oauth2'];
25+
1826
/** @var array<string, AuthScheme> */
1927
private array $schemes = [];
2028

@@ -35,7 +43,7 @@ public function verify(Auth $auth, PeerInfo $client): AuthResult
3543
{
3644
if (!isset($this->schemes[$auth->scheme])) {
3745
// mTLS and OAuth2 are reserved (RFC §8.2) but unimplemented in v0.1.
38-
if (\in_array($auth->scheme, ['mtls', 'oauth2'], true)) {
46+
if (\in_array($auth->scheme, self::RESERVED_SCHEMES, true)) {
3947
throw new UnimplementedException(
4048
'§8.2',
4149
\sprintf('auth scheme %s deferred to v0.2', $auth->scheme),

src/Auth/JwtAuth.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use Arcp\Messages\Session\PeerInfo;
99
use Firebase\JWT\JWT;
1010
use Firebase\JWT\Key;
11+
use Psr\Log\LoggerInterface;
12+
use Psr\Log\NullLogger;
1113

1214
/**
1315
* `signed_jwt` verification (RFC §8.2). The runtime supplies the trust
@@ -16,10 +18,14 @@
1618
*/
1719
final readonly class JwtAuth implements AuthScheme
1820
{
21+
private LoggerInterface $logger;
22+
1923
public function __construct(
2024
private Key $key,
2125
private string $audience,
26+
?LoggerInterface $logger = null,
2227
) {
28+
$this->logger = $logger ?? new NullLogger();
2329
}
2430

2531
#[\Override]
@@ -40,7 +46,12 @@ public function verify(Auth $auth, PeerInfo $client): AuthResult
4046
try {
4147
$decoded = JWT::decode($auth->token, $this->key);
4248
} catch (\Throwable $e) {
43-
return AuthResult::reject('jwt verification failed: ' . $e->getMessage());
49+
// Keep the wire reason opaque: the underlying decode error can
50+
// leak key id, algorithm, or expiry details useful to an
51+
// attacker probing trust material. Log it server-side instead.
52+
$this->logger->info('jwt verification failed', ['error' => $e->getMessage()]);
53+
54+
return AuthResult::reject('jwt verification failed');
4455
}
4556
$claims = (array) $decoded;
4657
$aud = $claims['aud'] ?? null;

src/Auth/NoneAuth.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ public function verify(Auth $auth, PeerInfo $client): AuthResult
2929
if ($auth->scheme !== 'none') {
3030
return AuthResult::reject('scheme mismatch');
3131
}
32-
return AuthResult::accept($client->principal ?? $this->defaultPrincipal);
32+
// Always use the configured default principal; do not trust the
33+
// principal supplied in the untrusted PeerInfo block (mirrors the
34+
// BearerAuth contract). Trusting it would let any anonymous peer
35+
// claim an arbitrary principal and bypass per-principal isolation.
36+
return AuthResult::accept($this->defaultPrincipal);
3337
}
3438
}

src/Cli/ServeCommand.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int
4444
$this->startWebSocketServer($runtime, $host, $port);
4545
$output->writeln(\sprintf('<info>arcp listening on ws://%s:%d/</info>', $host, $port));
4646

47-
// Block; pressing Ctrl-C kills the process. Production deployments
48-
// should wire SIGINT/SIGTERM via the EventLoop driver.
47+
// Stop the loop on SIGINT/SIGTERM for a graceful shutdown where the
48+
// driver supports signal handling; otherwise the default disposition
49+
// (Ctrl-C terminates the process) applies.
50+
$this->installSignalHandlers($output);
4951
EventLoop::run();
5052
return Command::SUCCESS;
5153
}
5254

55+
private function installSignalHandlers(OutputInterface $output): void
56+
{
57+
if (!\defined('SIGINT') || !\defined('SIGTERM')) {
58+
return;
59+
}
60+
$stop = static function () use ($output): void {
61+
$output->writeln('<info>arcp shutting down</info>');
62+
EventLoop::getDriver()->stop();
63+
};
64+
foreach ([\SIGINT, \SIGTERM] as $signal) {
65+
try {
66+
EventLoop::onSignal($signal, $stop);
67+
} catch (\Revolt\EventLoop\UnsupportedFeatureException) {
68+
// Loop driver lacks signal support; rely on default handling.
69+
return;
70+
}
71+
}
72+
}
73+
5374
/** @return array{0: string, 1: int} */
5475
private function parseListenOptions(InputInterface $input): array
5576
{

src/Cli/TailCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5353
$client->open(
5454
Auth::none(),
5555
new PeerInfo('arcp-tail', Version::IMPL_VERSION),
56-
new Capabilities(subscriptions: true, anonymous: true),
56+
new Capabilities(subscriptions: true, anonymous: true, features: ['subscribe']),
5757
);
5858

5959
$client->subscribe(

src/Client/ARCPClient.php

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use Arcp\Envelope\MessageCatalog;
1717
use Arcp\Envelope\MessageTypeRegistry;
1818
use Arcp\Errors\InvalidArgumentException;
19+
use Arcp\Errors\TransportClosedException;
20+
use Arcp\Errors\UnimplementedException;
1921
use Arcp\Ids\ArtifactId;
2022
use Arcp\Ids\IdempotencyKey;
2123
use Arcp\Ids\JobId;
@@ -468,19 +470,41 @@ private function runReadLoop(?Cancellation $cancellation): void
468470
{
469471
try {
470472
while (!$this->session->transport->isClosed()) {
471-
$env = $this->session->transport->receive($cancellation);
472-
if (!$env instanceof Envelope) {
473+
if (!$this->readOnce($cancellation)) {
473474
break;
474475
}
475-
if ($env->payload instanceof ResultChunk) {
476-
$this->resultChunks->push($env->payload);
477-
}
478-
$this->router->handle($env);
479476
}
480477
} catch (\Throwable $e) {
481478
$this->logger->warning('client read-loop ended', ['error' => $e->getMessage()]);
482479
} finally {
483480
$this->pending->failAll(new \RuntimeException('read loop ended'));
484481
}
485482
}
483+
484+
/**
485+
* Process a single inbound frame. Returns false when the loop should
486+
* stop (clean EOF or transport closure). A single undecodable or
487+
* unknown-type frame is logged and skipped so one bad frame from the
488+
* peer cannot kill the session (RFC §5 forward-compatibility).
489+
*/
490+
private function readOnce(?Cancellation $cancellation): bool
491+
{
492+
try {
493+
$env = $this->session->transport->receive($cancellation);
494+
} catch (TransportClosedException $e) {
495+
$this->logger->warning('client read-loop ended', ['error' => $e->getMessage()]);
496+
return false;
497+
} catch (InvalidArgumentException | UnimplementedException $e) {
498+
$this->logger->warning('client dropped undecodable frame', ['error' => $e->getMessage()]);
499+
return true;
500+
}
501+
if (!$env instanceof Envelope) {
502+
return false;
503+
}
504+
if ($env->payload instanceof ResultChunk) {
505+
$this->resultChunks->push($env->payload);
506+
}
507+
$this->router->handle($env);
508+
return true;
509+
}
486510
}

src/Client/ResultChunkAssembler.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Arcp\Errors\InvalidArgumentException;
88
use Arcp\Messages\Execution\ResultChunk;
9+
use Arcp\Messages\Execution\ResultChunkEncoding;
910

1011
/**
1112
* Collects `job.result_chunk` messages by result id and assembles final
@@ -57,13 +58,23 @@ public function assemble(string $resultId): string
5758
$this->assertContiguous($resultId, $chunks);
5859
$out = '';
5960
foreach ($chunks as $chunk) {
60-
$out .= $chunk->encoding === 'base64'
61+
$out .= $chunk->encoding === ResultChunkEncoding::Base64
6162
? $this->decodeBase64($chunk)
6263
: $chunk->data;
6364
}
65+
$this->forget($resultId);
6466
return $out;
6567
}
6668

69+
/**
70+
* Release all buffered chunks for an assembled (or abandoned) result so a
71+
* long-lived client streaming many results does not grow without bound.
72+
*/
73+
public function forget(string $resultId): void
74+
{
75+
unset($this->chunks[$resultId], $this->complete[$resultId]);
76+
}
77+
6778
/** @param array<int, ResultChunk> $chunks */
6879
private function assertContiguous(string $resultId, array $chunks): void
6980
{

src/Envelope/Envelope.php

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,28 @@ public function type(): string
7777
*/
7878
public function withCorrelationId(?MessageId $correlationId): self
7979
{
80-
return new self(
81-
id: $this->id,
82-
payload: $this->payload,
83-
timestamp: $this->timestamp,
84-
priority: $this->priority,
85-
sessionId: $this->sessionId,
86-
jobId: $this->jobId,
87-
streamId: $this->streamId,
88-
subscriptionId: $this->subscriptionId,
89-
traceId: $this->traceId,
90-
spanId: $this->spanId,
91-
parentSpanId: $this->parentSpanId,
92-
correlationId: $correlationId,
93-
causationId: $this->causationId,
94-
idempotencyKey: $this->idempotencyKey,
95-
source: $this->source,
96-
target: $this->target,
97-
arcp: $this->arcp,
98-
extensions: $this->extensions,
99-
);
80+
return $this->with(['correlationId' => $correlationId]);
81+
}
82+
83+
/**
84+
* Return a copy carrying a different payload, preserving every other
85+
* field. Used for log redaction so new envelope fields are retained
86+
* automatically.
87+
*/
88+
public function withPayload(MessageType $payload): self
89+
{
90+
return $this->with(['payload' => $payload]);
91+
}
92+
93+
/**
94+
* Rebuild this immutable envelope with the given field overrides.
95+
* Property names match the constructor parameter names, so a new field
96+
* is covered automatically without touching every wither.
97+
*
98+
* @param array<string, mixed> $overrides
99+
*/
100+
private function with(array $overrides): self
101+
{
102+
return new self(...[...get_object_vars($this), ...$overrides]);
100103
}
101104
}

0 commit comments

Comments
 (0)