Skip to content
Closed
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
11 changes: 7 additions & 4 deletions docs-site/src/content/docs/guides/remote-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ using the feature; do not configure both the legacy sandbox and a permission pro

## Pair an Executor

1. Open **Remote Workspace** in the Hub dashboard.
2. Select **Create pairing code**.
3. On Computer 2, change into the project directory you want to expose.
4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer.
1. Pair the browser with the Hub through the dashboard pairing panel. Run the displayed
`ocx gui pair --origin` command on the Hub and enter its one-time code; an automatically
bootstrapped local or Tailscale session may view status but cannot control Remote Workspace.
2. Open **Remote Workspace** in that paired Hub dashboard.
3. Select **Create pairing code**.
4. On Computer 2, change into the project directory you want to expose.
5. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer.
It pairs the current directory and keeps
`ocx remote-workspace agent` connected in that terminal.

Expand Down
10 changes: 5 additions & 5 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,12 @@ readable; mutations refuse without initializing workspace services.
| Method and path | Purpose | Notable errors |
| --- | --- | --- |
| `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | Disabled status when Hub role or explicit opt-in is absent |
| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity |
| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | Operator-paired GUI session only; 429 pairing capacity |
| `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — |
| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target |
| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure |
| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session |
| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device |
| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST requires an operator-paired GUI session; 409 offline/unavailable/invalid target |
| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | Operator-paired GUI session only; 409 active turn, offline Executor, or resume failure |
| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | Operator-paired GUI session only; 404 unknown session |
| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | Operator-paired GUI session only; 404 unknown device |

Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens
`/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints
Expand Down
8 changes: 4 additions & 4 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
break;
}
case "content_block_start": {
const block = data.content_block as { type: string; id?: string; name?: string; data?: string; thinking?: string } | undefined;
const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string } | undefined;
if (!block) break;
currentBlockType = block.type;
if (block.type === "thinking") {
Expand All @@ -1212,7 +1212,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
}
if (block.type === "tool_use") {
currentToolCallId = usableToolUseId(block.id);
currentToolCallName = toolNames.fromWire(block.name ?? "");
currentToolCallName = toolNames.fromWire(typeof block.name === "string" ? block.name : "");
currentToolCallJson = "";
budget.openCall(currentToolCallId);
yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
Expand Down Expand Up @@ -1426,7 +1426,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
}
}
}
const content = rawContent as { type: string; text?: string; id?: string; name?: string; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined;
const content = rawContent as { type: string; text?: string; id?: string; name?: unknown; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined;
if (content) {
for (const block of content) {
if (block.type === "text" && block.text) {
Expand All @@ -1442,7 +1442,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
events.push({ type: "redacted_thinking", data: block.data });
} else if (block.type === "tool_use") {
const id = usableToolUseId(block.id);
events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
events.push({ type: "tool_call_start", id, name: toolNames.fromWire(typeof block.name === "string" ? block.name : "") });
events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input, provider.anthropicEofTolerance === true) });
events.push({ type: "tool_call_end" });
}
Expand Down
3 changes: 2 additions & 1 deletion src/claude/agents-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ClaudeAgentDef {
const OWNED_PREFIX = "ocx-";
/** Ownership proof (audit 071 #2): a file without this marker is NEVER touched. */
const GENERATED_MARKER = "generated-by: opencodex";
const SAFE_AGENT_MODEL_ID = /^[a-z0-9][a-z0-9._:/@+\[\]~-]*$/i;

function sanitizeName(value: string): string {
const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
Expand Down Expand Up @@ -156,7 +157,7 @@ export function buildClaudeAgentDefs(
const roster = rosterOverride
?? (config.subagentModels === undefined ? DEFAULT_SUBAGENT_MODELS : config.subagentModels);
for (const entry of roster.slice(0, 5)) {
if (typeof entry !== "string" || entry.trim() === "") continue;
if (typeof entry !== "string" || !SAFE_AGENT_MODEL_ID.test(entry.trim())) continue;
const { alias, id, provider } = entryParts(entry.trim(), config);
push(sanitizeName(id), alias, `Delegate work to ${id} (${provider}) via opencodex routing. General-purpose worker/explorer on that model. ${NO_MODEL_ARG}`);
}
Expand Down
42 changes: 42 additions & 0 deletions src/cli/aside-profiles.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,52 @@
import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
import { readRuntimePort } from "../config/process-state";
import { createLocalAttestationChallenge, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, verifyLocalAttestationProof } from "../lib/local-management-attestation";
import { createLocalAsideSyncCapability, LOCAL_ASIDE_SYNC_CAPABILITY_HEADER, LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS, LOCAL_ASIDE_SYNC_CAPABILITY_VERSION, LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER, LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER, LOCAL_ASIDE_SYNC_METHOD, LOCAL_ASIDE_SYNC_NONCE_HEADER, LOCAL_ASIDE_SYNC_PATH } from "../lib/local-aside-sync-contract";
import { directLocalHttpFetch } from "../server/direct-local-http";
import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
import { runtimeRequest, RuntimeApiError, type RuntimeApiDeps } from "./runtime-api";

/** Aside policy and file writes share the running server's mutation owner. Never fall back locally. */
export async function refreshAsideProfilesThroughServer(
deps: RuntimeApiDeps = {},
): Promise<OwnedIntegrationRefreshOutcome[]> {
// An explicit URL is an opt-in transport used by connected callers and tests.
if (!deps.baseUrl) {
const live = await (deps.findLiveProxy ?? findLiveProxy)();
if (!live) throw new RuntimeApiError("Proxy is not running. Start it with: ocx start", 503, null);
if (live.source !== "runtime" || live.pid === null) {
throw new RuntimeApiError("Aside profile synchronization requires an attested running proxy", 503, null);
}
const runtime = readRuntimePort(live.pid);
if (!runtime?.attestationSecret || runtime.pid !== live.pid || runtime.port !== live.port) {
throw new RuntimeApiError("Aside profile synchronization could not verify the running proxy", 503, null);
}
const nonce = createLocalAttestationChallenge();
const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`;
const proofResponse = await directLocalHttpFetch(`${baseUrl}/healthz`, { headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: nonce } });
const health = await proofResponse.json().catch(() => null);
if (!proofResponse.ok || !isOpencodexHealthz(health) || health?.pid !== live.pid || health?.port !== live.port
|| health?.asideSyncCapability !== LOCAL_ASIDE_SYNC_CAPABILITY_VERSION
|| !verifyLocalAttestationProof(runtime.attestationSecret, nonce, live.pid, live.port, proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER))) {
throw new RuntimeApiError("Aside profile synchronization could not attest the running proxy", 503, null);
}
const expiresAt = Date.now() + LOCAL_ASIDE_SYNC_CAPABILITY_TTL_MS;
const capability = createLocalAsideSyncCapability(runtime.attestationSecret, nonce, LOCAL_ASIDE_SYNC_METHOD, LOCAL_ASIDE_SYNC_PATH, live.pid, live.port, expiresAt);
if (!capability) throw new RuntimeApiError("Aside profile synchronization capability was unavailable", 503, null);
const response = await directLocalHttpFetch(`${baseUrl}${LOCAL_ASIDE_SYNC_PATH}`, {
method: LOCAL_ASIDE_SYNC_METHOD,
headers: {
[LOCAL_ASIDE_SYNC_EXPECTED_PID_HEADER]: String(live.pid),
[LOCAL_ASIDE_SYNC_NONCE_HEADER]: nonce,
[LOCAL_ASIDE_SYNC_EXPIRES_AT_HEADER]: String(expiresAt),
[LOCAL_ASIDE_SYNC_CAPABILITY_HEADER]: capability,
},
});
const body = await response.json().catch(() => null) as { results?: OwnedIntegrationRefreshOutcome[] } | null;
if (!response.ok) throw new RuntimeApiError("Aside profile synchronization was rejected", response.status, body);
if (!Array.isArray(body?.results)) throw new RuntimeApiError("The running proxy does not support Aside profile synchronization", 502, body);
return body.results;
}
const result = await runtimeRequest<{ results?: OwnedIntegrationRefreshOutcome[] }>(
"/api/client-integrations/aside/sync",
{ method: "POST", body: "{}" },
Expand Down
16 changes: 12 additions & 4 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,9 +985,15 @@ function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = fa
};
}

/** Do not let provider-controlled catalog text become an environment lookup. */
function containsEnvInterpolation(value: string): boolean {
return value.includes("${");
}

function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
const models: Record<string, HermesModelEntry> = {};
for (const model of normalizeExportModels(ctx.models)) {
if (containsEnvInterpolation(model.namespaced)) continue;
const declared = model.inputModalities;
models[model.namespaced] = declared && declared.length > 0
? { supports_vision: declared.includes("image") }
Expand All @@ -1009,15 +1015,17 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
}

function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig {
const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => {
const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).flatMap(model => {
const name = exportModelLabel(model);
if (containsEnvInterpolation(model.namespaced) || containsEnvInterpolation(name)) return [];
const context = authoritativeContextWindow(model.contextWindow);
const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))];
return {
return [{
id: model.namespaced,
name: exportModelLabel(model),
name,
...(context !== undefined ? { contextWindow: context } : {}),
...(input.length > 0 ? { input } : {}),
};
}];
});
const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF);
return {
Expand Down
60 changes: 54 additions & 6 deletions src/github/star-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* invalidates the cache immediately, which is why the click path never has to
* wait for the TTL to see its own result.
*/
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, posix, win32 } from "node:path";
import { commandInvocation } from "../lib/win-exec";

export const STAR_REPO = "lidge-jun/opencodex";
Expand Down Expand Up @@ -54,13 +57,17 @@ export interface StarDeps {
*/
async function spawnGh(args: string[], timeoutMs: number): Promise<{ status: number | null } | null> {
try {
// On Windows `gh` is a `.cmd` shim, and a shell-less spawn of the bare name
// neither consults PATHEXT nor accepts a `.cmd` target. It does not fail
// fast either — it hangs until the timeout below fires, which is how these
// sidebar tests turned into 5s timeouts on windows-latest while passing
// everywhere else. `commandInvocation` is the resolver the CLI already uses.
const invocation = commandInvocation("gh", args);
const executable = resolveTrustedGhExecutable();
if (!executable) return null;
const trustedPath = trustedGhDirectories().join(delimiter);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path"),
);
env.PATH = trustedPath;
const invocation = commandInvocation(executable, args);
const proc = Bun.spawn([invocation.file, ...invocation.args], {
cwd: homedir(),
env,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
Expand All @@ -79,6 +86,47 @@ async function spawnGh(args: string[], timeoutMs: number): Promise<{ status: num
}
}

/** Fixed install roots keep an automatically polled route from searching the project or caller-supplied PATH. */
function trustedGhDirectories(
platform: NodeJS.Platform = process.platform,
env: Record<string, string | undefined> = process.env,
): string[] {
if (platform !== "win32") {
return [
"/usr/local/bin",
"/usr/bin",
"/bin",
"/opt/homebrew/bin",
"/opt/local/bin",
"/home/linuxbrew/.linuxbrew/bin",
"/snap/bin",
"/run/current-system/sw/bin",
];
}
const directories: string[] = [];
for (const root of [env.ProgramFiles, env.ProgramW6432, env["ProgramFiles(x86)"]]) {
if (root && win32.isAbsolute(root)) directories.push(win32.join(root, "GitHub CLI"));
}
if (env.LOCALAPPDATA && win32.isAbsolute(env.LOCALAPPDATA)) {
directories.push(win32.join(env.LOCALAPPDATA, "Programs", "GitHub CLI"));
}
return directories;
}

export function resolveTrustedGhExecutable(
platform: NodeJS.Platform = process.platform,
env: Record<string, string | undefined> = process.env,
exists: (path: string) => boolean = existsSync,
): string | null {
const filename = platform === "win32" ? "gh.exe" : "gh";
const paths = platform === "win32" ? win32 : posix;
for (const directory of trustedGhDirectories(platform, env)) {
const candidate = paths.join(directory, filename);
if (paths.isAbsolute(candidate) && exists(candidate)) return candidate;
}
return null;
}

const productionDeps: StarDeps = { runGh: spawnGh, nowMs: () => Date.now() };
let defaultDeps = productionDeps;

Expand Down
14 changes: 9 additions & 5 deletions src/grok/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,15 @@ export function isDirectory(path: string): boolean {

/** INTERNAL API — see `ManagedRegion` above. Not a public fence-parsing surface. */
export function findManagedRegion(content: string): ManagedRegion | null {
const start = content.indexOf(BEGIN_MARKER);
if (start === -1) return null;
const endMarkerStart = content.indexOf(END_MARKER, start + BEGIN_MARKER.length);
if (endMarkerStart === -1) return { start, end: content.length, orphaned: true };
return { start, end: endMarkerStart + END_MARKER.length, orphaned: false };
const markerLine = (marker: string): RegExp =>
new RegExp(`^[ \\t]*${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*$`, "gm");
const begin = markerLine(BEGIN_MARKER).exec(content);
if (!begin) return null;
const end = markerLine(END_MARKER);
end.lastIndex = begin.index + begin[0].length;
const endMatch = end.exec(content);
if (!endMatch) return { start: begin.index, end: content.length, orphaned: true };
return { start: begin.index, end: endMatch.index + endMatch[0].length, orphaned: false };
}

/**
Expand Down
15 changes: 11 additions & 4 deletions src/grok/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,18 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
return { configPath, present: false, baseUrl: null, models: [] };
}

const begin = content.indexOf(BEGIN_MARKER);
const end = content.indexOf(END_MARKER, begin + 1);
if (begin < 0 || end < 0) return { configPath, present: false, baseUrl: null, models: [] };
// Line-anchored like findManagedRegion: marker-shaped text inside TOML string
// data (e.g. a provider-supplied model id) is not a fence boundary.
const markerLine = (marker: string): RegExp =>
new RegExp(`^[ \\t]*${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*$`, "gm");
const beginMatch = markerLine(BEGIN_MARKER).exec(content);
if (!beginMatch) return { configPath, present: false, baseUrl: null, models: [] };
const endRe = markerLine(END_MARKER);
endRe.lastIndex = beginMatch.index + beginMatch[0].length;
const endMatch = endRe.exec(content);
if (!endMatch) return { configPath, present: false, baseUrl: null, models: [] };

const region = content.slice(begin + BEGIN_MARKER.length, end);
const region = content.slice(beginMatch.index + beginMatch[0].length, endMatch.index);
const models: GrokStatusModel[] = [];
let baseUrl: string | null = null;
let current: GrokStatusModel | null = null;
Expand Down
Loading
Loading