From 2f915b27b24e3d08385e8785949e6aad03d0f3ee Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 24 Aug 2026 17:32:02 -0700 Subject: [PATCH 1/2] refactor(sdk): name the v0 modules for the surface they serve Every SDK ended 0.6.0 with the v1 code in `*_v1` files and the v0 code still in the unsuffixed ones it had before there was anything to distinguish it from. So "unsuffixed file" meant v0 while "unsuffixed class" meant v1, and a reader opening `dstack_client.rs` landed on the legacy surface. The v0 modules now say so: `dstack_client.rs` -> `dstack_client_v0.rs` and types `dstack.rs` -> `dstack_v0.rs` in Rust, `dstack_client.py` -> `dstack_client_v0.py` in Python, `client.go` -> `client_v0.go` in Go. The JS SDK had both clients and their shared helpers in one `index.ts`; it is split into `client-v0.ts`, `client-v1.ts` and `shared.ts`, with `index.ts` kept as a barrel exporting exactly what it exported before. No compatibility aliases for the old module paths. 0.6.0 is already the release where the unsuffixed client name changed meaning, and the whole point of that decision was that an unmigrated caller fails at build time rather than silently binding the frozen surface; a module alias would reopen the hole the rename closes. Deprecation is now visible to each language's tooling rather than only to a reader. Go and JS already carried `// Deprecated:` and `@deprecated`; Rust had no attribute at all and Python only a docstring note. `DstackClientV0` and `TappdClient` now carry `#[deprecated]`, with `#[allow(deprecated)]` at the internal use sites so the attribute reaches downstream callers instead of being blanket-suppressed, and the Python v0 clients warn at construction through the helper the file already had for `TappdClient`. Two tests pin that warning, which nothing did before. `.claude/agents/sdk-sync-checker.md` listed the old paths and now lists both surfaces' files, since a rename that leaves the agent looking at the wrong file makes it quietly useless. --- .claude/agents/sdk-sync-checker.md | 8 +- CHANGELOG.md | 2 + sdk/go/dstack/{client.go => client_v0.go} | 5 + .../{client_test.go => client_v0_test.go} | 0 sdk/js/src/client-v0.ts | 528 ++++++++++ sdk/js/src/client-v1.ts | 355 +++++++ sdk/js/src/index.ts | 924 +----------------- sdk/js/src/shared.ts | 61 ++ sdk/js/src/solana.ts | 2 +- sdk/js/src/viem.ts | 2 +- sdk/python/src/dstack_sdk/__init__.py | 28 +- .../{dstack_client.py => dstack_client_v0.py} | 26 + sdk/python/src/dstack_sdk/dstack_client_v1.py | 6 +- sdk/python/src/dstack_sdk/ethereum.py | 4 +- sdk/python/src/dstack_sdk/solana.py | 4 +- sdk/python/tests/test_client.py | 43 +- sdk/python/tests/test_typing.py | 2 +- sdk/rust/README.md | 8 +- sdk/rust/examples/dstack_client_usage.rs | 7 +- sdk/rust/examples/tappd_client_usage.rs | 3 + .../{dstack_client.rs => dstack_client_v0.rs} | 10 +- sdk/rust/src/dstack_client_v1.rs | 6 +- sdk/rust/src/ethereum.rs | 2 +- sdk/rust/src/lib.rs | 2 +- sdk/rust/src/tappd_client.rs | 10 +- sdk/rust/tests/test_client.rs | 11 +- sdk/rust/tests/test_client_v1.rs | 6 +- sdk/rust/tests/test_eth.rs | 7 +- sdk/rust/tests/test_tappd_client.rs | 3 + sdk/rust/types/README.md | 2 +- .../types/src/{dstack.rs => dstack_v0.rs} | 0 sdk/rust/types/src/dstack_v1.rs | 2 +- sdk/rust/types/src/lib.rs | 2 +- sdk/rust/types/src/tappd.rs | 2 +- 34 files changed, 1111 insertions(+), 972 deletions(-) rename sdk/go/dstack/{client.go => client_v0.go} (99%) rename sdk/go/dstack/{client_test.go => client_v0_test.go} (100%) create mode 100644 sdk/js/src/client-v0.ts create mode 100644 sdk/js/src/client-v1.ts create mode 100644 sdk/js/src/shared.ts rename sdk/python/src/dstack_sdk/{dstack_client.py => dstack_client_v0.py} (95%) rename sdk/rust/src/{dstack_client.rs => dstack_client_v0.rs} (97%) rename sdk/rust/types/src/{dstack.rs => dstack_v0.rs} (100%) diff --git a/.claude/agents/sdk-sync-checker.md b/.claude/agents/sdk-sync-checker.md index 794123da8..4e4050109 100644 --- a/.claude/agents/sdk-sync-checker.md +++ b/.claude/agents/sdk-sync-checker.md @@ -67,10 +67,10 @@ Details: ## Locations - Protos: `dstack/guest-agent/rpc/proto/*.proto` -- Python: `sdk/python/src/dstack_sdk/dstack_client.py` -- Go: `sdk/go/dstack/client.go` -- Rust: `sdk/rust/types/src/dstack.rs` -- JS: `sdk/js/src/index.ts` +- Python: `sdk/python/src/dstack_sdk/dstack_client_v0.py`, `dstack_client_v1.py` +- Go: `sdk/go/dstack/client_v0.go`, `client_v1.go` +- Rust: `sdk/rust/types/src/dstack_v0.rs`, `dstack_v1.rs` +- JS: `sdk/js/src/client-v0.ts`, `client-v1.ts` - Docs: `sdk/curl/api.md`, `sdk/curl/api-tappd.md` Focus on API surface differences. Provide specific file paths and line numbers. diff --git a/CHANGELOG.md b/CHANGELOG.md index 808b7e61d..97d354190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - sdk: the Go SDK's v1 `IssueCert` defaults `usage_server_auth` to true, as the Rust, Python and JavaScript v1 clients already did. Go was the odd one out, so the same argument-free call produced a certificate that could serve TLS in three languages and one that could not in the fourth — and a certificate you cannot serve with is useless to most callers. `WithCertUsageServerAuth(false)` opts out. v0's `GetTlsKey` keeps its `false` default deliberately: that is what the released 0.5.x Go SDK sent, and `DstackClientV0` mirrors released behaviour rather than the better choice - sdk: the JavaScript v1 `issueCert` response no longer carries a raw-bytes accessor. `asUint8Array()` is **removed rather than renamed**: it existed to feed the private key into the blockchain adapters, and v1 has no chain-flavoured surface. `IssueCert` returns TLS material, PEM is the form a TLS stack takes, and a caller who genuinely needs DER converts it with a standard library. The Rust, Python and Go v1 clients already returned the PEM string and the chain alone, so all four now agree. v0's `GetTlsKeyResponse.asUint8Array` is untouched — released API, and the viem and solana adapters depend on its truncating behaviour - sdk: the JavaScript v1 GPU evidence bundle's `asUint8Array()` is renamed `decodeEvidence()`, matching Python's and Rust's `decode_evidence` and Go, which hands back the decoded `Evidence` bytes directly. The name now says what the bytes are — the vendor's evidence, hex off the wire and decoded byte-exact, because sha256 over precisely those bytes is what the measured `gpu-attestation` event commits to +- sdk: the v0 modules carry a `_v0` suffix, so the file a reader opens matches the client it holds. Rust's `dstack_sdk::dstack_client` becomes `dstack_sdk::dstack_client_v0` and `dstack_sdk_types::dstack` becomes `dstack_sdk_types::dstack_v0`; Python's `dstack_sdk.dstack_client` becomes `dstack_sdk.dstack_client_v0`; Go's `client.go`/`client_test.go` become `client_v0.go`/`client_v0_test.go`; and the JavaScript `index.ts`, which held both surfaces in one file, splits into `client-v0.ts`, `client-v1.ts` and a `shared.ts`, leaving `index.ts` as a barrel that re-exports exactly the names it always did. Until now the unsuffixed *file* meant v0 while the unsuffixed *class* meant v1, so a reader opening `dstack_client.rs` for the recommended client found the legacy one instead. **There are deliberately no backward-compat module aliases**: 0.6.0 is the loud-break release, and an import of an old module path fails at build time rather than silently binding the frozen surface under a name that now means something else. Package-level exports are untouched in every SDK — `dstack_sdk::DstackClient`, `from dstack_sdk import DstackClientV0` and `@phala/dstack-sdk`'s public surface are exactly what they were; only a deep import of the module path moves. In Go this is file naming alone, since it is all one `package dstack` +- sdk: the v0 clients are deprecated in the way each language's tooling understands, not only in prose. Rust's `DstackClientV0` and `TappdClient` carry `#[deprecated(since = "0.6.0")]`, so a downstream build warns at the `use` and at every call; Python's `DstackClientV0` and `AsyncDstackClientV0` emit a `DeprecationWarning` on construction, through the same helper `TappdClient` already used, alongside the `.. deprecated:: 0.6.0` docstring note they already carried. Go's `// Deprecated:` markers and JavaScript's `@deprecated` JSDoc were already in place; a few Go ones sat mid-comment rather than as their own trailing paragraph, which is the only form the tooling recognises, and are repaired. Nothing is removed and no behaviour changes — the frozen surface stays reachable under its explicit name, it just says what it is at build time now ### Removed diff --git a/sdk/go/dstack/client.go b/sdk/go/dstack/client_v0.go similarity index 99% rename from sdk/go/dstack/client.go rename to sdk/go/dstack/client_v0.go index 03f0cb5a2..11f12027b 100644 --- a/sdk/go/dstack/client.go +++ b/sdk/go/dstack/client_v0.go @@ -575,12 +575,14 @@ func (c *DstackClientV0) EmitEvent(ctx context.Context, event string, payload [] // Legacy methods for backward compatibility with warnings // DeriveKey is deprecated. Use GetKey instead. +// // Deprecated: Use GetKey instead. func (c *DstackClientV0) DeriveKey(path string, subject string, altNames []string) (*GetTlsKeyResponse, error) { return nil, fmt.Errorf("deriveKey is deprecated, please use GetKey instead") } // TdxQuote is deprecated. Use GetQuote instead. +// // Deprecated: Use GetQuote instead. func (c *DstackClientV0) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { c.logger.Warn("tdxQuote is deprecated, please use GetQuote instead") @@ -601,6 +603,7 @@ type TappdClient struct { } // NewTappdClient creates a new deprecated TappdClient. +// // Deprecated: Use NewDstackClient instead. func NewTappdClient(opts ...DstackClientOption) *TappdClient { // Create a modified option to use TAPPD_SIMULATOR_ENDPOINT @@ -632,6 +635,7 @@ func NewTappdClient(opts ...DstackClientOption) *TappdClient { // Override deprecated methods to use proper tappd RPC paths // DeriveKey is deprecated. Use GetKey instead. +// // Deprecated: Use GetKey instead. func (tc *TappdClient) DeriveKey(ctx context.Context, path string, subject string, altNames []string) (*GetTlsKeyResponse, error) { tc.logger.Warn("deriveKey is deprecated, please use GetKey instead") @@ -661,6 +665,7 @@ func (tc *TappdClient) DeriveKey(ctx context.Context, path string, subject strin } // TdxQuote is deprecated. Use GetQuote instead. +// // Deprecated: Use GetQuote instead. func (tc *TappdClient) TdxQuote(ctx context.Context, reportData []byte, hashAlgorithm string) (*GetQuoteResponse, error) { tc.logger.Warn("tdxQuote is deprecated, please use GetQuote instead") diff --git a/sdk/go/dstack/client_test.go b/sdk/go/dstack/client_v0_test.go similarity index 100% rename from sdk/go/dstack/client_test.go rename to sdk/go/dstack/client_v0_test.go diff --git a/sdk/js/src/client-v0.ts b/sdk/js/src/client-v0.ts new file mode 100644 index 000000000..bb241b571 --- /dev/null +++ b/sdk/js/src/client-v0.ts @@ -0,0 +1,528 @@ +// SPDX-FileCopyrightText: © 2024-2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// The frozen v0 guest-agent surface: `DstackClientV0` and the `TappdClient` +// that predates it, plus the response types they alone return. Nothing here +// changes again -- see `DstackClientV0` for why. + +import fs from 'fs' +import { send_rpc_request } from './send-rpc-request' +import { to_hex, throwOnRpcError, resolveDstackEndpoint, type Hex } from './shared' + +export interface GetTlsKeyResponse { + __name__: Readonly<'GetTlsKeyResponse'> + + key: string + certificate_chain: string[] + + asUint8Array: (max_length?: number) => Uint8Array +} + +export interface GetKeyResponse { + __name__: Readonly<'GetKeyResponse'> + + key: Uint8Array + signature_chain: Uint8Array[] +} + +export interface SignResponse { + __name__: Readonly<'SignResponse'> + + signature: Uint8Array + signature_chain: Uint8Array[] + public_key: Uint8Array +} + +export interface VerifyResponse { + __name__: Readonly<'VerifyResponse'> + + valid: boolean +} + +export type TdxQuoteHashAlgorithms = + 'sha256' | 'sha384' | 'sha512' | 'sha3-256' | 'sha3-384' | 'sha3-512' | + 'keccak256' | 'keccak384' | 'keccak512' | 'raw' + +export interface EventLog { + imr: number + event_type: number + digest: string + event: string + event_payload: string + version?: 1 | 2 + preimage?: string +} + +export interface TcbInfo { + mrtd: string + rtmr0: string + rtmr1: string + rtmr2: string + rtmr3: string + app_compose: string + event_log: EventLog[] +} + +export type TcbInfoV03x = TcbInfo & { + rootfs_hash?: string +} + +export type TcbInfoV05x = TcbInfo & { + mr_aggregated: string + os_image_hash: string + compose_hash: string + device_id: string +} + +export interface InfoResponse { + app_id: string + instance_id: string + app_cert: string + tcb_info: VersionTcbInfo + app_name: string + device_id: string + mr_aggregated?: string + os_image_hash?: string // Optional: empty if OS image is not measured by KMS + key_provider_info: string + compose_hash: string + vm_config?: string + // Cloud provider sys_vendor (e.g. "Google"). Available on dstack OS >= 0.5.7. + cloud_vendor?: string + // Cloud provider product_name (e.g. "Google Compute Engine"). Available on dstack OS >= 0.5.7. + cloud_product?: string +} + +export interface GetQuoteResponse { + quote: Hex + event_log: string + report_data?: Hex + vm_config?: string +} + +export interface AttestResponse { + __name__: Readonly<'AttestResponse'> + + attestation: Hex +} + +export interface VersionResponse { + __name__: Readonly<'VersionResponse'> + + version: string + rev: string +} + +// The v0 byte accessor, and v0's alone: the chain adapters feed it into seed +// derivation, so its truncating and zero-padding behaviour is load-bearing on +// the frozen surface. v1's IssueCert hands back PEM and nothing else. +function x509key_to_uint8array(pem: string, max_length?: number) { + const content = pem.replace(/-----BEGIN PRIVATE KEY-----/, '') + .replace(/-----END PRIVATE KEY-----/, '') + .replace(/\n/g, ''); + const binaryDer = atob(content) + if (!max_length) { + max_length = binaryDer.length + } + const result = new Uint8Array(max_length) + for (let i = 0; i < max_length; i++) { + result[i] = binaryDer.charCodeAt(i) + } + return result +} + +export interface TlsKeyOptions { + subject?: string; + altNames?: string[]; + usageRaTls?: boolean; + usageServerAuth?: boolean; + usageClientAuth?: boolean; + // Certificate validity start (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. + notBefore?: number; + // Certificate validity end (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. + notAfter?: number; + // Embed app info into the certificate. Requires dstack OS >= 0.5.7. + withAppInfo?: boolean; +} + +const SECP256K1_ALGORITHMS = new Set(['secp256k1', 'k256', '']) + +/** + * Client for the frozen v0 guest agent surface, served at `/` and, + * since dstack 0.6.0, equivalently at `/v0/`. + * + * This surface is closed at the dstack 0.5.11 shape and will not change again. + * New capability lands in `DstackClientV1`, which derives *different* key + * material for the same inputs -- the two are separate derivation trees, not + * two spellings of one. + * + * @deprecated Legacy surface, kept for apps that already published v0-derived + * material and therefore cannot move. Use `DstackClientV1`, which the + * unsuffixed `DstackClient` now names, for anything new. + */ +export class DstackClientV0 { + protected endpoint: string + + constructor(endpoint: string | undefined = undefined) { + this.endpoint = resolveDstackEndpoint(endpoint) + } + + private async ensureAlgorithmSupported(algorithm: string): Promise { + if (SECP256K1_ALGORITHMS.has(algorithm)) return + try { + await this.version() + } catch { + throw new Error(`algorithm "${algorithm}" is not supported: OS version too old (Version RPC unavailable)`) + } + } + + private async ensureTlsKeyOptionsSupported(featureNames: string[]): Promise { + try { + await this.version() + } catch { + throw new Error(`TLS key options [${featureNames.join(', ')}] are not supported: OS version too old (Version RPC unavailable)`) + } + } + + async getKey(path: string = '', purpose: string = '', algorithm: string = 'secp256k1'): Promise { + await this.ensureAlgorithmSupported(algorithm) + const payload = JSON.stringify({ + path: path, + purpose: purpose, + algorithm: algorithm + }) + const result = await send_rpc_request<{ key: string, signature_chain: string[] }>(this.endpoint, '/GetKey', payload) + return Object.freeze({ + key: new Uint8Array(Buffer.from(result.key, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + __name__: 'GetKeyResponse', + }) + } + + async getTlsKey(options: TlsKeyOptions = {}): Promise { + const { + subject = '', + altNames = [], + usageRaTls = false, + usageServerAuth = true, + usageClientAuth = false, + notBefore, + notAfter, + withAppInfo, + } = options; + + const newFeatures: string[] = [] + if (notBefore !== undefined) newFeatures.push('notBefore') + if (notAfter !== undefined) newFeatures.push('notAfter') + if (withAppInfo !== undefined) newFeatures.push('withAppInfo') + if (newFeatures.length > 0) { + await this.ensureTlsKeyOptionsSupported(newFeatures) + } + + let raw: Record = { + subject, + usage_ra_tls: usageRaTls, + usage_server_auth: usageServerAuth, + usage_client_auth: usageClientAuth, + } + if (altNames && altNames.length) { + raw['alt_names'] = altNames + } + if (notBefore !== undefined) { + raw['not_before'] = notBefore + } + if (notAfter !== undefined) { + raw['not_after'] = notAfter + } + if (withAppInfo !== undefined) { + raw['with_app_info'] = withAppInfo + } + const payload = JSON.stringify(raw) + const result = await send_rpc_request(this.endpoint, '/GetTlsKey', payload) + const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) + return Object.freeze({ + ...result, + asUint8Array, + __name__: 'GetTlsKeyResponse', + }) + } + + /** + * Request a TDX quote for the given report data. + * + * Needs Intel TDX. Without it the guest agent returns an error and this + * throws, and on GCP Confidential VMs it answers with the TDX quote alone, + * leaving out the vTPM quote GCP's verification also binds. Use `attest()` + * in both cases. + */ + async getQuote(report_data: string | Buffer | Uint8Array): Promise { + let hex = to_hex(report_data) + if (hex.length > 128) { + throw new Error(`Report data is too large, it should be less than 64 bytes.`) + } + const payload = JSON.stringify({ report_data: hex }) + const result = await send_rpc_request(this.endpoint, '/GetQuote', payload) + if ('error' in result) { + const err = result['error'] as string + throw new Error(err) + } + return Object.freeze(result) + } + + /** + * Requests a versioned attestation for the given report data. + * + * GPU evidence is not available here: this surface is frozen at the 0.5.11 + * shape. Use `DstackClientV1.attest` or `DstackClientV1.attestGpu`. + */ + async attest(report_data: string | Buffer | Uint8Array): Promise { + let hex = to_hex(report_data) + if (hex.length > 128) { + throw new Error(`Report data is too large, it should be less than 64 bytes.`) + } + const payload = JSON.stringify({ report_data: hex }) + const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/Attest', payload) + throwOnRpcError(result) + return Object.freeze({ + __name__: 'AttestResponse', + attestation: result.attestation as Hex, + }) + } + + async info(): Promise> { + const result = await send_rpc_request, 'tcb_info'> & { tcb_info: string }>(this.endpoint, '/Info', '{}') + return Object.freeze({ + ...result, + tcb_info: JSON.parse(result.tcb_info) as T, + }) + } + + /** + * Query the guest-agent version. + * + * Returns the version on OS >= 0.5.7. + * Throws on older OS versions that lack the Version RPC. + */ + async version(): Promise { + const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/Version', '{}') + return Object.freeze({ + ...result, + __name__: 'VersionResponse', + }) + } + + async isReachable(): Promise { + try { + // Use info endpoint to test connectivity with 500ms timeout + await send_rpc_request(this.endpoint, '/Info', '{}', 500) + return true + } catch (error) { + return false + } + } + + /** + * Emit an event. This extends the event to RTMR3 on TDX platform. + * + * Requires dstack OS 0.5.0 or later, and removed in 0.6.0: runtime RTMR3 + * events became system-owned, so a 0.6.0 agent answers every call with an + * error. It stays here because the frozen surface still carries the method, + * and the agent's own explanation is more useful than one invented here. + * + * @param event The event name + * @param payload The event data as string or Buffer or Uint8Array + */ + async emitEvent(event: string, payload: string | Buffer | Uint8Array): Promise { + if (!event) { + throw new Error('Event name cannot be empty') + } + + const hexPayload = to_hex(payload) + const result = await send_rpc_request( + this.endpoint, + '/EmitEvent', + JSON.stringify({ + event: event, + payload: hexPayload + }) + ) + throwOnRpcError(result) + } + + /** + * Signs a payload using a derived key. + * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") + * @param data The data to sign. If algorithm is "secp256k1_prehashed", this must be a 32-byte hash. + * @returns A SignResponse containing the signature, signature chain, and public key. + */ + async sign(algorithm: string, data: string | Buffer | Uint8Array): Promise { + const hexData = to_hex(data); + if (algorithm === 'secp256k1_prehashed' && hexData.length !== 64) { + throw new Error(`Pre-hashed signing requires a 32-byte digest, but received ${hexData.length / 2} bytes`); + } + + const payload = JSON.stringify({ + algorithm: algorithm, + data: hexData + }); + + const result = await send_rpc_request<{ signature: string, signature_chain: string[], public_key: string }>(this.endpoint, '/Sign', payload); + + return Object.freeze({ + signature: new Uint8Array(Buffer.from(result.signature, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), + __name__: 'SignResponse', + }); + } + + /** + * Verifies a payload signature. + * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") + * @param data The data that was signed. + * @param signature The signature to verify. + * @param publicKey The public key to use for verification. + * @returns A VerifyResponse indicating if the signature is valid. + */ + async verify( + algorithm: string, + data: string | Buffer | Uint8Array, + signature: string | Buffer | Uint8Array, + publicKey: string | Buffer | Uint8Array + ): Promise { + const payload = JSON.stringify({ + algorithm: algorithm, + data: to_hex(data), + signature: to_hex(signature), + public_key: to_hex(publicKey) + }); + + const result = await send_rpc_request<{ valid: boolean }>(this.endpoint, '/Verify', payload); + throwOnRpcError(result) + + return Object.freeze({ + ...result, + __name__: 'VerifyResponse', + }); + } + + // + // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. + // These methods don't mean fully compatible as past, but we keep them here until next major version. + // + + /** + * @deprecated Use getKey instead. + * @param path The path to the key. + * @param subject The subject of the key. + * @param altNames The alternative names of the key. + * @returns The key. + */ + async deriveKey(path?: string, subject?: string, altNames?: string[]): Promise { + throw new Error('deriveKey is deprecated, please use getKey instead.') + } + + /** + * @deprecated Use getQuote instead. + * @param report_data The report data. + * @param hash_algorithm The hash algorithm. + * @returns The quote. + */ + async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { + console.warn('tdxQuote is deprecated, please use getQuote instead') + if (hash_algorithm !== "raw") { + throw new Error('tdxQuote only supports raw hash algorithm.') + } + return this.getQuote(report_data) + } +} + +/** + * Client for the pre-0.3 tappd service, kept for applications that still call it. + * + * It names `DstackClientV0` rather than the `DstackClient` alias on purpose: the + * alias points at v1 now, and tappd speaks the v0 wire surface. + * + * @deprecated Superseded by `DstackClientV0` in dstack 0.3.0, and by + * `DstackClientV1` for anything new. + */ +export class TappdClient extends DstackClientV0 { + constructor(endpoint: string | undefined = undefined) { + if (endpoint === undefined) { + if (process.env.TAPPD_SIMULATOR_ENDPOINT) { + console.warn(`Using tappd endpoint: ${process.env.TAPPD_SIMULATOR_ENDPOINT}`) + endpoint = process.env.TAPPD_SIMULATOR_ENDPOINT + } else { + // Try paths in order: legacy paths first, then namespaced paths + const socketPaths = [ + '/var/run/tappd.sock', + '/run/tappd.sock', + '/var/run/dstack/tappd.sock', + '/run/dstack/tappd.sock', + ] + endpoint = socketPaths.find(p => fs.existsSync(p)) ?? socketPaths[0] + } + } + console.warn('TappdClient is deprecated, please use DstackClientV0 instead') + super(endpoint) + } + + /** + * @deprecated Use getKey instead. + * @param path The path to the key. + * @param subject The subject of the key. + * @param altNames The alternative names of the key. + * @returns The key. + */ + async deriveKey(path?: string, subject?: string, alt_names?: string[]): Promise { + console.warn('deriveKey is deprecated, please use getKey instead'); + let raw: Record = { path: path || '', subject: subject || path || '' } + if (alt_names && alt_names.length) { + raw['alt_names'] = alt_names + } + const payload = JSON.stringify(raw) + const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.DeriveKey', payload) + const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) + return Object.freeze({ + ...result, + asUint8Array, + __name__: 'GetTlsKeyResponse', + }) + } + + /** + * @deprecated Use getQuote instead. + * @param report_data The report data. + * @param hash_algorithm The hash algorithm. + * @returns The quote. + */ + async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { + console.warn('tdxQuote is deprecated, please use getQuote instead'); + let hex = to_hex(report_data) + if (hash_algorithm === 'raw') { + if (hex.length > 128) { + throw new Error(`Report data is too large, it should less then 64 bytes when hash_algorithm is raw.`) + } + if (hex.length < 128) { + hex = hex.padStart(128, '0') + } + } + const payload = JSON.stringify({ report_data: hex, hash_algorithm }) + const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.TdxQuote', payload) + if ('error' in result) { + const err = result['error'] as string + throw new Error(err) + } + return Object.freeze(result) + } + + async isReachable(): Promise { + try { + // Use info endpoint to test connectivity with 500ms timeout + await send_rpc_request(this.endpoint, '/prpc/Tappd.Info', '{}', 500) + return true + } catch (error) { + return false + } + } +} diff --git a/sdk/js/src/client-v1.ts b/sdk/js/src/client-v1.ts new file mode 100644 index 000000000..e7338d979 --- /dev/null +++ b/sdk/js/src/client-v1.ts @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: © 2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// The `dstack.guest.v1` surface: the recommended client, and what the +// unsuffixed `DstackClient` names since 0.6.0. + +import { send_rpc_request } from './send-rpc-request' +import { to_hex, throwOnRpcError, resolveDstackEndpoint, type Hex } from './shared' + +export interface IssueCertOptionsV1 { + subject?: string; + altNames?: string[]; + usageRaTls?: boolean; + usageServerAuth?: boolean; + usageClientAuth?: boolean; + withAppInfo?: boolean; + // Certificate validity start (seconds since UNIX epoch). + notBefore?: number; + // Certificate validity end (seconds since UNIX epoch). + notAfter?: number; +} + +export interface IssueCertResponseV1 { + __name__: Readonly<'IssueCertResponseV1'> + + /** + * The private key the agent generated for this certificate, PEM-encoded. + * + * PEM and nothing else. v0 attached a raw-bytes accessor here, but it existed + * to feed the key into the blockchain adapters, and v1 has no chain-flavored + * surface: this is TLS material, PEM is what a TLS stack takes, and a caller + * who genuinely wants DER converts it with a standard library. The other + * three SDKs' v1 clients return the PEM string alone too. + */ + key: string + /** The certificate chain, leaf first, each entry PEM-encoded. */ + certificate_chain: string[] +} + +export interface GetKeyResponseV1 { + __name__: Readonly<'GetKeyResponseV1'> + + /** The derived private key: 32 raw bytes for both supported algorithms. */ + key: Uint8Array + /** SEC1 compressed (33 bytes) for secp256k1, raw (32 bytes) for ed25519. */ + public_key: Uint8Array + /** Two links: the app root key over the v1 key claim, then the KMS root key. */ + signature_chain: Uint8Array[] +} + +export interface AttestResponseV1 { + __name__: Readonly<'AttestResponseV1'> + + attestation: Hex + + /** + * The GPU evidence nvattest recorded at boot, in the same bundle shape + * {@link DstackClientV1.attestGpu} returns, so one parser serves both. Empty + * unless the request asked for it and the guest has boot-time output -- + * absence is the empty array, not a sentinel. + * + * Not bound to `report_data`: nvattest ran at boot against its own nonce. + * Bind it by replaying the runtime event log and comparing sha256 of the + * bytes `decodeEvidence()` returns against `evidence_sha256` in the measured + * `gpu-attestation` event. + */ + boottime_gpu_evidence: GpuEvidenceBundleV1[] +} + +/** + * One vendor's GPU evidence, however it was obtained. + * + * Shared by {@link DstackClientV1.attestGpu} and + * {@link AttestResponseV1.boottime_gpu_evidence}; dispatch on `vendor` and + * `format`, because the two sources answer different questions and a verifier + * for one does not appraise the other: + * + * - `nvidia-nvattest-collect-evidence-json-v1` -- collected on demand by + * `attestGpu`, against the nonce you passed. + * - `nvidia-nvattest-boottime-json-v1` -- the record written at boot, carried + * by `attest`. + */ +export interface GpuEvidenceBundleV1 { + /** Stable GPU vendor identifier, for example `nvidia`. */ + vendor: string + /** Vendor-specific evidence format and version. */ + format: string + /** Opaque vendor-native evidence bytes, hex-encoded by the JSON RPC. */ + evidence: Hex + + /** + * The evidence as raw bytes, exactly as the vendor emitted it. + * + * Byte-exact by design: for a boot-time bundle the binding rule is sha256 + * over precisely these bytes, compared against `evidence_sha256` in the + * measured `gpu-attestation` event, so parsing and re-serialising the JSON + * breaks the comparison. + */ + decodeEvidence: () => Uint8Array +} + +export interface AttestGpuResponseV1 { + __name__: Readonly<'AttestGpuResponseV1'> + + bundles: GpuEvidenceBundleV1[] +} + +/** + * Identity and configuration. Not attestation. + * + * The measurement registers and the event log are deliberately absent -- they + * belong to `attest()`, which returns them quote-backed. Nothing here arrives + * with a quote behind it, so confirm anything you rely on against an + * attestation. + * + * `app_id`, `compose_hash`, `instance_id`, `device_id`, `os_image_hash` and + * `mr_aggregated` are lowercase hex; the rest are plain strings, with the three + * document fields carrying JSON owned by someone else (see `docs/guest-api-v1.md`). + */ +export interface InfoResponseV1 { + __name__: Readonly<'InfoResponseV1'> + + app_id: Hex + app_name: string + compose_hash: Hex + /** + * The app-compose document, verbatim. `compose_hash` is sha256 over exactly + * these bytes, so do not parse and re-serialize before hashing: key order, + * whitespace and unknown fields all change the digest. + */ + app_compose: string + instance_id: Hex + /** Identifies the host machine, not this instance. */ + device_id: Hex + os_image_hash: Hex + mr_aggregated: Hex + vm_config: string + key_provider_info: string + cloud_vendor: string + cloud_product: string +} + +export interface VersionResponseV1 { + __name__: Readonly<'VersionResponseV1'> + + version: string + rev: string +} + +/** + * Attach the byte accessor to the bundles a v1 RPC returned. + * + * Shared by `attest` and `attestGpu` so both hand back the same object shape, + * which is the point of the wire message being shared. + */ +function to_gpu_evidence_bundles( + bundles: Array> | undefined, +): GpuEvidenceBundleV1[] { + return (bundles ?? []).map(bundle => Object.freeze({ + ...bundle, + decodeEvidence: () => new Uint8Array(Buffer.from(bundle.evidence, 'hex')), + })) +} + +/** + * Client for `dstack.guest.v1`, served at `/v1/` by dstack 0.6.0 and later. + * + * Six methods, no more: v1 serves only what needs the TEE -- deriving keys from + * the app root key, and attesting. `sign`, `verify`, `getQuote`, `gpuInfo` and + * `emitEvent` are absent by design, not by oversight; see `docs/guest-api-v1.md`. + * + * A v1 key is NOT the v0 key of the same name. v1 derives under its own HKDF + * salt and binds the algorithm into the derivation, so `getKey('storage-encryption', + * 'secp256k1')` here returns different material than `DstackClientV0.getKey` + * ever did, and secp256k1 and ed25519 no longer share one secret. There is no + * compatibility mode. + * + * An agent that predates v1 has no `/v1` mount, so it answers with a plain + * HTTP 404 page rather than a JSON error. `version()` is the cheapest probe. + */ +export class DstackClientV1 { + protected endpoint: string + + constructor(endpoint: string | undefined = undefined) { + this.endpoint = resolveDstackEndpoint(endpoint) + } + + /** + * Issue a certificate for this application. + * + * The key is freshly generated on every call and is not derived from the app + * identity: two identical requests produce two unrelated keys. Use + * {@link getKey} for stable, attestable material. + */ + async issueCert(options: IssueCertOptionsV1 = {}): Promise { + const { + subject = '', + altNames = [], + usageRaTls = false, + usageServerAuth = true, + usageClientAuth = false, + withAppInfo = false, + notBefore, + notAfter, + } = options; + + const raw: Record = { + subject, + usage_ra_tls: usageRaTls, + usage_server_auth: usageServerAuth, + usage_client_auth: usageClientAuth, + with_app_info: withAppInfo, + } + if (altNames && altNames.length) { + raw['alt_names'] = altNames + } + // Both are `optional` on the wire, so send them only when asked for rather + // than pinning a validity window the caller never chose. + if (notBefore !== undefined) { + raw['not_before'] = notBefore + } + if (notAfter !== undefined) { + raw['not_after'] = notAfter + } + const result = await send_rpc_request<{ key: string, certificate_chain: string[] }>( + this.endpoint, '/v1/IssueCert', JSON.stringify(raw)) + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'IssueCertResponseV1' as const, + }) + } + + /** + * Derive an application key from `(domain, algorithm)`. + * + * `domain` is an opaque domain-separation string, not a DNS name and not a + * path: derivation is flat, so `a/b` is not a child of `a` and no key derived + * here can derive another. + * + * @param domain Caller-chosen domain-separation string. May be empty. + * @param algorithm Exactly `secp256k1` or `ed25519`. No default, no `k256` alias. + */ + async getKey(domain: string, algorithm: string): Promise { + // v0 defaulted an empty algorithm to secp256k1, which let a typo hand back a + // key of the wrong type under a name the caller thought meant something else. + if (!algorithm) { + throw new Error('algorithm is required, use "secp256k1" or "ed25519"') + } + const payload = JSON.stringify({ domain, algorithm }) + const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[] }>( + this.endpoint, '/v1/GetKey', payload) + throwOnRpcError(result) + return Object.freeze({ + key: new Uint8Array(Buffer.from(result.key, 'hex')), + public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), + signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), + __name__: 'GetKeyResponseV1' as const, + }) + } + + /** + * Produce a versioned attestation over the given report data. + * + * The only CVM attestation entry point in v1: the attestation already carries + * the TDX quote and the event log, so there is no separate `getQuote`. + * + * @param report_data 1 to 64 bytes, zero-padded on the right to 64 by the agent. + * @param include_boottime_gpu_evidence Also return the boot-time GPU evidence, + * as the same {@link GpuEvidenceBundleV1} list `attestGpu` returns, so a + * verifier gets both in one round trip. It is not bound to `report_data`. + */ + async attest( + report_data: string | Buffer | Uint8Array, + include_boottime_gpu_evidence: boolean = false, + ): Promise { + const hex = to_hex(report_data) + if (hex.length === 0) { + throw new Error('report data must not be empty') + } + if (hex.length > 128) { + throw new Error(`report data must be at most 64 bytes, but received ${hex.length / 2}`) + } + const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) + const result = await send_rpc_request<{ + attestation: string, + boottime_gpu_evidence?: Array>, + }>(this.endpoint, '/v1/Attest', payload) + throwOnRpcError(result) + return Object.freeze({ + __name__: 'AttestResponseV1' as const, + attestation: result.attestation as Hex, + boottime_gpu_evidence: to_gpu_evidence_bundles(result.boottime_gpu_evidence), + }) + } + + /** + * Collect GPU attestation evidence now, against a nonce you choose. + * + * Returns vendor-native evidence, not a verdict: select a verifier from each + * bundle's `vendor` and `format`, then check the signature, certificate chain, + * measurements and the embedded nonce yourself. Evidence does not by itself + * bind the GPU to this CVM. + * + * @param nonce Exactly 32 bytes, passed to the GPU verbatim. SPDM fixes the + * length; hash a longer challenge yourself. + */ + async attestGpu(nonce: Buffer | Uint8Array): Promise { + if (nonce.length !== 32) { + throw new Error(`nonce must be exactly 32 bytes, but received ${nonce.length}`) + } + const payload = JSON.stringify({ nonce: to_hex(nonce) }) + const result = await send_rpc_request<{ + bundles?: Array>, + }>(this.endpoint, '/v1/AttestGpu', payload) + throwOnRpcError(result) + return Object.freeze({ + bundles: to_gpu_evidence_bundles(result.bundles), + __name__: 'AttestGpuResponseV1' as const, + }) + } + + /** Return this application's identity and configuration. */ + async info(): Promise { + const result = await send_rpc_request>(this.endpoint, '/v1/Info', '{}') + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'InfoResponseV1' as const, + }) + } + + /** Return the guest agent version. Also the cheapest probe for v1 support. */ + async version(): Promise { + const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/v1/Version', '{}') + throwOnRpcError(result) + return Object.freeze({ + ...result, + __name__: 'VersionResponseV1' as const, + }) + } +} + +/** + * The recommended client: `dstack.guest.v1`. + * + * This alias used to mean `DstackClientV0`. Code that upgrades without + * changing the name fails loudly rather than quietly deriving different keys: + * the v1 signatures differ, and `getKey` requires `algorithm` explicitly, so a + * v0 call site stops compiling (or throws) instead of returning wrong material. + * To stay on the frozen surface, name `DstackClientV0` from `./client-v0`. + */ +export const DstackClient = DstackClientV1 +export type DstackClient = DstackClientV1 diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts index a509973da..17fbcd17d 100644 --- a/sdk/js/src/index.ts +++ b/sdk/js/src/index.ts @@ -2,925 +2,15 @@ // // SPDX-License-Identifier: Apache-2.0 -import fs from 'fs' -import { send_rpc_request } from './send-rpc-request' +// The package entry point, and only that: every declaration lives in the module +// named after the surface it belongs to. + export { getComposeHash } from './get-compose-hash' export { verifyEnvEncryptPublicKey, verifyEnvEncryptPublicKeyLegacy } from './verify-env-encrypt-public-key' export type { VerifyOptions } from './verify-env-encrypt-public-key' -export interface GetTlsKeyResponse { - __name__: Readonly<'GetTlsKeyResponse'> - - key: string - certificate_chain: string[] - - asUint8Array: (max_length?: number) => Uint8Array -} - -export interface GetKeyResponse { - __name__: Readonly<'GetKeyResponse'> - - key: Uint8Array - signature_chain: Uint8Array[] -} - -export interface SignResponse { - __name__: Readonly<'SignResponse'> - - signature: Uint8Array - signature_chain: Uint8Array[] - public_key: Uint8Array -} - -export interface VerifyResponse { - __name__: Readonly<'VerifyResponse'> - - valid: boolean -} - - -export type Hex = `${string}` - -export type TdxQuoteHashAlgorithms = - 'sha256' | 'sha384' | 'sha512' | 'sha3-256' | 'sha3-384' | 'sha3-512' | - 'keccak256' | 'keccak384' | 'keccak512' | 'raw' - -export interface EventLog { - imr: number - event_type: number - digest: string - event: string - event_payload: string - version?: 1 | 2 - preimage?: string -} - -export interface TcbInfo { - mrtd: string - rtmr0: string - rtmr1: string - rtmr2: string - rtmr3: string - app_compose: string - event_log: EventLog[] -} - -export type TcbInfoV03x = TcbInfo & { - rootfs_hash?: string -} - -export type TcbInfoV05x = TcbInfo & { - mr_aggregated: string - os_image_hash: string - compose_hash: string - device_id: string -} - -export interface InfoResponse { - app_id: string - instance_id: string - app_cert: string - tcb_info: VersionTcbInfo - app_name: string - device_id: string - mr_aggregated?: string - os_image_hash?: string // Optional: empty if OS image is not measured by KMS - key_provider_info: string - compose_hash: string - vm_config?: string - // Cloud provider sys_vendor (e.g. "Google"). Available on dstack OS >= 0.5.7. - cloud_vendor?: string - // Cloud provider product_name (e.g. "Google Compute Engine"). Available on dstack OS >= 0.5.7. - cloud_product?: string -} - -export interface GetQuoteResponse { - quote: Hex - event_log: string - report_data?: Hex - vm_config?: string -} - -export interface AttestResponse { - __name__: Readonly<'AttestResponse'> - - attestation: Hex -} - -export interface VersionResponse { - __name__: Readonly<'VersionResponse'> - - version: string - rev: string -} - -export function to_hex(data: string | Buffer | Uint8Array): string { - if (typeof data === 'string') { - return Buffer.from(data).toString('hex'); - } - if (data instanceof Uint8Array) { - return Buffer.from(data).toString('hex'); - } - return (data as Buffer).toString('hex'); -} - -// The v0 byte accessor, and v0's alone: the chain adapters feed it into seed -// derivation, so its truncating and zero-padding behaviour is load-bearing on -// the frozen surface. v1's IssueCert hands back PEM and nothing else. -function x509key_to_uint8array(pem: string, max_length?: number) { - const content = pem.replace(/-----BEGIN PRIVATE KEY-----/, '') - .replace(/-----END PRIVATE KEY-----/, '') - .replace(/\n/g, ''); - const binaryDer = atob(content) - if (!max_length) { - max_length = binaryDer.length - } - const result = new Uint8Array(max_length) - for (let i = 0; i < max_length; i++) { - result[i] = binaryDer.charCodeAt(i) - } - return result -} - -export interface TlsKeyOptions { - subject?: string; - altNames?: string[]; - usageRaTls?: boolean; - usageServerAuth?: boolean; - usageClientAuth?: boolean; - // Certificate validity start (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. - notBefore?: number; - // Certificate validity end (seconds since UNIX epoch). Requires dstack OS >= 0.5.7. - notAfter?: number; - // Embed app info into the certificate. Requires dstack OS >= 0.5.7. - withAppInfo?: boolean; -} - -const SECP256K1_ALGORITHMS = new Set(['secp256k1', 'k256', '']) - -/** Socket paths the clients probe, legacy first, then the namespaced variants. */ -const DSTACK_SOCKET_PATHS = [ - '/var/run/dstack.sock', - '/run/dstack.sock', - '/var/run/dstack/dstack.sock', - '/run/dstack/dstack.sock', -] - -/** - * A prpc handler reports failure in the response body rather than by refusing to - * answer, so every method has to look for it; an unchecked call would hand the - * caller a response object with every field missing. - */ -function throwOnRpcError(result: unknown): void { - if (result && typeof result === 'object' && 'error' in result) { - throw new Error(String((result as { error: unknown }).error)) - } -} - -/** - * Attach the byte accessor to the bundles a v1 RPC returned. - * - * Shared by `attest` and `attestGpu` so both hand back the same object shape, - * which is the point of the wire message being shared. - */ -function to_gpu_evidence_bundles( - bundles: Array> | undefined, -): GpuEvidenceBundleV1[] { - return (bundles ?? []).map(bundle => Object.freeze({ - ...bundle, - decodeEvidence: () => new Uint8Array(Buffer.from(bundle.evidence, 'hex')), - })) -} - -/** - * Client for the frozen v0 guest agent surface, served at `/` and, - * since dstack 0.6.0, equivalently at `/v0/`. - * - * This surface is closed at the dstack 0.5.11 shape and will not change again. - * New capability lands in {@link DstackClientV1}, which derives *different* key - * material for the same inputs -- the two are separate derivation trees, not - * two spellings of one. - * - * @deprecated Legacy surface, kept for apps that already published v0-derived - * material and therefore cannot move. Use {@link DstackClientV1}, which the - * unsuffixed `DstackClient` now names, for anything new. - */ -export class DstackClientV0 { - protected endpoint: string - - constructor(endpoint: string | undefined = undefined) { - if (endpoint === undefined) { - if (process.env.DSTACK_SIMULATOR_ENDPOINT) { - console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) - endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT - } else { - endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] - } - } - if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { - throw new Error(`Unix socket file ${endpoint} does not exist`); - } - this.endpoint = endpoint - } - - private async ensureAlgorithmSupported(algorithm: string): Promise { - if (SECP256K1_ALGORITHMS.has(algorithm)) return - try { - await this.version() - } catch { - throw new Error(`algorithm "${algorithm}" is not supported: OS version too old (Version RPC unavailable)`) - } - } - - private async ensureTlsKeyOptionsSupported(featureNames: string[]): Promise { - try { - await this.version() - } catch { - throw new Error(`TLS key options [${featureNames.join(', ')}] are not supported: OS version too old (Version RPC unavailable)`) - } - } - - async getKey(path: string = '', purpose: string = '', algorithm: string = 'secp256k1'): Promise { - await this.ensureAlgorithmSupported(algorithm) - const payload = JSON.stringify({ - path: path, - purpose: purpose, - algorithm: algorithm - }) - const result = await send_rpc_request<{ key: string, signature_chain: string[] }>(this.endpoint, '/GetKey', payload) - return Object.freeze({ - key: new Uint8Array(Buffer.from(result.key, 'hex')), - signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), - __name__: 'GetKeyResponse', - }) - } - - async getTlsKey(options: TlsKeyOptions = {}): Promise { - const { - subject = '', - altNames = [], - usageRaTls = false, - usageServerAuth = true, - usageClientAuth = false, - notBefore, - notAfter, - withAppInfo, - } = options; - - const newFeatures: string[] = [] - if (notBefore !== undefined) newFeatures.push('notBefore') - if (notAfter !== undefined) newFeatures.push('notAfter') - if (withAppInfo !== undefined) newFeatures.push('withAppInfo') - if (newFeatures.length > 0) { - await this.ensureTlsKeyOptionsSupported(newFeatures) - } - - let raw: Record = { - subject, - usage_ra_tls: usageRaTls, - usage_server_auth: usageServerAuth, - usage_client_auth: usageClientAuth, - } - if (altNames && altNames.length) { - raw['alt_names'] = altNames - } - if (notBefore !== undefined) { - raw['not_before'] = notBefore - } - if (notAfter !== undefined) { - raw['not_after'] = notAfter - } - if (withAppInfo !== undefined) { - raw['with_app_info'] = withAppInfo - } - const payload = JSON.stringify(raw) - const result = await send_rpc_request(this.endpoint, '/GetTlsKey', payload) - const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) - return Object.freeze({ - ...result, - asUint8Array, - __name__: 'GetTlsKeyResponse', - }) - } - - /** - * Request a TDX quote for the given report data. - * - * Needs Intel TDX. Without it the guest agent returns an error and this - * throws, and on GCP Confidential VMs it answers with the TDX quote alone, - * leaving out the vTPM quote GCP's verification also binds. Use `attest()` - * in both cases. - */ - async getQuote(report_data: string | Buffer | Uint8Array): Promise { - let hex = to_hex(report_data) - if (hex.length > 128) { - throw new Error(`Report data is too large, it should be less than 64 bytes.`) - } - const payload = JSON.stringify({ report_data: hex }) - const result = await send_rpc_request(this.endpoint, '/GetQuote', payload) - if ('error' in result) { - const err = result['error'] as string - throw new Error(err) - } - return Object.freeze(result) - } - - /** - * Requests a versioned attestation for the given report data. - * - * GPU evidence is not available here: this surface is frozen at the 0.5.11 - * shape. Use {@link DstackClientV1.attest} or {@link DstackClientV1.attestGpu}. - */ - async attest(report_data: string | Buffer | Uint8Array): Promise { - let hex = to_hex(report_data) - if (hex.length > 128) { - throw new Error(`Report data is too large, it should be less than 64 bytes.`) - } - const payload = JSON.stringify({ report_data: hex }) - const result = await send_rpc_request<{ attestation: string }>(this.endpoint, '/Attest', payload) - throwOnRpcError(result) - return Object.freeze({ - __name__: 'AttestResponse', - attestation: result.attestation as Hex, - }) - } - - async info(): Promise> { - const result = await send_rpc_request, 'tcb_info'> & { tcb_info: string }>(this.endpoint, '/Info', '{}') - return Object.freeze({ - ...result, - tcb_info: JSON.parse(result.tcb_info) as T, - }) - } - - /** - * Query the guest-agent version. - * - * Returns the version on OS >= 0.5.7. - * Throws on older OS versions that lack the Version RPC. - */ - async version(): Promise { - const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/Version', '{}') - return Object.freeze({ - ...result, - __name__: 'VersionResponse', - }) - } - - async isReachable(): Promise { - try { - // Use info endpoint to test connectivity with 500ms timeout - await send_rpc_request(this.endpoint, '/Info', '{}', 500) - return true - } catch (error) { - return false - } - } - - /** - * Emit an event. This extends the event to RTMR3 on TDX platform. - * - * Requires dstack OS 0.5.0 or later, and removed in 0.6.0: runtime RTMR3 - * events became system-owned, so a 0.6.0 agent answers every call with an - * error. It stays here because the frozen surface still carries the method, - * and the agent's own explanation is more useful than one invented here. - * - * @param event The event name - * @param payload The event data as string or Buffer or Uint8Array - */ - async emitEvent(event: string, payload: string | Buffer | Uint8Array): Promise { - if (!event) { - throw new Error('Event name cannot be empty') - } - - const hexPayload = to_hex(payload) - const result = await send_rpc_request( - this.endpoint, - '/EmitEvent', - JSON.stringify({ - event: event, - payload: hexPayload - }) - ) - throwOnRpcError(result) - } - - /** - * Signs a payload using a derived key. - * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") - * @param data The data to sign. If algorithm is "secp256k1_prehashed", this must be a 32-byte hash. - * @returns A SignResponse containing the signature, signature chain, and public key. - */ - async sign(algorithm: string, data: string | Buffer | Uint8Array): Promise { - const hexData = to_hex(data); - if (algorithm === 'secp256k1_prehashed' && hexData.length !== 64) { - throw new Error(`Pre-hashed signing requires a 32-byte digest, but received ${hexData.length / 2} bytes`); - } - - const payload = JSON.stringify({ - algorithm: algorithm, - data: hexData - }); - - const result = await send_rpc_request<{ signature: string, signature_chain: string[], public_key: string }>(this.endpoint, '/Sign', payload); - - return Object.freeze({ - signature: new Uint8Array(Buffer.from(result.signature, 'hex')), - signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), - public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), - __name__: 'SignResponse', - }); - } - - /** - * Verifies a payload signature. - * @param algorithm The algorithm to use (e.g., "ed25519", "secp256k1", "secp256k1_prehashed") - * @param data The data that was signed. - * @param signature The signature to verify. - * @param publicKey The public key to use for verification. - * @returns A VerifyResponse indicating if the signature is valid. - */ - async verify( - algorithm: string, - data: string | Buffer | Uint8Array, - signature: string | Buffer | Uint8Array, - publicKey: string | Buffer | Uint8Array - ): Promise { - const payload = JSON.stringify({ - algorithm: algorithm, - data: to_hex(data), - signature: to_hex(signature), - public_key: to_hex(publicKey) - }); - - const result = await send_rpc_request<{ valid: boolean }>(this.endpoint, '/Verify', payload); - throwOnRpcError(result) - - return Object.freeze({ - ...result, - __name__: 'VerifyResponse', - }); - } - - // - // Legacy methods for backward compatibility with a warning to notify users about migrating to new methods. - // These methods don't mean fully compatible as past, but we keep them here until next major version. - // - - /** - * @deprecated Use getKey instead. - * @param path The path to the key. - * @param subject The subject of the key. - * @param altNames The alternative names of the key. - * @returns The key. - */ - async deriveKey(path?: string, subject?: string, altNames?: string[]): Promise { - throw new Error('deriveKey is deprecated, please use getKey instead.') - } - - /** - * @deprecated Use getQuote instead. - * @param report_data The report data. - * @param hash_algorithm The hash algorithm. - * @returns The quote. - */ - async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { - console.warn('tdxQuote is deprecated, please use getQuote instead') - if (hash_algorithm !== "raw") { - throw new Error('tdxQuote only supports raw hash algorithm.') - } - return this.getQuote(report_data) - } -} - -// `TappdClient` names `DstackClientV0` rather than the `DstackClient` alias on -// purpose: the alias points at v1 now, and Tappd speaks the v0 wire surface. -export class TappdClient extends DstackClientV0 { - constructor(endpoint: string | undefined = undefined) { - if (endpoint === undefined) { - if (process.env.TAPPD_SIMULATOR_ENDPOINT) { - console.warn(`Using tappd endpoint: ${process.env.TAPPD_SIMULATOR_ENDPOINT}`) - endpoint = process.env.TAPPD_SIMULATOR_ENDPOINT - } else { - // Try paths in order: legacy paths first, then namespaced paths - const socketPaths = [ - '/var/run/tappd.sock', - '/run/tappd.sock', - '/var/run/dstack/tappd.sock', - '/run/dstack/tappd.sock', - ] - endpoint = socketPaths.find(p => fs.existsSync(p)) ?? socketPaths[0] - } - } - console.warn('TappdClient is deprecated, please use DstackClientV0 instead') - super(endpoint) - } - - /** - * @deprecated Use getKey instead. - * @param path The path to the key. - * @param subject The subject of the key. - * @param altNames The alternative names of the key. - * @returns The key. - */ - async deriveKey(path?: string, subject?: string, alt_names?: string[]): Promise { - console.warn('deriveKey is deprecated, please use getKey instead'); - let raw: Record = { path: path || '', subject: subject || path || '' } - if (alt_names && alt_names.length) { - raw['alt_names'] = alt_names - } - const payload = JSON.stringify(raw) - const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.DeriveKey', payload) - const asUint8Array = (length?: number) => x509key_to_uint8array(result.key, length) - return Object.freeze({ - ...result, - asUint8Array, - __name__: 'GetTlsKeyResponse', - }) - } - - /** - * @deprecated Use getQuote instead. - * @param report_data The report data. - * @param hash_algorithm The hash algorithm. - * @returns The quote. - */ - async tdxQuote(report_data: string | Buffer | Uint8Array, hash_algorithm?: TdxQuoteHashAlgorithms): Promise { - console.warn('tdxQuote is deprecated, please use getQuote instead'); - let hex = to_hex(report_data) - if (hash_algorithm === 'raw') { - if (hex.length > 128) { - throw new Error(`Report data is too large, it should less then 64 bytes when hash_algorithm is raw.`) - } - if (hex.length < 128) { - hex = hex.padStart(128, '0') - } - } - const payload = JSON.stringify({ report_data: hex, hash_algorithm }) - const result = await send_rpc_request(this.endpoint, '/prpc/Tappd.TdxQuote', payload) - if ('error' in result) { - const err = result['error'] as string - throw new Error(err) - } - return Object.freeze(result) - } - - async isReachable(): Promise { - try { - // Use info endpoint to test connectivity with 500ms timeout - await send_rpc_request(this.endpoint, '/prpc/Tappd.Info', '{}', 500) - return true - } catch (error) { - return false - } - } -} - -// --------------------------------------------------------------------------- -// dstack.guest.v1 -// --------------------------------------------------------------------------- - -export interface IssueCertOptionsV1 { - subject?: string; - altNames?: string[]; - usageRaTls?: boolean; - usageServerAuth?: boolean; - usageClientAuth?: boolean; - withAppInfo?: boolean; - // Certificate validity start (seconds since UNIX epoch). - notBefore?: number; - // Certificate validity end (seconds since UNIX epoch). - notAfter?: number; -} - -export interface IssueCertResponseV1 { - __name__: Readonly<'IssueCertResponseV1'> - - /** - * The private key the agent generated for this certificate, PEM-encoded. - * - * PEM and nothing else. v0 attached a raw-bytes accessor here, but it existed - * to feed the key into the blockchain adapters, and v1 has no chain-flavored - * surface: this is TLS material, PEM is what a TLS stack takes, and a caller - * who genuinely wants DER converts it with a standard library. The other - * three SDKs' v1 clients return the PEM string alone too. - */ - key: string - /** The certificate chain, leaf first, each entry PEM-encoded. */ - certificate_chain: string[] -} - -export interface GetKeyResponseV1 { - __name__: Readonly<'GetKeyResponseV1'> - - /** The derived private key: 32 raw bytes for both supported algorithms. */ - key: Uint8Array - /** SEC1 compressed (33 bytes) for secp256k1, raw (32 bytes) for ed25519. */ - public_key: Uint8Array - /** Two links: the app root key over the v1 key claim, then the KMS root key. */ - signature_chain: Uint8Array[] -} - -export interface AttestResponseV1 { - __name__: Readonly<'AttestResponseV1'> - - attestation: Hex - - /** - * The GPU evidence nvattest recorded at boot, in the same bundle shape - * {@link DstackClientV1.attestGpu} returns, so one parser serves both. Empty - * unless the request asked for it and the guest has boot-time output -- - * absence is the empty array, not a sentinel. - * - * Not bound to `report_data`: nvattest ran at boot against its own nonce. - * Bind it by replaying the runtime event log and comparing sha256 of the - * bytes `decodeEvidence()` returns against `evidence_sha256` in the measured - * `gpu-attestation` event. - */ - boottime_gpu_evidence: GpuEvidenceBundleV1[] -} - -/** - * One vendor's GPU evidence, however it was obtained. - * - * Shared by {@link DstackClientV1.attestGpu} and - * {@link AttestResponseV1.boottime_gpu_evidence}; dispatch on `vendor` and - * `format`, because the two sources answer different questions and a verifier - * for one does not appraise the other: - * - * - `nvidia-nvattest-collect-evidence-json-v1` -- collected on demand by - * `attestGpu`, against the nonce you passed. - * - `nvidia-nvattest-boottime-json-v1` -- the record written at boot, carried - * by `attest`. - */ -export interface GpuEvidenceBundleV1 { - /** Stable GPU vendor identifier, for example `nvidia`. */ - vendor: string - /** Vendor-specific evidence format and version. */ - format: string - /** Opaque vendor-native evidence bytes, hex-encoded by the JSON RPC. */ - evidence: Hex - - /** - * The evidence as raw bytes, exactly as the vendor emitted it. - * - * Byte-exact by design: for a boot-time bundle the binding rule is sha256 - * over precisely these bytes, compared against `evidence_sha256` in the - * measured `gpu-attestation` event, so parsing and re-serialising the JSON - * breaks the comparison. - */ - decodeEvidence: () => Uint8Array -} - -export interface AttestGpuResponseV1 { - __name__: Readonly<'AttestGpuResponseV1'> - - bundles: GpuEvidenceBundleV1[] -} - -/** - * Identity and configuration. Not attestation. - * - * The measurement registers and the event log are deliberately absent -- they - * belong to `attest()`, which returns them quote-backed. Nothing here arrives - * with a quote behind it, so confirm anything you rely on against an - * attestation. - * - * `app_id`, `compose_hash`, `instance_id`, `device_id`, `os_image_hash` and - * `mr_aggregated` are lowercase hex; the rest are plain strings, with the three - * document fields carrying JSON owned by someone else (see `docs/guest-api-v1.md`). - */ -export interface InfoResponseV1 { - __name__: Readonly<'InfoResponseV1'> - - app_id: Hex - app_name: string - compose_hash: Hex - /** - * The app-compose document, verbatim. `compose_hash` is sha256 over exactly - * these bytes, so do not parse and re-serialize before hashing: key order, - * whitespace and unknown fields all change the digest. - */ - app_compose: string - instance_id: Hex - /** Identifies the host machine, not this instance. */ - device_id: Hex - os_image_hash: Hex - mr_aggregated: Hex - vm_config: string - key_provider_info: string - cloud_vendor: string - cloud_product: string -} - -export interface VersionResponseV1 { - __name__: Readonly<'VersionResponseV1'> - - version: string - rev: string -} - -/** - * Client for `dstack.guest.v1`, served at `/v1/` by dstack 0.6.0 and later. - * - * Six methods, no more: v1 serves only what needs the TEE -- deriving keys from - * the app root key, and attesting. `sign`, `verify`, `getQuote`, `gpuInfo` and - * `emitEvent` are absent by design, not by oversight; see `docs/guest-api-v1.md`. - * - * A v1 key is NOT the v0 key of the same name. v1 derives under its own HKDF - * salt and binds the algorithm into the derivation, so `getKey('storage-encryption', - * 'secp256k1')` here returns different material than `DstackClientV0.getKey` - * ever did, and secp256k1 and ed25519 no longer share one secret. There is no - * compatibility mode. - * - * An agent that predates v1 has no `/v1` mount, so it answers with a plain - * HTTP 404 page rather than a JSON error. `version()` is the cheapest probe. - */ -export class DstackClientV1 { - protected endpoint: string - - constructor(endpoint: string | undefined = undefined) { - if (endpoint === undefined) { - if (process.env.DSTACK_SIMULATOR_ENDPOINT) { - console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) - endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT - } else { - endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] - } - } - if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { - throw new Error(`Unix socket file ${endpoint} does not exist`); - } - this.endpoint = endpoint - } - - /** - * Issue a certificate for this application. - * - * The key is freshly generated on every call and is not derived from the app - * identity: two identical requests produce two unrelated keys. Use - * {@link getKey} for stable, attestable material. - */ - async issueCert(options: IssueCertOptionsV1 = {}): Promise { - const { - subject = '', - altNames = [], - usageRaTls = false, - usageServerAuth = true, - usageClientAuth = false, - withAppInfo = false, - notBefore, - notAfter, - } = options; - - const raw: Record = { - subject, - usage_ra_tls: usageRaTls, - usage_server_auth: usageServerAuth, - usage_client_auth: usageClientAuth, - with_app_info: withAppInfo, - } - if (altNames && altNames.length) { - raw['alt_names'] = altNames - } - // Both are `optional` on the wire, so send them only when asked for rather - // than pinning a validity window the caller never chose. - if (notBefore !== undefined) { - raw['not_before'] = notBefore - } - if (notAfter !== undefined) { - raw['not_after'] = notAfter - } - const result = await send_rpc_request<{ key: string, certificate_chain: string[] }>( - this.endpoint, '/v1/IssueCert', JSON.stringify(raw)) - throwOnRpcError(result) - return Object.freeze({ - ...result, - __name__: 'IssueCertResponseV1' as const, - }) - } - - /** - * Derive an application key from `(domain, algorithm)`. - * - * `domain` is an opaque domain-separation string, not a DNS name and not a - * path: derivation is flat, so `a/b` is not a child of `a` and no key derived - * here can derive another. - * - * @param domain Caller-chosen domain-separation string. May be empty. - * @param algorithm Exactly `secp256k1` or `ed25519`. No default, no `k256` alias. - */ - async getKey(domain: string, algorithm: string): Promise { - // v0 defaulted an empty algorithm to secp256k1, which let a typo hand back a - // key of the wrong type under a name the caller thought meant something else. - if (!algorithm) { - throw new Error('algorithm is required, use "secp256k1" or "ed25519"') - } - const payload = JSON.stringify({ domain, algorithm }) - const result = await send_rpc_request<{ key: string, public_key: string, signature_chain: string[] }>( - this.endpoint, '/v1/GetKey', payload) - throwOnRpcError(result) - return Object.freeze({ - key: new Uint8Array(Buffer.from(result.key, 'hex')), - public_key: new Uint8Array(Buffer.from(result.public_key, 'hex')), - signature_chain: result.signature_chain.map(sig => new Uint8Array(Buffer.from(sig, 'hex'))), - __name__: 'GetKeyResponseV1' as const, - }) - } - - /** - * Produce a versioned attestation over the given report data. - * - * The only CVM attestation entry point in v1: the attestation already carries - * the TDX quote and the event log, so there is no separate `getQuote`. - * - * @param report_data 1 to 64 bytes, zero-padded on the right to 64 by the agent. - * @param include_boottime_gpu_evidence Also return the boot-time GPU evidence, - * as the same {@link GpuEvidenceBundleV1} list `attestGpu` returns, so a - * verifier gets both in one round trip. It is not bound to `report_data`. - */ - async attest( - report_data: string | Buffer | Uint8Array, - include_boottime_gpu_evidence: boolean = false, - ): Promise { - const hex = to_hex(report_data) - if (hex.length === 0) { - throw new Error('report data must not be empty') - } - if (hex.length > 128) { - throw new Error(`report data must be at most 64 bytes, but received ${hex.length / 2}`) - } - const payload = JSON.stringify({ report_data: hex, include_boottime_gpu_evidence }) - const result = await send_rpc_request<{ - attestation: string, - boottime_gpu_evidence?: Array>, - }>(this.endpoint, '/v1/Attest', payload) - throwOnRpcError(result) - return Object.freeze({ - __name__: 'AttestResponseV1' as const, - attestation: result.attestation as Hex, - boottime_gpu_evidence: to_gpu_evidence_bundles(result.boottime_gpu_evidence), - }) - } - - /** - * Collect GPU attestation evidence now, against a nonce you choose. - * - * Returns vendor-native evidence, not a verdict: select a verifier from each - * bundle's `vendor` and `format`, then check the signature, certificate chain, - * measurements and the embedded nonce yourself. Evidence does not by itself - * bind the GPU to this CVM. - * - * @param nonce Exactly 32 bytes, passed to the GPU verbatim. SPDM fixes the - * length; hash a longer challenge yourself. - */ - async attestGpu(nonce: Buffer | Uint8Array): Promise { - if (nonce.length !== 32) { - throw new Error(`nonce must be exactly 32 bytes, but received ${nonce.length}`) - } - const payload = JSON.stringify({ nonce: to_hex(nonce) }) - const result = await send_rpc_request<{ - bundles?: Array>, - }>(this.endpoint, '/v1/AttestGpu', payload) - throwOnRpcError(result) - return Object.freeze({ - bundles: to_gpu_evidence_bundles(result.bundles), - __name__: 'AttestGpuResponseV1' as const, - }) - } - - /** Return this application's identity and configuration. */ - async info(): Promise { - const result = await send_rpc_request>(this.endpoint, '/v1/Info', '{}') - throwOnRpcError(result) - return Object.freeze({ - ...result, - __name__: 'InfoResponseV1' as const, - }) - } - - /** Return the guest agent version. Also the cheapest probe for v1 support. */ - async version(): Promise { - const result = await send_rpc_request<{ version: string, rev: string }>(this.endpoint, '/v1/Version', '{}') - throwOnRpcError(result) - return Object.freeze({ - ...result, - __name__: 'VersionResponseV1' as const, - }) - } -} +export { to_hex } from './shared' +export type { Hex } from './shared' -/** - * The recommended client: `dstack.guest.v1`. - * - * Declared here rather than beside {@link DstackClientV0} because a `const` - * cannot name a class that has not been evaluated yet. - * - * This alias used to mean {@link DstackClientV0}. Code that upgrades without - * changing the name fails loudly rather than quietly deriving different keys: - * the v1 signatures differ, and `getKey` requires `algorithm` explicitly, so a - * v0 call site stops compiling (or throws) instead of returning wrong material. - * To stay on the frozen surface, name {@link DstackClientV0}. - */ -export const DstackClient = DstackClientV1 -export type DstackClient = DstackClientV1 +export * from './client-v0' +export * from './client-v1' diff --git a/sdk/js/src/shared.ts b/sdk/js/src/shared.ts new file mode 100644 index 000000000..db527ae8b --- /dev/null +++ b/sdk/js/src/shared.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: © 2024-2025 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +// Helpers both client surfaces need. Kept out of `client-v0.ts` so the v1 +// client does not have to import from the frozen module to reach them. + +import fs from 'fs' + +export type Hex = `${string}` + +export function to_hex(data: string | Buffer | Uint8Array): string { + if (typeof data === 'string') { + return Buffer.from(data).toString('hex'); + } + if (data instanceof Uint8Array) { + return Buffer.from(data).toString('hex'); + } + return (data as Buffer).toString('hex'); +} + +/** Socket paths the clients probe, legacy first, then the namespaced variants. */ +const DSTACK_SOCKET_PATHS = [ + '/var/run/dstack.sock', + '/run/dstack.sock', + '/var/run/dstack/dstack.sock', + '/run/dstack/dstack.sock', +] + +/** + * A prpc handler reports failure in the response body rather than by refusing to + * answer, so every method has to look for it; an unchecked call would hand the + * caller a response object with every field missing. + */ +export function throwOnRpcError(result: unknown): void { + if (result && typeof result === 'object' && 'error' in result) { + throw new Error(String((result as { error: unknown }).error)) + } +} + +/** + * Resolve the socket or URL a client talks to. + * + * Shared by both clients because they address the same agent: v0 and v1 are two + * mounts on one socket, so probing separate paths per surface would only give + * them a way to disagree about where the agent is. + */ +export function resolveDstackEndpoint(endpoint: string | undefined): string { + if (endpoint === undefined) { + if (process.env.DSTACK_SIMULATOR_ENDPOINT) { + console.warn(`Using simulator endpoint: ${process.env.DSTACK_SIMULATOR_ENDPOINT}`) + endpoint = process.env.DSTACK_SIMULATOR_ENDPOINT + } else { + endpoint = DSTACK_SOCKET_PATHS.find(p => fs.existsSync(p)) ?? DSTACK_SOCKET_PATHS[0] + } + } + if (endpoint.startsWith('/') && !fs.existsSync(endpoint)) { + throw new Error(`Unix socket file ${endpoint} does not exist`); + } + return endpoint +} diff --git a/sdk/js/src/solana.ts b/sdk/js/src/solana.ts index 5fe69f633..36e633451 100644 --- a/sdk/js/src/solana.ts +++ b/sdk/js/src/solana.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { sha256 } from '@noble/hashes/sha256' -import { type GetKeyResponse, type GetTlsKeyResponse } from './index' +import { type GetKeyResponse, type GetTlsKeyResponse } from './client-v0' import { Keypair } from '@solana/web3.js' /** diff --git a/sdk/js/src/viem.ts b/sdk/js/src/viem.ts index d2b505624..4f74bc524 100644 --- a/sdk/js/src/viem.ts +++ b/sdk/js/src/viem.ts @@ -4,7 +4,7 @@ import { sha256 } from '@noble/hashes/sha256' import { bytesToHex } from '@noble/hashes/utils' -import { type GetKeyResponse, type GetTlsKeyResponse } from './index' +import { type GetKeyResponse, type GetTlsKeyResponse } from './client-v0' import { privateKeyToAccount } from 'viem/accounts' /** diff --git a/sdk/python/src/dstack_sdk/__init__.py b/sdk/python/src/dstack_sdk/__init__.py index 4fee0ebdd..730b2f728 100644 --- a/sdk/python/src/dstack_sdk/__init__.py +++ b/sdk/python/src/dstack_sdk/__init__.py @@ -2,20 +2,20 @@ # # SPDX-License-Identifier: Apache-2.0 -from .dstack_client import AsyncDstackClientV0 -from .dstack_client import AsyncTappdClient -from .dstack_client import AttestResponse -from .dstack_client import DstackClientV0 -from .dstack_client import EventLog -from .dstack_client import GetKeyResponse -from .dstack_client import GetQuoteResponse -from .dstack_client import GetTlsKeyResponse -from .dstack_client import InfoResponse -from .dstack_client import SignResponse -from .dstack_client import TappdClient -from .dstack_client import TcbInfo -from .dstack_client import VerifyResponse -from .dstack_client import VersionResponse +from .dstack_client_v0 import AsyncDstackClientV0 +from .dstack_client_v0 import AsyncTappdClient +from .dstack_client_v0 import AttestResponse +from .dstack_client_v0 import DstackClientV0 +from .dstack_client_v0 import EventLog +from .dstack_client_v0 import GetKeyResponse +from .dstack_client_v0 import GetQuoteResponse +from .dstack_client_v0 import GetTlsKeyResponse +from .dstack_client_v0 import InfoResponse +from .dstack_client_v0 import SignResponse +from .dstack_client_v0 import TappdClient +from .dstack_client_v0 import TcbInfo +from .dstack_client_v0 import VerifyResponse +from .dstack_client_v0 import VersionResponse from .dstack_client_v1 import AsyncDstackClient from .dstack_client_v1 import AsyncDstackClientV1 from .dstack_client_v1 import AttestGpuResponseV1 diff --git a/sdk/python/src/dstack_sdk/dstack_client.py b/sdk/python/src/dstack_sdk/dstack_client_v0.py similarity index 95% rename from sdk/python/src/dstack_sdk/dstack_client.py rename to sdk/python/src/dstack_sdk/dstack_client_v0.py index b854cee0d..4a268227c 100644 --- a/sdk/python/src/dstack_sdk/dstack_client.py +++ b/sdk/python/src/dstack_sdk/dstack_client_v0.py @@ -413,6 +413,26 @@ class stays for code that must keep the v0 key derivation or needs PATH_PREFIX = "/" + def __init__( + self, + endpoint: str | None = None, + *, + use_sync_http: bool = False, + timeout: float = 3, + ): + """Initialize the legacy async client, warning that v0 is deprecated.""" + # Only when this class is what the caller actually asked for. A + # subclass (``AsyncTappdClient``) names its own surface in its own + # warning, and ``use_sync_http`` means this instance is the transport + # behind ``DstackClientV0``, which has already warned. + if type(self) is AsyncDstackClientV0 and not use_sync_http: + emit_deprecation_warning( + "AsyncDstackClientV0 is deprecated: the v0 surface is frozen at " + "dstack 0.5.11. Use AsyncDstackClient (AsyncDstackClientV1), " + "which derives different key material -- see docs/guest-api-v1.md" + ) + super().__init__(endpoint, use_sync_http=use_sync_http, timeout=timeout) + async def _ensure_algorithm_supported(self, algorithm: str) -> None: """Check OS version when a non-secp256k1 algorithm is requested.""" if algorithm in ("secp256k1", "k256", ""): @@ -633,6 +653,12 @@ def __init__(self, endpoint: str | None = None, *, timeout: float = 3): If a non-HTTP(S) endpoint is provided, it is treated as a Unix socket path and validated for existence. """ + if type(self) is DstackClientV0: + emit_deprecation_warning( + "DstackClientV0 is deprecated: the v0 surface is frozen at " + "dstack 0.5.11. Use DstackClient (DstackClientV1), which " + "derives different key material -- see docs/guest-api-v1.md" + ) self.async_client = AsyncDstackClientV0( endpoint, use_sync_http=True, timeout=timeout ) diff --git a/sdk/python/src/dstack_sdk/dstack_client_v1.py b/sdk/python/src/dstack_sdk/dstack_client_v1.py index 4fff04afb..55ccdd0fb 100644 --- a/sdk/python/src/dstack_sdk/dstack_client_v1.py +++ b/sdk/python/src/dstack_sdk/dstack_client_v1.py @@ -24,9 +24,9 @@ from pydantic import BaseModel -from .dstack_client import AsyncBaseClient -from .dstack_client import BaseClient -from .dstack_client import call_async +from .dstack_client_v0 import AsyncBaseClient +from .dstack_client_v0 import BaseClient +from .dstack_client_v0 import call_async class IssueCertResponseV1(BaseModel): diff --git a/sdk/python/src/dstack_sdk/ethereum.py b/sdk/python/src/dstack_sdk/ethereum.py index 6315f67c2..609607e57 100644 --- a/sdk/python/src/dstack_sdk/ethereum.py +++ b/sdk/python/src/dstack_sdk/ethereum.py @@ -16,8 +16,8 @@ from eth_account import Account from eth_account.signers.local import LocalAccount -from .dstack_client import GetKeyResponse -from .dstack_client import GetTlsKeyResponse +from .dstack_client_v0 import GetKeyResponse +from .dstack_client_v0 import GetTlsKeyResponse def to_account(get_key_response: GetKeyResponse | GetTlsKeyResponse) -> LocalAccount: diff --git a/sdk/python/src/dstack_sdk/solana.py b/sdk/python/src/dstack_sdk/solana.py index 058bb3e83..aca1c5896 100644 --- a/sdk/python/src/dstack_sdk/solana.py +++ b/sdk/python/src/dstack_sdk/solana.py @@ -15,8 +15,8 @@ from solders.keypair import Keypair -from .dstack_client import GetKeyResponse -from .dstack_client import GetTlsKeyResponse +from .dstack_client_v0 import GetKeyResponse +from .dstack_client_v0 import GetTlsKeyResponse def to_keypair(get_key_response: GetKeyResponse | GetTlsKeyResponse) -> Keypair: diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 18f355841..6189b64b2 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -20,8 +20,8 @@ from dstack_sdk import TappdClient from dstack_sdk import VerifyResponse from dstack_sdk import VersionResponse -from dstack_sdk.dstack_client import InfoResponse -from dstack_sdk.dstack_client import TcbInfo +from dstack_sdk.dstack_client_v0 import InfoResponse +from dstack_sdk.dstack_client_v0 import TcbInfo def test_sync_client_get_key(): @@ -414,6 +414,45 @@ async def test_async_sign_prehashed_length_error(): # Test deprecated TappdClient +def test_dstack_client_v0_deprecated(): + """The v0 client warns at construction. + + The frozen surface is what a 0.5.x program keeps working against, so the + class stays -- but the unsuffixed ``DstackClient`` name now means v1, and a + caller who landed on v0 by way of the rename should be told rather than + discovering it when the derived key does not match. + """ + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + DstackClientV0() + + v0_warnings = [ + warning + for warning in w + if issubclass(warning.category, DeprecationWarning) + and "DstackClientV0 is deprecated" in str(warning.message) + ] + + assert len(v0_warnings) == 1 + assert "frozen at dstack 0.5.11" in str(v0_warnings[0].message) + + +def test_async_dstack_client_v0_deprecated(): + """Same for the async client; both are entry points to the frozen API.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + AsyncDstackClientV0() + + v0_warnings = [ + warning + for warning in w + if issubclass(warning.category, DeprecationWarning) + and "AsyncDstackClientV0 is deprecated" in str(warning.message) + ] + + assert len(v0_warnings) == 1 + + def test_tappd_client_deprecated(): """Test that TappdClient shows deprecation warning.""" with warnings.catch_warnings(record=True) as w: diff --git a/sdk/python/tests/test_typing.py b/sdk/python/tests/test_typing.py index 9f7cbc997..a17731d6f 100644 --- a/sdk/python/tests/test_typing.py +++ b/sdk/python/tests/test_typing.py @@ -12,7 +12,7 @@ from dstack_sdk import GetKeyResponse from dstack_sdk import GetQuoteResponse from dstack_sdk import GetTlsKeyResponse -from dstack_sdk.dstack_client import InfoResponse +from dstack_sdk.dstack_client_v0 import InfoResponse # Use a test endpoint to avoid socket file not found errors TEST_ENDPOINT = "http://localhost:8080" diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 3de94903f..0c34f65ac 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -206,7 +206,7 @@ use dstack_sdk::tappd_client::TappdClient; let client = TappdClient::new(None); // After -use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v0::DstackClientV0; let client = DstackClientV0::new(None); ``` @@ -222,7 +222,7 @@ only if you need something v1 does not carry -- `sign`, `verify`, `emit_event`, `get_quote` -- or to keep existing code working while you migrate. ```rust -use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v0::DstackClientV0; let client = DstackClientV0::new(None); ``` @@ -300,7 +300,7 @@ No GPU-evidence flag on v0: that field is reserved on this surface, and only `get_tls_key()` creates fresh TLS certificates. Unlike `get_key()`, each call generates a new random key. ```rust -use dstack_sdk_types::dstack::TlsKeyConfig; +use dstack_sdk_types::dstack_v0::TlsKeyConfig; let tls_config = TlsKeyConfig::builder() .subject("api.example.com") @@ -360,7 +360,7 @@ way. The v1 surface has no chain-related functionality: it returns key material, and what an application builds from those bytes is its own business. ```rust -use dstack_sdk::dstack_client::DstackClientV0; +use dstack_sdk::dstack_client_v0::DstackClientV0; use dstack_sdk::ethereum::to_account; let client = DstackClientV0::new(None); diff --git a/sdk/rust/examples/dstack_client_usage.rs b/sdk/rust/examples/dstack_client_usage.rs index 5b3141531..2f3f0c72a 100644 --- a/sdk/rust/examples/dstack_client_usage.rs +++ b/sdk/rust/examples/dstack_client_usage.rs @@ -3,8 +3,11 @@ // // SPDX-License-Identifier: Apache-2.0 -use dstack_sdk::dstack_client::DstackClientV0; -use dstack_sdk_types::dstack::TlsKeyConfig; +// Demonstrates the deprecated v0 surface on purpose. +#![allow(deprecated)] + +use dstack_sdk::dstack_client_v0::DstackClientV0; +use dstack_sdk_types::dstack_v0::TlsKeyConfig; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/sdk/rust/examples/tappd_client_usage.rs b/sdk/rust/examples/tappd_client_usage.rs index e9a426035..d7980fbf4 100644 --- a/sdk/rust/examples/tappd_client_usage.rs +++ b/sdk/rust/examples/tappd_client_usage.rs @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: Apache-2.0 +// Demonstrates the deprecated v0 surface on purpose. +#![allow(deprecated)] + use dstack_sdk::tappd_client::TappdClient; #[tokio::main] diff --git a/sdk/rust/src/dstack_client.rs b/sdk/rust/src/dstack_client_v0.rs similarity index 97% rename from sdk/rust/src/dstack_client.rs rename to sdk/rust/src/dstack_client_v0.rs index da72becbf..01c9c7435 100644 --- a/sdk/rust/src/dstack_client.rs +++ b/sdk/rust/src/dstack_client_v0.rs @@ -12,7 +12,7 @@ use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; use std::env; -pub use dstack_sdk_types::dstack::*; +pub use dstack_sdk_types::dstack_v0::*; // Internal request structs for hex encoding #[derive(Debug, Serialize)] @@ -172,6 +172,10 @@ pub(crate) async fn unix_post( /// For anything new, use [`crate::dstack_client_v1::DstackClientV1`]. Note that /// **v1 derives different key material than v0 for the same inputs** -- see /// `docs/guest-api-v1.md` for the migration. +#[deprecated( + since = "0.6.0", + note = "the v0 surface is frozen at dstack 0.5.11; use `DstackClientV1`, which the unsuffixed `DstackClient` now names. Note that v1 derives different key material -- see docs/guest-api-v1.md" +)] pub struct DstackClientV0 { /// The base URL for HTTP requests base_url: String, @@ -181,8 +185,12 @@ pub struct DstackClientV0 { client: ClientKind, } +// The client is deprecated, not broken: its own implementation still has to +// name it. +#[allow(deprecated)] impl BaseClient for DstackClientV0 {} +#[allow(deprecated)] impl DstackClientV0 { pub fn new(endpoint: Option<&str>) -> Self { let endpoint = get_endpoint(endpoint); diff --git a/sdk/rust/src/dstack_client_v1.rs b/sdk/rust/src/dstack_client_v1.rs index 32748d68d..ba44f82b6 100644 --- a/sdk/rust/src/dstack_client_v1.rs +++ b/sdk/rust/src/dstack_client_v1.rs @@ -21,13 +21,13 @@ use serde_json::json; pub use dstack_sdk_types::dstack_v1::*; -use crate::dstack_client::{get_endpoint, http_post, unix_post, BaseClient, ClientKind}; +use crate::dstack_client_v0::{get_endpoint, http_post, unix_post, BaseClient, ClientKind}; /// Client for the v1 guest-agent surface. **This is the default client**, and /// what the unsuffixed [`crate::DstackClient`] names. /// /// **v1 keys are not v0 keys.** Deriving under the same name here as on -/// [`crate::dstack_client::DstackClientV0`] returns different key material, by +/// [`crate::dstack_client_v0::DstackClientV0`] returns different key material, by /// design: the v0 KDF ignored the algorithm, so one secret served both curves. /// There is no compatibility mode. An application holding assets under a v0 key /// migrates them deliberately -- see `docs/guest-api-v1.md`. @@ -167,5 +167,5 @@ impl DstackClientV1 { /// client, and code that was calling v0 methods through it stops compiling /// rather than silently deriving different key material -- the v1 signatures /// differ, and `get_key` requires an explicit `algorithm`. To stay on the -/// frozen surface, name [`crate::dstack_client::DstackClientV0`] explicitly. +/// frozen surface, name [`crate::dstack_client_v0::DstackClientV0`] explicitly. pub type DstackClient = DstackClientV1; diff --git a/sdk/rust/src/ethereum.rs b/sdk/rust/src/ethereum.rs index f53af5e4b..f26bb84db 100644 --- a/sdk/rust/src/ethereum.rs +++ b/sdk/rust/src/ethereum.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 use alloy::signers::local::PrivateKeySigner; -use dstack_sdk_types::dstack::GetKeyResponse; +use dstack_sdk_types::dstack_v0::GetKeyResponse; /// Build a signer from a `get_key` response. /// diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 80417eb17..af438decb 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 -pub mod dstack_client; +pub mod dstack_client_v0; pub mod dstack_client_v1; /// The recommended client: the v1 guest-agent surface. diff --git a/sdk/rust/src/tappd_client.rs b/sdk/rust/src/tappd_client.rs index e313c52eb..b7cf335c6 100644 --- a/sdk/rust/src/tappd_client.rs +++ b/sdk/rust/src/tappd_client.rs @@ -3,7 +3,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use crate::dstack_client::BaseClient; +use crate::dstack_client_v0::BaseClient; use anyhow::{bail, Result}; use hex::encode as hex_encode; use http_client_unix_domain_socket::{ClientUnix, Method}; @@ -43,6 +43,10 @@ pub enum TappdClientKind { } /// The main client for interacting with the legacy Tappd service +#[deprecated( + since = "0.6.0", + note = "the tappd surface was superseded by v0 in dstack 0.3.0; use `DstackClientV1`, which the unsuffixed `DstackClient` now names" +)] pub struct TappdClient { /// The base URL for HTTP requests base_url: String, @@ -52,8 +56,12 @@ pub struct TappdClient { client: TappdClientKind, } +// Same as the v0 client: deprecating the type does not stop its own methods +// from naming it. +#[allow(deprecated)] impl BaseClient for TappdClient {} +#[allow(deprecated)] impl TappdClient { pub fn new(endpoint: Option<&str>) -> Self { let endpoint = get_tappd_endpoint(endpoint); diff --git a/sdk/rust/tests/test_client.rs b/sdk/rust/tests/test_client.rs index 9084b4f84..e4cc38bdb 100644 --- a/sdk/rust/tests/test_client.rs +++ b/sdk/rust/tests/test_client.rs @@ -5,8 +5,11 @@ // // SPDX-License-Identifier: Apache-2.0 +// This file exercises the deprecated v0 surface; that is its purpose. +#![allow(deprecated)] + use dcap_qvl::quote::Quote; -use dstack_sdk::dstack_client::DstackClientV0 as AsyncDstackClient; +use dstack_sdk::dstack_client_v0::DstackClientV0 as AsyncDstackClient; use sha2::{Digest, Sha256}; #[tokio::test] @@ -27,7 +30,7 @@ async fn test_async_client_get_quote() { #[tokio::test] async fn test_async_client_get_tls_key() { let client = AsyncDstackClient::new(None); - let key_config = dstack_sdk_types::dstack::TlsKeyConfig::builder().build(); + let key_config = dstack_sdk_types::dstack_v0::TlsKeyConfig::builder().build(); let result = client.get_tls_key(key_config).await.unwrap(); assert!(result.key.starts_with("-----BEGIN PRIVATE KEY-----")); assert!(!result.certificate_chain.is_empty()); @@ -36,8 +39,8 @@ async fn test_async_client_get_tls_key() { #[tokio::test] async fn test_tls_key_uniqueness() { let client = AsyncDstackClient::new(None); - let key_config_1 = dstack_sdk_types::dstack::TlsKeyConfig::builder().build(); - let key_config_2 = dstack_sdk_types::dstack::TlsKeyConfig::builder().build(); + let key_config_1 = dstack_sdk_types::dstack_v0::TlsKeyConfig::builder().build(); + let key_config_2 = dstack_sdk_types::dstack_v0::TlsKeyConfig::builder().build(); let result1 = client.get_tls_key(key_config_1).await.unwrap(); let result2 = client.get_tls_key(key_config_2).await.unwrap(); assert_ne!(result1.key, result2.key); diff --git a/sdk/rust/tests/test_client_v1.rs b/sdk/rust/tests/test_client_v1.rs index bbddd60a9..75386e872 100644 --- a/sdk/rust/tests/test_client_v1.rs +++ b/sdk/rust/tests/test_client_v1.rs @@ -4,7 +4,6 @@ //! `DstackClientV1` against the guest-agent simulator. -use dstack_sdk::dstack_client::DstackClientV0; use dstack_sdk::dstack_client_v1::{DstackClientV1, IssueCertConfig}; fn client() -> DstackClientV1 { @@ -97,9 +96,12 @@ async fn the_two_algorithms_never_share_key_material() { /// than merely documented: an app that reuses its v0 path as a v1 domain gets /// different key material. #[tokio::test] +// The point of the assertion is that the two surfaces disagree, so it has to +// name the deprecated one. +#[allow(deprecated)] async fn v1_keys_differ_from_v0_keys_for_the_same_name() { let v1 = client().get_key("test", "secp256k1").await.unwrap(); - let v0 = DstackClientV0::new(None) + let v0 = dstack_sdk::dstack_client_v0::DstackClientV0::new(None) .get_key(Some("test".to_string()), Some("signing".to_string())) .await .unwrap(); diff --git a/sdk/rust/tests/test_eth.rs b/sdk/rust/tests/test_eth.rs index 2b6bea755..f9aa662be 100644 --- a/sdk/rust/tests/test_eth.rs +++ b/sdk/rust/tests/test_eth.rs @@ -4,9 +4,12 @@ // // SPDX-License-Identifier: Apache-2.0 -use dstack_sdk::dstack_client::DstackClientV0; +// This file exercises the deprecated v0 surface; that is its purpose. +#![allow(deprecated)] + +use dstack_sdk::dstack_client_v0::DstackClientV0; use dstack_sdk::ethereum::to_account; -use dstack_sdk_types::dstack::GetKeyResponse; +use dstack_sdk_types::dstack_v0::GetKeyResponse; #[tokio::test] async fn test_async_to_keypair() { diff --git a/sdk/rust/tests/test_tappd_client.rs b/sdk/rust/tests/test_tappd_client.rs index a5ef116f7..d15247e24 100644 --- a/sdk/rust/tests/test_tappd_client.rs +++ b/sdk/rust/tests/test_tappd_client.rs @@ -3,6 +3,9 @@ // // SPDX-License-Identifier: Apache-2.0 +// This file exercises the deprecated v0 surface; that is its purpose. +#![allow(deprecated)] + use dstack_sdk::tappd_client::TappdClient; use dstack_sdk_types::tappd::DeriveKeyResponse; use std::env; diff --git a/sdk/rust/types/README.md b/sdk/rust/types/README.md index 7ca4a40c6..a8db4e617 100644 --- a/sdk/rust/types/README.md +++ b/sdk/rust/types/README.md @@ -19,7 +19,7 @@ This crate is `#![no_std]` compatible and provides two main modules: ## Basic Usage ```rust -use dstack_sdk_types::dstack::{GetKeyResponse, GetQuoteResponse, InfoResponse}; +use dstack_sdk_types::dstack_v0::{GetKeyResponse, GetQuoteResponse, InfoResponse}; use dstack_sdk_types::tappd::{DeriveKeyResponse, TdxQuoteResponse, TappdInfoResponse}; // Parse a response from the dstack API diff --git a/sdk/rust/types/src/dstack.rs b/sdk/rust/types/src/dstack_v0.rs similarity index 100% rename from sdk/rust/types/src/dstack.rs rename to sdk/rust/types/src/dstack_v0.rs diff --git a/sdk/rust/types/src/dstack_v1.rs b/sdk/rust/types/src/dstack_v1.rs index 2c59c908d..040d14f59 100644 --- a/sdk/rust/types/src/dstack_v1.rs +++ b/sdk/rust/types/src/dstack_v1.rs @@ -4,7 +4,7 @@ //! Types for the `dstack.guest.v1` API surface. //! -//! Separate from [`crate::dstack`] because the two surfaces are separate +//! Separate from [`crate::dstack_v0`] because the two surfaces are separate //! contracts, not versions of one type: v1's `GetKeyResponse` carries a public //! key the v0 one has no field for, and its `InfoResponse` is flat where the v0 //! one nests a `tcb_info` document. Sharing a type between them would mean one diff --git a/sdk/rust/types/src/lib.rs b/sdk/rust/types/src/lib.rs index 1925a0218..3e49ae7bb 100644 --- a/sdk/rust/types/src/lib.rs +++ b/sdk/rust/types/src/lib.rs @@ -6,6 +6,6 @@ extern crate alloc; -pub mod dstack; +pub mod dstack_v0; pub mod dstack_v1; pub mod tappd; diff --git a/sdk/rust/types/src/tappd.rs b/sdk/rust/types/src/tappd.rs index 06e72e8d7..8d0c816bb 100644 --- a/sdk/rust/types/src/tappd.rs +++ b/sdk/rust/types/src/tappd.rs @@ -12,7 +12,7 @@ use borsh::BorshSchema; #[cfg(feature = "borsh")] use borsh::{BorshDeserialize, BorshSerialize}; -use crate::dstack::EventLog; +use crate::dstack_v0::EventLog; /// Hash algorithms supported by the TDX quote generation #[derive(Debug, Clone, Serialize, Deserialize)] From 689848669c2a5773a8419a17d3608518b93bf404 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Mon, 24 Aug 2026 20:06:30 -0700 Subject: [PATCH 2/2] fix(sdk): make the v0 deprecation markers actually fire Three of them did not, and the CHANGELOG claimed things the diff does not do. Python read the public `use_sync_http` flag as "this instance is an internal transport, stay quiet". It is a documented option on `AsyncDstackClientV0`, so a caller who set it themselves was silently opted out of the one signal that says the surface is frozen. The sync wrappers now pass a private `_warn=False` instead, which is what they actually mean. Go's `ToEthereumAccount` and `ToSolanaKeypair` still carried their `// Deprecated:` inside the first comment paragraph, where neither gopls nor pkg.go.dev recognises it -- the same shape this branch repairs five times over in `client_v0.go`, left on the two functions whose own doc comments say they have security concerns. The JSDoc on the `DstackClient` alias told readers to import `DstackClientV0` from `./client-v0`. That text ships in `dist/index.d.ts` and the path does not resolve for a package consumer: `client-v0` is not a `tsup` entry and not in the `exports` map. Point at the package root. CHANGELOG corrections: Rust warns at every *mention of the type*, not at every call -- `#[deprecated]` on a struct does not propagate to its inherent methods, and a client received from a factory function warns nowhere. `TappdClient`'s `@deprecated` JSDoc is added here, not pre-existing. And "no behaviour changes" was wrong for Python: the warning fires at construction, so a downstream suite with `filterwarnings = error` goes red on upgrade. Say so where they will read it. --- CHANGELOG.md | 4 ++- sdk/go/dstack/ethereum.go | 1 + sdk/go/dstack/solana.go | 1 + sdk/js/src/client-v1.ts | 3 ++- sdk/python/src/dstack_sdk/dstack_client_v0.py | 26 ++++++++++++------- sdk/python/tests/test_client.py | 23 ++++++++++++++++ 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d354190..0d9104519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - sdk: the JavaScript v1 `issueCert` response no longer carries a raw-bytes accessor. `asUint8Array()` is **removed rather than renamed**: it existed to feed the private key into the blockchain adapters, and v1 has no chain-flavoured surface. `IssueCert` returns TLS material, PEM is the form a TLS stack takes, and a caller who genuinely needs DER converts it with a standard library. The Rust, Python and Go v1 clients already returned the PEM string and the chain alone, so all four now agree. v0's `GetTlsKeyResponse.asUint8Array` is untouched — released API, and the viem and solana adapters depend on its truncating behaviour - sdk: the JavaScript v1 GPU evidence bundle's `asUint8Array()` is renamed `decodeEvidence()`, matching Python's and Rust's `decode_evidence` and Go, which hands back the decoded `Evidence` bytes directly. The name now says what the bytes are — the vendor's evidence, hex off the wire and decoded byte-exact, because sha256 over precisely those bytes is what the measured `gpu-attestation` event commits to - sdk: the v0 modules carry a `_v0` suffix, so the file a reader opens matches the client it holds. Rust's `dstack_sdk::dstack_client` becomes `dstack_sdk::dstack_client_v0` and `dstack_sdk_types::dstack` becomes `dstack_sdk_types::dstack_v0`; Python's `dstack_sdk.dstack_client` becomes `dstack_sdk.dstack_client_v0`; Go's `client.go`/`client_test.go` become `client_v0.go`/`client_v0_test.go`; and the JavaScript `index.ts`, which held both surfaces in one file, splits into `client-v0.ts`, `client-v1.ts` and a `shared.ts`, leaving `index.ts` as a barrel that re-exports exactly the names it always did. Until now the unsuffixed *file* meant v0 while the unsuffixed *class* meant v1, so a reader opening `dstack_client.rs` for the recommended client found the legacy one instead. **There are deliberately no backward-compat module aliases**: 0.6.0 is the loud-break release, and an import of an old module path fails at build time rather than silently binding the frozen surface under a name that now means something else. Package-level exports are untouched in every SDK — `dstack_sdk::DstackClient`, `from dstack_sdk import DstackClientV0` and `@phala/dstack-sdk`'s public surface are exactly what they were; only a deep import of the module path moves. In Go this is file naming alone, since it is all one `package dstack` -- sdk: the v0 clients are deprecated in the way each language's tooling understands, not only in prose. Rust's `DstackClientV0` and `TappdClient` carry `#[deprecated(since = "0.6.0")]`, so a downstream build warns at the `use` and at every call; Python's `DstackClientV0` and `AsyncDstackClientV0` emit a `DeprecationWarning` on construction, through the same helper `TappdClient` already used, alongside the `.. deprecated:: 0.6.0` docstring note they already carried. Go's `// Deprecated:` markers and JavaScript's `@deprecated` JSDoc were already in place; a few Go ones sat mid-comment rather than as their own trailing paragraph, which is the only form the tooling recognises, and are repaired. Nothing is removed and no behaviour changes — the frozen surface stays reachable under its explicit name, it just says what it is at build time now +- sdk: the v0 clients are deprecated in the way each language's tooling understands, not only in prose. Rust's `DstackClientV0` and `TappdClient` carry `#[deprecated(since = "0.6.0")]`, so a downstream build warns at every mention of the type — the `use`, the constructor, any signature naming it. Method calls on an already-built client stay silent, because Rust does not propagate the attribute to inherent methods. Python's `DstackClientV0` and `AsyncDstackClientV0` emit a `DeprecationWarning` on construction, through the same helper `TappdClient` already used, alongside the `.. deprecated:: 0.6.0` docstring note they already carried. JavaScript's `DstackClientV0` already had its `@deprecated` JSDoc and `TappdClient` gains one. Go's `// Deprecated:` markers were in place but seven sat mid-comment rather than as their own trailing paragraph, which is the only form gopls and pkg.go.dev recognise, and are repaired. + + Nothing is removed and the wire behaviour is unchanged, but Python's marker is a runtime warning rather than a build-time one: a downstream test suite that turns `DeprecationWarning` into an error (`filterwarnings = error`, which is a common setting) will fail on `DstackClientV0()` until it adds a filter. The frozen surface stays reachable under its explicit name; it just says what it is now ### Removed diff --git a/sdk/go/dstack/ethereum.go b/sdk/go/dstack/ethereum.go index 518b2bad3..c13f275ba 100644 --- a/sdk/go/dstack/ethereum.go +++ b/sdk/go/dstack/ethereum.go @@ -23,6 +23,7 @@ type EthereumAccount struct { } // ToEthereumAccount creates an Ethereum account from GetKeyResponse or GetTlsKeyResponse (legacy method). +// // Deprecated: Use ToEthereumAccountSecure instead. This method has security concerns. func ToEthereumAccount(keyResponse interface{}) (*EthereumAccount, error) { switch resp := keyResponse.(type) { diff --git a/sdk/go/dstack/solana.go b/sdk/go/dstack/solana.go index 6ce95d191..1784158cb 100644 --- a/sdk/go/dstack/solana.go +++ b/sdk/go/dstack/solana.go @@ -20,6 +20,7 @@ type SolanaKeypair struct { } // ToSolanaKeypair creates a Solana keypair from GetKeyResponse or GetTlsKeyResponse (legacy method). +// // Deprecated: Use ToSolanaKeypairSecure instead. This method has security concerns. func ToSolanaKeypair(keyResponse interface{}) (*SolanaKeypair, error) { switch resp := keyResponse.(type) { diff --git a/sdk/js/src/client-v1.ts b/sdk/js/src/client-v1.ts index e7338d979..6e77d1bd1 100644 --- a/sdk/js/src/client-v1.ts +++ b/sdk/js/src/client-v1.ts @@ -349,7 +349,8 @@ export class DstackClientV1 { * changing the name fails loudly rather than quietly deriving different keys: * the v1 signatures differ, and `getKey` requires `algorithm` explicitly, so a * v0 call site stops compiling (or throws) instead of returning wrong material. - * To stay on the frozen surface, name `DstackClientV0` from `./client-v0`. + * To stay on the frozen surface, import `DstackClientV0` by name from the + * package root -- `client-v0` is an internal module, not an export path. */ export const DstackClient = DstackClientV1 export type DstackClient = DstackClientV1 diff --git a/sdk/python/src/dstack_sdk/dstack_client_v0.py b/sdk/python/src/dstack_sdk/dstack_client_v0.py index 4a268227c..d3b7f5e17 100644 --- a/sdk/python/src/dstack_sdk/dstack_client_v0.py +++ b/sdk/python/src/dstack_sdk/dstack_client_v0.py @@ -419,13 +419,20 @@ def __init__( *, use_sync_http: bool = False, timeout: float = 3, + _warn: bool = True, ): - """Initialize the legacy async client, warning that v0 is deprecated.""" - # Only when this class is what the caller actually asked for. A + """Initialize the legacy async client, warning that v0 is deprecated. + + ``_warn`` is private: the sync wrappers build one of these as their own + transport and have already warned, so they pass ``False``. It is a + separate flag rather than a reading of ``use_sync_http`` because that + one is public and documented -- a caller who sets it is still a caller + who deserves the warning. + """ + # Only when this class is what the caller actually asked for: a # subclass (``AsyncTappdClient``) names its own surface in its own - # warning, and ``use_sync_http`` means this instance is the transport - # behind ``DstackClientV0``, which has already warned. - if type(self) is AsyncDstackClientV0 and not use_sync_http: + # warning. + if _warn and type(self) is AsyncDstackClientV0: emit_deprecation_warning( "AsyncDstackClientV0 is deprecated: the v0 surface is frozen at " "dstack 0.5.11. Use AsyncDstackClient (AsyncDstackClientV1), " @@ -660,7 +667,7 @@ def __init__(self, endpoint: str | None = None, *, timeout: float = 3): "derives different key material -- see docs/guest-api-v1.md" ) self.async_client = AsyncDstackClientV0( - endpoint, use_sync_http=True, timeout=timeout + endpoint, use_sync_http=True, timeout=timeout, _warn=False ) @call_async @@ -777,10 +784,11 @@ def __init__( *, use_sync_http: bool = False, timeout: float = 3, + _warn: bool = True, ): """Initialize deprecated async tappd client wrapper.""" - if not use_sync_http: - # Already warned in TappdClient.__init__ + if _warn: + # ``TappdClient`` has already warned when it builds one of these. emit_deprecation_warning( "AsyncTappdClient is deprecated, please use AsyncDstackClientV0 instead" ) @@ -863,7 +871,7 @@ def __init__(self, endpoint: str | None = None, timeout: float = 3): ) endpoint = get_tappd_endpoint(endpoint) self.async_client = AsyncTappdClient( - endpoint, use_sync_http=True, timeout=timeout + endpoint, use_sync_http=True, timeout=timeout, _warn=False ) @call_async diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 6189b64b2..6e23010de 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -697,3 +697,26 @@ async def fake_send(self, method, payload): client = AsyncDstackClientV0() with pytest.raises(RuntimeError, match="TLS key options"): await client.get_tls_key(with_app_info=False) + + +def test_v0_warns_even_when_the_caller_asks_for_sync_http(): + """``use_sync_http`` is a public transport option, not a warning switch. + + The sync wrappers build their async twin with it, and used to suppress the + deprecation warning by reading it -- so a user who set the documented flag + themselves was silently opted out of the one signal telling them the surface + is frozen. + """ + with pytest.warns(DeprecationWarning, match="AsyncDstackClientV0 is deprecated"): + AsyncDstackClientV0(use_sync_http=True) + + +def test_v0_sync_wrapper_warns_exactly_once(): + """It builds an AsyncDstackClientV0 internally; that must not warn twice.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + DstackClientV0() + v0_warnings = [ + w for w in caught if "DstackClientV0 is deprecated" in str(w.message) + ] + assert len(v0_warnings) == 1, [str(w.message) for w in caught]