@profoundlogic/coderflow-server 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/dist/base-image/entrypoint.sh +2 -1
  2. package/dist/coder-server.js +1 -1
  3. package/dist/config.js +1 -1
  4. package/dist/lib/agent-keepalive.js +1 -1
  5. package/dist/lib/agent-models.js +1 -1
  6. package/dist/lib/api-keys.js +1 -1
  7. package/dist/lib/apiKeys.js +1 -1
  8. package/dist/lib/app-server-ports.js +1 -1
  9. package/dist/lib/auto-judge.js +1 -1
  10. package/dist/lib/automation-service.js +1 -0
  11. package/dist/lib/basic-auth.js +1 -1
  12. package/dist/lib/bindings.js +1 -0
  13. package/dist/lib/build-history.js +1 -1
  14. package/dist/lib/build-output-service.js +1 -1
  15. package/dist/lib/build-scheduler.js +1 -1
  16. package/dist/lib/build-service.js +1 -1
  17. package/dist/lib/ca-certificates.js +1 -1
  18. package/dist/lib/claude-oauth-refresh.js +1 -1
  19. package/dist/lib/cli/build.js +1 -1
  20. package/dist/lib/cli/config-command.js +1 -1
  21. package/dist/lib/cli/config.js +1 -1
  22. package/dist/lib/cli/create-user.js +1 -1
  23. package/dist/lib/cli/init.js +1 -1
  24. package/dist/lib/cli/jira.js +1 -1
  25. package/dist/lib/cli/license.js +1 -1
  26. package/dist/lib/cli/server-manager.js +1 -1
  27. package/dist/lib/config-migration.js +1 -1
  28. package/dist/lib/container-credential-sync.js +1 -1
  29. package/dist/lib/container-tokens.js +1 -1
  30. package/dist/lib/data-dir.js +1 -1
  31. package/dist/lib/deployment-history.js +1 -1
  32. package/dist/lib/deployment-service.js +1 -1
  33. package/dist/lib/docker-utils.js +1 -1
  34. package/dist/lib/email.js +1 -1
  35. package/dist/lib/emailTemplates.js +1 -1
  36. package/dist/lib/entitlement.js +1 -1
  37. package/dist/lib/fetch-utils.js +1 -1
  38. package/dist/lib/git-commit-details-route.js +1 -1
  39. package/dist/lib/git-history-diff-guardrails.js +1 -1
  40. package/dist/lib/git-provider-service.js +1 -1
  41. package/dist/lib/git-provider-setup/github-setup-handler.js +1 -1
  42. package/dist/lib/git-provider-setup/index.js +1 -1
  43. package/dist/lib/git-provider-setup/setup-factory.js +1 -1
  44. package/dist/lib/git-provider-setup/setup-interface.js +1 -1
  45. package/dist/lib/git-providers/azure-devops-provider.js +1 -1
  46. package/dist/lib/git-providers/github-app-provider.js +1 -1
  47. package/dist/lib/git-providers/index.js +1 -1
  48. package/dist/lib/git-providers/provider-factory.js +1 -1
  49. package/dist/lib/git-providers/provider-interface.js +1 -1
  50. package/dist/lib/github-urls.js +1 -1
  51. package/dist/lib/group-objective-linking.js +1 -1
  52. package/dist/lib/jira-client.js +1 -1
  53. package/dist/lib/judge-blinding.js +1 -1
  54. package/dist/lib/logger.js +1 -1
  55. package/dist/lib/migration-to-scoped-rbac.js +1 -0
  56. package/dist/lib/model-fetcher.js +1 -1
  57. package/dist/lib/notifications.js +1 -1
  58. package/dist/lib/objective-context.js +1 -1
  59. package/dist/lib/oidc-auth.js +1 -1
  60. package/dist/lib/oidc-device-flow.js +1 -1
  61. package/dist/lib/passwordTokens.js +1 -1
  62. package/dist/lib/permission-resolver.js +1 -0
  63. package/dist/lib/pin-cascade.js +1 -1
  64. package/dist/lib/provider-accounts.js +1 -1
  65. package/dist/lib/provider-oauth.js +1 -1
  66. package/dist/lib/provider-profile.js +1 -1
  67. package/dist/lib/provider-token-refresh.js +1 -1
  68. package/dist/lib/request-url.js +1 -1
  69. package/dist/lib/rewind.js +1 -1
  70. package/dist/lib/role-definitions.js +1 -0
  71. package/dist/lib/roles.js +1 -1
  72. package/dist/lib/secrets.js +1 -1
  73. package/dist/lib/setup-repo-git-auth.js +1 -1
  74. package/dist/lib/state-capture.js +1 -1
  75. package/dist/lib/static-files.js +1 -1
  76. package/dist/lib/task-name-format.js +1 -1
  77. package/dist/lib/task-name-generator.js +1 -1
  78. package/dist/lib/task-source-metadata.js +1 -0
  79. package/dist/lib/teams.js +1 -0
  80. package/dist/lib/user-git-oauth.js +1 -1
  81. package/dist/lib/user-git-tokens.js +1 -1
  82. package/dist/lib/users.js +1 -1
  83. package/dist/middleware/requireAuth.js +1 -1
  84. package/dist/middleware/requireInit.js +1 -1
  85. package/dist/middleware/requirePermission.js +1 -1
  86. package/dist/package-lock.json +211 -21
  87. package/dist/package.json +2 -1
  88. package/dist/playwright.config.js +1 -0
  89. package/dist/routes/apiKeys.js +1 -1
  90. package/dist/routes/auth-oidc.js +1 -1
  91. package/dist/routes/auth.js +1 -1
  92. package/dist/routes/automations.js +1 -0
  93. package/dist/routes/bindings.js +1 -0
  94. package/dist/routes/build.js +1 -1
  95. package/dist/routes/containers.js +1 -1
  96. package/dist/routes/deploy-task.js +1 -1
  97. package/dist/routes/environment-management.js +1 -1
  98. package/dist/routes/environments.js +1 -1
  99. package/dist/routes/external-skills.js +1 -1
  100. package/dist/routes/git-credentials.js +1 -1
  101. package/dist/routes/git-oauth.js +1 -1
  102. package/dist/routes/git-provider-setup.js +1 -1
  103. package/dist/routes/health.js +1 -1
  104. package/dist/routes/jira.js +1 -1
  105. package/dist/routes/objective-management.js +1 -1
  106. package/dist/routes/password.js +1 -1
  107. package/dist/routes/prompt.js +1 -1
  108. package/dist/routes/provider-auth.js +1 -1
  109. package/dist/routes/qa.js +1 -1
  110. package/dist/routes/roles.js +1 -0
  111. package/dist/routes/settings.js +1 -1
  112. package/dist/routes/skill-management.js +1 -1
  113. package/dist/routes/skills.js +1 -1
  114. package/dist/routes/tasks.js +1 -1
  115. package/dist/routes/teams.js +1 -0
  116. package/dist/routes/templates.js +1 -1
  117. package/dist/routes/test-task.js +1 -1
  118. package/dist/routes/test.js +1 -1
  119. package/dist/routes/users.js +1 -1
  120. package/dist/routes/visualizations.js +1 -1
  121. package/dist/scripts/create-user.js +1 -1
  122. package/dist/scripts/migrate-config-to-data-dir.js +1 -1
  123. package/dist/scripts/migrate-to-scoped-rbac.js +2 -0
  124. package/dist/start.js +1 -1
  125. package/dist/start.js.bak +1381 -0
  126. package/dist/web-ui/public/activity-detail-modal.js +1 -1
  127. package/dist/web-ui/public/activity-feed.js +1 -1
  128. package/dist/web-ui/public/activity-formatters.js +1 -1
  129. package/dist/web-ui/public/agent-event-parser.js +1 -1
  130. package/dist/web-ui/public/app.js +1 -1
  131. package/dist/web-ui/public/approve-dialog.js +1 -1
  132. package/dist/web-ui/public/automation-links.js +1 -0
  133. package/dist/web-ui/public/automation-schedule.js +1 -0
  134. package/dist/web-ui/public/comments-widget.js +1 -1
  135. package/dist/web-ui/public/diff-utils.js +1 -1
  136. package/dist/web-ui/public/docs/_sidebar.md +1 -0
  137. package/dist/web-ui/public/docs/admin/automations.md +75 -0
  138. package/dist/web-ui/public/docs/admin/users-and-roles.md +14 -4
  139. package/dist/web-ui/public/environments.css +247 -125
  140. package/dist/web-ui/public/environments.html +346 -2
  141. package/dist/web-ui/public/environments.js +1 -1
  142. package/dist/web-ui/public/feedback-widget.css +42 -0
  143. package/dist/web-ui/public/feedback-widget.js +1 -1
  144. package/dist/web-ui/public/git-history-lazy-utils.js +1 -1
  145. package/dist/web-ui/public/git-history.html +15 -0
  146. package/dist/web-ui/public/git-history.js +1 -1
  147. package/dist/web-ui/public/git-status.js +1 -1
  148. package/dist/web-ui/public/index.html +27 -0
  149. package/dist/web-ui/public/index.js +1 -1
  150. package/dist/web-ui/public/login.js +1 -1
  151. package/dist/web-ui/public/markdown-editor.js +1 -1
  152. package/dist/web-ui/public/markdown-file-editor.js +1 -1
  153. package/dist/web-ui/public/modal-maximize.js +1 -1
  154. package/dist/web-ui/public/notifications.js +1 -1
  155. package/dist/web-ui/public/pr-dialog.js +1 -1
  156. package/dist/web-ui/public/roles.html +247 -0
  157. package/dist/web-ui/public/roles.js +1 -0
  158. package/dist/web-ui/public/server-health.js +1 -1
  159. package/dist/web-ui/public/settings.html +62 -0
  160. package/dist/web-ui/public/settings.js +1 -1
  161. package/dist/web-ui/public/setup-password.js +1 -1
  162. package/dist/web-ui/public/skills.html +15 -0
  163. package/dist/web-ui/public/skills.js +1 -1
  164. package/dist/web-ui/public/sse-client.js +1 -1
  165. package/dist/web-ui/public/sse-shared-worker.js +1 -1
  166. package/dist/web-ui/public/styles.css +198 -161
  167. package/dist/web-ui/public/task.html +2 -2
  168. package/dist/web-ui/public/task.js +1 -1
  169. package/dist/web-ui/public/teams.html +285 -0
  170. package/dist/web-ui/public/teams.js +1 -0
  171. package/dist/web-ui/public/terminal.js +1 -1
  172. package/dist/web-ui/public/theme.js +1 -1
  173. package/dist/web-ui/public/users.html +87 -29
  174. package/dist/web-ui/public/users.js +1 -1
  175. package/dist/web-ui/public/variant-grouping.js +1 -1
  176. package/package.json +6 -3
@@ -0,0 +1,1381 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CoderFlow Server
4
+ * Main entry point - sets up Express server and routes
5
+ */
6
+
7
+ import http from 'http';
8
+ import https from 'https';
9
+ import express from 'express';
10
+ import compression from 'compression';
11
+ import path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ // Load environment variables from .env file
15
+ // Priority: 1) coder-setup/.env 2) package/.env
16
+ import dotenv from 'dotenv';
17
+ import { existsSync } from 'fs';
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+
21
+ // Check for .env in coder-setup directory first
22
+ const coderSetupPath = process.env.CODER_SETUP_PATH;
23
+ const setupEnvPath = coderSetupPath ? path.join(coderSetupPath, '.env') : null;
24
+ const packageEnvPath = path.join(__dirname, '.env');
25
+
26
+ let envSource = 'none';
27
+ if (setupEnvPath && existsSync(setupEnvPath)) {
28
+ dotenv.config({ path: setupEnvPath });
29
+ envSource = setupEnvPath;
30
+ } else if (existsSync(packageEnvPath)) {
31
+ dotenv.config({ path: packageEnvPath });
32
+ envSource = packageEnvPath;
33
+ }
34
+ // Log env source after logger is available (see below)
35
+ import { promises as fs, readFileSync } from 'fs';
36
+ import { WebSocketServer, WebSocket } from 'ws';
37
+ import Docker from 'dockerode';
38
+ import session from 'express-session';
39
+ import MemoryStore from 'memorystore';
40
+ import tasksRouter, { tasks, taskGroups, loadExistingTasks, loadExistingGroups, processAutoJudgeGroupsOnStartup, getTaskDirectories, getBaseTaskStoragePath, codeServerPorts, appServerStates, updateClients, broadcastTaskUpdate } from './routes/tasks.js';
41
+ import containersRouter, { containers } from './routes/containers.js';
42
+ import healthRouter from './routes/health.js';
43
+ import environmentsRouter from './routes/environments.js';
44
+ import templatesRouter from './routes/templates.js';
45
+ import authRouter from './routes/auth.js';
46
+ import passwordRouter from './routes/password.js';
47
+ import usersRouter from './routes/users.js';
48
+ import apiKeysRouter from './routes/apiKeys.js';
49
+ import jiraRouter, { initializeJira } from './routes/jira.js';
50
+ import testRouter from './routes/test.js';
51
+ import testTaskRouter from './routes/test-task.js';
52
+ import deployTaskRouter from './routes/deploy-task.js';
53
+ import promptRouter from './routes/prompt.js';
54
+ import visualizationsRouter from './routes/visualizations.js';
55
+ import settingsRouter, { initializeSetupPath } from './routes/settings.js';
56
+ import gitProviderSetupRouter from './routes/git-provider-setup.js';
57
+ import { initializeEmailConfigPath } from './lib/email.js';
58
+ import providerAuthRouter from './routes/provider-auth.js';
59
+ import qaRouter from './routes/qa.js';
60
+ import buildRouter from './routes/build.js';
61
+ import skillsRouter from './routes/skills.js';
62
+ import externalSkillsRouter from './routes/external-skills.js';
63
+ import gitCredentialsRouter, { initializeSetupPath as initializeGitCredentialsPath } from './routes/git-credentials.js';
64
+ import objectiveManagementRouter, { initializeObjectiveManagement } from './routes/objective-management.js';
65
+ import environmentManagementRouter, { initializeEnvironmentManagement, updateCoderConfig as updateEnvironmentManagementConfig } from './routes/environment-management.js';
66
+ import skillManagementRouter, { initializeSkillManagement, updateSkillManagementConfig } from './routes/skill-management.js';
67
+ import authOidcRouter from './routes/auth-oidc.js';
68
+ import gitOAuthRouter from './routes/git-oauth.js';
69
+ import teamsRouter from './routes/teams.js';
70
+ import bindingsRouter from './routes/bindings.js';
71
+ import rolesRouter from './routes/roles.js';
72
+ import { loadOidcConfig } from './lib/oidc-auth.js';
73
+ import { migrateConfigFiles } from './lib/config-migration.js';
74
+ import { requireAuth, requireAdmin } from './middleware/requireAuth.js';
75
+ import { requireInit, setCoderConfig } from './middleware/requireInit.js';
76
+ import { loadCoderSetup } from './config.js';
77
+ import { loadRoles } from './lib/role-definitions.js';
78
+ import { getUsers } from './lib/users.js';
79
+ import { validateLicense } from './lib/entitlement.js';
80
+ import { logger } from './lib/logger.js';
81
+ import { createAgentKeepAliveService } from './lib/agent-keepalive.js';
82
+ import { createBuildSchedulerService } from './lib/build-scheduler.js';
83
+ import { DATA_DIR, getSessionSecret } from './lib/data-dir.js';
84
+
85
+ const app = express();
86
+ const PORT = process.env.PORT || 3000;
87
+ const HOST = process.env.HOST || '0.0.0.0';
88
+ const CODER_SETUP_PATH = process.env.CODER_SETUP_PATH;
89
+
90
+ // Trust proxy when behind a reverse proxy (nginx, Apache, load balancer, etc.)
91
+ // This ensures req.secure, req.protocol, req.hostname work correctly
92
+ if (process.env.TRUST_PROXY) {
93
+ app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? true : process.env.TRUST_PROXY);
94
+ logger.info('Trust proxy enabled', { trustProxy: app.get('trust proxy') });
95
+ }
96
+
97
+ // Log if using custom data directory
98
+ if (process.env.SERVER_DATA_PATH) {
99
+ logger.info('Using server data path override from environment', { dataDir: DATA_DIR });
100
+ }
101
+
102
+ // SSL/TLS Configuration (optional)
103
+ const SSL_CERT_PATH = process.env.SSL_CERT_PATH; // Path to certificate file (.crt or .pem)
104
+ const SSL_KEY_PATH = process.env.SSL_KEY_PATH; // Path to private key file (.key or .pem)
105
+ const SSL_CA_PATH = process.env.SSL_CA_PATH; // Optional: Path to CA bundle
106
+
107
+ const docker = new Docker();
108
+
109
+ // Global config storage
110
+ export let coderConfig = null;
111
+ export let setupPath = null;
112
+
113
+ /**
114
+ * Reload coder-setup configuration from disk
115
+ * Used after environment modifications (create, delete, etc.)
116
+ * Also reloads dependent services (build-scheduler, agent-keepalive, jira)
117
+ */
118
+ export async function reloadCoderConfig() {
119
+ if (!setupPath) {
120
+ throw new Error('No setup path configured');
121
+ }
122
+
123
+ logger.info('Starting configuration reload...');
124
+
125
+ const { loadCoderSetup } = await import('./config.js');
126
+ const newConfig = await loadCoderSetup(setupPath);
127
+
128
+ // Re-add host repos path
129
+ newConfig.hostReposPath = path.resolve(__dirname, '..', '..', '..');
130
+
131
+ logger.info('Configuration loaded from disk', {
132
+ environments: Object.keys(newConfig.environments).join(', ')
133
+ });
134
+
135
+ // Update global reference FIRST (so routes see new config immediately)
136
+ coderConfig = newConfig;
137
+
138
+ // Reload dependent services
139
+ const reloadErrors = [];
140
+
141
+ try {
142
+ // Reload JIRA client
143
+ const { reloadJiraClient } = await import('./routes/jira.js');
144
+ await reloadJiraClient(newConfig);
145
+ } catch (error) {
146
+ logger.error('Failed to reload JIRA client', { error: error.message });
147
+ reloadErrors.push('JIRA client');
148
+ }
149
+
150
+ try {
151
+ // Reload build scheduler
152
+ const { buildSchedulerService } = global.services || {};
153
+ if (buildSchedulerService) {
154
+ await buildSchedulerService.reload(newConfig);
155
+ }
156
+ } catch (error) {
157
+ logger.error('Failed to reload build scheduler', { error: error.message });
158
+ reloadErrors.push('Build scheduler');
159
+ }
160
+
161
+ try {
162
+ // Reload agent keep-alive
163
+ const { agentKeepAliveService } = global.services || {};
164
+ if (agentKeepAliveService) {
165
+ await agentKeepAliveService.reload(newConfig);
166
+ }
167
+ } catch (error) {
168
+ logger.error('Failed to reload agent keep-alive', { error: error.message });
169
+ reloadErrors.push('Agent keep-alive');
170
+ }
171
+
172
+ // Update task-based management routes with new config
173
+ try {
174
+ updateEnvironmentManagementConfig(newConfig);
175
+ updateSkillManagementConfig(newConfig);
176
+ } catch (error) {
177
+ logger.error('Failed to update task management routes config', { error: error.message });
178
+ reloadErrors.push('Task management routes');
179
+ }
180
+
181
+ if (reloadErrors.length > 0) {
182
+ logger.warn('Configuration reloaded with errors', {
183
+ failedServices: reloadErrors.join(', ')
184
+ });
185
+ } else {
186
+ logger.info('Configuration and all services reloaded successfully');
187
+ }
188
+
189
+ return coderConfig;
190
+ }
191
+
192
+ // Middleware
193
+
194
+ // Enable gzip/deflate compression for all responses
195
+ // This significantly reduces payload sizes over the network (typically 70-80% reduction)
196
+ app.use(compression({
197
+ // Compress responses above 1KB
198
+ threshold: 1024,
199
+ // Use maximum compression level for best size reduction
200
+ level: 6,
201
+ // Filter function to decide what to compress
202
+ filter: (req, res) => {
203
+ // Never compress SSE responses - compression buffers break streaming updates
204
+ const ssePaths = [
205
+ /^\/tasks\/updates/,
206
+ /^\/tasks\/[^/]+\/stream/,
207
+ /^\/tasks\/[^/]+\/tests\/[^/]+\/stream\//,
208
+ /^\/tasks\/[^/]+\/exec-stream/,
209
+ /^\/prompt\/stream/
210
+ ];
211
+ const acceptsEventStream = (req.headers.accept || '').includes('text/event-stream');
212
+ if (acceptsEventStream || ssePaths.some(pattern => pattern.test(req.path))) {
213
+ return false;
214
+ }
215
+
216
+ // Don't compress if client doesn't accept it
217
+ if (req.headers['x-no-compression']) {
218
+ return false;
219
+ }
220
+ // Use compression's default filter (compresses text-based content types)
221
+ return compression.filter(req, res);
222
+ }
223
+ }));
224
+
225
+ // Skip body parsing for proxy routes to allow http-proxy to forward the raw body
226
+ app.use((req, res, next) => {
227
+ // Skip JSON parsing for app server proxy routes and vscode proxy routes
228
+ if (req.path.match(/^\/tasks\/[^/]+\/(app\/\d+|vscode)/)) {
229
+ return next();
230
+ }
231
+ express.json({ limit: '10mb' })(req, res, next);
232
+ });
233
+
234
+ // Session middleware - using in-memory store with persistence on shutdown
235
+ const SessionStore = MemoryStore(session);
236
+ const SESSION_BACKUP_PATH = path.join(DATA_DIR, 'sessions-backup.json');
237
+ const SESSION_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
238
+
239
+ // Create the session store instance (exported for shutdown persistence)
240
+ const sessionStore = new SessionStore({
241
+ checkPeriod: 86400000, // Prune expired entries every 24h
242
+ ttl: SESSION_TTL
243
+ });
244
+
245
+ // Restore sessions from backup file SYNCHRONOUSLY at startup
246
+ // This must happen before the session middleware processes any requests
247
+ function restoreSessionsSync() {
248
+ try {
249
+ const data = readFileSync(SESSION_BACKUP_PATH, 'utf8');
250
+ const backup = JSON.parse(data);
251
+ const now = Date.now();
252
+ let restored = 0;
253
+ let expired = 0;
254
+
255
+ for (const [sid, sessionData] of Object.entries(backup.sessions || {})) {
256
+ // Check if session is still valid (not expired)
257
+ const cookie = sessionData.cookie;
258
+ if (cookie && cookie.expires) {
259
+ const expiresAt = new Date(cookie.expires).getTime();
260
+ if (expiresAt > now) {
261
+ sessionStore.set(sid, sessionData, () => {});
262
+ restored++;
263
+ } else {
264
+ expired++;
265
+ }
266
+ }
267
+ }
268
+
269
+ logger.info('Sessions restored from backup', { restored, expired, backupTime: backup.savedAt });
270
+ } catch (error) {
271
+ if (error.code === 'ENOENT') {
272
+ logger.info('No session backup file found, starting fresh');
273
+ } else {
274
+ logger.warn('Failed to restore sessions from backup', { error: error.message });
275
+ }
276
+ }
277
+ }
278
+
279
+ // Restore sessions immediately during module initialization
280
+ restoreSessionsSync();
281
+
282
+ // Save sessions to backup file (called on shutdown)
283
+ async function saveSessions() {
284
+ return new Promise((resolve) => {
285
+ sessionStore.all((err, sessions) => {
286
+ if (err) {
287
+ logger.error('Failed to get sessions for backup', { error: err.message });
288
+ resolve();
289
+ return;
290
+ }
291
+
292
+ const backup = {
293
+ savedAt: new Date().toISOString(),
294
+ sessions: sessions || {}
295
+ };
296
+
297
+ fs.writeFile(SESSION_BACKUP_PATH, JSON.stringify(backup, null, 2))
298
+ .then(() => {
299
+ const count = Object.keys(backup.sessions).length;
300
+ logger.info('Sessions saved to backup', { count });
301
+ resolve();
302
+ })
303
+ .catch((writeErr) => {
304
+ logger.error('Failed to write session backup', { error: writeErr.message });
305
+ resolve();
306
+ });
307
+ });
308
+ });
309
+ }
310
+
311
+ // Export for shutdown handler
312
+ export { saveSessions };
313
+
314
+ // Determine if we're using HTTPS directly (via SSL cert/key config)
315
+ const hasDirectSSL = !!(SSL_CERT_PATH && SSL_KEY_PATH);
316
+
317
+ // When behind a trusted proxy, use 'auto' so express-session checks req.secure per-request.
318
+ // This allows secure cookies for proxied HTTPS while still working over plain HTTP
319
+ // (e.g., Playwright tests hitting localhost directly inside the container).
320
+ // With direct SSL (no proxy), always set secure: true.
321
+ const secureCookie = hasDirectSSL ? true : (app.get('trust proxy') ? 'auto' : false);
322
+
323
+ // SameSite setting: 'none' allows cookies to work in proxied contexts (Testing menu)
324
+ // but requires secure flag. Use 'lax' for local dev / direct HTTP.
325
+ // When secureCookie is 'auto', sameSite must also adapt: use 'lax' as the safe default
326
+ // and let the proxy's X-Forwarded-Proto drive the secure flag per-request.
327
+ const sameSite = hasDirectSSL ? 'none' : 'lax';
328
+
329
+ // Get session secret (shared between host and container via data directory)
330
+ const sessionSecret = getSessionSecret();
331
+
332
+ // Log environment configuration source
333
+ if (envSource !== 'none') {
334
+ logger.info('Environment configuration loaded', { path: envSource });
335
+ }
336
+
337
+ logger.info('Session configuration', {
338
+ secretSource: process.env.SESSION_SECRET ? 'environment' : 'shared file',
339
+ secure: secureCookie,
340
+ sameSite,
341
+ trustProxy: !!app.get('trust proxy'),
342
+ store: 'in-memory with persistence',
343
+ dataDir: DATA_DIR
344
+ });
345
+
346
+ app.use(session({
347
+ store: sessionStore,
348
+ secret: sessionSecret,
349
+ resave: false,
350
+ saveUninitialized: false,
351
+ cookie: {
352
+ maxAge: SESSION_TTL,
353
+ httpOnly: true,
354
+ secure: secureCookie,
355
+ sameSite: sameSite
356
+ },
357
+ name: `coder.${PORT}.sid` // Custom session ID cookie name (port-specific to avoid collisions)
358
+ }));
359
+
360
+ // API Routes (defined first so they take precedence)
361
+ // Auth routes (public)
362
+ app.use('/auth', authRouter);
363
+
364
+ // OIDC auth routes (public)
365
+ app.use('/auth/oidc', authOidcRouter);
366
+
367
+ // Password routes (public for token validation and setup, admin for token creation)
368
+ app.use('/password', passwordRouter);
369
+
370
+ // Protected API routes (require authentication and valid init)
371
+ app.use('/tasks', requireAuth, requireInit, tasksRouter);
372
+ app.use('/containers', requireAuth, containersRouter);
373
+ app.use('/environments', requireAuth, environmentsRouter);
374
+ app.use('/templates', requireAuth, templatesRouter);
375
+ app.use('/api-keys', requireAuth, apiKeysRouter);
376
+ app.use('/jira', requireAuth, jiraRouter);
377
+ app.use('/test', requireAuth, testRouter);
378
+ app.use('/test-task', requireAuth, testTaskRouter);
379
+ app.use('/deploy-task', requireAuth, deployTaskRouter);
380
+ app.use('/prompt-parameter', requireAuth, promptRouter);
381
+ app.use('/visualizations', requireAuth, visualizationsRouter);
382
+ app.use('/qa', requireAuth, qaRouter);
383
+ app.use('/build', requireAuth, buildRouter);
384
+ app.use('/skills', requireAuth, skillsRouter);
385
+ app.use('/external-skills', requireAuth, externalSkillsRouter);
386
+
387
+ // Admin-only routes
388
+ app.use('/users', requireAuth, requireAdmin, usersRouter);
389
+ app.use('/settings', requireAuth, settingsRouter);
390
+ app.use('/settings/provider-auth', requireAuth, providerAuthRouter);
391
+ app.use('/settings/git-provider-setup', requireAuth, gitProviderSetupRouter);
392
+
393
+ // Scoped RBAC routes (teams, bindings, roles handle their own authorization per-endpoint)
394
+ app.use('/teams', requireAuth, teamsRouter);
395
+ app.use('/bindings', requireAuth, bindingsRouter);
396
+ app.use('/roles', requireAuth, rolesRouter);
397
+
398
+ // Health check (public for monitoring)
399
+ app.use('/health', healthRouter);
400
+
401
+ // Git credentials endpoint (uses container token auth, not session auth)
402
+ app.use('/api/git/credentials', gitCredentialsRouter);
403
+
404
+ // Git OAuth endpoint (user-level OAuth for Git providers)
405
+ app.use('/api/git-oauth', requireAuth, gitOAuthRouter);
406
+
407
+ // Objective management endpoint (uses task ID auth for container access)
408
+ // Allows tasks launched from objectives to manage their parent objective
409
+ app.use('/api/objective-management', objectiveManagementRouter);
410
+
411
+ // Environment management endpoint (uses task ID auth for container access)
412
+ // Allows tasks to update environment instructions (AGENTS.md) and templates
413
+ app.use('/api/environment-management', environmentManagementRouter);
414
+
415
+ // Skill management endpoint (uses task ID auth for container access)
416
+ // Allows tasks to edit skills assigned to their environment
417
+ app.use('/api/skill-management', skillManagementRouter);
418
+
419
+ // Serve user avatars (requires auth - checking is done at upload time)
420
+ const avatarsPath = path.join(DATA_DIR, 'avatars');
421
+ app.use('/avatars', express.static(avatarsPath, {
422
+ setHeaders: (res) => {
423
+ res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day cache
424
+ }
425
+ }));
426
+
427
+ // Serve documentation (public, no auth)
428
+ // Check both possible locations: dist/web-ui (installed) and ../web-ui (source)
429
+ const docsPathDist = path.join(__dirname, 'web-ui/public/docs');
430
+ const docsPathSource = path.join(__dirname, '../web-ui/public/docs');
431
+ const docsPath = existsSync(docsPathDist) ? docsPathDist : docsPathSource;
432
+ app.use('/docs', express.static(docsPath, {
433
+ setHeaders: (res, filePath) => {
434
+ const ext = path.extname(filePath).toLowerCase();
435
+ if (ext === '.html') {
436
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
437
+ } else if (ext === '.md') {
438
+ res.setHeader('Cache-Control', 'public, max-age=300'); // 5 min cache for markdown
439
+ }
440
+ }
441
+ }));
442
+
443
+ // Serve internal dev docs (only when running from source, not from npm package)
444
+ const devDocsPath = path.join(__dirname, '../../docs');
445
+ if (existsSync(devDocsPath)) {
446
+ app.use('/dev-docs', express.static(devDocsPath, {
447
+ setHeaders: (res, filePath) => {
448
+ const ext = path.extname(filePath).toLowerCase();
449
+ if (ext === '.html') {
450
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
451
+ } else if (ext === '.md') {
452
+ res.setHeader('Cache-Control', 'no-cache'); // No cache for dev docs
453
+ }
454
+ }
455
+ }));
456
+ }
457
+
458
+ // Serve Web UI static files with caching headers
459
+ // Static assets (JS, CSS, images) are cached for 1 day to reduce repeat requests
460
+ // HTML files are not cached to ensure users always get the latest version
461
+ // Check both possible locations: dist/web-ui (installed) and ../web-ui (source)
462
+ const webUiPathDist = path.join(__dirname, 'web-ui/public');
463
+ const webUiPathSource = path.join(__dirname, '../web-ui/public');
464
+ const webUiPath = existsSync(webUiPathDist) ? webUiPathDist : webUiPathSource;
465
+ app.use(express.static(webUiPath, {
466
+ // Set cache headers for static assets
467
+ setHeaders: (res, filePath) => {
468
+ const ext = path.extname(filePath).toLowerCase();
469
+
470
+ // Cache JS, CSS, and image files for 1 day (these have version query strings)
471
+ if (['.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf'].includes(ext)) {
472
+ res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
473
+ } else if (ext === '.html') {
474
+ // Don't cache HTML files - always fetch fresh
475
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
476
+ res.setHeader('Pragma', 'no-cache');
477
+ res.setHeader('Expires', '0');
478
+ }
479
+ },
480
+ // Enable etag for cache validation
481
+ etag: true,
482
+ // Enable Last-Modified header
483
+ lastModified: true
484
+ }));
485
+ logger.info('Web UI enabled', { path: webUiPath, caching: 'enabled for static assets' });
486
+
487
+ // Referer-based redirect fallback for Vite/ES module imports
488
+ // When a module loaded from /tasks/{id}/app/{port}/... imports another module via
489
+ // an absolute path like "/foo.js", the browser requests "/foo.js" directly (no prefix).
490
+ // This middleware catches such requests, extracts taskId/port from the Referer header,
491
+ // and 302 redirects to the prefixed URL. The redirect (not proxy) is critical: it ensures
492
+ // the browser loads the module from the prefixed URL, so its own imports carry the correct
493
+ // Referer, maintaining the chain at any depth.
494
+ app.use((req, res, next) => {
495
+ // Only handle GET requests that haven't matched any route
496
+ if (req.method !== 'GET') return next();
497
+
498
+ // Don't redirect requests that are already prefixed or to known paths
499
+ if (req.path.startsWith('/tasks/') || req.path.startsWith('/api/') ||
500
+ req.path.startsWith('/auth/') || req.path.startsWith('/dev-docs/')) {
501
+ return next();
502
+ }
503
+
504
+ const referer = req.headers.referer || req.headers.referrer;
505
+ if (!referer) return next();
506
+
507
+ // Extract taskId and port from Referer
508
+ const refMatch = referer.match(/\/tasks\/([^\/]+)\/app\/(\d+)/);
509
+ if (!refMatch) return next();
510
+
511
+ const taskId = refMatch[1];
512
+ const port = refMatch[2];
513
+
514
+ // Verify task is active
515
+ const state = appServerStates.get(taskId);
516
+ if (!state || !state.proxies) return next();
517
+
518
+ // Verify this port belongs to the task
519
+ let portValid = false;
520
+ if (state.ports) {
521
+ for (const [, portInfo] of state.ports.entries()) {
522
+ if (portInfo.internal.toString() === port) {
523
+ portValid = true;
524
+ break;
525
+ }
526
+ }
527
+ } else if (state.isProxyMode) {
528
+ portValid = true;
529
+ }
530
+ if (!portValid) return next();
531
+
532
+ const redirectUrl = `/tasks/${taskId}/app/${port}${req.originalUrl}`;
533
+ logger.debug('Referer-based redirect for ES module import', {
534
+ taskId,
535
+ port,
536
+ from: req.originalUrl,
537
+ to: redirectUrl
538
+ });
539
+ res.redirect(302, redirectUrl);
540
+ });
541
+
542
+ // Error handling middleware (must be after all routes)
543
+ app.use((err, req, res, next) => {
544
+ // Handle PayloadTooLargeError specifically
545
+ if (err.type === 'entity.too.large' || err.status === 413) {
546
+ logger.error('Payload too large', {
547
+ url: req.url,
548
+ method: req.method,
549
+ error: err.message
550
+ });
551
+ return res.status(413).json({
552
+ error: 'Payload too large',
553
+ message: 'The request payload is too large. This typically occurs when capturing local state with large diffs. Consider committing or stashing large changes before capturing local state.'
554
+ });
555
+ }
556
+
557
+ // Log all other errors
558
+ logger.error('Request error', {
559
+ url: req.url,
560
+ method: req.method,
561
+ error: err.message,
562
+ stack: err.stack
563
+ });
564
+
565
+ // Send error response
566
+ const statusCode = err.status || err.statusCode || 500;
567
+ res.status(statusCode).json({
568
+ error: err.name || 'Server Error',
569
+ message: err.message || 'An unexpected error occurred'
570
+ });
571
+ });
572
+
573
+ // Load configuration and start server
574
+ async function startServer() {
575
+ // Load coder-setup configuration if path is provided
576
+ if (CODER_SETUP_PATH) {
577
+ try {
578
+ logger.info('Loading coder-setup', { path: CODER_SETUP_PATH });
579
+ setupPath = CODER_SETUP_PATH;
580
+ coderConfig = await loadCoderSetup(CODER_SETUP_PATH);
581
+
582
+ // Calculate host repos path (parent directory of profound-coder)
583
+ // __dirname is at: /path/to/profound-coder/packages/server
584
+ // We need to go up 3 levels to get to the parent of profound-coder
585
+ coderConfig.hostReposPath = path.resolve(__dirname, '..', '..', '..');
586
+
587
+ logger.info('Configuration loaded successfully', {
588
+ setupName: coderConfig.setup.name,
589
+ defaultEnvironment: coderConfig.setup.default_environment,
590
+ environments: Object.keys(coderConfig.environments).join(', '),
591
+ hostReposPath: coderConfig.hostReposPath
592
+ });
593
+
594
+ // Initialize settings router with setup path
595
+ initializeSetupPath({ setupPath: CODER_SETUP_PATH });
596
+
597
+ // Migrate instance-specific config files to DATA_DIR
598
+ await migrateConfigFiles(CODER_SETUP_PATH);
599
+
600
+ // Initialize git credentials route with setup path
601
+ initializeGitCredentialsPath(CODER_SETUP_PATH);
602
+
603
+ // Initialize objective management route with tasks map and storage path
604
+ initializeObjectiveManagement({
605
+ tasks,
606
+ taskStoragePath: coderConfig.taskStoragePath,
607
+ broadcastTaskUpdate
608
+ });
609
+
610
+ // Initialize environment management route with tasks map and setup path
611
+ initializeEnvironmentManagement({
612
+ tasks,
613
+ setupPath: CODER_SETUP_PATH,
614
+ coderConfig
615
+ });
616
+
617
+ // Initialize skill management route with tasks map and setup path
618
+ initializeSkillManagement({
619
+ tasks,
620
+ setupPath: CODER_SETUP_PATH,
621
+ coderConfig
622
+ });
623
+
624
+ // Initialize email config path
625
+ initializeEmailConfigPath(CODER_SETUP_PATH);
626
+
627
+ // Set coderConfig reference for license middleware
628
+ setCoderConfig(coderConfig);
629
+
630
+ // Seed predefined roles and check for migration needs
631
+ await loadRoles();
632
+
633
+ // Load OIDC configuration if present
634
+ try {
635
+ const oidcConfig = await loadOidcConfig(CODER_SETUP_PATH);
636
+ if (oidcConfig) {
637
+ logger.info('OIDC authentication enabled', {
638
+ displayName: oidcConfig.display_name,
639
+ issuer: oidcConfig.issuer,
640
+ autoProvision: oidcConfig.auto_provision,
641
+ allowLocalAuth: oidcConfig.allow_local_auth
642
+ });
643
+ }
644
+ } catch (oidcError) {
645
+ logger.error('Failed to load OIDC configuration', oidcError);
646
+ // Continue without OIDC - it's optional
647
+ }
648
+ } catch (error) {
649
+ logger.error('Failed to load coder-setup', error);
650
+ process.exit(1);
651
+ }
652
+ } else {
653
+ logger.info('No CODER_SETUP_PATH provided - running in basic mode');
654
+ logger.info('Set CODER_SETUP_PATH to enable coder-setup integration');
655
+ }
656
+
657
+ // Check that at least one Server Admin exists whenever there are users
658
+ const allUsers = await getUsers();
659
+ if (allUsers.length > 0 && !allUsers.some(u => u.isServerAdmin)) {
660
+ logger.error(
661
+ '⚠️ NO SERVER ADMIN: Users exist but none has server admin privileges. ' +
662
+ 'The permissions system requires at least one Server Admin. ' +
663
+ 'Please run the migration script: node packages/server/scripts/migrate-to-scoped-rbac.js'
664
+ );
665
+ }
666
+
667
+ // Validate license
668
+ const licenseResult = await validateLicense(setupPath);
669
+ if (!licenseResult.valid) {
670
+ logger.error('License validation failed', {
671
+ code: licenseResult.code,
672
+ message: licenseResult.message
673
+ });
674
+ console.error('\n========================================');
675
+ console.error('LICENSE ERROR');
676
+ console.error('========================================');
677
+ console.error(licenseResult.message);
678
+ console.error('========================================\n');
679
+ process.exit(1);
680
+ }
681
+ logger.info('License validated', {
682
+ expirationDate: licenseResult.expirationDate
683
+ });
684
+
685
+ // Load existing tasks from disk
686
+ // Configure task loading via environment variables:
687
+ // - TASK_LOAD_DAYS: Number of days of tasks to load (default: 365)
688
+ // - TASK_LOAD_MAX: Maximum number of tasks to load (default: 10000)
689
+ const taskLoadOptions = {
690
+ daysToLoad: parseInt(process.env.TASK_LOAD_DAYS) || 365,
691
+ maxTasks: parseInt(process.env.TASK_LOAD_MAX) || 10000
692
+ };
693
+ await loadExistingTasks(taskLoadOptions);
694
+ await loadExistingGroups();
695
+ await processAutoJudgeGroupsOnStartup();
696
+
697
+ // Initialize JIRA integration if configured
698
+ await initializeJira();
699
+
700
+ // Global service instances (for hot-reload)
701
+ let buildSchedulerService = null;
702
+ let agentKeepAliveService = null;
703
+
704
+ // Check if we are running inside a task container
705
+ // If so, we should skip background services that interfere with the host
706
+ if (process.env.TASK_ID) {
707
+ logger.info('Running inside a task container - skipping background services', {
708
+ taskId: process.env.TASK_ID,
709
+ skippedServices: ['auto-cleanup', 'agent-keepalive', 'build-scheduler']
710
+ });
711
+ } else {
712
+ // Start auto-cleanup job for inactive containers
713
+ startAutoCleanup();
714
+
715
+ // Start agent keep-alive service (for token refresh)
716
+ if (coderConfig) {
717
+ agentKeepAliveService = createAgentKeepAliveService(coderConfig);
718
+ } else {
719
+ logger.info('Skipping agent keep-alive - no coder-setup configuration loaded');
720
+ }
721
+
722
+ // Start build scheduler service (for scheduled environment rebuilds)
723
+ if (coderConfig) {
724
+ buildSchedulerService = createBuildSchedulerService(coderConfig);
725
+ } else {
726
+ logger.info('Skipping build scheduler - no coder-setup configuration loaded');
727
+ }
728
+ }
729
+
730
+ // Export service instances for hot-reload
731
+ global.services = { buildSchedulerService, agentKeepAliveService };
732
+
733
+ // Create HTTP or HTTPS server based on SSL configuration
734
+ let server;
735
+ let protocol = 'http';
736
+
737
+ if (SSL_CERT_PATH && SSL_KEY_PATH) {
738
+ logger.info('SSL configuration detected, creating HTTPS server', {
739
+ cert: SSL_CERT_PATH,
740
+ key: SSL_KEY_PATH,
741
+ ca: SSL_CA_PATH || 'none'
742
+ });
743
+
744
+ // Load SSL certificates
745
+ const sslOptions = {};
746
+
747
+ try {
748
+ sslOptions.cert = await fs.readFile(SSL_CERT_PATH);
749
+ } catch (error) {
750
+ logger.error('Failed to load SSL certificate', { path: SSL_CERT_PATH, error: error.message });
751
+ console.error('\n========================================');
752
+ console.error('SSL CERTIFICATE ERROR');
753
+ console.error('========================================');
754
+ console.error(`Failed to load SSL certificate: ${SSL_CERT_PATH}`);
755
+ console.error(`Error: ${error.message}`);
756
+ console.error('');
757
+ console.error('Please verify the SSL file paths in your configuration:');
758
+ console.error(' coder-server config show');
759
+ console.error('========================================\n');
760
+ process.exit(1);
761
+ }
762
+
763
+ try {
764
+ sslOptions.key = await fs.readFile(SSL_KEY_PATH);
765
+ } catch (error) {
766
+ logger.error('Failed to load SSL private key', { path: SSL_KEY_PATH, error: error.message });
767
+ console.error('\n========================================');
768
+ console.error('SSL PRIVATE KEY ERROR');
769
+ console.error('========================================');
770
+ console.error(`Failed to load SSL private key: ${SSL_KEY_PATH}`);
771
+ console.error(`Error: ${error.message}`);
772
+ console.error('');
773
+ console.error('Please verify the SSL file paths in your configuration:');
774
+ console.error(' coder-server config show');
775
+ console.error('========================================\n');
776
+ process.exit(1);
777
+ }
778
+
779
+ // Add CA bundle if provided
780
+ if (SSL_CA_PATH) {
781
+ try {
782
+ sslOptions.ca = await fs.readFile(SSL_CA_PATH);
783
+ } catch (error) {
784
+ logger.error('Failed to load SSL CA bundle', { path: SSL_CA_PATH, error: error.message });
785
+ console.error('\n========================================');
786
+ console.error('SSL CA BUNDLE ERROR');
787
+ console.error('========================================');
788
+ console.error(`Failed to load SSL CA bundle: ${SSL_CA_PATH}`);
789
+ console.error(`Error: ${error.message}`);
790
+ console.error('');
791
+ console.error('Please verify the SSL file paths in your configuration:');
792
+ console.error(' coder-server config show');
793
+ console.error('========================================\n');
794
+ process.exit(1);
795
+ }
796
+ }
797
+
798
+ server = https.createServer(sslOptions, app);
799
+ protocol = 'https';
800
+ } else {
801
+ server = http.createServer(app);
802
+ }
803
+
804
+ const wss = setupTerminalBridge(server);
805
+
806
+ // Track all connections so we can close them during shutdown
807
+ const connections = new Set();
808
+ server.on('connection', (socket) => {
809
+ connections.add(socket);
810
+ socket.on('close', () => connections.delete(socket));
811
+ });
812
+
813
+ server.listen(PORT, HOST, () => {
814
+ logger.info('CoderFlow Server listening', {
815
+ host: HOST,
816
+ port: PORT,
817
+ protocol: protocol,
818
+ environment: process.env.NODE_ENV || 'development'
819
+ });
820
+ logger.info('Web UI available at:', {
821
+ url: `${protocol}://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`
822
+ });
823
+ });
824
+
825
+ // Graceful shutdown handlers for PM2 and other process managers
826
+ const shutdown = async (signal) => {
827
+ logger.info(`Received ${signal}, starting graceful shutdown...`);
828
+
829
+ // Save sessions to disk before shutting down
830
+ await saveSessions();
831
+
832
+ // Close all SSE client connections
833
+ const sseClientCount = updateClients.size;
834
+ if (sseClientCount > 0) {
835
+ logger.info(`Closing ${sseClientCount} SSE client connections...`);
836
+ for (const client of updateClients) {
837
+ try {
838
+ // Must destroy the socket, not just end() the response
839
+ // end() only signals we're done writing but keeps the connection open
840
+ // waiting for the client to close their end, which may never happen
841
+ if (client.socket && !client.socket.destroyed) {
842
+ client.socket.destroy();
843
+ } else {
844
+ client.end();
845
+ }
846
+ } catch (err) {
847
+ // Ignore errors when closing SSE clients
848
+ }
849
+ }
850
+ updateClients.clear();
851
+ }
852
+
853
+ // Close all WebSocket connections
854
+ const wsClientCount = wss.clients.size;
855
+ if (wsClientCount > 0) {
856
+ logger.info(`Closing ${wsClientCount} WebSocket connections...`);
857
+ for (const ws of wss.clients) {
858
+ try {
859
+ ws.close(1001, 'Server shutting down');
860
+ } catch (err) {
861
+ // Ignore errors when closing WebSocket clients
862
+ }
863
+ }
864
+ }
865
+
866
+ // Stop accepting new connections
867
+ server.close(() => {
868
+ logger.info('HTTP server closed');
869
+ process.exit(0);
870
+ });
871
+
872
+ // Destroy all remaining connections (keep-alive, idle HTTP connections, etc.)
873
+ // This ensures server.close() callback fires promptly instead of waiting
874
+ // for clients to close their end of keep-alive connections
875
+ const remainingConnections = connections.size;
876
+ if (remainingConnections > 0) {
877
+ logger.info(`Closing ${remainingConnections} remaining HTTP connections...`);
878
+ for (const socket of connections) {
879
+ try {
880
+ socket.destroy();
881
+ } catch (err) {
882
+ // Ignore errors when destroying sockets
883
+ }
884
+ }
885
+ connections.clear();
886
+ }
887
+
888
+ // Force shutdown after 10 seconds if graceful shutdown still fails
889
+ setTimeout(() => {
890
+ logger.error('Forced shutdown after timeout');
891
+ process.exit(1);
892
+ }, 10000);
893
+ };
894
+
895
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
896
+ process.on('SIGINT', () => shutdown('SIGINT'));
897
+ }
898
+
899
+ startServer().catch(error => {
900
+ logger.error('FATAL: Server startup failed', error);
901
+ process.exit(1);
902
+ });
903
+
904
+ export default app;
905
+
906
+ function setupTerminalBridge(server) {
907
+ const wss = new WebSocketServer({ noServer: true });
908
+
909
+ server.on('upgrade', (request, socket, head) => {
910
+ try {
911
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
912
+
913
+ // Handle terminal WebSocket connections
914
+ if (pathname && pathname.startsWith('/ws/containers/')) {
915
+ wss.handleUpgrade(request, socket, head, (ws) => {
916
+ wss.emit('connection', ws, request, pathname);
917
+ });
918
+ }
919
+ // Handle code-server WebSocket connections
920
+ else if (pathname && pathname.match(/^\/tasks\/[^\/]+\/vscode/)) {
921
+ // Extract taskId to verify it exists
922
+ const match = pathname.match(/^\/tasks\/([^\/]+)\/vscode/);
923
+ if (match) {
924
+ const taskId = match[1];
925
+ const serverInfo = codeServerPorts.get(taskId);
926
+
927
+ if (serverInfo && serverInfo.proxy) {
928
+ // Extract the path we want (everything after /tasks/:id/vscode)
929
+ const targetPath = request.url.replace(/^\/tasks\/[^\/]+\/vscode/, '');
930
+
931
+ logger.debug('WebSocket upgrade for code-server', { taskId });
932
+
933
+ // Add error handler to socket to catch any connection issues
934
+ socket.on('error', (err) => {
935
+ logger.error('WebSocket socket error', { error: err.message, taskId });
936
+ });
937
+
938
+ // Modify request.url to remove the /tasks/:id/vscode prefix
939
+ // This way the proxy will forward the correct path to code-server
940
+ request.url = targetPath;
941
+
942
+ // Proxy the WebSocket upgrade (target already set in proxy instance)
943
+ serverInfo.proxy.ws(request, socket, head);
944
+ } else{
945
+ logger.error('Code-server not started or proxy not initialized', { taskId, pathname, hasProxy: !!serverInfo?.proxy });
946
+ socket.destroy();
947
+ }
948
+ } else {
949
+ logger.error('Could not extract taskId from WebSocket upgrade path', { pathname });
950
+ socket.destroy();
951
+ }
952
+ }
953
+ // Handle app server WebSocket connections
954
+ else if (pathname && pathname.match(/^\/tasks\/[^\/]+\/app\/\d+/)) {
955
+ // Extract taskId and port
956
+ const match = pathname.match(/^\/tasks\/([^\/]+)\/app\/(\d+)(\/.*)?$/);
957
+ if (match) {
958
+ const taskId = match[1];
959
+ const port = match[2];
960
+
961
+ const state = appServerStates.get(taskId);
962
+ if (!state || !state.proxies) {
963
+ logger.error('App server not started or proxies not initialized', { taskId, port, pathname });
964
+ socket.destroy();
965
+ return;
966
+ }
967
+
968
+ // Find proxy by port number
969
+ let proxy = null;
970
+ for (const [name, portInfo] of state.ports.entries()) {
971
+ if (portInfo.internal.toString() === port) {
972
+ proxy = state.proxies.get(name);
973
+ break;
974
+ }
975
+ }
976
+
977
+ if (proxy) {
978
+ // Extract the path we want (everything after /tasks/:id/app/:port)
979
+ const targetPath = request.url.replace(/^\/tasks\/[^\/]+\/app\/\d+/, '');
980
+
981
+ logger.debug('WebSocket upgrade for app server', { taskId, port, targetPath });
982
+
983
+ // Add error handler to socket to catch any connection issues
984
+ socket.on('error', (err) => {
985
+ logger.error('WebSocket socket error for app server', { error: err.message, taskId, port });
986
+ });
987
+
988
+ // Modify request.url to remove the /tasks/:id/app/:port prefix
989
+ // This way the proxy will forward the correct path to the app server
990
+ request.url = targetPath;
991
+
992
+ // Proxy the WebSocket upgrade (target already set in proxy instance)
993
+ proxy.ws(request, socket, head);
994
+ } else {
995
+ logger.error('App server proxy not found for port', { taskId, port, pathname });
996
+ socket.destroy();
997
+ }
998
+ } else {
999
+ logger.error('Could not extract taskId/port from app server WebSocket upgrade path', { pathname });
1000
+ socket.destroy();
1001
+ }
1002
+ }
1003
+ else {
1004
+ // Fallback: check if this is a Vite HMR or similar WebSocket from an app server page.
1005
+ // The browser may connect to a non-prefixed WS path. Try to route using Origin/Referer.
1006
+ const wsReferer = request.headers.origin || request.headers.referer || '';
1007
+ const wsRefMatch = wsReferer.match(/\/tasks\/([^\/]+)\/app\/(\d+)/);
1008
+ let routed = false;
1009
+
1010
+ if (wsRefMatch) {
1011
+ const taskId = wsRefMatch[1];
1012
+ const port = wsRefMatch[2];
1013
+ const state = appServerStates.get(taskId);
1014
+
1015
+ if (state && state.proxies) {
1016
+ let proxy = null;
1017
+ if (state.ports) {
1018
+ for (const [name, portInfo] of state.ports.entries()) {
1019
+ if (portInfo.internal.toString() === port) {
1020
+ proxy = state.proxies.get(name);
1021
+ break;
1022
+ }
1023
+ }
1024
+ } else if (state.isProxyMode) {
1025
+ // Proxy mode uses 'proxy' as the key
1026
+ const entry = state.proxies.get('proxy');
1027
+ proxy = entry?.proxy || entry;
1028
+ }
1029
+
1030
+ if (proxy) {
1031
+ logger.debug('WebSocket upgrade fallback via Referer/Origin', { taskId, port, pathname });
1032
+ socket.on('error', (err) => {
1033
+ logger.error('WebSocket socket error (fallback)', { error: err.message, taskId, port });
1034
+ });
1035
+ proxy.ws(request, socket, head);
1036
+ routed = true;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ if (!routed) {
1042
+ logger.warn('Unknown WebSocket upgrade path', { pathname });
1043
+ socket.destroy();
1044
+ }
1045
+ }
1046
+ } catch (error) {
1047
+ logger.error('Failed to handle websocket upgrade', error);
1048
+ socket.destroy();
1049
+ }
1050
+ });
1051
+
1052
+ wss.on('connection', (ws, request, pathname) => {
1053
+ handleTerminalConnection(ws, request, pathname).catch(error => {
1054
+ logger.error('Terminal connection failed', error);
1055
+ try {
1056
+ ws.send(JSON.stringify({ type: 'error', message: error.message }));
1057
+ } catch (sendError) {
1058
+ logger.warn('Failed to send terminal error message', sendError);
1059
+ }
1060
+ ws.close(1011, 'Internal server error');
1061
+ });
1062
+ });
1063
+
1064
+ return wss;
1065
+ }
1066
+
1067
+ async function handleTerminalConnection(ws, request, pathname) {
1068
+ const url = new URL(request.url, `http://${request.headers.host}`);
1069
+ const [, , , containerKeyRaw] = pathname.split('/');
1070
+ const containerKey = decodeURIComponent(containerKeyRaw || '').trim();
1071
+ const cmd = url.searchParams.get('cmd');
1072
+
1073
+ if (!containerKey) {
1074
+ ws.send(JSON.stringify({ type: 'error', message: 'Missing container identifier' }));
1075
+ ws.close(1008, 'Missing container identifier');
1076
+ return;
1077
+ }
1078
+
1079
+ const dockerId = resolveDockerContainerId(containerKey);
1080
+
1081
+ if (!dockerId) {
1082
+ ws.send(JSON.stringify({ type: 'error', message: 'Container not found' }));
1083
+ ws.close(1008, 'Container not found');
1084
+ return;
1085
+ }
1086
+
1087
+ const container = docker.getContainer(dockerId);
1088
+ try {
1089
+ await container.inspect();
1090
+ } catch {
1091
+ ws.send(JSON.stringify({ type: 'error', message: 'Container is not available' }));
1092
+ ws.close(1008, 'Container is not available');
1093
+ return;
1094
+ }
1095
+
1096
+ logger.info('Opening terminal bridge', { containerKey, dockerId });
1097
+
1098
+ // Check if this is a task container
1099
+ let isTaskContainer = false;
1100
+ for (const [taskId, task] of tasks.entries()) {
1101
+ if (task.containerId === dockerId || task.containerId?.startsWith(dockerId)) {
1102
+ isTaskContainer = true;
1103
+ logger.info('Attaching to task container', { taskId, containerKey });
1104
+ break;
1105
+ }
1106
+ }
1107
+
1108
+ // If executing a command, wrap it to wait for credentials to be ready
1109
+ let shellCmd;
1110
+ if (cmd) {
1111
+ // For task containers, create the credentials marker before running the command
1112
+ // Task containers don't go through the same startup as interactive containers
1113
+ const credentialsSetup = isTaskContainer
1114
+ ? 'touch /tmp/.credentials-ready 2>/dev/null || true\n '
1115
+ : `while [ ! -f /tmp/.credentials-ready ]; do
1116
+ sleep 0.1
1117
+ done
1118
+ `;
1119
+
1120
+ const wrappedCmd = `
1121
+ ${credentialsSetup}${cmd}
1122
+ `;
1123
+ shellCmd = ['/bin/bash', '-c', wrappedCmd];
1124
+ } else {
1125
+ // For shell-only mode, start an interactive login shell
1126
+ // Login shell (-l) sources ~/.profile which sources ~/.bash_env
1127
+ // For task containers, also create ready marker
1128
+ if (isTaskContainer) {
1129
+ shellCmd = ['/bin/bash', '-c', 'touch /tmp/.credentials-ready 2>/dev/null || true; exec /bin/bash -l'];
1130
+ } else {
1131
+ shellCmd = ['/bin/bash', '-l'];
1132
+ }
1133
+ }
1134
+
1135
+ const exec = await container.exec({
1136
+ AttachStdin: true,
1137
+ AttachStdout: true,
1138
+ AttachStderr: true,
1139
+ Tty: true,
1140
+ User: 'coder',
1141
+ Cmd: shellCmd
1142
+ });
1143
+
1144
+ const execStream = await exec.start({ hijack: true, stdin: true });
1145
+
1146
+ execStream.on('data', (chunk) => {
1147
+ if (ws.readyState === WebSocket.OPEN) {
1148
+ // Docker exec stream includes 8-byte headers for stream multiplexing
1149
+ // Header format: [stream_type(1), 0, 0, 0, size(4 bytes big-endian)]
1150
+ // We need to strip these headers before sending to the client
1151
+ let offset = 0;
1152
+ const messages = [];
1153
+
1154
+ while (offset < chunk.length) {
1155
+ if (chunk.length - offset < 8) {
1156
+ // Incomplete header, shouldn't happen but handle gracefully
1157
+ break;
1158
+ }
1159
+
1160
+ // Read the payload size from bytes 4-7 (big-endian)
1161
+ const size = chunk.readUInt32BE(offset + 4);
1162
+ const payloadStart = offset + 8;
1163
+ const payloadEnd = payloadStart + size;
1164
+
1165
+ if (payloadEnd > chunk.length) {
1166
+ // Incomplete payload, shouldn't happen but handle gracefully
1167
+ break;
1168
+ }
1169
+
1170
+ // Extract just the payload (skip the 8-byte header)
1171
+ const payload = chunk.slice(payloadStart, payloadEnd);
1172
+ messages.push(payload.toString('utf-8'));
1173
+
1174
+ offset = payloadEnd;
1175
+ }
1176
+
1177
+ if (messages.length > 0) {
1178
+ ws.send(JSON.stringify({ type: 'data', data: messages.join('') }));
1179
+ }
1180
+ }
1181
+ });
1182
+
1183
+ execStream.on('end', () => {
1184
+ if (ws.readyState === WebSocket.OPEN) {
1185
+ ws.send(JSON.stringify({ type: 'status', status: 'terminated' }));
1186
+ ws.close(1000, 'Terminal session ended');
1187
+ }
1188
+ });
1189
+
1190
+ execStream.on('error', (error) => {
1191
+ logger.error('Exec stream error', error, { containerId: dockerId });
1192
+ if (ws.readyState === WebSocket.OPEN) {
1193
+ ws.send(JSON.stringify({ type: 'error', message: error.message }));
1194
+ ws.close(1011, 'Terminal stream error');
1195
+ }
1196
+ });
1197
+
1198
+ // Set up message handler BEFORE sending 'connected' status to avoid race condition
1199
+ ws.on('message', async (message) => {
1200
+ try {
1201
+ const payload = JSON.parse(message.toString());
1202
+ if (payload.type === 'data' && typeof payload.data === 'string') {
1203
+ execStream.write(payload.data);
1204
+ } else if (payload.type === 'resize' && Number.isFinite(payload.cols) && Number.isFinite(payload.rows)) {
1205
+ try {
1206
+ const rows = Math.floor(payload.rows);
1207
+ const cols = Math.floor(payload.cols);
1208
+ await exec.resize({ h: rows, w: cols });
1209
+ } catch (resizeError) {
1210
+ logger.warn('Failed to resize exec TTY', resizeError, { containerId: dockerId });
1211
+ }
1212
+ }
1213
+ } catch (parseError) {
1214
+ logger.warn('Failed to process websocket message', parseError);
1215
+ }
1216
+ });
1217
+
1218
+ ws.on('close', () => {
1219
+ safeCloseStream(execStream);
1220
+ });
1221
+
1222
+ ws.on('error', (error) => {
1223
+ logger.warn('Websocket error', error, { containerId: dockerId });
1224
+ safeCloseStream(execStream);
1225
+ });
1226
+
1227
+ // Update task activity when terminal connects
1228
+ for (const [taskId, task] of tasks.entries()) {
1229
+ if (task.containerId === dockerId) {
1230
+ task.lastActivity = new Date().toISOString();
1231
+ logger.debug('Updated task activity for terminal connection', { taskId });
1232
+ break;
1233
+ }
1234
+ }
1235
+
1236
+ // Send 'connected' status AFTER all handlers are set up
1237
+ ws.send(JSON.stringify({ type: 'status', status: 'connected' }));
1238
+ }
1239
+
1240
+ function resolveDockerContainerId(containerKey) {
1241
+ if (!containerKey) return null;
1242
+
1243
+ if (containers.has(containerKey)) {
1244
+ return containers.get(containerKey).fullContainerId;
1245
+ }
1246
+
1247
+ for (const data of containers.values()) {
1248
+ if (data.fullContainerId?.startsWith(containerKey) || data.containerId === containerKey || data.name === containerKey) {
1249
+ return data.fullContainerId;
1250
+ }
1251
+ }
1252
+
1253
+ for (const task of tasks.values()) {
1254
+ if (task.containerId && task.containerId.startsWith(containerKey)) {
1255
+ return task.containerId;
1256
+ }
1257
+ }
1258
+
1259
+ return containerKey;
1260
+ }
1261
+
1262
+ function safeCloseStream(stream) {
1263
+ if (!stream) return;
1264
+ try {
1265
+ stream.end();
1266
+ } catch (error) {
1267
+ logger.debug('Stream end failed', { message: error.message });
1268
+ }
1269
+ if (typeof stream.destroy === 'function') {
1270
+ stream.destroy();
1271
+ }
1272
+ }
1273
+
1274
+ /**
1275
+ * Auto-cleanup job: Stop inactive task containers after configurable inactivity period
1276
+ * and clean up orphaned task directories after 24 hours
1277
+ */
1278
+ function startAutoCleanup() {
1279
+ // Container inactivity threshold (default: 2 hours, configurable via CONTAINER_CLEANUP_HOURS)
1280
+ const cleanupHours = parseInt(process.env.CONTAINER_CLEANUP_HOURS || '2', 10);
1281
+ const CONTAINER_INACTIVITY_THRESHOLD = cleanupHours * 60 * 60 * 1000;
1282
+ const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
1283
+ const CHECK_INTERVAL = 10 * 60 * 1000; // Check every 10 minutes
1284
+
1285
+ setInterval(async () => {
1286
+ const now = Date.now();
1287
+
1288
+ // Stop inactive containers (but keep them for easy restart)
1289
+ for (const [taskId, task] of tasks.entries()) {
1290
+ if (!task.containerId) continue;
1291
+
1292
+ // Get last activity time
1293
+ const lastActivity = task.lastActivity || task.finishedAt || task.createdAt;
1294
+ if (!lastActivity) continue;
1295
+
1296
+ const lastActivityTime = new Date(lastActivity).getTime();
1297
+ const inactiveTime = now - lastActivityTime;
1298
+
1299
+ // If inactive for more than threshold, stop container
1300
+ if (inactiveTime > CONTAINER_INACTIVITY_THRESHOLD) {
1301
+ try {
1302
+ const container = docker.getContainer(task.containerId);
1303
+
1304
+ // Check if container actually exists and is running
1305
+ let containerInfo;
1306
+ try {
1307
+ containerInfo = await container.inspect();
1308
+ } catch (error) {
1309
+ // Container doesn't exist - just clear the ID silently
1310
+ if (error.statusCode === 404) {
1311
+ task.containerId = null;
1312
+ continue; // Skip to next task without logging
1313
+ }
1314
+ throw error; // Re-throw other errors
1315
+ }
1316
+
1317
+ // Only stop if container is running
1318
+ if (containerInfo.State.Running) {
1319
+ logger.info('Auto-stopping inactive container', {
1320
+ taskId,
1321
+ containerId: task.containerId.substring(0, 12),
1322
+ inactiveHours: (inactiveTime / (60 * 60 * 1000)).toFixed(2)
1323
+ });
1324
+
1325
+ // Stop container (keep it for easy restart)
1326
+ await container.stop({ t: 5 });
1327
+
1328
+ logger.info('Container stopped', { taskId });
1329
+ }
1330
+ } catch (error) {
1331
+ logger.warn('Failed to auto-stop container', {
1332
+ taskId,
1333
+ error: error.message
1334
+ });
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ // Clean up orphaned task directories (older than 24 hours with no task.json)
1340
+ try {
1341
+ const basePath = getBaseTaskStoragePath();
1342
+ const dirs = await fs.readdir(basePath).catch(() => []);
1343
+
1344
+ for (const taskId of dirs) {
1345
+ const { outputDir } = getTaskDirectories(taskId);
1346
+ const taskJsonPath = path.join(outputDir, 'task.json');
1347
+
1348
+ try {
1349
+ // Check if task.json exists
1350
+ await fs.access(taskJsonPath);
1351
+ // Task has task.json, skip it
1352
+ } catch {
1353
+ // No task.json - check if directory is old enough to clean
1354
+ try {
1355
+ const stats = await fs.stat(outputDir);
1356
+ const age = now - stats.mtimeMs;
1357
+
1358
+ if (age > TWENTY_FOUR_HOURS) {
1359
+ logger.debug('Removing orphaned task directory', {
1360
+ taskId,
1361
+ ageHours: (age / (60 * 60 * 1000)).toFixed(2)
1362
+ });
1363
+
1364
+ await fs.rm(path.join(basePath, taskId), { recursive: true, force: true });
1365
+ }
1366
+ } catch {
1367
+ // Ignore errors accessing directory stats
1368
+ }
1369
+ }
1370
+ }
1371
+ } catch (error) {
1372
+ logger.debug('Failed to clean orphaned directories', { error: error.message });
1373
+ }
1374
+ }, CHECK_INTERVAL);
1375
+
1376
+ logger.info('Auto-cleanup job started', {
1377
+ inactivityThreshold: `${cleanupHours} hours`,
1378
+ orphanedDirectoryThreshold: '24 hours',
1379
+ checkInterval: '10 minutes'
1380
+ });
1381
+ }