elanous 0.2.8 → 0.2.9
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/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/bFI5EEwg7T6aG88ibX4Zq/_buildManifest.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8630-cdfc61e82c653ffc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8894-7317998f6cb97d8c.js → 8894-e263b332208e3957.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-fb7c2d458f32fcad.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-f4d2ecbbae6088d6.js → page-35e2713597dd24d1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-2053e49930c82748.js → page-918b656f064577ba.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-d4b6df905f185055.js → page-f972fc1c00a62e88.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-a70ba27c33003dd0.js → page-5f30e00693527ebd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-7fbae173b3c103a7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-18166f885d831d3e.js → page-3b4edcce364062ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/{page-1c9d6cfe68b121c2.js → page-c5794f484c251f44.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-1130d8eefcd87e76.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-973996d584f008c1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-147a328753d983e2.js → page-016e2bda04eecb1f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-799395a9df7d95aa.js → page-b4d6d9a3b56f2940.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-6a8158b71d1fe9d7.js → page-ec49a466121233ba.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-f72db356744ceade.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-a6fd9b50d1b85378.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/{page-c0c0d19d51342d3e.js → page-8b0402a46a749a3d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-2e002ad8faa43291.js → page-037f83e2a1edc8e2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-b672e02e4bec8deb.js → page-adfbd2ba9598e59a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-46fedff57d4bec67.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/{page-9db2226f38c542fb.js → page-0cf318e9dcee2bbf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-899b327cb8b6845c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-6c1e6ba743a29dfe.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{page-70ee65f5f7a151dc.js → page-1a8e4807292f6b17.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-28deba6bcb834916.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-092e1bcf27d751cc.js → page-de68e2205794037d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-eb52580c4b75b905.js → page-c71c33a573230672.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-7464c1987dd76e6d.js → page-0df30e0c7e50148a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-4fc56790fe0d2682.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-59df2aebb38a7c1e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/{page-085847b088675978.js → page-4647be5dbcbd0795.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/{page-9fe9d38c36999eb4.js → page-e025892bda74b743.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-369846d2921af584.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/{page-295a02b1026b8255.js → page-72cbc1c8cdd0f65d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-77b28bfb0996d5ea.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/page-3220c4934f49c6c2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-90670589b87abea8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/{page-2e367075ffdbc062.js → page-7df9243ed021a4cc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-647ed2b66c1d49f1.js → page-4498c3853f4f688d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-35bedde6d885f08f.js +1 -0
- package/apps/pwa/out/_next/static/css/f92f63a5df0ba229.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +30 -29
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +30 -29
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +30 -29
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +27 -26
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +27 -26
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +27 -26
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +30 -29
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +30 -29
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +30 -29
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +30 -29
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +30 -29
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +30 -29
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +27 -26
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +30 -29
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +30 -29
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +27 -26
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +27 -26
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +30 -29
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +30 -29
- package/apps/pwa/out/ops/checklist/index.html +1 -0
- package/apps/pwa/out/ops/checklist/index.txt +30 -0
- package/apps/pwa/out/ops/release/index.html +1 -0
- package/apps/pwa/out/ops/release/index.txt +30 -0
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +28 -27
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +30 -29
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +27 -26
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +30 -29
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +30 -29
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +30 -29
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +30 -29
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +30 -29
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +27 -26
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +30 -29
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +30 -29
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +30 -29
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +30 -29
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +30 -29
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +30 -29
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +30 -29
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +30 -29
- package/graphs/release/recipes.yaml +3 -0
- package/graphs/release/release-loop.yaml +3 -1
- package/graphs/test-diet/nightly-audit.yaml +18 -0
- package/graphs/test-diet/recipes.yaml +3 -0
- package/package.json +1 -1
- package/plugins/doc-draft/graphs/run-step.ts +1 -1
- package/scripts/release-loop/cutoff.ts +20 -2
- package/scripts/release-loop/export-check-node.ts +85 -0
- package/scripts/release-loop/gate-node.ts +18 -4
- package/scripts/release-loop/node-verdict.ts +8 -1
- package/scripts/test-diet/lib.ts +1 -0
- package/scripts/test-diet/node.ts +56 -14
- package/src/auth/setup-link-tokens.fixture.ts +19 -0
- package/src/auth/setup-link-tokens.ts +153 -0
- package/src/budget/unified-usage.ts +75 -0
- package/src/channel-bot-token.ts +37 -10
- package/src/chat/index.ts +9 -9
- package/src/cli/op-proxy-sync.ts +83 -0
- package/src/cli/pr-cli.ts +6 -2
- package/src/cli/release-cli.ts +41 -1
- package/src/dashboard/dashboard-status-lines.ts +45 -0
- package/src/dashboard/first-screen-band.ts +35 -0
- package/src/dashboard/index.ts +70 -13
- package/src/dashboard/provider-slash-runtime.ts +28 -11
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +10 -0
- package/src/dashboard/turn-stream-formatter.ts +1 -1
- package/src/decisions/decision-cards.ts +1 -1
- package/src/index.ts +16 -4
- package/src/maturity/feature-maturity.ts +2 -0
- package/src/nexus/api/http-server.ts +20 -0
- package/src/nexus/api/meta-api.ts +27 -0
- package/src/nexus/api/operator.ts +61 -0
- package/src/nexus/api/ops-api.ts +139 -0
- package/src/nexus/api/ops-seats-sources.ts +65 -0
- package/src/nexus/api/ops-seats.ts +114 -0
- package/src/nexus/api/public-routes.ts +14 -1
- package/src/nexus/api/setup-claim.ts +21 -0
- package/src/nexus/config/secrets/file-backend.ts +56 -18
- package/src/onboarding/first-run-doctor.ts +54 -0
- package/src/release-loop/checklist.ts +5 -64
- package/src/release-loop/feature-store.ts +327 -0
- package/src/release-loop/manifest.ts +59 -5
- package/src/seat-dispatch/ceo-commands.ts +65 -0
- package/src/skills/tools/usage-slash.ts +8 -7
- package/src/telegram-commands.ts +16 -0
- package/src/telegram-run.ts +83 -13
- package/src/thinking-line.ts +28 -20
- package/src/tui/chat/execution-badge.ts +1 -1
- package/src/ux-sim/turn-states.ts +1 -1
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-3084c58c1725a40b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-e7ed3d010153d818.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-e573437b0a1c728f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-82efa177a30f3741.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +0 -1
- package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +0 -1
- package/apps/pwa/out/_next/static/lywc8m_HDzH9MKSDQhyEj/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{lywc8m_HDzH9MKSDQhyEj → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
package/src/dashboard/index.ts
CHANGED
|
@@ -881,6 +881,10 @@ import { getModelFamily, getModelTier } from '../models/prompts.js';
|
|
|
881
881
|
import { buildPromptInjection, getPromptBankStore } from '../prompt-bank/index.js';
|
|
882
882
|
import { getInputHistoryStore, inputHistoryDbPath, inputHistoryJsonPath } from '../input-history.js';
|
|
883
883
|
import { inspectActiveProvider } from '../provider-summary.js';
|
|
884
|
+
import { activeCodexAccountView } from '../oauth/codex-account-store.js';
|
|
885
|
+
import { buildDashboardStatusLines } from './dashboard-status-lines.js';
|
|
886
|
+
import { buildFirstScreenBand } from './first-screen-band.js';
|
|
887
|
+
import { runNexusShow, type NexusShowResult } from '../cli/nexus-show.js';
|
|
884
888
|
import {
|
|
885
889
|
workingDirSegment, sessionCwdSegment, modelSegment, gitSegment,
|
|
886
890
|
ctxBarSegment, costSegment, speedSegment, elapsedSegment, cacheSegment,
|
|
@@ -2100,6 +2104,35 @@ export function createDashboardLogSearchOpener(deps: {
|
|
|
2100
2104
|
};
|
|
2101
2105
|
}
|
|
2102
2106
|
|
|
2107
|
+
export function renderDashboardFirstScreenBand(input: {
|
|
2108
|
+
readonly history: ReadonlyArray<{ role: string }>;
|
|
2109
|
+
readonly lines: string[];
|
|
2110
|
+
readonly width: number;
|
|
2111
|
+
readonly draw: () => void;
|
|
2112
|
+
readonly observe: (data: { daemon: boolean; hasPwa: boolean; hasTailnet: boolean }) => void;
|
|
2113
|
+
readonly show?: () => Promise<Pick<NexusShowResult, 'status' | 'urls'>>;
|
|
2114
|
+
}): Promise<void> {
|
|
2115
|
+
if (input.history.some((message) => message.role !== 'system')) return Promise.resolve();
|
|
2116
|
+
const band = buildFirstScreenBand({ width: input.width, daemon: false });
|
|
2117
|
+
input.lines.unshift(...band);
|
|
2118
|
+
const show = input.show ?? (() => runNexusShow({ format: 'json', out: { log: () => {}, error: () => {} } }));
|
|
2119
|
+
const observe = (data: { daemon: boolean; hasPwa: boolean; hasTailnet: boolean }): void => {
|
|
2120
|
+
try { input.observe(data); } catch { /* observation cannot interrupt the transcript */ }
|
|
2121
|
+
};
|
|
2122
|
+
return Promise.resolve().then(show).then((result) => {
|
|
2123
|
+
const daemon = result.status !== 'absent';
|
|
2124
|
+
const pwa = result.urls?.pwa;
|
|
2125
|
+
const hasPwa = !!pwa;
|
|
2126
|
+
const hasTailnet = !!pwa?.tailnet;
|
|
2127
|
+
observe({ daemon, hasPwa, hasTailnet });
|
|
2128
|
+
if (input.lines[1] !== band[1]) return;
|
|
2129
|
+
input.lines[1] = buildFirstScreenBand({ width: input.width, daemon, pwa })[1]!;
|
|
2130
|
+
try { input.draw(); } catch { /* screen may have closed */ }
|
|
2131
|
+
}).catch(() => {
|
|
2132
|
+
observe({ daemon: false, hasPwa: false, hasTailnet: false });
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2103
2136
|
export async function showDashboard(opts: ShowDashboardOptions = {}): Promise<DashboardAction> {
|
|
2104
2137
|
const dashboardQuitConfirmationState = createQuitConfirmationState();
|
|
2105
2138
|
const signalQuitConfirmationState = createQuitConfirmationState();
|
|
@@ -6330,19 +6363,34 @@ Mode- and sync-specific instructions are injected per-turn when relevant — do
|
|
|
6330
6363
|
draw,
|
|
6331
6364
|
});
|
|
6332
6365
|
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
)
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6366
|
+
let daemonAttached = false;
|
|
6367
|
+
const getDashboardStatusLines = () => {
|
|
6368
|
+
const cfg = getUserConfig();
|
|
6369
|
+
const active = inspectActiveProvider(cfg);
|
|
6370
|
+
const reasoningProvider = (['openai-codex', 'openai', 'anthropic', 'gemini', 'grok', 'local', 'openrouter'] as const)
|
|
6371
|
+
.find(provider => active.provider === provider || active.provider === `auto:${provider}`);
|
|
6372
|
+
return buildDashboardStatusLines({
|
|
6373
|
+
provider: active.provider,
|
|
6374
|
+
model: active.model,
|
|
6375
|
+
reasoning: reasoningProvider
|
|
6376
|
+
? modelSupportsReasoning(reasoningProvider, active.model)
|
|
6377
|
+
? reasoningLevelLabel(effectiveReasoningLevel(cfg.llm, reasoningProvider, active.model))
|
|
6378
|
+
: '미지원'
|
|
6379
|
+
: undefined,
|
|
6380
|
+
daemonAddress: opts.remote?.url,
|
|
6381
|
+
daemonConnected: daemonAttached,
|
|
6382
|
+
accountName: active.provider === 'openai-codex' || active.provider === 'auto:openai-codex'
|
|
6383
|
+
? activeCodexAccountView().name : undefined,
|
|
6384
|
+
sessionSurfaceLines: buildSessionDashboardStatusLines(resolveSessionSurfaceStatus({ chatModeState })),
|
|
6385
|
+
view: String(workingDir.view),
|
|
6386
|
+
focus: workingDir.focus,
|
|
6387
|
+
cwd: workingDir.cwd,
|
|
6388
|
+
preview: dockedPreview.sourceMode,
|
|
6389
|
+
starterClosed: closedStarterPanes().length ? closedStarterPanes().map((pane) => paneLabel(pane)).join(', ') : 'none',
|
|
6390
|
+
chatOnly: `${effectiveChatOnlyMode() ? 'on' : 'off'}${chatOnlyMode ? ' (manual)' : ''}`,
|
|
6391
|
+
acp: dashboardAcpChat.isBusy() ? 'busy' : 'idle',
|
|
6392
|
+
});
|
|
6393
|
+
};
|
|
6346
6394
|
|
|
6347
6395
|
bootDashboardSlashExecutor({
|
|
6348
6396
|
initDashboardSlashExecutor,
|
|
@@ -15397,6 +15445,7 @@ Mode- and sync-specific instructions are injected per-turn when relevant — do
|
|
|
15397
15445
|
...(opts.remote ? {
|
|
15398
15446
|
remote: opts.remote,
|
|
15399
15447
|
onRemoteDisconnect: () => {
|
|
15448
|
+
daemonAttached = false;
|
|
15400
15449
|
pushChatLine(C.warning(' ⚠ 데몬 연결이 끊겼다 · 입력 중인 글은 그대로다 · 다시 붙으려면 TUI 를 다시 띄워라'));
|
|
15401
15450
|
draw();
|
|
15402
15451
|
},
|
|
@@ -15610,6 +15659,7 @@ Mode- and sync-specific instructions are injected per-turn when relevant — do
|
|
|
15610
15659
|
},
|
|
15611
15660
|
});
|
|
15612
15661
|
const dashboardAcpSession: DashboardSession = dashboardAcpBootResult.session;
|
|
15662
|
+
daemonAttached = !!opts.remote && dashboardAcpBootResult.mode !== 'in-process';
|
|
15613
15663
|
// ⭐ `B3` — 턴 중 발화를 «도는 턴»에 넣을 때 쓰는 키.
|
|
15614
15664
|
try { acpSessionIdRef.current = dashboardAcpSession.currentSessionId ?? null; } catch { /* 없으면 넣지 않는다 */ }
|
|
15615
15665
|
pushDebugLine(C.muted('[acp-boot] dashboard ACP session ready'));
|
|
@@ -15702,6 +15752,13 @@ Mode- and sync-specific instructions are injected per-turn when relevant — do
|
|
|
15702
15752
|
}
|
|
15703
15753
|
}
|
|
15704
15754
|
chatScrollOffset = -1;
|
|
15755
|
+
void renderDashboardFirstScreenBand({
|
|
15756
|
+
history: chat.history,
|
|
15757
|
+
lines: chatLines,
|
|
15758
|
+
width: Math.max(0, termSize().cols - 4),
|
|
15759
|
+
draw: () => draw(),
|
|
15760
|
+
observe: (data) => { try { debug.log('dashboard.first-screen-band', 'resolved', data); } catch { /* observation is best-effort */ } },
|
|
15761
|
+
});
|
|
15705
15762
|
|
|
15706
15763
|
while (true) {
|
|
15707
15764
|
draw();
|
|
@@ -8,7 +8,7 @@ export interface DashboardProviderRotationEntryView {
|
|
|
8
8
|
export interface DashboardProviderAvailabilityView {
|
|
9
9
|
available: boolean;
|
|
10
10
|
name: string;
|
|
11
|
-
model: string;
|
|
11
|
+
model: string | undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface DashboardProviderSlashRuntimeDeps {
|
|
@@ -31,6 +31,7 @@ export interface DashboardProviderSlashRuntime {
|
|
|
31
31
|
overviewLines(
|
|
32
32
|
rotationEntries: readonly DashboardProviderRotationEntryView[],
|
|
33
33
|
providers: readonly DashboardProviderAvailabilityView[],
|
|
34
|
+
catalogModelIds?: ReadonlySet<string>,
|
|
34
35
|
): string[];
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -48,25 +49,41 @@ export function createDashboardProviderSlashRuntime(
|
|
|
48
49
|
switchedLine: (label, provider, model) => deps.success(` ✓ switched → ${label} (${providerDetail(provider, model)})`),
|
|
49
50
|
resetEmptyLine: () => deps.warning(' rotation empty — nothing to reset'),
|
|
50
51
|
resetLine: (label) => deps.success(` ✓ reset → ${label}`),
|
|
51
|
-
overviewLines: (rotationEntries, providers) => {
|
|
52
|
+
overviewLines: (rotationEntries, providers, catalogModelIds) => {
|
|
52
53
|
const lines = ['', deps.accent('\u276f /provider')];
|
|
53
54
|
if (rotationEntries.length > 0) {
|
|
54
|
-
lines.push(deps.muted('
|
|
55
|
+
lines.push(deps.muted(' 회전(바꾸기: /provider next · /provider use <이름>)'));
|
|
55
56
|
for (const entry of rotationEntries) {
|
|
56
57
|
const marker = entry.current ? deps.success('▸') : ' ';
|
|
57
|
-
|
|
58
|
+
const missing = catalogModelIds && entry.model !== '(provider default)' && !catalogModelIds.has(entry.model)
|
|
59
|
+
? deps.muted(' · 목록에 없는 모델 — /provider use 로 바꾸기')
|
|
60
|
+
: '';
|
|
61
|
+
lines.push(` ${marker} ${entry.label.padEnd(14)} ${deps.subtext(entry.provider)} ${deps.muted(entry.model)}${missing}`);
|
|
58
62
|
}
|
|
59
63
|
lines.push('');
|
|
60
64
|
}
|
|
61
|
-
lines.push(deps.muted('
|
|
65
|
+
lines.push(deps.muted(' 쓸 수 있는 공급자'));
|
|
66
|
+
// PROVIDERS has anthropic, not claude; collapse the legacy claude display alias.
|
|
67
|
+
const canonicalName = (name: string): string => {
|
|
68
|
+
const lower = name.toLowerCase();
|
|
69
|
+
return lower === 'claude' ? 'anthropic' : lower;
|
|
70
|
+
};
|
|
71
|
+
const unique = new Map<string, DashboardProviderAvailabilityView>();
|
|
62
72
|
for (const provider of providers) {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
const key = canonicalName(provider.name);
|
|
74
|
+
const previous = unique.get(key);
|
|
75
|
+
if (!previous || (!previous.available && provider.available) || (key === provider.name && previous.name !== key && previous.available === provider.available)) {
|
|
76
|
+
unique.set(key, provider);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let unavailable = 0;
|
|
80
|
+
for (const provider of unique.values()) {
|
|
81
|
+
if (!provider.available) { unavailable++; continue; }
|
|
82
|
+
lines.push(` ${deps.success('\u2713')} ${deps.text(provider.name).padEnd(20)} ${deps.subtext(provider.model ?? '')}`);
|
|
69
83
|
}
|
|
84
|
+
if (unavailable > 0) lines.push(deps.muted(` 그 밖 ${unavailable}개는 키·로그인 필요 — elanous setup llm`));
|
|
85
|
+
// Keep the old English search terms as a subordinate hint, not section headings.
|
|
86
|
+
lines.push(deps.muted(' · Rotation (cycle with /provider next) · Available providers'));
|
|
70
87
|
return lines;
|
|
71
88
|
},
|
|
72
89
|
};
|
|
@@ -301,6 +301,7 @@ import {
|
|
|
301
301
|
import type { LLMProviderName } from '../../user-config.js';
|
|
302
302
|
import { lookupLlmTierSpec } from '../../model-tier/index.js';
|
|
303
303
|
import { listProviders } from '../../llm.js';
|
|
304
|
+
import { enabledModels, loadCatalog } from '../../intelligence-map/model-catalog.js';
|
|
304
305
|
import { selfOrchestrateRuntime } from '../../self-dev/self-orchestrate-runtime.js';
|
|
305
306
|
import type { ToolRuntime } from '../../tool-runtime/types.js';
|
|
306
307
|
import { getSessionCwd } from '../../session/working-dir.js';
|
|
@@ -742,6 +743,7 @@ export interface DashboardSlashContext {
|
|
|
742
743
|
overviewLines(
|
|
743
744
|
rotation: readonly { label: string; provider: string; model: string; current: boolean }[],
|
|
744
745
|
providers: readonly { available: boolean; name: string; model: string | undefined }[],
|
|
746
|
+
catalogModelIds?: ReadonlySet<string>,
|
|
745
747
|
): readonly string[];
|
|
746
748
|
};
|
|
747
749
|
/** 2026-05-05 — open the visual model picker programmatically.
|
|
@@ -5281,6 +5283,13 @@ export function buildDashboardSlashRegistry(): SlashCommandRegistry<DashboardSla
|
|
|
5281
5283
|
const cfgNow = getUserConfig();
|
|
5282
5284
|
const rot = cfgNow.llm.rotation;
|
|
5283
5285
|
const curIdx = rot && rot.length > 0 ? currentRotationIndex(cfgNow) : -1;
|
|
5286
|
+
let catalogModelIds: Set<string> | undefined;
|
|
5287
|
+
try {
|
|
5288
|
+
const loaded = loadCatalog();
|
|
5289
|
+
if (loaded.source !== 'fallback') {
|
|
5290
|
+
catalogModelIds = new Set(enabledModels(loaded.catalog).map((model) => model.id));
|
|
5291
|
+
}
|
|
5292
|
+
} catch { /* catalog unreadable: no stale-model verdict */ }
|
|
5284
5293
|
ctx.chatLines.push(...ctx.provider.slashRuntime.overviewLines(
|
|
5285
5294
|
(rot ?? []).map((entry, index) => ({
|
|
5286
5295
|
label: rotationEntryLabel(entry),
|
|
@@ -5293,6 +5302,7 @@ export function buildDashboardSlashRegistry(): SlashCommandRegistry<DashboardSla
|
|
|
5293
5302
|
name: provider.name,
|
|
5294
5303
|
model: provider.model,
|
|
5295
5304
|
})),
|
|
5305
|
+
catalogModelIds,
|
|
5296
5306
|
));
|
|
5297
5307
|
const currentRoute = currentRouteDecision('dashboard');
|
|
5298
5308
|
if (currentRoute) ctx.chatLines.push(ctx.muted(` current route: ${formatRouteDecisionSummary(currentRoute)}`));
|
|
@@ -236,7 +236,7 @@ export function createTurnStreamFormatter(
|
|
|
236
236
|
},
|
|
237
237
|
onToolCall(call: TurnStreamCall): void {
|
|
238
238
|
toolCallCount += 1;
|
|
239
|
-
deps.thinking.update(`Streaming ${call.name} (${toolCallCount} tools)`);
|
|
239
|
+
deps.thinking.update(`Streaming ${call.name} (${toolCallCount} ${toolCallCount === 1 ? 'tool' : 'tools'})`);
|
|
240
240
|
const ptyLine = deps.ptyCallLine(call.name, call.args);
|
|
241
241
|
if (ptyLine) {
|
|
242
242
|
nonGenericCallIds.add(call.id);
|
|
@@ -277,7 +277,7 @@ export function ghPrReplier(target: string, run: (args: string[], stdin: string)
|
|
|
277
277
|
|
|
278
278
|
/** Post as the automation App (not the machine's personal gh login — that would make 대표 a participant and mail them),
|
|
279
279
|
* with proxy variables removed (they break gh on the ops host). TC review #22660. */
|
|
280
|
-
async function runGh(args: string[], stdin: string): Promise<number> {
|
|
280
|
+
export async function runGh(args: string[], stdin: string): Promise<number> {
|
|
281
281
|
const env: Record<string, string> = {};
|
|
282
282
|
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !/^(https?|all|no)_proxy$/i.test(k)) env[k] = v;
|
|
283
283
|
try {
|
package/src/index.ts
CHANGED
|
@@ -10114,10 +10114,14 @@ tokenCmd
|
|
|
10114
10114
|
.description('Rotate the ACP admin token')
|
|
10115
10115
|
.option('--grace-ms <ms>', 'Previous-token grace period in milliseconds', (value: string) => Number(value))
|
|
10116
10116
|
.option('--show-token', 'Print the complete new token')
|
|
10117
|
-
.
|
|
10117
|
+
.option('--sync-op-proxy', 'Update the configured op proxy bearer over SSH after rotation')
|
|
10118
|
+
.action(async (opts: { graceMs?: number; showToken?: boolean; syncOpProxy?: boolean }) => {
|
|
10118
10119
|
if (opts.graceMs !== undefined && (!Number.isFinite(opts.graceMs) || opts.graceMs < 0)) {
|
|
10119
10120
|
throw new Error('--grace-ms must be a non-negative finite number');
|
|
10120
10121
|
}
|
|
10122
|
+
if (opts.syncOpProxy && opts.showToken) {
|
|
10123
|
+
throw new Error('--sync-op-proxy cannot be combined with --show-token');
|
|
10124
|
+
}
|
|
10121
10125
|
const [{ rotateAdminToken }, { getElanousConfigDir }] = await Promise.all([
|
|
10122
10126
|
import('./auth/token-store.js'),
|
|
10123
10127
|
import('./elanous-config-dir.js'),
|
|
@@ -10128,9 +10132,17 @@ tokenCmd
|
|
|
10128
10132
|
});
|
|
10129
10133
|
if (opts.showToken) {
|
|
10130
10134
|
console.log(result.newActive);
|
|
10131
|
-
|
|
10132
|
-
|
|
10133
|
-
|
|
10135
|
+
} else {
|
|
10136
|
+
console.log(opts.syncOpProxy
|
|
10137
|
+
? `ACP admin token rotated: length=${result.newActive.length}`
|
|
10138
|
+
: `ACP admin token rotated: length=${result.newActive.length} prefix=${result.newActive.slice(0, 4)}`);
|
|
10139
|
+
}
|
|
10140
|
+
const { readOpProxyConfig, finishOpProxyRotation } = await import('./cli/op-proxy-sync.js');
|
|
10141
|
+
finishOpProxyRotation(readOpProxyConfig(), result.newActive, {
|
|
10142
|
+
sync: opts.syncOpProxy === true,
|
|
10143
|
+
tokenFile: `${getElanousConfigDir()}/acp-token`,
|
|
10144
|
+
...(opts.showToken ? { out: (line: string) => console.error(line) } : {}),
|
|
10145
|
+
});
|
|
10134
10146
|
});
|
|
10135
10147
|
|
|
10136
10148
|
const acpCmd = program.command('acp').description('Agent Client Protocol — spawn ACP agents (claude-code, codex, gemini)');
|
|
@@ -38,6 +38,8 @@ export const FEATURE_MATURITY = {
|
|
|
38
38
|
'/design-check': { pwa: 'tool' },
|
|
39
39
|
'/control': { pwa: 'tool' },
|
|
40
40
|
'/scheduler': { pwa: 'ops' },
|
|
41
|
+
'/ops/release': { pwa: 'ops' },
|
|
42
|
+
'/ops/checklist': { pwa: 'ops' },
|
|
41
43
|
'/dashboard': { pwa: 'ops' },
|
|
42
44
|
'/bots': { pwa: 'ops' },
|
|
43
45
|
'/botlab': { pwa: 'ops' },
|
|
@@ -155,11 +155,14 @@ import {
|
|
|
155
155
|
import { handleBuildsGet, parseBuildsPath } from './builds-api.js';
|
|
156
156
|
import { handleHarnessAskPost, handleHarnessAskStatusGet, handleHarnessRunEventsGet, handleHarnessRunScreenGet, handleHarnessRunsGet, handleHarnessStopPost } from './harness-api.js';
|
|
157
157
|
import { dispatchPersonaRoute } from './personas.js';
|
|
158
|
+
import { handleMe } from './operator.js';
|
|
159
|
+
import { handleOpsApi } from './ops-api.js';
|
|
158
160
|
import { handleRoleJudge } from './role-judge.js';
|
|
159
161
|
import { handleAudioStt } from './audio-stt.js';
|
|
160
162
|
import { handleLlmModels } from './llm-models.js';
|
|
161
163
|
import { handleLlmHostsConfig } from './llm-hosts-config.js';
|
|
162
164
|
import { handleLlmRotationGet, handleLlmRotationNext } from './llm-rotation.js';
|
|
165
|
+
import { handleSetupClaim } from './setup-claim.js';
|
|
163
166
|
import {
|
|
164
167
|
handleLlmProvidersList,
|
|
165
168
|
handleLlmProviderSet,
|
|
@@ -312,6 +315,7 @@ import {
|
|
|
312
315
|
handleVoiceTranscribe,
|
|
313
316
|
checkAuth,
|
|
314
317
|
bearerCredential,
|
|
318
|
+
setupBearerScopeDenied,
|
|
315
319
|
registerAuthPeerAddress,
|
|
316
320
|
type MetaApiOpts,
|
|
317
321
|
} from './meta-api.js';
|
|
@@ -826,6 +830,9 @@ export async function routeRequest(
|
|
|
826
830
|
registerAuthPeerAddress(req, (server as BunServerLike & {
|
|
827
831
|
requestIP?: (request: Request) => { address: string } | null;
|
|
828
832
|
}).requestIP?.(req)?.address);
|
|
833
|
+
if (opts.metaApi && setupBearerScopeDenied(req, opts.metaApi)) {
|
|
834
|
+
return jsonResponse({ error: 'forbidden' }, 403);
|
|
835
|
+
}
|
|
829
836
|
|
|
830
837
|
// PR c — try WS upgrade before HTTP routing. On undefined return the
|
|
831
838
|
// bridge has accepted the upgrade (Bun completes the handshake) and
|
|
@@ -852,6 +859,12 @@ export async function routeRequest(
|
|
|
852
859
|
const { pathname } = url;
|
|
853
860
|
const method = req.method;
|
|
854
861
|
|
|
862
|
+
// Operator proxy credentials are distinct from the owner bearer. The ops handler
|
|
863
|
+
// performs its own operatorSignal gate before reading any run or checklist data.
|
|
864
|
+
if (pathname === '/v1/ops' || pathname.startsWith('/v1/ops/')) {
|
|
865
|
+
return handleOpsApi(req, opts.metaApi);
|
|
866
|
+
}
|
|
867
|
+
|
|
855
868
|
// Default-deny for every `/v1/` path that is not on PUBLIC_ROUTES.
|
|
856
869
|
// Reuses checkAuth (same-origin exemption + constant-time bearer).
|
|
857
870
|
// setupMode is not implemented on this server (P24b) — pass false.
|
|
@@ -1520,6 +1533,10 @@ export async function routeRequest(
|
|
|
1520
1533
|
if (pathname === '/v1/llm/rotation' && method === 'OPTIONS') {
|
|
1521
1534
|
return handleLlmRotationGet(req);
|
|
1522
1535
|
}
|
|
1536
|
+
// The one-use link is verified by the claim handler, not by the owner bearer.
|
|
1537
|
+
if (pathname === '/v1/setup/claim' && (method === 'POST' || method === 'OPTIONS')) {
|
|
1538
|
+
return method === 'OPTIONS' ? corsPreflight('POST, OPTIONS') : handleSetupClaim(req);
|
|
1539
|
+
}
|
|
1523
1540
|
// PWA `/setup` wizard Phase 1 (2026-05-19) — LLM provider 첫 셋업.
|
|
1524
1541
|
// POST 는 mutation 블록 안에서 등록 (memory `feedback_post_route_must_be_in_method_block`).
|
|
1525
1542
|
// GET sibling 은 아래 GET-only 블록에 등록.
|
|
@@ -2153,6 +2170,9 @@ export async function routeRequest(
|
|
|
2153
2170
|
}
|
|
2154
2171
|
return jsonResponse({ error: 'static-not-wired' }, 404);
|
|
2155
2172
|
}
|
|
2173
|
+
// OPS1·OPS2 — whether this caller is an operator (draws the ops area). Answers everyone before any auth gate;
|
|
2174
|
+
// false unless this daemon's config has operator.enabled.
|
|
2175
|
+
if (pathname === '/v1/me' && req.method === 'GET') return handleMe(req, opts.metaApi ?? ({} as MetaApiOpts));
|
|
2156
2176
|
if (pathname === '/v1/health') {
|
|
2157
2177
|
return handleHealth(opts.state, opts.registry, {
|
|
2158
2178
|
bindHost: bind.hostname,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { randomUUID } from 'node:crypto';
|
|
18
18
|
import { matchTempToken } from '../../auth/temp-tokens.js';
|
|
19
|
+
import { matchSetupBearer } from '../../auth/setup-link-tokens.js';
|
|
19
20
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
21
|
import { extname } from 'node:path';
|
|
21
22
|
|
|
@@ -215,6 +216,8 @@ type AuthReason =
|
|
|
215
216
|
| 'untrusted-same-origin-peer'
|
|
216
217
|
| 'bearer-match'
|
|
217
218
|
| 'temp-token'
|
|
219
|
+
| 'setup-bearer'
|
|
220
|
+
| 'setup-scope-denied'
|
|
218
221
|
| 'no-bearer-configured'
|
|
219
222
|
| 'missing-auth-header'
|
|
220
223
|
| 'bearer-length-mismatch'
|
|
@@ -248,8 +251,27 @@ export function registerAuthPeerAddress(req: Request, peerAddress: string | unde
|
|
|
248
251
|
authPeerAddresses.set(req, peerAddress);
|
|
249
252
|
}
|
|
250
253
|
|
|
254
|
+
function setupRouteAllowed(req: Request): boolean {
|
|
255
|
+
const path = new URL(req.url).pathname;
|
|
256
|
+
if (path.startsWith('/v1/setup/')) return true;
|
|
257
|
+
return (req.method === 'POST' && path === '/v1/prompt/stream')
|
|
258
|
+
|| (req.method === 'GET' && (
|
|
259
|
+
/^\/v1\/sessions\/store\/[^/]+$/.test(path) || path === '/v1/chat/events'
|
|
260
|
+
));
|
|
261
|
+
}
|
|
262
|
+
|
|
251
263
|
function decideAuth(req: Request, opts: MetaApiOpts): { ok: boolean; reason: AuthReason } {
|
|
252
264
|
if (opts.noAuth) return { ok: true, reason: 'noauth' };
|
|
265
|
+
const offered = req.headers.get('authorization');
|
|
266
|
+
if (offered?.startsWith('Bearer elsb_')) {
|
|
267
|
+
const owner = decideBearer(req, opts);
|
|
268
|
+
if (owner.ok) return owner;
|
|
269
|
+
const bearer = offered.slice('Bearer '.length).trim();
|
|
270
|
+
if (!matchSetupBearer(bearer)) return { ok: false, reason: 'bearer-mismatch' };
|
|
271
|
+
return setupRouteAllowed(req)
|
|
272
|
+
? { ok: true, reason: 'setup-bearer' }
|
|
273
|
+
: { ok: false, reason: 'setup-scope-denied' };
|
|
274
|
+
}
|
|
253
275
|
const peerAddress = authPeerAddresses.get(req);
|
|
254
276
|
if (isSameOriginRequest(req, peerAddress)) return { ok: true, reason: 'same-origin' };
|
|
255
277
|
const peerRejected = req.headers.get('sec-fetch-site') === 'same-origin'
|
|
@@ -292,6 +314,10 @@ export function bearerCredential(req: Request, opts: MetaApiOpts): 'noauth' | 'b
|
|
|
292
314
|
// check — without duplicating security-sensitive code.
|
|
293
315
|
const authDecisions = new WeakMap<Request, { ok: boolean; reason: AuthReason }>();
|
|
294
316
|
|
|
317
|
+
export function setupBearerScopeDenied(req: Request, opts: MetaApiOpts): boolean {
|
|
318
|
+
return decideAuth(req, opts).reason === 'setup-scope-denied';
|
|
319
|
+
}
|
|
320
|
+
|
|
295
321
|
export function checkAuth(req: Request, opts: MetaApiOpts): boolean {
|
|
296
322
|
const decision = decideAuth(req, opts);
|
|
297
323
|
authDecisions.set(req, decision);
|
|
@@ -322,6 +348,7 @@ export function checkAuth(req: Request, opts: MetaApiOpts): boolean {
|
|
|
322
348
|
* intentionally collapse here to avoid exposing token-length information. */
|
|
323
349
|
function authFailureResponse(req: Request, opts: MetaApiOpts): Response {
|
|
324
350
|
const decision = authDecisions.get(req) ?? decideAuth(req, opts);
|
|
351
|
+
if (decision.reason === 'setup-scope-denied') return jsonResponse({ error: 'forbidden' }, 403);
|
|
325
352
|
return jsonResponse({ error: 'unauthorized', reason: publicAuthFailureReason(decision.reason) }, 401);
|
|
326
353
|
}
|
|
327
354
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// OPS1·OPS2 — who is an «operator» (sees the ops area). Off unless this daemon's config says `operator.enabled: true`
|
|
2
|
+
// (only our own machines); an external install never reports an operator, whatever header or token arrives.
|
|
3
|
+
// Design: 내부 문서 `DESIGN-pwa-menu-reorg-and-ops-area-2026-10-02` §2.
|
|
4
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { debug } from '../../debug/log.js';
|
|
7
|
+
import { getUserConfig } from '../../user-config.js';
|
|
8
|
+
import { checkAuth, type MetaApiOpts } from './meta-api.js';
|
|
9
|
+
|
|
10
|
+
/** Set by the op.elanous.ai proxy (Caddy) after Google sign-in, server side; the proxy strips any incoming copy. */
|
|
11
|
+
export const OPERATOR_HEADER = 'x-elanous-operator';
|
|
12
|
+
|
|
13
|
+
export type OperatorSource = 'op-proxy' | 'owner' | null;
|
|
14
|
+
export interface OperatorSignal { operator: boolean; operatorSource: OperatorSource }
|
|
15
|
+
export interface OperatorConfig { enabled: boolean; proxySecretFile?: string }
|
|
16
|
+
|
|
17
|
+
export function readOperatorConfig(raw: unknown = getUserConfig().raw?.operator): OperatorConfig {
|
|
18
|
+
const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
|
|
19
|
+
return {
|
|
20
|
+
enabled: o.enabled === true,
|
|
21
|
+
...(typeof o.proxySecretFile === 'string' && o.proxySecretFile.trim() ? { proxySecretFile: o.proxySecretFile.trim() } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The proxy secret, only from a regular file nobody but the owner can read (mode 600); anything else turns the header path off. */
|
|
26
|
+
export function readProxySecret(path: string | undefined): string | null {
|
|
27
|
+
if (!path) return null;
|
|
28
|
+
try {
|
|
29
|
+
const st = statSync(path);
|
|
30
|
+
if (!st.isFile() || (st.mode & 0o077) !== 0) return null;
|
|
31
|
+
const value = readFileSync(path, 'utf8').trim();
|
|
32
|
+
return value.length >= 16 ? value : null;
|
|
33
|
+
} catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sameSecret(offered: string, secret: string): boolean {
|
|
37
|
+
const a = Buffer.from(offered);
|
|
38
|
+
const b = Buffer.from(secret);
|
|
39
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OperatorDeps { config?: OperatorConfig; readSecret?: (path: string | undefined) => string | null; isOwner?: (req: Request) => boolean }
|
|
43
|
+
|
|
44
|
+
export function operatorSignal(req: Request, opts: MetaApiOpts, deps: OperatorDeps = {}): OperatorSignal {
|
|
45
|
+
const config = deps.config ?? readOperatorConfig();
|
|
46
|
+
if (!config.enabled) return { operator: false, operatorSource: null };
|
|
47
|
+
const offered = req.headers.get(OPERATOR_HEADER);
|
|
48
|
+
if (offered) {
|
|
49
|
+
const secret = (deps.readSecret ?? readProxySecret)(config.proxySecretFile);
|
|
50
|
+
if (secret && sameSecret(offered, secret)) return { operator: true, operatorSource: 'op-proxy' };
|
|
51
|
+
}
|
|
52
|
+
if ((deps.isOwner ?? ((r: Request) => checkAuth(r, opts)))(req)) return { operator: true, operatorSource: 'owner' };
|
|
53
|
+
return { operator: false, operatorSource: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `GET /v1/me` — answers every caller (no auth needed: it only says whether to draw the ops area). */
|
|
57
|
+
export function handleMe(req: Request, opts: MetaApiOpts, deps: OperatorDeps = {}): Response {
|
|
58
|
+
const signal = operatorSignal(req, opts, deps);
|
|
59
|
+
debug.log('ops.api', 'me', { operator: signal.operator, source: signal.operatorSource });
|
|
60
|
+
return Response.json(signal, { headers: { 'cache-control': 'no-store' } });
|
|
61
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { debug, redactSecretText } from '../../debug/log.js';
|
|
4
|
+
import { effectiveInstanceRoot } from '../../instance/resolve.js';
|
|
5
|
+
import { listChecklist } from '../../release-loop/checklist.js';
|
|
6
|
+
import { jsonResponse } from './json-response.js';
|
|
7
|
+
import { operatorSignal } from './operator.js';
|
|
8
|
+
import { createSeatsCache, todayKst } from './ops-seats.js';
|
|
9
|
+
import { liveSeatsSources } from './ops-seats-sources.js';
|
|
10
|
+
import type { MetaApiOpts } from './meta-api.js';
|
|
11
|
+
|
|
12
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/;
|
|
13
|
+
const IDENTIFIER = /^[A-Za-z0-9-]+$/;
|
|
14
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
let seatsCache: ReturnType<typeof createSeatsCache> | null = null;
|
|
16
|
+
/** Tests swap the sources; production builds them lazily on first use. */
|
|
17
|
+
export function setSeatsCacheForTest(cache: ReturnType<typeof createSeatsCache> | null): void { seatsCache = cache; }
|
|
18
|
+
const LOG_PATH = /^\/v1\/ops\/release\/runs\/([^/]+)\/nodes\/([^/]+)\/log$/;
|
|
19
|
+
|
|
20
|
+
interface RunNode { nodeId: string; ok?: unknown; output?: unknown }
|
|
21
|
+
interface RunRecord {
|
|
22
|
+
status: string;
|
|
23
|
+
path: string[];
|
|
24
|
+
startedAt: string;
|
|
25
|
+
input?: unknown;
|
|
26
|
+
nodes: RunNode[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function recordAt(path: string): RunRecord | null {
|
|
30
|
+
try {
|
|
31
|
+
if (!lstatSync(path).isFile()) return null;
|
|
32
|
+
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
33
|
+
if (!value || typeof value !== 'object') return null;
|
|
34
|
+
const run = value as Partial<RunRecord>;
|
|
35
|
+
if (typeof run.status !== 'string' || !Array.isArray(run.path)
|
|
36
|
+
|| typeof run.startedAt !== 'string' || !Array.isArray(run.nodes)) return null;
|
|
37
|
+
return run as RunRecord;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function outputText(output: unknown): string {
|
|
42
|
+
if (typeof output === 'string') return output;
|
|
43
|
+
if (output === undefined || output === null) return '';
|
|
44
|
+
return JSON.stringify(output) ?? '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nodeSummary(output: unknown): string {
|
|
48
|
+
let value = output;
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
const text = value;
|
|
51
|
+
try { value = JSON.parse(text) as unknown; }
|
|
52
|
+
catch {
|
|
53
|
+
const last = text.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
|
|
54
|
+
try { value = JSON.parse(last ?? '') as unknown; } catch { return ''; }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!value || typeof value !== 'object') return '';
|
|
58
|
+
const summary = (value as { summary?: unknown }).summary;
|
|
59
|
+
return typeof summary === 'string' ? redactSecretText(summary).slice(0, 300) : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runVersion(input: unknown): string | null {
|
|
63
|
+
if (!input || typeof input !== 'object') return null;
|
|
64
|
+
const version = (input as { version?: unknown }).version;
|
|
65
|
+
return typeof version === 'string' && VERSION.test(version) ? version : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The sole data gate for every read-only /v1/ops endpoint. */
|
|
69
|
+
export function handleOpsApi(req: Request, metaApi: MetaApiOpts | undefined): Response | Promise<Response> {
|
|
70
|
+
const url = new URL(req.url);
|
|
71
|
+
const { pathname } = url;
|
|
72
|
+
if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
73
|
+
|
|
74
|
+
const signal = metaApi ? operatorSignal(req, metaApi) : { operator: false, operatorSource: null };
|
|
75
|
+
if (!signal.operator) {
|
|
76
|
+
debug.log('ops.api', 'refused', { path: pathname, source: signal.operatorSource });
|
|
77
|
+
return jsonResponse({ error: 'forbidden' }, 403);
|
|
78
|
+
}
|
|
79
|
+
const served = (response: Response): Response => {
|
|
80
|
+
debug.log('ops.api', 'served', { path: pathname, source: signal.operatorSource });
|
|
81
|
+
response.headers.set('cache-control', 'no-store');
|
|
82
|
+
return response;
|
|
83
|
+
};
|
|
84
|
+
if (pathname === '/v1/ops/seats') {
|
|
85
|
+
const date = url.searchParams.get('date') ?? todayKst();
|
|
86
|
+
if (!DATE.test(date) || Number.isNaN(Date.parse(`${date}T00:00:00+09:00`))) return jsonResponse({ error: 'invalid-date' }, 400);
|
|
87
|
+
seatsCache ??= createSeatsCache(liveSeatsSources());
|
|
88
|
+
return seatsCache(date).then((board) => served(jsonResponse(board)), () => jsonResponse({ error: 'seats-unavailable' }, 500));
|
|
89
|
+
}
|
|
90
|
+
const version = url.searchParams.get('version');
|
|
91
|
+
if (pathname === '/v1/ops/checklist') {
|
|
92
|
+
if (!version || !VERSION.test(version)) return jsonResponse({ error: 'invalid-version' }, 400);
|
|
93
|
+
return served(jsonResponse(listChecklist(version)));
|
|
94
|
+
}
|
|
95
|
+
if (pathname === '/v1/ops/release/runs') {
|
|
96
|
+
if (version !== null && !VERSION.test(version)) return jsonResponse({ error: 'invalid-version' }, 400);
|
|
97
|
+
const dir = join(effectiveInstanceRoot(), 'graph-runs', 'release-loop');
|
|
98
|
+
let filenames: string[];
|
|
99
|
+
try {
|
|
100
|
+
filenames = readdirSync(dir, { withFileTypes: true })
|
|
101
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json')
|
|
102
|
+
&& IDENTIFIER.test(entry.name.slice(0, -5)))
|
|
103
|
+
.map((entry) => entry.name);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return served(jsonResponse([]));
|
|
107
|
+
return jsonResponse({ error: 'runs-unavailable' }, 500);
|
|
108
|
+
}
|
|
109
|
+
const runs = filenames.flatMap((filename) => {
|
|
110
|
+
const run = recordAt(join(dir, filename));
|
|
111
|
+
if (!run) return [];
|
|
112
|
+
const v = runVersion(run.input);
|
|
113
|
+
if (version !== null && v !== version) return [];
|
|
114
|
+
return [{
|
|
115
|
+
runId: redactSecretText(filename.slice(0, -5)), status: redactSecretText(run.status),
|
|
116
|
+
startedAt: redactSecretText(run.startedAt), version: v,
|
|
117
|
+
path: run.path.filter((step): step is string => typeof step === 'string').map(redactSecretText),
|
|
118
|
+
nodes: run.nodes.filter((node) => node && typeof node.nodeId === 'string').map((node) => ({
|
|
119
|
+
nodeId: redactSecretText(node.nodeId), ok: typeof node.ok === 'boolean' ? node.ok : null,
|
|
120
|
+
summary: nodeSummary(node.output),
|
|
121
|
+
})),
|
|
122
|
+
}];
|
|
123
|
+
}).sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 20);
|
|
124
|
+
return served(jsonResponse(runs));
|
|
125
|
+
}
|
|
126
|
+
// WHATWG URL normalizes a literal runs/../nodes/... before routing.
|
|
127
|
+
if (pathname.startsWith('/v1/ops/release/nodes/')) return jsonResponse({ error: 'invalid-id' }, 400);
|
|
128
|
+
if (pathname.startsWith('/v1/ops/release/runs/')) {
|
|
129
|
+
const match = LOG_PATH.exec(pathname);
|
|
130
|
+
if (!match) return jsonResponse({ error: 'invalid-id' }, 400);
|
|
131
|
+
const [, id, nodeId] = match;
|
|
132
|
+
if (!IDENTIFIER.test(id!) || !IDENTIFIER.test(nodeId!)) return jsonResponse({ error: 'invalid-id' }, 400);
|
|
133
|
+
const run = recordAt(join(effectiveInstanceRoot(), 'graph-runs', 'release-loop', `${id}.json`));
|
|
134
|
+
const node = run?.nodes.find((candidate) => candidate?.nodeId === nodeId);
|
|
135
|
+
if (!node || node.output === undefined || node.output === null) return jsonResponse({ error: 'not-found' }, 404);
|
|
136
|
+
return served(jsonResponse({ log: redactSecretText(outputText(node.output)).slice(-4_000) }));
|
|
137
|
+
}
|
|
138
|
+
return jsonResponse({ error: 'not-found' }, 404);
|
|
139
|
+
}
|