This commit is contained in:
+36
-543
@@ -24,6 +24,7 @@ import { mountBrandingApi, resolveBranding } from './branding-api.js';
|
||||
import { createBrowserApi } from './browser-api.js';
|
||||
import { createBrowserSessionApi } from './browser-session-api.js';
|
||||
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
||||
import { createDelegateRunsRouter } from './delegate-runs-api.js';
|
||||
import { createUsageRouter } from './usage-api.js';
|
||||
import { SessionManager } from '../engine/browser-session.js';
|
||||
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
|
||||
@@ -31,12 +32,14 @@ import { setSessionManager } from '../engine/tools/browser.js';
|
||||
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
|
||||
import { setSkillToolDeps } from '../engine/tools/skills.js';
|
||||
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
|
||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
|
||||
import { canUserSeeTask, canEditInSpace } from './visibility.js';
|
||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
|
||||
import { canUserSeeTask } from './visibility.js';
|
||||
import { mountAdminApi } from './admin-api.js';
|
||||
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
||||
import { mountUsersApi, mountUsersPickableApi } from './users-api.js';
|
||||
import { mountShareApi } from './share-api.js';
|
||||
import { mountAppShareApi } from './app-share-api.js';
|
||||
import { mountMetaApi } from './meta-api.js';
|
||||
import { securityHeadersMiddleware } from './security-headers.js';
|
||||
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||
import { findPieceFile } from './pieces-api.js';
|
||||
@@ -47,6 +50,9 @@ import { createMemoryApi } from './memory-api.js';
|
||||
import { createReflectionApi } from './reflection-api.js';
|
||||
import { createDashboardApi } from './dashboard-api.js';
|
||||
import { createSpaceApi } from './space-api.js';
|
||||
import { setupMcpSubsystem } from './mcp-subsystem.js';
|
||||
import { setupSshSubsystem, type SshConsoleDeps } from './ssh-subsystem.js';
|
||||
import { mountAppHarnessApi } from './app-harness-api.js';
|
||||
import { createCrossCalendarApi } from './cross-calendar-api.js';
|
||||
import { registerShutdownHook, installSignalHandlers } from './shutdown.js';
|
||||
import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js';
|
||||
@@ -58,58 +64,7 @@ import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
|
||||
import { userPiecesDir } from '../user-folder/paths.js';
|
||||
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
|
||||
import type { AuthConfig } from '../config.js';
|
||||
import { isKeyConfigured } from '../mcp/crypto.js';
|
||||
import { createRegistry } from '../mcp/registry.js';
|
||||
import { createTokenManager } from '../mcp/token-manager.js';
|
||||
import { createToolCache } from '../mcp/tool-cache.js';
|
||||
import { createAggregator } from '../mcp/aggregator.js';
|
||||
import { createMcpClient } from '../mcp/client-factory.js';
|
||||
import { executeMcpCall } from '../mcp/tool-executor.js';
|
||||
import { refreshAccessToken } from '../mcp/discovery.js';
|
||||
import { setMcpAggregator } from '../engine/tools/index.js';
|
||||
import { setMcpToolLookup } from '../engine/tools/docs.js';
|
||||
import { setCalendarRepo } from '../engine/tools/calendar.js';
|
||||
import { createMcpOauthRouter } from '../mcp/oauth-routes.js';
|
||||
import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js';
|
||||
import { mergeMcpConfig } from '../mcp/config.js';
|
||||
import { mergeSshConfig } from '../ssh/config.js';
|
||||
import {
|
||||
bootstrapSystemDek,
|
||||
verifySystemDek,
|
||||
encryptPrivateKey as sshEncryptPrivateKey,
|
||||
decryptPrivateKey as sshDecryptPrivateKey,
|
||||
computeKeyFingerprint as sshComputeKeyFingerprint,
|
||||
formatPublicKey as sshFormatPublicKey,
|
||||
generateKeypair as sshGenerateKeypair,
|
||||
type GeneratedKeyType as SshGeneratedKeyType,
|
||||
} from '../ssh/crypto.js';
|
||||
import { createConnectionRepo } from '../ssh/connection-repo.js';
|
||||
import { createGrantsRepo } from '../ssh/grants-repo.js';
|
||||
import { createAuditRepo } from '../ssh/audit-repo.js';
|
||||
import { createAbuseRepo } from '../ssh/abuse-repo.js';
|
||||
import { createAccessResolver } from '../ssh/access.js';
|
||||
import { maintenance as sshMaintenance } from '../ssh/maintenance.js';
|
||||
import { createAdminRateLimiter, FORCE_UNLOCK_LIMIT } from '../ssh/admin-rate-limit.js';
|
||||
import {
|
||||
sshTest,
|
||||
sshExec,
|
||||
sshUpload,
|
||||
sshDownload,
|
||||
openShellChannel,
|
||||
type ResolvedConnection as SshResolvedConnection,
|
||||
} from '../ssh/session.js';
|
||||
import { SessionRegistry } from '../ssh/console-registry.js';
|
||||
import { createSshUserRouter, createSshAdminRouter, type SshApiDeps } from './ssh-api.js';
|
||||
import { setSshSubsystem, preflight as sshPreflight, type SshSubsystem } from '../engine/tools/ssh.js';
|
||||
import { __setActiveSessionLookup } from '../engine/agent-loop.js';
|
||||
import {
|
||||
attachConsoleWs,
|
||||
createConsoleStatusRouter,
|
||||
createConsoleSessionRouter,
|
||||
type SimpleTask,
|
||||
type SimpleUser,
|
||||
} from './console-ws-api.js';
|
||||
import { createConsoleAdminRouter } from './console-admin-api.js';
|
||||
import { attachConsoleWs } from './console-ws-api.js';
|
||||
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
||||
import { readGatewayConfig } from '../gateway/config.js';
|
||||
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
||||
@@ -161,17 +116,6 @@ export interface CoreServerOptions {
|
||||
listenPort?: number;
|
||||
}
|
||||
|
||||
export interface SshConsoleDeps {
|
||||
registry: SessionRegistry;
|
||||
resolveUserFromUpgrade: (req: import('http').IncomingMessage) => Promise<SimpleUser | null>;
|
||||
resolveTask: (taskId: string, user: SimpleUser) => Promise<SimpleTask | null>;
|
||||
resolveSshAccess: (
|
||||
user: SimpleUser,
|
||||
session: import('../ssh/console-session.js').ConsoleSession,
|
||||
task: SimpleTask,
|
||||
) => Promise<boolean>;
|
||||
denyPatterns: import('./console-ws-api.js').DenyPatternProvider;
|
||||
}
|
||||
|
||||
export function createCoreServer(opts: CoreServerOptions): {
|
||||
app: express.Application;
|
||||
@@ -468,6 +412,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// --- Share API (public + authenticated routes) ---
|
||||
mountShareApi(app, repo);
|
||||
|
||||
// --- 公開アプリ共有 API(read-only・認証なし)---
|
||||
// space-api と同じワークスペース解決(loadConfig().worktreeDir)を使う。
|
||||
mountAppShareApi(app, repo, worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees');
|
||||
|
||||
// Redirect root to UI
|
||||
app.get('/', (_req: Request, res: Response) => {
|
||||
res.redirect('/ui');
|
||||
@@ -479,493 +427,33 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
app.get('/ui/*', (_req, res) => {
|
||||
res.sendFile(join(uiDistPath, 'index.html'));
|
||||
});
|
||||
// Headless test-harness page for workspace-app E2E (Task 5/6).
|
||||
// Must be registered BEFORE any SPA catch-all and BEFORE space-api routes
|
||||
// so that the app-harness auth middleware (mounted below) sees the request.
|
||||
// The harness JS/CSS chunks are served by the existing /ui static mount
|
||||
// because Vite builds them with base: '/ui/' → /ui/assets/… paths.
|
||||
app.get('/app-harness', (_req, res) => {
|
||||
res.sendFile(join(uiDistPath, 'app-harness.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Version endpoint
|
||||
app.get('/api/version', async (_req: Request, res: Response) => {
|
||||
let version = 'dev';
|
||||
try {
|
||||
const mod = await import('../generated/version.js');
|
||||
version = mod.APP_VERSION;
|
||||
} catch {
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
version = execSync("TZ=UTC git log -1 --format=%cd --date=format:'%Y%m%d.%H%M%S'", { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
// keep 'dev'
|
||||
}
|
||||
}
|
||||
res.json({ version });
|
||||
});
|
||||
|
||||
app.get('/api/repos', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const reposFromJobs = repo.getDistinctRepos();
|
||||
const reposFromConfig = (opts.configuredRepos ?? []).filter(Boolean);
|
||||
const repos = Array.from(new Set([...reposFromConfig, ...reposFromJobs])).sort();
|
||||
res.json({ repos });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to fetch repos' });
|
||||
}
|
||||
});
|
||||
// Version + repos info endpoints (see meta-api.ts). Mounted here to preserve
|
||||
// ordering relative to the `/api/repos` requireAuth guard registered above.
|
||||
mountMetaApi(app, { repo, configuredRepos: opts.configuredRepos });
|
||||
|
||||
// Per-user browser session profile repo (envelope-encrypted storageState).
|
||||
// Created up-front so APIs that bind a profile to a task (local + scheduled)
|
||||
// can run an owner-scoped check before persisting.
|
||||
const sessRepo = new BrowserSessionRepo(repo.getDb());
|
||||
|
||||
// Surfaces per-user MCP servers into /api/tools so the Piece allowed_tools
|
||||
// editor can include them. Populated by the MCP block below when the
|
||||
// subsystem initialises; remains null otherwise.
|
||||
let mcpCatalogDeps: import('./tools-api.js').McpCatalogDeps | null = null;
|
||||
const mcpCatalogDeps = setupMcpSubsystem(app, {
|
||||
repo,
|
||||
authActive,
|
||||
workerManager: opts.workerManager,
|
||||
callbackBaseUrl: deriveCallbackBaseUrl(opts.authConfig),
|
||||
});
|
||||
|
||||
// MCP subsystem (gated on MCP_ENCRYPTION_KEY)
|
||||
{
|
||||
if (isKeyConfigured()) {
|
||||
const mcpConfig = mergeMcpConfig(loadConfig().mcp);
|
||||
const mcpRegistry = createRegistry(repo.getDb());
|
||||
const mcpTokenManager = createTokenManager(repo.getDb(), {
|
||||
doRefresh: async (serverId: string, refreshToken: string) => {
|
||||
const server = mcpRegistry.getDecrypted(serverId);
|
||||
if (!server || !server.tokenEndpoint) {
|
||||
throw new Error(`server or token endpoint missing for ${serverId}`);
|
||||
}
|
||||
return refreshAccessToken({
|
||||
tokenEndpoint: server.tokenEndpoint,
|
||||
clientId: server.oauthClientId,
|
||||
clientSecret: server.oauthClientSecret,
|
||||
refreshToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
const mcpToolCache = createToolCache(repo.getDb(), mcpConfig.toolCacheTtlSeconds);
|
||||
const mcpAggregator = createAggregator({
|
||||
registry: mcpRegistry,
|
||||
tokenManager: mcpTokenManager,
|
||||
toolCache: mcpToolCache,
|
||||
executeCall: async (args) => {
|
||||
const server = mcpRegistry.getDecrypted(args.serverId);
|
||||
if (!server) return { output: `未登録の MCP サーバー: ${args.serverId}`, isError: true };
|
||||
const { client, close } = await createMcpClient(server, args.accessToken, {
|
||||
callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000,
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
});
|
||||
try {
|
||||
return await executeMcpCall({
|
||||
client,
|
||||
serverId: args.serverId,
|
||||
toolName: args.toolName,
|
||||
input: args.input,
|
||||
ctx: args.ctx,
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
});
|
||||
setMcpAggregator(mcpAggregator);
|
||||
setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName));
|
||||
setCalendarRepo(repo);
|
||||
opts.workerManager?.setMcpDeps({ tokenManager: mcpTokenManager });
|
||||
|
||||
// Expose MCP enumeration to /api/tools so the Piece allowed_tools editor
|
||||
// can list per-user MCP tools alongside builtin ones. Methods on the
|
||||
// registry/tokenManager/toolCache surfaces are already DB-backed and
|
||||
// safe to call per-request.
|
||||
mcpCatalogDeps = {
|
||||
registry: { listEnabledForUser: (uid) => mcpRegistry.listEnabledForUser(uid) },
|
||||
tokenManager: { hasToken: (uid, sid) => mcpTokenManager.hasToken(uid, sid) },
|
||||
toolCache: { getAllForServers: (ids) => mcpToolCache.getAllForServers(ids) },
|
||||
};
|
||||
|
||||
const callbackBaseUrl = deriveCallbackBaseUrl(opts.authConfig);
|
||||
|
||||
app.use(
|
||||
'/auth/mcp',
|
||||
createMcpOauthRouter({
|
||||
db: repo.getDb(),
|
||||
registry: mcpRegistry,
|
||||
tokenManager: mcpTokenManager,
|
||||
pendingTtlMinutes: mcpConfig.oauthPendingTtlMinutes,
|
||||
getCallbackBaseUrl: () => callbackBaseUrl,
|
||||
getAuthenticatedUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
resumeWaitingJobs: (uid, sid) => {
|
||||
repo.resumeMcpWaitingJobs(uid, sid);
|
||||
},
|
||||
listToolsAfterAuth: async (serverId: string, accessToken: string) => {
|
||||
const server = mcpRegistry.getDecrypted(serverId);
|
||||
if (!server) return;
|
||||
const { client, close } = await createMcpClient(server, accessToken, {
|
||||
callTimeoutMs: mcpConfig.callTimeoutSeconds * 1000,
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
});
|
||||
try {
|
||||
const list = (await client.listTools()) as {
|
||||
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>;
|
||||
};
|
||||
mcpToolCache.replaceForServer(serverId, list.tools);
|
||||
logger.info(
|
||||
`[mcp] auto list_tools after OAuth server=${serverId} count=${list.tools.length}`,
|
||||
);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.use('/api/mcp/servers', express.json(), createMcpAdminRouter({
|
||||
db: repo.getDb(),
|
||||
registry: mcpRegistry,
|
||||
tokenManager: mcpTokenManager,
|
||||
toolCache: mcpToolCache,
|
||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
}));
|
||||
|
||||
app.use('/api/mcp/connections', express.json(), createMcpUserRouter({
|
||||
db: repo.getDb(),
|
||||
registry: mcpRegistry,
|
||||
tokenManager: mcpTokenManager,
|
||||
toolCache: mcpToolCache,
|
||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
}));
|
||||
|
||||
app.use('/api/mcp/user-servers', express.json(), createMcpUserServersRouter({
|
||||
db: repo.getDb(),
|
||||
registry: mcpRegistry,
|
||||
tokenManager: mcpTokenManager,
|
||||
toolCache: mcpToolCache,
|
||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||
// No-auth single-user mode: passport is not mounted, so req.user is
|
||||
// never populated. Fall back to the synthetic `local` admin (the same
|
||||
// owner the SSH user router and space API use) so per-user / per-space
|
||||
// MCP server management stays usable; otherwise getUserId is null and
|
||||
// every register/list 401s, making the per-space MCP panel dead in
|
||||
// no-auth deployments. No-op when auth is active (req.user wins).
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? (authActive ? null : 'local'),
|
||||
// Mirror the no-auth fallback: when auth is off and passport hasn't
|
||||
// populated a viewer, authorize the space against the synthetic `local`
|
||||
// admin so getSpace doesn't reject the (null-owner) local space.
|
||||
getSpace: (spaceId, viewer) =>
|
||||
repo.getSpace(spaceId, {
|
||||
viewer: viewer ?? (authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User)),
|
||||
}),
|
||||
// Space-as-Principal P2: edit-rights check so space members can share the
|
||||
// management of space-owned api_key MCP servers. Mirrors the no-auth
|
||||
// synthetic local admin used by getUserId/getSpace above.
|
||||
canEditSpace: async (spaceId, req) => {
|
||||
const viewer =
|
||||
(req.user as Express.User | undefined) ??
|
||||
(authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User));
|
||||
if (!viewer) return false;
|
||||
const space = await repo.getSpace(spaceId, { viewer });
|
||||
if (!space) return false;
|
||||
const role = repo.getSpaceMemberRole(spaceId, viewer.id);
|
||||
return canEditInSpace(viewer, { ownerId: space.ownerId }, role);
|
||||
},
|
||||
insecureLocalTestMode: false,
|
||||
allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
|
||||
}));
|
||||
|
||||
logger.info('[mcp] subsystem initialised');
|
||||
} else {
|
||||
logger.warn('[mcp] MCP_ENCRYPTION_KEY not configured — MCP features disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// SSH subsystem (gated on ssh.enabled AND MCP_ENCRYPTION_KEY)
|
||||
// Phase 5 (SSH Console): sshConsole is captured here so that
|
||||
// startCoreServer() can wire the WS upgrade hook to the http.Server
|
||||
// it eventually creates. null when SSH is disabled / failed init.
|
||||
let sshConsole: SshConsoleDeps | null = null;
|
||||
{
|
||||
const sshConfig = mergeSshConfig(loadConfig().ssh);
|
||||
if (!sshConfig.enabled) {
|
||||
setSshSubsystem(null);
|
||||
__setActiveSessionLookup(null);
|
||||
} else if (!isKeyConfigured()) {
|
||||
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
|
||||
setSshSubsystem(null);
|
||||
__setActiveSessionLookup(null);
|
||||
} else {
|
||||
try {
|
||||
bootstrapSystemDek(repo.getDb());
|
||||
verifySystemDek(repo.getDb());
|
||||
|
||||
const connectionRepo = createConnectionRepo(repo.getDb());
|
||||
const grantsRepo = createGrantsRepo(repo.getDb());
|
||||
const auditRepo = createAuditRepo(repo.getDb());
|
||||
const abuseRepo = createAbuseRepo(repo.getDb(), {
|
||||
windowMinutes: sshConfig.abuseWindowMinutes,
|
||||
failureThreshold: sshConfig.abuseFailureThreshold,
|
||||
lockMinutes: sshConfig.abuseLockMinutes,
|
||||
});
|
||||
const accessResolver = createAccessResolver(grantsRepo, {
|
||||
adminBypassesGrants: sshConfig.adminBypassesGrants,
|
||||
});
|
||||
const forceUnlockLimiter = createAdminRateLimiter(FORCE_UNLOCK_LIMIT);
|
||||
|
||||
// Hoisted above sshDeps so the grant-revocation hook can call
|
||||
// sessionRegistry.revokeAccessFor (introduced for Phase 5 hardening:
|
||||
// kick active WS viewers when their grant is deleted).
|
||||
const sessionRegistry = new SessionRegistry({
|
||||
idleTimeoutMs: sshConfig.console.idleTimeoutSeconds * 1000,
|
||||
maxSessionDurationMs: sshConfig.console.maxSessionDurationSeconds * 1000,
|
||||
maxSessionsPerConnection: sshConfig.console.maxSessionsPerConnection,
|
||||
});
|
||||
|
||||
const sshDeps: SshApiDeps = {
|
||||
db: repo.getDb(),
|
||||
authActive,
|
||||
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
|
||||
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
|
||||
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
|
||||
isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin',
|
||||
getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
|
||||
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
|
||||
connectionRepo,
|
||||
grantsRepo,
|
||||
auditRepo,
|
||||
abuseRepo,
|
||||
accessResolver,
|
||||
maintenance: sshMaintenance,
|
||||
forceUnlockLimiter,
|
||||
encryptKeyMaterial: (ownerId, pem, passphrase, spaceId) => {
|
||||
const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem, spaceId);
|
||||
const passphraseBlob = passphrase
|
||||
? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase, spaceId).blob
|
||||
: null;
|
||||
const fingerprint = sshComputeKeyFingerprint(pem, passphrase);
|
||||
const publicKey = sshFormatPublicKey(pem, passphrase);
|
||||
return { blob, passphraseBlob, keyVersion, fingerprint, publicKey };
|
||||
},
|
||||
decryptKeyMaterial: (ownerId, blob, spaceId) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
|
||||
decryptPassphrase: (ownerId, blob, spaceId) =>
|
||||
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
|
||||
generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType),
|
||||
derivePublicKey: (ownerId, blob, passphraseBlob, spaceId) => {
|
||||
const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId);
|
||||
const pass = passphraseBlob
|
||||
? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob, spaceId)
|
||||
: null;
|
||||
try {
|
||||
return sshFormatPublicKey(pem, pass);
|
||||
} finally {
|
||||
pem.fill(0);
|
||||
if (pass) pass.fill(0);
|
||||
}
|
||||
},
|
||||
sshTester: {
|
||||
async test({ connection, decryptedKey, passphrase, timeoutMs }) {
|
||||
const conn: SshResolvedConnection = {
|
||||
id: connection.id,
|
||||
ownerId: connection.ownerId,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
privateKeyPem: decryptedKey,
|
||||
passphrase: passphrase ?? undefined,
|
||||
hostKeyB64: connection.hostKeyB64,
|
||||
hostKeyVerified: connection.hostKeyVerifiedAt !== null,
|
||||
allowPrivate:
|
||||
sshConfig.allowPrivateAddresses || connection.allowPrivateAddresses,
|
||||
};
|
||||
return sshTest({ connection: conn, timeoutMs });
|
||||
},
|
||||
},
|
||||
connectionTestTimeoutMs: sshConfig.callTimeoutSeconds * 1000,
|
||||
onAccessRevoked: ({ connectionId, userId }) =>
|
||||
sessionRegistry.revokeAccessFor({
|
||||
connectionId,
|
||||
userId,
|
||||
reason: 'access_revoked',
|
||||
}),
|
||||
};
|
||||
|
||||
app.use('/api/ssh/admin', express.json(), createSshAdminRouter(sshDeps));
|
||||
app.use('/api/ssh', express.json(), createSshUserRouter(sshDeps));
|
||||
|
||||
// Phase 7: register the SSH tool subsystem so SshExec / SshUpload /
|
||||
// SshDownload tools can access the same repos / session primitives /
|
||||
// crypto wrappers that the HTTP layer uses. sessionRegistry is
|
||||
// constructed above (hoisted so sshDeps.onAccessRevoked can use it).
|
||||
// Captured in a local const (not just passed to setSshSubsystem)
|
||||
// so the user-initiated console-session REST endpoint can call the
|
||||
// shared openConsoleSession core with the EXACT same `sub` the
|
||||
// agent-facing console tools use — no second SshSubsystem.
|
||||
const sshSubsystem: SshSubsystem = {
|
||||
connectionRepo,
|
||||
auditRepo,
|
||||
abuseRepo,
|
||||
accessResolver,
|
||||
decryptKeyMaterial: (ownerId, blob, spaceId) =>
|
||||
sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
|
||||
decryptPassphrase: (ownerId, blob, spaceId) =>
|
||||
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
|
||||
getUserAccess: (userId) => {
|
||||
const user = repo.getUserById(userId);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const orgIds = resolveOrgIds(repo, userId);
|
||||
return { isAdmin, orgIds };
|
||||
},
|
||||
sshExec,
|
||||
sshUpload,
|
||||
sshDownload,
|
||||
maintenance: sshMaintenance,
|
||||
config: sshConfig,
|
||||
sessionRegistry,
|
||||
openShellChannel,
|
||||
};
|
||||
setSshSubsystem(sshSubsystem);
|
||||
|
||||
// Phase 4 (SSH Console): wire the registry into agent-loop so
|
||||
// buildSystemPrompt can auto-inject the live screen tail into
|
||||
// the LLM system prompt for movements that allow SshConsole*.
|
||||
__setActiveSessionLookup((taskId) => sessionRegistry.get(taskId));
|
||||
|
||||
// Phase 5 (SSH Console): start the periodic sweep so idle /
|
||||
// duration-cap sessions actually get closed. Without this, the
|
||||
// registry just holds sessions until shutdown.
|
||||
sessionRegistry.startSweepTimer(60_000);
|
||||
|
||||
// Phase 5 (SSH Console): when SSH maintenance mode activates
|
||||
// (master-key rotation), close all live console sessions. They
|
||||
// would otherwise hold a decrypted DEK reference past the
|
||||
// rewrap window. The reason 'maintenance' is surfaced to the
|
||||
// WS client as the close cause.
|
||||
sshMaintenance.onEnter(async () => {
|
||||
const all = sessionRegistry.listAll();
|
||||
for (const s of all) {
|
||||
await sessionRegistry.closeForTask(s.localTaskId, 'maintenance');
|
||||
}
|
||||
});
|
||||
|
||||
// Capture WS / status deps for startCoreServer to wire up.
|
||||
const consoleDeps: SshConsoleDeps = {
|
||||
registry: sessionRegistry,
|
||||
resolveUserFromUpgrade: async (req) => {
|
||||
if (!authenticateUpgrade) {
|
||||
// No-auth single-user mode: synthesize a stable `local` admin
|
||||
// user so the Console terminal WS attaches (admin role makes
|
||||
// the null-owner no-auth task visible in resolveTask). Mirrors
|
||||
// the Console REST routers and notifications-api.
|
||||
return { id: 'local', role: 'admin' };
|
||||
}
|
||||
const u = await authenticateUpgrade(req);
|
||||
return u ? { id: u.id, role: u.role } : null;
|
||||
},
|
||||
resolveTask: async (taskId, user) => {
|
||||
const idNum = Number(taskId);
|
||||
if (!Number.isFinite(idNum)) return null;
|
||||
const viewer: Express.User = {
|
||||
id: user.id,
|
||||
email: '',
|
||||
name: null,
|
||||
avatarUrl: null,
|
||||
role: (user.role === 'admin' ? 'admin' : 'user'),
|
||||
status: 'active',
|
||||
orgIds: resolveOrgIds(repo, user.id),
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
};
|
||||
const task = await repo.getLocalTask(idNum, { viewer });
|
||||
if (!task) return null;
|
||||
return {
|
||||
id: String(task.id),
|
||||
ownerId: task.ownerId ?? '',
|
||||
visibility: task.visibility,
|
||||
pieceName: task.pieceName,
|
||||
};
|
||||
},
|
||||
resolveSshAccess: async (user, session, task) => {
|
||||
const connection = connectionRepo.resolveConnection(session.connectionId);
|
||||
if (!connection) return false;
|
||||
const orgIds = resolveOrgIds(repo, user.id);
|
||||
const decision = accessResolver.resolveAccess({
|
||||
connection,
|
||||
userId: user.id,
|
||||
isAdmin: user.role === 'admin',
|
||||
// Use the task's actual piece name so piece-specific grants in
|
||||
// ssh_connection_grants match (applies_to_all_pieces=0 case).
|
||||
// Bug pre-fix: hardcoded '' silently failed every piece-scoped grant.
|
||||
pieceName: task.pieceName,
|
||||
orgIds,
|
||||
});
|
||||
return decision.allowed;
|
||||
},
|
||||
denyPatterns: {
|
||||
async getPatterns(connectionId: string) {
|
||||
const c = connectionRepo.resolveConnection(connectionId);
|
||||
if (!c) return { deny: [], allow: [] };
|
||||
const split = (s: string | null): string[] =>
|
||||
s ? s.split('\n').map((x) => x.trim()).filter((x) => x.length > 0) : [];
|
||||
return {
|
||||
deny: split(c.commandDenyPatterns),
|
||||
allow: split(c.commandAllowPatterns),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
sshConsole = consoleDeps;
|
||||
|
||||
// REST status endpoint: /api/local/tasks/:taskId/console/status
|
||||
app.use(
|
||||
'/api',
|
||||
createConsoleStatusRouter({
|
||||
registry: sessionRegistry,
|
||||
authActive,
|
||||
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||
resolveTask: consoleDeps.resolveTask,
|
||||
}),
|
||||
);
|
||||
|
||||
// REST user-initiated session-open endpoint:
|
||||
// POST /api/local/tasks/:taskId/console/session. Reuses the same
|
||||
// SshSubsystem + preflight the console tools use; the access gate
|
||||
// runs inside openConsoleSession against task.pieceName.
|
||||
// NOTE: no express.json() here — the router is mounted on the whole
|
||||
// /api prefix, so a mount-level parser (default limit 100kb) would
|
||||
// run for EVERY /api request and 413 large bodies before the
|
||||
// route-specific parsers (e.g. the task-attachment limit) ever ran.
|
||||
// The session route carries its own scoped json() parser.
|
||||
app.use(
|
||||
'/api',
|
||||
createConsoleSessionRouter({
|
||||
sub: sshSubsystem,
|
||||
preflight: sshPreflight,
|
||||
authActive,
|
||||
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||
resolveTask: consoleDeps.resolveTask,
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 (SSH Console): admin list + kill endpoints. The
|
||||
// `/api/admin` prefix already has `express.json()` mounted above
|
||||
// (see Admin user management API), so POST bodies parse correctly.
|
||||
app.use(
|
||||
'/api/admin',
|
||||
createConsoleAdminRouter({
|
||||
registry: sessionRegistry,
|
||||
requireAdmin: authActive ? requireAdmin : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||
}),
|
||||
);
|
||||
|
||||
logger.info('[ssh] subsystem initialised');
|
||||
} catch (e) {
|
||||
logger.error(`[ssh] init failed err=${String(e)}`);
|
||||
setSshSubsystem(null);
|
||||
__setActiveSessionLookup(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sshConsole = setupSshSubsystem(app, { repo, authActive, authenticateUpgrade });
|
||||
|
||||
// --- Local tasks API ---
|
||||
mountLocalTasksApi(app, {
|
||||
@@ -999,6 +487,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
|
||||
// --- Subtask activity API ---
|
||||
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
|
||||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||||
app.use('/api/usage', createUsageRouter(repo, { authActive }));
|
||||
// 横断(全スペース)カレンダー: GET /api/calendar/cross
|
||||
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
|
||||
@@ -1221,6 +710,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// Per-user reflection history REST API (GET/POST).
|
||||
app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive }));
|
||||
|
||||
// App-harness token-auth middleware (Task 5).
|
||||
// Must run BEFORE space-api so that req.user is populated before viewerOf() reads it.
|
||||
mountAppHarnessApi(app);
|
||||
|
||||
// スペース(個人 / 案件)CRUD REST API。
|
||||
// NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
|
||||
// upload ルートの大きい json parser より前に body を消費し、>100kb のアップロードが
|
||||
|
||||
Reference in New Issue
Block a user