@zq-silk/yui 0.2.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as pty from "node-pty";
|
|
2
|
+
import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
3
|
+
/**
|
|
4
|
+
* Creates only a tmux client PTY. Closing it detaches the browser while the
|
|
5
|
+
* native Agent process remains owned by the existing tmux Role window.
|
|
6
|
+
*/
|
|
7
|
+
export class TmuxWebTerminalService {
|
|
8
|
+
options;
|
|
9
|
+
#writers = new Set();
|
|
10
|
+
#spawnPty;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.#spawnPty = options.spawnPty ?? ((command, args, ptyOptions) => (pty.spawn(command, args, ptyOptions)));
|
|
14
|
+
}
|
|
15
|
+
async open(request) {
|
|
16
|
+
const hostId = request.scope === "task" ? request.taskId : "operator";
|
|
17
|
+
if (request.scope === "task") {
|
|
18
|
+
await this.options.prepareTaskRole({
|
|
19
|
+
taskId: request.taskId,
|
|
20
|
+
roleName: request.roleName
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
await this.options.prepareGlobalRole(request.roleName);
|
|
25
|
+
}
|
|
26
|
+
const roleHistory = this.options.tmux.inspectRoleHistory?.(hostId, request.roleName);
|
|
27
|
+
const readOnly = this.#writers.has(hostId)
|
|
28
|
+
|| this.options.tmux.hasWritableClient(hostId);
|
|
29
|
+
if (!readOnly)
|
|
30
|
+
this.#writers.add(hostId);
|
|
31
|
+
let clientSession;
|
|
32
|
+
let process;
|
|
33
|
+
try {
|
|
34
|
+
clientSession = this.options.tmux.createInteractiveClientSession(hostId);
|
|
35
|
+
process = this.#spawnPty(this.options.tmuxBin, [
|
|
36
|
+
"-L", yuiTmuxServerName(this.options.yuiHome),
|
|
37
|
+
"attach-session",
|
|
38
|
+
...(readOnly ? ["-r"] : []),
|
|
39
|
+
"-t",
|
|
40
|
+
`${clientSession}:${request.roleName}`
|
|
41
|
+
], {
|
|
42
|
+
name: "xterm-256color",
|
|
43
|
+
cols: request.columns,
|
|
44
|
+
rows: request.rows,
|
|
45
|
+
cwd: this.options.yuiHome,
|
|
46
|
+
env: {
|
|
47
|
+
...(this.options.environment ?? processEnvironment()),
|
|
48
|
+
TERM: "xterm-256color"
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (!readOnly)
|
|
54
|
+
this.#writers.delete(hostId);
|
|
55
|
+
if (clientSession !== undefined) {
|
|
56
|
+
this.destroyClientSession(clientSession);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
let closed = false;
|
|
61
|
+
let clientReleased = false;
|
|
62
|
+
const releaseClient = () => {
|
|
63
|
+
if (clientReleased)
|
|
64
|
+
return;
|
|
65
|
+
clientReleased = true;
|
|
66
|
+
if (!readOnly)
|
|
67
|
+
this.#writers.delete(hostId);
|
|
68
|
+
this.destroyClientSession(clientSession);
|
|
69
|
+
};
|
|
70
|
+
const dataListeners = new Set();
|
|
71
|
+
const exitListeners = new Set();
|
|
72
|
+
const bufferedData = [];
|
|
73
|
+
let completedExit;
|
|
74
|
+
const dataLifecycle = process.onData((data) => {
|
|
75
|
+
if (dataListeners.size === 0) {
|
|
76
|
+
bufferedData.push(data);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const listener of dataListeners)
|
|
80
|
+
listener(data);
|
|
81
|
+
});
|
|
82
|
+
const exitLifecycle = process.onExit((exit) => {
|
|
83
|
+
completedExit = exit;
|
|
84
|
+
releaseClient();
|
|
85
|
+
for (const listener of exitListeners)
|
|
86
|
+
listener(exit);
|
|
87
|
+
exitListeners.clear();
|
|
88
|
+
});
|
|
89
|
+
const close = () => {
|
|
90
|
+
if (closed)
|
|
91
|
+
return;
|
|
92
|
+
closed = true;
|
|
93
|
+
dataLifecycle.dispose();
|
|
94
|
+
exitLifecycle.dispose();
|
|
95
|
+
dataListeners.clear();
|
|
96
|
+
exitListeners.clear();
|
|
97
|
+
bufferedData.length = 0;
|
|
98
|
+
releaseClient();
|
|
99
|
+
try {
|
|
100
|
+
process.kill();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// The tmux client may already have detached or exited.
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
readOnly,
|
|
108
|
+
...(roleHistory === undefined ? {} : {
|
|
109
|
+
history: {
|
|
110
|
+
limit: roleHistory.actual,
|
|
111
|
+
target: roleHistory.configured
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
onData(listener) {
|
|
115
|
+
if (closed)
|
|
116
|
+
return () => { };
|
|
117
|
+
dataListeners.add(listener);
|
|
118
|
+
for (const data of bufferedData.splice(0))
|
|
119
|
+
listener(data);
|
|
120
|
+
return () => dataListeners.delete(listener);
|
|
121
|
+
},
|
|
122
|
+
onExit(listener) {
|
|
123
|
+
if (completedExit !== undefined) {
|
|
124
|
+
listener(completedExit);
|
|
125
|
+
return () => { };
|
|
126
|
+
}
|
|
127
|
+
if (closed)
|
|
128
|
+
return () => { };
|
|
129
|
+
exitListeners.add(listener);
|
|
130
|
+
return () => exitListeners.delete(listener);
|
|
131
|
+
},
|
|
132
|
+
write(data) {
|
|
133
|
+
if (!readOnly)
|
|
134
|
+
process.write(data);
|
|
135
|
+
},
|
|
136
|
+
resize(columns, rows) {
|
|
137
|
+
process.resize(columns, rows);
|
|
138
|
+
},
|
|
139
|
+
close
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
destroyClientSession(clientSession) {
|
|
143
|
+
try {
|
|
144
|
+
this.options.tmux.destroyInteractiveClientSession(clientSession);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
try {
|
|
148
|
+
this.options.onError?.(error);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Cleanup reporting must not escape the PTY lifecycle.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function processEnvironment() {
|
|
157
|
+
return { ...process.env };
|
|
158
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
4
|
+
import { usageError } from "../errors/cliError.js";
|
|
5
|
+
import { DASHBOARD_HTML, findWebAsset } from "./assets/assetManifest.js";
|
|
6
|
+
import { buildWebDashboardSnapshot, buildWebTaskDetail } from "./webSnapshot.js";
|
|
7
|
+
const MAX_JSON_BODY_BYTES = 16 * 1024;
|
|
8
|
+
const MAX_TERMINAL_MESSAGE_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_TERMINAL_BUFFERED_BYTES = 1024 * 1024;
|
|
10
|
+
export function parseWebCommandOptions(args) {
|
|
11
|
+
let host = "127.0.0.1";
|
|
12
|
+
let port = 4173;
|
|
13
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
14
|
+
const option = args[index];
|
|
15
|
+
const value = args[index + 1];
|
|
16
|
+
if (option === "--host" && value !== undefined) {
|
|
17
|
+
host = value;
|
|
18
|
+
index += 1;
|
|
19
|
+
}
|
|
20
|
+
else if (option === "--port" && value !== undefined) {
|
|
21
|
+
port = Number(value);
|
|
22
|
+
index += 1;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
throw webUsageError();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!new Set(["127.0.0.1", "::1", "localhost"]).has(host)) {
|
|
29
|
+
throw usageError("Web host must be a loopback address (127.0.0.1, ::1, or localhost).", webUsage());
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
32
|
+
throw usageError("Web port must be an integer between 1 and 65535.", webUsage());
|
|
33
|
+
}
|
|
34
|
+
return { host, port };
|
|
35
|
+
}
|
|
36
|
+
export function createYuiWebServer(store, dependencies = {}) {
|
|
37
|
+
const now = dependencies.now ?? (() => new Date());
|
|
38
|
+
const token = dependencies.token ?? randomBytes(24).toString("base64url");
|
|
39
|
+
const webSocketServer = new WebSocketServer({
|
|
40
|
+
noServer: true,
|
|
41
|
+
perMessageDeflate: false,
|
|
42
|
+
maxPayload: MAX_TERMINAL_MESSAGE_BYTES
|
|
43
|
+
});
|
|
44
|
+
const server = createServer((request, response) => {
|
|
45
|
+
void handleHttpRequest(request, response, store, dependencies, token, now).catch(() => {
|
|
46
|
+
if (!response.headersSent)
|
|
47
|
+
setSecurityHeaders(response);
|
|
48
|
+
if (!response.writableEnded) {
|
|
49
|
+
sendJson(response, 500, { error: "Unable to process Yui web request." }, false);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
server.on("upgrade", (request, socket, head) => {
|
|
54
|
+
void handleTerminalUpgrade(request, socket, head, webSocketServer, dependencies.terminal, token);
|
|
55
|
+
});
|
|
56
|
+
server.on("close", () => {
|
|
57
|
+
for (const socket of webSocketServer.clients)
|
|
58
|
+
socket.terminate();
|
|
59
|
+
webSocketServer.close();
|
|
60
|
+
});
|
|
61
|
+
return server;
|
|
62
|
+
}
|
|
63
|
+
export async function startYuiWebServer(store, options, dependencies = {}) {
|
|
64
|
+
const server = createYuiWebServer(store, dependencies);
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
server.once("error", reject);
|
|
67
|
+
server.listen(options.port, options.host, () => {
|
|
68
|
+
server.off("error", reject);
|
|
69
|
+
resolve();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return server;
|
|
73
|
+
}
|
|
74
|
+
async function handleHttpRequest(request, response, store, dependencies, token, now) {
|
|
75
|
+
setSecurityHeaders(response);
|
|
76
|
+
const method = request.method ?? "GET";
|
|
77
|
+
if (!isLoopbackHost(headerValue(request, "host"))) {
|
|
78
|
+
sendJson(response, 403, { error: "Invalid Host." }, method === "HEAD");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let pathname;
|
|
82
|
+
try {
|
|
83
|
+
pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
sendJson(response, 400, { error: "Invalid URL." }, method === "HEAD");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (method === "GET" || method === "HEAD") {
|
|
90
|
+
try {
|
|
91
|
+
const asset = findWebAsset(pathname);
|
|
92
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
93
|
+
sendText(response, 200, "text/html; charset=utf-8", dashboardHtml(token), method === "HEAD");
|
|
94
|
+
}
|
|
95
|
+
else if (asset !== null) {
|
|
96
|
+
sendAsset(response, asset, method === "HEAD");
|
|
97
|
+
}
|
|
98
|
+
else if (pathname === "/api/dashboard") {
|
|
99
|
+
sendJson(response, 200, buildWebDashboardSnapshot(store, now()), method === "HEAD");
|
|
100
|
+
}
|
|
101
|
+
else if (pathname.startsWith("/api/tasks/")) {
|
|
102
|
+
const taskId = decodeURIComponent(pathname.slice("/api/tasks/".length));
|
|
103
|
+
const detail = taskId.length === 0 || taskId.includes("/")
|
|
104
|
+
? null
|
|
105
|
+
: buildWebTaskDetail(store, taskId);
|
|
106
|
+
sendJson(response, detail === null ? 404 : 200, detail ?? { error: "Task not found." }, method === "HEAD");
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
sendJson(response, 404, { error: "Not found." }, method === "HEAD");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof URIError) {
|
|
114
|
+
sendJson(response, 400, { error: "Invalid URL encoding." }, method === "HEAD");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
sendJson(response, 500, { error: "Unable to read Yui state." }, method === "HEAD");
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const answerTarget = parseAnswerPath(pathname);
|
|
122
|
+
if (method === "POST" && answerTarget !== null && dependencies.answerInput !== undefined) {
|
|
123
|
+
if (!tokenMatches(headerValue(request, "x-yui-web-token"), token)) {
|
|
124
|
+
sendJson(response, 403, { error: "Invalid Yui web token." }, false);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let answer;
|
|
128
|
+
try {
|
|
129
|
+
answer = parseWebInputAnswer(await readJsonBody(request));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
sendJson(response, 400, {
|
|
133
|
+
error: error instanceof Error ? error.message : "Invalid input answer."
|
|
134
|
+
}, false);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const answered = await dependencies.answerInput({
|
|
139
|
+
...answerTarget,
|
|
140
|
+
answer
|
|
141
|
+
});
|
|
142
|
+
sendJson(response, 200, { request: answered }, false);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
sendJson(response, 409, {
|
|
146
|
+
error: error instanceof Error ? error.message : "Unable to answer input request."
|
|
147
|
+
}, false);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
response.setHeader("allow", "GET, HEAD");
|
|
152
|
+
sendJson(response, 405, { error: "Method not allowed." }, method === "HEAD");
|
|
153
|
+
}
|
|
154
|
+
async function handleTerminalUpgrade(request, socket, head, webSocketServer, terminalPort, token) {
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
157
|
+
if (!isLoopbackHost(headerValue(request, "host"))) {
|
|
158
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname !== "/api/terminal" || terminalPort === undefined) {
|
|
162
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!tokenMatches(url.searchParams.get("token") ?? undefined, token)) {
|
|
166
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const origin = headerValue(request, "origin");
|
|
170
|
+
const host = headerValue(request, "host");
|
|
171
|
+
if (origin === undefined || host === undefined || origin !== `http://${host}`) {
|
|
172
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const terminalRequest = parseTerminalRequest(url.searchParams);
|
|
176
|
+
webSocketServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
177
|
+
void connectTerminal(webSocket, terminalPort, terminalRequest);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function connectTerminal(webSocket, terminalPort, request) {
|
|
185
|
+
let disconnected = false;
|
|
186
|
+
const markDisconnected = () => {
|
|
187
|
+
disconnected = true;
|
|
188
|
+
};
|
|
189
|
+
webSocket.once("close", markDisconnected);
|
|
190
|
+
webSocket.once("error", markDisconnected);
|
|
191
|
+
let connection;
|
|
192
|
+
try {
|
|
193
|
+
connection = await terminalPort.open(request);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!disconnected) {
|
|
197
|
+
sendWebSocket(webSocket, {
|
|
198
|
+
type: "error",
|
|
199
|
+
message: error instanceof Error ? error.message : "Unable to open terminal."
|
|
200
|
+
});
|
|
201
|
+
webSocket.close(1011, "Unable to open terminal");
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
webSocket.off("close", markDisconnected);
|
|
206
|
+
webSocket.off("error", markDisconnected);
|
|
207
|
+
if (disconnected || webSocket.readyState !== WebSocket.OPEN) {
|
|
208
|
+
connection.close();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
let closed = false;
|
|
212
|
+
let stopData = () => { };
|
|
213
|
+
let stopExit = () => { };
|
|
214
|
+
const close = () => {
|
|
215
|
+
if (closed)
|
|
216
|
+
return;
|
|
217
|
+
closed = true;
|
|
218
|
+
stopData();
|
|
219
|
+
stopExit();
|
|
220
|
+
connection.close();
|
|
221
|
+
};
|
|
222
|
+
webSocket.once("close", close);
|
|
223
|
+
webSocket.once("error", close);
|
|
224
|
+
stopData = connection.onData((data) => {
|
|
225
|
+
if (webSocket.bufferedAmount > MAX_TERMINAL_BUFFERED_BYTES) {
|
|
226
|
+
webSocket.close(1013, "Terminal client is too slow");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
sendWebSocket(webSocket, { type: "data", data });
|
|
230
|
+
});
|
|
231
|
+
if (closed) {
|
|
232
|
+
stopData();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
stopExit = connection.onExit((exit) => {
|
|
236
|
+
sendWebSocket(webSocket, { type: "exit", ...exit });
|
|
237
|
+
webSocket.close(1000, "Terminal detached");
|
|
238
|
+
close();
|
|
239
|
+
});
|
|
240
|
+
if (closed) {
|
|
241
|
+
stopExit();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
webSocket.on("message", (payload, binary) => {
|
|
245
|
+
if (binary) {
|
|
246
|
+
webSocket.close(1003, "Invalid terminal message");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const message = parseTerminalClientMessage(String(payload));
|
|
251
|
+
if (message.type === "resize") {
|
|
252
|
+
connection.resize(message.columns, message.rows);
|
|
253
|
+
}
|
|
254
|
+
else if (!connection.readOnly) {
|
|
255
|
+
connection.write(message.data);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
webSocket.close(1003, "Invalid terminal message");
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
sendWebSocket(webSocket, {
|
|
263
|
+
type: "ready",
|
|
264
|
+
readOnly: connection.readOnly,
|
|
265
|
+
...(connection.history === undefined ? {} : { history: connection.history })
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function parseTerminalRequest(parameters) {
|
|
269
|
+
const scope = parameters.get("scope");
|
|
270
|
+
const roleName = safeIdentity(parameters.get("role"), "Role");
|
|
271
|
+
const columns = boundedInteger(parameters.get("cols"), 20, 400, "Terminal columns");
|
|
272
|
+
const rows = boundedInteger(parameters.get("rows"), 5, 200, "Terminal rows");
|
|
273
|
+
if (scope === "global") {
|
|
274
|
+
if (parameters.has("task"))
|
|
275
|
+
throw new Error("Global terminal cannot include a Task.");
|
|
276
|
+
return { scope, roleName, columns, rows };
|
|
277
|
+
}
|
|
278
|
+
if (scope === "task") {
|
|
279
|
+
return {
|
|
280
|
+
scope,
|
|
281
|
+
taskId: safeIdentity(parameters.get("task"), "Task"),
|
|
282
|
+
roleName,
|
|
283
|
+
columns,
|
|
284
|
+
rows
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
throw new Error("Terminal scope is invalid.");
|
|
288
|
+
}
|
|
289
|
+
function parseTerminalClientMessage(value) {
|
|
290
|
+
const parsed = JSON.parse(value);
|
|
291
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
292
|
+
throw new Error("Terminal message must be an object.");
|
|
293
|
+
}
|
|
294
|
+
const message = parsed;
|
|
295
|
+
if (message.type === "input"
|
|
296
|
+
&& Object.keys(message).length === 2
|
|
297
|
+
&& typeof message.data === "string"
|
|
298
|
+
&& Buffer.byteLength(message.data) <= MAX_TERMINAL_MESSAGE_BYTES) {
|
|
299
|
+
return { type: "input", data: message.data };
|
|
300
|
+
}
|
|
301
|
+
if (message.type === "resize"
|
|
302
|
+
&& Object.keys(message).length === 3
|
|
303
|
+
&& Number.isInteger(message.columns)
|
|
304
|
+
&& Number.isInteger(message.rows)) {
|
|
305
|
+
return {
|
|
306
|
+
type: "resize",
|
|
307
|
+
columns: boundedInteger(String(message.columns), 20, 400, "Terminal columns"),
|
|
308
|
+
rows: boundedInteger(String(message.rows), 5, 200, "Terminal rows")
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
throw new Error("Terminal message is invalid.");
|
|
312
|
+
}
|
|
313
|
+
function parseAnswerPath(pathname) {
|
|
314
|
+
const match = /^\/api\/tasks\/([^/]+)\/inputs\/([^/]+)\/answer$/u.exec(pathname);
|
|
315
|
+
if (match === null)
|
|
316
|
+
return null;
|
|
317
|
+
try {
|
|
318
|
+
return {
|
|
319
|
+
taskId: safeIdentity(decodeURIComponent(match[1]), "Task"),
|
|
320
|
+
inputId: safeIdentity(decodeURIComponent(match[2]), "Input request")
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function parseWebInputAnswer(value) {
|
|
328
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
329
|
+
throw new Error("Input answer must be an object.");
|
|
330
|
+
}
|
|
331
|
+
const answer = value;
|
|
332
|
+
if (Object.keys(answer).length !== 1) {
|
|
333
|
+
throw new Error("Exactly one of choiceKey or text is required.");
|
|
334
|
+
}
|
|
335
|
+
if (typeof answer.choiceKey === "string" && answer.choiceKey.trim().length > 0) {
|
|
336
|
+
return { choiceKey: answer.choiceKey.trim() };
|
|
337
|
+
}
|
|
338
|
+
if (typeof answer.text === "string" && answer.text.trim().length > 0) {
|
|
339
|
+
return { text: answer.text.trim() };
|
|
340
|
+
}
|
|
341
|
+
throw new Error("Exactly one non-empty choiceKey or text is required.");
|
|
342
|
+
}
|
|
343
|
+
async function readJsonBody(request) {
|
|
344
|
+
const chunks = [];
|
|
345
|
+
let size = 0;
|
|
346
|
+
for await (const chunk of request) {
|
|
347
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
348
|
+
size += buffer.length;
|
|
349
|
+
if (size > MAX_JSON_BODY_BYTES)
|
|
350
|
+
throw new Error("Request body is too large.");
|
|
351
|
+
chunks.push(buffer);
|
|
352
|
+
}
|
|
353
|
+
if (size === 0)
|
|
354
|
+
throw new Error("Request body is required.");
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
throw new Error("Request body must be valid JSON.");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function setSecurityHeaders(response) {
|
|
363
|
+
response.setHeader("cache-control", "no-store");
|
|
364
|
+
response.setHeader("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
|
|
365
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
366
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
367
|
+
response.setHeader("x-frame-options", "DENY");
|
|
368
|
+
}
|
|
369
|
+
function sendJson(response, status, value, head) {
|
|
370
|
+
sendText(response, status, "application/json; charset=utf-8", JSON.stringify(value), head);
|
|
371
|
+
}
|
|
372
|
+
function sendText(response, status, contentType, body, head) {
|
|
373
|
+
response.statusCode = status;
|
|
374
|
+
response.setHeader("content-type", contentType);
|
|
375
|
+
response.setHeader("content-length", Buffer.byteLength(body));
|
|
376
|
+
response.end(head ? undefined : body);
|
|
377
|
+
}
|
|
378
|
+
function sendAsset(response, asset, head) {
|
|
379
|
+
const payload = asset.encoding === "base64"
|
|
380
|
+
? Buffer.from(asset.body, "base64")
|
|
381
|
+
: Buffer.from(asset.body, "utf-8");
|
|
382
|
+
if (asset.contentType.startsWith("font/") || asset.contentType.startsWith("image/")) {
|
|
383
|
+
response.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
384
|
+
}
|
|
385
|
+
response.statusCode = 200;
|
|
386
|
+
response.setHeader("content-type", asset.contentType);
|
|
387
|
+
response.setHeader("content-length", payload.length);
|
|
388
|
+
response.end(head ? undefined : payload);
|
|
389
|
+
}
|
|
390
|
+
function sendWebSocket(webSocket, value) {
|
|
391
|
+
if (webSocket.readyState === WebSocket.OPEN) {
|
|
392
|
+
webSocket.send(JSON.stringify(value));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function rejectUpgrade(socket, status, message) {
|
|
396
|
+
if (socket.destroyed)
|
|
397
|
+
return;
|
|
398
|
+
socket.end(`HTTP/1.1 ${status} ${message}\r\n`
|
|
399
|
+
+ "Connection: close\r\n"
|
|
400
|
+
+ "Content-Length: 0\r\n\r\n");
|
|
401
|
+
}
|
|
402
|
+
function dashboardHtml(token) {
|
|
403
|
+
return DASHBOARD_HTML.replace('content="__YUI_WEB_TOKEN__"', `content="${escapeHtmlAttribute(token)}"`);
|
|
404
|
+
}
|
|
405
|
+
function tokenMatches(candidate, expected) {
|
|
406
|
+
if (candidate === undefined)
|
|
407
|
+
return false;
|
|
408
|
+
const left = Buffer.from(candidate);
|
|
409
|
+
const right = Buffer.from(expected);
|
|
410
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
411
|
+
}
|
|
412
|
+
function headerValue(request, name) {
|
|
413
|
+
const value = request.headers[name];
|
|
414
|
+
return Array.isArray(value) ? value[0] : value;
|
|
415
|
+
}
|
|
416
|
+
function isLoopbackHost(value) {
|
|
417
|
+
if (value === undefined)
|
|
418
|
+
return false;
|
|
419
|
+
let url;
|
|
420
|
+
try {
|
|
421
|
+
url = new URL(`http://${value}`);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if (url.username.length > 0
|
|
427
|
+
|| url.password.length > 0
|
|
428
|
+
|| url.pathname !== "/"
|
|
429
|
+
|| url.search.length > 0
|
|
430
|
+
|| url.hash.length > 0) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const hostname = url.hostname.toLowerCase();
|
|
434
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
435
|
+
}
|
|
436
|
+
function safeIdentity(value, label) {
|
|
437
|
+
if (value === null || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(value)) {
|
|
438
|
+
throw new Error(`${label} is invalid.`);
|
|
439
|
+
}
|
|
440
|
+
return value;
|
|
441
|
+
}
|
|
442
|
+
function boundedInteger(value, minimum, maximum, label) {
|
|
443
|
+
if (value === null || !/^[0-9]+$/u.test(value))
|
|
444
|
+
throw new Error(`${label} is invalid.`);
|
|
445
|
+
const number = Number(value);
|
|
446
|
+
if (!Number.isInteger(number) || number < minimum || number > maximum) {
|
|
447
|
+
throw new Error(`${label} must be between ${minimum} and ${maximum}.`);
|
|
448
|
+
}
|
|
449
|
+
return number;
|
|
450
|
+
}
|
|
451
|
+
function escapeHtmlAttribute(value) {
|
|
452
|
+
return value
|
|
453
|
+
.replaceAll("&", "&")
|
|
454
|
+
.replaceAll('"', """)
|
|
455
|
+
.replaceAll("<", "<")
|
|
456
|
+
.replaceAll(">", ">");
|
|
457
|
+
}
|
|
458
|
+
function webUsageError() {
|
|
459
|
+
return usageError("Web usage: yui web [--host <loopback>] [--port <port>].", webUsage());
|
|
460
|
+
}
|
|
461
|
+
function webUsage() {
|
|
462
|
+
return "Usage: yui web [--host <127.0.0.1|::1|localhost>] [--port <1-65535>]";
|
|
463
|
+
}
|