antechamber 0.1.1 → 0.2.1

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 (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +24 -14
  3. package/bin/opencode-antechamber +14 -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 +10 -6
  18. package/dist/pi-event-translation.js +237 -80
  19. package/dist/pi-rpc.d.ts +20 -2
  20. package/dist/pi-rpc.js +96 -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 +113 -65
  26. package/dist/routes-v2.js +407 -95
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +12 -15
  30. package/dist/session-commands.js +54 -13
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.d.ts +1 -1
  34. package/dist/session-history.js +110 -44
  35. package/dist/session-hosts.js +2 -2
  36. package/dist/session-store.d.ts +8 -4
  37. package/dist/session-store.js +191 -38
  38. package/dist/session-tree.d.ts +10 -2
  39. package/dist/session-tree.js +55 -26
  40. package/dist/sessions.d.ts +32 -5
  41. package/dist/sessions.js +331 -120
  42. package/dist/sse.d.ts +16 -6
  43. package/dist/sse.js +77 -21
  44. package/dist/subagents.d.ts +5 -0
  45. package/dist/subagents.js +84 -71
  46. package/dist/tool-map.d.ts +3 -2
  47. package/dist/types.d.ts +152 -1
  48. package/dist/v2.d.ts +18 -4
  49. package/dist/v2.js +128 -44
  50. package/dist/vcs.d.ts +5 -0
  51. package/dist/vcs.js +199 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/omo-dag/README.md +2 -0
  55. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  56. package/extensions/omo-dag/panel/index.html +1 -1
  57. package/extensions/omo-dag/panel/main.js +60 -60
  58. package/extensions/omo-dag/service/main.js +72 -8
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-10-15
11
+
12
+ ### Fixed
13
+
14
+ - fix(session): accept OmO messages stored as plain-string content when titling, binding and restoring sessions instead of crashing
15
+ - fix(api): answer OpenChamber's location eviction (`DELETE /api/debug/location`) with OpenCode's empty 204
16
+ - fix(api): answer the v2 routes OpenChamber 2.3.2 calls instead of a generic 404/501. Plugin list and check return an empty inventory, `/api/experimental/generate` returns OpenCode's typed 503, and plugin update, credential update/activate, integration connections and session generate/background/move return `UnsupportedError`
17
+ - fix(api): stop faking mutations. `POST /api/credential` and `PATCH /api/project/{projectID}` return `UnsupportedError` instead of keeping an in-memory credential or echoing an unsaved project
18
+ - fix(api): reject missing or empty prompt and synthetic-context input with a typed 400 before anything reaches OmO
19
+ - fix(vcs): back `/api/vcs/status`, `/api/vcs/diff`, `/api/vcs/branch` and `/api/vcs/base` with read-only git instead of fixed empty data
20
+ - fix(cli): refuse `opencode-antechamber upgrade` with the antechamber upgrade command, and document OpenChamber 2.3.2's OpenCode update notice
21
+
22
+ ### Changed
23
+
24
+ - refactor: use `findLast` for last-match scans
25
+ - docs(api): unknown routes return 404 on both APIs; only unknown subpaths of an existing v2 session return 501
26
+
27
+ ## [0.2.0] - 2026-10-14
28
+
29
+ ### Added
30
+
31
+ - feat(api): add v2 credential, integration, file-search, VCS, and synthetic-context routes
32
+ - feat(events): replay retained SSE events and expose v2 shell, compaction, and execution terminals
33
+
34
+ ### Fixed
35
+
36
+ - fix(session): preserve rich prompt parts, metadata, ancestry, and part identities across reloads
37
+ - fix(runtime): retain operation ordering after caller cancellation and finalize interrupted tool cards
38
+ - fix(rpc): close failed transports and reject responses correlated to the wrong command
39
+ - fix(http): allow passwordless loopback origins across ports while retaining explicit origin checks
40
+ - fix(security): validate registry records and write registry state with private permissions
41
+ - fix(cli): advertise OpenCode API compatibility as 2.0.20
42
+ - 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
43
+ - 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
44
+ - fix(extension): bound graph responses and display omitted-node counts
45
+ - 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
46
+ - 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
47
+ - fix(events): keep an unscoped v2 `/api/event` stream global, as OpenChamber 2.3.2 subscribes
48
+ - fix(rpc): after the agent exits during shutdown, kill only re-identified members of its process group
49
+
50
+ ### Changed
51
+
52
+ - docs(api): document route statuses, response envelopes, stream framing, and unsupported capabilities
53
+ - docs(maintainers): correct smoke paths, typecheck commands, and release guidance
54
+
10
55
  ## [0.1.1] - 2026-10-14
11
56
 
12
57
  ### Added
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
@@ -151,6 +147,13 @@ Restart OpenChamber, or `systemctl --user restart openchamber.service` for a
151
147
  managed service. Run `antechamber doctor` again. If the executable moved,
152
148
  rerun `antechamber setup`.
153
149
 
150
+ OpenChamber 2.3.2 may announce a newer OpenCode release. antechamber reports
151
+ OpenCode API 2.0.20, the minimum OpenChamber accepts, and OpenChamber offers
152
+ no signal that keeps that check while hiding the notice. Dismiss it or turn off
153
+ OpenCode update notifications in OpenChamber. Its Update action runs
154
+ `opencode-antechamber upgrade`, which refuses; upgrade antechamber with the
155
+ commands above.
156
+
154
157
  To roll back wiring, copy the backup printed by setup over the settings file:
155
158
 
156
159
  ```bash
@@ -218,9 +221,11 @@ extension, and refuses it as an invalid manifest. Details:
218
221
 
219
222
  ## Configuration
220
223
 
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).
224
+ Connector settings use the `ANTECHAMBER_` prefix; the deprecated `OCPI_`
225
+ prefix is still accepted as a fallback. RPC capability passthrough also reads
226
+ `OMO_RPC_CLIENT_CAPABILITIES` and `SENPI_RPC_CLIENT_CAPABILITIES` directly.
227
+ Full reference, defaults, and units:
228
+ [docs/configuration.md](docs/configuration.md).
224
229
 
225
230
  | Variable | Purpose |
226
231
  |---|---|
@@ -231,6 +236,7 @@ deployments. Full reference, defaults, and units: [docs/configuration.md](docs/c
231
236
  | `ANTECHAMBER_IDLE_TIMEOUT_MS` | idle session reap timeout |
232
237
  | `ANTECHAMBER_MAX_LIVE` | cap on concurrent live agent processes |
233
238
  | `ANTECHAMBER_DEBUG` / `ANTECHAMBER_DEBUG_LOG` | request/event debug logging |
239
+ | `OMO_RPC_CLIENT_CAPABILITIES` / `SENPI_RPC_CLIENT_CAPABILITIES` | inherited RPC capabilities merged for the agent process |
234
240
 
235
241
  ## Security
236
242
 
@@ -244,8 +250,11 @@ Hardening controls (full reference in [docs/configuration.md](docs/configuration
244
250
  - Optional HTTP Basic auth, matching OpenCode's own contract:
245
251
  `OPENCODE_SERVER_PASSWORD` (plus `OPENCODE_SERVER_USERNAME`, default
246
252
  `opencode`).
247
- - Without a password, requests carrying a non-loopback browser `Origin` are
248
- rejected unless listed in `ANTECHAMBER_ALLOWED_ORIGINS`.
253
+ - Without a password, browser origins are limited to the adapter's own origin,
254
+ HTTP(S) loopback origins (including a different loopback port), and origins
255
+ listed in `ANTECHAMBER_ALLOWED_ORIGINS`; other origins get `403`. With a
256
+ password, other origins may make authenticated requests but receive no CORS
257
+ headers unless explicitly allowed.
249
258
  - Binding to a non-loopback host requires a password, `ANTECHAMBER_ALLOW_INSECURE=1`,
250
259
  or the `--insecure-no-auth` flag.
251
260
  - Request bodies are capped by `ANTECHAMBER_MAX_BODY_BYTES` (default 10 MiB);
@@ -268,7 +277,8 @@ of crashing; antechamber never falls back to signalling numeric PIDs. macOS is u
268
277
  level, not the model level.
269
278
  - Some OpenCode routes have no pi/OmO equivalent and are stubbed or return
270
279
  `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.
280
+ share). See [docs/api-surface.md](docs/api-surface.md) for the maintained
281
+ route and unsupported-response inventory.
272
282
 
273
283
  ## Development
274
284
 
@@ -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
@@ -13,6 +22,10 @@ fi
13
22
  case "${1:-}" in
14
23
  serve) shift; exec node "$CLI" serve "$@" ;;
15
24
  --version|-v) exec node "$CLI" --version ;;
25
+ upgrade)
26
+ echo "opencode-antechamber: OpenChamber cannot upgrade antechamber; run 'npm update -g antechamber' (or 'bun add -g antechamber') instead" >&2
27
+ exit 1
28
+ ;;
16
29
  esac
17
30
  echo "opencode-antechamber: unsupported command '${1:-}' (only 'serve' and '--version' are implemented)" >&2
18
31
  exit 1
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
  }