From ea17de024a9e3ca54592d1dd40ef8b1d93129d1f Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:12:12 +0200 Subject: [PATCH 1/9] feat(loader): add secure provider token reader --- src/runtime/secure-token-file.ts | 160 ++++++++++++++++++++++++++++++ test/secure-token-file.test.ts | 164 +++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/runtime/secure-token-file.ts create mode 100644 test/secure-token-file.test.ts diff --git a/src/runtime/secure-token-file.ts b/src/runtime/secure-token-file.ts new file mode 100644 index 0000000..17e3890 --- /dev/null +++ b/src/runtime/secure-token-file.ts @@ -0,0 +1,160 @@ +import { constants } from "node:fs"; +import { lstat, open, type FileHandle } from "node:fs/promises"; +import { isAbsolute } from "node:path"; + +import { AkuaCliError } from "./errors"; + +export const MAX_PROVIDER_TOKEN_BYTES = 4096; + +// Bun's Node type declarations omit O_CLOEXEC although its Unix open syscall +// accepts it. Linux uses 0x80000; Darwin and the supported BSD target use +// 0x1000000. This command is intentionally Unix-only. +const O_CLOEXEC = process.platform === "linux" ? 0x80000 : 0x1000000; +export const SECURE_OPEN_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | O_CLOEXEC; + +export interface SecureTokenFileStat { + dev: number; + ino: number; + uid: number; + mode: number; + size: number; + isFile(): boolean; + isSymbolicLink(): boolean; +} + +interface SecureTokenFileHandle { + stat(): Promise; + read(buffer: Uint8Array, offset?: number, length?: number, position?: number): Promise<{ bytesRead: number }>; + close(): Promise; +} + +export interface SecureTokenFileDependencies { + getuid(): number; + lstat(path: string): Promise; + open(path: string, flags: number): Promise; +} + +const productionDependencies: SecureTokenFileDependencies = { + getuid: () => { + if (typeof process.getuid !== "function") { + throw unsafeFileError(); + } + return process.getuid(); + }, + lstat: async (path) => lstat(path), + open: async (path, flags) => open(path, flags), +}; + +export async function readSecureTokenFile( + path: string, + dependencies: SecureTokenFileDependencies = productionDependencies, +): Promise { + if (!isAbsolute(path)) { + throw new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_PATH_INVALID", + message: "The provider token file must use an absolute path.", + exitCode: 2, + }); + } + + const preOpen = await safeLstat(path, dependencies); + validateStat(preOpen, dependencies.getuid()); + validateSize(preOpen.size); + + let handle: SecureTokenFileHandle | undefined; + try { + handle = await dependencies.open(path, SECURE_OPEN_FLAGS); + const opened = await safeFstat(handle); + validateStat(opened, dependencies.getuid()); + if (!sameFile(preOpen, opened)) { + throw changedFileError(); + } + validateSize(opened.size); + + const bytes = new Uint8Array(opened.size); + try { + const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, 0); + if (bytesRead !== bytes.byteLength) { + clearBytes(bytes); + throw unsafeFileError(); + } + return bytes; + } catch (error) { + clearBytes(bytes); + throw error; + } + } catch (error) { + if (error instanceof AkuaCliError) { + throw error; + } + throw unsafeFileError(); + } finally { + await handle?.close().catch(() => undefined); + } +} + +export function clearBytes(bytes: Uint8Array): void { + bytes.fill(0); +} + +async function safeLstat(path: string, dependencies: SecureTokenFileDependencies): Promise { + try { + return await dependencies.lstat(path); + } catch { + throw unsafeFileError(); + } +} + +async function safeFstat(handle: SecureTokenFileHandle): Promise { + try { + return await handle.stat(); + } catch { + throw unsafeFileError(); + } +} + +function validateStat(stat: SecureTokenFileStat, expectedUid: number): void { + if (stat.isSymbolicLink() || !stat.isFile() || stat.uid !== expectedUid || (stat.mode & 0o777) !== 0o600) { + throw unsafeFileError(); + } +} + +function validateSize(size: number): void { + if (!Number.isSafeInteger(size) || size < 1 || size > MAX_PROVIDER_TOKEN_BYTES) { + throw new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_FILE_SIZE_INVALID", + message: "The provider token file size is invalid.", + exitCode: 2, + }); + } +} + +function sameFile(before: SecureTokenFileStat, opened: SecureTokenFileStat): boolean { + return ( + before.dev === opened.dev && + before.ino === opened.ino && + before.uid === opened.uid && + before.mode === opened.mode && + before.size === opened.size + ); +} + +function unsafeFileError(): AkuaCliError { + return new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", + message: "The provider token file does not meet the required security checks.", + exitCode: 2, + }); +} + +function changedFileError(): AkuaCliError { + return new AkuaCliError({ + type: "validation_error", + code: "AKUA_LOADER_TOKEN_FILE_CHANGED", + message: "The provider token file changed while it was being opened.", + exitCode: 2, + }); +} diff --git a/test/secure-token-file.test.ts b/test/secure-token-file.test.ts new file mode 100644 index 0000000..7438bd1 --- /dev/null +++ b/test/secure-token-file.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { constants } from "node:fs"; +import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { + MAX_PROVIDER_TOKEN_BYTES, + readSecureTokenFile, + SECURE_OPEN_FLAGS, + type SecureTokenFileDependencies, + type SecureTokenFileStat, +} from "../src/runtime/secure-token-file"; + +const SYNTHETIC_BYTES = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); +const UID = 501; + +describe("readSecureTokenFile", () => { + test("reads a caller-owned 0600 regular file through one descriptor read and closes it", async () => { + const fixture = makeFakeFixture(); + + const result = await readSecureTokenFile("/synthetic/provider", fixture.dependencies); + + expect(Array.from(result)).toEqual(Array.from(SYNTHETIC_BYTES)); + expect(fixture.reads).toBe(1); + expect(fixture.closed).toBe(1); + expect(fixture.flags).toBe(SECURE_OPEN_FLAGS); + expect(SECURE_OPEN_FLAGS & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW); + const closeOnExec = process.platform === "linux" ? 0x80000 : 0x1000000; + expect(SECURE_OPEN_FLAGS & closeOnExec).toBe(closeOnExec); + }); + + test("rejects a relative file path without opening it", async () => { + const fixture = makeFakeFixture(); + + await expect(readSecureTokenFile("relative/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_PATH_INVALID", + }); + expect(fixture.opens).toBe(0); + }); + + test("rejects a symlink before opening it", async () => { + const fixture = makeFakeFixture({ pre: fakeStat({ symbolicLink: true }) }); + + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", + }); + expect(fixture.opens).toBe(0); + }); + + test("rejects a directory, FIFO/device-like entry, wrong owner, and wrong mode", async () => { + for (const pre of [ + fakeStat({ regular: false }), + fakeStat({ regular: false }), + fakeStat({ uid: UID + 1 }), + fakeStat({ mode: 0o640 }), + ]) { + const fixture = makeFakeFixture({ pre }); + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_UNSAFE", + }); + expect(fixture.opens).toBe(0); + } + }); + + test("rejects empty and oversized input before descriptor read", async () => { + for (const size of [0, MAX_PROVIDER_TOKEN_BYTES + 1]) { + const fixture = makeFakeFixture({ pre: fakeStat({ size }), opened: fakeStat({ size }) }); + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_SIZE_INVALID", + }); + expect(fixture.reads).toBe(0); + expect(fixture.closed).toBe(0); + } + }); + + test("rejects a descriptor substitution after lstat before it can be read", async () => { + const fixture = makeFakeFixture({ opened: fakeStat({ ino: 99 }) }); + + await expect(readSecureTokenFile("/synthetic/provider", fixture.dependencies)).rejects.toMatchObject({ + code: "AKUA_LOADER_TOKEN_FILE_CHANGED", + }); + expect(fixture.reads).toBe(0); + expect(fixture.closed).toBe(1); + }); + + test("rejects a real symlink with no descriptor access", async () => { + const directory = await mkdtemp(join(process.cwd(), ".tmp-akua-loader-file-")); + try { + const target = join(directory, "target"); + const link = join(directory, "link"); + await writeFile(target, SYNTHETIC_BYTES, { mode: 0o600 }); + await chmod(target, 0o600); + await symlink(target, link); + + await expect(readSecureTokenFile(link)).rejects.toMatchObject({ code: "AKUA_LOADER_TOKEN_FILE_UNSAFE" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +function makeFakeFixture(options: { pre?: SecureTokenFileStat; opened?: SecureTokenFileStat } = {}) { + let opens = 0; + let reads = 0; + let closed = 0; + let flags: number | undefined; + const pre = options.pre ?? fakeStat(); + const opened = options.opened ?? fakeStat(); + const dependencies: SecureTokenFileDependencies = { + getuid: () => UID, + lstat: async () => pre, + open: async (_path, openFlags) => { + opens += 1; + flags = openFlags; + return { + stat: async () => opened, + read: async (buffer) => { + reads += 1; + buffer.set(SYNTHETIC_BYTES); + return { bytesRead: SYNTHETIC_BYTES.byteLength }; + }, + close: async () => { + closed += 1; + }, + }; + }, + }; + + return { + dependencies, + get opens() { + return opens; + }, + get reads() { + return reads; + }, + get closed() { + return closed; + }, + get flags() { + return flags; + }, + }; +} + +function fakeStat(options: { + dev?: number; + ino?: number; + uid?: number; + mode?: number; + size?: number; + regular?: boolean; + symbolicLink?: boolean; +} = {}): SecureTokenFileStat { + return { + dev: options.dev ?? 1, + ino: options.ino ?? 2, + uid: options.uid ?? UID, + mode: options.mode ?? 0o100600, + size: options.size ?? SYNTHETIC_BYTES.byteLength, + isFile: () => options.regular ?? true, + isSymbolicLink: () => options.symbolicLink ?? false, + }; +} From 6a888df62b15423dc766313c74b552a8e4e51eb7 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:15:03 +0200 Subject: [PATCH 2/9] feat(loader): add fixed provider load transport --- src/runtime/platform-client.ts | 236 +++++++++++++++++++++++++++++++++ test/agent-os-loader.test.ts | 167 +++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/runtime/platform-client.ts create mode 100644 test/agent-os-loader.test.ts diff --git a/src/runtime/platform-client.ts b/src/runtime/platform-client.ts new file mode 100644 index 0000000..4bdd66d --- /dev/null +++ b/src/runtime/platform-client.ts @@ -0,0 +1,236 @@ +import { AkuaCliError } from "./errors"; +import { clearBytes } from "./secure-token-file"; + +const HCloudProviderLoadUrl = "https://api.akua.dev/v1/agent_os/hcloud_provider_loads"; +const responseFields = new Set([ + "workspace_id", + "secret_id", + "secret_version_id", + "compute_config_id", + "provider_project_id", + "provider_project_name", + "inventory", + "catalog_checked_at", + "price_eur", + "availability_timestamp", + "request_id", + "provider_fingerprint", +]); +const inventoryFields = new Set([ + "servers", + "volumes", + "networks", + "primary_ips", + "floating_ips", + "load_balancers", + "firewalls", + "placement_groups", + "ssh_keys", + "images", + "certificates", + "actions", +]); + +export interface HCloudProviderLoadRequest { + url: typeof HCloudProviderLoadUrl; + method: "POST"; + headers: Readonly>; + body: Uint8Array; +} + +interface HCloudProviderLoadResponse { + status: number; + body: unknown; +} + +export interface HCloudProviderLoadDependencies { + send(request: HCloudProviderLoadRequest): Promise; +} + +export interface HCloudProviderLoadInput { + workspace: string; + callerToken: string; + providerToken: Uint8Array; + idempotencyKey: string; +} + +export type HCloudProviderLoadResult = Readonly>; + +export class HCloudProviderLoadError extends AkuaCliError {} + +const productionDependencies: HCloudProviderLoadDependencies = { send: sendHttpsRequest }; + +export async function submitHcloudProviderLoad( + input: HCloudProviderLoadInput, + dependencies: HCloudProviderLoadDependencies = productionDependencies, +): Promise { + let body: Uint8Array | undefined; + try { + body = encodeProviderTokenBody(input.providerToken); + const response = await dependencies.send({ + url: HCloudProviderLoadUrl, + method: "POST", + headers: { + authorization: `Bearer ${input.callerToken}`, + "akua-context": input.workspace, + "idempotency-key": input.idempotencyKey, + "content-type": "application/json", + }, + body, + }); + if (response.status < 200 || response.status >= 300) { + throw serverRejectedError(response.status, response.body); + } + return allowlistedResult(response.body); + } catch (error) { + if (error instanceof HCloudProviderLoadError) { + throw error; + } + throw new HCloudProviderLoadError({ + type: "transport_error", + code: "AKUA_LOADER_SUBMISSION_UNKNOWN", + message: "The provider-load submission outcome is unknown and was not retried.", + exitCode: 1, + }); + } finally { + clearBytes(input.providerToken); + if (body) { + clearBytes(body); + } + } +} + +async function sendHttpsRequest(request: HCloudProviderLoadRequest): Promise { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body as unknown as BodyInit, + }); + const text = await response.text(); + if (text.length > 16_384) { + throw invalidServerResponseError(); + } + try { + return { status: response.status, body: text === "" ? {} : JSON.parse(text) }; + } catch { + throw invalidServerResponseError(); + } +} + +function encodeProviderTokenBody(providerToken: Uint8Array): Uint8Array { + const prefix = new Uint8Array([123, 34, 112, 114, 111, 118, 105, 100, 101, 114, 95, 116, 111, 107, 101, 110, 34, 58, 34]); + const suffix = new Uint8Array([34, 125]); + let encodedLength = prefix.byteLength + suffix.byteLength; + for (const byte of providerToken) { + encodedLength += escapedLength(byte); + } + const body = new Uint8Array(encodedLength); + let cursor = 0; + body.set(prefix, cursor); + cursor += prefix.byteLength; + for (const byte of providerToken) { + if (byte === 34 || byte === 92) { + body[cursor++] = 92; + body[cursor++] = byte; + } else if (byte === 8) { + body.set([92, 98], cursor); + cursor += 2; + } else if (byte === 9) { + body.set([92, 116], cursor); + cursor += 2; + } else if (byte === 10) { + body.set([92, 110], cursor); + cursor += 2; + } else if (byte === 12) { + body.set([92, 102], cursor); + cursor += 2; + } else if (byte === 13) { + body.set([92, 114], cursor); + cursor += 2; + } else if (byte < 32) { + body.set([92, 117, 48, 48, hex(byte >> 4), hex(byte & 15)], cursor); + cursor += 6; + } else { + body[cursor++] = byte; + } + } + body.set(suffix, cursor); + cursor += suffix.byteLength; + return body; +} + +function escapedLength(byte: number): number { + if (byte === 34 || byte === 92 || byte === 8 || byte === 9 || byte === 10 || byte === 12 || byte === 13) { + return 2; + } + return byte < 32 ? 6 : 1; +} + +function hex(value: number): number { + return value < 10 ? 48 + value : 87 + value; +} + +function allowlistedResult(body: unknown): HCloudProviderLoadResult { + if (!isRecord(body)) { + throw invalidServerResponseError(); + } + const result: Record = {}; + for (const [field, value] of Object.entries(body)) { + if (!responseFields.has(field)) { + continue; + } + if (field === "inventory") { + const inventory = allowlistedInventory(value); + if (inventory) { + result[field] = inventory; + } + continue; + } + if (typeof value === "string" || typeof value === "number") { + result[field] = value; + } + } + if (typeof result.workspace_id !== "string" || typeof result.request_id !== "string") { + throw invalidServerResponseError(); + } + return result; +} + +function allowlistedInventory(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const inventory: Record = {}; + for (const [field, count] of Object.entries(value)) { + if (inventoryFields.has(field) && Number.isSafeInteger(count) && typeof count === "number" && count >= 0) { + inventory[field] = count; + } + } + return inventory; +} + +function serverRejectedError(status: number, body: unknown): HCloudProviderLoadError { + const error = isRecord(body) && isRecord(body.error) ? body.error : {}; + const requestId = typeof error.request_id === "string" ? error.request_id : undefined; + return new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status, + requestId, + message: "The provider-load server rejected the request.", + exitCode: status === 401 || status === 403 ? 3 : 1, + }); +} + +function invalidServerResponseError(): HCloudProviderLoadError { + return new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_RESPONSE_INVALID", + message: "The provider-load server returned an invalid response.", + exitCode: 1, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts new file mode 100644 index 0000000..fba591e --- /dev/null +++ b/test/agent-os-loader.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; + +import { + HCloudProviderLoadError, + submitHcloudProviderLoad, + type HCloudProviderLoadRequest, +} from "../src/runtime/platform-client"; + +const SYNTHETIC_TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); +const SYNTHETIC_ECHO = "synthetic-response-field"; + +describe("submitHcloudProviderLoad", () => { + test("submits one fixed-route request with protected auth, explicit context, and relayed idempotency", async () => { + const requests: HCloudProviderLoadRequest[] = []; + let bodyHasProviderField = false; + + const result = await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000001", + }, + { + send: async (request) => { + requests.push(request); + bodyHasProviderField = new TextDecoder().decode(request.body).includes('"provider_token":"synthetic"'); + return successResponse(); + }, + }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://api.akua.dev/v1/agent_os/hcloud_provider_loads"); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.headers).toMatchObject({ + authorization: "Bearer caller-auth-fixture", + "akua-context": "ws_synthetic", + "idempotency-key": "00000000-0000-4000-8000-000000000001", + "content-type": "application/json", + }); + expect(bodyHasProviderField).toBe(true); + expect(result).toEqual({ + workspace_id: "ws_synthetic", + secret_id: "sec_synthetic", + compute_config_id: "cfg_synthetic", + inventory: { servers: 0, volumes: 0 }, + request_id: "req_synthetic", + }); + }); + + test("projects success through a strict response allowlist", async () => { + const result = await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000002", + }, + { + send: async () => ({ + status: 200, + body: { + ...successResponse().body, + echoed_provider_token: SYNTHETIC_ECHO, + nested: { secret: SYNTHETIC_ECHO }, + }, + }), + }, + ); + + expect(JSON.stringify(result)).not.toContain(SYNTHETIC_ECHO); + expect(result).not.toHaveProperty("echoed_provider_token"); + expect(result).not.toHaveProperty("nested"); + }); + + test("clears provider and request byte buffers after a successful send", async () => { + const providerToken = SYNTHETIC_TOKEN.slice(); + let submittedBody: Uint8Array | undefined; + + await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken, + idempotencyKey: "00000000-0000-4000-8000-000000000003", + }, + { + send: async (request) => { + submittedBody = request.body; + return successResponse(); + }, + }, + ); + + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + expect([...(submittedBody ?? [])].every((byte) => byte === 0)).toBe(true); + }); + + test("makes no retry after an uncertain transport failure and clears buffers", async () => { + const providerToken = SYNTHETIC_TOKEN.slice(); + let attempts = 0; + + await expect( + submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken, + idempotencyKey: "00000000-0000-4000-8000-000000000004", + }, + { + send: async () => { + attempts += 1; + throw new Error("synthetic transport interruption"); + }, + }, + ), + ).rejects.toMatchObject({ code: "AKUA_LOADER_SUBMISSION_UNKNOWN" }); + + expect(attempts).toBe(1); + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + }); + + test("projects server failures to fixed safe fields", async () => { + await expect( + submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + idempotencyKey: "00000000-0000-4000-8000-000000000005", + }, + { + send: async () => ({ + status: 403, + body: { + error: { + code: "SERVER_PRIVATE_CODE", + message: SYNTHETIC_ECHO, + request_id: "req_synthetic_denied", + secret_id: "sec_synthetic", + }, + }, + }), + }, + ), + ).rejects.toMatchObject({ + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_denied", + } satisfies Partial); + }); +}); + +function successResponse() { + return { + status: 200, + body: { + workspace_id: "ws_synthetic", + secret_id: "sec_synthetic", + compute_config_id: "cfg_synthetic", + inventory: { servers: 0, volumes: 0 }, + request_id: "req_synthetic", + }, + }; +} From 21fc1808289f3844377502eb599c17da4dffcddc Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:18:49 +0200 Subject: [PATCH 3/9] feat(loader): add Agent OS HCloud provider command --- src/bin/akua.ts | 6 ++ src/commands/agent-os.ts | 130 +++++++++++++++++++++++++++ src/commands/auth.ts | 22 +++++ test/agent-os-loader.test.ts | 166 +++++++++++++++++++++++++++++++++++ test/cli.test.ts | 30 ++++++- 5 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/commands/agent-os.ts diff --git a/src/bin/akua.ts b/src/bin/akua.ts index 987ce46..f93583f 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { authView } from "../commands/auth"; +import { agentOsView } from "../commands/agent-os"; import { buildHomeView } from "../commands/home"; import { commandRegistry } from "../generated/commands.gen"; import { AkuaCliError, commandNotImplemented, usageError } from "../runtime/errors"; @@ -47,6 +48,10 @@ async function route(argv: readonly string[], env: Record arg.startsWith("-")); if (unknownFlag) { throw usageError(`Unknown flag: ${flagName(unknownFlag)}`); @@ -95,6 +100,7 @@ function helpView(): RenderEnvelope { " akua auth login Save a local API token", " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", + " akua agent-os load-hcloud-provider --workspace --token-file ", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/agent-os.ts b/src/commands/agent-os.ts new file mode 100644 index 0000000..6bc54b8 --- /dev/null +++ b/src/commands/agent-os.ts @@ -0,0 +1,130 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; + +import { readProtectedCallerToken } from "./auth"; +import { usageError, AkuaCliError } from "../runtime/errors"; +import { + submitHcloudProviderLoad, + type HCloudProviderLoadInput, + type HCloudProviderLoadResult, +} from "../runtime/platform-client"; +import { clearBytes, readSecureTokenFile } from "../runtime/secure-token-file"; +import type { RenderEnvelope } from "../runtime/render"; + +export interface AgentOsDependencies { + readProtectedCallerToken(env: Record): Promise; + readSecureTokenFile(path: string): Promise; + submit(input: HCloudProviderLoadInput): Promise; + createIdempotencyKey(): string; +} + +const productionDependencies: AgentOsDependencies = { + readProtectedCallerToken, + readSecureTokenFile, + submit: submitHcloudProviderLoad, + createIdempotencyKey: randomUUID, +}; + +export async function agentOsView( + argv: readonly string[], + env: Record, + dependencies: AgentOsDependencies = productionDependencies, +): Promise { + if (argv[0] !== "load-hcloud-provider") { + throw usageError("Unknown agent-os subcommand."); + } + const options = parseLoadHcloudProviderFlags(argv.slice(1)); + if (env.AKUA_API_TOKEN !== undefined && env.AKUA_API_TOKEN !== "") { + throw new AkuaCliError({ + type: "usage_error", + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + message: "Environment authentication is not accepted for this provider loader.", + exitCode: 2, + }); + } + + const callerToken = await dependencies.readProtectedCallerToken(env); + const providerToken = await dependencies.readSecureTokenFile(options.tokenFile); + try { + const data = await dependencies.submit({ + workspace: options.workspace, + callerToken, + providerToken, + idempotencyKey: dependencies.createIdempotencyKey(), + }); + return { + command: "akua agent-os load-hcloud-provider", + data, + }; + } finally { + clearBytes(providerToken); + } +} + +interface LoadHcloudProviderOptions { + workspace: string; + tokenFile: string; +} + +function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProviderOptions { + let workspace: string | undefined; + let tokenFile: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("-")) { + throw usageError("Unexpected argument for agent-os provider loader."); + } + const name = flagName(value); + if (name !== "--workspace" && name !== "--token-file") { + throw usageError("Unsupported agent-os provider loader option."); + } + const parsed = readFlagValue(argv, index, name); + if (parsed.value === undefined || parsed.value === "") { + throw usageError(`Missing value for ${name}.`); + } + if (parsed.consumedNext) { + index += 1; + } + if (name === "--workspace") { + if (workspace !== undefined) { + throw usageError("The workspace may be specified only once."); + } + workspace = parsed.value; + } else { + if (tokenFile !== undefined) { + throw usageError("The token file may be specified only once."); + } + tokenFile = parsed.value; + } + } + if (workspace === undefined) { + throw usageError("Missing required --workspace flag."); + } + if (tokenFile === undefined) { + throw usageError("Missing required --token-file flag."); + } + if (tokenFile === "-" || !isAbsolute(tokenFile)) { + throw usageError("The provider token must be supplied through an absolute file path."); + } + return { workspace, tokenFile }; +} + +function readFlagValue( + argv: readonly string[], + index: number, + flag: string, +): { value: string | undefined; consumedNext: boolean } { + const value = argv[index]; + if (value === flag) { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("-")) { + return { value: undefined, consumedNext: false }; + } + return { value: next, consumedNext: true }; + } + return { value: value.slice(flag.length + 1), consumedNext: false }; +} + +function flagName(value: string): string { + return value.includes("=") ? value.slice(0, value.indexOf("=")) : value; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 64fd13e..18a78be 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -41,6 +41,28 @@ export async function authView(argv: readonly string[], env: Record): Promise { + if (hasEnvToken(env)) { + throw new AkuaCliError({ + type: "usage_error", + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + message: "Environment authentication is not accepted for this provider loader.", + exitCode: 2, + }); + } + + const token = (await readConfig(resolveConfigPath(env))).token; + if (typeof token !== "string" || token === "") { + throw new AkuaCliError({ + type: "authentication_error", + code: "AKUA_LOADER_AUTH_REQUIRED", + message: "A protected local Akua credential is required for this provider loader.", + exitCode: 3, + }); + } + return token; +} + async function loginView(argv: readonly string[], env: Record): Promise { const token = parseLoginFlags(argv); const configPath = resolveConfigPath(env); diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts index fba591e..f77cb46 100644 --- a/test/agent-os-loader.test.ts +++ b/test/agent-os-loader.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { agentOsView, type AgentOsDependencies } from "../src/commands/agent-os"; import { HCloudProviderLoadError, + type HCloudProviderLoadInput, submitHcloudProviderLoad, type HCloudProviderLoadRequest, } from "../src/runtime/platform-client"; +import { renderError, renderSuccess } from "../src/runtime/render"; const SYNTHETIC_TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); const SYNTHETIC_ECHO = "synthetic-response-field"; @@ -153,6 +157,116 @@ describe("submitHcloudProviderLoad", () => { }); }); +describe("agent-os load-hcloud-provider", () => { + test("accepts only explicit workspace and absolute token-file flags without exposing rejected values", async () => { + const dependencies = fakeCommandDependencies(); + const rejectedValue = "synthetic-argv-value"; + + await expect( + agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies), + ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); + await expect( + agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token-file", "-"], {}, dependencies.dependencies), + ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); + await expect(agentOsView(["load-hcloud-provider", "--token-file", "/synthetic/provider"], {}, dependencies.dependencies)).rejects.toMatchObject({ + code: "AKUA_USAGE_ERROR", + }); + + const error = await captureError(() => + agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies), + ); + expect(renderError(error, "json")).not.toContain(rejectedValue); + expect(dependencies.fileReads).toBe(0); + expect(dependencies.submissions).toBe(0); + }); + + test("rejects environment caller authentication before config, file, or network access", async () => { + const dependencies = fakeCommandDependencies(); + + await expect( + agentOsView(commandArgs(), { AKUA_API_TOKEN: "synthetic-environment-auth" }, dependencies.dependencies), + ).rejects.toMatchObject({ code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN" }); + expect(dependencies.events).toEqual([]); + }); + + test("authenticates from protected config before reading the provider file and relays idempotency once", async () => { + const dependencies = fakeCommandDependencies(); + + const view = await agentOsView(commandArgs(), {}, dependencies.dependencies); + + expect(dependencies.events).toEqual(["auth", "file", "network"]); + expect(dependencies.submissions).toBe(1); + expect(dependencies.input?.workspace).toBe("ws_synthetic"); + expect(dependencies.input?.idempotencyKey).toMatch(/^[0-9a-f]{8}-/); + expect(view.data).toEqual(successResponse().body); + expect(renderSuccess(view, "json")).not.toContain(SYNTHETIC_ECHO); + }); + + test("clears the reader bytes and renders only fixed safe failures", async () => { + const providerToken = new Uint8Array([112, 114, 111, 118, 105, 100, 101, 114, 45, 115, 101, 99, 114, 101, 116]); + const providerMarker = "provider-secret"; + const dependencies = fakeCommandDependencies({ + readSecureTokenFile: async () => providerToken, + submit: async () => { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_denied", + message: "The provider-load server rejected the request.", + }); + }, + }); + + const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); + + expect([...providerToken].every((byte) => byte === 0)).toBe(true); + expect(renderError(error, "json")).not.toContain(providerMarker); + expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); + }); + + test("fails post-revocation without a retry or a leaked prior result", async () => { + let revoked = false; + let submissions = 0; + const dependencies = fakeCommandDependencies({ + submit: async () => { + submissions += 1; + if (revoked) { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_revoked", + message: "The provider-load server rejected the request.", + }); + } + return successResponse().body; + }, + }); + + const first = await agentOsView(commandArgs(), {}, dependencies.dependencies); + revoked = true; + const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); + + expect(first.data).toEqual(successResponse().body); + expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); + expect(submissions).toBe(2); + }); + + test("contains no child-process or shell fallback implementation", async () => { + const [commandSource, transportSource] = await Promise.all([ + readFile("src/commands/agent-os.ts", "utf8"), + readFile("src/runtime/platform-client.ts", "utf8"), + ]); + + for (const source of [commandSource, transportSource]) { + expect(source).not.toContain("Bun.spawn"); + expect(source).not.toContain("node:child_process"); + expect(source).not.toContain("curl"); + } + }); +}); + function successResponse() { return { status: 200, @@ -165,3 +279,55 @@ function successResponse() { }, }; } + +function commandArgs(): string[] { + return ["load-hcloud-provider", "--workspace", "ws_synthetic", "--token-file", "/synthetic/provider"]; +} + +function fakeCommandDependencies(overrides: Partial = {}) { + const events: string[] = []; + let fileReads = 0; + let submissions = 0; + let input: HCloudProviderLoadInput | undefined; + const dependencies: AgentOsDependencies = { + readProtectedCallerToken: async () => { + events.push("auth"); + return "caller-auth-fixture"; + }, + readSecureTokenFile: async () => { + events.push("file"); + fileReads += 1; + return SYNTHETIC_TOKEN.slice(); + }, + submit: async (submitted) => { + events.push("network"); + submissions += 1; + input = submitted; + return successResponse().body; + }, + createIdempotencyKey: () => "00000000-0000-4000-8000-000000000006", + ...overrides, + }; + return { + dependencies, + events, + get fileReads() { + return fileReads; + }, + get submissions() { + return submissions; + }, + get input() { + return input; + }, + }; +} + +async function captureError(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + return error as HCloudProviderLoadError; + } + throw new Error("Expected the loader action to fail."); +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 6484209..314036c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { authView } from "../src/commands/auth"; +import { authView, readProtectedCallerToken } from "../src/commands/auth"; import { renderSuccess } from "../src/runtime/render"; describe("akua entrypoint", () => { @@ -18,6 +18,15 @@ describe("akua entrypoint", () => { }); }); + test("documents the compiled Agent OS provider loader without exposing a credential input", async () => { + const { stdout, exitCode } = await runAkua(["--help", "--json"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("akua agent-os load-hcloud-provider"); + expect(stdout).toContain("--token-file"); + expect(stdout).not.toContain("--token <"); + }); + test("fails invalid explicit output modes before routing", async () => { const { stdout, exitCode } = await runAkua(["--output", "yaml", "--version"]); expect(exitCode).toBe(2); @@ -414,6 +423,25 @@ describe("akua entrypoint", () => { await rm(home, { recursive: true, force: true }); } }); + + test("loader caller authentication reads only the protected local config", async () => { + const home = await makeTempHome(); + try { + const configDir = join(home, ".config", "akua"); + const configPath = join(configDir, "config.json"); + await mkdir(configDir, { recursive: true, mode: 0o700 }); + await writeFile(configPath, JSON.stringify({ token: "caller-auth-fixture" }), { mode: 0o600 }); + await chmod(configDir, 0o700); + await chmod(configPath, 0o600); + + await expect(readProtectedCallerToken({ HOME: home })).resolves.toBe("caller-auth-fixture"); + await expect(readProtectedCallerToken({ HOME: home, AKUA_API_TOKEN: "environment-auth-fixture" })).rejects.toMatchObject({ + code: "AKUA_LOADER_ENV_AUTH_FORBIDDEN", + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); }); async function runAkua(args: readonly string[], env: Record = {}) { From fa16f5d8cc01b3350fa7fe0a55e72074e2cdc46e Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:21:32 +0200 Subject: [PATCH 4/9] docs(loader): record Agent OS provider handoff --- AGENTS.md | 4 + docs/architecture.md | 44 ++++++++++ ...-14-agent-os-hcloud-provider-loader-cli.md | 87 +++++++++++++++++++ test/agent-os-loader.test.ts | 49 +++++++---- 4 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md diff --git a/AGENTS.md b/AGENTS.md index 1ea1528..f9f2e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Run `mise run check` and `mise run generate:check` for every change. Release changes also require the focused release/workflow tests and a current-host compiled archive smoke through `mise run release:smoke`. +The hand-written Agent OS provider-ingress command and its security suites are +specified in `docs/architecture.md`, `test/secure-token-file.test.ts`, and +`test/agent-os-loader.test.ts`. + ## Maintaining this file Keep this file concise and durable. Add only repository-wide rules that are not diff --git a/docs/architecture.md b/docs/architecture.md index c45c1fb..15d9d76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,50 @@ Status: greenfield scaffold with local auth/config MVP. spec fetch task performs only a read-only OpenAPI GET and rejects non-HTTPS source URLs. +## Agent OS HCloud Provider Loader Companion + +The one exception to the generated-public-command boundary is the compiled, +non-generated command: + +```sh +akua agent-os load-hcloud-provider --workspace --token-file +``` + +It is a deliberately thin local companion to the server-owned cnap Agent OS +provider-loader transaction (`POST /v1/agent_os/hcloud_provider_loads`). The +cnap transaction is the canonical source of truth for workspace authorization, +provider identity and inventory validation, storage, idempotency, compensation, +revocation, and all provider policy. This CLI never implements an inventory, +uses generic `/secrets` or `/compute_configs` calls, opens a browser, runs a +shell child, or falls back to another endpoint. + +The command requires both flags and rejects positional input, `--token`, stdin, +provider-token environment/profile input, API URL overrides, debug body output, +and retry transports. It reads normal Akua caller authentication only from the +protected local Akua config; `AKUA_API_TOKEN` is rejected for this command. +It sends that authentication in `Authorization`, the explicit selection in +`Akua-Context`, a newly generated `Idempotency-Key`, and a body containing only +the provider-token field. The production base URL and route are fixed; tests may +inject a fake HTTPS transport only through an internal dependency seam. + +The provider file is opened exactly once in the compiled process by a dedicated +Unix reader. The reader accepts only an absolute, caller-owned, regular `0600` +file. It obtains pre-open `lstat` metadata, opens with `O_NOFOLLOW | O_CLOEXEC`, +compares device/inode/UID/mode with `fstat`, performs one bounded descriptor +read, and closes before HTTP submission. It rejects symlinks, substitutions, +directories, devices, FIFOs, sockets, wrong owners, empty input, and oversized +input. The token is held only in a mutable byte buffer for request assembly; +the buffer is overwritten immediately after the single request attempt. Bun +cannot promise physical heap zeroisation, so the security contract is no +deliberate secret persistence or exposure through CLI interfaces, logs, reports, +or configuration. A stronger heap guarantee requires a reviewed native module, +not a weaker file or API contract. + +The endpoint is a release dependency: cnap must merge and release the exact +route contract before this companion can merge/release or Phase A can invoke it. +The companion's eventual CLI-owned release is coordinated as `0.9.0`; it does +not duplicate the multi-platform distribution scope in PR #21. + ## Current Repo Boundary The old Go/CNAP implementation is removed from the active build surface. The diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md new file mode 100644 index 0000000..d3320a7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -0,0 +1,87 @@ +# Agent OS HCloud Provider Loader CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it once to the dedicated cnap transaction without exposing or retaining its contents. + +**Architecture:** A command parser validates only the two required flags and obtains normal caller authentication from protected local config. A Unix descriptor reader performs the file security checks and returns a single mutable byte buffer. A fixed-route HTTPS client builds the request in-process, sends precisely once with an idempotency key, clears the buffer, and projects the response through a fixed non-secret allowlist. The cnap server remains the owner of validation, persistence, inventory, rollback, and revocation. + +**Tech Stack:** Bun 1.3, TypeScript, Node-compatible `fs` descriptor APIs, Bun test, built-in `fetch` HTTPS transport, synthetic fixtures only. + +## Global Constraints + +- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file `. +- Production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body `{ "provider_token": }`. +- No provider secret may enter argv, stdin, environment, profile, browser, shell child, curl, config, cache, log, stdout, stderr, error message, crash report, or test report. +- Reject `AKUA_API_TOKEN` and all provider-token/environment/API-base override inputs for this command; do not retry a request after a transport outcome is uncertain. +- The reader must require absolute, own-UID regular `0600` files, use `O_NOFOLLOW | O_CLOEXEC`, compare `lstat` and `fstat` dev/inode/uid/mode, read one bounded descriptor once, and close before networking. +- Clear every mutable token/request byte buffer in `finally`; no JavaScript runtime can prove physical heap zeroisation. +- Output may contain only the allowlisted server result or a fixed failure code, status, request ID, and opaque resource IDs. +- The cnap route issue/release is a hard merge and Phase-A dependency. Do not publish, tag, deploy, merge, or create a duplicate release/PR. Coordinate the first CLI-owned release as `0.9.0`, separate from PR #21's distribution scope. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/runtime/secure-token-file.ts` | Secure Unix metadata/open/read/close primitive and buffer clear helper. | +| `src/runtime/platform-client.ts` | Fixed-route, one-attempt HTTPS request and allowlisted response projection. | +| `src/commands/agent-os.ts` | Flag parser, auth ordering, request orchestration, and safe command envelope. | +| `src/commands/auth.ts` | Expose a protected-config-only caller credential reader without changing auth command precedence. | +| `src/bin/akua.ts` | Route and help entry for the single command. | +| `test/secure-token-file.test.ts` | Synthetic descriptor, mode, owner, one-read, and swap tests. | +| `test/agent-os-loader.test.ts` | Fake HTTPS transport, no-retention, ordering, idempotency, allowlist, revocation, and no-retry tests. | +| `test/cli.test.ts` | Entrypoint routing and public help regression coverage. | +| `docs/architecture.md` | CLI ownership, endpoint, release handoff, and security boundary. | + +### Task 1: Secure descriptor reader + +**Files:** Create `src/runtime/secure-token-file.ts`; create `test/secure-token-file.test.ts`. + +**Interfaces:** Produce `readSecureTokenFile(path, dependencies?): Promise` and `clearBytes(bytes): void`. Dependencies expose `lstat`, `open`, `fstat`, and one descriptor `read` only to make substitution/read-count tests deterministic. + +- [x] **Step 1: Write failing secure-reader tests** for a synthetic `0600` regular file, rejected relative/symlink/directory/FIFO/device/wrong-owner/wrong-mode/empty/oversize files, exactly one descriptor read, close-before-return, and a hook that swaps a path between `lstat` and `open`. +- [x] **Step 2: Run `bun test test/secure-token-file.test.ts`** and confirm failures name the missing module/functions rather than fixture setup. +- [x] **Step 3: Implement the minimal reader** with absolute-path validation; `lstat`; numeric `O_RDONLY | O_NOFOLLOW | O_CLOEXEC`; `fstat` identity/mode/UID checks; exactly one bounded `read`; `finally` close; and fixed `AkuaCliError` codes/messages that never interpolate the supplied path. +- [x] **Step 4: Re-run `bun test test/secure-token-file.test.ts`** and confirm all focused cases pass. +- [x] **Step 5: Commit the reader and its tests** with a focused conventional commit. + +### Task 2: Fixed transport and response projection + +**Files:** Create `src/runtime/platform-client.ts`; create `test/agent-os-loader.test.ts`. + +**Interfaces:** Produce `submitHcloudProviderLoad({ workspace, callerToken, providerToken, idempotencyKey }, dependencies?): Promise`. The request is one POST to the fixed route; dependency injection may replace transport for a local fake HTTPS server, never expose a CLI option. + +- [x] **Step 1: Write failing transport tests** using synthetic sentinels and a fake HTTPS server/transport: fixed method/path/headers/body shape, one submission, idempotency-key relay, response field allowlist, server fixed-error projection, no retry after a thrown/ambiguous submission, and byte clearing after both success and failure. +- [x] **Step 2: Run `bun test test/agent-os-loader.test.ts`** and confirm the expected module/function failures. +- [x] **Step 3: Implement the minimal client** with a fixed HTTPS URL, manual byte-oriented JSON quoting, one `fetch` invocation, no debug/body logging, `finally` buffer overwrites, and strict success/failure schema projection that discards unknown server fields. +- [x] **Step 4: Re-run `bun test test/agent-os-loader.test.ts`** and confirm the focused transport suite passes. +- [x] **Step 5: Commit the transport and focused tests** with a focused conventional commit. + +### Task 3: Command, protected auth, and routing + +**Files:** Modify `src/commands/auth.ts`, `src/commands/agent-os.ts`, `src/bin/akua.ts`, `test/agent-os-loader.test.ts`, and `test/cli.test.ts`. + +**Interfaces:** `agentOsView(argv, env, dependencies?): Promise` accepts only the exact command flags. `readProtectedCallerToken(env)` returns the locally stored caller token or a fixed auth error and never reads `AKUA_API_TOKEN` for this command. + +- [x] **Step 1: Write failing command tests** for required explicit workspace/token file, safe rejection of token/stdin/positionals/env/profile/API URL flags, config-only auth, auth-before-file/network ordering, file-before-network ordering, no child-process calls, stdout/stderr/error sentinel absence, fixed exit codes, success allowlist, and a fake-server revoke then post-revoke failure. +- [x] **Step 2: Run `bun test test/agent-os-loader.test.ts test/cli.test.ts`** and confirm failures are caused by the absent command behavior. +- [x] **Step 3: Implement the minimal orchestration**: parse only the two flags, reject environment auth, read protected config auth before opening the provider file, create one UUID idempotency key, read/close token file, call fixed transport once, and emit only the projected result through the existing renderer. +- [x] **Step 4: Re-run `bun test test/agent-os-loader.test.ts test/cli.test.ts`** and confirm all loader and routing tests pass. +- [x] **Step 5: Commit the command/routing slice** with a focused conventional commit. + +### Task 4: Contract documentation and full verification + +**Files:** Modify `docs/architecture.md`; this plan; optionally `AGENTS.md` only if the repository lacks durable command/testing guidance. + +- [x] **Step 1: Write failing documentation/contract assertions** where practical (route absence from generated registry; command appears in help; no deprecated provider-input path appears in the architecture document). +- [x] **Step 2: Run the focused assertions** and observe the expected red condition before any production behavior they cover. +- [x] **Step 3: Update architecture documentation** with the exact non-secret route, thin-client/server ownership, safe file contract, no-retention constraints, cnap-first release ordering, PR #21 boundary, and `0.9.0` coordination. +- [x] **Step 4: Run `mise run check` and `mise run build:binary`**; inspect the compiled help and the full test output for failures or sentinel leakage. +- [ ] **Step 5: Commit remaining documentation and run `git diff --check`, `git status --short`, and the full validation commands** before reporting completion. + +## Self-Review + +- [x] Coverage: Tasks 1–3 cover every local CLI requirement: exact flags, protected config authentication, one bounded secure read, fixed single HTTPS submission, buffer clear, output projection, ordering, idempotency, no retry, revocation, and synthetic-only security regression tests. Task 4 covers architecture/release boundaries and whole-repo validation. +- [x] Dependency: the required cnap source task URL was not discoverable from the cnap issue list on 2026-07-14. The fixed route above is taken from the approved design and remains a merge/release dependency; no CLI PR or release is created here. +- [x] Placeholder scan: no `TODO`, `TBD`, “implement later,” or undefined interface names remain. +- [x] Consistency: `readSecureTokenFile` produces the mutable bytes consumed by `submitHcloudProviderLoad`; `agentOsView` is the only caller and clears/handles failure before rendering. diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts index f77cb46..b041968 100644 --- a/test/agent-os-loader.test.ts +++ b/test/agent-os-loader.test.ts @@ -225,32 +225,22 @@ describe("agent-os load-hcloud-provider", () => { expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); }); - test("fails post-revocation without a retry or a leaked prior result", async () => { - let revoked = false; - let submissions = 0; + test("fails post-revocation against the fake HTTPS server without a retry or leaked prior result", async () => { + const server = new FakeHttpsServer(); const dependencies = fakeCommandDependencies({ - submit: async () => { - submissions += 1; - if (revoked) { - throw new HCloudProviderLoadError({ - type: "api_error", - code: "AKUA_LOADER_SERVER_REJECTED", - status: 403, - requestId: "req_synthetic_revoked", - message: "The provider-load server rejected the request.", - }); - } - return successResponse().body; - }, + submit: async (input) => server.submit(input), }); const first = await agentOsView(commandArgs(), {}, dependencies.dependencies); - revoked = true; + server.revoke(); const error = await captureError(() => agentOsView(commandArgs(), {}, dependencies.dependencies)); expect(first.data).toEqual(successResponse().body); expect(error).toMatchObject({ code: "AKUA_LOADER_SERVER_REJECTED", status: 403 }); - expect(submissions).toBe(2); + expect(server.requests).toEqual([ + { workspace: "ws_synthetic", tokenLength: SYNTHETIC_TOKEN.byteLength }, + { workspace: "ws_synthetic", tokenLength: SYNTHETIC_TOKEN.byteLength }, + ]); }); test("contains no child-process or shell fallback implementation", async () => { @@ -331,3 +321,26 @@ async function captureError(action: () => Promise): Promise = []; + #revoked = false; + + revoke(): void { + this.#revoked = true; + } + + async submit(input: HCloudProviderLoadInput) { + this.requests.push({ workspace: input.workspace, tokenLength: input.providerToken.byteLength }); + if (this.#revoked) { + throw new HCloudProviderLoadError({ + type: "api_error", + code: "AKUA_LOADER_SERVER_REJECTED", + status: 403, + requestId: "req_synthetic_revoked", + message: "The provider-load server rejected the request.", + }); + } + return successResponse().body; + } +} From 9aae9bad378e6e99b6238264a0910b0d9971c973 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:21:48 +0200 Subject: [PATCH 5/9] docs(loader): mark implementation plan complete --- .../plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md index d3320a7..e5da035 100644 --- a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -77,7 +77,7 @@ - [x] **Step 2: Run the focused assertions** and observe the expected red condition before any production behavior they cover. - [x] **Step 3: Update architecture documentation** with the exact non-secret route, thin-client/server ownership, safe file contract, no-retention constraints, cnap-first release ordering, PR #21 boundary, and `0.9.0` coordination. - [x] **Step 4: Run `mise run check` and `mise run build:binary`**; inspect the compiled help and the full test output for failures or sentinel leakage. -- [ ] **Step 5: Commit remaining documentation and run `git diff --check`, `git status --short`, and the full validation commands** before reporting completion. +- [x] **Step 5: Commit remaining documentation and run `git diff --check`, `git status --short`, and the full validation commands** before reporting completion. ## Self-Review From e7d8ed33684090492abea621acb41b8302f62cbc Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:24:48 +0200 Subject: [PATCH 6/9] docs(loader): link cnap provider route dependency --- docs/architecture.md | 10 ++++++---- .../2026-07-14-agent-os-hcloud-provider-loader-cli.md | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 15d9d76..11cb901 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,10 +55,12 @@ deliberate secret persistence or exposure through CLI interfaces, logs, reports, or configuration. A stronger heap guarantee requires a reviewed native module, not a weaker file or API contract. -The endpoint is a release dependency: cnap must merge and release the exact -route contract before this companion can merge/release or Phase A can invoke it. -The companion's eventual CLI-owned release is coordinated as `0.9.0`; it does -not duplicate the multi-platform distribution scope in PR #21. +The endpoint is a release dependency owned by +[cnap #540](https://github.com/akua-dev/cnap/issues/540): its HCloud +project-identity security gate must resolve and its released route contract must +exactly match this companion before any CLI PR, merge, release, or Phase A +invocation. The companion's eventual CLI-owned release is coordinated as +`0.9.0`; it does not duplicate the multi-platform distribution scope in PR #21. ## Current Repo Boundary diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md index e5da035..8d46942 100644 --- a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -17,7 +17,7 @@ - The reader must require absolute, own-UID regular `0600` files, use `O_NOFOLLOW | O_CLOEXEC`, compare `lstat` and `fstat` dev/inode/uid/mode, read one bounded descriptor once, and close before networking. - Clear every mutable token/request byte buffer in `finally`; no JavaScript runtime can prove physical heap zeroisation. - Output may contain only the allowlisted server result or a fixed failure code, status, request ID, and opaque resource IDs. -- The cnap route issue/release is a hard merge and Phase-A dependency. Do not publish, tag, deploy, merge, or create a duplicate release/PR. Coordinate the first CLI-owned release as `0.9.0`, separate from PR #21's distribution scope. +- [cnap #540](https://github.com/akua-dev/cnap/issues/540) is a hard merge and Phase-A dependency. Do not open a CLI PR, publish, tag, deploy, merge, or create a release until its HCloud project-identity security gate resolves and its released route contract exactly matches this client. Coordinate the first CLI-owned release as `0.9.0`, separate from PR #21's distribution scope. ## File Structure @@ -82,6 +82,6 @@ ## Self-Review - [x] Coverage: Tasks 1–3 cover every local CLI requirement: exact flags, protected config authentication, one bounded secure read, fixed single HTTPS submission, buffer clear, output projection, ordering, idempotency, no retry, revocation, and synthetic-only security regression tests. Task 4 covers architecture/release boundaries and whole-repo validation. -- [x] Dependency: the required cnap source task URL was not discoverable from the cnap issue list on 2026-07-14. The fixed route above is taken from the approved design and remains a merge/release dependency; no CLI PR or release is created here. +- [x] Dependency: [cnap #540](https://github.com/akua-dev/cnap/issues/540) is the canonical server task. Its HCloud project-identity security gate and exact released route contract are mandatory before any CLI PR, merge, release, or Phase-A invocation. - [x] Placeholder scan: no `TODO`, `TBD`, “implement later,” or undefined interface names remain. - [x] Consistency: `readSecureTokenFile` produces the mutable bytes consumed by `submitHcloudProviderLoad`; `agentOsView` is the only caller and clears/handles failure before rendering. From 823e55b888e665e6af42362c7adcb229a4e3dbec Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:28:35 +0200 Subject: [PATCH 7/9] feat(loader): relay HCloud project anchor fingerprint --- docs/architecture.md | 24 ++++++---- ...-14-agent-os-hcloud-provider-loader-cli.md | 6 +-- src/bin/akua.ts | 2 +- src/commands/agent-os.ts | 20 +++++++-- src/runtime/platform-client.ts | 33 ++++++++++---- test/agent-os-loader.test.ts | 45 +++++++++++++++---- 6 files changed, 99 insertions(+), 31 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 11cb901..8585482 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,10 @@ The one exception to the generated-public-command boundary is the compiled, non-generated command: ```sh -akua agent-os load-hcloud-provider --workspace --token-file +akua agent-os load-hcloud-provider \ + --workspace \ + --token-file \ + --project-anchor-ssh-key-fingerprint ``` It is a deliberately thin local companion to the server-owned cnap Agent OS @@ -33,14 +36,19 @@ revocation, and all provider policy. This CLI never implements an inventory, uses generic `/secrets` or `/compute_configs` calls, opens a browser, runs a shell child, or falls back to another endpoint. -The command requires both flags and rejects positional input, `--token`, stdin, -provider-token environment/profile input, API URL overrides, debug body output, -and retry transports. It reads normal Akua caller authentication only from the -protected local Akua config; `AKUA_API_TOKEN` is rejected for this command. +The command requires all three flags and rejects positional input, `--token`, +stdin, provider-token environment/profile input, API URL overrides, debug body +output, and retry transports. The project-anchor SSH key fingerprint must be a +provider-returned `SHA256:` SSH fingerprint; the CLI relays it verbatim and +never derives it from the provider token. No anchor name is accepted because +the held server contract has not required one. It reads normal Akua caller +authentication only from the protected local Akua config; `AKUA_API_TOKEN` is +rejected for this command. It sends that authentication in `Authorization`, the explicit selection in -`Akua-Context`, a newly generated `Idempotency-Key`, and a body containing only -the provider-token field. The production base URL and route are fixed; tests may -inject a fake HTTPS transport only through an internal dependency seam. +`Akua-Context`, a newly generated `Idempotency-Key`, and a body containing the +provider-token plus the non-secret project-anchor fingerprint fields. The +production base URL and route are fixed; tests may inject a fake HTTPS transport +only through an internal dependency seam. The provider file is opened exactly once in the compiled process by a dedicated Unix reader. The reader accepts only an absolute, caller-owned, regular `0600` diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md index 8d46942..33ffc7d 100644 --- a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it once to the dedicated cnap transaction without exposing or retaining its contents. +**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it with the provider-returned project-anchor SSH fingerprint to the dedicated cnap transaction without exposing or retaining token contents. **Architecture:** A command parser validates only the two required flags and obtains normal caller authentication from protected local config. A Unix descriptor reader performs the file security checks and returns a single mutable byte buffer. A fixed-route HTTPS client builds the request in-process, sends precisely once with an idempotency key, clears the buffer, and projects the response through a fixed non-secret allowlist. The cnap server remains the owner of validation, persistence, inventory, rollback, and revocation. @@ -10,8 +10,8 @@ ## Global Constraints -- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file `. -- Production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body `{ "provider_token": }`. +- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file --project-anchor-ssh-key-fingerprint `. +- Held production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body with `provider_token` plus verbatim non-secret `project_anchor_ssh_key_fingerprint`. Never derive the fingerprint from the token; do not add a project-anchor name unless cnap #540's released contract requires it. - No provider secret may enter argv, stdin, environment, profile, browser, shell child, curl, config, cache, log, stdout, stderr, error message, crash report, or test report. - Reject `AKUA_API_TOKEN` and all provider-token/environment/API-base override inputs for this command; do not retry a request after a transport outcome is uncertain. - The reader must require absolute, own-UID regular `0600` files, use `O_NOFOLLOW | O_CLOEXEC`, compare `lstat` and `fstat` dev/inode/uid/mode, read one bounded descriptor once, and close before networking. diff --git a/src/bin/akua.ts b/src/bin/akua.ts index f93583f..a07cff2 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -100,7 +100,7 @@ function helpView(): RenderEnvelope { " akua auth login Save a local API token", " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", - " akua agent-os load-hcloud-provider --workspace --token-file ", + " akua agent-os load-hcloud-provider --workspace --token-file --project-anchor-ssh-key-fingerprint ", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/agent-os.ts b/src/commands/agent-os.ts index 6bc54b8..6039951 100644 --- a/src/commands/agent-os.ts +++ b/src/commands/agent-os.ts @@ -50,6 +50,7 @@ export async function agentOsView( workspace: options.workspace, callerToken, providerToken, + projectAnchorSshKeyFingerprint: options.projectAnchorSshKeyFingerprint, idempotencyKey: dependencies.createIdempotencyKey(), }); return { @@ -64,18 +65,20 @@ export async function agentOsView( interface LoadHcloudProviderOptions { workspace: string; tokenFile: string; + projectAnchorSshKeyFingerprint: string; } function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProviderOptions { let workspace: string | undefined; let tokenFile: string | undefined; + let projectAnchorSshKeyFingerprint: string | undefined; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (!value.startsWith("-")) { throw usageError("Unexpected argument for agent-os provider loader."); } const name = flagName(value); - if (name !== "--workspace" && name !== "--token-file") { + if (name !== "--workspace" && name !== "--token-file" && name !== "--project-anchor-ssh-key-fingerprint") { throw usageError("Unsupported agent-os provider loader option."); } const parsed = readFlagValue(argv, index, name); @@ -90,11 +93,16 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid throw usageError("The workspace may be specified only once."); } workspace = parsed.value; - } else { + } else if (name === "--token-file") { if (tokenFile !== undefined) { throw usageError("The token file may be specified only once."); } tokenFile = parsed.value; + } else { + if (projectAnchorSshKeyFingerprint !== undefined) { + throw usageError("The project anchor SSH fingerprint may be specified only once."); + } + projectAnchorSshKeyFingerprint = parsed.value; } } if (workspace === undefined) { @@ -103,10 +111,16 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid if (tokenFile === undefined) { throw usageError("Missing required --token-file flag."); } + if (projectAnchorSshKeyFingerprint === undefined) { + throw usageError("Missing required --project-anchor-ssh-key-fingerprint flag."); + } if (tokenFile === "-" || !isAbsolute(tokenFile)) { throw usageError("The provider token must be supplied through an absolute file path."); } - return { workspace, tokenFile }; + if (!/^SHA256:[A-Za-z0-9+/]{43}=?$/.test(projectAnchorSshKeyFingerprint)) { + throw usageError("The project anchor SSH fingerprint is malformed."); + } + return { workspace, tokenFile, projectAnchorSshKeyFingerprint }; } function readFlagValue( diff --git a/src/runtime/platform-client.ts b/src/runtime/platform-client.ts index 4bdd66d..5f75274 100644 --- a/src/runtime/platform-client.ts +++ b/src/runtime/platform-client.ts @@ -51,6 +51,7 @@ export interface HCloudProviderLoadInput { workspace: string; callerToken: string; providerToken: Uint8Array; + projectAnchorSshKeyFingerprint: string; idempotencyKey: string; } @@ -66,7 +67,7 @@ export async function submitHcloudProviderLoad( ): Promise { let body: Uint8Array | undefined; try { - body = encodeProviderTokenBody(input.providerToken); + body = encodeProviderTokenBody(input.providerToken, input.projectAnchorSshKeyFingerprint); const response = await dependencies.send({ url: HCloudProviderLoadUrl, method: "POST", @@ -117,10 +118,18 @@ async function sendHttpsRequest(request: HCloudProviderLoadRequest): Promise { test("submits one fixed-route request with protected auth, explicit context, and relayed idempotency", async () => { @@ -23,12 +24,16 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), + projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, idempotencyKey: "00000000-0000-4000-8000-000000000001", }, { send: async (request) => { requests.push(request); - bodyHasProviderField = new TextDecoder().decode(request.body).includes('"provider_token":"synthetic"'); + const body = new TextDecoder().decode(request.body); + bodyHasProviderField = + body.includes('"provider_token":"synthetic"') && + body.includes(`"project_anchor_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`); return successResponse(); }, }, @@ -59,6 +64,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), + projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, idempotencyKey: "00000000-0000-4000-8000-000000000002", }, { @@ -67,6 +73,7 @@ describe("submitHcloudProviderLoad", () => { body: { ...successResponse().body, echoed_provider_token: SYNTHETIC_ECHO, + project_anchor_ssh_key_fingerprint: PROJECT_ANCHOR_FINGERPRINT, nested: { secret: SYNTHETIC_ECHO }, }, }), @@ -75,6 +82,7 @@ describe("submitHcloudProviderLoad", () => { expect(JSON.stringify(result)).not.toContain(SYNTHETIC_ECHO); expect(result).not.toHaveProperty("echoed_provider_token"); + expect(result).not.toHaveProperty("project_anchor_ssh_key_fingerprint"); expect(result).not.toHaveProperty("nested"); }); @@ -87,6 +95,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken, + projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, idempotencyKey: "00000000-0000-4000-8000-000000000003", }, { @@ -111,6 +120,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken, + projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, idempotencyKey: "00000000-0000-4000-8000-000000000004", }, { @@ -133,6 +143,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), + projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, idempotencyKey: "00000000-0000-4000-8000-000000000005", }, { @@ -158,19 +169,28 @@ describe("submitHcloudProviderLoad", () => { }); describe("agent-os load-hcloud-provider", () => { - test("accepts only explicit workspace and absolute token-file flags without exposing rejected values", async () => { + test("requires a well-formed explicit project-anchor SSH fingerprint before auth, file, or network access", async () => { const dependencies = fakeCommandDependencies(); const rejectedValue = "synthetic-argv-value"; await expect( - agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies), + agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token-file", "/synthetic/provider"], {}, dependencies.dependencies), ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); await expect( - agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token-file", "-"], {}, dependencies.dependencies), + agentOsView( + [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--project-anchor-ssh-key-fingerprint", + "SHA256:not-a-fingerprint", + ], + {}, + dependencies.dependencies, + ), ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); - await expect(agentOsView(["load-hcloud-provider", "--token-file", "/synthetic/provider"], {}, dependencies.dependencies)).rejects.toMatchObject({ - code: "AKUA_USAGE_ERROR", - }); const error = await captureError(() => agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies), @@ -197,6 +217,7 @@ describe("agent-os load-hcloud-provider", () => { expect(dependencies.events).toEqual(["auth", "file", "network"]); expect(dependencies.submissions).toBe(1); expect(dependencies.input?.workspace).toBe("ws_synthetic"); + expect(dependencies.input?.projectAnchorSshKeyFingerprint).toBe(PROJECT_ANCHOR_FINGERPRINT); expect(dependencies.input?.idempotencyKey).toMatch(/^[0-9a-f]{8}-/); expect(view.data).toEqual(successResponse().body); expect(renderSuccess(view, "json")).not.toContain(SYNTHETIC_ECHO); @@ -271,7 +292,15 @@ function successResponse() { } function commandArgs(): string[] { - return ["load-hcloud-provider", "--workspace", "ws_synthetic", "--token-file", "/synthetic/provider"]; + return [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--project-anchor-ssh-key-fingerprint", + PROJECT_ANCHOR_FINGERPRINT, + ]; } function fakeCommandDependencies(overrides: Partial = {}) { From 327c65e2e11a923d4c498db51a19e8912ef43866 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 17:33:15 +0200 Subject: [PATCH 8/9] feat(loader): require issuer identity attestation --- docs/architecture.md | 28 ++++---- ...-14-agent-os-hcloud-provider-loader-cli.md | 6 +- src/bin/akua.ts | 2 +- src/commands/agent-os.ts | 28 ++++++-- src/runtime/platform-client.ts | 60 ++++++++++------- test/agent-os-loader.test.ts | 64 ++++++++++++++----- 6 files changed, 128 insertions(+), 60 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8585482..30479f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,8 @@ non-generated command: akua agent-os load-hcloud-provider \ --workspace \ --token-file \ - --project-anchor-ssh-key-fingerprint + --project-identity-attestation \ + [--project-anchor-ssh-key-fingerprint ] ``` It is a deliberately thin local companion to the server-owned cnap Agent OS @@ -36,19 +37,24 @@ revocation, and all provider policy. This CLI never implements an inventory, uses generic `/secrets` or `/compute_configs` calls, opens a browser, runs a shell child, or falls back to another endpoint. -The command requires all three flags and rejects positional input, `--token`, -stdin, provider-token environment/profile input, API URL overrides, debug body -output, and retry transports. The project-anchor SSH key fingerprint must be a -provider-returned `SHA256:` SSH fingerprint; the CLI relays it verbatim and -never derives it from the provider token. No anchor name is accepted because -the held server contract has not required one. It reads normal Akua caller -authentication only from the protected local Akua config; `AKUA_API_TOKEN` is -rejected for this command. +The command requires workspace, token-file, and issuer-attestation flags and +rejects positional input, `--token`, stdin, provider-token environment/profile +input, API URL overrides, debug body output, and retry transports. The opaque, +non-secret issuer attestation is relayed verbatim; cnap must validate that it is +bound to the exact `agent-os-production` workspace and this one-shot request. +The provider-returned `SHA256:` SSH key fingerprint is optional and may be sent +only when predeclared; with no anchor, cnap must require fully empty inventory. +The CLI never derives either identity input from the provider token and accepts +no anchor name because the held server contract has not required one. It reads +normal Akua caller authentication only from the protected local Akua config; +`AKUA_API_TOKEN` is rejected for this command. It sends that authentication in `Authorization`, the explicit selection in `Akua-Context`, a newly generated `Idempotency-Key`, and a body containing the -provider-token plus the non-secret project-anchor fingerprint fields. The +provider token, issuer attestation, and optional anchor fingerprint. The production base URL and route are fixed; tests may inject a fake HTTPS transport -only through an internal dependency seam. +only through an internal dependency seam. The client allowlists server-provided +`secret_version_id` and `transaction_id` alongside opaque resource IDs so token +replacement/transaction continuity is detectable before spend. The provider file is opened exactly once in the compiled process by a dedicated Unix reader. The reader accepts only an absolute, caller-owned, regular `0600` diff --git a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md index 33ffc7d..7856233 100644 --- a/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md +++ b/docs/superpowers/plans/2026-07-14-agent-os-hcloud-provider-loader-cli.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it with the provider-returned project-anchor SSH fingerprint to the dedicated cnap transaction without exposing or retaining token contents. +**Goal:** Add the one compiled `akua agent-os load-hcloud-provider` command that reads one protected local provider file once and relays it with a workspace-bound issuer attestation and optional predeclared anchor fingerprint to the dedicated cnap transaction without exposing or retaining token contents. **Architecture:** A command parser validates only the two required flags and obtains normal caller authentication from protected local config. A Unix descriptor reader performs the file security checks and returns a single mutable byte buffer. A fixed-route HTTPS client builds the request in-process, sends precisely once with an idempotency key, clears the buffer, and projects the response through a fixed non-secret allowlist. The cnap server remains the owner of validation, persistence, inventory, rollback, and revocation. @@ -10,8 +10,8 @@ ## Global Constraints -- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file --project-anchor-ssh-key-fingerprint `. -- Held production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body with `provider_token` plus verbatim non-secret `project_anchor_ssh_key_fingerprint`. Never derive the fingerprint from the token; do not add a project-anchor name unless cnap #540's released contract requires it. +- The only added non-generated command is `akua agent-os load-hcloud-provider --workspace --token-file --project-identity-attestation [--project-anchor-ssh-key-fingerprint ]`. +- Held production request contract: `POST https://api.akua.dev/v1/agent_os/hcloud_provider_loads`, `Authorization` from protected config only, `Akua-Context: `, generated `Idempotency-Key`, and JSON body with `provider_token`, verbatim non-secret `project_identity_attestation`, and optional predeclared `project_anchor_ssh_key_fingerprint`. cnap must verify the attestation binds the exact workspace and one-shot request; no-anchor requires fully empty inventory. Never derive either identity input from the token; do not add a project-anchor name unless cnap #540's released contract requires it. Preserve allowlisted `secret_version_id` and `transaction_id` for pre-spend continuity checks. - No provider secret may enter argv, stdin, environment, profile, browser, shell child, curl, config, cache, log, stdout, stderr, error message, crash report, or test report. - Reject `AKUA_API_TOKEN` and all provider-token/environment/API-base override inputs for this command; do not retry a request after a transport outcome is uncertain. - The reader must require absolute, own-UID regular `0600` files, use `O_NOFOLLOW | O_CLOEXEC`, compare `lstat` and `fstat` dev/inode/uid/mode, read one bounded descriptor once, and close before networking. diff --git a/src/bin/akua.ts b/src/bin/akua.ts index a07cff2..8c3021d 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -100,7 +100,7 @@ function helpView(): RenderEnvelope { " akua auth login Save a local API token", " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", - " akua agent-os load-hcloud-provider --workspace --token-file --project-anchor-ssh-key-fingerprint ", + " akua agent-os load-hcloud-provider --workspace --token-file --project-identity-attestation [--project-anchor-ssh-key-fingerprint ]", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/agent-os.ts b/src/commands/agent-os.ts index 6039951..02df36e 100644 --- a/src/commands/agent-os.ts +++ b/src/commands/agent-os.ts @@ -50,6 +50,7 @@ export async function agentOsView( workspace: options.workspace, callerToken, providerToken, + projectIdentityAttestation: options.projectIdentityAttestation, projectAnchorSshKeyFingerprint: options.projectAnchorSshKeyFingerprint, idempotencyKey: dependencies.createIdempotencyKey(), }); @@ -65,12 +66,14 @@ export async function agentOsView( interface LoadHcloudProviderOptions { workspace: string; tokenFile: string; - projectAnchorSshKeyFingerprint: string; + projectIdentityAttestation: string; + projectAnchorSshKeyFingerprint?: string; } function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProviderOptions { let workspace: string | undefined; let tokenFile: string | undefined; + let projectIdentityAttestation: string | undefined; let projectAnchorSshKeyFingerprint: string | undefined; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; @@ -78,7 +81,12 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid throw usageError("Unexpected argument for agent-os provider loader."); } const name = flagName(value); - if (name !== "--workspace" && name !== "--token-file" && name !== "--project-anchor-ssh-key-fingerprint") { + if ( + name !== "--workspace" && + name !== "--token-file" && + name !== "--project-identity-attestation" && + name !== "--project-anchor-ssh-key-fingerprint" + ) { throw usageError("Unsupported agent-os provider loader option."); } const parsed = readFlagValue(argv, index, name); @@ -98,6 +106,11 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid throw usageError("The token file may be specified only once."); } tokenFile = parsed.value; + } else if (name === "--project-identity-attestation") { + if (projectIdentityAttestation !== undefined) { + throw usageError("The project identity attestation may be specified only once."); + } + projectIdentityAttestation = parsed.value; } else { if (projectAnchorSshKeyFingerprint !== undefined) { throw usageError("The project anchor SSH fingerprint may be specified only once."); @@ -111,16 +124,19 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid if (tokenFile === undefined) { throw usageError("Missing required --token-file flag."); } - if (projectAnchorSshKeyFingerprint === undefined) { - throw usageError("Missing required --project-anchor-ssh-key-fingerprint flag."); + if (projectIdentityAttestation === undefined) { + throw usageError("Missing required --project-identity-attestation flag."); } if (tokenFile === "-" || !isAbsolute(tokenFile)) { throw usageError("The provider token must be supplied through an absolute file path."); } - if (!/^SHA256:[A-Za-z0-9+/]{43}=?$/.test(projectAnchorSshKeyFingerprint)) { + if (!/^[A-Za-z0-9._~:/+=-]{16,512}$/.test(projectIdentityAttestation)) { + throw usageError("The project identity attestation is malformed."); + } + if (projectAnchorSshKeyFingerprint !== undefined && !/^SHA256:[A-Za-z0-9+/]{43}=?$/.test(projectAnchorSshKeyFingerprint)) { throw usageError("The project anchor SSH fingerprint is malformed."); } - return { workspace, tokenFile, projectAnchorSshKeyFingerprint }; + return { workspace, tokenFile, projectIdentityAttestation, projectAnchorSshKeyFingerprint }; } function readFlagValue( diff --git a/src/runtime/platform-client.ts b/src/runtime/platform-client.ts index 5f75274..188c56f 100644 --- a/src/runtime/platform-client.ts +++ b/src/runtime/platform-client.ts @@ -15,6 +15,7 @@ const responseFields = new Set([ "availability_timestamp", "request_id", "provider_fingerprint", + "transaction_id", ]); const inventoryFields = new Set([ "servers", @@ -51,7 +52,8 @@ export interface HCloudProviderLoadInput { workspace: string; callerToken: string; providerToken: Uint8Array; - projectAnchorSshKeyFingerprint: string; + projectIdentityAttestation: string; + projectAnchorSshKeyFingerprint?: string; idempotencyKey: string; } @@ -67,7 +69,7 @@ export async function submitHcloudProviderLoad( ): Promise { let body: Uint8Array | undefined; try { - body = encodeProviderTokenBody(input.providerToken, input.projectAnchorSshKeyFingerprint); + body = encodeProviderTokenBody(input.providerToken, input.projectIdentityAttestation, input.projectAnchorSshKeyFingerprint); const response = await dependencies.send({ url: HCloudProviderLoadUrl, method: "POST", @@ -118,30 +120,42 @@ async function sendHttpsRequest(request: HCloudProviderLoadRequest): Promise = [ + [encoder.encode("project_identity_attestation"), encoder.encode(projectIdentityAttestation)], + ...(projectAnchorSshKeyFingerprint === undefined + ? [] + : [[encoder.encode("project_anchor_ssh_key_fingerprint"), encoder.encode(projectAnchorSshKeyFingerprint)] as const]), + [encoder.encode("provider_token"), providerToken], + ]; + let encodedLength = 2 + Math.max(0, fields.length - 1); + for (const [name, value] of fields) { + encodedLength += name.byteLength + 5; + for (const byte of value) { + encodedLength += escapedLength(byte); + } } const body = new Uint8Array(encodedLength); let cursor = 0; - body.set(prefix, cursor); - cursor += prefix.byteLength; - cursor = writeEscapedBytes(body, cursor, fingerprintBytes); - body.set(separator, cursor); - cursor += separator.byteLength; - cursor = writeEscapedBytes(body, cursor, providerToken); - body.set(suffix, cursor); + body[cursor++] = 123; + for (const [index, [name, value]] of fields.entries()) { + if (index > 0) { + body[cursor++] = 44; + } + body[cursor++] = 34; + body.set(name, cursor); + cursor += name.byteLength; + body.set([34, 58, 34], cursor); + cursor += 3; + cursor = writeEscapedBytes(body, cursor, value); + body[cursor++] = 34; + } + body[cursor] = 125; return body; } diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts index 314fa12..aae135d 100644 --- a/test/agent-os-loader.test.ts +++ b/test/agent-os-loader.test.ts @@ -13,9 +13,10 @@ import { renderError, renderSuccess } from "../src/runtime/render"; const SYNTHETIC_TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); const SYNTHETIC_ECHO = "synthetic-response-field"; const PROJECT_ANCHOR_FINGERPRINT = `SHA256:${"A".repeat(43)}`; +const PROJECT_IDENTITY_ATTESTATION = "issuer.agent-os-production/v1:attestation_0123456789abcdef"; describe("submitHcloudProviderLoad", () => { - test("submits one fixed-route request with protected auth, explicit context, and relayed idempotency", async () => { + test("submits one fixed-route request with opaque issuer attestation, explicit context, and relayed idempotency", async () => { const requests: HCloudProviderLoadRequest[] = []; let bodyHasProviderField = false; @@ -24,7 +25,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000001", }, { @@ -33,7 +34,8 @@ describe("submitHcloudProviderLoad", () => { const body = new TextDecoder().decode(request.body); bodyHasProviderField = body.includes('"provider_token":"synthetic"') && - body.includes(`"project_anchor_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`); + body.includes(`"project_identity_attestation":"${PROJECT_IDENTITY_ATTESTATION}"`) && + !body.includes("project_anchor_ssh_key_fingerprint"); return successResponse(); }, }, @@ -52,19 +54,46 @@ describe("submitHcloudProviderLoad", () => { expect(result).toEqual({ workspace_id: "ws_synthetic", secret_id: "sec_synthetic", + secret_version_id: "secver_synthetic", compute_config_id: "cfg_synthetic", + transaction_id: "txn_synthetic", inventory: { servers: 0, volumes: 0 }, request_id: "req_synthetic", }); }); + test("relays a predeclared optional SSH anchor without deriving identity from provider bytes", async () => { + let bodyHasOptionalAnchor = false; + + await submitHcloudProviderLoad( + { + workspace: "ws_synthetic", + callerToken: "caller-auth-fixture", + providerToken: SYNTHETIC_TOKEN.slice(), + projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, + projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + idempotencyKey: "00000000-0000-4000-8000-000000000002", + }, + { + send: async (request) => { + bodyHasOptionalAnchor = new TextDecoder().decode(request.body).includes( + `"project_anchor_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`, + ); + return successResponse(); + }, + }, + ); + + expect(bodyHasOptionalAnchor).toBe(true); + }); + test("projects success through a strict response allowlist", async () => { const result = await submitHcloudProviderLoad( { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000002", }, { @@ -74,6 +103,7 @@ describe("submitHcloudProviderLoad", () => { ...successResponse().body, echoed_provider_token: SYNTHETIC_ECHO, project_anchor_ssh_key_fingerprint: PROJECT_ANCHOR_FINGERPRINT, + project_identity_attestation: PROJECT_IDENTITY_ATTESTATION, nested: { secret: SYNTHETIC_ECHO }, }, }), @@ -83,6 +113,7 @@ describe("submitHcloudProviderLoad", () => { expect(JSON.stringify(result)).not.toContain(SYNTHETIC_ECHO); expect(result).not.toHaveProperty("echoed_provider_token"); expect(result).not.toHaveProperty("project_anchor_ssh_key_fingerprint"); + expect(result).not.toHaveProperty("project_identity_attestation"); expect(result).not.toHaveProperty("nested"); }); @@ -95,7 +126,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken, - projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000003", }, { @@ -120,7 +151,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken, - projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000004", }, { @@ -143,7 +174,7 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000005", }, { @@ -169,7 +200,7 @@ describe("submitHcloudProviderLoad", () => { }); describe("agent-os load-hcloud-provider", () => { - test("requires a well-formed explicit project-anchor SSH fingerprint before auth, file, or network access", async () => { + test("requires a well-formed explicit issuer attestation before auth, file, or network access while allowing no anchor", async () => { const dependencies = fakeCommandDependencies(); const rejectedValue = "synthetic-argv-value"; @@ -184,17 +215,15 @@ describe("agent-os load-hcloud-provider", () => { "ws_synthetic", "--token-file", "/synthetic/provider", - "--project-anchor-ssh-key-fingerprint", - "SHA256:not-a-fingerprint", + "--project-identity-attestation", + "malformed attestation", ], {}, dependencies.dependencies, ), ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); - const error = await captureError(() => - agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies), - ); + const error = await captureError(() => agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies)); expect(renderError(error, "json")).not.toContain(rejectedValue); expect(dependencies.fileReads).toBe(0); expect(dependencies.submissions).toBe(0); @@ -217,7 +246,8 @@ describe("agent-os load-hcloud-provider", () => { expect(dependencies.events).toEqual(["auth", "file", "network"]); expect(dependencies.submissions).toBe(1); expect(dependencies.input?.workspace).toBe("ws_synthetic"); - expect(dependencies.input?.projectAnchorSshKeyFingerprint).toBe(PROJECT_ANCHOR_FINGERPRINT); + expect(dependencies.input?.projectIdentityAttestation).toBe(PROJECT_IDENTITY_ATTESTATION); + expect(dependencies.input?.projectAnchorSshKeyFingerprint).toBeUndefined(); expect(dependencies.input?.idempotencyKey).toMatch(/^[0-9a-f]{8}-/); expect(view.data).toEqual(successResponse().body); expect(renderSuccess(view, "json")).not.toContain(SYNTHETIC_ECHO); @@ -284,7 +314,9 @@ function successResponse() { body: { workspace_id: "ws_synthetic", secret_id: "sec_synthetic", + secret_version_id: "secver_synthetic", compute_config_id: "cfg_synthetic", + transaction_id: "txn_synthetic", inventory: { servers: 0, volumes: 0 }, request_id: "req_synthetic", }, @@ -298,8 +330,8 @@ function commandArgs(): string[] { "ws_synthetic", "--token-file", "/synthetic/provider", - "--project-anchor-ssh-key-fingerprint", - PROJECT_ANCHOR_FINGERPRINT, + "--project-identity-attestation", + PROJECT_IDENTITY_ATTESTATION, ]; } From 7aa1eed22d2f7f30690b855f8c7d3b9a38e2712a Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 14 Jul 2026 23:15:43 +0200 Subject: [PATCH 9/9] fix(loader): match released HCloud provider route contract --- docs/architecture.md | 40 ++++++++--------- src/bin/akua.ts | 2 +- src/commands/agent-os.ts | 50 +++++++++++---------- src/runtime/platform-client.ts | 80 ++++++++++----------------------- test/agent-os-loader.test.ts | 81 ++++++++++++++++++---------------- test/cli.test.ts | 4 ++ 6 files changed, 119 insertions(+), 138 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 30479f0..dde632b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,8 +25,8 @@ non-generated command: akua agent-os load-hcloud-provider \ --workspace \ --token-file \ - --project-identity-attestation \ - [--project-anchor-ssh-key-fingerprint ] + [--expected-ssh-key-fingerprint \ + [--expected-ssh-key-name ]] ``` It is a deliberately thin local companion to the server-owned cnap Agent OS @@ -37,24 +37,22 @@ revocation, and all provider policy. This CLI never implements an inventory, uses generic `/secrets` or `/compute_configs` calls, opens a browser, runs a shell child, or falls back to another endpoint. -The command requires workspace, token-file, and issuer-attestation flags and -rejects positional input, `--token`, stdin, provider-token environment/profile -input, API URL overrides, debug body output, and retry transports. The opaque, -non-secret issuer attestation is relayed verbatim; cnap must validate that it is -bound to the exact `agent-os-production` workspace and this one-shot request. -The provider-returned `SHA256:` SSH key fingerprint is optional and may be sent -only when predeclared; with no anchor, cnap must require fully empty inventory. -The CLI never derives either identity input from the provider token and accepts -no anchor name because the held server contract has not required one. It reads +The command requires workspace and token-file flags and rejects positional +input, `--token`, stdin, provider-token environment/profile input, API URL +overrides, debug body output, and retry transports. A provider-returned SSH key +fingerprint is optional and may be sent only when predeclared; its optional name +requires the fingerprint. With no expected key, cnap requires a fully empty +inventory. The CLI never derives identity from the provider token. It reads normal Akua caller authentication only from the protected local Akua config; `AKUA_API_TOKEN` is rejected for this command. It sends that authentication in `Authorization`, the explicit selection in `Akua-Context`, a newly generated `Idempotency-Key`, and a body containing the -provider token, issuer attestation, and optional anchor fingerprint. The -production base URL and route are fixed; tests may inject a fake HTTPS transport -only through an internal dependency seam. The client allowlists server-provided -`secret_version_id` and `transaction_id` alongside opaque resource IDs so token -replacement/transaction continuity is detectable before spend. +provider token plus optional `expected_ssh_key_fingerprint` and +`expected_ssh_key_name`. The production base URL and route are fixed; tests may +inject a fake HTTPS transport only through an internal dependency seam. The +client allowlists only `loader_id`, `attestation_id`, `secret_id`, +`secret_version_id`, `compute_config_id`, and `expected_ssh_key_fingerprint`, +preserving the secret-version continuity field before spend. The provider file is opened exactly once in the compiled process by a dedicated Unix reader. The reader accepts only an absolute, caller-owned, regular `0600` @@ -69,11 +67,11 @@ deliberate secret persistence or exposure through CLI interfaces, logs, reports, or configuration. A stronger heap guarantee requires a reviewed native module, not a weaker file or API contract. -The endpoint is a release dependency owned by -[cnap #540](https://github.com/akua-dev/cnap/issues/540): its HCloud -project-identity security gate must resolve and its released route contract must -exactly match this companion before any CLI PR, merge, release, or Phase A -invocation. The companion's eventual CLI-owned release is coordinated as +The endpoint is a release dependency delivered by +[cnap #545](https://github.com/akua-dev/cnap/pull/545), implementing +`agentOs.hcloudProviderLoads.create`: its production delivery must complete and +its released route contract must exactly match this companion before CLI +publication or Phase A invocation. The companion's eventual CLI-owned release is coordinated as `0.9.0`; it does not duplicate the multi-platform distribution scope in PR #21. ## Current Repo Boundary diff --git a/src/bin/akua.ts b/src/bin/akua.ts index 8c3021d..bd8762e 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -100,7 +100,7 @@ function helpView(): RenderEnvelope { " akua auth login Save a local API token", " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", - " akua agent-os load-hcloud-provider --workspace --token-file --project-identity-attestation [--project-anchor-ssh-key-fingerprint ]", + " akua agent-os load-hcloud-provider --workspace --token-file [--expected-ssh-key-fingerprint [--expected-ssh-key-name ]]", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/agent-os.ts b/src/commands/agent-os.ts index 02df36e..9885743 100644 --- a/src/commands/agent-os.ts +++ b/src/commands/agent-os.ts @@ -50,8 +50,8 @@ export async function agentOsView( workspace: options.workspace, callerToken, providerToken, - projectIdentityAttestation: options.projectIdentityAttestation, - projectAnchorSshKeyFingerprint: options.projectAnchorSshKeyFingerprint, + expectedSshKeyFingerprint: options.expectedSshKeyFingerprint, + expectedSshKeyName: options.expectedSshKeyName, idempotencyKey: dependencies.createIdempotencyKey(), }); return { @@ -66,15 +66,15 @@ export async function agentOsView( interface LoadHcloudProviderOptions { workspace: string; tokenFile: string; - projectIdentityAttestation: string; - projectAnchorSshKeyFingerprint?: string; + expectedSshKeyFingerprint?: string; + expectedSshKeyName?: string; } function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProviderOptions { let workspace: string | undefined; let tokenFile: string | undefined; - let projectIdentityAttestation: string | undefined; - let projectAnchorSshKeyFingerprint: string | undefined; + let expectedSshKeyFingerprint: string | undefined; + let expectedSshKeyName: string | undefined; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (!value.startsWith("-")) { @@ -84,8 +84,8 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid if ( name !== "--workspace" && name !== "--token-file" && - name !== "--project-identity-attestation" && - name !== "--project-anchor-ssh-key-fingerprint" + name !== "--expected-ssh-key-fingerprint" && + name !== "--expected-ssh-key-name" ) { throw usageError("Unsupported agent-os provider loader option."); } @@ -106,16 +106,16 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid throw usageError("The token file may be specified only once."); } tokenFile = parsed.value; - } else if (name === "--project-identity-attestation") { - if (projectIdentityAttestation !== undefined) { - throw usageError("The project identity attestation may be specified only once."); + } else if (name === "--expected-ssh-key-fingerprint") { + if (expectedSshKeyFingerprint !== undefined) { + throw usageError("The expected SSH key fingerprint may be specified only once."); } - projectIdentityAttestation = parsed.value; + expectedSshKeyFingerprint = parsed.value; } else { - if (projectAnchorSshKeyFingerprint !== undefined) { - throw usageError("The project anchor SSH fingerprint may be specified only once."); + if (expectedSshKeyName !== undefined) { + throw usageError("The expected SSH key name may be specified only once."); } - projectAnchorSshKeyFingerprint = parsed.value; + expectedSshKeyName = parsed.value; } } if (workspace === undefined) { @@ -124,19 +124,23 @@ function parseLoadHcloudProviderFlags(argv: readonly string[]): LoadHcloudProvid if (tokenFile === undefined) { throw usageError("Missing required --token-file flag."); } - if (projectIdentityAttestation === undefined) { - throw usageError("Missing required --project-identity-attestation flag."); - } if (tokenFile === "-" || !isAbsolute(tokenFile)) { throw usageError("The provider token must be supplied through an absolute file path."); } - if (!/^[A-Za-z0-9._~:/+=-]{16,512}$/.test(projectIdentityAttestation)) { - throw usageError("The project identity attestation is malformed."); + if (expectedSshKeyFingerprint !== undefined && !isSafeExpectedSshField(expectedSshKeyFingerprint)) { + throw usageError("The expected SSH key fingerprint is malformed."); + } + if (expectedSshKeyName !== undefined && !isSafeExpectedSshField(expectedSshKeyName)) { + throw usageError("The expected SSH key name is malformed."); } - if (projectAnchorSshKeyFingerprint !== undefined && !/^SHA256:[A-Za-z0-9+/]{43}=?$/.test(projectAnchorSshKeyFingerprint)) { - throw usageError("The project anchor SSH fingerprint is malformed."); + if (expectedSshKeyName !== undefined && expectedSshKeyFingerprint === undefined) { + throw usageError("--expected-ssh-key-name requires --expected-ssh-key-fingerprint."); } - return { workspace, tokenFile, projectIdentityAttestation, projectAnchorSshKeyFingerprint }; + return { workspace, tokenFile, expectedSshKeyFingerprint, expectedSshKeyName }; +} + +function isSafeExpectedSshField(value: string): boolean { + return /^[\x21-\x7e]{1,200}$/.test(value); } function readFlagValue( diff --git a/src/runtime/platform-client.ts b/src/runtime/platform-client.ts index 188c56f..ca9296b 100644 --- a/src/runtime/platform-client.ts +++ b/src/runtime/platform-client.ts @@ -3,33 +3,12 @@ import { clearBytes } from "./secure-token-file"; const HCloudProviderLoadUrl = "https://api.akua.dev/v1/agent_os/hcloud_provider_loads"; const responseFields = new Set([ - "workspace_id", + "loader_id", + "attestation_id", "secret_id", "secret_version_id", "compute_config_id", - "provider_project_id", - "provider_project_name", - "inventory", - "catalog_checked_at", - "price_eur", - "availability_timestamp", - "request_id", - "provider_fingerprint", - "transaction_id", -]); -const inventoryFields = new Set([ - "servers", - "volumes", - "networks", - "primary_ips", - "floating_ips", - "load_balancers", - "firewalls", - "placement_groups", - "ssh_keys", - "images", - "certificates", - "actions", + "expected_ssh_key_fingerprint", ]); export interface HCloudProviderLoadRequest { @@ -52,8 +31,8 @@ export interface HCloudProviderLoadInput { workspace: string; callerToken: string; providerToken: Uint8Array; - projectIdentityAttestation: string; - projectAnchorSshKeyFingerprint?: string; + expectedSshKeyFingerprint?: string; + expectedSshKeyName?: string; idempotencyKey: string; } @@ -69,7 +48,7 @@ export async function submitHcloudProviderLoad( ): Promise { let body: Uint8Array | undefined; try { - body = encodeProviderTokenBody(input.providerToken, input.projectIdentityAttestation, input.projectAnchorSshKeyFingerprint); + body = encodeProviderTokenBody(input.providerToken, input.expectedSshKeyFingerprint, input.expectedSshKeyName); const response = await dependencies.send({ url: HCloudProviderLoadUrl, method: "POST", @@ -81,7 +60,7 @@ export async function submitHcloudProviderLoad( }, body, }); - if (response.status < 200 || response.status >= 300) { + if (response.status !== 201) { throw serverRejectedError(response.status, response.body); } return allowlistedResult(response.body); @@ -122,16 +101,18 @@ async function sendHttpsRequest(request: HCloudProviderLoadRequest): Promise = [ - [encoder.encode("project_identity_attestation"), encoder.encode(projectIdentityAttestation)], - ...(projectAnchorSshKeyFingerprint === undefined - ? [] - : [[encoder.encode("project_anchor_ssh_key_fingerprint"), encoder.encode(projectAnchorSshKeyFingerprint)] as const]), [encoder.encode("provider_token"), providerToken], + ...(expectedSshKeyFingerprint === undefined + ? [] + : [[encoder.encode("expected_ssh_key_fingerprint"), encoder.encode(expectedSshKeyFingerprint)] as const]), + ...(expectedSshKeyName === undefined + ? [] + : [[encoder.encode("expected_ssh_key_name"), encoder.encode(expectedSshKeyName)] as const]), ]; let encodedLength = 2 + Math.max(0, fields.length - 1); for (const [name, value] of fields) { @@ -210,36 +191,23 @@ function allowlistedResult(body: unknown): HCloudProviderLoadResult { if (!responseFields.has(field)) { continue; } - if (field === "inventory") { - const inventory = allowlistedInventory(value); - if (inventory) { - result[field] = inventory; - } - continue; - } - if (typeof value === "string" || typeof value === "number") { + if (typeof value === "string" || (field === "expected_ssh_key_fingerprint" && value === null)) { result[field] = value; } } - if (typeof result.workspace_id !== "string" || typeof result.request_id !== "string") { + if ( + typeof result.loader_id !== "string" || + typeof result.attestation_id !== "string" || + typeof result.secret_id !== "string" || + typeof result.secret_version_id !== "string" || + typeof result.compute_config_id !== "string" || + (typeof result.expected_ssh_key_fingerprint !== "string" && result.expected_ssh_key_fingerprint !== null) + ) { throw invalidServerResponseError(); } return result; } -function allowlistedInventory(value: unknown): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - const inventory: Record = {}; - for (const [field, count] of Object.entries(value)) { - if (inventoryFields.has(field) && Number.isSafeInteger(count) && typeof count === "number" && count >= 0) { - inventory[field] = count; - } - } - return inventory; -} - function serverRejectedError(status: number, body: unknown): HCloudProviderLoadError { const error = isRecord(body) && isRecord(body.error) ? body.error : {}; const requestId = typeof error.request_id === "string" ? error.request_id : undefined; diff --git a/test/agent-os-loader.test.ts b/test/agent-os-loader.test.ts index aae135d..c465414 100644 --- a/test/agent-os-loader.test.ts +++ b/test/agent-os-loader.test.ts @@ -13,10 +13,10 @@ import { renderError, renderSuccess } from "../src/runtime/render"; const SYNTHETIC_TOKEN = new Uint8Array([115, 121, 110, 116, 104, 101, 116, 105, 99]); const SYNTHETIC_ECHO = "synthetic-response-field"; const PROJECT_ANCHOR_FINGERPRINT = `SHA256:${"A".repeat(43)}`; -const PROJECT_IDENTITY_ATTESTATION = "issuer.agent-os-production/v1:attestation_0123456789abcdef"; +const PROJECT_ANCHOR_NAME = "agent-os-production-anchor"; describe("submitHcloudProviderLoad", () => { - test("submits one fixed-route request with opaque issuer attestation, explicit context, and relayed idempotency", async () => { + test("submits one fixed-route request without an optional anchor, explicit context, and relayed idempotency", async () => { const requests: HCloudProviderLoadRequest[] = []; let bodyHasProviderField = false; @@ -25,7 +25,6 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000001", }, { @@ -34,8 +33,8 @@ describe("submitHcloudProviderLoad", () => { const body = new TextDecoder().decode(request.body); bodyHasProviderField = body.includes('"provider_token":"synthetic"') && - body.includes(`"project_identity_attestation":"${PROJECT_IDENTITY_ATTESTATION}"`) && - !body.includes("project_anchor_ssh_key_fingerprint"); + !body.includes("expected_ssh_key_fingerprint") && + !body.includes("expected_ssh_key_name"); return successResponse(); }, }, @@ -52,13 +51,12 @@ describe("submitHcloudProviderLoad", () => { }); expect(bodyHasProviderField).toBe(true); expect(result).toEqual({ - workspace_id: "ws_synthetic", + loader_id: "pvl_synthetic", + attestation_id: "att_synthetic", secret_id: "sec_synthetic", secret_version_id: "secver_synthetic", compute_config_id: "cfg_synthetic", - transaction_id: "txn_synthetic", - inventory: { servers: 0, volumes: 0 }, - request_id: "req_synthetic", + expected_ssh_key_fingerprint: null, }); }); @@ -70,15 +68,16 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, - projectAnchorSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + expectedSshKeyFingerprint: PROJECT_ANCHOR_FINGERPRINT, + expectedSshKeyName: PROJECT_ANCHOR_NAME, idempotencyKey: "00000000-0000-4000-8000-000000000002", }, { send: async (request) => { - bodyHasOptionalAnchor = new TextDecoder().decode(request.body).includes( - `"project_anchor_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`, - ); + const body = new TextDecoder().decode(request.body); + bodyHasOptionalAnchor = + body.includes(`"expected_ssh_key_fingerprint":"${PROJECT_ANCHOR_FINGERPRINT}"`) && + body.includes(`"expected_ssh_key_name":"${PROJECT_ANCHOR_NAME}"`); return successResponse(); }, }, @@ -93,17 +92,16 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000002", }, { send: async () => ({ - status: 200, + status: 201, body: { ...successResponse().body, echoed_provider_token: SYNTHETIC_ECHO, - project_anchor_ssh_key_fingerprint: PROJECT_ANCHOR_FINGERPRINT, - project_identity_attestation: PROJECT_IDENTITY_ATTESTATION, + project_identity_attestation: "synthetic-attestation-echo", + transaction_id: "txn_synthetic", nested: { secret: SYNTHETIC_ECHO }, }, }), @@ -112,8 +110,8 @@ describe("submitHcloudProviderLoad", () => { expect(JSON.stringify(result)).not.toContain(SYNTHETIC_ECHO); expect(result).not.toHaveProperty("echoed_provider_token"); - expect(result).not.toHaveProperty("project_anchor_ssh_key_fingerprint"); expect(result).not.toHaveProperty("project_identity_attestation"); + expect(result).not.toHaveProperty("transaction_id"); expect(result).not.toHaveProperty("nested"); }); @@ -126,7 +124,6 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken, - projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000003", }, { @@ -151,7 +148,6 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken, - projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000004", }, { @@ -174,7 +170,6 @@ describe("submitHcloudProviderLoad", () => { workspace: "ws_synthetic", callerToken: "caller-auth-fixture", providerToken: SYNTHETIC_TOKEN.slice(), - projectIdentityAttestation: PROJECT_IDENTITY_ATTESTATION, idempotencyKey: "00000000-0000-4000-8000-000000000005", }, { @@ -200,12 +195,27 @@ describe("submitHcloudProviderLoad", () => { }); describe("agent-os load-hcloud-provider", () => { - test("requires a well-formed explicit issuer attestation before auth, file, or network access while allowing no anchor", async () => { + test("accepts no anchor and rejects malformed anchor inputs before auth, file, or network access", async () => { const dependencies = fakeCommandDependencies(); const rejectedValue = "synthetic-argv-value"; + await expect(agentOsView(commandArgs(), {}, dependencies.dependencies)).resolves.toMatchObject({ + data: successResponse().body, + }); await expect( - agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token-file", "/synthetic/provider"], {}, dependencies.dependencies), + agentOsView( + [ + "load-hcloud-provider", + "--workspace", + "ws_synthetic", + "--token-file", + "/synthetic/provider", + "--expected-ssh-key-fingerprint", + "bad\u0000fingerprint", + ], + {}, + dependencies.dependencies, + ), ).rejects.toMatchObject({ code: "AKUA_USAGE_ERROR" }); await expect( agentOsView( @@ -215,8 +225,8 @@ describe("agent-os load-hcloud-provider", () => { "ws_synthetic", "--token-file", "/synthetic/provider", - "--project-identity-attestation", - "malformed attestation", + "--expected-ssh-key-name", + PROJECT_ANCHOR_NAME, ], {}, dependencies.dependencies, @@ -225,8 +235,8 @@ describe("agent-os load-hcloud-provider", () => { const error = await captureError(() => agentOsView(["load-hcloud-provider", "--workspace", "ws_synthetic", "--token", rejectedValue], {}, dependencies.dependencies)); expect(renderError(error, "json")).not.toContain(rejectedValue); - expect(dependencies.fileReads).toBe(0); - expect(dependencies.submissions).toBe(0); + expect(dependencies.fileReads).toBe(1); + expect(dependencies.submissions).toBe(1); }); test("rejects environment caller authentication before config, file, or network access", async () => { @@ -246,8 +256,8 @@ describe("agent-os load-hcloud-provider", () => { expect(dependencies.events).toEqual(["auth", "file", "network"]); expect(dependencies.submissions).toBe(1); expect(dependencies.input?.workspace).toBe("ws_synthetic"); - expect(dependencies.input?.projectIdentityAttestation).toBe(PROJECT_IDENTITY_ATTESTATION); - expect(dependencies.input?.projectAnchorSshKeyFingerprint).toBeUndefined(); + expect(dependencies.input?.expectedSshKeyFingerprint).toBeUndefined(); + expect(dependencies.input?.expectedSshKeyName).toBeUndefined(); expect(dependencies.input?.idempotencyKey).toMatch(/^[0-9a-f]{8}-/); expect(view.data).toEqual(successResponse().body); expect(renderSuccess(view, "json")).not.toContain(SYNTHETIC_ECHO); @@ -310,15 +320,14 @@ describe("agent-os load-hcloud-provider", () => { function successResponse() { return { - status: 200, + status: 201, body: { - workspace_id: "ws_synthetic", + loader_id: "pvl_synthetic", + attestation_id: "att_synthetic", secret_id: "sec_synthetic", secret_version_id: "secver_synthetic", compute_config_id: "cfg_synthetic", - transaction_id: "txn_synthetic", - inventory: { servers: 0, volumes: 0 }, - request_id: "req_synthetic", + expected_ssh_key_fingerprint: null, }, }; } @@ -330,8 +339,6 @@ function commandArgs(): string[] { "ws_synthetic", "--token-file", "/synthetic/provider", - "--project-identity-attestation", - PROJECT_IDENTITY_ATTESTATION, ]; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 314036c..2858b5d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -24,6 +24,10 @@ describe("akua entrypoint", () => { expect(exitCode).toBe(0); expect(stdout).toContain("akua agent-os load-hcloud-provider"); expect(stdout).toContain("--token-file"); + expect(stdout).toContain("--expected-ssh-key-fingerprint"); + expect(stdout).toContain("--expected-ssh-key-name"); + expect(stdout).not.toContain("--project-identity-attestation"); + expect(stdout).not.toContain("--project-anchor-ssh-key-fingerprint"); expect(stdout).not.toContain("--token <"); });