Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ parallel refactor branch).
| `sphere nametag` / `crypto` / `util` / `faucet` | legacy bridge | |
| `sphere daemon` / `config` / `completions` | legacy bridge | |
| `sphere host` | **DM-native (live)** | HMCP-0: spawn, list, stop, start, inspect, remove, pause, resume, help, cmd |
| `sphere trader` | **DM-native (live)** | ACP-0: create-intent, cancel-intent, list-intents, list-deals, portfolio, set-strategy, status. Mirrors canonical [`trader-ctl`](https://github.com/vrogojin/trader-service) |
| `sphere tenant` | Phase 4 (stub) | Exits with scheduled message |

## Install
Expand All @@ -45,6 +46,13 @@ sphere wallet init --network testnet
# DM-native host example
sphere host list --manager @myhostmanager
sphere host spawn --manager @myhostmanager --template tpl-1 mybot

# DM-native trader example (talks directly to a running trader tenant)
sphere trader status --tenant @trader-alice
sphere trader create-intent --tenant @trader-alice \
--direction sell --base UCT --quote USDC \
--rate-min 95 --rate-max 100 --volume-min 10 --volume-total 100
sphere trader list-deals --tenant @trader-alice --state active
```

## Development
Expand Down
2 changes: 1 addition & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('sphere-cli scaffold', () => {
const expectedNamespaces = [
'wallet', 'balance', 'payments', 'dm', 'group', 'market', 'swap',
'invoice', 'nametag', 'crypto', 'util', 'faucet', 'daemon',
'host', 'tenant', 'config', 'completions',
'host', 'tenant', 'trader', 'config', 'completions',
];
for (const ns of expectedNamespaces) {
expect(help).toContain(ns);
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { Command } from 'commander';
import { VERSION } from './version.js';
import { createHostCommand } from './host/host-commands.js';
import { createTraderCommand } from './trader/trader-commands.js';

// Legacy namespaces that delegate to the sphere-sdk CLI dispatcher.
// These are wired in phase 2 and replaced command-by-command in phase 4+.
Expand Down Expand Up @@ -171,6 +172,12 @@ export function createCli(): Command {
// Phase 4 (live): `sphere host` — HMCP over Sphere DMs.
program.addCommand(createHostCommand());

// Phase 4 (live): `sphere trader` — ACP over Sphere DMs.
// Mirrors the canonical `trader-ctl` tool from vrogojin/trader-service.
// Operators with the canonical tool installed can use either; sphere-cli
// ships this for convenience parity with `sphere host`.
program.addCommand(createTraderCommand());

return program;
}

Expand Down
14 changes: 14 additions & 0 deletions src/shared/timeout-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Shared timeout constants used across CLI namespaces.
*
* MIN_TIMEOUT_MS is the smallest --timeout value sphere-cli accepts before
* forwarding to the tenant. Anything finer-grained guarantees a timeout
* before the tenant has even finished parsing the request, which a malicious
* controller could weaponise to drain the registry's concurrency slots.
*
* Aligned with agentic-hosting's `command-registry.ts` MIN_TIMEOUT_MS = 100.
* If those layers diverge, this value MUST track the tenant-side floor —
* sending a value below the tenant's floor produces a confusing two-hop
* `invalid_params` error far from the source.
*/
export const MIN_TIMEOUT_MS = 100;
56 changes: 56 additions & 0 deletions src/trader/acp-envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* ACP-0 envelope helpers — JSON parse + size cap + dangerous-keys + freshness.
*
* Mirror of trader-service/src/protocols/envelope.ts (itself mirroring
* agentic-hosting/src/protocols/envelope.ts post-decoupling). Trimmed to the
* surface this CLI's DM transport actually needs.
*/

import { isValidAcpMessage } from './acp-protocols.js';
import type { AcpMessage } from './acp-protocols.js';

/** 64 KiB ceiling on serialized ACP payloads (UTF-16 code-unit count). */
export const MAX_MESSAGE_SIZE = 64 * 1024;
export const MAX_NESTING_DEPTH = 20;

/**
* ±5min clock-skew tolerance applied at every inbound parse site. Beyond
* the structural validity check, this catches stale replays whose msg_id /
* content hash slipped past dedup (e.g. after TTL expiry, restart of the
* receiver, or cross-instance log loss). Symmetric — receivers rejecting
* only "future" leak clock-skew info to the sender.
*/
export const MAX_CLOCK_SKEW_MS = 300_000;

export function hasDangerousKeys(obj: unknown, depth = 0): boolean {
if (depth > MAX_NESTING_DEPTH) return true;
if (typeof obj !== 'object' || obj === null) return false;
for (const key of Object.keys(obj as Record<string, unknown>)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
return true;
}
const val = (obj as Record<string, unknown>)[key];
if (typeof val === 'object' && val !== null && hasDangerousKeys(val, depth + 1)) {
return true;
}
}
return false;
}

export function isTimestampFresh(tsMs: number, now: number = Date.now()): boolean {
if (typeof tsMs !== 'number' || !Number.isFinite(tsMs)) return false;
return Math.abs(tsMs - now) <= MAX_CLOCK_SKEW_MS;
}

export function parseAcpJson(data: string): AcpMessage | null {
if (data.length > MAX_MESSAGE_SIZE) return null;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
return null;
}
if (hasDangerousKeys(parsed)) return null;
if (!isValidAcpMessage(parsed)) return null;
return parsed;
}
113 changes: 113 additions & 0 deletions src/trader/acp-protocols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* ACP-0 (Agent Control Protocol) types, constructors, and validators.
*
* Mirror of trader-service/src/protocols/acp.ts (which itself mirrors
* agentic-hosting/src/protocols/acp.ts after the Phase 4(h) decoupling).
* ACP-0 is owned by the agentic-hosting protocol spec; if the spec evolves,
* all three repos must update in lockstep.
*
* Why duplicated rather than imported: this CLI ships standalone (no
* runtime dep on trader-service); the transport boundary is the same
* 6-message ACP envelope set so the duplication is small + bounded.
*/

import { randomUUID } from 'node:crypto';
import { hasDangerousKeys } from './acp-envelope.js';

export const ACP_VERSION = '0.1';

export const ACP_MESSAGE_TYPES = [
'acp.hello',
'acp.hello_ack',
'acp.heartbeat',
'acp.ping',
'acp.pong',
'acp.command',
'acp.result',
'acp.error',
] as const;
export type AcpMessageType = (typeof ACP_MESSAGE_TYPES)[number];

export interface AcpCommandPayload {
readonly command_id: string;
readonly name: string;
readonly params: Readonly<Record<string, unknown>>;
}

export interface AcpResultPayload {
readonly command_id: string;
readonly ok: true;
readonly result: Readonly<Record<string, unknown>>;
}

export interface AcpErrorPayload {
readonly command_id: string;
readonly ok: false;
readonly error_code: string;
readonly message: string;
}

export interface AcpMessage {
readonly acp_version: string;
readonly msg_id: string;
readonly ts_ms: number;
readonly instance_id: string;
readonly instance_name: string;
readonly type: AcpMessageType;
readonly payload: Record<string, unknown>;
}

export function createAcpMessage(
type: AcpMessageType,
instanceId: string,
instanceName: string,
payload: Record<string, unknown>,
): AcpMessage {
return {
acp_version: ACP_VERSION,
msg_id: randomUUID(),
ts_ms: Date.now(),
instance_id: instanceId,
instance_name: instanceName,
type,
payload,
};
}

export function isValidAcpMessage(msg: unknown): msg is AcpMessage {
if (typeof msg !== 'object' || msg === null) return false;
const obj = msg as Record<string, unknown>;
return (
obj['acp_version'] === ACP_VERSION &&
typeof obj['msg_id'] === 'string' && obj['msg_id'] !== '' &&
Number.isFinite(obj['ts_ms']) &&
typeof obj['instance_id'] === 'string' && obj['instance_id'] !== '' &&
typeof obj['instance_name'] === 'string' && obj['instance_name'] !== '' &&
typeof obj['type'] === 'string' &&
(ACP_MESSAGE_TYPES as readonly string[]).includes(obj['type'] as string) &&
typeof obj['payload'] === 'object' &&
obj['payload'] !== null &&
!hasDangerousKeys(obj)
);
}

export function isAcpResultPayload(payload: unknown): payload is AcpResultPayload {
if (typeof payload !== 'object' || payload === null) return false;
const p = payload as Record<string, unknown>;
return (
typeof p['command_id'] === 'string' && p['command_id'] !== '' &&
p['ok'] === true &&
typeof p['result'] === 'object' && p['result'] !== null && !Array.isArray(p['result'])
);
}

export function isAcpErrorPayload(payload: unknown): payload is AcpErrorPayload {
if (typeof payload !== 'object' || payload === null) return false;
const p = payload as Record<string, unknown>;
return (
typeof p['command_id'] === 'string' &&
p['ok'] === false &&
typeof p['error_code'] === 'string' && p['error_code'] !== '' &&
typeof p['message'] === 'string'
);
}
Loading
Loading