Add remote dashboard bridge
This commit is contained in:
parent
9b417f622f
commit
91316becd0
29 changed files with 1609 additions and 12 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codex-flows",
|
||||
"version": "0.132.6",
|
||||
"version": "0.133.4",
|
||||
"description": "Full Codex turn automation, backend setup, backend operation, and workspace orchestration guidance.",
|
||||
"author": {
|
||||
"name": "Peezy",
|
||||
|
|
@ -38,7 +38,8 @@
|
|||
"Set up the local Codex workspace backend.",
|
||||
"Discover my Codex App remote connections and run codex-flows over SSH.",
|
||||
"Run codex-flows against a remote Codex workspace over SSH.",
|
||||
"Coordinate delegated workspace work."
|
||||
"Coordinate delegated workspace work.",
|
||||
"Build a local Vite dashboard for a remote workspace over SSH."
|
||||
],
|
||||
"brandColor": "#2563EB"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ codex-flows --ssh devbox --cwd /repo automation run openai-codex-bindings --even
|
|||
codex-flows --ssh devbox --cwd /repo fetch
|
||||
codex-flows --ssh devbox --cwd /repo remote preflight
|
||||
codex-flows --ssh devbox --cwd /repo app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
|
||||
codex-flows --ssh devbox --cwd /repo functions list --json
|
||||
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
|
||||
codex-flows --ssh devbox --cwd /repo turn run "Scan current folder" --wait --sandbox danger-full-access --approval-policy never
|
||||
codex-flows workspace doctor
|
||||
codex-flows workspace backend status
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
CodexWorkspaceBackendProtocolServer,
|
||||
type CodexWorkspaceBackendPeer,
|
||||
} from "@peezy.tech/codex-flows/workspace-backend";
|
||||
import { createWorkspaceFunctionMethods } from "@peezy.tech/codex-flows/functions";
|
||||
|
||||
import http from "node:http";
|
||||
import { WebSocketServer, type RawData, type WebSocket } from "ws";
|
||||
|
|
@ -30,6 +31,9 @@ async function main(): Promise<void> {
|
|||
appServer: client,
|
||||
serverName: "codex-workspace-backend-local",
|
||||
serverVersion: "0.1.0",
|
||||
methods: createWorkspaceFunctionMethods({
|
||||
cwd: parsed.cwd,
|
||||
}),
|
||||
});
|
||||
const peers = new WeakMap<WebSocket, CodexWorkspaceBackendPeer>();
|
||||
const server = http.createServer((_request, response) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
|
||||
"@peezy.tech/codex-flows/functions": ["../../packages/codex-client/src/functions.ts"],
|
||||
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@peezy.tech/codex-flow-docs",
|
||||
"version": "0.133.3",
|
||||
"version": "0.133.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ targeting a remote workspace:
|
|||
codex-flows --ssh devbox --cwd /repo remote preflight
|
||||
codex-flows --ssh devbox --cwd /repo app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
|
||||
codex-flows --ssh devbox --cwd /repo workspace delegation.list
|
||||
codex-flows --ssh devbox --cwd /repo functions list --json
|
||||
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
|
||||
codex-flows --ssh devbox --cwd /repo automation run check-release --event event.json
|
||||
codex-flows --ssh devbox --cwd /repo turn run "Scan current folder" --wait --sandbox danger-full-access --approval-policy never
|
||||
```
|
||||
|
|
@ -179,6 +181,23 @@ If an older PowerShell native-command mode strips JSON quotes before argv
|
|||
delivery, `--params-json` also accepts the common stripped shape for simple
|
||||
objects, for example `{limit:3,sourceKinds:[]}`.
|
||||
|
||||
## Workspace Functions
|
||||
|
||||
```bash
|
||||
codex-flows functions list [--json]
|
||||
codex-flows functions describe <name> [--json]
|
||||
codex-flows functions call <name> [--params-json <json>] [--json]
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions list --json
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions describe <name> --json
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions call <name> --params-json '{"sample":true}' --json
|
||||
```
|
||||
|
||||
Workspace functions are JSON-in/JSON-out helpers loaded from
|
||||
`.codex/functions.ts`, `.codex/functions.js`, or `.codex/functions.mjs` in the
|
||||
target workspace. They are intended for local dashboards and operators that need
|
||||
active workspace data without starting a Codex turn or exposing a remote HTTP
|
||||
server. With `--ssh`, calls run through the SSH remote-agent provider.
|
||||
|
||||
## Workspace Backend Calls
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ exports:
|
|||
- turn automation helpers for pre-turn scripts that can skip or start native
|
||||
Codex turns
|
||||
- SSH remote provider helpers for targeting remote workspaces from a local CLI
|
||||
- workspace functions under `@peezy.tech/codex-flows/functions`
|
||||
- Vite bridge plugin under `@peezy.tech/codex-flows/vite`
|
||||
- browser-safe workspace backend client and protocol server primitives
|
||||
- browser-safe WebSocket transport
|
||||
- browser-safe WebSocket transport and dashboard client under
|
||||
`@peezy.tech/codex-flows/browser`
|
||||
- auth helpers for account login/status/usage
|
||||
- Actions-mode workspace helpers under `@peezy.tech/codex-flows/actions`
|
||||
- stable Codex memory artifact helpers under `@peezy.tech/codex-flows/memories`
|
||||
|
|
@ -50,6 +53,51 @@ Actions-mode simulation:
|
|||
These helpers intentionally do not inspect or mutate Codex memory SQLite
|
||||
internals.
|
||||
|
||||
## `@peezy.tech/codex-flows/functions`
|
||||
|
||||
Workspace functions expose named JSON-in/JSON-out capabilities from a workspace
|
||||
manifest at `.codex/functions.ts`, `.codex/functions.js`, or
|
||||
`.codex/functions.mjs`.
|
||||
|
||||
```ts
|
||||
import { defineFunctions } from "@peezy.tech/codex-flows/functions";
|
||||
|
||||
export default defineFunctions({
|
||||
portfolioSnapshot: {
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
handler: async () => ({ positions: [], cash: 0 }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The CLI, workspace backend, SSH remote-agent, Vite plugin, and browser client
|
||||
use the same `functions.list`, `functions.describe`, and `functions.call`
|
||||
workspace methods.
|
||||
|
||||
## `@peezy.tech/codex-flows/vite`
|
||||
|
||||
`codexFlowsRemote` is a local Vite middleware plugin that forwards dashboard
|
||||
requests to a workspace backend or SSH remote-agent without exposing remote HTTP
|
||||
ports.
|
||||
|
||||
```ts
|
||||
import { codexFlowsRemote } from "@peezy.tech/codex-flows/vite";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
codexFlowsRemote({
|
||||
ssh: process.env.CODEX_FLOWS_REMOTE_SSH_TARGET,
|
||||
cwd: process.env.CODEX_FLOWS_REMOTE_CWD,
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Dashboard code can use `codexFlows` from
|
||||
`@peezy.tech/codex-flows/browser` to list, describe, and call workspace
|
||||
functions through the local Vite bridge.
|
||||
|
||||
## `@peezy.tech/codex-flows/memories`
|
||||
|
||||
The memory transplant helpers operate on stable markdown artifacts only.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codex-flows-monorepo",
|
||||
"version": "0.133.3",
|
||||
"version": "0.133.4",
|
||||
"description": "Codex app-server clients, turn automation, workspace backend tools, and repo-native workspace operations.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ Full documentation lives in the repo docs site:
|
|||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `@peezy.tech/codex-flows` | Node app-server client, turn automation helpers, SSH provider helpers, event emitter base, stdio/WebSocket transports, JSON-RPC helpers, auth helpers. |
|
||||
| `@peezy.tech/codex-flows/browser` | Browser-safe app-server client and WebSocket transport. |
|
||||
| `@peezy.tech/codex-flows/browser` | Browser-safe app-server client, WebSocket transport, and local dashboard bridge client. |
|
||||
| `@peezy.tech/codex-flows/functions` | Workspace function definitions and backend method helpers. |
|
||||
| `@peezy.tech/codex-flows/vite` | Vite middleware plugin for local dashboards that call remote workspace functions. |
|
||||
| `@peezy.tech/codex-flows/auth` | Privacy-preserving Codex account login, status, and usage helpers. |
|
||||
| `@peezy.tech/codex-flows/workbench` | Transport-neutral thread UX reducers and app-server request descriptors. |
|
||||
| `@peezy.tech/codex-flows/threads` | Raw Codex rollout locate, inspect, install, and transplant helpers. |
|
||||
|
|
@ -147,6 +149,8 @@ codex-flows automation run openai-codex-bindings --event event.json
|
|||
codex-flows --ssh devbox --cwd /repo automation run openai-codex-bindings --event event.json
|
||||
codex-flows --ssh devbox --cwd /repo fetch
|
||||
codex-flows --ssh devbox --cwd /repo app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
|
||||
codex-flows --ssh devbox --cwd /repo functions list --json
|
||||
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
|
||||
codex-flows --ssh devbox --cwd /repo turn run "Scan current folder" --wait --sandbox danger-full-access --approval-policy never
|
||||
codex-flows app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
|
||||
codex-flows workspace app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@peezy.tech/codex-flows",
|
||||
"version": "0.133.3",
|
||||
"version": "0.133.4",
|
||||
"description": "Codex app-server clients, turn automation, workspace backend helpers, and runnable local backend bins.",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
|
|
@ -40,6 +40,16 @@
|
|||
"require": "./dist/browser.js",
|
||||
"import": "./dist/browser.js"
|
||||
},
|
||||
"./functions": {
|
||||
"types": "./dist/functions.d.ts",
|
||||
"require": "./dist/functions.js",
|
||||
"import": "./dist/functions.js"
|
||||
},
|
||||
"./vite": {
|
||||
"types": "./dist/vite.d.ts",
|
||||
"require": "./dist/vite.js",
|
||||
"import": "./dist/vite.js"
|
||||
},
|
||||
"./auth": {
|
||||
"types": "./dist/auth.d.ts",
|
||||
"require": "./dist/auth.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const checks = [
|
||||
["@peezy.tech/codex-flows", ["CodexAppServerClient", "CodexEventEmitter"]],
|
||||
["@peezy.tech/codex-flows/browser", ["CodexAppServerClient"]],
|
||||
["@peezy.tech/codex-flows/browser", ["CodexAppServerClient", "codexFlows"]],
|
||||
["@peezy.tech/codex-flows/functions", ["defineFunctions", "createWorkspaceFunctionMethods"]],
|
||||
["@peezy.tech/codex-flows/vite", ["codexFlowsRemote"]],
|
||||
["@peezy.tech/codex-flows/auth", ["CodexAuthClient", "createCodexAuthClient"]],
|
||||
["@peezy.tech/codex-flows/actions", ["repoCodexHome", "prepareActionsCodexAuth"]],
|
||||
["@peezy.tech/codex-flows/memories", ["listCodexMemoryArtifacts"]],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
import type {
|
||||
WorkspaceFunctionMetadata,
|
||||
WorkspaceFunctionsCallResponse,
|
||||
WorkspaceFunctionsDescribeResponse,
|
||||
WorkspaceFunctionsListResponse,
|
||||
} from "./functions.ts";
|
||||
|
||||
export {
|
||||
CodexBrowserAppServerClient as CodexAppServerClient,
|
||||
type CodexBrowserAppServerClientOptions as CodexAppServerClientOptions,
|
||||
|
|
@ -29,6 +36,12 @@ export type {
|
|||
JsonRpcResponse,
|
||||
} from "./app-server/rpc.ts";
|
||||
export type { v2 } from "./app-server/generated/index.ts";
|
||||
export type {
|
||||
WorkspaceFunctionMetadata,
|
||||
WorkspaceFunctionsCallResponse,
|
||||
WorkspaceFunctionsDescribeResponse,
|
||||
WorkspaceFunctionsListResponse,
|
||||
} from "./functions.ts";
|
||||
export {
|
||||
CodexAuthClient,
|
||||
CodexAuthTimeoutError,
|
||||
|
|
@ -51,3 +64,74 @@ export type {
|
|||
CodexUsageWindow,
|
||||
WaitForLoginOptions,
|
||||
} from "./app-server/auth.ts";
|
||||
|
||||
export type CodexFlowsBrowserClientOptions = {
|
||||
basePath?: string;
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
|
||||
export type CodexFlowsBrowserFunctionsClient = {
|
||||
list(): Promise<WorkspaceFunctionMetadata[]>;
|
||||
describe(name: string): Promise<WorkspaceFunctionMetadata>;
|
||||
call<T = unknown>(name: string, params?: unknown): Promise<T>;
|
||||
};
|
||||
|
||||
export type CodexFlowsBrowserClient = {
|
||||
functions: CodexFlowsBrowserFunctionsClient;
|
||||
status(): Promise<unknown>;
|
||||
};
|
||||
|
||||
export function createCodexFlowsBrowserClient(
|
||||
options: CodexFlowsBrowserClientOptions = {},
|
||||
): CodexFlowsBrowserClient {
|
||||
const basePath = (options.basePath ?? "/__codex_flows").replace(/\/$/, "");
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
return {
|
||||
status: async () => await requestJson(fetchImpl, `${basePath}/status`),
|
||||
functions: {
|
||||
list: async () => {
|
||||
const response = await requestJson<WorkspaceFunctionsListResponse>(
|
||||
fetchImpl,
|
||||
`${basePath}/functions`,
|
||||
);
|
||||
return response.functions;
|
||||
},
|
||||
describe: async (name) => {
|
||||
const response = await requestJson<WorkspaceFunctionsDescribeResponse>(
|
||||
fetchImpl,
|
||||
`${basePath}/functions/${encodeURIComponent(name)}`,
|
||||
);
|
||||
return response.function;
|
||||
},
|
||||
call: async <T = unknown>(name: string, params?: unknown) => {
|
||||
const response = await requestJson<WorkspaceFunctionsCallResponse>(
|
||||
fetchImpl,
|
||||
`${basePath}/functions/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ params }),
|
||||
},
|
||||
);
|
||||
return response.result as T;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const codexFlows = createCodexFlowsBrowserClient();
|
||||
|
||||
async function requestJson<T = unknown>(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetchImpl(url, init);
|
||||
const text = await response.text();
|
||||
const parsed = text ? JSON.parse(text) as unknown : undefined;
|
||||
if (!response.ok) {
|
||||
const input = parsed && typeof parsed === "object" ? parsed as { error?: unknown } : {};
|
||||
throw new Error(typeof input.error === "string" ? input.error : `Codex Flows request failed: ${response.status}`);
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ export const COMMON_WORKSPACE_BACKEND_METHODS = [
|
|||
"delegation.setPolicy",
|
||||
"delegation.flushResults",
|
||||
"delegation.listGroups",
|
||||
"functions.list",
|
||||
"functions.describe",
|
||||
"functions.call",
|
||||
] as const;
|
||||
|
||||
export function validateMethodName(value: string, label: string): string {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,31 @@ type ParsedCliBase =
|
|||
timeoutMs: number;
|
||||
pretty: boolean;
|
||||
}
|
||||
| {
|
||||
type: "functions-list";
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
json: boolean;
|
||||
pretty: boolean;
|
||||
}
|
||||
| {
|
||||
type: "functions-describe";
|
||||
name: string;
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
json: boolean;
|
||||
pretty: boolean;
|
||||
}
|
||||
| {
|
||||
type: "functions-call";
|
||||
name: string;
|
||||
paramsText?: string;
|
||||
paramsFile?: string;
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
json: boolean;
|
||||
pretty: boolean;
|
||||
}
|
||||
| { type: "workspace-methods"; url: string; timeoutMs: number; pretty: boolean }
|
||||
| {
|
||||
type: "workspace-doctor";
|
||||
|
|
@ -909,6 +934,43 @@ export function parseArgs(
|
|||
...remoteFields(),
|
||||
};
|
||||
}
|
||||
if (command === "functions" || command === "function") {
|
||||
const subcommand = positionals[1];
|
||||
if (subcommand === "list" || subcommand === "ls") {
|
||||
return {
|
||||
type: "functions-list",
|
||||
url: workspaceUrl,
|
||||
timeoutMs,
|
||||
json,
|
||||
pretty,
|
||||
...remoteFields(),
|
||||
};
|
||||
}
|
||||
if (subcommand === "describe" || subcommand === "show") {
|
||||
return {
|
||||
type: "functions-describe",
|
||||
name: requiredPositional(positionals, 2, "functions describe requires <name>"),
|
||||
url: workspaceUrl,
|
||||
timeoutMs,
|
||||
json,
|
||||
pretty,
|
||||
...remoteFields(),
|
||||
};
|
||||
}
|
||||
if (subcommand === "call" || subcommand === "run") {
|
||||
return {
|
||||
type: "functions-call",
|
||||
name: requiredPositional(positionals, 2, "functions call requires <name>"),
|
||||
...paramsSource(positionals.slice(3), paramsJson, paramsFile),
|
||||
url: workspaceUrl,
|
||||
timeoutMs,
|
||||
json,
|
||||
pretty,
|
||||
...remoteFields(),
|
||||
};
|
||||
}
|
||||
throw new Error("functions requires list, describe, or call");
|
||||
}
|
||||
if (command === "workspace") {
|
||||
const subcommand = positionals[1];
|
||||
if (!subcommand || subcommand === "methods") {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,15 @@ import {
|
|||
type RemoteAutomationListResponse,
|
||||
type RemoteAutomationRunParams,
|
||||
} from "./remote-automation.ts";
|
||||
import {
|
||||
WORKSPACE_FUNCTIONS_CALL_METHOD,
|
||||
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
|
||||
WORKSPACE_FUNCTIONS_LIST_METHOD,
|
||||
type WorkspaceFunctionMetadata,
|
||||
type WorkspaceFunctionsCallResponse,
|
||||
type WorkspaceFunctionsDescribeResponse,
|
||||
type WorkspaceFunctionsListResponse,
|
||||
} from "../functions.ts";
|
||||
import { serveRemoteAgent } from "./remote-agent.ts";
|
||||
import type { CodexWorkspaceBackendTransport } from "../workspace-backend/client.ts";
|
||||
import {
|
||||
|
|
@ -259,6 +268,42 @@ async function main(): Promise<void> {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "functions-list") {
|
||||
const response = await callWorkspaceBackend(
|
||||
WORKSPACE_FUNCTIONS_LIST_METHOD,
|
||||
{},
|
||||
parsed,
|
||||
) as WorkspaceFunctionsListResponse;
|
||||
write(parsed.json
|
||||
? `${JSON.stringify(response, null, parsed.pretty ? 2 : 0)}\n`
|
||||
: formatFunctionsList(response.functions));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "functions-describe") {
|
||||
const response = await callWorkspaceBackend(
|
||||
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
|
||||
{ name: parsed.name },
|
||||
parsed,
|
||||
) as WorkspaceFunctionsDescribeResponse;
|
||||
write(parsed.json
|
||||
? `${JSON.stringify(response, null, parsed.pretty ? 2 : 0)}\n`
|
||||
: formatFunctionDescription(response.function));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "functions-call") {
|
||||
const response = await callWorkspaceBackend(
|
||||
WORKSPACE_FUNCTIONS_CALL_METHOD,
|
||||
{
|
||||
name: parsed.name,
|
||||
params: await readParams(parsed.paramsText, parsed.paramsFile),
|
||||
},
|
||||
parsed,
|
||||
) as WorkspaceFunctionsCallResponse;
|
||||
write(parsed.json
|
||||
? `${JSON.stringify(response, null, parsed.pretty ? 2 : 0)}\n`
|
||||
: `${JSON.stringify(response.result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "turn-run") {
|
||||
const result = await startRemoteTurn({
|
||||
prompt: parsed.prompt,
|
||||
|
|
@ -690,6 +735,20 @@ function validateAutomationTurnOptions(options: {
|
|||
}
|
||||
}
|
||||
|
||||
function formatFunctionsList(functions: WorkspaceFunctionMetadata[]): string {
|
||||
if (functions.length === 0) {
|
||||
return "No workspace functions found.\n";
|
||||
}
|
||||
return `${functions.map((fn) => {
|
||||
const suffix = fn.description ? ` - ${fn.description}` : "";
|
||||
return `${fn.name} [${fn.sideEffects}]${suffix}`;
|
||||
}).join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatFunctionDescription(fn: WorkspaceFunctionMetadata): string {
|
||||
return `${JSON.stringify(fn, null, 2)}\n`;
|
||||
}
|
||||
|
||||
async function runTurnAutomationForCli(
|
||||
target: TurnAutomationRunTarget,
|
||||
options: {
|
||||
|
|
@ -1401,6 +1460,11 @@ Usage:
|
|||
echo '<params-json>' | codex-flows app <method>
|
||||
codex-flows app actions
|
||||
|
||||
codex-flows functions list [--json]
|
||||
codex-flows functions describe <name> [--json]
|
||||
codex-flows functions call <name> [--params-json <json>] [--json]
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions list [--json]
|
||||
|
||||
codex-flows workspace <method> [params-json]
|
||||
codex-flows workspace <method> --params-json <json>
|
||||
codex-flows workspace <method> --params-file <file>
|
||||
|
|
@ -1507,6 +1571,8 @@ Examples:
|
|||
codex-flows automation run check-release --event event.json
|
||||
codex-flows --ssh devbox --cwd /repo automation list --json
|
||||
codex-flows --ssh devbox --cwd /repo automation run check-release --event event.json
|
||||
codex-flows --ssh devbox --cwd /repo functions list --json
|
||||
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
|
||||
codex-flows --ssh devbox --cwd /repo app thread/list '{"limit":20,"sourceKinds":[]}'
|
||||
codex-flows --ssh devbox --cwd /repo workspace delegation.list
|
||||
codex-flows app thread/list '{"limit":20,"sourceKinds":[]}'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type CodexWorkspaceBackendPeer,
|
||||
type WorkspaceBackendMethodHandler,
|
||||
} from "../workspace-backend/server.ts";
|
||||
import { createWorkspaceFunctionMethods } from "../functions.ts";
|
||||
import { createRemoteAutomationMethods } from "./remote-automation.ts";
|
||||
|
||||
export type RemoteAgentServeOptions = {
|
||||
|
|
@ -62,6 +63,9 @@ export async function serveRemoteAgent(
|
|||
codexCommand: options.remoteCodexCommand ?? "codex",
|
||||
codexArgs: options.remoteCodexArgs ?? [],
|
||||
});
|
||||
Object.assign(methods, createWorkspaceFunctionMethods({
|
||||
cwd: options.cwd,
|
||||
}));
|
||||
Object.assign(methods, createRemoteAutomationMethods({
|
||||
cwd: options.cwd,
|
||||
timeoutMs: options.timeoutMs,
|
||||
|
|
|
|||
334
packages/codex-client/src/functions.ts
Normal file
334
packages/codex-client/src/functions.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { access, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { tsImport } from "tsx/esm/api";
|
||||
import type { WorkspaceBackendMethodHandler } from "./workspace-backend/server.ts";
|
||||
|
||||
export const WORKSPACE_FUNCTIONS_LIST_METHOD = "functions.list";
|
||||
export const WORKSPACE_FUNCTIONS_DESCRIBE_METHOD = "functions.describe";
|
||||
export const WORKSPACE_FUNCTIONS_CALL_METHOD = "functions.call";
|
||||
|
||||
export type WorkspaceFunctionSideEffects =
|
||||
| "none"
|
||||
| "read-only"
|
||||
| "writes-local"
|
||||
| "external-write";
|
||||
|
||||
export type WorkspaceFunctionMetadata = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema?: unknown;
|
||||
outputSchema?: unknown;
|
||||
examples?: unknown;
|
||||
tags?: string[];
|
||||
sideEffects: WorkspaceFunctionSideEffects;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionContext = {
|
||||
cwd: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionHandler = (
|
||||
params: unknown,
|
||||
context: WorkspaceFunctionContext,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
export type WorkspaceFunctionDefinition =
|
||||
| WorkspaceFunctionHandler
|
||||
| {
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
outputSchema?: unknown;
|
||||
examples?: unknown;
|
||||
tags?: string[];
|
||||
sideEffects?: WorkspaceFunctionSideEffects;
|
||||
timeoutMs?: number;
|
||||
handler: WorkspaceFunctionHandler;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionDefinitions = Record<string, WorkspaceFunctionDefinition>;
|
||||
|
||||
export type WorkspaceFunctionsModule =
|
||||
| WorkspaceFunctionDefinitions
|
||||
| {
|
||||
default?: WorkspaceFunctionDefinitions;
|
||||
functions?: WorkspaceFunctionDefinitions;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionsListResponse = {
|
||||
functions: WorkspaceFunctionMetadata[];
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionsDescribeParams = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionsDescribeResponse = {
|
||||
function: WorkspaceFunctionMetadata;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionsCallParams = {
|
||||
name: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionsCallResponse = {
|
||||
result: unknown;
|
||||
};
|
||||
|
||||
export type WorkspaceFunctionRuntimeOptions = {
|
||||
cwd?: string;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export function defineFunctions(
|
||||
functions: WorkspaceFunctionDefinitions,
|
||||
): WorkspaceFunctionDefinitions {
|
||||
return functions;
|
||||
}
|
||||
|
||||
export function createWorkspaceFunctionMethods(
|
||||
options: WorkspaceFunctionRuntimeOptions = {},
|
||||
): Record<string, WorkspaceBackendMethodHandler> {
|
||||
const runtime = new WorkspaceFunctionRuntime(options);
|
||||
return {
|
||||
[WORKSPACE_FUNCTIONS_LIST_METHOD]: async () => await runtime.list(),
|
||||
[WORKSPACE_FUNCTIONS_DESCRIBE_METHOD]: async (params) =>
|
||||
await runtime.describe(describeParams(params).name),
|
||||
[WORKSPACE_FUNCTIONS_CALL_METHOD]: async (params) => {
|
||||
const call = callParams(params);
|
||||
return await runtime.call(call.name, call.params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkspaceFunctionRuntime {
|
||||
#cwd: string;
|
||||
#now: () => number;
|
||||
|
||||
constructor(options: WorkspaceFunctionRuntimeOptions = {}) {
|
||||
this.#cwd = path.resolve(options.cwd ?? process.cwd());
|
||||
this.#now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async list(): Promise<WorkspaceFunctionsListResponse> {
|
||||
const loaded = await this.#load();
|
||||
return {
|
||||
functions: [...loaded.entries()]
|
||||
.map(([name, definition]) => metadataFor(name, definition))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
};
|
||||
}
|
||||
|
||||
async describe(name: string): Promise<WorkspaceFunctionsDescribeResponse> {
|
||||
const definition = (await this.#load()).get(name);
|
||||
if (!definition) {
|
||||
throw new Error(`Workspace function not found: ${name}`);
|
||||
}
|
||||
return { function: metadataFor(name, definition) };
|
||||
}
|
||||
|
||||
async call(name: string, params?: unknown): Promise<WorkspaceFunctionsCallResponse> {
|
||||
const definition = (await this.#load()).get(name);
|
||||
if (!definition) {
|
||||
throw new Error(`Workspace function not found: ${name}`);
|
||||
}
|
||||
const result = await handlerFor(definition)(params, { cwd: this.#cwd, name });
|
||||
return { result: jsonRoundTrip(result, `Workspace function returned non-JSON data: ${name}`) };
|
||||
}
|
||||
|
||||
async #load(): Promise<Map<string, WorkspaceFunctionDefinition>> {
|
||||
const manifestPath = await findFunctionsManifest(this.#cwd);
|
||||
if (!manifestPath) {
|
||||
return new Map();
|
||||
}
|
||||
const module = await importFunctionsModule(manifestPath, this.#now());
|
||||
const definitions = definitionsFromModule(module);
|
||||
return new Map(Object.entries(definitions).map(([name, definition]) => {
|
||||
if (!isFunctionName(name)) {
|
||||
throw new Error(`Invalid workspace function name: ${name}`);
|
||||
}
|
||||
if (!isWorkspaceFunctionDefinition(definition)) {
|
||||
throw new Error(`Invalid workspace function definition: ${name}`);
|
||||
}
|
||||
return [name, definition];
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function findFunctionsManifest(cwd: string): Promise<string | undefined> {
|
||||
const root = path.resolve(cwd);
|
||||
for (const name of ["functions.ts", "functions.js", "functions.mjs"]) {
|
||||
const candidate = path.join(root, ".codex", name);
|
||||
if (await exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function importFunctionsModule(
|
||||
manifestPath: string,
|
||||
cacheBust: number,
|
||||
): Promise<WorkspaceFunctionsModule> {
|
||||
const url = pathToFileURL(manifestPath).href;
|
||||
const version = encodeURIComponent(String((await stat(manifestPath)).mtimeMs || cacheBust));
|
||||
if (manifestPath.endsWith(".ts")) {
|
||||
return await tsImport(`${url}?v=${version}`, import.meta.url) as WorkspaceFunctionsModule;
|
||||
}
|
||||
return await import(`${url}?v=${version}`) as WorkspaceFunctionsModule;
|
||||
}
|
||||
|
||||
function definitionsFromModule(module: WorkspaceFunctionsModule): WorkspaceFunctionDefinitions {
|
||||
const definitions = resolveDefinitions(module);
|
||||
if (definitions) {
|
||||
return definitions;
|
||||
}
|
||||
throw new Error("Workspace functions manifest must export default functions or named functions");
|
||||
}
|
||||
|
||||
function resolveDefinitions(
|
||||
value: unknown,
|
||||
depth = 0,
|
||||
): WorkspaceFunctionDefinitions | undefined {
|
||||
if (depth > 5) {
|
||||
return undefined;
|
||||
}
|
||||
if (isWorkspaceFunctionDefinitions(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const input = value as {
|
||||
default?: unknown;
|
||||
functions?: unknown;
|
||||
"module.exports"?: unknown;
|
||||
};
|
||||
return resolveDefinitions(input.default, depth + 1) ??
|
||||
resolveDefinitions(input.functions, depth + 1) ??
|
||||
resolveDefinitions(input["module.exports"], depth + 1);
|
||||
}
|
||||
|
||||
function isWorkspaceFunctionDefinitions(
|
||||
value: unknown,
|
||||
): value is WorkspaceFunctionDefinitions {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(isWorkspaceFunctionDefinition),
|
||||
);
|
||||
}
|
||||
|
||||
function metadataFor(
|
||||
name: string,
|
||||
definition: WorkspaceFunctionDefinition,
|
||||
): WorkspaceFunctionMetadata {
|
||||
if (typeof definition === "function") {
|
||||
return {
|
||||
name,
|
||||
description: "",
|
||||
sideEffects: "read-only",
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description: definition.description ?? "",
|
||||
...(definition.inputSchema !== undefined ? { inputSchema: definition.inputSchema } : {}),
|
||||
...(definition.outputSchema !== undefined ? { outputSchema: definition.outputSchema } : {}),
|
||||
...(definition.examples !== undefined ? { examples: definition.examples } : {}),
|
||||
...(definition.tags !== undefined ? { tags: definition.tags } : {}),
|
||||
sideEffects: definition.sideEffects ?? "read-only",
|
||||
...(definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function handlerFor(definition: WorkspaceFunctionDefinition): WorkspaceFunctionHandler {
|
||||
return typeof definition === "function" ? definition : definition.handler;
|
||||
}
|
||||
|
||||
function isWorkspaceFunctionDefinition(
|
||||
value: unknown,
|
||||
): value is WorkspaceFunctionDefinition {
|
||||
if (typeof value === "function") {
|
||||
return true;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const input = value as { handler?: unknown; sideEffects?: unknown; tags?: unknown };
|
||||
return typeof input.handler === "function" &&
|
||||
(input.sideEffects === undefined || isSideEffects(input.sideEffects)) &&
|
||||
(input.tags === undefined ||
|
||||
(Array.isArray(input.tags) && input.tags.every((tag) => typeof tag === "string")));
|
||||
}
|
||||
|
||||
function isSideEffects(value: unknown): value is WorkspaceFunctionSideEffects {
|
||||
return value === "none" ||
|
||||
value === "read-only" ||
|
||||
value === "writes-local" ||
|
||||
value === "external-write";
|
||||
}
|
||||
|
||||
function isFunctionName(value: string): boolean {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$.-]*$/.test(value);
|
||||
}
|
||||
|
||||
function describeParams(value: unknown): WorkspaceFunctionsDescribeParams {
|
||||
const input = record(value);
|
||||
const name = stringValue(input.name);
|
||||
if (!name) {
|
||||
throw new Error("functions.describe requires name");
|
||||
}
|
||||
return { name };
|
||||
}
|
||||
|
||||
function callParams(value: unknown): WorkspaceFunctionsCallParams {
|
||||
const input = record(value);
|
||||
const name = stringValue(input.name);
|
||||
if (!name) {
|
||||
throw new Error("functions.call requires name");
|
||||
}
|
||||
return { name, params: input.params };
|
||||
}
|
||||
|
||||
function jsonRoundTrip(value: unknown, message: string): unknown {
|
||||
if (value === undefined) {
|
||||
throw new Error(message);
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
if (text === undefined) {
|
||||
throw new Error("JSON.stringify returned undefined");
|
||||
}
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`${message}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -27,6 +27,27 @@ export {
|
|||
CodexWebSocketTransport,
|
||||
type CodexWebSocketTransportOptions,
|
||||
} from "./app-server/websocket-transport.ts";
|
||||
export {
|
||||
WORKSPACE_FUNCTIONS_CALL_METHOD,
|
||||
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
|
||||
WORKSPACE_FUNCTIONS_LIST_METHOD,
|
||||
WorkspaceFunctionRuntime,
|
||||
createWorkspaceFunctionMethods,
|
||||
defineFunctions,
|
||||
findFunctionsManifest,
|
||||
type WorkspaceFunctionContext,
|
||||
type WorkspaceFunctionDefinition,
|
||||
type WorkspaceFunctionDefinitions,
|
||||
type WorkspaceFunctionHandler,
|
||||
type WorkspaceFunctionMetadata,
|
||||
type WorkspaceFunctionRuntimeOptions,
|
||||
type WorkspaceFunctionSideEffects,
|
||||
type WorkspaceFunctionsCallParams,
|
||||
type WorkspaceFunctionsCallResponse,
|
||||
type WorkspaceFunctionsDescribeParams,
|
||||
type WorkspaceFunctionsDescribeResponse,
|
||||
type WorkspaceFunctionsListResponse,
|
||||
} from "./functions.ts";
|
||||
export {
|
||||
createSshRemoteAgentPlan,
|
||||
createSshRemoteAgentTransport,
|
||||
|
|
|
|||
219
packages/codex-client/src/vite.ts
Normal file
219
packages/codex-client/src/vite.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
import { CodexWebSocketTransport } from "./app-server/websocket-transport.ts";
|
||||
import type { CodexWorkspaceBackendTransport } from "./workspace-backend/client.ts";
|
||||
import {
|
||||
WORKSPACE_BACKEND_INITIALIZE_METHOD,
|
||||
type WorkspaceBackendInitializeResponse,
|
||||
} from "./workspace-backend/index.ts";
|
||||
import {
|
||||
WORKSPACE_FUNCTIONS_CALL_METHOD,
|
||||
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
|
||||
WORKSPACE_FUNCTIONS_LIST_METHOD,
|
||||
type WorkspaceFunctionsCallResponse,
|
||||
type WorkspaceFunctionsDescribeResponse,
|
||||
type WorkspaceFunctionsListResponse,
|
||||
} from "./functions.ts";
|
||||
import {
|
||||
createSshRemoteAgentTransport,
|
||||
hasSshRemote,
|
||||
type SshRemoteProviderOptions,
|
||||
} from "./cli/remote-provider.ts";
|
||||
|
||||
export type CodexFlowsRemoteVitePluginOptions = Partial<SshRemoteProviderOptions> & {
|
||||
ssh?: string;
|
||||
sshTarget?: string;
|
||||
workspaceUrl?: string;
|
||||
basePath?: string;
|
||||
transport?: CodexWorkspaceBackendTransport;
|
||||
};
|
||||
|
||||
type WorkspaceRequester = {
|
||||
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export function codexFlowsRemote(
|
||||
options: CodexFlowsRemoteVitePluginOptions = {},
|
||||
): Plugin {
|
||||
const basePath = normalizeBasePath(options.basePath ?? "/__codex_flows");
|
||||
let requester: WorkspaceRequester | undefined;
|
||||
const getRequester = (): WorkspaceRequester => {
|
||||
if (requester) {
|
||||
return requester;
|
||||
}
|
||||
requester = createWorkspaceRequester(options);
|
||||
return requester;
|
||||
};
|
||||
return {
|
||||
name: "codex-flows-remote",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const url = new URL(request.url, "http://codex-flows.local");
|
||||
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handleCodexFlowsRequest(
|
||||
getRequester(),
|
||||
basePath,
|
||||
request,
|
||||
response,
|
||||
);
|
||||
} catch (error) {
|
||||
writeJson(response, 500, { error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
server.httpServer?.once("close", () => {
|
||||
requester?.close();
|
||||
requester = undefined;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceRequester(
|
||||
options: CodexFlowsRemoteVitePluginOptions,
|
||||
): WorkspaceRequester {
|
||||
const timeoutMs = options.timeoutMs ?? 90_000;
|
||||
const transport = options.transport ?? (hasSshRemote({
|
||||
sshTarget: options.sshTarget ?? options.ssh,
|
||||
env: options.env,
|
||||
})
|
||||
? createSshRemoteAgentTransport({
|
||||
...options,
|
||||
sshTarget: options.sshTarget ?? options.ssh,
|
||||
timeoutMs,
|
||||
})
|
||||
: new CodexWebSocketTransport({
|
||||
url: options.workspaceUrl ?? "ws://127.0.0.1:3586",
|
||||
requestTimeoutMs: timeoutMs,
|
||||
}));
|
||||
let initialized: Promise<WorkspaceBackendInitializeResponse> | undefined;
|
||||
const initialize = async () => {
|
||||
transport.start();
|
||||
initialized ??= transport.request<WorkspaceBackendInitializeResponse>(
|
||||
WORKSPACE_BACKEND_INITIALIZE_METHOD,
|
||||
{
|
||||
clientInfo: {
|
||||
name: "codex-flows-vite",
|
||||
title: "Codex Flows Vite Plugin",
|
||||
version: "0.1.0",
|
||||
},
|
||||
capabilities: {
|
||||
appServerPassThrough: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
await initialized;
|
||||
};
|
||||
return {
|
||||
request: async (method, params) => {
|
||||
await initialize();
|
||||
return await transport.request(method, params);
|
||||
},
|
||||
close: () => {
|
||||
transport.close();
|
||||
initialized = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCodexFlowsRequest(
|
||||
requester: WorkspaceRequester,
|
||||
basePath: string,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
): Promise<void> {
|
||||
const url = new URL(request.url ?? "/", "http://codex-flows.local");
|
||||
const path = url.pathname.slice(basePath.length) || "/";
|
||||
if (request.method === "GET" && path === "/status") {
|
||||
let remoteAgent: unknown = null;
|
||||
try {
|
||||
remoteAgent = await requester.request("remoteAgent/status", {});
|
||||
} catch {
|
||||
remoteAgent = null;
|
||||
}
|
||||
writeJson(response, 200, { ok: true, remoteAgent });
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/functions") {
|
||||
writeJson(
|
||||
response,
|
||||
200,
|
||||
await requester.request<WorkspaceFunctionsListResponse>(
|
||||
WORKSPACE_FUNCTIONS_LIST_METHOD,
|
||||
{},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const match = path.match(/^\/functions\/([^/]+)$/);
|
||||
if (match?.[1] && request.method === "GET") {
|
||||
writeJson(
|
||||
response,
|
||||
200,
|
||||
await requester.request<WorkspaceFunctionsDescribeResponse>(
|
||||
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
|
||||
{ name: decodeURIComponent(match[1]) },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (match?.[1] && request.method === "POST") {
|
||||
const body = await readJsonBody(request);
|
||||
writeJson(
|
||||
response,
|
||||
200,
|
||||
await requester.request<WorkspaceFunctionsCallResponse>(
|
||||
WORKSPACE_FUNCTIONS_CALL_METHOD,
|
||||
{
|
||||
name: decodeURIComponent(match[1]),
|
||||
params: record(body).params,
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeJson(response, 404, { error: "Unknown Codex Flows endpoint" });
|
||||
}
|
||||
|
||||
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
||||
let body = "";
|
||||
for await (const chunk of request) {
|
||||
body += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
if (body.length > 1_000_000) {
|
||||
throw new Error("Request body is too large");
|
||||
}
|
||||
}
|
||||
if (!body.trim()) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(body) as unknown;
|
||||
}
|
||||
|
||||
function writeJson(response: ServerResponse, status: number, value: unknown): void {
|
||||
response.statusCode = status;
|
||||
response.setHeader("content-type", "application/json; charset=utf-8");
|
||||
response.end(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function normalizeBasePath(value: string): string {
|
||||
const path = value.startsWith("/") ? value : `/${value}`;
|
||||
return path.replace(/\/+$/, "") || "/__codex_flows";
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -1,3 +1,15 @@
|
|||
export {
|
||||
WORKSPACE_FUNCTIONS_CALL_METHOD,
|
||||
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
|
||||
WORKSPACE_FUNCTIONS_LIST_METHOD,
|
||||
createWorkspaceFunctionMethods,
|
||||
type WorkspaceFunctionMetadata,
|
||||
type WorkspaceFunctionsCallParams,
|
||||
type WorkspaceFunctionsCallResponse,
|
||||
type WorkspaceFunctionsDescribeParams,
|
||||
type WorkspaceFunctionsDescribeResponse,
|
||||
type WorkspaceFunctionsListResponse,
|
||||
} from "../functions.ts";
|
||||
export {
|
||||
CodexWorkspaceBackendClient,
|
||||
type CodexWorkspaceBackendClientOptions,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export type WorkspaceBackendEventParams = {
|
|||
|
||||
export const workspaceBackendOwnedMethodPrefixes = [
|
||||
"delegation.",
|
||||
"functions.",
|
||||
"workbench.",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
52
packages/codex-client/test/browser.test.ts
Normal file
52
packages/codex-client/test/browser.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { createCodexFlowsBrowserClient } from "../src/browser.ts";
|
||||
|
||||
describe("Codex Flows browser client", () => {
|
||||
test("calls function endpoints and unwraps responses", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const client = createCodexFlowsBrowserClient({
|
||||
basePath: "/bridge",
|
||||
fetch: (async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url) === "/bridge/functions") {
|
||||
return jsonResponse({ functions: [{ name: "snapshot", description: "", sideEffects: "read-only" }] });
|
||||
}
|
||||
if (String(url) === "/bridge/functions/snapshot" && !init) {
|
||||
return jsonResponse({ function: { name: "snapshot", description: "", sideEffects: "read-only" } });
|
||||
}
|
||||
if (String(url) === "/bridge/functions/snapshot" && init?.method === "POST") {
|
||||
return jsonResponse({ result: { ok: true } });
|
||||
}
|
||||
return jsonResponse({ error: "not found" }, 404);
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(client.functions.list()).resolves.toEqual([
|
||||
{ name: "snapshot", description: "", sideEffects: "read-only" },
|
||||
]);
|
||||
await expect(client.functions.describe("snapshot")).resolves.toEqual({
|
||||
name: "snapshot",
|
||||
description: "",
|
||||
sideEffects: "read-only",
|
||||
});
|
||||
await expect(client.functions.call("snapshot", { include: "cash" })).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(calls.at(-1)?.init?.body).toBe("{\"params\":{\"include\":\"cash\"}}");
|
||||
});
|
||||
|
||||
test("propagates endpoint errors", async () => {
|
||||
const client = createCodexFlowsBrowserClient({
|
||||
fetch: (async () => jsonResponse({ error: "boom" }, 500)) as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(client.functions.list()).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -322,6 +322,93 @@ describe("codex-flows CLI args", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("parses workspace function commands", () => {
|
||||
expect(parseArgs(["functions", "list", "--json"], {})).toMatchObject({
|
||||
type: "functions-list",
|
||||
json: true,
|
||||
});
|
||||
expect(parseArgs(["functions", "describe", "portfolioSnapshot", "--json"], {}))
|
||||
.toMatchObject({
|
||||
type: "functions-describe",
|
||||
name: "portfolioSnapshot",
|
||||
json: true,
|
||||
});
|
||||
expect(parseArgs([
|
||||
"--ssh",
|
||||
"devbox",
|
||||
"--cwd",
|
||||
"/repo",
|
||||
"functions",
|
||||
"call",
|
||||
"portfolioSnapshot",
|
||||
"--params-json",
|
||||
"{\"account\":\"demo\"}",
|
||||
"--json",
|
||||
], {})).toMatchObject({
|
||||
type: "functions-call",
|
||||
name: "portfolioSnapshot",
|
||||
paramsText: "{\"account\":\"demo\"}",
|
||||
sshTarget: "devbox",
|
||||
cwd: "/repo",
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("executes SSH workspace function commands", async () => {
|
||||
const fakeSsh = await createFunctionFakeSshCommand();
|
||||
const env = { CODEX_FLOWS_SSH_COMMAND: fakeSsh };
|
||||
|
||||
const list = await runCli([
|
||||
"--ssh",
|
||||
"devbox",
|
||||
"--cwd",
|
||||
"/repo",
|
||||
"functions",
|
||||
"list",
|
||||
"--json",
|
||||
], env);
|
||||
expect(list.exitCode).toBe(0);
|
||||
expect(JSON.parse(list.stdout)).toEqual({
|
||||
functions: [{
|
||||
name: "portfolioSnapshot",
|
||||
description: "Read portfolio.",
|
||||
sideEffects: "read-only",
|
||||
}],
|
||||
});
|
||||
|
||||
const describe = await runCli([
|
||||
"--ssh",
|
||||
"devbox",
|
||||
"--cwd",
|
||||
"/repo",
|
||||
"functions",
|
||||
"describe",
|
||||
"portfolioSnapshot",
|
||||
"--json",
|
||||
], env);
|
||||
expect(describe.exitCode).toBe(0);
|
||||
expect(JSON.parse(describe.stdout)).toMatchObject({
|
||||
function: { name: "portfolioSnapshot" },
|
||||
});
|
||||
|
||||
const call = await runCli([
|
||||
"--ssh",
|
||||
"devbox",
|
||||
"--cwd",
|
||||
"/repo",
|
||||
"functions",
|
||||
"call",
|
||||
"portfolioSnapshot",
|
||||
"--params-json",
|
||||
"{\"account\":\"demo\"}",
|
||||
"--json",
|
||||
], env);
|
||||
expect(call.exitCode).toBe(0);
|
||||
expect(JSON.parse(call.stdout)).toEqual({
|
||||
result: { account: "demo", equity: 456 },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses app-server pass-through through the workspace backend", () => {
|
||||
expect(parseArgs([
|
||||
"workspace",
|
||||
|
|
@ -599,6 +686,86 @@ async function runCli(
|
|||
return { exitCode: exitCode ?? 1, stdout, stderr };
|
||||
}
|
||||
|
||||
async function createFunctionFakeSshCommand(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "codex-functions-cli-"));
|
||||
const command = path.join(dir, "ssh.mjs");
|
||||
await writeFile(command, functionFakeSshScript());
|
||||
await chmod(command, 0o755);
|
||||
return command;
|
||||
}
|
||||
|
||||
function functionFakeSshScript(): string {
|
||||
return `#!/usr/bin/env node
|
||||
import { stdin, stdout } from "node:process";
|
||||
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
|
||||
let buffer = "";
|
||||
stdin.setEncoding("utf8");
|
||||
stdin.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
let newline = buffer.indexOf("\\n");
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line) handle(line);
|
||||
newline = buffer.indexOf("\\n");
|
||||
}
|
||||
});
|
||||
|
||||
function handle(line) {
|
||||
const message = JSON.parse(line);
|
||||
stdout.write(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: resultFor(message.method, message.params),
|
||||
}) + "\\n");
|
||||
}
|
||||
|
||||
function resultFor(method, params) {
|
||||
if (method === "workspace.initialize") {
|
||||
return {
|
||||
ok: true,
|
||||
serverInfo: { name: "fake-remote-agent", version: "0.1.0" },
|
||||
capabilities: {
|
||||
appServerPassThrough: true,
|
||||
workspaceMethods: ["functions.list", "functions.describe", "functions.call"],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "functions.list") {
|
||||
return {
|
||||
functions: [{
|
||||
name: "portfolioSnapshot",
|
||||
description: "Read portfolio.",
|
||||
sideEffects: "read-only",
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (method === "functions.describe") {
|
||||
return {
|
||||
function: {
|
||||
name: params.name,
|
||||
description: "Read portfolio.",
|
||||
sideEffects: "read-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "functions.call") {
|
||||
return {
|
||||
result: {
|
||||
account: params.params.account,
|
||||
equity: 456,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
setInterval(() => {}, 1_000);
|
||||
`;
|
||||
}
|
||||
|
||||
async function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
|
||||
let output = "";
|
||||
if (!stream) {
|
||||
|
|
|
|||
119
packages/codex-client/test/functions.test.ts
Normal file
119
packages/codex-client/test/functions.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
WorkspaceFunctionRuntime,
|
||||
createWorkspaceFunctionMethods,
|
||||
defineFunctions,
|
||||
findFunctionsManifest,
|
||||
} from "../src/functions.ts";
|
||||
|
||||
describe("workspace functions", () => {
|
||||
test("defineFunctions returns definitions unchanged", () => {
|
||||
const definitions = defineFunctions({
|
||||
ping: () => ({ ok: true }),
|
||||
});
|
||||
expect(Object.keys(definitions)).toEqual(["ping"]);
|
||||
});
|
||||
|
||||
test("loads TypeScript manifest metadata and calls handlers", async () => {
|
||||
const root = await createWorkspace(`export default {
|
||||
greet: {
|
||||
description: "Greet a person.",
|
||||
inputSchema: { type: "object", properties: { name: { type: "string" } } },
|
||||
outputSchema: { type: "object" },
|
||||
tags: ["demo"],
|
||||
handler: async (params) => ({ message: "hello " + params.name })
|
||||
},
|
||||
snapshot: async () => ({ ok: true })
|
||||
};`);
|
||||
const runtime = new WorkspaceFunctionRuntime({ cwd: root });
|
||||
|
||||
await expect(findFunctionsManifest(root)).resolves.toMatch(/functions\.ts$/);
|
||||
await expect(runtime.list()).resolves.toEqual({
|
||||
functions: [
|
||||
{
|
||||
name: "greet",
|
||||
description: "Greet a person.",
|
||||
inputSchema: { type: "object", properties: { name: { type: "string" } } },
|
||||
outputSchema: { type: "object" },
|
||||
tags: ["demo"],
|
||||
sideEffects: "read-only",
|
||||
},
|
||||
{
|
||||
name: "snapshot",
|
||||
description: "",
|
||||
sideEffects: "read-only",
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(runtime.describe("greet")).resolves.toMatchObject({
|
||||
function: { name: "greet", sideEffects: "read-only" },
|
||||
});
|
||||
await expect(runtime.call("greet", { name: "Ada" })).resolves.toEqual({
|
||||
result: { message: "hello Ada" },
|
||||
});
|
||||
});
|
||||
|
||||
test("reports missing functions and handler failures", async () => {
|
||||
const root = await createWorkspace(`export default {
|
||||
fail: {
|
||||
description: "Always fails.",
|
||||
handler: async () => { throw new Error("boom"); }
|
||||
}
|
||||
};`);
|
||||
const runtime = new WorkspaceFunctionRuntime({ cwd: root });
|
||||
|
||||
await expect(runtime.describe("missing")).rejects.toThrow("Workspace function not found: missing");
|
||||
await expect(runtime.call("fail")).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
test("rejects non JSON-serializable results", async () => {
|
||||
const root = await createWorkspace(`export default {
|
||||
circular: () => {
|
||||
const value = {};
|
||||
value.self = value;
|
||||
return value;
|
||||
},
|
||||
empty: () => undefined
|
||||
};`);
|
||||
const runtime = new WorkspaceFunctionRuntime({ cwd: root });
|
||||
|
||||
await expect(runtime.call("circular")).rejects.toThrow("Workspace function returned non-JSON data: circular");
|
||||
await expect(runtime.call("empty")).rejects.toThrow("Workspace function returned non-JSON data: empty");
|
||||
});
|
||||
|
||||
test("creates workspace backend methods", async () => {
|
||||
const root = await createWorkspace(`export default {
|
||||
echo: {
|
||||
description: "Echo params.",
|
||||
handler: (params) => params
|
||||
}
|
||||
};`);
|
||||
const methods = createWorkspaceFunctionMethods({ cwd: root });
|
||||
|
||||
await expect(methods["functions.list"]?.({}, rpcRequest("functions.list")))
|
||||
.resolves.toMatchObject({ functions: [{ name: "echo" }] });
|
||||
await expect(methods["functions.call"]?.(
|
||||
{ name: "echo", params: { ok: true } },
|
||||
rpcRequest("functions.call"),
|
||||
)).resolves.toEqual({ result: { ok: true } });
|
||||
});
|
||||
});
|
||||
|
||||
async function createWorkspace(source: string): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "codex-functions-"));
|
||||
await mkdir(path.join(root, ".codex"), { recursive: true });
|
||||
await writeFile(path.join(root, ".codex", "functions.ts"), source);
|
||||
return root;
|
||||
}
|
||||
|
||||
function rpcRequest(method: string) {
|
||||
return {
|
||||
jsonrpc: "2.0" as const,
|
||||
id: "test",
|
||||
method,
|
||||
params: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -109,6 +109,29 @@ describe("SSH remote provider", () => {
|
|||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
const functions = await transport.request("functions.list", {});
|
||||
expect(functions).toEqual({
|
||||
functions: [{
|
||||
name: "portfolioSnapshot",
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
}],
|
||||
});
|
||||
const described = await transport.request("functions.describe", {
|
||||
name: "portfolioSnapshot",
|
||||
});
|
||||
expect(described).toEqual({
|
||||
function: {
|
||||
name: "portfolioSnapshot",
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
},
|
||||
});
|
||||
const called = await transport.request("functions.call", {
|
||||
name: "portfolioSnapshot",
|
||||
params: { account: "demo" },
|
||||
});
|
||||
expect(called).toEqual({ result: { account: "demo", equity: 123 } });
|
||||
await waitForLog(fakeSsh, (entries) =>
|
||||
entries.some((entry) => entry.mode === "request" &&
|
||||
entry.method === "appServer.call")
|
||||
|
|
@ -197,7 +220,7 @@ function resultFor(method, params) {
|
|||
return {
|
||||
ok: true,
|
||||
serverInfo: { name: "fake-remote-agent", version: "0.1.0" },
|
||||
capabilities: { appServerPassThrough: true, workspaceMethods: ["remoteAgent/status"] },
|
||||
capabilities: { appServerPassThrough: true, workspaceMethods: ["remoteAgent/status", "functions.list", "functions.describe", "functions.call"] },
|
||||
};
|
||||
}
|
||||
if (method === "appServer.call" && params.method === "thread/list") {
|
||||
|
|
@ -208,6 +231,32 @@ function resultFor(method, params) {
|
|||
name: "Remote thread",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (method === "functions.list") {
|
||||
return {
|
||||
functions: [{
|
||||
name: "portfolioSnapshot",
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (method === "functions.describe") {
|
||||
return {
|
||||
function: {
|
||||
name: params.name,
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "functions.call") {
|
||||
return {
|
||||
result: {
|
||||
account: params.params.account,
|
||||
equity: 123,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
|
|
|
|||
110
packages/codex-client/test/vite.test.ts
Normal file
110
packages/codex-client/test/vite.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { createServer } from "vite";
|
||||
import { CodexEventEmitter } from "../src/app-server/events.ts";
|
||||
import type { CodexWorkspaceBackendTransport } from "../src/workspace-backend/client.ts";
|
||||
import { codexFlowsRemote } from "../src/vite.ts";
|
||||
|
||||
describe("codexFlowsRemote Vite plugin", () => {
|
||||
test("serves local bridge endpoints and forwards function calls", async () => {
|
||||
const transport = new FakeWorkspaceTransport();
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
},
|
||||
plugins: [codexFlowsRemote({ transport })],
|
||||
});
|
||||
try {
|
||||
await server.listen();
|
||||
const baseUrl = serverBaseUrl(server);
|
||||
|
||||
const status = await fetchJson(`${baseUrl}/__codex_flows/status`);
|
||||
expect(status).toMatchObject({ ok: true, remoteAgent: { ok: true, cwd: "/remote" } });
|
||||
|
||||
const functions = await fetchJson(`${baseUrl}/__codex_flows/functions`);
|
||||
expect(functions).toEqual({
|
||||
functions: [{ name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" }],
|
||||
});
|
||||
|
||||
const described = await fetchJson(`${baseUrl}/__codex_flows/functions/snapshot`);
|
||||
expect(described).toEqual({
|
||||
function: { name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" },
|
||||
});
|
||||
|
||||
const called = await fetchJson(`${baseUrl}/__codex_flows/functions/snapshot`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ params: { id: "one" } }),
|
||||
});
|
||||
expect(called).toEqual({ result: { id: "one", ok: true } });
|
||||
expect(transport.requests.map((request) => request.method)).toEqual([
|
||||
"workspace.initialize",
|
||||
"remoteAgent/status",
|
||||
"functions.list",
|
||||
"functions.describe",
|
||||
"functions.call",
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
class FakeWorkspaceTransport extends CodexEventEmitter implements CodexWorkspaceBackendTransport {
|
||||
readonly requestTimeoutMs = 1_000;
|
||||
requests: Array<{ method: string; params?: unknown }> = [];
|
||||
|
||||
start(): void {}
|
||||
|
||||
close(): void {}
|
||||
|
||||
notify(): void {}
|
||||
|
||||
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
this.requests.push({ method, params });
|
||||
if (method === "workspace.initialize") {
|
||||
return {
|
||||
ok: true,
|
||||
serverInfo: { name: "fake", version: "0.1.0" },
|
||||
capabilities: {
|
||||
appServerPassThrough: true,
|
||||
workspaceMethods: ["functions.list", "functions.describe", "functions.call"],
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
if (method === "remoteAgent/status") {
|
||||
return { ok: true, cwd: "/remote" } as T;
|
||||
}
|
||||
if (method === "functions.list") {
|
||||
return {
|
||||
functions: [{ name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "functions.describe") {
|
||||
return {
|
||||
function: { name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" },
|
||||
} as T;
|
||||
}
|
||||
if (method === "functions.call") {
|
||||
const input = params as { params?: { id?: string } };
|
||||
return { result: { id: input.params?.id, ok: true } } as T;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(url, init);
|
||||
expect(response.ok).toBe(true);
|
||||
return await response.json() as unknown;
|
||||
}
|
||||
|
||||
function serverBaseUrl(server: Awaited<ReturnType<typeof createServer>>): string {
|
||||
const address = server.httpServer?.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Vite server is not listening on a TCP port");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codex-flows-remote-control",
|
||||
"version": "0.133.0",
|
||||
"version": "0.133.4",
|
||||
"description": "Operate remote Codex workspaces from a local Codex App over SSH.",
|
||||
"author": {
|
||||
"name": "Peezy",
|
||||
|
|
@ -35,7 +35,8 @@
|
|||
"defaultPrompt": [
|
||||
"Discover my Codex App remote connections and run codex-flows over SSH.",
|
||||
"Check whether my remote codex-flows agent is reachable.",
|
||||
"Start a Codex turn in the remote workspace."
|
||||
"Start a Codex turn in the remote workspace.",
|
||||
"Build a local Vite dashboard for a remote workspace over SSH."
|
||||
],
|
||||
"brandColor": "#0F766E"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
name: remote-dashboard-builder
|
||||
description: Use when building local Vite dashboards that inspect or operate remote Codex workspaces over SSH through codex-flows functions, without exposing remote HTTP ports.
|
||||
---
|
||||
|
||||
# Remote Dashboard Builder
|
||||
|
||||
Use this skill when a user wants a browser dashboard for a remote Codex
|
||||
workspace, especially when human verification needs a local browser but the
|
||||
workspace runs over SSH.
|
||||
|
||||
## Direction
|
||||
|
||||
- Build the dashboard as a local Vite app.
|
||||
- Do not start human-facing preview servers on the remote host.
|
||||
- Use `@peezy.tech/codex-flows/vite` as the local SSH bridge.
|
||||
- Use `@peezy.tech/codex-flows/browser` from dashboard code.
|
||||
- Use `.codex/functions.ts` in the remote workspace for active data or actions.
|
||||
|
||||
## Discovery Flow
|
||||
|
||||
Before designing the dashboard, inspect what the remote workspace already
|
||||
exposes:
|
||||
|
||||
```bash
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions list --json
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions describe <name> --json
|
||||
```
|
||||
|
||||
Probe only read-only or no-side-effect functions with small sample inputs:
|
||||
|
||||
```bash
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions call <name> --params-json '{"sample":true}' --json
|
||||
```
|
||||
|
||||
Do not casually call functions that declare `sideEffects: "writes-local"` or
|
||||
`sideEffects: "external-write"`. Ask the user before calling functions that can
|
||||
mutate local workspace state, external systems, money, deployments, accounts,
|
||||
or production data.
|
||||
|
||||
## Vite Setup
|
||||
|
||||
Use the Vite plugin in the local dashboard:
|
||||
|
||||
```ts
|
||||
import { codexFlowsRemote } from "@peezy.tech/codex-flows/vite";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
codexFlowsRemote({
|
||||
ssh: process.env.CODEX_FLOWS_REMOTE_SSH_TARGET,
|
||||
cwd: process.env.CODEX_FLOWS_REMOTE_CWD,
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Dashboard code calls the local bridge:
|
||||
|
||||
```ts
|
||||
import { codexFlows } from "@peezy.tech/codex-flows/browser";
|
||||
|
||||
const functions = await codexFlows.functions.list();
|
||||
const snapshot = await codexFlows.functions.call("portfolioSnapshot");
|
||||
```
|
||||
|
||||
The browser talks only to the local Vite server under `/__codex_flows`. The
|
||||
Vite plugin owns the SSH connection and forwards requests to the remote-agent.
|
||||
|
||||
## Remote Workspace Functions
|
||||
|
||||
Add narrow, named functions only when the dashboard needs active data that is
|
||||
not already exposed:
|
||||
|
||||
```ts
|
||||
import { defineFunctions } from "@peezy.tech/codex-flows/functions";
|
||||
|
||||
export default defineFunctions({
|
||||
portfolioSnapshot: {
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
handler: async () => {
|
||||
return { positions: [], cash: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Keep functions JSON-in and JSON-out. Return plain objects, arrays, strings,
|
||||
numbers, booleans, or null. Avoid returning class instances, streams, circular
|
||||
objects, functions, BigInts, secrets, raw private keys, or broad filesystem
|
||||
contents.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never expose arbitrary shell execution to the browser.
|
||||
- Prefer small read-only functions for discovery and dashboard refresh.
|
||||
- Use descriptive names, descriptions, schemas, examples, and tags when useful.
|
||||
- Use `sideEffects: "external-write"` for actions that touch external systems.
|
||||
- Build the dashboard from real function metadata and returned shapes, not
|
||||
guesses.
|
||||
101
skills/remote-dashboard-builder/SKILL.md
Normal file
101
skills/remote-dashboard-builder/SKILL.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
name: remote-dashboard-builder
|
||||
description: Use when building local Vite dashboards that inspect or operate remote Codex workspaces over SSH through codex-flows functions, without exposing remote HTTP ports.
|
||||
---
|
||||
|
||||
# Remote Dashboard Builder
|
||||
|
||||
Use this skill when a user wants a browser dashboard for a remote Codex
|
||||
workspace, especially when human verification needs a local browser but the
|
||||
workspace runs over SSH.
|
||||
|
||||
## Direction
|
||||
|
||||
- Build the dashboard as a local Vite app.
|
||||
- Do not start human-facing preview servers on the remote host.
|
||||
- Use `@peezy.tech/codex-flows/vite` as the local SSH bridge.
|
||||
- Use `@peezy.tech/codex-flows/browser` from dashboard code.
|
||||
- Use `.codex/functions.ts` in the remote workspace for active data or actions.
|
||||
|
||||
## Discovery Flow
|
||||
|
||||
Before designing the dashboard, inspect what the remote workspace already
|
||||
exposes:
|
||||
|
||||
```bash
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions list --json
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions describe <name> --json
|
||||
```
|
||||
|
||||
Probe only read-only or no-side-effect functions with small sample inputs:
|
||||
|
||||
```bash
|
||||
codex-flows --ssh <target> --cwd <remote-workspace> functions call <name> --params-json '{"sample":true}' --json
|
||||
```
|
||||
|
||||
Do not casually call functions that declare `sideEffects: "writes-local"` or
|
||||
`sideEffects: "external-write"`. Ask the user before calling functions that can
|
||||
mutate local workspace state, external systems, money, deployments, accounts,
|
||||
or production data.
|
||||
|
||||
## Vite Setup
|
||||
|
||||
Use the Vite plugin in the local dashboard:
|
||||
|
||||
```ts
|
||||
import { codexFlowsRemote } from "@peezy.tech/codex-flows/vite";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
codexFlowsRemote({
|
||||
ssh: process.env.CODEX_FLOWS_REMOTE_SSH_TARGET,
|
||||
cwd: process.env.CODEX_FLOWS_REMOTE_CWD,
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Dashboard code calls the local bridge:
|
||||
|
||||
```ts
|
||||
import { codexFlows } from "@peezy.tech/codex-flows/browser";
|
||||
|
||||
const functions = await codexFlows.functions.list();
|
||||
const snapshot = await codexFlows.functions.call("portfolioSnapshot");
|
||||
```
|
||||
|
||||
The browser talks only to the local Vite server under `/__codex_flows`. The
|
||||
Vite plugin owns the SSH connection and forwards requests to the remote-agent.
|
||||
|
||||
## Remote Workspace Functions
|
||||
|
||||
Add narrow, named functions only when the dashboard needs active data that is
|
||||
not already exposed:
|
||||
|
||||
```ts
|
||||
import { defineFunctions } from "@peezy.tech/codex-flows/functions";
|
||||
|
||||
export default defineFunctions({
|
||||
portfolioSnapshot: {
|
||||
description: "Read the latest portfolio snapshot.",
|
||||
sideEffects: "read-only",
|
||||
handler: async () => {
|
||||
return { positions: [], cash: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Keep functions JSON-in and JSON-out. Return plain objects, arrays, strings,
|
||||
numbers, booleans, or null. Avoid returning class instances, streams, circular
|
||||
objects, functions, BigInts, secrets, raw private keys, or broad filesystem
|
||||
contents.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never expose arbitrary shell execution to the browser.
|
||||
- Prefer small read-only functions for discovery and dashboard refresh.
|
||||
- Use descriptive names, descriptions, schemas, examples, and tags when useful.
|
||||
- Use `sideEffects: "external-write"` for actions that touch external systems.
|
||||
- Build the dashboard from real function metadata and returned shapes, not
|
||||
guesses.
|
||||
Loading…
Add table
Add a link
Reference in a new issue