Skip to content

feat(agent): device identity enrollment and channel - #2000

Merged
Benoît Cortier (CBenoit) merged 410 commits into
masterfrom
cbenoit-agent-identity-v1
Oct 26, 2026
Merged

Benoît Cortier (CBenoit) merged 410 commits into
masterfrom
cbenoit-agent-identity-v1

Conversation

@CBenoit

@CBenoit Benoît Cortier (CBenoit) commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Devolutions Agent can obtain its own device identity from an authority such as Devolutions Server: a server-issued identity ID and an X.509 certificate for a P-256 key that never leaves the machine.
One reusable enrollment token, deployable with identity enroll - or the MSI's IDENTITY_ENROLLMENT_TOKEN property, enrolls any number of machines.
After enrolling, the agent renews its certificate on its own and keeps an authenticated, bidirectional gRPC channel to the authority, so the authority can push config changes and ask for a renewal; while no channel works, the agent checks in over HTTPS instead.

The agent-facing protocol is product-neutral and specified in docs/agent-identity/CONTRACT.agent-identity.md: the enrollment token, the HTTP API, an RFC 9421 request-signature profile, certificate rules, the gRPC channel and its proof-of-possession handshake, and renewal and root rotation.
Design decisions shared by every component are in docs/agent-identity/INTENT.agent-identity.md, and each crate has its own INTENT.md.
docs/agent-identity/test-vectors.json holds signature, channel-proof and CSR vectors for every implementation, checked independently by verify_vectors.py.

Structure:

  • device-key: non-exportable P-256 device keys, in the Microsoft Software Key Storage Provider on Windows or as protected PKCS#8 files.
  • agent-identity-httpsig: signs the agent's requests and channel openings with httpsig, under the contract's profile.
  • agent-identity: tokens, pending-enrollment files, stored identities and their trust checks, schedules, CSRs and the HTTP client.
  • agent-channel-proto and agent-channel-client: the channel schema, also published as the Devolutions.Agent.Channel NuGet package for .NET authorities, and the client that opens, authenticates and keeps the stream.
  • devolutions-agent/src/identity: the identity enroll command, the Identity configuration section and the service task that enrolls, renews, checks in and runs the channel.
  • package/AgentWindowsManaged: the MSI's enrollment property, and cleanup of identities and their machine keys on full uninstall.
  • agent-identity-mock and agent-identity-conformance: a mock authority and a conformance tester that checks an authority (the mock or a real DVLS) and the agent against the contract; the testsuite runs both.
  • http-client-proxy: proxy selection per URL, shared by identity requests, the channel and the agent's other outbound HTTP.

The operator guide is docs/agent-identity/README.md.
Agent Tunnel's existing enroll command and files are unchanged.

Issue: DGW-478

@CBenoit

Copy link
Copy Markdown
Member Author

Implementation notes (Phase 1: contract and conformance suite)

Contents

  • docs/agent-identity/CONTRACT.md v0.3 (approved), including the §10.1 clarification that extra_trusted_root is a PEM bundle.
  • docs/agent-identity/test-vectors.json, produced by a throwaway generator that builds the signature bases by hand and signs deterministically (RFC 6979).
    It was cross-checked with Python cryptography and includes RFC 9421 Appendix B.2.4.
  • agent-identity-channel-proto: tonic/prost code generated with vendored protoc, passed through prost_build::Config::protoc_executable rather than set_var.
    The same .proto builds the Devolutions.AgentIdentity.Channel NuGet package (net10.0, Google.Protobuf + Grpc.Core.Api).
    CI builds the package and publish-libraries.yml publishes it; the new package ID may need nuget.org trusted-publishing setup.
  • agent-identity-mock: the conformance oracle.
    It verifies the RFC 9421 profile by hand with p256, without an RFC 9421 library, applying the §6 checks in contract order and committing each nonce only after its signature verifies.
    Authenticated handlers take an AuthenticatedDevice that only the verifier can construct.
    A unit test runs every entry of test-vectors.json through the server's own verifier.
  • agent-identity-conformance: a custom harness that exits non-zero on failure.
    • Protocol tests act as a raw client, with their own signer written independently of the mock.
    • Agent tests spawn devolutions-agent run with DAGENT_CONFIG_PATH and stand in for the MSI through the pending file (Unix 0600; Windows DPAPI machine scope with a protected DACL).
    • Observation order: admin API first, agent files second, logs last.
    • Mock-only tests are skipped with --target dvls.
    • Rotation tests against DVLS need --disposable-dvls-target.
  • The testsuite starts two mocks under /mock and runs the tester against them.

Current results (Windows, local)

  • 47/47 protocol tests pass against the mock (about 15 s).
  • Expected to fail until the agent implementation lands: the 22 a_* tests below, which need devolutions-agent identity support.
    a_file_backend_permissions is Unix-only and skips on Windows.
    Until then, the testsuite test agent_identity::conformance is red in CI; fmt, clippy, the existing tests and the protocol tests are green.
    • a_pending_file_enroll_success, a_pending_file_deleted_on_permanent_error, a_pending_file_kept_on_transient_error, a_token_never_logged
    • a_same_token_no_enrollment, a_same_token_rejected_identity_no_enrollment, a_different_token_replaces_identity, a_cli_identity_enroll_writes_pending_file
    • a_renewal_happy_path, a_renewal_lost_response_retried, a_channel_connected_and_metadata, a_make_before_break_on_renewal
    • a_request_renewal_connected, a_request_renewal_while_offline, a_reconnect_make_before_break, a_revocation_stops_agent
    • a_device_unknown_recorded, a_no_channel_when_absent, a_multi_authority, a_key_non_exportable
    • a_rotation_migrates_connected_agent, a_rotation_migrates_on_schedule

Dependencies

  • The workspace resolves p256 0.14.0-rc.14 and ecdsa 0.17.0-rc.22, because ironrdp-connector 0.10 → sspi 0.21 → picky =7.0.0-rc.25 pins those release candidates.
    The mock and tester therefore pin p256 =0.14.0-rc.14.
  • The agent's signing layer, in the next phase, holds httpsig at =0.0.24 for the same reason.
    httpsig is used only to build and sign the agent's own requests, never to parse incoming headers, because 0.0.24 can panic on malformed Signature-Input.
  • Follow-up: once IronRDP releases on sspi 0.22 (picky rc.26), bump the picky pins to rc.26 and httpsig to 0.0.26, and drop the duplicate p256/ecdsa generation.

Suite decisions

  • Enroll idempotence is checked before token expiry and exhaustion, so a lost response on a maxUses = 1 token can be retried.
  • Chain checks:
    • The last chain element must equal a published root byte for byte.
    • Links are checked by name, and their signatures are verified for P-256 issuers.
    • Validity is checked at evaluation time.
    • There's no full RFC 5280 path validation (§4, invariant 10).
  • Windows test cleanup deletes a machine key only when the key didn't exist before the test, its name matches the contract pattern, and its authority is the server-issued authority_id.

Review
Each suite task went through a coder ↔ reviewer loop using the repository code-review skill.
The tester task ended after 3 rounds (19 → 14 → 8 findings); a follow-up fix task addressed the remaining 8 and was approved in round 2.

Note

LLM-assisted content (no human feedback).

Each test, vector case and mixed assertion now names its source with an
oracle marker: the contract's signature profile, URL prefix and channel
sequence, the crate intent, or the RFCs and tower documentation that
decide the value.

The path expected for a base URL whose path ends with a slash is left
unmarked: no document states how that slash is treated.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…aches the transport

The test only checked that the call returned an error, so a layer that
forwarded the unsigned opening and then returned the signing error
passed.
It now also records whether the inner service was called and requires
that it was not, since a channel opening carries signature metadata.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…y rule

The vector test compares Signature-Input and the signature base byte
for byte, including a parameter order the contract leaves free; the
shared intent's conformance rule is what requires reproducing them.
The structured-field bound test also relies on expires being created
plus 60 seconds, which the contract's signature profile sets.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…eral trailing slashes

The contract removes every trailing slash from agent_channel_url before
prepending its path, but the tests only used a single slash, so a layer
that dropped just one passed.
The channel-layer test now also opens `https://example.test/dvls//` and
`https://example.test//`, which must give the same path as without
the slashes.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract's URL and path-prefix section now says the agent removes
trailing slashes from agent_channel_url before prepending its path, so
it decides the path expected for `https://example.test/dvls/` and for
the site root `https://example.test/`.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Several checks can find a completion due at once; the log records the
rotation once, so it counts rotations rather than checks.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A first channel message that fails to decode carries no readable proof,
so it fails authentication with signature_invalid, ahead of the
metadata checks, as authentication errors do on HTTP.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Tests, code comments and other documents cite contract sections; an
anchor keeps each citation valid when a heading is reworded or a section
renumbered. Anchors only; no rule changes.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
After the handshake, a message the server cannot decode ends the stream
with invalid_request rather than a clean close, so a broken peer is
visible; the agent reconnects as after any closure.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… as no Hello

The contract closes a stream whose first agent message cannot be decoded
with UNAUTHENTICATED and error-code signature_invalid, like any other
first message that is not a Hello, and leaves the identity's state
unchanged.
The mock already does so; the test pins it for invalid UTF-8 in a string
field and for a truncated message, each sent as a raw gRPC frame.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…th invalid_request

The contract closes an authenticated channel stream whose agent message
cannot be decoded with INVALID_ARGUMENT and error-code invalid_request,
so that a broken peer does not look like a clean close.
The mock ended such a stream with OK, because tonic's prost codec fails
the stream with the same kind of status for an undecodable message as
for a broken transport.

The channel now uses its own codec, which passes each agent message to
the stream decoded or with its decoding error, so that only an
undecodable message gets invalid_request; a transport failure still
closes the stream with OK, and an undecodable first message is still
signature_invalid.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each side accepts envelopes up to 64 KiB of serialized protobuf and sends
none larger, matching the request body limit. The server treats a larger
AgentMessage as one it cannot decode, so the existing outcomes apply:
signature_invalid before Welcome, invalid_request after it.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An authority never holds an effective config whose ConfigUpdate envelope
would exceed the 64 KiB channel limit, so enroll, check-in and the channel
deliver the same configs.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A citation names the file with the anchor, so an anchor only needs to be
unique within its file; the agent-identity- prefix repeated the file name.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each test runs on the first authority that offers what it needs, else
SKIP naming the missing capability; a failed agent test keeps its data
directory and logs; usage errors never echo a credential.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…sted

After a restart the anchor uses the load time, which still never passes
notBefore.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A token whose config would not fit one ConfigUpdate is refused. If a later
change outside the module makes an identity's config too large, that
identity keeps its last config, the others still update, and
administrators are warned.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rename UntrustedAuthorityDirectory to UntrustedStoredIdentity and the
authority-directory check to check_stored_identities, so that one error
type and one check can also cover an untrusted identity.json.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An identity.json that other users could access, or that was not a
regular file, read as absent: its identity's runner stopped until the
next restart, and only a fallback in the identity listing kept its token
from enrolling again. The trust check of the authority directories now
covers each directory's identity.json with the same rules, on Windows
exactly the DACL the agent provisions, so such a file stops all identity
processing through the existing stop path, is never repaired, and
processing resumes once it passes. The service log names the path and
the fix, and the operator guide lists that message.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The non-canonical base64 test looks for a CSR whose canonical encoding
has padding and a + or /, and retried by signing the same request with
the same key. ECDSA signing here is deterministic (RFC 6979), so every
attempt gave the same CSR and the test gave up in a large share of runs
before sending anything. Each attempt now takes a new key, and the test
keeps the key it found for the control enrollment.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent's refusal of a leaf naming another URL now also covers a URL
differing from the token's only by a trailing slash or by an upper-case
scheme and host, which a URL parser would read as the same. The check
that an authority ignores other requested extensions places the extra
extension both before and after the enrollment URL, so a reader taking
the first or last extension of the request fails it.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng CSR URL

An enrollment CSR without the enrollment URL extension, or naming
another URL than the token's u, now gets the permanent code
token_url_mismatch rather than invalid_request, which the agent retried
as its own bug, since a token used at an address it does not name can
never work there. The authority checks expect 403 token_url_mismatch
for every such CSR, ahead of the key lookup and the token's state, and
invalid_request for a CSR that is also invalid, which the contract
orders first.

The relayed enrollment is therefore a permanent refusal: the agent
sends it once, deletes the pending file, in-progress record and
enrollment key, and records token_url_mismatch in a rejection marker,
while the authority creates nothing.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntract

The trailing-slash and upper-case cases of the enrollment URL checks
rested on how a URL parser reads them. The contract decides them
directly: the authority refuses a CSR whose value is not the token's u
without trailing slashes, and the agent a leaf whose value, read as
text, is not. The docs now quote those sentences and §2.1 in full. The
case that upper-cased the scheme and host is named for the scheme
alone, since the mock's IP host has no case; it now upper-cases only
the scheme, through one helper both sides share.

The CSR vectors with a bad self-signature or SHA-384 are not refused
first for their missing enrollment URL extension: §2.1 orders
invalid_request before token_url_mismatch, so the enrollment test that
sends them isolates their rules. The coverage record drops that gap and
lists the new leaf cases.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DVLS names the enrollment token's lifecycle field `status`, the same
name identities and certificates use.
The mock emits `status` in its token responses and names the Rust
accessor and its tests the same way; the values and their precedence
are unchanged.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DVLS names the enrollment token's management-API member `status`, the same
name identities use.
The suite parses that member and the Rust items and failure messages follow.
No assertion changes.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its four base URLs are rows of the malformed-token test, which also
checks that the error never holds the token.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t URL OID

The contract now states the enrollment URL extension OID itself,
so the constant's documentation names the contract as the source of the value
instead of describing a stand-in for another OID.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… their rules

The `bad_self_signature` and `ecdsa_with_sha384` vectors carry an enrollment URL extension naming `https://example.test/dvls`, which is not the target's `u`.
At enrollment they would draw `token_url_mismatch` if `invalid_request` did not come first, and §2.1 orders it first, so the coverage note names the URL mismatch rather than a missing extension.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract now names 1.3.6.1.4.1.32473.1.1 as the enrollment URL
extension OID, so the constants no longer stand in for another one.
Their doc comments cite the contract, and the mock README drops the
note that its OID differs from the contract's.

The agent's CSR test compared the extension's OID with the constant
that produced it, so a wrong constant passed; it now compares it with
the dotted OID the contract states.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract has the agent's CSR carry one attribute, an
extensionRequest holding only the enrollment URL extension, and has
enrollment refuse a CSR without it. csr.valid had no attributes, so it
no longer showed what an agent sends, and the refusal vectors failed
the URL rule as well as their own.

csr.valid, bad_self_signature and ecdsa_with_sha384 now request
https://example.test/dvls, named in a new enrollment_url field, and
are what the cryptography package's CSR builder generates from seeded
keys with RFC 6979 nonces. verify_vectors.py regenerates them, checks
them with its own DER reader, and checks that its hand-built DER
matches the builder's. valid_with_empty_extension_request keeps its
bytes.

A CSR's outcome now depends on the endpoint, so each entry's expected
holds an enroll and a renew outcome: the pinned entry is
token_url_mismatch at enrollment and accepted at renewal. The mock
checks the renewal outcome and the URL its oracle reads against the
vectors, and runs every vector through enrollment.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Readers outside this repository take a csr entry's expected as the
string outcome of the section 4 CSR checks. Each entry keeps that
string, and its outcome at enrollment and at renewal moves to a new
expected_at object, so the per-endpoint outcomes are an additive
change.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… URL

A CSR that requests the enrollment URL extension more than once, in one
extensionRequest or several, or not as an IA5String, names no
enrollment URL, so an authority refuses it at enrollment with
token_url_mismatch even when every instance holds the token's u. The
enrollment URL test now sends each such shape, with the IA5String and
UTF8String instances in both orders so that a server reading only the
first or only the last instance is caught. A renewal CSR requesting the
URL twice still renews, because renewal ignores the value.

The CSR builder takes one extension list per extensionRequest attribute
so a CSR can carry several.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CSR that requests the enrollment URL extension more than once, in one
extensionRequest or several, or not as an IA5String, names no
enrollment URL, so its enrollment is refused with token_url_mismatch
and creates nothing. The CSR reader took the first well-formed
instance, which let a CSR naming the right URL once and another
elsewhere enroll. Renewal still ignores the value.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's u holds only ASCII characters, since certificates carry it as
an IA5String, so a mock whose --path-prefix holds a non-ASCII character
now answers token creation with 422 and creates no token, as it does
for a base URL with an @. Before, it created the token and every
enrollment with it failed with 500 when the leaf could not carry the
URL.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hoice

The contract doesn't decide how an attribute that isn't DER affects
reading the enrollment URL, so the test that the reader stops at such
an attribute pins the mock's choice and is marked as a mutant guard
rather than citing the contract.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CSR reader stopped at the first attribute it could not frame and
counted only the enrollment URL instances before it, so a CSR holding
one instance, then such an attribute, then another instance named a
URL and enrolled, while it requests the extension twice. Every
container the reader walks to count instances, the attribute set, an
extensionRequest's fields, its values and each Extensions list, must
now be framed to its end, or the CSR names no URL. Unrelated
extensions' values stay unread, and the BER bounds are unchanged.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al URLs

The enrollment refusal test quotes the contract's rule that a CSR
requesting the enrollment URL extension more than once or not as an
IA5String names no URL, and the renewal case that repeats the
extension names two different URLs, so renewal is seen to ignore both.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CSR reader takes one-byte tags, so it read a high-tag-number tag
such as BF 81 nn as tag BF with a long-form length that swallowed the
next extensionRequest or extension, and a CSR requesting the
enrollment URL twice then counted one instance and enrolled. A first
tag byte with the low 5 bits set (X.690 section 8.1.2.4) now makes the
item unreadable, so the CSR names no URL.

The test of items the reader cannot frame now covers each level it
walks: the attribute set, an extensionRequest's fields, its values,
an Extensions list, and an attribute or extension whose type cannot be
read.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The renewal case that repeats the enrollment URL extension names two
URLs that both differ from the mock's, so a renewal that read the first
instance instead of ignoring the value would still be caught.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ccepts

The reader also refuses indefinite lengths and lengths of more than
four bytes, which BER allows.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit

Copy link
Copy Markdown
Member Author

The head moved from 7a81460d to 15fc4910 to bind each enrollment to its token's URL and to follow the revised contract; CI run 40161741007 is green (50 jobs; skipped: Upload git-log output, OneDrive), after one re-run of the agent tunnel end-to-end test, whose Docker build hit a GitHub API rate limit while installing PowerShell.

  • What changed, as the contract now states it:
    • Every certificate an authority issues for an identity carries a non-critical enrollment URL extension holding the enrolling token's u without trailing slashes, and the agent's CSR requests it with the URL it contacts.
    • An authority refuses an enrollment CSR that names no enrollment URL, or another one, with the permanent token_url_mismatch; the agent refuses a leaf that doesn't name its token's u as authority_url_mismatch. A server holding another authority's token can no longer relay a device's enrollment.
    • A token's u is ASCII only.
    • When a known authority moves to another base URL, the agent verifies the new chain with ecdsa-with-SHA256 over P-256 or ecdsa-with-SHA384 over P-384, never RSA.
    • Enrollment tokens are revoked, not deleted, carry no config, and report a status.
  • The enrollment URL extension uses 1.3.6.1.4.1.32473.1.1, under the enterprise number RFC 5612 reserves for documentation; the contract moves it under Devolutions' own enterprise number before the feature is released.
  • Conformance against the mock, locally on Windows at 15fc4910: file backend 188 PASS, 0 FAIL, 1 SKIP, 2 N/A.
  • Test vectors: the CSR vectors are regenerated with Python cryptography and checked by verify_vectors.py; csr.valid now requests the enrollment URL, valid_with_empty_extension_request keeps its bytes, and a new expected_at field gives each CSR's outcome at enrollment and at renewal.
  • Dependencies: p384 (P-384 verification) is a new direct dependency of agent-identity, and rsa a dev-dependency for a genuine RSA-signed test chain; both were already in Cargo.lock at the same versions, so no package is added.
  • Known gap: the conformance suite can't yet drive the agent's chain check for a moved authority end to end; unit tests cover it.

New untested properties, one per UNTESTED: comment added:

  • crates/agent-identity/src/client.rs: every enrollment response, first enrollments included, is refused when its leaf doesn't name the token's URL.
  • devolutions-agent/src/identity/mod.rs: the enrollment CSR names the token's u without trailing slashes.
  • devolutions-agent/src/identity/runner.rs: the renewal CSR names the identity's base URL.

Note

Human-tuned, LLM-assisted content.

…ity-v1

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The outer-timeout cleanup selected agent processes by the `sess` column of
`ps`, which is not portable: on macOS it prints the kernel session pointer
from `struct eproc` instead of a session ID, so no agent matched and the
agents outlived the runner.
List PIDs only and keep those whose `getsid` equals the runner, which is
the check the cleanup already ran before each kill.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ps` lists the kernel as PID 0 on macOS, and getsid and kill treat 0 as
the caller itself, so the session sweep must never pass it on.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit

Copy link
Copy Markdown
Member Author

The head moved from 15fc4910 to 7a09e162 to merge master, which adds a macOS leg to CI, and to fix the one test that failed on it; CI run 40195669577 is green (52 jobs; skipped: Upload git-log output, OneDrive).

  • The macOS leg runs cargo test --workspace for aarch64-apple-darwin, so it now builds and tests agent-identity, agent-identity-mock, agent-identity-conformance, devolutions-agent and private-fs; agent_identity::conformance passes there.
  • agent_identity::outer_timeout_kills_agent_group failed on macOS because it found an agent's session through the sess column of ps, which macOS fills with a kernel session pointer rather than a session ID. The test now lists PIDs with ps -e -o pid= and matches each with getsid, skipping PID 0, which getsid and kill read as the caller.

The untested-properties list from the earlier comment loses one line, because CI now compiles and tests the macOS code:

  • CI compiles neither the agent crates nor private-fs for macOS; the macOS code was only type-checked locally with clippy for aarch64-apple-darwin.

These two still hold, because no test sets "Ignore ownership on this volume" or adds an extended ACL:

  • crates/private-fs/src/macos.rs: a volume with "Ignore ownership on this volume" set is detected, and the boot volume isn't.
  • crates/private-fs/src/macos.rs: a file with extended ACL entries is detected, and one without isn't.

Note

Human-tuned, LLM-assisted content.

An enrollment token still gives an identity no config by default, but
an authority may let it carry assignments it recognizes, such as
tunnel agent of one Gateway, and apply them to each identity the token
enrolls as an administrator's assignment right after enrollment. This
lets an administrator pre-configure a small batch of machines known to
be online, and suits only a short-lived token whose uses match those
machines, because anyone holding the token receives the assignments.

Issue: DGW-478

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit
Benoît Cortier (CBenoit) merged commit 1c51c77 into master Oct 26, 2026
52 checks passed
@CBenoit
Benoît Cortier (CBenoit) deleted the cbenoit-agent-identity-v1 branch October 26, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants