open-managed-agents 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,17 +12,35 @@ files, and sandboxes live on your machines, under your policies.
12
12
  > self-hosted baseline. The orchestration/persistence layer (multi-agent
13
13
  > runtime, memory stores,
14
14
  > scheduled deployments, webhooks, outcomes) is deliberately deferred. See
15
- > **[PARITY.md](PARITY.md)** for the full domain-by-domain scorecard and the
16
- > active parity worklist, and **[ALPHA.md](ALPHA.md)** for the first-user
15
+ > **[PARITY.md](https://github.com/oneryalcin/open-managed-agents/blob/main/PARITY.md)** for the full domain-by-domain scorecard and the
16
+ > active parity worklist, and **[ALPHA.md](https://github.com/oneryalcin/open-managed-agents/blob/main/ALPHA.md)** for the first-user
17
17
  > alpha readiness plan.
18
18
 
19
19
  ## Quickstart
20
20
 
21
- Start with the canonical source-checkout guide:
21
+ > **Public onboarding preview:** the one-command path is shipped, but its
22
+ > three-minute warm-path target has not yet completed the real-user timing
23
+ > study. Docker image downloads are explicitly outside that warm-path target.
22
24
 
23
- **[Getting Started](docs/getting-started.md)**
25
+ Prerequisites: Node.js 22.19 or newer and a running Docker-compatible daemon.
26
+ Then run:
24
27
 
25
- The alpha path is:
28
+ ```bash
29
+ npx --yes open-managed-agents@latest
30
+ ```
31
+
32
+ The guided terminal checks local prerequisites, asks which model provider to
33
+ use, reads the API key through a masked prompt, starts an authenticated
34
+ loopback appliance, creates or reuses a starter agent/environment/session, and
35
+ opens that session in the console. The appliance stays attached to the command;
36
+ press Ctrl-C to stop it. State and provider credentials persist under `~/.oma`.
37
+
38
+ If the pinned sandbox image is not cached, OMA names the image and asks before
39
+ pulling it. Rerunning the same command while its onboarding-owned appliance is
40
+ alive reopens the existing starter session rather than creating duplicates.
41
+
42
+ For the full source-checkout diagnostics and deterministic smoke tests, use
43
+ the **[Getting Started guide](https://github.com/oneryalcin/open-managed-agents/blob/main/docs/getting-started.md)**:
26
44
 
27
45
  ```bash
28
46
  git clone https://github.com/oneryalcin/open-managed-agents.git
@@ -45,7 +63,8 @@ events, and cleanup.
45
63
  Docker-local egress boundary: npm, uv/PyPI, and GitHub must work under the
46
64
  reviewed preset while an unrelated HTTPS destination remains denied.
47
65
 
48
- `oma up` starts the durable local appliance in the foreground, stores data
66
+ In the advanced source-checkout flow, `oma up` starts the durable local appliance
67
+ in the foreground, stores data
49
68
  under `~/.oma`, and prints the console URL plus the first workspace API key
50
69
  once. Docker deployments have the pinned approved-HTTPS sidecar capability,
51
70
  but every environment remains offline unless its immutable allowlist selects a
@@ -57,6 +76,12 @@ mint another while the server is running:
57
76
  oma keys mint
58
77
  ```
59
78
 
79
+ When using the public package without a global install, the equivalent is:
80
+
81
+ ```bash
82
+ npx --yes open-managed-agents@latest keys mint
83
+ ```
84
+
60
85
  Interactive, air-gap-safe OpenAPI documentation is served at `/docs/`, with
61
86
  the same schema available to tools at `/openapi.json`. The documentation lists
62
87
  only routes OMA currently ships; workspace and admin credentials entered in the
@@ -69,7 +94,7 @@ import anthropic
69
94
 
70
95
  client = anthropic.Anthropic(
71
96
  base_url="http://127.0.0.1:4180",
72
- api_key="oma_...", # printed on first boot
97
+ api_key="oma_...", # returned by `oma keys mint`
73
98
  default_headers={"anthropic-beta": "managed-agents-2026-04-01"},
74
99
  )
75
100
 
package/bin/oma.mjs CHANGED
@@ -20,7 +20,9 @@ const { resolveOmaUpEgressEnvironment } = await import(
20
20
 
21
21
  checkNode();
22
22
 
23
- const command = args[0];
23
+ // `npx open-managed-agents` is the public one-command path. Keep an installed
24
+ // bare `oma` invocation as help so existing CLI discovery remains stable.
25
+ const command = args[0] ?? (process.env.npm_command === "exec" ? "onboard" : undefined);
24
26
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
25
27
  if (command === "help" && args.length > 1) {
26
28
  printCommandHelp(args.slice(1));
@@ -33,7 +35,7 @@ if ((command === "version" && args.length === 1) || command === "--version" || c
33
35
  console.log(`oma ${packageJson.version}`);
34
36
  process.exit(0);
35
37
  }
36
- const commandArgs = args.slice(1);
38
+ const commandArgs = command === "onboard" && args.length === 0 ? [] : args.slice(1);
37
39
  const helpIndex = commandArgs.findIndex((arg) => arg === "--help" || arg === "-h");
38
40
  if (helpIndex !== -1) {
39
41
  if (helpIndex !== commandArgs.length - 1) fail(`Help must be the last argument. Run \`oma help ${[command, ...commandArgs.slice(0, helpIndex)].join(" ")}\`.`);
@@ -57,6 +59,8 @@ if (command === "up") {
57
59
  await runModels(args.slice(1));
58
60
  } else if (command === "auth") {
59
61
  await runAuth(args.slice(1));
62
+ } else if (command === "onboard") {
63
+ await runOnboard(commandArgs);
60
64
  } else if (command === "doctor") {
61
65
  await runDoctor(args.slice(1));
62
66
  } else if (command === "down" || command === "logs" || command === "status") {
@@ -77,6 +81,21 @@ async function runDoctor(commandArgs) {
77
81
  );
78
82
  }
79
83
 
84
+ async function runOnboard(commandArgs) {
85
+ const childEnv = commandArgs.includes("--json")
86
+ ? { ...process.env, OMA_LOG_LEVEL: "error" }
87
+ : process.env;
88
+ await runChild(
89
+ process.execPath,
90
+ [
91
+ ...runtimeNodeFlags(),
92
+ runtimeFile("scripts", "oma-onboard"),
93
+ ...commandArgs,
94
+ ],
95
+ childEnv,
96
+ );
97
+ }
98
+
80
99
  async function runUp(commandArgs) {
81
100
  let sandbox = "docker-local";
82
101
  for (let index = 0; index < commandArgs.length; index += 1) {
@@ -415,6 +434,7 @@ Usage:
415
434
  oma auth set <provider> [--stdin]
416
435
  oma auth status [provider]
417
436
  oma auth remove <provider>
437
+ oma onboard [--provider name] [--stdin] [--sandbox docker|microsandbox] [--pull] [--json]
418
438
  oma doctor [--sandbox docker|microsandbox] [--json]
419
439
  oma admin init
420
440
  oma admin status
@@ -429,6 +449,7 @@ Commands:
429
449
  providers Inspect enabled Pi model providers and credential readiness.
430
450
  models List or validate enabled Pi models.
431
451
  auth Store, remove, or inspect model provider API-key credentials.
452
+ onboard Prepare credentials, start or resume local OMA, and open a starter console session.
432
453
  doctor Run secret-safe, read-only local readiness diagnostics.
433
454
  admin Initialize or inspect local appliance admin mode.
434
455
  version Print the installed OMA version.
@@ -478,6 +499,7 @@ function commandHelpFor(key) {
478
499
  "auth set": ["Usage: oma auth set <provider> [--stdin]", "Store a provider key without accepting it as a command-line argument.", "Example: printf '%s\\n' \"$OPENAI_API_KEY\" | oma auth set openai --stdin"],
479
500
  "auth status": ["Usage: oma auth status [provider]", "Show credential readiness without secret values.", "Example: oma auth status openai"],
480
501
  "auth remove": ["Usage: oma auth remove <provider>", "Remove a stored provider credential idempotently.", "Example: oma auth remove openai"],
502
+ onboard: ["Usage: oma onboard [--provider name] [--stdin] [--sandbox docker|microsandbox] [--pull] [--json]", "Verify warm-path prerequisites, select or store a provider credential, start or resume the attached local appliance, and open its starter console session. --pull explicitly permits a missing Docker sandbox image to be downloaded.", "Examples: oma onboard; oma onboard --pull; printf '%s\\n' \"$ANTHROPIC_API_KEY\" | oma onboard --provider anthropic --stdin"],
481
503
  admin: ["Usage: oma admin <init|status>", "Initialize or inspect appliance-wide admin mode.", "Examples: oma admin init; oma admin status"],
482
504
  "admin init": ["Usage: oma admin init [--file path]", "Create a new owner-only admin key file; existing files are never overwritten.", "Example: oma admin init"],
483
505
  "admin status": ["Usage: oma admin status", "Inspect admin configuration without printing the key.", "Example: oma admin status"],
@@ -0,0 +1,544 @@
1
+ import * as p from "@clack/prompts";
2
+ import pc from "picocolors";
3
+ import { stdin as input } from "node:process";
4
+ import { spawnSync } from "node:child_process";
5
+ import { randomBytes } from "node:crypto";
6
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { inspectOma, } from "./oma-doctor.js";
10
+ import { runModelsCli } from "./oma-models.js";
11
+ import { DEFAULT_OMA_SANDBOX_IMAGE } from "../src/control-plane/sessions/pi/sandbox/image.js";
12
+ import { resolveOmaUpEgressEnvironment } from "../src/control-plane/egress/image.js";
13
+ import { ConsoleBootstrapService } from "../src/control-plane/console/bootstrap.js";
14
+ import { startAppliance } from "../src/main.js";
15
+ import { ensureStarterResources } from "./oma-onboarding-resources.js";
16
+ export const ONBOARD_CANCELLED = Symbol("oma-onboard-cancelled");
17
+ const PROVIDER_LABELS = {
18
+ anthropic: "Anthropic",
19
+ openai: "OpenAI",
20
+ openrouter: "OpenRouter",
21
+ };
22
+ export async function runOnboard(argv, runtime) {
23
+ const wantsJson = argv.includes("--json");
24
+ let options;
25
+ try {
26
+ options = parseOnboardArgs(argv);
27
+ }
28
+ catch (error) {
29
+ emitFailure(runtime, wantsJson, error instanceof Error ? error.message : String(error));
30
+ return { code: 2, credential: "not_reached" };
31
+ }
32
+ if (!options.json)
33
+ runtime.terminal.intro(pc.bgCyan(pc.black(" Open Managed Agents ")));
34
+ if (options.provider === undefined && !options.stdin) {
35
+ try {
36
+ const resumed = await runtime.resume({ json: options.json });
37
+ if (resumed !== undefined) {
38
+ emitResumed(runtime, options.json, resumed.sessionId);
39
+ return { code: 0, credential: "reused", sessionId: resumed.sessionId };
40
+ }
41
+ }
42
+ catch (error) {
43
+ emitFailure(runtime, options.json, safeMessage(error));
44
+ return { code: 1, credential: "not_reached" };
45
+ }
46
+ }
47
+ if (!runtime.interactive && !options.stdin && options.provider === undefined) {
48
+ emitFailure(runtime, options.json, "Non-interactive onboarding requires --provider and either --stdin or an existing stored credential.");
49
+ return { code: 2, credential: "not_reached" };
50
+ }
51
+ if (options.stdin && options.provider === undefined) {
52
+ emitFailure(runtime, options.json, "--stdin requires --provider so OMA knows which credential to store.");
53
+ return { code: 2, credential: "not_reached" };
54
+ }
55
+ let report = await runtime.inspect({ sandbox: options.sandbox });
56
+ const imageMissing = options.sandbox === "docker-local"
57
+ && report.checks.find((check) => check.id === "sandbox.image")?.status !== "pass";
58
+ const failures = onboardingPreflightFailures(report, options.sandbox, { allowMissingImage: imageMissing });
59
+ if (failures.length > 0) {
60
+ const detail = failures.join("\n");
61
+ emitFailure(runtime, options.json, detail);
62
+ return { code: 1, credential: "not_reached", report };
63
+ }
64
+ if (imageMissing) {
65
+ let approved = options.pull;
66
+ if (!approved && runtime.interactive && !options.json) {
67
+ const answer = await runtime.terminal.confirm({
68
+ message: `The cached OMA image is missing. Pull it now? This download is outside the three-minute warm-path target.`,
69
+ initialValue: true,
70
+ });
71
+ if (answer === ONBOARD_CANCELLED)
72
+ return cancelled(runtime, options.json, report);
73
+ approved = answer;
74
+ }
75
+ if (!approved) {
76
+ emitFailure(runtime, options.json, `Pull the cached prerequisite image, then retry: docker pull ${DEFAULT_OMA_SANDBOX_IMAGE}`);
77
+ return { code: 1, credential: "not_reached", report };
78
+ }
79
+ if (!options.json)
80
+ runtime.terminal.step(`Pulling ${DEFAULT_OMA_SANDBOX_IMAGE}`);
81
+ try {
82
+ await runtime.pullImage(DEFAULT_OMA_SANDBOX_IMAGE);
83
+ report = await runtime.inspect({ sandbox: options.sandbox });
84
+ }
85
+ catch (error) {
86
+ emitFailure(runtime, options.json, safeMessage(error));
87
+ return { code: 1, credential: "not_reached", report };
88
+ }
89
+ const afterPull = onboardingPreflightFailures(report, options.sandbox);
90
+ if (afterPull.length > 0) {
91
+ emitFailure(runtime, options.json, afterPull.join("\n"));
92
+ return { code: 1, credential: "not_reached", report };
93
+ }
94
+ }
95
+ if (!options.json)
96
+ runtime.terminal.step("Docker and the cached OMA sandbox image are ready.");
97
+ let provider = options.provider;
98
+ let providerState;
99
+ try {
100
+ providerState = await runtime.providerStatus(provider);
101
+ }
102
+ catch (error) {
103
+ emitFailure(runtime, options.json, safeMessage(error));
104
+ return { code: 2, credential: "not_reached", report };
105
+ }
106
+ if (providerState.providers.length === 0) {
107
+ emitFailure(runtime, options.json, "No enabled model providers are available for onboarding.");
108
+ return { code: 2, credential: "not_reached", report };
109
+ }
110
+ if (provider === undefined) {
111
+ if (!runtime.interactive) {
112
+ emitFailure(runtime, options.json, "Non-interactive onboarding requires --provider.");
113
+ return { code: 2, credential: "not_reached", report };
114
+ }
115
+ const selected = await runtime.terminal.select({
116
+ message: "Choose a model provider",
117
+ options: providerState.providers.map((value) => ({
118
+ value,
119
+ label: PROVIDER_LABELS[value] ?? value,
120
+ })),
121
+ });
122
+ if (selected === ONBOARD_CANCELLED)
123
+ return cancelled(runtime, options.json, report);
124
+ provider = selected;
125
+ try {
126
+ providerState = await runtime.providerStatus(provider);
127
+ }
128
+ catch (error) {
129
+ emitFailure(runtime, options.json, safeMessage(error));
130
+ return { code: 2, credential: "not_reached", report };
131
+ }
132
+ }
133
+ if (!providerState.providers.includes(provider)) {
134
+ emitFailure(runtime, options.json, `Model provider ${JSON.stringify(provider)} is not enabled. Enabled providers: ${providerState.providers.join(", ")}.`);
135
+ return { code: 2, credential: "not_reached", report };
136
+ }
137
+ if (providerState.stored) {
138
+ if (!runtime.interactive || options.json) {
139
+ return finishOnboarding(runtime, options, provider, "reused", report);
140
+ }
141
+ const reuse = await runtime.terminal.confirm({
142
+ message: `Use the saved ${PROVIDER_LABELS[provider] ?? provider} credential?`,
143
+ initialValue: true,
144
+ });
145
+ if (reuse === ONBOARD_CANCELLED)
146
+ return cancelled(runtime, options.json, report);
147
+ if (reuse) {
148
+ return finishOnboarding(runtime, options, provider, "reused", report);
149
+ }
150
+ }
151
+ let key;
152
+ if (options.stdin) {
153
+ try {
154
+ key = await runtime.readStdin();
155
+ }
156
+ catch (error) {
157
+ emitFailure(runtime, options.json, safeMessage(error));
158
+ return { code: 2, credential: "not_reached", report };
159
+ }
160
+ }
161
+ else if (runtime.interactive) {
162
+ key = await runtime.terminal.password({
163
+ message: `Paste API key for ${PROVIDER_LABELS[provider] ?? provider}`,
164
+ validate(value) {
165
+ return value.trim() === "" ? "API key must not be empty." : undefined;
166
+ },
167
+ });
168
+ }
169
+ else {
170
+ emitFailure(runtime, options.json, "Non-interactive onboarding requires --stdin when no stored credential exists.");
171
+ return { code: 2, credential: "not_reached", report };
172
+ }
173
+ if (key === ONBOARD_CANCELLED)
174
+ return cancelled(runtime, options.json, report);
175
+ if (key.trim() === "") {
176
+ emitFailure(runtime, options.json, "API key must not be empty.");
177
+ return { code: 2, credential: "not_reached", report };
178
+ }
179
+ try {
180
+ await runtime.storeCredential(provider, key.trim());
181
+ }
182
+ catch (error) {
183
+ emitFailure(runtime, options.json, safeMessage(error));
184
+ return { code: 1, credential: "not_reached", report };
185
+ }
186
+ return finishOnboarding(runtime, options, provider, "stored", report);
187
+ }
188
+ async function finishOnboarding(runtime, options, provider, credential, report) {
189
+ try {
190
+ let readyEmitted = false;
191
+ const launched = await runtime.launch(provider, options.sandbox, {
192
+ json: options.json,
193
+ onReady(sessionId) {
194
+ readyEmitted = true;
195
+ emitSuccess(runtime, options.json, provider, credential, sessionId);
196
+ },
197
+ });
198
+ if (!readyEmitted)
199
+ emitSuccess(runtime, options.json, provider, credential, launched.sessionId);
200
+ return { code: 0, provider, credential, report, sessionId: launched.sessionId };
201
+ }
202
+ catch (error) {
203
+ emitFailure(runtime, options.json, safeMessage(error));
204
+ return { code: 1, provider, credential, report };
205
+ }
206
+ }
207
+ export function createProcessOnboardRuntime() {
208
+ return {
209
+ env: process.env,
210
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
211
+ terminal: createClackTerminal(),
212
+ inspect: (options) => inspectOma(options, { env: process.env }),
213
+ async providerStatus(provider) {
214
+ const status = await runModelsCli(["providers-status"], { env: process.env });
215
+ if (status.code !== 0)
216
+ throw new Error(status.stderr.trim());
217
+ const providers = parseProviderStatus(status.stdout);
218
+ if (provider === undefined)
219
+ return { providers, stored: undefined };
220
+ const auth = await runModelsCli(["auth-status", provider], { env: process.env });
221
+ if (auth.code !== 0)
222
+ throw new Error(auth.stderr.trim());
223
+ return { providers, stored: parseStoredCredential(auth.stdout, provider) };
224
+ },
225
+ async storeCredential(provider, key) {
226
+ const result = await runModelsCli(["auth-set", provider], {
227
+ env: process.env,
228
+ promptSecret: async () => key,
229
+ });
230
+ if (result.code !== 0)
231
+ throw new Error(result.stderr.trim());
232
+ },
233
+ readStdin: readOneSecretFromStdin,
234
+ async pullImage(image) {
235
+ const result = spawnSync("docker", ["pull", image], { stdio: "inherit" });
236
+ if (result.status !== 0)
237
+ throw new Error(`docker pull failed for ${image}`);
238
+ },
239
+ resume: (options) => tryResumeLocalOnboarding(process.env, createClackTerminal(), options.json),
240
+ launch: (provider, sandbox, options) => launchLocalOnboarding(provider, sandbox, process.env, createClackTerminal(), options),
241
+ };
242
+ }
243
+ export async function launchLocalOnboarding(provider, sandbox, env, terminal, options) {
244
+ const bootstrap = new ConsoleBootstrapService();
245
+ const controlToken = `oct_${randomBytes(32).toString("base64url")}`;
246
+ const runtimeEnv = resolveOmaUpEgressEnvironment(env, sandbox);
247
+ const applianceEnv = {
248
+ ...runtimeEnv,
249
+ OMA_AUTH_MODE: "api-key",
250
+ OMA_HOST: "127.0.0.1",
251
+ OMA_PORT: runtimeEnv.OMA_PORT ?? "0",
252
+ OMA_TLS_TERMINATED: "0",
253
+ OMA_SANDBOX_PROVIDER: sandbox,
254
+ ...(sandbox === "docker-local"
255
+ ? { OMA_ALLOW_DOCKER_LOCAL: "true" }
256
+ : { OMA_ALLOW_MICROSANDBOX_LOCAL: "true" }),
257
+ };
258
+ if (!options.json)
259
+ terminal.step("Starting local OMA");
260
+ const appliance = await startAppliance(applianceEnv, {
261
+ log: () => undefined,
262
+ onboarding: { bootstrap, controlToken },
263
+ });
264
+ let resumeStateWritten = false;
265
+ try {
266
+ if (appliance.onboarding === undefined)
267
+ throw new Error("Local onboarding requires workspace authentication");
268
+ if (!options.json)
269
+ terminal.step("Preparing your first session");
270
+ const starter = await ensureStarterResources({
271
+ baseUrl: appliance.baseUrl,
272
+ workspaceKey: appliance.onboarding.workspaceKey,
273
+ provider,
274
+ });
275
+ if (!options.json)
276
+ for (const warning of starter.warnings)
277
+ terminal.warn(warning);
278
+ writeResumeState(env, {
279
+ schema_version: 1,
280
+ pid: process.pid,
281
+ base_url: appliance.baseUrl,
282
+ session_id: starter.sessionId,
283
+ control_token: controlToken,
284
+ });
285
+ resumeStateWritten = true;
286
+ const url = `${appliance.baseUrl}/console/#bootstrap=${encodeURIComponent(appliance.onboarding.bootstrapNonce)}&session=${encodeURIComponent(starter.sessionId)}`;
287
+ const opened = openBrowser(url);
288
+ if (!opened && !options.json)
289
+ terminal.warn(`Browser launch failed. Open this local URL manually: ${url}`);
290
+ options.onReady(starter.sessionId);
291
+ if (!options.json)
292
+ terminal.outro(`Ready — ${opened ? "console opened" : "console available"} at ${appliance.baseUrl}/console/\nPress Ctrl-C to stop OMA.`);
293
+ await waitForShutdownSignal();
294
+ return { sessionId: starter.sessionId };
295
+ }
296
+ finally {
297
+ if (resumeStateWritten)
298
+ removeResumeState(env, controlToken);
299
+ await appliance.close();
300
+ }
301
+ }
302
+ export async function tryResumeLocalOnboarding(env, terminal, json, dependencies = {}) {
303
+ const fetchImpl = dependencies.fetch ?? fetch;
304
+ const openBrowserImpl = dependencies.openBrowser ?? openBrowser;
305
+ const state = readResumeState(env);
306
+ if (state === undefined)
307
+ return undefined;
308
+ const health = await fetchImpl(new URL("/health", state.base_url), {
309
+ signal: AbortSignal.timeout(1_000),
310
+ }).catch(() => undefined);
311
+ if (health?.ok !== true) {
312
+ removeResumeState(env, state.control_token);
313
+ return undefined;
314
+ }
315
+ const renewed = await fetchImpl(new URL("/console/auth/bootstrap/renew", state.base_url), {
316
+ method: "POST",
317
+ headers: { "x-oma-onboarding-token": state.control_token },
318
+ signal: AbortSignal.timeout(1_000),
319
+ }).catch(() => undefined);
320
+ if (renewed?.ok !== true) {
321
+ removeResumeState(env, state.control_token);
322
+ return undefined;
323
+ }
324
+ const body = await renewed.json();
325
+ if (typeof body.nonce !== "string" || !body.nonce.startsWith("ocb_")) {
326
+ throw new Error("The running OMA appliance returned an invalid onboarding resume response");
327
+ }
328
+ const url = `${state.base_url}/console/#bootstrap=${encodeURIComponent(body.nonce)}&session=${encodeURIComponent(state.session_id)}`;
329
+ const opened = openBrowserImpl(url);
330
+ if (!opened && !json)
331
+ terminal.warn(`Browser launch failed. Open this local URL manually: ${url}`);
332
+ return { sessionId: state.session_id };
333
+ }
334
+ function writeResumeState(env, state) {
335
+ const path = resumeStatePath(env);
336
+ const directory = dirname(path);
337
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
338
+ const directoryStat = lstatSync(directory);
339
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
340
+ throw new Error(`OMA onboarding state directory is unsafe: ${directory}`);
341
+ }
342
+ const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
343
+ try {
344
+ writeFileSync(temporary, `${JSON.stringify(state)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
345
+ renameSync(temporary, path);
346
+ chmodSync(path, 0o600);
347
+ }
348
+ finally {
349
+ rmSync(temporary, { force: true });
350
+ }
351
+ }
352
+ function readResumeState(env) {
353
+ const path = resumeStatePath(env);
354
+ if (!existsSync(path))
355
+ return undefined;
356
+ const stat = lstatSync(path);
357
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
358
+ throw new Error(`OMA onboarding resume state is unsafe: ${path} must be a private regular file`);
359
+ }
360
+ let value;
361
+ try {
362
+ value = JSON.parse(readFileSync(path, "utf8"));
363
+ }
364
+ catch {
365
+ throw new Error(`OMA onboarding resume state is invalid: ${path}`);
366
+ }
367
+ if (value.schema_version !== 1
368
+ || typeof value.pid !== "number"
369
+ || typeof value.base_url !== "string"
370
+ || typeof value.session_id !== "string"
371
+ || typeof value.control_token !== "string") {
372
+ throw new Error(`OMA onboarding resume state is invalid: ${path}`);
373
+ }
374
+ const url = new URL(value.base_url);
375
+ if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname) || url.username || url.password) {
376
+ throw new Error(`OMA onboarding resume state has an unsafe appliance URL: ${value.base_url}`);
377
+ }
378
+ return value;
379
+ }
380
+ function removeResumeState(env, expectedControlToken) {
381
+ const path = resumeStatePath(env);
382
+ if (!existsSync(path))
383
+ return;
384
+ try {
385
+ const current = readResumeState(env);
386
+ if (current?.control_token === expectedControlToken)
387
+ rmSync(path, { force: true });
388
+ }
389
+ catch {
390
+ // Never delete a state file that failed validation or may belong to a
391
+ // newer onboarding process.
392
+ }
393
+ }
394
+ function resumeStatePath(env) {
395
+ return join(env.OMA_HOME?.trim() || join(homedir(), ".oma"), "onboarding-resume.json");
396
+ }
397
+ function isLoopbackHostname(hostname) {
398
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1";
399
+ }
400
+ function openBrowser(url) {
401
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
402
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
403
+ return spawnSync(command, args, { stdio: "ignore" }).status === 0;
404
+ }
405
+ function waitForShutdownSignal() {
406
+ return new Promise((resolve) => {
407
+ const done = () => {
408
+ process.off("SIGINT", done);
409
+ process.off("SIGTERM", done);
410
+ resolve();
411
+ };
412
+ process.once("SIGINT", done);
413
+ process.once("SIGTERM", done);
414
+ });
415
+ }
416
+ function createClackTerminal() {
417
+ return {
418
+ intro: p.intro,
419
+ step: (message) => p.log.success(message),
420
+ warn: (message) => p.log.warn(message),
421
+ error: (message) => p.log.error(message),
422
+ outro: p.outro,
423
+ cancel: p.cancel,
424
+ async select(options) {
425
+ const selected = await p.select(options);
426
+ return p.isCancel(selected) ? ONBOARD_CANCELLED : selected;
427
+ },
428
+ async confirm(options) {
429
+ const confirmed = await p.confirm(options);
430
+ return p.isCancel(confirmed) ? ONBOARD_CANCELLED : confirmed;
431
+ },
432
+ async password(options) {
433
+ const key = await p.password({
434
+ ...options,
435
+ validate: (value) => options.validate(value ?? ""),
436
+ });
437
+ return p.isCancel(key) ? ONBOARD_CANCELLED : key;
438
+ },
439
+ };
440
+ }
441
+ function parseOnboardArgs(argv) {
442
+ const options = { stdin: false, json: false, pull: false, sandbox: "docker-local" };
443
+ for (let index = 0; index < argv.length; index += 1) {
444
+ const arg = argv[index];
445
+ if (arg === "--provider") {
446
+ const provider = argv[++index];
447
+ if (provider === undefined || provider.startsWith("-"))
448
+ throw new Error("--provider requires a provider name");
449
+ options.provider = provider;
450
+ }
451
+ else if (arg === "--stdin") {
452
+ options.stdin = true;
453
+ }
454
+ else if (arg === "--json") {
455
+ options.json = true;
456
+ }
457
+ else if (arg === "--pull") {
458
+ options.pull = true;
459
+ }
460
+ else if (arg === "--sandbox") {
461
+ const sandbox = argv[++index];
462
+ if (sandbox === "docker" || sandbox === "docker-local")
463
+ options.sandbox = "docker-local";
464
+ else if (sandbox === "microsandbox" || sandbox === "microsandbox-local")
465
+ options.sandbox = "microsandbox-local";
466
+ else
467
+ throw new Error("--sandbox requires docker or microsandbox");
468
+ }
469
+ else {
470
+ throw new Error(`Unknown option for oma onboard: ${arg}`);
471
+ }
472
+ }
473
+ return options;
474
+ }
475
+ function onboardingPreflightFailures(report, sandbox, options = {}) {
476
+ const required = ["node.version", "models.catalog", "sandbox.runtime", "server.port"];
477
+ if (sandbox === "docker-local" && !options.allowMissingImage)
478
+ required.push("sandbox.image");
479
+ const failures = report.checks
480
+ .filter((check) => required.includes(check.id) && check.status !== "pass")
481
+ .map((check) => check.summary);
482
+ if (sandbox === "docker-local" && !options.allowMissingImage && report.checks.find((check) => check.id === "sandbox.image")?.status !== "pass") {
483
+ failures.push(`Pull the cached prerequisite image, then retry: docker pull ${DEFAULT_OMA_SANDBOX_IMAGE}`);
484
+ }
485
+ return [...new Set(failures)];
486
+ }
487
+ function parseProviderStatus(output) {
488
+ const rows = output.trim().split("\n").slice(1).filter(Boolean);
489
+ return rows.map((row) => row.split("\t", 1)[0]).filter(Boolean);
490
+ }
491
+ function parseStoredCredential(output, provider) {
492
+ const row = output.trim().split("\n").slice(1).find((line) => line.startsWith(`${provider}\t`));
493
+ if (row === undefined)
494
+ throw new Error(`Provider ${provider} was not returned by auth status`);
495
+ return row.split("\t")[1] === "true";
496
+ }
497
+ async function readOneSecretFromStdin() {
498
+ let value = "";
499
+ input.setEncoding("utf8");
500
+ for await (const chunk of input)
501
+ value += chunk;
502
+ value = value.replace(/\r?\n$/, "");
503
+ if (value.includes("\n") || value.includes("\r"))
504
+ throw new Error("--stdin accepts exactly one API key value");
505
+ return value;
506
+ }
507
+ function cancelled(runtime, json, report) {
508
+ if (json)
509
+ console.log(JSON.stringify({ schema_version: 1, status: "cancelled" }));
510
+ else
511
+ runtime.terminal.cancel("Onboarding cancelled before changing OMA credentials.");
512
+ return { code: 130, credential: "not_reached", report };
513
+ }
514
+ function emitFailure(runtime, json, message) {
515
+ if (json)
516
+ console.log(JSON.stringify({ schema_version: 1, status: "failed", error: message }));
517
+ else
518
+ runtime.terminal.error(message);
519
+ }
520
+ function emitSuccess(runtime, json, provider, credential, sessionId) {
521
+ if (json) {
522
+ console.log(JSON.stringify({ schema_version: 1, status: "ready", provider, credential, session_id: sessionId }));
523
+ return;
524
+ }
525
+ const verb = credential === "stored" ? "stored securely" : "already stored";
526
+ runtime.terminal.step(`Credential ${verb}.`);
527
+ }
528
+ function emitResumed(runtime, json, sessionId) {
529
+ if (json) {
530
+ console.log(JSON.stringify({ schema_version: 1, status: "ready", resumed: true, session_id: sessionId }));
531
+ }
532
+ else {
533
+ runtime.terminal.outro("Existing local OMA session reopened.");
534
+ }
535
+ }
536
+ function safeMessage(error) {
537
+ return error instanceof Error ? error.message : String(error);
538
+ }
539
+ async function main() {
540
+ const result = await runOnboard(process.argv.slice(2), createProcessOnboardRuntime());
541
+ process.exitCode = result.code;
542
+ }
543
+ if (import.meta.main)
544
+ void main();
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { MANAGED_AGENTS_BETA } from "../src/control-plane/api-constants.js";
3
+ const MARKER = "oma.onboarding";
4
+ const MARKER_VERSION = "starter-v1";
5
+ export async function ensureStarterResources(input) {
6
+ const request = createApiRequest(input.baseUrl, input.workspaceKey, input.fetch ?? fetch);
7
+ const warnings = [];
8
+ const created = [];
9
+ const models = await listAll(request, `/v1/model-catalog?available=true&provider=${encodeURIComponent(input.provider)}`);
10
+ const model = models.find((item) => item.default) ?? models[0];
11
+ if (model === undefined)
12
+ throw new Error(`No credential-ready model is available for provider ${input.provider}`);
13
+ const agentRows = await listAll(request, "/v1/agents?include_archived=true");
14
+ const markedAgents = agentRows.filter((item) => hasMarker(item, "agent"));
15
+ let agent = markedAgents.find((item) => item.archived_at === null && item.model.provider === model.provider && item.model.id === model.id);
16
+ if (agent === undefined) {
17
+ if (markedAgents.length > 0)
18
+ warnings.push("The previous starter agent was archived or used a different model; a compatible starter agent was created.");
19
+ agent = await request("/v1/agents", {
20
+ method: "POST",
21
+ body: {
22
+ name: "OMA Starter",
23
+ model: { provider: model.provider, id: model.id },
24
+ description: "Local starter agent created by oma onboard.",
25
+ system: "You are the local Open Managed Agents starter. Help the operator explore this workspace clearly and safely.",
26
+ tools: [], skills: [], mcp_servers: [],
27
+ metadata: marker("agent"),
28
+ },
29
+ });
30
+ created.push("agent");
31
+ }
32
+ const environmentRows = await listAll(request, "/v1/environments?include_archived=true");
33
+ const markedEnvironments = environmentRows.filter((item) => hasMarker(item, "environment"));
34
+ let environment = markedEnvironments.find((item) => item.archived_at === null && isOffline(item.config));
35
+ if (environment === undefined) {
36
+ if (markedEnvironments.length > 0)
37
+ warnings.push("The previous starter environment was archived or no longer offline; a new offline environment was created.");
38
+ environment = await request("/v1/environments", {
39
+ method: "POST",
40
+ body: {
41
+ name: "OMA Starter (offline)",
42
+ config: { type: "cloud", networking: { type: "limited", allowed_hosts: [] } },
43
+ metadata: marker("environment"),
44
+ },
45
+ });
46
+ created.push("environment");
47
+ }
48
+ const sessionRows = await listAll(request, "/v1/sessions?include_archived=true");
49
+ const markedSessions = sessionRows.filter((item) => hasMarker(item, "session"));
50
+ let session = markedSessions.find((item) => item.archived_at === null && item.agent.id === agent.id && item.environment_id === environment.id);
51
+ if (session === undefined) {
52
+ if (markedSessions.length > 0)
53
+ warnings.push("The previous starter session was archived or referenced older starter resources; a new session was created.");
54
+ session = await request("/v1/sessions", {
55
+ method: "POST",
56
+ headers: { "idempotency-key": idempotencyKey(agent.id, environment.id) },
57
+ body: {
58
+ agent: agent.id,
59
+ environment_id: environment.id,
60
+ title: "Your first OMA session",
61
+ metadata: marker("session"),
62
+ },
63
+ });
64
+ created.push("session");
65
+ }
66
+ return {
67
+ agentId: agent.id,
68
+ environmentId: environment.id,
69
+ sessionId: session.id,
70
+ model: { provider: model.provider, id: model.id },
71
+ created,
72
+ warnings,
73
+ };
74
+ }
75
+ function marker(resource) {
76
+ return { [MARKER]: MARKER_VERSION, "oma.onboarding.resource": resource };
77
+ }
78
+ function hasMarker(resource, kind) {
79
+ return resource.metadata?.[MARKER] === MARKER_VERSION && resource.metadata["oma.onboarding.resource"] === kind;
80
+ }
81
+ function isOffline(config) {
82
+ const networking = config.networking;
83
+ return networking?.type === "limited" && Array.isArray(networking.allowed_hosts) && networking.allowed_hosts.length === 0;
84
+ }
85
+ function idempotencyKey(agentId, environmentId) {
86
+ return `oma-onboard-${createHash("sha256").update(`${MARKER_VERSION}:${agentId}:${environmentId}`).digest("hex").slice(0, 32)}`;
87
+ }
88
+ function createApiRequest(baseUrl, workspaceKey, fetchImpl) {
89
+ return async function request(path, options = {}) {
90
+ const response = await fetchImpl(new URL(path, baseUrl), {
91
+ method: options.method ?? "GET",
92
+ headers: {
93
+ accept: "application/json",
94
+ "anthropic-beta": MANAGED_AGENTS_BETA,
95
+ "x-api-key": workspaceKey,
96
+ ...(options.body === undefined ? {} : { "content-type": "application/json" }),
97
+ ...options.headers,
98
+ },
99
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
100
+ });
101
+ const body = await response.json().catch(() => undefined);
102
+ if (!response.ok) {
103
+ const message = typeof body === "object" && body !== null && "error" in body
104
+ ? body.error?.message
105
+ : undefined;
106
+ throw new Error(message ?? `OMA API ${options.method ?? "GET"} ${path} failed with HTTP ${response.status}`);
107
+ }
108
+ return body;
109
+ };
110
+ }
111
+ async function listAll(request, initialPath) {
112
+ const data = [];
113
+ let path = addLimit(initialPath);
114
+ for (let pageCount = 0; path !== undefined && pageCount < 100; pageCount += 1) {
115
+ const page = await request(path);
116
+ data.push(...page.data);
117
+ path = page.has_more && page.next_page ? addPage(initialPath, page.next_page) : undefined;
118
+ }
119
+ return data;
120
+ }
121
+ function addLimit(path) {
122
+ const url = new URL(path, "http://oma.local");
123
+ url.searchParams.set("limit", "100");
124
+ return `${url.pathname}${url.search}`;
125
+ }
126
+ function addPage(path, page) {
127
+ const url = new URL(path, "http://oma.local");
128
+ url.searchParams.set("limit", "100");
129
+ url.searchParams.set("page", page);
130
+ return `${url.pathname}${url.search}`;
131
+ }
@@ -274,6 +274,32 @@ function registerConsoleSessionRoutes(app, services) {
274
274
  c.header("cache-control", "no-store");
275
275
  return c.json({ workspace: consoleWorkspace(session.workspaceId, consoleAuth.service) });
276
276
  });
277
+ if (consoleAuth.bootstrap !== undefined) {
278
+ app.post("/console/auth/bootstrap/renew", async (c) => {
279
+ const controlToken = c.req.header("x-oma-onboarding-token");
280
+ if (controlToken === undefined)
281
+ return consoleAuthenticationFailed(c);
282
+ const nonce = consoleAuth.bootstrap.issueForControlToken(controlToken);
283
+ if (nonce === undefined)
284
+ return consoleAuthenticationFailed(c);
285
+ c.header("cache-control", "no-store");
286
+ return c.json({ nonce });
287
+ });
288
+ app.post("/console/auth/bootstrap", async (c) => {
289
+ if (!sameOriginForCookieWrite(c))
290
+ return c.text("Forbidden\n", 403);
291
+ const nonce = stringField(await parseJsonBody(c.req), "nonce");
292
+ const workspaceKey = consoleAuth.bootstrap.consume(nonce);
293
+ if (workspaceKey === undefined)
294
+ return consoleAuthenticationFailed(c);
295
+ const session = consoleAuth.service.createWorkspaceSession(workspaceKey);
296
+ if (session === undefined)
297
+ return consoleAuthenticationFailed(c);
298
+ setConsoleCookie(c, CONSOLE_WORKSPACE_COOKIE, session.token, consoleAuth.secureCookies, 30 * 24 * 60 * 60);
299
+ c.header("cache-control", "no-store");
300
+ return c.json({ workspace: consoleWorkspace(session.workspaceId, consoleAuth.service) });
301
+ });
302
+ }
277
303
  app.post("/console/auth/admin", async (c) => {
278
304
  if (!sameOriginForCookieWrite(c) || services.admin === undefined)
279
305
  return c.text("Not found\n", 404);
@@ -359,6 +385,12 @@ export function createDeploymentControlPlaneApp(env = process.env, opts = {}) {
359
385
  // tests that boot twice against one OMA_HOME) can close them and release the
360
386
  // .oma.lock instead of leaking them until process exit.
361
387
  export function createDeploymentControlPlane(env = process.env, opts = {}, internal = {}) {
388
+ if (opts.consoleBootstrap !== undefined && !isLoopbackHost(env.OMA_HOST)) {
389
+ throw new Error("Console bootstrap is available only on a loopback appliance bind");
390
+ }
391
+ if (opts.consoleBootstrap !== undefined && env.OMA_TLS_TERMINATED === "1") {
392
+ throw new Error("Console bootstrap is not available behind TLS termination");
393
+ }
362
394
  const runtimeConfig = parseDeploymentRuntimeConfigFromEnv(env);
363
395
  const modelConfig = parseModelDeploymentConfigFromEnv(env);
364
396
  const authMode = parseDeploymentAuthMode(env);
@@ -642,6 +674,7 @@ export function createDeploymentControlPlane(env = process.env, opts = {}, inter
642
674
  consoleSessionAuth: {
643
675
  service: consoleSessionAuth,
644
676
  secureCookies: tlsTerminated,
677
+ ...(opts.consoleBootstrap === undefined ? {} : { bootstrap: opts.consoleBootstrap }),
645
678
  },
646
679
  ...(openapiRoot === undefined ? {} : { openapi: { root: openapiRoot } }),
647
680
  ...(authMode === "api-key"
@@ -0,0 +1,59 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
3
+ /**
4
+ * Process-local bearer grants used only to exchange the onboarding browser
5
+ * handoff for the ordinary opaque console cookie. Grants are never persisted,
6
+ * are stored by hash, and are removed before their workspace key is returned.
7
+ */
8
+ export class ConsoleBootstrapService {
9
+ now;
10
+ ttlMs;
11
+ grants = new Map();
12
+ authority;
13
+ constructor(now = Date.now, ttlMs = DEFAULT_TTL_MS) {
14
+ this.now = now;
15
+ this.ttlMs = ttlMs;
16
+ }
17
+ issue(workspaceKey) {
18
+ const nonce = `ocb_${randomBytes(32).toString("base64url")}`;
19
+ this.grants.set(hash(nonce), {
20
+ workspaceKey,
21
+ expiresAt: this.now() + this.ttlMs,
22
+ });
23
+ return nonce;
24
+ }
25
+ bindResumeAuthority(workspaceKey, controlToken) {
26
+ this.authority = {
27
+ workspaceKey,
28
+ controlTokenHash: digest(controlToken),
29
+ };
30
+ }
31
+ issueForControlToken(controlToken) {
32
+ if (this.authority === undefined)
33
+ return undefined;
34
+ const candidate = digest(controlToken);
35
+ if (!timingSafeEqual(candidate, this.authority.controlTokenHash))
36
+ return undefined;
37
+ return this.issue(this.authority.workspaceKey);
38
+ }
39
+ consume(nonce) {
40
+ const key = hash(nonce);
41
+ const grant = this.grants.get(key);
42
+ // Delete before validating/returning so every attempted consumption is
43
+ // terminal, including an expired grant or an invalid downstream key.
44
+ this.grants.delete(key);
45
+ if (grant === undefined || grant.expiresAt <= this.now())
46
+ return undefined;
47
+ return grant.workspaceKey;
48
+ }
49
+ clear() {
50
+ this.grants.clear();
51
+ this.authority = undefined;
52
+ }
53
+ }
54
+ function hash(value) {
55
+ return createHash("sha256").update(value).digest("hex");
56
+ }
57
+ function digest(value) {
58
+ return createHash("sha256").update(value).digest();
59
+ }
@@ -18,6 +18,7 @@ export class DefaultEnvironmentService {
18
18
  type: "environment",
19
19
  name: req.name,
20
20
  config: req.config,
21
+ metadata: req.metadata ?? {},
21
22
  created_at: now,
22
23
  updated_at: now,
23
24
  archived_at: null,
@@ -83,7 +84,7 @@ function parseCreateEnvironment(input) {
83
84
  }
84
85
  throw error;
85
86
  }
86
- return { name, config: canonicalConfig };
87
+ return { name, config: canonicalConfig, metadata: metadataField(obj) };
87
88
  }
88
89
  function toManagedEnvironment(row) {
89
90
  return {
@@ -91,11 +92,26 @@ function toManagedEnvironment(row) {
91
92
  type: row.type,
92
93
  name: row.name,
93
94
  config: row.config,
95
+ metadata: row.metadata ?? {},
94
96
  created_at: row.created_at,
95
97
  updated_at: row.updated_at,
96
98
  archived_at: row.archived_at,
97
99
  };
98
100
  }
101
+ function metadataField(obj) {
102
+ const value = obj.metadata;
103
+ if (value === undefined)
104
+ return undefined;
105
+ if (!isJsonObject(value))
106
+ throw invalidRequest("`metadata` must be an object");
107
+ const metadata = {};
108
+ for (const [key, item] of Object.entries(value)) {
109
+ if (typeof item !== "string")
110
+ throw invalidRequest("`metadata` values must be strings");
111
+ metadata[key] = item;
112
+ }
113
+ return metadata;
114
+ }
99
115
  function objectInput(input) {
100
116
  if (!isJsonObject(input)) {
101
117
  throw invalidRequest("Request body must be a JSON object");
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS environments (
6
6
  type TEXT NOT NULL,
7
7
  name TEXT NOT NULL,
8
8
  config TEXT NOT NULL,
9
+ metadata TEXT NOT NULL DEFAULT '{}',
9
10
  created_at TEXT NOT NULL,
10
11
  updated_at TEXT NOT NULL,
11
12
  archived_at TEXT
@@ -26,9 +27,10 @@ export class SqliteEnvironmentStore {
26
27
  constructor(db) {
27
28
  this.db = db;
28
29
  this.db.exec(SCHEMA);
30
+ migrateMetadata(this.db);
29
31
  this.insertStmt = this.db.prepare(`INSERT INTO environments (
30
- id, workspace_id, type, name, config, created_at, updated_at, archived_at
31
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
32
+ id, workspace_id, type, name, config, metadata, created_at, updated_at, archived_at
33
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
32
34
  this.retrieveActiveStmt = this.db.prepare(`SELECT * FROM environments
33
35
  WHERE workspace_id = ? AND id = ? AND archived_at IS NULL`);
34
36
  this.retrieveAnyStmt = this.db.prepare(`SELECT * FROM environments WHERE workspace_id = ? AND id = ?`);
@@ -59,7 +61,7 @@ export class SqliteEnvironmentStore {
59
61
  }
60
62
  create(record) {
61
63
  const e = record.row;
62
- this.insertStmt.run(e.id, e.workspace_id, e.type, e.name, JSON.stringify(e.config), e.created_at, e.updated_at, e.archived_at);
64
+ this.insertStmt.run(e.id, e.workspace_id, e.type, e.name, JSON.stringify(e.config), JSON.stringify(e.metadata ?? {}), e.created_at, e.updated_at, e.archived_at);
63
65
  return e;
64
66
  }
65
67
  retrieve(workspaceId, environmentId) {
@@ -125,8 +127,15 @@ function deserialize(row) {
125
127
  type: "environment",
126
128
  name: row.name,
127
129
  config: JSON.parse(row.config),
130
+ metadata: JSON.parse(row.metadata),
128
131
  created_at: row.created_at,
129
132
  updated_at: row.updated_at,
130
133
  archived_at: row.archived_at,
131
134
  };
132
135
  }
136
+ function migrateMetadata(db) {
137
+ const columns = db.prepare("PRAGMA table_info(environments)").all();
138
+ if (!columns.some((column) => column.name === "metadata")) {
139
+ db.exec("ALTER TABLE environments ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'");
140
+ }
141
+ }
@@ -167,8 +167,8 @@ const schemas = {
167
167
  CreateAgentRequest: { type: "object", required: ["name", "model"], properties: { name: { type: "string", minLength: 1 }, model: ref("ModelInput"), system: ref("NullableString"), description: ref("NullableString"), tools: { type: "array", items: ref("JsonObject") }, skills: { type: "array", items: ref("JsonObject") }, mcp_servers: { type: "array", items: ref("JsonObject") }, metadata: ref("StringMap"), multiagent: { type: "null", description: "Full multi-agent runtime is not implemented. Omit this field or send null." } }, additionalProperties: false },
168
168
  UpdateAgentRequest: { type: "object", required: ["version"], properties: { version: { type: "integer", minimum: 1 }, name: { type: "string", minLength: 1 }, model: ref("ModelInput"), system: ref("NullableString"), description: ref("NullableString"), tools: { type: ["array", "null"], items: ref("JsonObject") }, skills: { type: ["array", "null"], items: ref("JsonObject") }, mcp_servers: { type: ["array", "null"], items: ref("JsonObject") }, metadata: { type: "object", additionalProperties: { type: ["string", "null"] } }, multiagent: { type: "null", description: "Full multi-agent runtime is not implemented. Omit this field or send null." } }, additionalProperties: false },
169
169
  Agent: { type: "object", required: ["id", "type", "name", "model", "version", "created_at", "updated_at", "archived_at"], properties: { id: { type: "string" }, type: { const: "agent" }, name: { type: "string" }, model: { type: "object", required: ["provider", "id", "speed"], properties: { provider: { type: "string" }, id: { type: "string" }, speed: { enum: ["standard", "fast"] } }, additionalProperties: false }, system: ref("NullableString"), description: ref("NullableString"), tools: { type: "array", items: ref("JsonObject") }, skills: { type: "array", items: ref("JsonObject") }, mcp_servers: { type: "array", items: ref("JsonObject") }, metadata: ref("StringMap"), multiagent: { type: ["object", "null"] }, version: { type: "integer", minimum: 1 }, created_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" }, archived_at: { type: ["string", "null"], format: "date-time" } }, additionalProperties: false },
170
- CreateEnvironmentRequest: { type: "object", required: ["name", "config"], properties: { name: { type: "string", minLength: 1 }, config: ref("JsonObject") }, additionalProperties: false },
171
- Environment: { type: "object", required: ["id", "type", "name", "config", "created_at", "updated_at", "archived_at"], properties: { id: { type: "string" }, type: { const: "environment" }, name: { type: "string" }, config: ref("JsonObject"), created_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" }, archived_at: { type: ["string", "null"], format: "date-time" } } },
170
+ CreateEnvironmentRequest: { type: "object", required: ["name", "config"], properties: { name: { type: "string", minLength: 1 }, config: ref("JsonObject"), metadata: ref("StringMap") }, additionalProperties: false },
171
+ Environment: { type: "object", required: ["id", "type", "name", "config", "metadata", "created_at", "updated_at", "archived_at"], properties: { id: { type: "string" }, type: { const: "environment" }, name: { type: "string" }, config: ref("JsonObject"), metadata: ref("StringMap"), created_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" }, archived_at: { type: ["string", "null"], format: "date-time" } } },
172
172
  DeletedEnvironment: { type: "object", required: ["type", "id"], properties: { type: { const: "environment_deleted" }, id: { type: "string" } }, additionalProperties: false },
173
173
  EnvironmentNetworkingPresetCatalog: { type: "object", required: ["type", "deployment", "presets", "custom"], properties: { type: { const: "environment_networking_presets" }, deployment: { type: "object", required: ["provider", "egress_supported", "reason"], properties: { provider: { type: ["string", "null"] }, egress_supported: { type: "boolean" }, reason: { type: ["string", "null"] } }, additionalProperties: false }, presets: { type: "array", items: { type: "object", required: ["id", "version", "name", "description", "networking"], properties: { id: { type: "string" }, version: { type: "integer", minimum: 1 }, name: { type: "string" }, description: { type: "string" }, networking: { type: "object", required: ["type", "allowed_hosts"], properties: { type: { const: "limited" }, allowed_hosts: { type: "array", items: { type: "string" } } }, additionalProperties: false } }, additionalProperties: false } }, custom: { type: "object", required: ["https_only", "wildcard_matches_bare_domain"], properties: { https_only: { const: true }, wildcard_matches_bare_domain: { const: false } }, additionalProperties: false } }, additionalProperties: false },
174
174
  ValidateEnvironmentNetworkingHostsRequest: { type: "object", required: ["allowed_hosts"], properties: { allowed_hosts: { type: "array", items: { type: "string" } } }, additionalProperties: false },
package/dist/src/main.js CHANGED
@@ -50,7 +50,9 @@ export async function startAppliance(env = process.env, opts = {}) {
50
50
  const resolved = resolveApplianceEnv(env);
51
51
  const port = parseAppliancePort(resolved.OMA_PORT);
52
52
  const host = resolved.OMA_HOST ?? "127.0.0.1";
53
- const plane = createDeploymentControlPlane(resolved);
53
+ const plane = createDeploymentControlPlane(resolved, {
54
+ ...(opts.onboarding === undefined ? {} : { consoleBootstrap: opts.onboarding.bootstrap }),
55
+ });
54
56
  const { app, stores, authMode } = plane;
55
57
  let server;
56
58
  try {
@@ -70,7 +72,13 @@ export async function startAppliance(env = process.env, opts = {}) {
70
72
  // skip minting — an unprinted key locking the operator out. A key that
71
73
  // prints but never serves only costs a retry; the reverse costs the
72
74
  // quickstart.
73
- const minted = authMode === "api-key" && stores.workspaces.countApiKeys() === 0
75
+ const onboardingKey = authMode === "api-key" && opts.onboarding !== undefined
76
+ ? mintOnboardingKey(stores.workspaces)
77
+ : undefined;
78
+ if (onboardingKey !== undefined) {
79
+ opts.onboarding.bootstrap.bindResumeAuthority(onboardingKey.plaintextKey, opts.onboarding.controlToken);
80
+ }
81
+ const minted = onboardingKey === undefined && authMode === "api-key" && stores.workspaces.countApiKeys() === 0
74
82
  ? stores.workspaces.mintKey(DEFAULT_WORKSPACE_ID, "first-boot")
75
83
  : undefined;
76
84
  const baseUrl = `http://${host}:${boundPort}`;
@@ -93,13 +101,26 @@ export async function startAppliance(env = process.env, opts = {}) {
93
101
  }
94
102
  return {
95
103
  port: boundPort,
104
+ baseUrl,
105
+ ...(onboardingKey === undefined
106
+ ? {}
107
+ : {
108
+ onboarding: {
109
+ workspaceKey: onboardingKey.plaintextKey,
110
+ bootstrapNonce: opts.onboarding.bootstrap.issue(onboardingKey.plaintextKey),
111
+ },
112
+ }),
96
113
  close: async () => {
114
+ opts.onboarding?.bootstrap.clear();
115
+ if (onboardingKey !== undefined)
116
+ stores.workspaces.revokeKey(onboardingKey.keySha256);
97
117
  await closeServer(bound.server);
98
118
  await plane.close();
99
119
  },
100
120
  };
101
121
  }
102
122
  catch (error) {
123
+ opts.onboarding?.bootstrap.clear();
103
124
  // Failed startups must release everything (notably .oma.lock) so the
104
125
  // operator's retry is a clean boot, not a lock error.
105
126
  if (server !== undefined) {
@@ -109,6 +130,14 @@ export async function startAppliance(env = process.env, opts = {}) {
109
130
  throw error;
110
131
  }
111
132
  }
133
+ function mintOnboardingKey(workspaces) {
134
+ for (const key of workspaces.listKeys(DEFAULT_WORKSPACE_ID)) {
135
+ if (key.label === "onboarding-console" && key.revoked_at === null) {
136
+ workspaces.revokeKey(key.key_sha256);
137
+ }
138
+ }
139
+ return workspaces.mintKey(DEFAULT_WORKSPACE_ID, "onboarding-console");
140
+ }
112
141
  function closeServer(server) {
113
142
  return new Promise((resolvePromise, reject) => {
114
143
  server.close((err) => (err ? reject(err) : resolvePromise()));
@@ -299,6 +299,10 @@ export function loginConsoleWorkspace(apiKey) {
299
299
  return request("/console/auth/workspace", { method: "POST", body: { api_key: apiKey } });
300
300
  }
301
301
 
302
+ export function consumeConsoleBootstrap(nonce) {
303
+ return request("/console/auth/bootstrap", { method: "POST", body: { nonce } });
304
+ }
305
+
302
306
  export function loginConsoleAdmin(adminKey) {
303
307
  return request("/console/auth/admin", { method: "POST", body: { admin_key: adminKey } });
304
308
  }
@@ -707,6 +711,9 @@ function toUiEnvironment(environment) {
707
711
  allowedHosts,
708
712
  created: shortDate(environment.created_at),
709
713
  config: environment.config ?? {},
714
+ metadata: environment.metadata && typeof environment.metadata === "object"
715
+ ? environment.metadata
716
+ : {},
710
717
  archived: Boolean(environment.archived_at),
711
718
  };
712
719
  }
@@ -1010,6 +1017,7 @@ if (typeof window !== "undefined") {
1010
1017
  revokeKey,
1011
1018
  getConsoleAuthStatus,
1012
1019
  loginConsoleWorkspace,
1020
+ consumeConsoleBootstrap,
1013
1021
  loginConsoleAdmin,
1014
1022
  selectConsoleWorkspace,
1015
1023
  logoutConsole,
@@ -127,6 +127,19 @@ function readRouteTarget(sessions, agents) {
127
127
  return null;
128
128
  }
129
129
 
130
+ function takeBootstrapHandoff() {
131
+ const rawHash = window.location.hash.replace(/^#/, '');
132
+ const params = new URLSearchParams(rawHash);
133
+ const nonce = params.get('bootstrap');
134
+ if (!nonce) return null;
135
+ params.delete('bootstrap');
136
+ const nextHash = params.toString();
137
+ // Clear the bearer value before the network exchange so reload, copy/paste,
138
+ // browser history, and later application code cannot recover it.
139
+ window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : '#start'}`);
140
+ return nonce;
141
+ }
142
+
130
143
  function App() {
131
144
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
132
145
  const demoMode = new URLSearchParams(window.location.search).get('mode') === 'demo';
@@ -191,7 +204,12 @@ function App() {
191
204
  if (target) setRoute(target);
192
205
  return () => { alive = false; };
193
206
  }
194
- OmaConsoleApi.getConsoleAuthStatus()
207
+ const bootstrapNonce = takeBootstrapHandoff();
208
+ const bootstrap = bootstrapNonce
209
+ ? OmaConsoleApi.consumeConsoleBootstrap(bootstrapNonce)
210
+ : Promise.resolve(undefined);
211
+ bootstrap
212
+ .then(() => OmaConsoleApi.getConsoleAuthStatus())
195
213
  .then((status) => {
196
214
  if (!alive) return undefined;
197
215
  setAuth({ phase: status.workspace || !status.auth_required || status.admin ? 'ready' : 'login', admin:status.admin, workspace:status.workspace, error:null, busy:false });
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "open-managed-agents",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Self-hostable implementation of Anthropic's Managed Agents API surface.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/oneryalcin/open-managed-agents.git"
8
+ },
9
+ "homepage": "https://github.com/oneryalcin/open-managed-agents#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/oneryalcin/open-managed-agents/issues"
12
+ },
5
13
  "type": "module",
6
14
  "bin": {
7
15
  "oma": "bin/oma.mjs"
@@ -21,6 +29,7 @@
21
29
  "typecheck": "tsc --noEmit",
22
30
  "build:package": "node scripts/package-build.mjs",
23
31
  "package:verify": "node scripts/package-verify.mjs",
32
+ "npm:publish": "node scripts/npm-publish.mjs",
24
33
  "prepack": "npm run build:package",
25
34
  "alpha:smoke": "node scripts/alpha-smoke.mjs",
26
35
  "alpha:smoke:providers": "node scripts/alpha-provider-smoke-matrix.mjs",
@@ -28,11 +37,13 @@
28
37
  "ui:dev": "node ui/managed-agents-console/serve.mjs"
29
38
  },
30
39
  "dependencies": {
40
+ "@clack/prompts": "1.7.0",
31
41
  "@earendil-works/pi-coding-agent": "0.80.6",
32
42
  "@hono/node-server": "2.0.0",
33
43
  "@modelcontextprotocol/sdk": "1.29.0",
34
44
  "hono": "4.12.29",
35
45
  "node-forge": "1.4.0",
46
+ "picocolors": "1.1.1",
36
47
  "proper-lockfile": "4.1.2",
37
48
  "typebox": "1.1.29",
38
49
  "undici": "7.28.0",