@pleri/olam-cli 0.1.7
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/dist/__tests__/auth-status.test.d.ts +2 -0
- package/dist/__tests__/auth-status.test.d.ts.map +1 -0
- package/dist/__tests__/auth-status.test.js +290 -0
- package/dist/__tests__/auth-status.test.js.map +1 -0
- package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
- package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/auth-upgrade.test.js +161 -0
- package/dist/__tests__/auth-upgrade.test.js.map +1 -0
- package/dist/__tests__/create-app-urls.test.d.ts +2 -0
- package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-urls.test.js +102 -0
- package/dist/__tests__/create-app-urls.test.js.map +1 -0
- package/dist/__tests__/enter.test.d.ts +2 -0
- package/dist/__tests__/enter.test.d.ts.map +1 -0
- package/dist/__tests__/enter.test.js +90 -0
- package/dist/__tests__/enter.test.js.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.js +119 -0
- package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
- package/dist/__tests__/host-cp.test.d.ts +9 -0
- package/dist/__tests__/host-cp.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp.test.js +254 -0
- package/dist/__tests__/host-cp.test.js.map +1 -0
- package/dist/__tests__/keys.test.d.ts +9 -0
- package/dist/__tests__/keys.test.d.ts.map +1 -0
- package/dist/__tests__/keys.test.js +145 -0
- package/dist/__tests__/keys.test.js.map +1 -0
- package/dist/__tests__/logs.test.d.ts +9 -0
- package/dist/__tests__/logs.test.d.ts.map +1 -0
- package/dist/__tests__/logs.test.js +124 -0
- package/dist/__tests__/logs.test.js.map +1 -0
- package/dist/__tests__/ps.test.d.ts +2 -0
- package/dist/__tests__/ps.test.d.ts.map +1 -0
- package/dist/__tests__/ps.test.js +172 -0
- package/dist/__tests__/ps.test.js.map +1 -0
- package/dist/__tests__/status-app-urls.test.d.ts +2 -0
- package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/status-app-urls.test.js +125 -0
- package/dist/__tests__/status-app-urls.test.js.map +1 -0
- package/dist/__tests__/upgrade.test.d.ts +9 -0
- package/dist/__tests__/upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade.test.js +262 -0
- package/dist/__tests__/upgrade.test.js.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
- package/dist/commands/__tests__/refresh.test.d.ts +13 -0
- package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
- package/dist/commands/__tests__/refresh.test.js +170 -0
- package/dist/commands/__tests__/refresh.test.js.map +1 -0
- package/dist/commands/auth-status.d.ts +43 -0
- package/dist/commands/auth-status.d.ts.map +1 -0
- package/dist/commands/auth-status.js +208 -0
- package/dist/commands/auth-status.js.map +1 -0
- package/dist/commands/auth-upgrade.d.ts +47 -0
- package/dist/commands/auth-upgrade.d.ts.map +1 -0
- package/dist/commands/auth-upgrade.js +277 -0
- package/dist/commands/auth-upgrade.js.map +1 -0
- package/dist/commands/auth.d.ts +16 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +283 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +512 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/crystallize.d.ts +8 -0
- package/dist/commands/crystallize.d.ts.map +1 -0
- package/dist/commands/crystallize.js +101 -0
- package/dist/commands/crystallize.js.map +1 -0
- package/dist/commands/destroy.d.ts +6 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/destroy.js +54 -0
- package/dist/commands/destroy.js.map +1 -0
- package/dist/commands/dispatch.d.ts +9 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +94 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/enter.d.ts +63 -0
- package/dist/commands/enter.d.ts.map +1 -0
- package/dist/commands/enter.js +206 -0
- package/dist/commands/enter.js.map +1 -0
- package/dist/commands/host-cp.d.ts +191 -0
- package/dist/commands/host-cp.d.ts.map +1 -0
- package/dist/commands/host-cp.js +797 -0
- package/dist/commands/host-cp.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +143 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +22 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +203 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/keys.d.ts +26 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +151 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/lanes.d.ts +18 -0
- package/dist/commands/lanes.d.ts.map +1 -0
- package/dist/commands/lanes.js +122 -0
- package/dist/commands/lanes.js.map +1 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +39 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +38 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +177 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/observe.d.ts +9 -0
- package/dist/commands/observe.d.ts.map +1 -0
- package/dist/commands/observe.js +34 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/commands/policy-check.d.ts +14 -0
- package/dist/commands/policy-check.d.ts.map +1 -0
- package/dist/commands/policy-check.js +76 -0
- package/dist/commands/policy-check.js.map +1 -0
- package/dist/commands/pr.d.ts +17 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +148 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/ps.d.ts +25 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +164 -0
- package/dist/commands/ps.js.map +1 -0
- package/dist/commands/refresh-helpers.d.ts +25 -0
- package/dist/commands/refresh-helpers.d.ts.map +1 -0
- package/dist/commands/refresh-helpers.js +56 -0
- package/dist/commands/refresh-helpers.js.map +1 -0
- package/dist/commands/refresh.d.ts +23 -0
- package/dist/commands/refresh.d.ts.map +1 -0
- package/dist/commands/refresh.js +237 -0
- package/dist/commands/refresh.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/upgrade.d.ts +67 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +358 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/workspace.d.ts +23 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +198 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/commands/world-snapshot.d.ts +18 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -0
- package/dist/commands/world-snapshot.js +327 -0
- package/dist/commands/world-snapshot.js.map +1 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +51 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18007 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.js +32236 -0
- package/dist/output.d.ts +10 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +31 -0
- package/dist/output.js.map +1 -0
- package/host-cp/compose.yaml +126 -0
- package/host-cp/src/auth-secret-hint.mjs +45 -0
- package/host-cp/src/auth.mjs +155 -0
- package/host-cp/src/compose-worlds-sources.mjs +170 -0
- package/host-cp/src/container-secret-fetcher.mjs +163 -0
- package/host-cp/src/docker-events.mjs +184 -0
- package/host-cp/src/local-worlds-source.mjs +83 -0
- package/host-cp/src/plan-orchestrator.mjs +829 -0
- package/host-cp/src/plan-progress.mjs +282 -0
- package/host-cp/src/pr-cache.mjs +201 -0
- package/host-cp/src/pr-merge-poller.mjs +154 -0
- package/host-cp/src/process-poller.mjs +250 -0
- package/host-cp/src/proxy.mjs +245 -0
- package/host-cp/src/pylon-worlds-source.mjs +68 -0
- package/host-cp/src/redact.mjs +67 -0
- package/host-cp/src/secret-cache.mjs +104 -0
- package/host-cp/src/server.mjs +2215 -0
- package/host-cp/src/sse-gate.mjs +117 -0
- package/host-cp/src/version-status.mjs +209 -0
- package/host-cp/src/workspace-catalog.mjs +149 -0
- package/host-cp/src/world-names-store.mjs +176 -0
- package/host-cp/src/world-pr-state.mjs +97 -0
- package/host-cp/src/world-progress.mjs +322 -0
- package/host-cp/src/world-tunnel-manager.mjs +288 -0
- package/host-cp/src/worlds-db-source.mjs +191 -0
- package/host-cp/src/worlds-source.mjs +59 -0
- package/package.json +38 -0
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output helpers for consistent CLI formatting.
|
|
3
|
+
*/
|
|
4
|
+
export declare function printError(message: string): void;
|
|
5
|
+
export declare function printSuccess(message: string): void;
|
|
6
|
+
export declare function printWarning(message: string): void;
|
|
7
|
+
export declare function printInfo(label: string, value: string): void;
|
|
8
|
+
export declare function printHeader(title: string): void;
|
|
9
|
+
export declare function formatAge(createdAt: string): string;
|
|
10
|
+
//# sourceMappingURL=output.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAEhD;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAElD;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAElD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAE5D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE/C;AAED,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQnD"}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output helpers for consistent CLI formatting.
|
|
3
|
+
*/
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
export function printError(message) {
|
|
6
|
+
console.error(`${pc.red('error')} ${message}`);
|
|
7
|
+
}
|
|
8
|
+
export function printSuccess(message) {
|
|
9
|
+
console.log(`${pc.green('ok')} ${message}`);
|
|
10
|
+
}
|
|
11
|
+
export function printWarning(message) {
|
|
12
|
+
console.log(`${pc.yellow('warn')} ${message}`);
|
|
13
|
+
}
|
|
14
|
+
export function printInfo(label, value) {
|
|
15
|
+
console.log(` ${pc.dim(label.padEnd(14))} ${value}`);
|
|
16
|
+
}
|
|
17
|
+
export function printHeader(title) {
|
|
18
|
+
console.log(`\n${pc.bold(title)}`);
|
|
19
|
+
}
|
|
20
|
+
export function formatAge(createdAt) {
|
|
21
|
+
const ms = Date.now() - new Date(createdAt).getTime();
|
|
22
|
+
const minutes = Math.floor(ms / 60_000);
|
|
23
|
+
if (minutes < 60)
|
|
24
|
+
return `${minutes}m`;
|
|
25
|
+
const hours = Math.floor(minutes / 60);
|
|
26
|
+
if (hours < 24)
|
|
27
|
+
return `${hours}h ${minutes % 60}m`;
|
|
28
|
+
const days = Math.floor(hours / 24);
|
|
29
|
+
return `${days}d ${hours % 24}h`;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=output.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"output.js","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,UAAU,UAAU,CAAC,OAAe;IACxC,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,KAAa;IACpD,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC;AACxD,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AACrC,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,SAAiB;IACzC,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC;IACtD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,MAAM,CAAC,CAAC;IACxC,IAAI,OAAO,GAAG,EAAE;QAAE,OAAO,GAAG,OAAO,GAAG,CAAC;IACvC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACvC,IAAI,KAAK,GAAG,EAAE;QAAE,OAAO,GAAG,KAAK,KAAK,OAAO,GAAG,EAAE,GAAG,CAAC;IACpD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC;IACpC,OAAO,GAAG,IAAI,KAAK,KAAK,GAAG,EAAE,GAAG,CAAC;AACnC,CAAC"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Phase F-2-B (B2): olam-host-cp compose stack.
|
|
2
|
+
#
|
|
3
|
+
# Two services on a private internal network:
|
|
4
|
+
#
|
|
5
|
+
# 1. host-cp — the SPA proxy server (B3+ implementation). Exposes
|
|
6
|
+
# port 19000 to the operator's host. Talks to the
|
|
7
|
+
# docker-socket-proxy via `tcp://docker-socket-proxy:2375`
|
|
8
|
+
# (NOT the raw /var/run/docker.sock).
|
|
9
|
+
#
|
|
10
|
+
# 2. docker-socket-proxy
|
|
11
|
+
# — tecnativa/docker-socket-proxy sidecar. Mounts the
|
|
12
|
+
# real /var/run/docker.sock read-only and exposes a
|
|
13
|
+
# whitelisted subset of the Docker API. Whitelist:
|
|
14
|
+
# CONTAINERS=1 — list/inspect (find world IDs)
|
|
15
|
+
# EVENTS=1 — stream restart/stop events
|
|
16
|
+
# (cache invalidation; B3 / T2)
|
|
17
|
+
# EXEC=1 — exec inside containers
|
|
18
|
+
# (read /tmp/olam-container-secret)
|
|
19
|
+
# Everything else is denied (images, volumes,
|
|
20
|
+
# networks, swarm, build, push, etc.). T6 + T8
|
|
21
|
+
# mitigation: blast-radius reduction vs raw socket.
|
|
22
|
+
#
|
|
23
|
+
# Bring up: `docker compose -f packages/host-cp/compose.yaml up --build -d`
|
|
24
|
+
# Tear down: `docker compose -f packages/host-cp/compose.yaml down`
|
|
25
|
+
|
|
26
|
+
services:
|
|
27
|
+
host-cp:
|
|
28
|
+
container_name: olam-host-cp
|
|
29
|
+
build:
|
|
30
|
+
context: .
|
|
31
|
+
dockerfile: Dockerfile
|
|
32
|
+
image: olam-host-cp:latest
|
|
33
|
+
ports:
|
|
34
|
+
# Bind to 127.0.0.1 only — single-user-per-host assumption (T4).
|
|
35
|
+
# Multi-user / TLS / remote access lands in Phase G+.
|
|
36
|
+
- "127.0.0.1:19000:19000"
|
|
37
|
+
environment:
|
|
38
|
+
# Connection string for docker-socket-proxy. The proxy listens on
|
|
39
|
+
# tcp://0.0.0.0:2375 inside the internal network. host-cp uses
|
|
40
|
+
# this to enumerate worlds (containers list) + read secrets
|
|
41
|
+
# (containers exec) + subscribe to restart events.
|
|
42
|
+
DOCKER_HOST: "tcp://docker-socket-proxy:2375"
|
|
43
|
+
# Phase F-2-B M2 ship gate: secret cache TTL (5min, demoted from
|
|
44
|
+
# 1h per D2). B3 reads this; B10's m2-cache-invalidate.sh tests
|
|
45
|
+
# the docker-events invalidation path.
|
|
46
|
+
OLAM_SECRET_CACHE_TTL_SEC: "300"
|
|
47
|
+
# Bind operator-facing UI port. Always 19000 in compose.
|
|
48
|
+
OLAM_HOST_CP_PORT: "19000"
|
|
49
|
+
# Token + workspace + world registry mount points. Bind-mounted
|
|
50
|
+
# below; host CP reads these at boot.
|
|
51
|
+
OLAM_HOST_CP_TOKEN_PATH: "/data/host-cp.token"
|
|
52
|
+
OLAM_WORKSPACES_DIR: "/data/workspaces"
|
|
53
|
+
OLAM_WORLDS_DB: "/data/worlds.db"
|
|
54
|
+
OLAM_PR_POLL_INTERVAL_MS: "300000"
|
|
55
|
+
OLAM_MERGE_GRACE_MS: "600000"
|
|
56
|
+
# Version detection: path to the operator's olam repo checkout.
|
|
57
|
+
# host-cp reads .git/refs/heads/main (or the active branch) to
|
|
58
|
+
# determine "latest" SHA. Defaults to $HOME/Projects/ein-sof/olam;
|
|
59
|
+
# override with OLAM_REPO_PATH env var before `docker compose up`.
|
|
60
|
+
OLAM_REPO_PATH: "${OLAM_REPO_PATH:-}"
|
|
61
|
+
# Auth-service inter-service auth. The secret is shared with the
|
|
62
|
+
# long-lived olam-auth container (generated on first `olam auth
|
|
63
|
+
# up` at ~/.olam/auth-secret). Without it, X-Olam-Secret is never
|
|
64
|
+
# sent and auth-service 401s every host-cp → /credentials/* call,
|
|
65
|
+
# which surfaces in the dashboard as a failed Connect Claude flow.
|
|
66
|
+
OLAM_AUTH_SERVICE_URL: "http://host.docker.internal:9999"
|
|
67
|
+
OLAM_AUTH_SECRET: "${OLAM_AUTH_SECRET:-}"
|
|
68
|
+
volumes:
|
|
69
|
+
# ~/.olam/ from operator's home → /data/ inside container. B4
|
|
70
|
+
# writes the startup token here (chmod 600). B6 reads workspaces
|
|
71
|
+
# + worlds.db from here. ~/.olam/ is the canonical operator-state
|
|
72
|
+
# directory established by the Olam CLI; consistent with the
|
|
73
|
+
# devbox container's mount layout.
|
|
74
|
+
- ${HOME}/.olam:/data
|
|
75
|
+
- ${HOME}/.config/gh:/gh-config:ro
|
|
76
|
+
# Operator's olam repo mounted read-only so host-cp can poll
|
|
77
|
+
# .git/refs/heads/main to detect when a new version is available.
|
|
78
|
+
# The path inside the container is always /operator-repo.
|
|
79
|
+
# On the host: OLAM_REPO_PATH env var, or defaults to
|
|
80
|
+
# $HOME/Projects/ein-sof/olam. If the path doesn't exist, the
|
|
81
|
+
# mount is a no-op and version detection reports "operator-repo not mounted".
|
|
82
|
+
- ${OLAM_REPO_PATH:-${HOME}/Projects/ein-sof/olam}:/operator-repo:ro
|
|
83
|
+
depends_on:
|
|
84
|
+
docker-socket-proxy:
|
|
85
|
+
condition: service_started
|
|
86
|
+
networks:
|
|
87
|
+
- olam-host-cp-internal
|
|
88
|
+
restart: unless-stopped
|
|
89
|
+
|
|
90
|
+
docker-socket-proxy:
|
|
91
|
+
container_name: olam-docker-socket-proxy
|
|
92
|
+
# Pin to a specific tag, not :latest. Update via Renovate / dependabot.
|
|
93
|
+
# tecnativa/docker-socket-proxy:0.3.0 (2024-10-22) — last tagged
|
|
94
|
+
# release as of plan-pass-2 emit. T8 mitigation: pinning prevents
|
|
95
|
+
# supply-chain drift on the sidecar.
|
|
96
|
+
image: tecnativa/docker-socket-proxy:0.3.0
|
|
97
|
+
environment:
|
|
98
|
+
# Whitelist matches plan D5 + T6/T8: host CP needs exactly these
|
|
99
|
+
# four operations. EVERYTHING else stays at the proxy default
|
|
100
|
+
# (deny). Audit periodically; widen with explicit justification.
|
|
101
|
+
CONTAINERS: "1"
|
|
102
|
+
EVENTS: "1"
|
|
103
|
+
EXEC: "1"
|
|
104
|
+
# tecnativa/docker-socket-proxy 0.3.0 requires POST=1 to allow
|
|
105
|
+
# POST verbs on whitelisted endpoints (exec creation requires
|
|
106
|
+
# POST /containers/<id>/exec + POST /exec/<id>/start). Phase
|
|
107
|
+
# F-2-D dogfood revealed the missing perm.
|
|
108
|
+
POST: "1"
|
|
109
|
+
# Optional: lower log verbosity. Default is INFO; DEBUG floods
|
|
110
|
+
# logs in dev. Comment out for troubleshooting.
|
|
111
|
+
LOG_LEVEL: "warning"
|
|
112
|
+
volumes:
|
|
113
|
+
# Mount the host's docker socket READ-ONLY. The proxy is the only
|
|
114
|
+
# consumer of the raw socket. host-cp talks to the proxy over
|
|
115
|
+
# TCP (port 2375 on the internal network).
|
|
116
|
+
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
117
|
+
networks:
|
|
118
|
+
- olam-host-cp-internal
|
|
119
|
+
restart: unless-stopped
|
|
120
|
+
|
|
121
|
+
networks:
|
|
122
|
+
olam-host-cp-internal:
|
|
123
|
+
name: olam-host-cp-internal
|
|
124
|
+
driver: bridge
|
|
125
|
+
# Internal-only: no host port published; host-cp <-> proxy traffic
|
|
126
|
+
# never leaves the docker network.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-facing diagnostic for auth-service authentication failures.
|
|
3
|
+
*
|
|
4
|
+
* Pre-fix, an empty OLAM_AUTH_SECRET (compose.yaml's
|
|
5
|
+
* `${OLAM_AUTH_SECRET:-}` interpolation when the operator's shell
|
|
6
|
+
* didn't export it) silently 401'd every host-cp → auth-service
|
|
7
|
+
* call. The SPA showed "0 credentials" with no log line explaining
|
|
8
|
+
* why. Logging a clear hint — both at boot when the env var is empty
|
|
9
|
+
* AND on the first runtime 401 — turns a silent footgun into a
|
|
10
|
+
* grep-able warning.
|
|
11
|
+
*
|
|
12
|
+
* Lives in its own file (not server.mjs) so unit tests can import it
|
|
13
|
+
* without triggering server.mjs's top-level mkdir + http.listen side
|
|
14
|
+
* effects.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} ctx
|
|
19
|
+
* @param {string} ctx.authServiceUrl
|
|
20
|
+
* The configured auth-service base URL — quoted back to the operator
|
|
21
|
+
* so they can cross-reference with their compose env.
|
|
22
|
+
* @param {boolean} ctx.hasSecret
|
|
23
|
+
* True when host-cp's OLAM_AUTH_SECRET is set (and the 401 means a
|
|
24
|
+
* value mismatch); false when it's empty (the original silent-fail
|
|
25
|
+
* regression mode).
|
|
26
|
+
* @returns {string}
|
|
27
|
+
* A single-line message safe for `console.warn` / docker-compose-logs.
|
|
28
|
+
*/
|
|
29
|
+
export function authSecretHint({ authServiceUrl, hasSecret }) {
|
|
30
|
+
if (!hasSecret) {
|
|
31
|
+
return (
|
|
32
|
+
`[auth] auth-service at ${authServiceUrl} is configured but ` +
|
|
33
|
+
`OLAM_AUTH_SECRET is empty — every credentials/* call will 401. ` +
|
|
34
|
+
`Set the env var to the contents of ~/.olam/auth-secret (or run ` +
|
|
35
|
+
`'olam host-cp start' so the CLI loads it for you).`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
`[auth] auth-service at ${authServiceUrl} returned 401 even though ` +
|
|
40
|
+
`OLAM_AUTH_SECRET is set — the secret does NOT match the value the ` +
|
|
41
|
+
`auth-service container is using. Check that both containers were ` +
|
|
42
|
+
`started from the same ~/.olam/auth-secret file and recreate them ` +
|
|
43
|
+
`together if the file changed.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Phase F-2-B (B4): startup-token authentication for host CP.
|
|
2
|
+
//
|
|
3
|
+
// On boot: generate a 32-byte hex token (or reuse the file if it
|
|
4
|
+
// exists), write to `~/.olam/host-cp.token` with mode 0600, cache in
|
|
5
|
+
// memory. Middleware on all non-static, non-bootstrap routes validates
|
|
6
|
+
// the request via:
|
|
7
|
+
// - Cookie `olam_host_cp_token=<value>`
|
|
8
|
+
// - OR Authorization: Bearer <value>
|
|
9
|
+
// Reject 401 if neither matches.
|
|
10
|
+
//
|
|
11
|
+
// Threat model (T4 mitigation):
|
|
12
|
+
// - Bound to 127.0.0.1:19000 only (compose.yaml). No public exposure.
|
|
13
|
+
// - Single-user-per-host assumption; multi-user is Phase G+.
|
|
14
|
+
// - Token file is chmod 600 owned by the operator. Browser tabs on
|
|
15
|
+
// the same machine that try to hit :19000 are blocked unless they
|
|
16
|
+
// have the token (cookie or header).
|
|
17
|
+
// - /api/bootstrap returns the token unauthenticated. Rationale:
|
|
18
|
+
// anything local that can hit 127.0.0.1:19000 can also read
|
|
19
|
+
// ~/.olam/host-cp.token (same OS-level privilege boundary). This
|
|
20
|
+
// just removes a UX friction step. NOT acceptable in multi-user
|
|
21
|
+
// mode (Phase G+ uses cookie-with-Secure+HttpOnly via real auth).
|
|
22
|
+
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
export class StartupToken {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.tokenPath absolute path to the token file
|
|
31
|
+
* @param {() => string} [opts.generate] defaults to 32-byte hex via crypto.randomBytes
|
|
32
|
+
* @param {(message: string) => void} [opts.log]
|
|
33
|
+
* @param {typeof fs} [opts.fs] injectable for tests
|
|
34
|
+
*/
|
|
35
|
+
constructor({ tokenPath, generate, log = console.log, fs: fsImpl = fs }) {
|
|
36
|
+
if (!tokenPath || !path.isAbsolute(tokenPath)) {
|
|
37
|
+
throw new Error('StartupToken: tokenPath must be an absolute path');
|
|
38
|
+
}
|
|
39
|
+
this.tokenPath = tokenPath;
|
|
40
|
+
this.generate = generate ?? (() => crypto.randomBytes(32).toString('hex'));
|
|
41
|
+
this.log = log;
|
|
42
|
+
this.fs = fsImpl;
|
|
43
|
+
/** @type {string | null} */
|
|
44
|
+
this.token = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ensure the token exists in memory + on disk. Call once at server
|
|
49
|
+
* boot before listen(). Idempotent: subsequent calls return the
|
|
50
|
+
* cached value.
|
|
51
|
+
*
|
|
52
|
+
* Behavior:
|
|
53
|
+
* - If tokenPath exists: read it, cache, return it. (Lifecycle
|
|
54
|
+
* CLI's `olam host-cp start` may have written the token before
|
|
55
|
+
* the container starts; we must reuse the operator-visible
|
|
56
|
+
* value, not regenerate it.)
|
|
57
|
+
* - Else: generate a new token, write file with mode 0600, return.
|
|
58
|
+
*
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
ensure() {
|
|
62
|
+
if (this.token) return this.token;
|
|
63
|
+
const dir = path.dirname(this.tokenPath);
|
|
64
|
+
if (!this.fs.existsSync(dir)) {
|
|
65
|
+
this.fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
if (this.fs.existsSync(this.tokenPath)) {
|
|
68
|
+
const raw = this.fs.readFileSync(this.tokenPath, 'utf-8').trim();
|
|
69
|
+
if (raw.length < 16) {
|
|
70
|
+
// Defensive: a too-short token is almost certainly a corrupted
|
|
71
|
+
// file. Regenerate rather than accept.
|
|
72
|
+
this.log(`auth: existing token at ${this.tokenPath} too short (${raw.length}); regenerating`);
|
|
73
|
+
this.token = this._writeNew();
|
|
74
|
+
} else {
|
|
75
|
+
this.token = raw;
|
|
76
|
+
this.log(`auth: reused existing token at ${this.tokenPath}`);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
this.token = this._writeNew();
|
|
80
|
+
}
|
|
81
|
+
return this.token;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @private */
|
|
85
|
+
_writeNew() {
|
|
86
|
+
const t = this.generate();
|
|
87
|
+
this.fs.writeFileSync(this.tokenPath, t, { mode: 0o600 });
|
|
88
|
+
this.log(`auth: generated new token at ${this.tokenPath} (${t.length} chars)`);
|
|
89
|
+
return t;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check request authorization. Constant-time comparison via
|
|
94
|
+
* crypto.timingSafeEqual prevents timing-side-channel leaks of the
|
|
95
|
+
* token's first-byte mismatches.
|
|
96
|
+
*
|
|
97
|
+
* @param {import('node:http').IncomingMessage} req
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
isAuthorized(req) {
|
|
101
|
+
if (!this.token) return false;
|
|
102
|
+
|
|
103
|
+
// Bearer header
|
|
104
|
+
const authHeader = req.headers['authorization'];
|
|
105
|
+
if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
|
106
|
+
const got = authHeader.slice('Bearer '.length).trim();
|
|
107
|
+
if (this._compare(got)) return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cookie
|
|
111
|
+
const cookieHeader = req.headers['cookie'];
|
|
112
|
+
if (typeof cookieHeader === 'string') {
|
|
113
|
+
const cookies = parseCookies(cookieHeader);
|
|
114
|
+
const got = cookies['olam_host_cp_token'];
|
|
115
|
+
if (got && this._compare(got)) return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @private */
|
|
122
|
+
_compare(got) {
|
|
123
|
+
if (!this.token) return false;
|
|
124
|
+
if (got.length !== this.token.length) return false;
|
|
125
|
+
try {
|
|
126
|
+
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(this.token));
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse a Cookie request header into an object. Handles `; ` separators
|
|
135
|
+
* and `=` value-may-contain-equals (e.g., base64). Empty values + cookies
|
|
136
|
+
* without `=` are tolerated.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} header
|
|
139
|
+
* @returns {Record<string, string>}
|
|
140
|
+
*/
|
|
141
|
+
export function parseCookies(header) {
|
|
142
|
+
/** @type {Record<string, string>} */
|
|
143
|
+
const out = {};
|
|
144
|
+
for (const pair of header.split(';')) {
|
|
145
|
+
const trimmed = pair.trim();
|
|
146
|
+
if (!trimmed) continue;
|
|
147
|
+
const eq = trimmed.indexOf('=');
|
|
148
|
+
if (eq === -1) {
|
|
149
|
+
out[trimmed] = '';
|
|
150
|
+
} else {
|
|
151
|
+
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase E4 (olam-dogfood-vision): WorldsSource composition + dedup.
|
|
3
|
+
*
|
|
4
|
+
* Runs every configured WorldsSource (E1) in parallel and dedupes by
|
|
5
|
+
* `id`. Source-array order expresses precedence: the LAST source to
|
|
6
|
+
* claim an id wins on collision. server.mjs (E4 wiring via
|
|
7
|
+
* `buildWorldsSources`) orders sources `[localSource, pylonSource]`
|
|
8
|
+
* so cloud-side metadata overrides local stubs when the Pylon SDK
|
|
9
|
+
* eventually returns real data for a world that's also docker-
|
|
10
|
+
* resident locally.
|
|
11
|
+
*
|
|
12
|
+
* The function is intentionally pure + dep-free (no env reads, no
|
|
13
|
+
* http, no module-level state) so vitest can drive it with two mock
|
|
14
|
+
* sources to assert dedup direction without spinning up the server.
|
|
15
|
+
*
|
|
16
|
+
* ## Failure-mode contract (CP3 audit follow-up — closes CRIT/HIGH-1+2)
|
|
17
|
+
*
|
|
18
|
+
* Robustness goals:
|
|
19
|
+
* 1. **One bad source must NOT take down the union.** Pylon SDK
|
|
20
|
+
* transient outages, auth errors, network blips — these MUST
|
|
21
|
+
* degrade to "cloud worlds missing this poll" rather than
|
|
22
|
+
* "/api/worlds endpoint hangs". Achieved via `Promise.allSettled`
|
|
23
|
+
* + per-source try/log/treat-as-empty.
|
|
24
|
+
* 2. **Slow sources MUST NOT extend wall time past the SPA poll
|
|
25
|
+
* cadence.** The SPA polls every 4s (Worlds.tsx:124); a Pylon
|
|
26
|
+
* `client.worlds.list()` that takes 8s would block, queue
|
|
27
|
+
* sockets, and pile up overlapping fetches. Achieved via
|
|
28
|
+
* per-source `Promise.race` with `timeoutMs` (default 2000ms,
|
|
29
|
+
* matching the existing docker-inspect timeout in
|
|
30
|
+
* fetchWorldServices). A timed-out source is treated as `[]` for
|
|
31
|
+
* this poll.
|
|
32
|
+
* 3. **A failing source must produce a log line, not a silent
|
|
33
|
+
* empty.** Operators need to see "[worlds-source] pylon-cloud
|
|
34
|
+
* list() failed: <err>" in the host-cp boot log so the
|
|
35
|
+
* degradation is observable.
|
|
36
|
+
*
|
|
37
|
+
* ## Dedup semantics on collision (CP3 audit follow-up — closes HIGH-4)
|
|
38
|
+
*
|
|
39
|
+
* Whole-record replacement (the pre-audit behavior) blanks fields the
|
|
40
|
+
* later source doesn't populate. Concrete example: Pylon returns
|
|
41
|
+
* `{services: undefined}` (or omits the field entirely) for a freshly-
|
|
42
|
+
* claimed world while Local has `{services: [4 entries]}`. Whole-
|
|
43
|
+
* record replacement would drop the local services array; the SPA
|
|
44
|
+
* would render the world with no clickable links until Pylon
|
|
45
|
+
* back-fills.
|
|
46
|
+
*
|
|
47
|
+
* Field-merge (the post-audit behavior): later source's defined
|
|
48
|
+
* fields override earlier; earlier source's fields are preserved
|
|
49
|
+
* where the later source omits them. `id` and `source` always come
|
|
50
|
+
* from the later source (the precedence contract). Implementation:
|
|
51
|
+
* `{ ...byId.get(id), ...world }` — ES spread skips own properties
|
|
52
|
+
* with value `undefined` only if the producer ELIDES them; explicit
|
|
53
|
+
* `field: undefined` does override. Therefore source authors should
|
|
54
|
+
* OMIT fields they don't manage rather than setting them to
|
|
55
|
+
* `undefined` / `[]`.
|
|
56
|
+
*
|
|
57
|
+
* @typedef {import('./worlds-source.mjs').WorldsSource} WorldsSource
|
|
58
|
+
* @typedef {import('./worlds-source.mjs').WorldSummary} WorldSummary
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} ComposeWorldsSourcesOptions
|
|
63
|
+
* @property {number} [timeoutMs=2000]
|
|
64
|
+
* Per-source timeout cap. A source whose `list()` doesn't resolve
|
|
65
|
+
* within this budget is treated as `[]` for this composition pass
|
|
66
|
+
* (logged at error level). Default matches the docker-inspect
|
|
67
|
+
* timeout used elsewhere in host-cp so the /api/worlds path's worst-
|
|
68
|
+
* case wall time stays bounded by it.
|
|
69
|
+
* @property {(sourceName: string, err: unknown) => void} [onSourceError]
|
|
70
|
+
* Invoked when a source rejects or times out. Defaults to
|
|
71
|
+
* `console.error('[worlds-source] <name> list() failed:', err)`.
|
|
72
|
+
* Tests inject a spy to assert log behavior without polluting
|
|
73
|
+
* stderr.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-source last-known-good cache. Keyed by source.name → WorldSummary[].
|
|
80
|
+
* When a source resolves successfully, its output is stored here. When a
|
|
81
|
+
* source rejects or times out, we fall back to the cached value so the
|
|
82
|
+
* dashboard shows stale data rather than blanking. Stale data self-heals
|
|
83
|
+
* on the next successful poll.
|
|
84
|
+
*
|
|
85
|
+
* Process-local, no TTL — the running server is authoritative. Tests that
|
|
86
|
+
* need a clean slate should call _resetLastKnownGoodCache().
|
|
87
|
+
*
|
|
88
|
+
* @type {Map<string, import('./worlds-source.mjs').WorldSummary[]>}
|
|
89
|
+
*/
|
|
90
|
+
const _lastKnownGood = new Map();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wraps a Promise in a per-source timeout race. The timeout error
|
|
94
|
+
* carries the source name so `onSourceError` can log it usefully.
|
|
95
|
+
*
|
|
96
|
+
* @template T
|
|
97
|
+
* @param {Promise<T>} promise
|
|
98
|
+
* @param {number} ms
|
|
99
|
+
* @param {string} sourceName
|
|
100
|
+
* @returns {Promise<T>}
|
|
101
|
+
*/
|
|
102
|
+
function withTimeout(promise, ms, sourceName) {
|
|
103
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
104
|
+
let timer = null;
|
|
105
|
+
const timeout = new Promise((_, reject) => {
|
|
106
|
+
timer = setTimeout(() => {
|
|
107
|
+
reject(new Error(`source "${sourceName}" timed out after ${ms}ms`));
|
|
108
|
+
}, ms);
|
|
109
|
+
});
|
|
110
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
111
|
+
if (timer !== null) clearTimeout(timer);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Reset the last-known-good cache. Exposed for tests only — call before
|
|
117
|
+
* each test that needs a clean slate.
|
|
118
|
+
*/
|
|
119
|
+
export function _resetLastKnownGoodCache() {
|
|
120
|
+
_lastKnownGood.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {WorldsSource[]} sources
|
|
125
|
+
* Sources to compose. Order expresses precedence: later wins.
|
|
126
|
+
* @param {ComposeWorldsSourcesOptions} [options]
|
|
127
|
+
* @returns {Promise<WorldSummary[]>}
|
|
128
|
+
* Deduped union of every source's `list()` output, keyed by `id`.
|
|
129
|
+
* On collision: fields from later source override earlier where
|
|
130
|
+
* defined; earlier fields preserved where later source omits them.
|
|
131
|
+
*/
|
|
132
|
+
export async function composeWorldsSources(sources, options = {}) {
|
|
133
|
+
if (sources.length === 0) return [];
|
|
134
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
135
|
+
const onSourceError =
|
|
136
|
+
options.onSourceError ??
|
|
137
|
+
((name, err) => {
|
|
138
|
+
console.error(`[worlds-source] ${name} list() failed:`, err);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const settled = await Promise.allSettled(
|
|
142
|
+
sources.map((s) => withTimeout(s.list(), timeoutMs, s.name)),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/** @type {Map<string, WorldSummary>} */
|
|
146
|
+
const byId = new Map();
|
|
147
|
+
for (let i = 0; i < settled.length; i++) {
|
|
148
|
+
const result = settled[i];
|
|
149
|
+
const source = sources[i];
|
|
150
|
+
let resolved;
|
|
151
|
+
if (result.status === 'rejected') {
|
|
152
|
+
onSourceError(source.name, result.reason);
|
|
153
|
+
const lkg = _lastKnownGood.get(source.name);
|
|
154
|
+
if (!lkg) continue;
|
|
155
|
+
resolved = lkg;
|
|
156
|
+
} else {
|
|
157
|
+
resolved = result.value;
|
|
158
|
+
_lastKnownGood.set(source.name, result.value);
|
|
159
|
+
}
|
|
160
|
+
for (const world of resolved) {
|
|
161
|
+
// Field-merge on collision: later source overrides earlier
|
|
162
|
+
// where defined; earlier preserved where later omits. Keeps
|
|
163
|
+
// local service-strip + host_port intact when Pylon claims a
|
|
164
|
+
// world but hasn't populated those fields yet.
|
|
165
|
+
const prior = byId.get(world.id);
|
|
166
|
+
byId.set(world.id, prior ? { ...prior, ...world } : world);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return [...byId.values()];
|
|
170
|
+
}
|