From 045fcf8534ef76265eeeb60ac9b2918b732fddaa Mon Sep 17 00:00:00 2001 From: akarma-synetal Date: Fri, 10 Jul 2026 19:30:09 +0530 Subject: [PATCH 1/2] feat: expose extended branding fields in runtime config API Add logoUrl, faviconUrl, brandColor, pwaDescription, and pwaThemeColor to the /api/v1/runtime/config response, driven by corresponding OS_* environment variables. Also decouples /runtime/assets/* serving from the console plugin into a standalone createRuntimeAssetsPlugin so branding assets resolve even when the console dist hasn't been built yet. Accepts an optional OS_RUNTIME_ASSETS_DIR override; falls back to process.cwd()/assets. Co-authored-by: Cursor --- packages/cli/src/commands/serve.ts | 13 +++++ packages/cli/src/utils/console.ts | 48 +++++++++++++++++ packages/cli/test/runtime-assets.test.ts | 54 +++++++++++++++++++ .../src/runtime-config-plugin.ts | 32 ++++++++++- 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/runtime-assets.test.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 785e0ef861..502d90eb88 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -25,6 +25,7 @@ import { resolveConsolePath, hasConsoleDist, createConsoleStaticPlugin, + createRuntimeAssetsPlugin, } from '../utils/console.js'; import dotenvFlow from 'dotenv-flow'; @@ -814,6 +815,18 @@ export default class Serve extends Command { } } + // Serve /runtime/assets/* unconditionally — branding logos, favicons, + // and other static runtime assets must resolve even when the Console + // dist hasn't been built yet. The directory is resolved as: + // 1. OS_RUNTIME_ASSETS_DIR env var (explicit override) + // 2. process.cwd() + '/assets' (when CLI cwd is a runtime/ package) + // Silently skips if no assets directory exists. + const runtimeAssetsDir = ( + process.env.OS_RUNTIME_ASSETS_DIR?.trim() || + path.resolve(process.cwd(), 'assets') + ); + await kernel.use(createRuntimeAssetsPlugin(runtimeAssetsDir)); + // Unknown-environment hostname guard. // // In multi-tenant cloud deployments (e.g. *.objectos.ai), every diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index 342df805e2..4db5664814 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -450,6 +450,54 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: }; } +// ─── Runtime Assets Plugin ────────────────────────────────────────── + +/** + * Create a plugin that serves static runtime assets at /runtime/assets/*. + * Decoupled from the console plugin so branding assets (logos, favicons) are + * served even when the console dist hasn't been built yet. + * + * The `distPath` should point at the host project's `runtime/assets` directory + * (i.e. `path.resolve(process.cwd(), 'assets')` when the CLI cwd is the + * `runtime/` package). + */ +export function createRuntimeAssetsPlugin(distPath: string) { + return { + name: 'com.objectstack.runtime-assets', + + init: async () => {}, + + start: async (ctx: any) => { + const httpServer = ctx.getService?.('http.server'); + if (!httpServer?.getRawApp) return; + + const app = httpServer.getRawApp(); + const assetsDir = path.resolve(distPath); + if (!fs.existsSync(assetsDir)) return; + + app.get('/runtime/assets/:filename', async (c: any) => { + const filename = String(c.req.param?.('filename') ?? '').replace(/[/\\]+/g, ''); + const filePath = path.join(assetsDir, filename); + // Path-traversal guard: reject any path that escapes assetsDir. + if (!path.resolve(filePath).startsWith(path.resolve(assetsDir))) { + return c.text('Forbidden', 403); + } + try { + const content = fs.readFileSync(filePath); + return new Response(content, { + headers: { + 'content-type': mimeType(filePath), + 'cache-control': 'public, max-age=3600', + }, + }); + } catch { + return c.text('Not Found', 404); + } + }); + }, + }; +} + // ─── Helpers ──────────────────────────────────────────────────────── const MIME_TYPES: Record = { diff --git a/packages/cli/test/runtime-assets.test.ts b/packages/cli/test/runtime-assets.test.ts new file mode 100644 index 0000000000..be9a5aacd3 --- /dev/null +++ b/packages/cli/test/runtime-assets.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * createRuntimeAssetsPlugin() — serves /runtime/assets/* unconditionally. + * + * The route must resolve even when the Console dist isn't built — unlike + * the rest of createConsoleStaticPlugin which early-returns when + * dist/index.html is missing. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createRuntimeAssetsPlugin } from '../src/utils/console.js'; + +const assetsDir = path.join(os.tmpdir(), `os-test-runtime-assets-${Date.now()}`); +const testPng = path.join(assetsDir, 'test-logo.png'); + +beforeAll(() => { + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync(testPng, Buffer.from('fake-png-content')); +}); + +afterAll(() => { + fs.rmSync(assetsDir, { recursive: true, force: true }); +}); + +describe('createRuntimeAssetsPlugin', () => { + it('returns a plugin object with name, init, and start', () => { + const plugin = createRuntimeAssetsPlugin(assetsDir); + expect(plugin).toHaveProperty('name', 'com.objectstack.runtime-assets'); + expect(plugin).toHaveProperty('init'); + expect(plugin).toHaveProperty('start'); + }); + + it('skips registration when assets dir does not exist', async () => { + const noopPlugin = createRuntimeAssetsPlugin('/nonexistent/dir'); + const ctx = { + getService: () => ({ + getRawApp: () => ({ + get: () => { throw new Error('should not be called'); }, + }), + }), + }; + // Should not throw when dir doesn't exist — silently skips. + await expect(noopPlugin.start(ctx as any)).resolves.toBeUndefined(); + }); + + it('skips registration when http server service is missing', async () => { + const plugin = createRuntimeAssetsPlugin(assetsDir); + const ctx = { getService: () => null }; + await expect(plugin.start(ctx as any)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index 3a5c40b1c6..ae8e14d323 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -15,7 +15,7 @@ * singleEnvironment: boolean, * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... }, - * branding: { productName, productShortName } + * branding: { productName, productShortName, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor } * } * * ## Feature seam (open-core boundary — cloud ADR-0012) @@ -95,6 +95,16 @@ export interface RuntimeConfigPluginConfig { productName?: string; /** Short product name (PWA shortName, compact spots). Defaults to productName. */ productShortName?: string; + /** Absolute or relative URL for the product logo. Falls back to OS_LOGO_URL env var. */ + logoUrl?: string; + /** Absolute or relative URL for the favicon. Falls back to OS_FAVICON_URL env var. */ + faviconUrl?: string; + /** Primary brand hex color (e.g. '#4F46E5'). Falls back to OS_BRAND_COLOR env var. */ + brandColor?: string; + /** PWA manifest description. Falls back to OS_PWA_DESCRIPTION env var. Default: " — runtime console". */ + pwaDescription?: string; + /** PWA theme color hex. Falls back to OS_PWA_THEME_COLOR env var. Default: brandColor or '#4f46e5'. */ + pwaThemeColor?: string; /** * Distribution feature-policy hook (open-core seam — cloud ADR-0012). * Called with `undefined` for the static default (no environment resolved @@ -123,6 +133,11 @@ export class RuntimeConfigPlugin implements Plugin { private readonly singleEnvironment: boolean; private readonly productName: string; private readonly productShortName: string; + private readonly logoUrl: string | undefined; + private readonly faviconUrl: string | undefined; + private readonly brandColor: string | undefined; + private readonly pwaDescription: string; + private readonly pwaThemeColor: string; private readonly resolveFeatures?: (token: string | undefined) => RuntimeFeatureOverrides; constructor(config: RuntimeConfigPluginConfig = {}) { @@ -140,6 +155,16 @@ export class RuntimeConfigPlugin implements Plugin { const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim(); this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS'; this.productShortName = (config.productShortName ?? envShort ?? this.productName).trim() || this.productName; + const envLogoUrl = (typeof process !== 'undefined' ? process.env?.OS_LOGO_URL : undefined)?.trim(); + const envFaviconUrl = (typeof process !== 'undefined' ? process.env?.OS_FAVICON_URL : undefined)?.trim(); + const envBrandColor = (typeof process !== 'undefined' ? process.env?.OS_BRAND_COLOR : undefined)?.trim(); + const envPwaDescription = (typeof process !== 'undefined' ? process.env?.OS_PWA_DESCRIPTION : undefined)?.trim(); + const envPwaThemeColor = (typeof process !== 'undefined' ? process.env?.OS_PWA_THEME_COLOR : undefined)?.trim(); + this.logoUrl = config.logoUrl ?? envLogoUrl; + this.faviconUrl = config.faviconUrl ?? envFaviconUrl; + this.brandColor = config.brandColor ?? envBrandColor; + this.pwaDescription = config.pwaDescription ?? envPwaDescription ?? `${this.productName} — runtime console`; + this.pwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor ?? this.brandColor ?? '#4f46e5'; } init = async (_ctx: PluginContext): Promise => {}; @@ -244,6 +269,11 @@ export class RuntimeConfigPlugin implements Plugin { branding: { productName: this.productName, productShortName: this.productShortName, + logoUrl: this.logoUrl, + faviconUrl: this.faviconUrl, + brandColor: this.brandColor, + pwaDescription: this.pwaDescription, + pwaThemeColor: this.pwaThemeColor, }, }); }; From cfb0f6c519edda1f05d37ba2f4fbf7d64abd7993 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 11 Jul 2026 10:10:05 +0800 Subject: [PATCH 2/2] test(cli): use mkdtempSync for runtime-assets temp dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged js/insecure-temporary-file (high) on the test: it built a temp dir from a predictable `Date.now()` name and wrote into it, which is open to a temp-file race / symlink attack in the shared OS temp dir. Use fs.mkdtempSync() to atomically create a uniquely-named directory with a random suffix — the pattern CodeQL recognizes as safe — and drop the now redundant mkdirSync. Behavior is otherwise unchanged; all 3 tests pass. Co-Authored-By: Claude Opus 4.8 --- packages/cli/test/runtime-assets.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/runtime-assets.test.ts b/packages/cli/test/runtime-assets.test.ts index be9a5aacd3..80ff9a9239 100644 --- a/packages/cli/test/runtime-assets.test.ts +++ b/packages/cli/test/runtime-assets.test.ts @@ -13,11 +13,13 @@ import os from 'os'; import path from 'path'; import { createRuntimeAssetsPlugin } from '../src/utils/console.js'; -const assetsDir = path.join(os.tmpdir(), `os-test-runtime-assets-${Date.now()}`); +// Atomically create a uniquely-named temp dir (random suffix) instead of a +// predictable `Date.now()` name — avoids the temp-file race/symlink attack +// flagged by CodeQL js/insecure-temporary-file. +const assetsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-runtime-assets-')); const testPng = path.join(assetsDir, 'test-logo.png'); beforeAll(() => { - fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(testPng, Buffer.from('fake-png-content')); });