@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.
- package/dist/base-image/entrypoint.sh +2 -1
- package/dist/coder-server.js +1 -1
- package/dist/config.js +1 -1
- package/dist/lib/agent-keepalive.js +1 -1
- package/dist/lib/agent-models.js +1 -1
- package/dist/lib/api-keys.js +1 -1
- package/dist/lib/apiKeys.js +1 -1
- package/dist/lib/app-server-ports.js +1 -1
- package/dist/lib/auto-judge.js +1 -1
- package/dist/lib/automation-service.js +1 -0
- package/dist/lib/basic-auth.js +1 -1
- package/dist/lib/bindings.js +1 -0
- package/dist/lib/build-history.js +1 -1
- package/dist/lib/build-output-service.js +1 -1
- package/dist/lib/build-scheduler.js +1 -1
- package/dist/lib/build-service.js +1 -1
- package/dist/lib/ca-certificates.js +1 -1
- package/dist/lib/claude-oauth-refresh.js +1 -1
- package/dist/lib/cli/build.js +1 -1
- package/dist/lib/cli/config-command.js +1 -1
- package/dist/lib/cli/config.js +1 -1
- package/dist/lib/cli/create-user.js +1 -1
- package/dist/lib/cli/init.js +1 -1
- package/dist/lib/cli/jira.js +1 -1
- package/dist/lib/cli/license.js +1 -1
- package/dist/lib/cli/server-manager.js +1 -1
- package/dist/lib/config-migration.js +1 -1
- package/dist/lib/container-credential-sync.js +1 -1
- package/dist/lib/container-tokens.js +1 -1
- package/dist/lib/data-dir.js +1 -1
- package/dist/lib/deployment-history.js +1 -1
- package/dist/lib/deployment-service.js +1 -1
- package/dist/lib/docker-utils.js +1 -1
- package/dist/lib/email.js +1 -1
- package/dist/lib/emailTemplates.js +1 -1
- package/dist/lib/entitlement.js +1 -1
- package/dist/lib/fetch-utils.js +1 -1
- package/dist/lib/git-commit-details-route.js +1 -1
- package/dist/lib/git-history-diff-guardrails.js +1 -1
- package/dist/lib/git-provider-service.js +1 -1
- package/dist/lib/git-provider-setup/github-setup-handler.js +1 -1
- package/dist/lib/git-provider-setup/index.js +1 -1
- package/dist/lib/git-provider-setup/setup-factory.js +1 -1
- package/dist/lib/git-provider-setup/setup-interface.js +1 -1
- package/dist/lib/git-providers/azure-devops-provider.js +1 -1
- package/dist/lib/git-providers/github-app-provider.js +1 -1
- package/dist/lib/git-providers/index.js +1 -1
- package/dist/lib/git-providers/provider-factory.js +1 -1
- package/dist/lib/git-providers/provider-interface.js +1 -1
- package/dist/lib/github-urls.js +1 -1
- package/dist/lib/group-objective-linking.js +1 -1
- package/dist/lib/jira-client.js +1 -1
- package/dist/lib/judge-blinding.js +1 -1
- package/dist/lib/logger.js +1 -1
- package/dist/lib/migration-to-scoped-rbac.js +1 -0
- package/dist/lib/model-fetcher.js +1 -1
- package/dist/lib/notifications.js +1 -1
- package/dist/lib/objective-context.js +1 -1
- package/dist/lib/oidc-auth.js +1 -1
- package/dist/lib/oidc-device-flow.js +1 -1
- package/dist/lib/passwordTokens.js +1 -1
- package/dist/lib/permission-resolver.js +1 -0
- package/dist/lib/pin-cascade.js +1 -1
- package/dist/lib/provider-accounts.js +1 -1
- package/dist/lib/provider-oauth.js +1 -1
- package/dist/lib/provider-profile.js +1 -1
- package/dist/lib/provider-token-refresh.js +1 -1
- package/dist/lib/request-url.js +1 -1
- package/dist/lib/rewind.js +1 -1
- package/dist/lib/role-definitions.js +1 -0
- package/dist/lib/roles.js +1 -1
- package/dist/lib/secrets.js +1 -1
- package/dist/lib/setup-repo-git-auth.js +1 -1
- package/dist/lib/state-capture.js +1 -1
- package/dist/lib/static-files.js +1 -1
- package/dist/lib/task-name-format.js +1 -1
- package/dist/lib/task-name-generator.js +1 -1
- package/dist/lib/task-source-metadata.js +1 -0
- package/dist/lib/teams.js +1 -0
- package/dist/lib/user-git-oauth.js +1 -1
- package/dist/lib/user-git-tokens.js +1 -1
- package/dist/lib/users.js +1 -1
- package/dist/middleware/requireAuth.js +1 -1
- package/dist/middleware/requireInit.js +1 -1
- package/dist/middleware/requirePermission.js +1 -1
- package/dist/package-lock.json +211 -21
- package/dist/package.json +2 -1
- package/dist/playwright.config.js +1 -0
- package/dist/routes/apiKeys.js +1 -1
- package/dist/routes/auth-oidc.js +1 -1
- package/dist/routes/auth.js +1 -1
- package/dist/routes/automations.js +1 -0
- package/dist/routes/bindings.js +1 -0
- package/dist/routes/build.js +1 -1
- package/dist/routes/containers.js +1 -1
- package/dist/routes/deploy-task.js +1 -1
- package/dist/routes/environment-management.js +1 -1
- package/dist/routes/environments.js +1 -1
- package/dist/routes/external-skills.js +1 -1
- package/dist/routes/git-credentials.js +1 -1
- package/dist/routes/git-oauth.js +1 -1
- package/dist/routes/git-provider-setup.js +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/jira.js +1 -1
- package/dist/routes/objective-management.js +1 -1
- package/dist/routes/password.js +1 -1
- package/dist/routes/prompt.js +1 -1
- package/dist/routes/provider-auth.js +1 -1
- package/dist/routes/qa.js +1 -1
- package/dist/routes/roles.js +1 -0
- package/dist/routes/settings.js +1 -1
- package/dist/routes/skill-management.js +1 -1
- package/dist/routes/skills.js +1 -1
- package/dist/routes/tasks.js +1 -1
- package/dist/routes/teams.js +1 -0
- package/dist/routes/templates.js +1 -1
- package/dist/routes/test-task.js +1 -1
- package/dist/routes/test.js +1 -1
- package/dist/routes/users.js +1 -1
- package/dist/routes/visualizations.js +1 -1
- package/dist/scripts/create-user.js +1 -1
- package/dist/scripts/migrate-config-to-data-dir.js +1 -1
- package/dist/scripts/migrate-to-scoped-rbac.js +2 -0
- package/dist/start.js +1 -1
- package/dist/start.js.bak +1381 -0
- package/dist/web-ui/public/activity-detail-modal.js +1 -1
- package/dist/web-ui/public/activity-feed.js +1 -1
- package/dist/web-ui/public/activity-formatters.js +1 -1
- package/dist/web-ui/public/agent-event-parser.js +1 -1
- package/dist/web-ui/public/app.js +1 -1
- package/dist/web-ui/public/approve-dialog.js +1 -1
- package/dist/web-ui/public/automation-links.js +1 -0
- package/dist/web-ui/public/automation-schedule.js +1 -0
- package/dist/web-ui/public/comments-widget.js +1 -1
- package/dist/web-ui/public/diff-utils.js +1 -1
- package/dist/web-ui/public/docs/_sidebar.md +1 -0
- package/dist/web-ui/public/docs/admin/automations.md +75 -0
- package/dist/web-ui/public/docs/admin/users-and-roles.md +14 -4
- package/dist/web-ui/public/environments.css +247 -125
- package/dist/web-ui/public/environments.html +346 -2
- package/dist/web-ui/public/environments.js +1 -1
- package/dist/web-ui/public/feedback-widget.css +42 -0
- package/dist/web-ui/public/feedback-widget.js +1 -1
- package/dist/web-ui/public/git-history-lazy-utils.js +1 -1
- package/dist/web-ui/public/git-history.html +15 -0
- package/dist/web-ui/public/git-history.js +1 -1
- package/dist/web-ui/public/git-status.js +1 -1
- package/dist/web-ui/public/index.html +27 -0
- package/dist/web-ui/public/index.js +1 -1
- package/dist/web-ui/public/login.js +1 -1
- package/dist/web-ui/public/markdown-editor.js +1 -1
- package/dist/web-ui/public/markdown-file-editor.js +1 -1
- package/dist/web-ui/public/modal-maximize.js +1 -1
- package/dist/web-ui/public/notifications.js +1 -1
- package/dist/web-ui/public/pr-dialog.js +1 -1
- package/dist/web-ui/public/roles.html +247 -0
- package/dist/web-ui/public/roles.js +1 -0
- package/dist/web-ui/public/server-health.js +1 -1
- package/dist/web-ui/public/settings.html +62 -0
- package/dist/web-ui/public/settings.js +1 -1
- package/dist/web-ui/public/setup-password.js +1 -1
- package/dist/web-ui/public/skills.html +15 -0
- package/dist/web-ui/public/skills.js +1 -1
- package/dist/web-ui/public/sse-client.js +1 -1
- package/dist/web-ui/public/sse-shared-worker.js +1 -1
- package/dist/web-ui/public/styles.css +198 -161
- package/dist/web-ui/public/task.html +2 -2
- package/dist/web-ui/public/task.js +1 -1
- package/dist/web-ui/public/teams.html +285 -0
- package/dist/web-ui/public/teams.js +1 -0
- package/dist/web-ui/public/terminal.js +1 -1
- package/dist/web-ui/public/theme.js +1 -1
- package/dist/web-ui/public/users.html +87 -29
- package/dist/web-ui/public/users.js +1 -1
- package/dist/web-ui/public/variant-grouping.js +1 -1
- 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
|
+
}
|