antechamber 0.1.0 → 0.2.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +17 -14
  3. package/bin/opencode-antechamber +10 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +21 -3
  18. package/dist/pi-event-translation.js +317 -65
  19. package/dist/pi-rpc.d.ts +26 -2
  20. package/dist/pi-rpc.js +102 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +110 -65
  26. package/dist/routes-v2.js +376 -73
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +20 -15
  30. package/dist/session-commands.js +61 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +113 -42
  34. package/dist/session-hosts.js +2 -2
  35. package/dist/session-store.d.ts +8 -4
  36. package/dist/session-store.js +183 -33
  37. package/dist/session-tree.d.ts +9 -2
  38. package/dist/session-tree.js +51 -63
  39. package/dist/sessions.d.ts +34 -6
  40. package/dist/sessions.js +340 -146
  41. package/dist/sse.d.ts +16 -6
  42. package/dist/sse.js +77 -21
  43. package/dist/subagents.d.ts +5 -0
  44. package/dist/subagents.js +86 -69
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +164 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +141 -50
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/extensions/omo-dag/README.md +2 -0
  52. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  53. package/extensions/omo-dag/panel/index.html +1 -1
  54. package/extensions/omo-dag/panel/main.js +60 -60
  55. package/extensions/omo-dag/service/main.js +72 -8
  56. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,57 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - feat(api): add v2 credential, integration, file-search, VCS, and synthetic-context routes
15
+ - feat(events): replay retained SSE events and expose v2 shell, compaction, and execution terminals
16
+
17
+ ### Fixed
18
+
19
+ - fix(session): preserve rich prompt parts, metadata, ancestry, and part identities across reloads
20
+ - fix(runtime): retain operation ordering after caller cancellation and finalize interrupted tool cards
21
+ - fix(rpc): close failed transports and reject responses correlated to the wrong command
22
+ - fix(http): allow passwordless loopback origins across ports while retaining explicit origin checks
23
+ - fix(security): validate registry records and write registry state with private permissions
24
+ - fix(cli): advertise OpenCode API compatibility as 2.0.20
25
+ - fix(api): return OpenCode 2.x bootstrap shapes for `/api/config`, `/api/shell`, `/api/websearch/provider` and `GET /api/mcp` so OpenChamber 2.3.2 can initialize workspaces
26
+ - fix(api): answer v2 synthetic context admission with the admitted record and return the bare interrupt flag, so OpenChamber 2.3.2 can send prompts and stop turns
27
+ - fix(extension): bound graph responses and display omitted-node counts
28
+ - fix(api): carry v2 prompt attachments, agent mentions and metadata into OmO's input, admission events and message history, and answer skill references with OpenCode's tagged not-found error
29
+ - fix(session): admit synthetic context and v1 `noReply` messages into OmO's conversation without a turn, keep them after restarts even when no prompt follows, and stop context from titling sessions
30
+ - fix(events): keep an unscoped v2 `/api/event` stream global, as OpenChamber 2.3.2 subscribes
31
+ - fix(rpc): after the agent exits during shutdown, kill only re-identified members of its process group
32
+
33
+ ### Changed
34
+
35
+ - docs(api): document route statuses, response envelopes, stream framing, and unsupported capabilities
36
+ - docs(maintainers): correct smoke paths, typecheck commands, and release guidance
37
+
38
+ ## [0.1.1] - 2026-10-14
39
+
40
+ ### Added
41
+
42
+ - Sessions now report `cost` (USD) from OmO's per-message usage, live and from
43
+ history, on v1 session payloads and v2 session reads. Subagent sessions carry
44
+ their own cost, so OpenChamber's work-status panel shows the session total
45
+ with its session/subagents breakdown.
46
+
47
+ ### Fixed
48
+
49
+ - `/compact` now shows progress and its result. The session turns busy while
50
+ OmO compacts, and the summary appears as a `/compact` turn with a summary
51
+ reply, live and after reload. The compact RPC now waits up to OmO's own
52
+ 30-minute compaction budget instead of giving up after 60 seconds, which left
53
+ the compaction running unseen until a later abort reported it cancelled.
54
+ - Reverting no longer adds a "Conversation reverted. Files were not changed."
55
+ card to the transcript; OpenChamber's own reverted-message dock (driven by
56
+ `session.revert`) already shows the state.
57
+ - Sessions opened with a slash command (for example `/skill:plan Build the
58
+ importer`) now get a generated title. OmO skips titling slash-led prompts, so
59
+ the adapter passes the command's arguments as the native `sessionTitlePrompt`.
60
+
10
61
  ## [0.1.0] - 2026-10-14
11
62
 
12
63
  First standalone release under the `antechamber` name.
package/README.md CHANGED
@@ -55,15 +55,15 @@ another executable (see [docs/configuration.md](docs/configuration.md)).
55
55
 
56
56
  | OpenChamber | API | Status |
57
57
  |---|---|---|
58
- | >= 2.0.4 | v2 (`@opencode-ai/sdk/v2`) | primary target; antechamber advertises `opencode v2.0.18` on `--version` |
58
+ | >= 2.0.4 | v2 (`@opencode-ai/sdk/v2`) | primary target; antechamber advertises `opencode v2.0.20` on `--version` |
59
59
  | 1.24.x | v1 | supported |
60
60
  | other versions | - | not tested |
61
61
 
62
62
  OpenChamber changes which OpenCode API calls it makes between releases. If a
63
63
  feature breaks on a newer OpenChamber, run `npm run smoke` to see which check
64
64
  failed, then open an issue with the output. See
65
- [docs/api-surface.md](docs/api-surface.md) for the exact endpoints antechamber
66
- implements.
65
+ [docs/api-surface.md](docs/api-surface.md) for the maintained route, response,
66
+ and event-stream inventory.
67
67
 
68
68
  ## Quick start
69
69
 
@@ -76,10 +76,6 @@ antechamber doctor
76
76
  antechamber setup
77
77
  ```
78
78
 
79
- The first npm publication (0.1.0) is part of the
80
- [release process](docs/releasing.md). If it is not on the registry yet, use
81
- [From source](#from-source) below.
82
-
83
79
  `setup` updates OpenChamber's `opencodeBinary`, preserves all other settings,
84
80
  and backs up an existing settings file before changing it. Restart OpenChamber
85
81
  after setup. Preview changes with `antechamber setup --dry-run`.
@@ -137,7 +133,7 @@ antechamber serve --hostname 127.0.0.1 --port 4096
137
133
  | `antechamber serve` | OpenCode-compatible server; `--hostname`/`--host`, `--port`, `--directory`. Bare `antechamber` also starts the server. |
138
134
  | `antechamber setup` | Configure OpenChamber; `--settings <file>`, `--profile <dir>`, `--binary <path>`, `--dry-run`, `--systemd [unit]`, `--pi-binary <path>`, `--restart`. |
139
135
  | `antechamber doctor` | Read-only Node, backend, agent/state directories, teardown, OpenChamber and auth checks; `--json`, `--settings <file>`, `--profile <dir>`. Exits 1 on any FAIL; warnings do not fail. |
140
- | `antechamber version` | Package version plus OpenCode API contract version. `--version`/`-v` deliberately print exactly `opencode v2.0.18` for OpenChamber. |
136
+ | `antechamber version` | Package version plus OpenCode API contract version. `--version`/`-v` deliberately print exactly `opencode v2.0.20` for OpenChamber. |
141
137
  | `antechamber help` | Usage and options; also `--help`/`-h`. |
142
138
 
143
139
  ## Upgrade, uninstall and rollback
@@ -218,9 +214,11 @@ extension, and refuses it as an invalid manifest. Details:
218
214
 
219
215
  ## Configuration
220
216
 
221
- All settings are environment variables prefixed `ANTECHAMBER_`. The
222
- deprecated `OCPI_` prefix is still accepted as a fallback for existing
223
- deployments. Full reference, defaults, and units: [docs/configuration.md](docs/configuration.md).
217
+ Connector settings use the `ANTECHAMBER_` prefix; the deprecated `OCPI_`
218
+ prefix is still accepted as a fallback. RPC capability passthrough also reads
219
+ `OMO_RPC_CLIENT_CAPABILITIES` and `SENPI_RPC_CLIENT_CAPABILITIES` directly.
220
+ Full reference, defaults, and units:
221
+ [docs/configuration.md](docs/configuration.md).
224
222
 
225
223
  | Variable | Purpose |
226
224
  |---|---|
@@ -231,6 +229,7 @@ deployments. Full reference, defaults, and units: [docs/configuration.md](docs/c
231
229
  | `ANTECHAMBER_IDLE_TIMEOUT_MS` | idle session reap timeout |
232
230
  | `ANTECHAMBER_MAX_LIVE` | cap on concurrent live agent processes |
233
231
  | `ANTECHAMBER_DEBUG` / `ANTECHAMBER_DEBUG_LOG` | request/event debug logging |
232
+ | `OMO_RPC_CLIENT_CAPABILITIES` / `SENPI_RPC_CLIENT_CAPABILITIES` | inherited RPC capabilities merged for the agent process |
234
233
 
235
234
  ## Security
236
235
 
@@ -244,8 +243,11 @@ Hardening controls (full reference in [docs/configuration.md](docs/configuration
244
243
  - Optional HTTP Basic auth, matching OpenCode's own contract:
245
244
  `OPENCODE_SERVER_PASSWORD` (plus `OPENCODE_SERVER_USERNAME`, default
246
245
  `opencode`).
247
- - Without a password, requests carrying a non-loopback browser `Origin` are
248
- rejected unless listed in `ANTECHAMBER_ALLOWED_ORIGINS`.
246
+ - Without a password, browser origins are limited to the adapter's own origin,
247
+ HTTP(S) loopback origins (including a different loopback port), and origins
248
+ listed in `ANTECHAMBER_ALLOWED_ORIGINS`; other origins get `403`. With a
249
+ password, other origins may make authenticated requests but receive no CORS
250
+ headers unless explicitly allowed.
249
251
  - Binding to a non-loopback host requires a password, `ANTECHAMBER_ALLOW_INSECURE=1`,
250
252
  or the `--insecure-no-auth` flag.
251
253
  - Request bodies are capped by `ANTECHAMBER_MAX_BODY_BYTES` (default 10 MiB);
@@ -268,7 +270,8 @@ of crashing; antechamber never falls back to signalling numeric PIDs. macOS is u
268
270
  level, not the model level.
269
271
  - Some OpenCode routes have no pi/OmO equivalent and are stubbed or return
270
272
  `400`/`501` (OAuth-based provider auth, MCP connect/disconnect, session
271
- share). See [docs/api-surface.md](docs/api-surface.md) for the exact list.
273
+ share). See [docs/api-surface.md](docs/api-surface.md) for the maintained
274
+ route and unsupported-response inventory.
272
275
 
273
276
  ## Development
274
277
 
@@ -4,7 +4,16 @@
4
4
  # `<binary> --version`; anything else is rejected.
5
5
  # Requires a build (`npm run build`) so that dist/cli.js exists.
6
6
  set -eu
7
- SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
7
+ SCRIPT_PATH=$0
8
+ while [ -h "$SCRIPT_PATH" ]; do
9
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
10
+ LINK="$(readlink "$SCRIPT_PATH")"
11
+ case "$LINK" in
12
+ /*) SCRIPT_PATH=$LINK ;;
13
+ *) SCRIPT_PATH="$SCRIPT_DIR/$LINK" ;;
14
+ esac
15
+ done
16
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
8
17
  CLI="$SCRIPT_DIR/../dist/cli.js"
9
18
  if [ ! -f "$CLI" ]; then
10
19
  echo "opencode-antechamber: $CLI not found; run 'npm run build' first" >&2
package/bin/opencode-pi CHANGED
@@ -1,3 +1,13 @@
1
1
  #!/bin/sh
2
2
  # Deprecated name kept for existing OpenChamber settings; use opencode-antechamber.
3
- exec "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/opencode-antechamber" "$@"
3
+ SCRIPT_PATH=$0
4
+ while [ -h "$SCRIPT_PATH" ]; do
5
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
6
+ LINK="$(readlink "$SCRIPT_PATH")"
7
+ case "$LINK" in
8
+ /*) SCRIPT_PATH=$LINK ;;
9
+ *) SCRIPT_PATH="$SCRIPT_DIR/$LINK" ;;
10
+ esac
11
+ done
12
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
13
+ exec "$SCRIPT_DIR/opencode-antechamber" "$@"
@@ -12,7 +12,7 @@ export type ApprovalEvent = WithoutEventID<EventPermissionAsked | EventPermissio
12
12
  formAnswer?: Record<string, string | string[]>;
13
13
  };
14
14
  };
15
- export type PermissionReply = "once" | "always" | "reject";
15
+ type PermissionReply = "once" | "always" | "reject";
16
16
  export type PendingPermission = PermissionRequest;
17
17
  export type PendingQuestion = QuestionRequest & {
18
18
  questionIDs?: string[];
@@ -75,10 +75,13 @@ export declare class ApprovalBridge {
75
75
  observeQuestion(ev: Record<string, unknown>): void;
76
76
  observe(context: Context, ev: Record<string, unknown>): void;
77
77
  private ask;
78
+ private askStructuredQuestion;
79
+ private askLegacyDialog;
78
80
  replyPermission(id: string, reply: PermissionReply, message?: string, sessionID?: string): boolean;
79
81
  replyQuestion(id: string, answers: string[][], sessionID?: string, comment?: string): boolean | Promise<boolean>;
80
82
  rejectQuestion(id: string, sessionID?: string): boolean | Promise<boolean>;
81
83
  private respondStructured;
84
+ private rememberSettled;
82
85
  private follow;
83
86
  private remove;
84
87
  clear(sessionID: string, cancel: boolean, callID?: string): void;
package/dist/approvals.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  export class ApprovalInputError extends Error {
3
3
  }
4
+ const MAX_SETTLED_STRUCTURED_QUESTIONS = 256;
4
5
  function record(value) {
5
6
  return typeof value === "object" && value !== null && !Array.isArray(value)
6
7
  ? value
@@ -169,7 +170,7 @@ export class ApprovalBridge {
169
170
  const p = [...this.pending.values()].find((p) => p.request.method === "question" &&
170
171
  (p.request.id === ev.id || (typeof ev.requestId === "string" && p.request.requestId === ev.requestId)));
171
172
  if (ev.type === "question_resolved" && typeof ev.requestId === "string")
172
- this.settled.add(ev.requestId);
173
+ this.rememberSettled(ev.requestId);
173
174
  if (!p || p.kind !== "question")
174
175
  return;
175
176
  if (ev.type === "question_updated") {
@@ -181,7 +182,7 @@ export class ApprovalBridge {
181
182
  p.card.metadata = { ...p.card.metadata, deadlineAtMs: p.request.deadlineAtMs, draft: p.request.draft };
182
183
  return;
183
184
  }
184
- this.settled.add(p.request.requestId ?? "");
185
+ this.rememberSettled(p.request.requestId);
185
186
  if (ev.outcome !== "answered" && ev.outcome !== "comment-submitted") {
186
187
  this.remove(p, true);
187
188
  return;
@@ -264,41 +265,47 @@ export class ApprovalBridge {
264
265
  if (!request)
265
266
  return;
266
267
  if (request.method === "question") {
267
- if (this.settled.has(request.requestId ?? ""))
268
- return;
269
- const publicID = `que_${createHash("sha256")
270
- .update(JSON.stringify([context.sessionID, request.requestId]))
271
- .digest("hex")
272
- .slice(0, 32)}`;
273
- const existing = this.pending.get(publicID);
274
- if (existing) {
275
- existing.request = request;
276
- if (existing.kind === "question")
277
- existing.card.metadata = {
278
- requestId: request.requestId,
279
- waitForAnswer: request.waitForAnswer,
280
- deadlineAtMs: request.deadlineAtMs,
281
- draft: request.draft,
282
- };
283
- return;
284
- }
285
- const card = {
286
- id: publicID,
287
- sessionID: context.sessionID,
288
- questions: (request.questions ?? []).map(({ id: _id, ...q }) => q),
289
- questionIDs: (request.questions ?? []).map((q) => q.id),
290
- metadata: {
268
+ this.askStructuredQuestion(context, request);
269
+ return;
270
+ }
271
+ this.askLegacyDialog(context, request);
272
+ }
273
+ askStructuredQuestion(context, request) {
274
+ if (this.settled.has(request.requestId ?? ""))
275
+ return;
276
+ const publicID = `que_${createHash("sha256")
277
+ .update(JSON.stringify([context.sessionID, request.requestId]))
278
+ .digest("hex")
279
+ .slice(0, 32)}`;
280
+ const existing = this.pending.get(publicID);
281
+ if (existing) {
282
+ existing.request = request;
283
+ if (existing.kind === "question")
284
+ existing.card.metadata = {
291
285
  requestId: request.requestId,
292
286
  waitForAnswer: request.waitForAnswer,
293
287
  deadlineAtMs: request.deadlineAtMs,
294
288
  draft: request.draft,
295
- },
296
- tool: { messageID: context.messageID, callID: request.requestId ?? "" },
297
- };
298
- this.pending.set(publicID, { kind: "question", card, context, request });
299
- this.emit(context.directory, { type: "question.asked", properties: card });
289
+ };
300
290
  return;
301
291
  }
292
+ const card = {
293
+ id: publicID,
294
+ sessionID: context.sessionID,
295
+ questions: (request.questions ?? []).map(({ id: _id, ...q }) => q),
296
+ questionIDs: (request.questions ?? []).map((q) => q.id),
297
+ metadata: {
298
+ requestId: request.requestId,
299
+ waitForAnswer: request.waitForAnswer,
300
+ deadlineAtMs: request.deadlineAtMs,
301
+ draft: request.draft,
302
+ },
303
+ tool: { messageID: context.messageID, callID: request.requestId ?? "" },
304
+ };
305
+ this.pending.set(publicID, { kind: "question", card, context, request });
306
+ this.emit(context.directory, { type: "question.asked", properties: card });
307
+ }
308
+ askLegacyDialog(context, request) {
302
309
  if (this.pending.has(request.id))
303
310
  return;
304
311
  const calls = this.questionCalls.get(context.sessionID);
@@ -470,6 +477,17 @@ export class ApprovalBridge {
470
477
  p.replying = false;
471
478
  }
472
479
  }
480
+ rememberSettled(requestID) {
481
+ if (!requestID)
482
+ return;
483
+ this.settled.delete(requestID);
484
+ this.settled.add(requestID);
485
+ if (this.settled.size <= MAX_SETTLED_STRUCTURED_QUESTIONS)
486
+ return;
487
+ const oldest = this.settled.values().next().value;
488
+ if (oldest !== undefined)
489
+ this.settled.delete(oldest);
490
+ }
473
491
  follow(p, value, kind) {
474
492
  const callID = p.request.toolCallId;
475
493
  if (callID === undefined)
@@ -502,8 +520,8 @@ export class ApprovalBridge {
502
520
  continue;
503
521
  if (p.request.method === "question" && !cancel)
504
522
  continue;
505
- if (cancel && p.request.requestId)
506
- this.settled.add(p.request.requestId);
523
+ if (cancel)
524
+ this.rememberSettled(p.request.requestId);
507
525
  if (cancel)
508
526
  p.context.send({ type: "extension_ui_response", id: p.request.id, cancelled: true });
509
527
  this.remove(p, true);
package/dist/catalog.d.ts CHANGED
@@ -48,7 +48,7 @@ interface OCv2Model {
48
48
  options: Record<string, unknown>;
49
49
  variants?: Record<string, Record<string, unknown>>;
50
50
  }
51
- export interface OCv2Provider {
51
+ interface OCv2Provider {
52
52
  id: string;
53
53
  name: string;
54
54
  source: "config";
package/dist/catalog.js CHANGED
@@ -45,7 +45,7 @@ function toOCModel(m) {
45
45
  };
46
46
  }
47
47
  export async function loadCatalog(directory) {
48
- const pi = new PiRpcClient({ cwd: directory });
48
+ const pi = new PiRpcClient({ cwd: directory, args: ["--no-session"] });
49
49
  try {
50
50
  const [modelsRes, firstState] = await Promise.all([
51
51
  pi.command({ type: "get_available_models" }),
@@ -13,7 +13,6 @@ export interface CommandContext {
13
13
  readonly launcherPath: string;
14
14
  }
15
15
  export declare function createContext(overrides?: Partial<CommandContext>): CommandContext;
16
- export declare const packageCliPath: string;
17
16
  /** Resolve executables without shell evaluation, respecting the supplied PATH. */
18
17
  export declare function resolveBinary(binary: string, ctx: CommandContext): string | undefined;
19
18
  export interface SettingsOptions {
@@ -35,7 +35,7 @@ export function createContext(overrides = {}) {
35
35
  ...overrides,
36
36
  };
37
37
  }
38
- export const packageCliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
38
+ const packageCliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
39
39
  /** Resolve executables without shell evaluation, respecting the supplied PATH. */
40
40
  export function resolveBinary(binary, ctx) {
41
41
  const candidates = isAbsolute(binary) || binary.includes("/") || binary.includes("\\")
@@ -1,13 +1,14 @@
1
1
  import { type CommandContext, type SettingsOptions } from "./context.js";
2
- export interface DoctorCheck {
2
+ interface DoctorCheck {
3
3
  readonly name: string;
4
4
  readonly status: "PASS" | "WARN" | "FAIL" | "INFO";
5
5
  readonly message: string;
6
6
  }
7
- export interface DoctorReport {
7
+ interface DoctorReport {
8
8
  readonly checks: readonly DoctorCheck[];
9
9
  readonly exitCode: 0 | 1;
10
10
  }
11
11
  /** Checks capabilities with access/stat only, never creating state or config files. */
12
12
  export declare function doctor(options?: SettingsOptions, ctx?: CommandContext): DoctorReport;
13
13
  export declare function formatDoctor(report: DoctorReport, json: boolean): string;
14
+ export {};
@@ -1,4 +1,4 @@
1
- export interface ServeOptions {
1
+ interface ServeOptions {
2
2
  readonly port: number;
3
3
  readonly host: string;
4
4
  readonly directory: string;
@@ -6,3 +6,4 @@ export interface ServeOptions {
6
6
  readonly compatible: boolean;
7
7
  }
8
8
  export declare function serve(options: ServeOptions): Promise<void>;
9
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { type CommandContext, type SettingsOptions } from "./context.js";
2
- export interface SetupOptions extends SettingsOptions {
2
+ interface SetupOptions extends SettingsOptions {
3
3
  readonly binary?: string;
4
4
  readonly dryRun?: boolean;
5
5
  readonly systemd?: string;
@@ -8,7 +8,6 @@ export interface SetupOptions extends SettingsOptions {
8
8
  /** User-unit root, overridable without touching the real systemd configuration. */
9
9
  readonly systemdDir?: string;
10
10
  }
11
- /** The bundled OmO Graph extension folder, when this installation has its built files. */
12
- export declare function omoGraphExtensionDir(ctx: CommandContext): string | undefined;
13
11
  /** Plan all inputs before applying changes; dependencies are injected for hermetic tests. */
14
12
  export declare function setup(options: SetupOptions, ctx?: CommandContext): string[];
13
+ export {};
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { dirname, join, resolve } from "node:path";
2
3
  import { createContext, isPackageBinary, readSettings, resolveBinary, settingsPath, shellQuote, } from "./context.js";
3
4
  function systemdValue(value) {
@@ -5,8 +6,13 @@ function systemdValue(value) {
5
6
  throw new Error("systemd environment values cannot contain newlines or NUL");
6
7
  return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%");
7
8
  }
9
+ function writeAtomically(path, content, ctx) {
10
+ const temporary = join(dirname(path), `.antechamber-${randomUUID()}.tmp`);
11
+ ctx.fs.writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
12
+ ctx.fs.renameSync(temporary, path);
13
+ }
8
14
  /** The bundled OmO Graph extension folder, when this installation has its built files. */
9
- export function omoGraphExtensionDir(ctx) {
15
+ function omoGraphExtensionDir(ctx) {
10
16
  const dir = resolve(dirname(ctx.cliPath), "..", "extensions", "omo-dag");
11
17
  const built = ["package.json", "panel/main.js", "service/main.js"].every((file) => ctx.fs.existsSync(join(dir, file)));
12
18
  return built ? dir : undefined;
@@ -75,8 +81,14 @@ export function setup(options, ctx = createContext()) {
75
81
  const stamp = ctx.now().toISOString().replace(/\D/g, "").slice(0, 14);
76
82
  const backup = (path) => {
77
83
  const target = `${path}.bak-antechamber-${stamp}`;
78
- if (options.dryRun)
84
+ if (options.dryRun) {
85
+ if (ctx.fs.existsSync(target)) {
86
+ throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${path}' -> '${target}'`), {
87
+ code: "EEXIST",
88
+ });
89
+ }
79
90
  report(`Would back up ${path} to ${target}`);
91
+ }
80
92
  else {
81
93
  ctx.fs.copyFileSync(path, target, ctx.fs.constants.COPYFILE_EXCL);
82
94
  report(`Backed up ${path} to ${target}`);
@@ -98,7 +110,7 @@ export function setup(options, ctx = createContext()) {
98
110
  backup(settings);
99
111
  if (!options.dryRun) {
100
112
  ctx.fs.mkdirSync(dirname(settings), { recursive: true });
101
- ctx.fs.writeFileSync(settings, updated, { mode: 0o600 });
113
+ writeAtomically(settings, updated, ctx);
102
114
  report(`Wrote ${settings}`);
103
115
  }
104
116
  }
@@ -113,7 +125,7 @@ export function setup(options, ctx = createContext()) {
113
125
  backup(dropIn);
114
126
  if (!options.dryRun) {
115
127
  ctx.fs.mkdirSync(dirname(dropIn), { recursive: true });
116
- ctx.fs.writeFileSync(dropIn, dropInText, { mode: 0o600 });
128
+ writeAtomically(dropIn, dropInText, ctx);
117
129
  report(`Wrote ${dropIn}`);
118
130
  }
119
131
  }
package/dist/http.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  /** Shared HTTP body, response, authentication, and CORS plumbing. */
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
+ export declare class RequestBodyError extends Error {
4
+ readonly status: 400 | 413;
5
+ constructor(status: 400 | 413, message: string);
6
+ }
3
7
  export declare function serverPassword(): string | undefined;
4
8
  export declare function isLoopbackHost(host: string): boolean;
5
9
  export declare function errorBody(name: string, message: string): {
@@ -10,9 +14,12 @@ export declare function errorBody(name: string, message: string): {
10
14
  };
11
15
  export declare function json(res: ServerResponse, status: number, body: unknown): void;
12
16
  export declare class Http {
17
+ private serverOrigin?;
13
18
  private readonly credentials;
14
19
  private readonly maxBodyBytes;
15
- constructor();
20
+ constructor(serverOrigin?: (() => string) | undefined);
21
+ allowsUpgrade(req: IncomingMessage): boolean;
16
22
  route(req: IncomingMessage, res: ServerResponse, handle: () => Promise<void>): Promise<void>;
17
23
  body(req: IncomingMessage): Promise<unknown>;
24
+ private authorized;
18
25
  }
package/dist/http.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { createHash, timingSafeEqual } from "node:crypto";
3
3
  import { ApprovalInputError } from "./approvals.js";
4
4
  import { envSetting } from "./config.js";
5
+ import { UpstreamProtocolError } from "./pi-rpc.js";
5
6
  const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
6
- class RequestBodyError extends Error {
7
+ export class RequestBodyError extends Error {
7
8
  status;
8
9
  constructor(status, message) {
9
10
  super(message);
@@ -19,7 +20,7 @@ export function isLoopbackHost(host) {
19
20
  return true;
20
21
  return /^127(?:\.\d{1,3}){3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255);
21
22
  }
22
- function allowedOrigin(origin) {
23
+ function allowedOrigin(origin, serverOrigin) {
23
24
  try {
24
25
  const url = new URL(origin);
25
26
  if (!/^https?:\/\/[^/?#]+$/.test(origin) ||
@@ -29,7 +30,8 @@ function allowedOrigin(origin) {
29
30
  url.search !== "" ||
30
31
  url.hash !== "")
31
32
  return false;
32
- return (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
33
+ return (origin === serverOrigin ||
34
+ isLoopbackHost(url.hostname) ||
33
35
  (envSetting("ALLOWED_ORIGINS") ?? "").split(",").some((entry) => entry.trim() === origin));
34
36
  }
35
37
  catch {
@@ -44,9 +46,11 @@ export function json(res, status, body) {
44
46
  res.writeHead(status, { "Content-Type": "application/json" }).end(payload);
45
47
  }
46
48
  export class Http {
49
+ serverOrigin;
47
50
  credentials;
48
51
  maxBodyBytes;
49
- constructor() {
52
+ constructor(serverOrigin) {
53
+ this.serverOrigin = serverOrigin;
50
54
  const maxBodyBytes = Number(envSetting("MAX_BODY_BYTES"));
51
55
  this.maxBodyBytes = Number.isSafeInteger(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
52
56
  const password = serverPassword();
@@ -57,11 +61,17 @@ export class Http {
57
61
  .update(`${process.env.OPENCODE_SERVER_USERNAME || "opencode"}:${password}`)
58
62
  .digest();
59
63
  }
64
+ allowsUpgrade(req) {
65
+ const origin = req.headers.origin;
66
+ if (origin !== undefined && !allowedOrigin(origin, this.serverOrigin?.()) && this.credentials === undefined)
67
+ return false;
68
+ return this.authorized(req);
69
+ }
60
70
  async route(req, res, handle) {
61
71
  res.setHeader("Vary", "Origin");
62
72
  const origin = req.headers.origin;
63
73
  if (origin !== undefined) {
64
- if (allowedOrigin(origin)) {
74
+ if (allowedOrigin(origin, this.serverOrigin?.())) {
65
75
  res.setHeader("Access-Control-Allow-Origin", origin);
66
76
  res.setHeader("Access-Control-Allow-Credentials", "true");
67
77
  }
@@ -74,16 +84,10 @@ export class Http {
74
84
  return;
75
85
  }
76
86
  }
77
- if (this.credentials !== undefined) {
78
- const match = req.headers.authorization?.match(/^Basic\s+([A-Za-z0-9+/]+=*)$/i);
79
- const supplied = createHash("sha256")
80
- .update(Buffer.from(match?.[1] ?? "", "base64"))
81
- .digest();
82
- if (!match || !timingSafeEqual(this.credentials, supplied)) {
83
- res.setHeader("WWW-Authenticate", 'Basic realm="OpenCode"');
84
- json(res, 401, errorBody("UnauthorizedError", "Authentication required"));
85
- return;
86
- }
87
+ if (!this.authorized(req)) {
88
+ res.setHeader("WWW-Authenticate", 'Basic realm="OpenCode"');
89
+ json(res, 401, errorBody("UnauthorizedError", "Authentication required"));
90
+ return;
87
91
  }
88
92
  res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
89
93
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-opencode-directory");
@@ -100,11 +104,21 @@ export class Http {
100
104
  res.setHeader("Connection", "close");
101
105
  res.once("finish", () => req.destroy());
102
106
  }
103
- json(res, err instanceof RequestBodyError ? err.status : err instanceof ApprovalInputError ? 400 : 500, errorBody(err instanceof RequestBodyError
107
+ json(res, err instanceof UpstreamProtocolError
108
+ ? 502
109
+ : err instanceof RequestBodyError
110
+ ? err.status
111
+ : err instanceof ApprovalInputError || err instanceof URIError
112
+ ? 400
113
+ : 500, errorBody(err instanceof UpstreamProtocolError
104
114
  ? err.name
105
- : err instanceof ApprovalInputError
106
- ? "InvalidRequestError"
107
- : "UnknownError", message));
115
+ : err instanceof RequestBodyError
116
+ ? err.name
117
+ : err instanceof ApprovalInputError
118
+ ? "InvalidRequestError"
119
+ : err instanceof URIError
120
+ ? "BadRequestError"
121
+ : "UnknownError", err instanceof URIError ? "invalid URL encoding" : message));
108
122
  }
109
123
  }
110
124
  body(req) {
@@ -165,4 +179,13 @@ export class Http {
165
179
  req.once("aborted", onAborted);
166
180
  });
167
181
  }
182
+ authorized(req) {
183
+ if (this.credentials === undefined)
184
+ return true;
185
+ const match = req.headers.authorization?.match(/^Basic\s+([A-Za-z0-9+/]+=*)$/i);
186
+ const supplied = createHash("sha256")
187
+ .update(Buffer.from(match?.[1] ?? "", "base64"))
188
+ .digest();
189
+ return match !== null && match !== undefined && timingSafeEqual(this.credentials, supplied);
190
+ }
168
191
  }