SNA

Runtime setup

런타임 감지, CLI 경로 저장, 모델 등록, 설치 테스트를 다루는 패턴입니다.

Agentic product에는 보통 runtime settings flow가 필요합니다. Runtime setup은 두 가지 관심사로 나눠서 다루면 이해하기 쉽습니다.

  • Path detection은 사용자 machine에 있는 실제 CLI binary를 찾습니다.
  • Runtime registration은 product UI에 어떤 runtime과 model을 노출할지 결정합니다.

이 둘을 분리해두면 settings 화면을 다루기 쉬워집니다. Auto-detection은 좋은 기본값을 채워주고, 사용자는 registered model list를 직접 관리할 수 있습니다.

Detection과 registration을 분리하기

Runtime은 감지되었지만 disabled 상태일 수 있습니다. Model도 catalog에는 있지만 아직 product에 등록되지 않았을 수 있습니다. Product state에 두 정보를 따로 두면 설정 흐름이 깔끔해집니다.

type RuntimeId = "claude-code" | "codex" | "opencode" | "grok" | "cursor";

type RegisteredModel = {
  // Product 안에서 쓰는 안정적인 id입니다. Compound id를 쓰면 selector에서 충돌이 줄어듭니다.
  id: `${RuntimeId}/${string}`;

  // SNA startAgent/runOnce에 다시 넘기는 runtime-native model slug입니다.
  modelSlug: string;

  // Settings UI와 model picker에 보여줄 사람이 읽기 좋은 label입니다.
  label: string;

  // Inference backend family입니다. SNA catalog에서는 `provider`라고 부르지만,
  // app state에는 `modelProvider`로 저장하면 SNA runtime id와 덜 헷갈립니다.
  modelProvider: "anthropic" | "openai" | "oss" | string;
};

type RuntimeSlot = {
  // 사용자는 감지된 runtime을 끌 수 있지만, config는 남겨둘 수 있습니다.
  enabled: boolean;

  config: {
    // SNA runtime은 agentic CLI입니다. Runtime path override에는 cliPath를 씁니다.
    cliPath?: string;

    // OpenRouter나 local OpenAI-compatible API는 Codex의 native
    // model_providers.* 설정을 providerOptions.config로 넘겨 구성합니다.
    codexConfig?: Record<string, string>;
  };

  // 이 curated list는 app이 소유합니다. SNA model catalog에서 seed할 수 있습니다.
  models: RegisteredModel[];
};

CLI path는 예측 가능한 순서로 resolve하기

CLI path는 다음 순서로 resolve하는 패턴이 다루기 쉽습니다.

  1. Settings에서 저장한 user override.
  2. 이전 detection이 남긴 cache entry.
  3. 새 filesystem scan.

이 우선순서를 쓰면 사용자가 직접 고친 값은 안정적으로 유지되고, 첫 실행 사용자에게도 자동 기본값을 줄 수 있습니다.

const RUNTIME_ENV: Record<RuntimeId, string> = {
  "claude-code": "SNA_CLAUDE_COMMAND",
  codex: "SNA_CODEX_COMMAND",
  opencode: "SNA_OPENCODE_COMMAND",
  grok: "SNA_GROK_COMMAND",
  cursor: "SNA_CURSOR_COMMAND",
};

async function resolveRuntimePath(runtimeId: RuntimeId): Promise<string | null> {
  // 1. 사용자가 직접 입력한 값은 auto-detected path보다 우선하도록 둡니다.
  const override = await settingsDb.get(`runtime:${runtimeId}:path`);
  if (override) return override;

  // 2. Cache를 읽으면 app boot 때마다 shell probe를 하지 않아도 됩니다.
  const cached = await pathCache.read(runtimeId);
  if (cached) return cached;

  // 3. Fresh detection에서는 static install location과 login-shell PATH를 함께 볼 수 있습니다.
  const detected = await detectCliOnDisk(runtimeId);
  if (detected) await pathCache.write(runtimeId, detected);
  return detected;
}

async function setRuntimePath(runtimeId: RuntimeId, cliPath: string) {
  // 저장하기 전에 validate하면 typo 때문에 다음 agent launch가 깨지는 일을 줄일 수 있습니다.
  await validateExecutable(cliPath);

  // 사용자가 고른 값은 cache가 아니라 override로 저장합니다.
  await settingsDb.set(`runtime:${runtimeId}:path`, cliPath);

  // SNA를 같은 process에 embed했다면, process.env mirror로 다음 runtime spawn이 바로 같은 path를 보게 됩니다.
  process.env[RUNTIME_ENV[runtimeId]] = cliPath;
}

SNA launcher에 runtime path 넘기기

Electron에서 SNA를 띄울 때 resolve된 runtime path를 startup option으로 넘겨두는 패턴이 가장 단순합니다. SNA는 이 값을 runtime resolver가 읽는 environment variable로 매핑합니다.

import { startSnaServerInProcess } from "@sna-sdk/core/electron";

const runtimePaths = {
  // Settings layer가 resolve한 path를 사용합니다. 찾지 못한 값은 생략해도 됩니다.
  claudeCode: (await resolveRuntimePath("claude-code")) ?? undefined,
  codex: (await resolveRuntimePath("codex")) ?? undefined,
  opencode: (await resolveRuntimePath("opencode")) ?? undefined,
  grok: (await resolveRuntimePath("grok")) ?? undefined,
  cursor: (await resolveRuntimePath("cursor")) ?? undefined,
};

const sna = await startSnaServerInProcess({
  appId: "my-app",
  port: 3099,
  dbPath: appPaths.snaDb,
  allowedOrigins: ["http://localhost:5173"],

  // runtimePaths는 SNA_CLAUDE_COMMAND, SNA_CODEX_COMMAND 같은 env로 변환됩니다.
  runtimePaths,

  // env는 runtimePaths 뒤에 merge되므로 runtime command 변수나 기타
  // runtime-specific escape hatch로 쓸 수 있습니다. Auth/identity는 launcher option이 최종값입니다.
  env: {
    SNA_MAX_SESSIONS: "20",
  },
});

sna.connectionSnaClientSnaProvider에 넘깁니다. Token은 이 server process를 위해 생성되고 connection object와 함께 묶여 있으므로 긴 수명의 사용자 설정으로 저장하지 않습니다. HTTP와 SSE 호출은 이 token을 Authorization: Bearer <authToken>으로 보내고, browser WebSocket 연결은 upgrade에 /ws?token=<authToken>을 사용합니다.

Forked standalone SNA server에서는 path change를 launch 전에 반영하거나 SNA를 재시작하는 구성이 보통 다루기 쉽습니다. In-process SNA에서는 runtime adapter가 같은 process 안에서 path를 resolve하므로, host가 process.env.SNA_*_COMMAND를 갱신하면 이후 spawn에 반영될 수 있습니다.

runOnce로 실제 runtime 테스트하기

Settings screen에서는 파일 존재 여부만 확인하기보다 실제 CLI path와 agent bootstrap을 같이 테스트하는 쪽이 실제 동작에 가깝습니다. 아래 health check는 path가 이미 host settings에 저장되고 SNA launch environment에 mirror된 상태를 전제로 합니다.

const HEALTH_MODELS: Partial<Record<RuntimeId, string>> = {
  "claude-code": "haiku",
  codex: "gpt-5.4-mini",
  grok: "grok-build",
  cursor: "auto",
};

async function testRuntime(client: SnaClient, runtimeId: RuntimeId) {
  const result = await client.agent.runOnce({
    provider: runtimeId,
    model: HEALTH_MODELS[runtimeId],

    // Setup probe가 사용자 project directory를 오염시키지 않도록 cwd를 분리합니다.
    cwd: `/tmp/my-app-runtime-tests/${runtimeId}`,

    // Health check는 sentinel만 반환하고 끝나는 단순한 call로 두면 결과 판정이 쉽습니다.
    permissionMode: "bypassPermissions",
    systemPrompt: "Reply exactly APP_RUNTIME_OK and do not use tools.",
    message: "Reply exactly: APP_RUNTIME_OK",

    // First-run auth나 CLI bootstrap은 일반 turn보다 느릴 수 있습니다.
    timeout: 120_000,
  });

  const text = result.result.trim();
  return {
    ok: text.includes("APP_RUNTIME_OK"),
    response: text.slice(0, 500),
  };
}

이 방식은 단순 path check보다 더 강한 신뢰를 줍니다. SNA가 runtime을 spawn하고, prompt를 넘기고, output을 받고, one-shot session을 정상 완료할 수 있는지 확인하기 때문입니다.

Model은 list한 뒤 등록하기

agent.listModels(runtime, config?)는 catalog call입니다. config.cliPath는 Codex나 OpenCode처럼 CLI를 inspect하는 runtime의 model listing에만 영향을 줍니다. 일반 agent spawn 경로를 바꾸려면 launcher runtimePaths 또는 SNA_*_COMMAND 쪽을 써야 합니다.

async function refreshCatalog(client: SnaClient, runtimeId: RuntimeId, cliPath?: string) {
  const catalog = await client.agent.listModels(runtimeId, {
    // Settings에서 CLI path를 막 수정한 직후 catalog probe에 유용합니다.
    cliPath,

    // 사용자가 refresh button을 누른 경우 runtime-side cache를 우회합니다.
    refresh: true,
  });

  if (catalog.error) {
    // UI는 fallback model을 보여줄 수 있지만, partial warning을 같이 표시하면 디버깅이 쉬워집니다.
    toast.warning(`Model catalog was partial: ${catalog.error}`);
  }

  return catalog.models;
}

async function registerModel(runtimeId: RuntimeId, model: RuntimeModelInfo) {
  // Registration은 product decision입니다. SNA model id는 runtime-native slug로 저장하고,
  // app selector에서는 app-owned compound id를 씁니다.
  await settingsDb.addModel(runtimeId, {
    id: `${runtimeId}/${model.id}`,
    modelSlug: model.id,
    label: model.label,
    modelProvider: model.provider,
  });
}

이 two-step flow를 쓰면 사용자가 catalog를 보고 원하는 entry만 추가할 수 있습니다. Runtime을 나중에 disabled로 바꿔도 model list 자체는 유지할 수 있습니다.

React settings flow

Settings UI는 host API 위의 얇은 shell로 두면 다루기 쉽습니다. Filesystem validation, env mirroring, SNA health check는 host process가 맡고, renderer는 사용자 intent를 조율합니다.

function RuntimeSetupPanel({ runtimeId }: { runtimeId: RuntimeId }) {
  const [cliPath, setCliPath] = useState("");
  const [models, setModels] = useState<RuntimeModelInfo[]>([]);
  const [testing, setTesting] = useState(false);

  async function redetect() {
    // Host code는 login-shell lookup, static candidates, cache write를 사용할 수 있습니다.
    const path = await window.appRuntime.redetect(runtimeId);
    if (path) setCliPath(path);
  }

  async function saveAndTest() {
    setTesting(true);
    try {
      // 먼저 persist해야 SNA runtime adapter가 runOnce 중 이 path를 resolve할 수 있습니다.
      await window.appRuntime.setPath(runtimeId, cliPath);

      // 이것은 fake ping이 아니라 SNA를 통한 실제 runtime invocation입니다.
      const result = await window.appRuntime.test(runtimeId);
      if (!result.ok) throw new Error(result.response || "Runtime test failed");
    } finally {
      setTesting(false);
    }
  }

  async function refreshModels() {
    // CLI path는 catalog probing에 도움을 줍니다. 일반 agent spawn은 setPath/startup runtimePaths가 저장한 path를 씁니다.
    const catalog = await window.appRuntime.listModels(runtimeId, { cliPath, refresh: true });
    setModels(catalog.models);
  }

  async function addModel(model: RuntimeModelInfo) {
    // Product picker에 실제로 보여줄 model만 저장합니다.
    await window.appRuntime.registerModel(runtimeId, model);
  }

  return (
    <section>
      <input value={cliPath} onChange={(event) => setCliPath(event.target.value)} />
      <button onClick={redetect}>Redetect</button>
      <button onClick={saveAndTest} disabled={testing}>Test runtime</button>
      <button onClick={refreshModels}>Refresh models</button>
      {models.map((model) => (
        <button key={model.id} onClick={() => addModel(model)}>
          Add {model.label}
        </button>
      ))}
    </section>
  );
}

여기서 중요한 product boundary는 renderer가 SNA runtime environment를 직접 추측해서 바꾸지 않는 점입니다. Renderer는 host에게 save, test, list, register를 요청하고, host가 실제 environment mutation을 책임지는 구조가 안정적입니다.

목차