agentlas 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +112 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +136 -12
  10. package/engine/agentlas-input.cjs +118 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +239 -70
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +352 -23
  18. package/engine/agentlas.cjs +2819 -351
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/route-regression.cjs +121 -0
  33. package/test/run-api-regression.cjs +322 -0
  34. package/test/runtime-env-protection.cjs +45 -0
  35. package/test/semver-precedence.cjs +39 -0
  36. package/test/smoke.sh +20 -0
  37. package/test/sqlite-driver-probe.cjs +22 -0
  38. package/test/terminal-ui-regression.cjs +472 -0
  39. package/test/timeout-regression.cjs +218 -0
  40. package/test/tool-workspace-boundary.cjs +165 -0
  41. package/test/update-safety.cjs +376 -0
@@ -28,6 +28,7 @@ const os = require("node:os");
28
28
  const fs = require("node:fs");
29
29
  const { spawn } = require("node:child_process");
30
30
  const crypto = require("node:crypto");
31
+ const { compareSemVer, normalizeSemVer, parseSemVer } = require("./semver.cjs");
31
32
 
32
33
  // ── 앱과 동일한 userData 경로 (electron app.getPath('userData')와 일치) ──
33
34
  function userDataDir() {
@@ -138,6 +139,8 @@ function loadMultimodalCatalog() {
138
139
  } catch {
139
140
  const providers = [
140
141
  { id: "codex-cli-image", modality: "image", label: "Codex CLI image", labelKo: "Codex CLI 이미지", envKeys: [], billing: "subscription", defaultModel: "runtime-default" },
142
+ { id: "grok-cli-image", modality: "image", label: "Grok CLI image (Imagine)", labelKo: "Grok CLI 이미지 (Imagine)", envKeys: [], billing: "subscription", defaultModel: "runtime-default" },
143
+ { id: "grok-cli-video", modality: "video", label: "Grok CLI video (Imagine)", labelKo: "Grok CLI 영상 (Imagine)", envKeys: [], billing: "subscription", defaultModel: "runtime-default" },
141
144
  { id: "openai-image", modality: "image", label: "OpenAI Images API", labelKo: "OpenAI 이미지 API", envKeys: ["OPENAI_API_KEY"], billing: "paid-api", defaultModel: "gpt-image-2" },
142
145
  { id: "google-image", modality: "image", label: "Google Gemini Image", labelKo: "Google Gemini 이미지", envKeys: ["GOOGLE_API_KEY"], billing: "paid-api", defaultModel: "gemini-image" },
143
146
  { id: "runway-video", modality: "video", label: "Runway API", labelKo: "Runway API", envKeys: ["RUNWAY_API_KEY"], billing: "paid-api", defaultModel: "gen4.5" },
@@ -297,7 +300,9 @@ function resolveMetaBuilder(db) {
297
300
  }
298
301
  return null;
299
302
  }
300
- const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "please", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
303
+ // "ai"/"llm" 같은 초범용 토큰은 모든 에이전트 프롬프트에 나오므로 판별력이 0이다
304
+ // 이런 단어 하나로 전문 에이전트가 선택되던 오라우팅(예: 일반 맥 질문 → Pitch Deck Architect)을 막는다.
305
+ const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "please", "ai", "llm", "인공지능", "에이아이", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
301
306
  const ROUTE_HINTS = [
302
307
  {
303
308
  slug: "agentlas-app-builder",
@@ -360,6 +365,10 @@ function routeTokenize(value) {
360
365
  const expanded = matches.flatMap((term) => term.split("-").filter(Boolean).concat(term));
361
366
  return [...new Set(expanded.filter((term) => term.length >= 2 && !ROUTE_STOP_WORDS.has(term)))];
362
367
  }
368
+ // 정체성 존(slug/이름/태그라인) — 여기 적중은 강한 라우팅 신호. system_prompt 본문 적중은 약한 신호.
369
+ function routeIdentityHaystack(agent) {
370
+ return routeNormalize([agent.slug, agent.name, agent.name_en, agent.tagline, agent.tagline_en].join("\n"));
371
+ }
363
372
  function routeHaystack(agent) {
364
373
  return routeNormalize([
365
374
  agent.slug,
@@ -445,6 +454,7 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
445
454
  terms: [],
446
455
  };
447
456
  }
457
+ const identityHay = routeIdentityHaystack(agent);
448
458
  const haystack = routeHaystack(agent);
449
459
  let score = 0;
450
460
  const terms = [];
@@ -457,7 +467,10 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
457
467
  }
458
468
  }
459
469
  for (const term of promptTerms) {
460
- if (haystack.includes(term)) {
470
+ if (identityHay.includes(term)) {
471
+ score += 6; // 이름/태그라인 적중 = 그 에이전트의 정체성 자체를 부른 것
472
+ terms.push(term);
473
+ } else if (haystack.includes(term)) {
461
474
  score += term.length >= 5 ? 3 : 2;
462
475
  terms.push(term);
463
476
  }
@@ -475,6 +488,30 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
475
488
  : "no specialist matched clearly, so the default project coordinator is safest");
476
489
  return { agent, score, reason, terms: unique };
477
490
  }
491
+ // 라우팅 확신 임계값. 이름/태그라인 적중(+6), 라우트 힌트(+12↑), 이름 포함(+20)만 전문 라우트로 인정하고,
492
+ // system_prompt 본문의 약한 단어 적중(+2~3) 한두 개로는 에이전트를 절대 선택하지 않는다.
493
+ // 임계값 미만이면 "직답"(에이전트·능력 라우팅 없음) — 일반 질문이 Pitch Deck Architect 같은
494
+ // 무관 페르소나 + gemini 이미지 런타임으로 끌려가던 사고의 근본 수리.
495
+ const MIN_ROUTE_SCORE = 6;
496
+ function directRouteChoice(lang) {
497
+ const resolvedLang = lang || prefsLang();
498
+ return {
499
+ direct: true,
500
+ agent: null,
501
+ score: 0,
502
+ terms: [],
503
+ reason: resolvedLang === "ko"
504
+ ? "특정 전문 에이전트가 필요 없는 일반 요청입니다"
505
+ : "this is a general request that needs no specialist agent",
506
+ };
507
+ }
508
+ // 직답 모드 시스템 프롬프트 — 페르소나·라우팅 오염 없이 현재 런타임 그대로 답한다.
509
+ function directSystemPrompt(lang) {
510
+ const resolvedLang = lang || prefsLang();
511
+ return resolvedLang === "ko"
512
+ ? "당신은 Agentlas 터미널의 기본 어시스턴트입니다. 특별한 페르소나 없이 사용자의 요청에 정확하고 간결하게 바로 답하세요. 에이전트 라우팅이나 이미지 생성 능력을 스스로 언급하지 마세요."
513
+ : "You are the Agentlas terminal's default assistant. Answer the user's request directly and concisely, with no special persona. Do not bring up agent routing or image-generation capabilities on your own.";
514
+ }
478
515
  function autoRouteAgent(db, prompt, lang) {
479
516
  const resolvedLang = lang || prefsLang();
480
517
  // 명확한 "에이전트/팀/회사 만들기" 의도 → 메타-빌더로 직행 (약한 키워드 점수에 밀리지 않게).
@@ -493,28 +530,40 @@ function autoRouteAgent(db, prompt, lang) {
493
530
  }
494
531
  }
495
532
  const agents = listRoutableAgents(db).filter((agent) => !NON_GENERIC_ROUTE_SLUGS.has(agent.slug));
496
- if (!agents.length) return null;
497
- const terms = routeTokenize(prompt);
533
+ if (!agents.length) return directRouteChoice(resolvedLang);
534
+ let terms = routeTokenize(prompt);
535
+ // IDF 근사 — 설치 에이전트 절반 이상의 haystack에 나오는 단어("ai","도구" 등)는 판별력이 없어 제외.
536
+ if (agents.length >= 3) {
537
+ const hays = agents.map((agent) => routeHaystack(agent));
538
+ terms = terms.filter((term) => hays.filter((hay) => hay.includes(term)).length * 2 <= agents.length);
539
+ }
498
540
  const ranked = agents.map((agent) => scoreRouteAgent(prompt, terms, agent, resolvedLang)).sort((a, b) => b.score - a.score);
499
- if (ranked[0] && ranked[0].score > 0) return ranked[0];
500
- const fallback = agents.find((agent) => agent.slug === "agentlas-pm-soul") || agents[0];
501
- return {
502
- agent: fallback,
503
- score: 0,
504
- reason: resolvedLang === "ko"
505
- ? "명확한 전문 에이전트가 없어 기본 프로젝트 조율 경로를 선택했습니다"
506
- : "no specialist matched clearly, so Agentlas chose the default coordination route",
507
- terms: [],
508
- };
541
+ if (ranked[0] && ranked[0].score >= MIN_ROUTE_SCORE) return ranked[0];
542
+ return directRouteChoice(resolvedLang);
509
543
  }
510
544
  function autoRouteNote(choice, lang) {
511
- const name = (lang || prefsLang()) === "ko" ? choice.agent.name : choice.agent.name_en || choice.agent.name;
512
- return (lang || prefsLang()) === "ko"
545
+ const resolvedLang = lang || prefsLang();
546
+ if (choice.direct) {
547
+ return resolvedLang === "ko"
548
+ ? `사용 에이전트: 없음 — 바로 답합니다. 이유: ${choice.reason}.`
549
+ : `Selected agent: none — answering directly. Reason: ${choice.reason}.`;
550
+ }
551
+ const name = resolvedLang === "ko" ? choice.agent.name : choice.agent.name_en || choice.agent.name;
552
+ return resolvedLang === "ko"
513
553
  ? `사용 에이전트: ${name}. 이유: ${choice.reason}.`
514
554
  : `Selected agent: ${name}. Reason: ${choice.reason}.`;
515
555
  }
516
556
  function autoRoutePreamble(choice, lang) {
517
557
  const resolvedLang = lang || prefsLang();
558
+ if (choice.direct) {
559
+ return [
560
+ "## Agentlas direct answer",
561
+ "",
562
+ resolvedLang === "ko"
563
+ ? "이 요청은 전문 에이전트 라우팅 없이 처리합니다. 라우팅이나 에이전트를 언급하지 말고 사용자 요청에 바로 답하세요."
564
+ : "This request is handled without specialist routing. Answer the user directly, without mentioning routing or agents.",
565
+ ].join("\n");
566
+ }
518
567
  const appBuilderNeedsConsent = choice.agent && choice.agent.slug === "agentlas-app-builder";
519
568
  const instruction = appBuilderNeedsConsent
520
569
  ? resolvedLang === "ko"
@@ -544,8 +593,13 @@ function agentFolder(agent) {
544
593
  const routes = routesMap();
545
594
  const r = routes[agent.id];
546
595
  if (r && r.path) return r.path; // 로컬 임포트는 원본 폴더
596
+ const cloudRoot = path.join(userDataDir(), "cloud-agent-installs", cloudSlug(agent.slug));
597
+ if (exists(path.join(cloudRoot, CLOUD_RESTORE_MARKER_PATH))) return cloudRoot;
547
598
  return path.join(userDataDir(), "agents", agent.slug);
548
599
  }
600
+ function agentSystemPromptCli(agent) {
601
+ return agent && agent.system_prompt ? agent.system_prompt : `You are ${agent?.name || "an Agentlas agent"}.`;
602
+ }
549
603
 
550
604
  // ── 로컬 폴더 임포트 (앱의 electron/agents/import-local.ts 와 동일 규칙) ──
551
605
  // 터미널에서 "폴더 드래그" = `agentlas import <path>`. 앱과 같은 DB/라우트를 공유한다.
@@ -767,10 +821,15 @@ function cmdImport(db, absPath) {
767
821
  const CLOUD_MAX_TOTAL_BYTES = 3 * 1024 * 1024;
768
822
  const CLOUD_MAX_FILE_BYTES = 512 * 1024;
769
823
  const CLOUD_MAX_FILES = 400;
770
- const CLOUD_TEXT_EXTS = new Set([".cjs", ".css", ".csv", ".html", ".js", ".json", ".jsonl", ".md", ".mjs", ".py", ".sh", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml"]);
824
+ const CLOUD_PACKAGE_HASH_V1 = "path-sha256-v1";
825
+ const CLOUD_PACKAGE_HASH_V2 = "path-sha256-executable-v2";
826
+ const CLOUD_RESTORE_MARKER_PATH = ".agentlas-cloud-package.json";
827
+ const CLOUD_ASSET_STATE_FILE = "cloud-asset-state.v1.json";
828
+ const CLOUD_ASSET_SCOPES = new Set(["owner-private", "hub-public"]);
829
+ const CLOUD_TEXT_EXTS = new Set([".cfg", ".cjs", ".conf", ".config", ".css", ".csv", ".env", ".html", ".ini", ".js", ".json", ".jsonl", ".md", ".mjs", ".properties", ".ps1", ".psd1", ".psm1", ".py", ".sh", ".toml", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml"]);
771
830
  const CLOUD_AGENT_FILES = new Set(["AGENT.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md", "agent.md", "manifest.md", "system-prompt.md"]);
772
831
  const CLOUD_SKIP_DIRS = new Set([".git", ".next", ".studio-runtime", ".turbo", "build", "coverage", "dist", "node_modules", "out", "release"]);
773
- const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
832
+ const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /^cloud-asset-state\.v1\.json$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
774
833
  const CLOUD_ROUTING_CARD_PATH = ".agentlas/routing-card.json";
775
834
  const CLOUD_ROUTING_CARD_CAPABILITY_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/;
776
835
  const CLOUD_ROUTING_CARD_STATUSES = new Set(["draft", "searchable", "candidate", "routing_ready", "trusted"]);
@@ -778,11 +837,149 @@ const CLOUD_SECRET_RE = [
778
837
  ["private-key", /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i, "private key material"],
779
838
  ["openai-key", /\bsk-[A-Za-z0-9_-]{20,}\b/, "OpenAI-style API key"],
780
839
  ["github-token", /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, "GitHub token"],
840
+ ["gitlab-token", /\bglpat-[A-Za-z0-9_-]{20,}\b/, "GitLab token"],
841
+ ["google-api-key", /\bAIza[0-9A-Za-z_-]{35}\b/, "Google API key"],
842
+ ["npm-token", /\bnpm_[A-Za-z0-9]{30,}\b/, "npm access token"],
843
+ ["stripe-secret", /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/, "Stripe secret key"],
781
844
  ["slack-token", /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/, "Slack token"],
782
845
  ["aws-key", /\bAKIA[0-9A-Z]{16}\b/, "AWS access key"],
783
846
  ["generic-secret", /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"][^'"]{8,}['"]/i, "hard-coded credential"],
784
847
  ];
785
848
 
849
+ const HUB_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
850
+ const HUB_TIMEOUT_DEFAULTS = Object.freeze({ connectMs: 15_000, idleMs: 30_000, totalMs: 180_000 });
851
+
852
+ function finiteTimeoutMs(value, fallback, min, max) {
853
+ const parsed = Number(value);
854
+ if (!Number.isFinite(parsed)) return fallback;
855
+ return Math.min(max, Math.max(min, Math.trunc(parsed)));
856
+ }
857
+
858
+ function hubTimeoutConfig(env = process.env) {
859
+ const totalMs = finiteTimeoutMs(env.AGENTLAS_HUB_TOTAL_TIMEOUT_MS, HUB_TIMEOUT_DEFAULTS.totalMs, 5_000, 900_000);
860
+ return {
861
+ connectMs: Math.min(totalMs, finiteTimeoutMs(env.AGENTLAS_HUB_CONNECT_TIMEOUT_MS, HUB_TIMEOUT_DEFAULTS.connectMs, 1_000, 120_000)),
862
+ idleMs: Math.min(totalMs, finiteTimeoutMs(env.AGENTLAS_HUB_IDLE_TIMEOUT_MS, HUB_TIMEOUT_DEFAULTS.idleMs, 1_000, 300_000)),
863
+ totalMs,
864
+ };
865
+ }
866
+
867
+ function directHubTimeoutConfig(value = {}) {
868
+ const totalMs = finiteTimeoutMs(value.totalMs, HUB_TIMEOUT_DEFAULTS.totalMs, 10, 900_000);
869
+ return {
870
+ connectMs: Math.min(totalMs, finiteTimeoutMs(value.connectMs, HUB_TIMEOUT_DEFAULTS.connectMs, 10, 120_000)),
871
+ idleMs: Math.min(totalMs, finiteTimeoutMs(value.idleMs, HUB_TIMEOUT_DEFAULTS.idleMs, 10, 300_000)),
872
+ totalMs,
873
+ };
874
+ }
875
+
876
+ function hubTimeoutError(kind, ms) {
877
+ const message = kind === "connect"
878
+ ? `Hub 연결 제한 시간(${ms}ms)을 초과했습니다.`
879
+ : kind === "idle"
880
+ ? `Hub 응답이 ${ms}ms 동안 멈췄습니다.`
881
+ : `Hub 요청 전체 제한 시간(${ms}ms)을 초과했습니다.`;
882
+ const error = new Error(message);
883
+ error.code = `AGENTLAS_HUB_${kind.toUpperCase()}_TIMEOUT`;
884
+ return error;
885
+ }
886
+
887
+ /** Hub/Cloud fetch + body reader. Headers 전 connect, chunk 사이 idle, 전 구간 total timeout. */
888
+ async function fetchHubCli(url, init = {}, options = {}) {
889
+ const fetchImpl = options.fetch || globalThis.fetch;
890
+ if (typeof fetchImpl !== "function") throw new Error("이 런타임에 fetch가 없습니다.");
891
+ const timeout = options.timeoutConfig ? directHubTimeoutConfig(options.timeoutConfig) : hubTimeoutConfig(options.env || process.env);
892
+ const controller = new AbortController();
893
+ const upstreamSignal = init.signal;
894
+ let connectTimer = null;
895
+ let idleTimer = null;
896
+ let totalTimer = null;
897
+ let reader = null;
898
+ let terminalError = null;
899
+ let rejectTerminal;
900
+ const terminal = new Promise((_, reject) => { rejectTerminal = reject; });
901
+ const stop = (error) => {
902
+ if (terminalError) return;
903
+ terminalError = error;
904
+ try { controller.abort(error); } catch { controller.abort(); }
905
+ rejectTerminal(error);
906
+ };
907
+ const onUpstreamAbort = () => {
908
+ const reason = upstreamSignal && upstreamSignal.reason;
909
+ const error = reason instanceof Error ? reason : new Error("Hub 요청이 취소되었습니다.");
910
+ if (!error.code) error.code = "ABORT_ERR";
911
+ stop(error);
912
+ };
913
+ const armIdle = () => {
914
+ if (idleTimer) clearTimeout(idleTimer);
915
+ idleTimer = setTimeout(() => stop(hubTimeoutError("idle", timeout.idleMs)), timeout.idleMs);
916
+ };
917
+
918
+ if (upstreamSignal) {
919
+ if (upstreamSignal.aborted) onUpstreamAbort();
920
+ else upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
921
+ }
922
+ connectTimer = setTimeout(() => stop(hubTimeoutError("connect", timeout.connectMs)), timeout.connectMs);
923
+ totalTimer = setTimeout(() => stop(hubTimeoutError("total", timeout.totalMs)), timeout.totalMs);
924
+
925
+ try {
926
+ const response = await Promise.race([
927
+ Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal })),
928
+ terminal,
929
+ ]);
930
+ if (connectTimer) clearTimeout(connectTimer);
931
+ connectTimer = null;
932
+ const chunks = [];
933
+ let bytes = 0;
934
+ armIdle();
935
+ if (response.body && typeof response.body.getReader === "function") {
936
+ reader = response.body.getReader();
937
+ while (true) {
938
+ const part = await Promise.race([reader.read(), terminal]);
939
+ if (part.done) break;
940
+ armIdle();
941
+ const chunk = Buffer.from(part.value || []);
942
+ bytes += chunk.length;
943
+ if (bytes > HUB_RESPONSE_MAX_BYTES) {
944
+ const error = new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
945
+ error.code = "AGENTLAS_HUB_RESPONSE_TOO_LARGE";
946
+ stop(error);
947
+ throw error;
948
+ }
949
+ chunks.push(chunk);
950
+ }
951
+ } else {
952
+ const raw = Buffer.from(await Promise.race([response.arrayBuffer(), terminal]));
953
+ bytes = raw.length;
954
+ if (bytes > HUB_RESPONSE_MAX_BYTES) throw new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
955
+ chunks.push(raw);
956
+ }
957
+ if (idleTimer) clearTimeout(idleTimer);
958
+ idleTimer = null;
959
+ const text = Buffer.concat(chunks, bytes).toString("utf8");
960
+ return { ok: response.ok, status: response.status, headers: response.headers, text };
961
+ } catch (error) {
962
+ if (terminalError) throw terminalError;
963
+ throw error;
964
+ } finally {
965
+ if (connectTimer) clearTimeout(connectTimer);
966
+ if (idleTimer) clearTimeout(idleTimer);
967
+ if (totalTimer) clearTimeout(totalTimer);
968
+ if (upstreamSignal) upstreamSignal.removeEventListener?.("abort", onUpstreamAbort);
969
+ if (reader && terminalError) {
970
+ try { await reader.cancel(terminalError); } catch { /* ignore */ }
971
+ }
972
+ }
973
+ }
974
+
975
+ function parseHubJsonCli(response, label) {
976
+ try {
977
+ return JSON.parse(response.text || "null");
978
+ } catch {
979
+ throw new Error(`${label} 응답 JSON 형식이 올바르지 않습니다.`);
980
+ }
981
+ }
982
+
786
983
  function parseCloudFlags(args) {
787
984
  const flags = { _: [] };
788
985
  for (let i = 0; i < args.length; i++) {
@@ -803,6 +1000,35 @@ function parseCloudFlags(args) {
803
1000
  return flags;
804
1001
  }
805
1002
 
1003
+ function cloudVisibilityFlag(value) {
1004
+ if (value == null) return null;
1005
+ if (value === "private-link" || value === "marketplace") return value;
1006
+ throw new Error("--visibility must be private-link or marketplace");
1007
+ }
1008
+
1009
+ function cloudVisibilityForAction(sub, flags) {
1010
+ const explicit = cloudVisibilityFlag(flags.visibility);
1011
+ if (sub === "save") {
1012
+ if (explicit === "marketplace") {
1013
+ throw new Error("`agentlas cloud save` is owner-private. Use `agentlas cloud publish` for the public Hub.");
1014
+ }
1015
+ return "private-link";
1016
+ }
1017
+ if (sub === "publish") {
1018
+ if (explicit === "private-link") {
1019
+ throw new Error("`agentlas cloud publish` is public Hub publication. Use `agentlas cloud save` for owner-private Agent Cloud storage.");
1020
+ }
1021
+ return "marketplace";
1022
+ }
1023
+ if (explicit) return explicit;
1024
+ return "private-link";
1025
+ }
1026
+
1027
+ function cloudActionForTopLevelUpload(args) {
1028
+ const flags = parseCloudFlags(args);
1029
+ return cloudVisibilityFlag(flags.visibility) === "marketplace" ? "publish" : "save";
1030
+ }
1031
+
806
1032
  async function cmdCloud(db, args, runtimeOverride) {
807
1033
  const sub = args[0] || "help";
808
1034
  if (sub === "help" || sub === "--help" || sub === "-h") {
@@ -815,28 +1041,58 @@ async function cmdCloud(db, args, runtimeOverride) {
815
1041
  " runtime read-agent-file <path> <file>",
816
1042
  " lazy read with allow/deny gates",
817
1043
  " field-test [--json] run local Cloud contract field test",
818
- " package <path> [--json] package + static security review",
1044
+ " save <path> [--dry-run] [--slug name]",
1045
+ " save owner-private in Agent Cloud (default upload)",
819
1046
  " publish <path> [--dry-run] [--llm-review] [--slug name]",
820
- " register with submitter-paid local review",
821
- " install <slug> download/install from Agentlas Cloud marketplace",
822
- " delete <slug> [--json] unpublish one of your cloud marketplace agents",
1047
+ " explicitly publish to the public Agentlas Hub",
1048
+ " package <path> [--json] [--visibility private-link|marketplace]",
1049
+ " package only; defaults to private-save checks",
1050
+ " list [--json] list packages in your private Agent Cloud",
1051
+ " restore <slug> [--json] restore an owned Cloud package on this machine",
1052
+ " install <slug> compatibility alias: install from the public Hub",
1053
+ " delete <slug> [--scope owner-private|hub-public] [--json]",
1054
+ " conditionally delete one exact observed Cloud revision",
823
1055
  " search \"<what you need>\" [--limit 10]",
824
- " search the marketplace (no sign-in needed)",
1056
+ " search the public Hub (no sign-in needed)",
825
1057
  "",
826
- "Model cost rule: Agentlas Cloud does not run a platform-owned LLM here.",
827
- "--llm-review uses only this machine's active CLI/BYOK/Ollama runtime.",
1058
+ "Private save rule: no public review or routing card; local secret/path/hash checks remain.",
1059
+ "--llm-review applies only to public Hub publishing and uses this machine's runtime.",
828
1060
  ].join("\n"));
829
1061
  return;
830
1062
  }
831
1063
  if (sub === "search") {
832
1064
  return parity().cloudSearch(db, args.slice(1));
833
1065
  }
1066
+ if (sub === "list") {
1067
+ const flags = parseCloudFlags(args.slice(1));
1068
+ const result = await listOwnedCloudAgentsCli(Number(flags.limit || 100));
1069
+ if (flags.json) return out(JSON.stringify(result, null, 2));
1070
+ const agents = Array.isArray(result.results) ? result.results : [];
1071
+ if (!agents.length) return out("Private Agent Cloud에 저장된 에이전트가 없습니다.");
1072
+ for (const agent of agents) out(`${agent.slug}\t${agent.name || agent.nameEn || agent.slug}\t${agent.entityKind || "agent"}`);
1073
+ return;
1074
+ }
1075
+ if (sub === "restore") {
1076
+ const flags = parseCloudFlags(args.slice(1));
1077
+ const slug = flags._[0];
1078
+ if (!slug) fail("usage: agentlas cloud restore <slug> [--json]");
1079
+ const result = await restoreOwnedCloudAgentCli(db, slug);
1080
+ if (flags.json) return out(JSON.stringify(result, null, 2));
1081
+ out(`✓ restored ${result.slug} from private Agent Cloud`);
1082
+ out(` hash: ${result.packageHash}`);
1083
+ if (result.localPath) out(` files: ${result.localPath}`);
1084
+ if (result.localStateWarning) out(` warning: ${result.localStateWarning}`);
1085
+ return;
1086
+ }
834
1087
  if (sub === "delete" || sub === "unpublish") {
835
1088
  const flags = parseCloudFlags(args.slice(1));
836
1089
  const slug = flags._[0];
837
1090
  if (!slug) fail(`usage: agentlas cloud ${sub} <slug> [--json]`);
838
- const result = await deleteCloudAgentCli(slug);
1091
+ const result = await deleteCloudAgentCli(slug, { scope: flags.scope });
839
1092
  out(flags.json ? JSON.stringify(result, null, 2) : `✓ deleted ${result.slug || slug}`);
1093
+ if (!flags.json && Array.isArray(result.localStateWarnings)) {
1094
+ for (const warning of result.localStateWarnings) out(` warning: ${warning}`);
1095
+ }
840
1096
  return;
841
1097
  }
842
1098
  const cloudRuntime = require("./agentlas-cloud-runtime.cjs");
@@ -884,14 +1140,15 @@ async function cmdCloud(db, args, runtimeOverride) {
884
1140
  return;
885
1141
  }
886
1142
  if (sub === "install") return cmdCloudInstall(db, args[1]);
887
- if (sub !== "package" && sub !== "publish") fail("usage: agentlas cloud <package|publish|install|delete> ...");
1143
+ if (sub !== "package" && sub !== "save" && sub !== "publish") fail("usage: agentlas cloud <save|publish|package|list|restore|install|delete> ...");
888
1144
  const flags = parseCloudFlags(args.slice(1));
889
1145
  const root = flags._[0];
890
1146
  if (!root) fail(`usage: agentlas cloud ${sub} <path>`);
1147
+ const visibility = cloudVisibilityForAction(sub, flags);
891
1148
  const dryRun = sub === "package" || Boolean(flags["dry-run"]);
892
1149
  const result = await packageCloudAgentCli(db, root, {
893
1150
  slug: typeof flags.slug === "string" ? flags.slug : undefined,
894
- visibility: flags.visibility === "private-link" ? "private-link" : "marketplace",
1151
+ visibility,
895
1152
  llmReview: Boolean(flags["llm-review"]),
896
1153
  dryRun,
897
1154
  runtimeOverride,
@@ -901,51 +1158,74 @@ async function cmdCloud(db, args, runtimeOverride) {
901
1158
  return;
902
1159
  }
903
1160
  printCloudPackageResult(result);
904
- if (sub === "publish" && result.status === "blocked") process.exit(1);
1161
+ if ((sub === "save" || sub === "publish") && result.status === "blocked") process.exit(1);
905
1162
  }
906
1163
 
907
1164
  async function packageCloudAgentCli(db, root, opts) {
908
- const rootPath = path.resolve(root);
1165
+ const requestedRoot = path.resolve(root);
909
1166
  let st;
910
- try { st = fs.statSync(rootPath); } catch { fail(`폴더를 찾을 수 없습니다: ${root}`); }
911
- if (!st.isDirectory()) fail(`폴더가 아닙니다: ${root}`);
1167
+ try { st = fs.lstatSync(requestedRoot); } catch { throw new Error(`폴더를 찾을 수 없습니다: ${root}`); }
1168
+ if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`실제 폴더가 아닙니다: ${root}`);
1169
+ const rootPath = fs.realpathSync.native(requestedRoot);
1170
+ const visibility = opts.visibility || "private-link";
1171
+ const isPublicHubPublish = visibility === "marketplace";
912
1172
  const scan = scanCloudFolderCli(rootPath);
913
- const routingCard = readCloudRoutingCardCli(rootPath);
1173
+ let snapshot = cloudPackageSnapshot(scan.included);
1174
+ let careerGraph;
1175
+ if (isPublicHubPublish) {
1176
+ careerGraph = cloudReadPublicCareerCard(snapshot, scan.findings);
1177
+ cloudReplacePublicCareerCard(scan, careerGraph);
1178
+ snapshot = cloudPackageSnapshot(scan.included);
1179
+ }
1180
+ const routingCard = isPublicHubPublish ? readCloudRoutingCardCli(snapshot) : {};
914
1181
  if (routingCard.finding) scan.findings.push(routingCard.finding);
915
- const name = cloudReadName(rootPath);
916
- const slug = cloudSlug(opts.slug || cloudReadStableSlug(rootPath) || name || path.basename(rootPath));
917
- const packageHash = cloudHashPackage(scan.included);
1182
+ const packageFindings = isPublicHubPublish ? scan.findings : privateCloudSafetyFindingsCli(scan.findings);
1183
+ const name = cloudReadName(snapshot, path.basename(rootPath));
1184
+ const slug = cloudSlug(opts.slug || cloudReadStableSlug(snapshot) || name || path.basename(rootPath));
1185
+ const scope = cloudScopeForVisibility(visibility);
1186
+ const baseDescriptor = cloudBaseDescriptorForSourceCli(scan.localPackageMarker, rootPath, slug, scope);
1187
+ const packageHashVersion = CLOUD_PACKAGE_HASH_V2;
1188
+ const packageHash = cloudHashPackage(scan.included, packageHashVersion);
918
1189
  const manifest = {
919
1190
  version: "0.1",
920
1191
  kind: "agentlas-cloud-agent",
921
1192
  slug,
922
1193
  name,
923
- tagline: cloudReadTagline(rootPath),
924
- agentKind: cloudInferKind(rootPath),
925
- runtimeLabels: detectRuntimeLabels(rootPath),
926
- visibility: opts.visibility || "marketplace",
927
- rootFingerprint: sha(rootPath),
1194
+ tagline: cloudReadTagline(snapshot),
1195
+ agentKind: cloudInferKind(snapshot),
1196
+ runtimeLabels: cloudDetectRuntimeLabels(snapshot),
1197
+ visibility,
1198
+ // Content-derived and host-independent. Never persist an absolute local
1199
+ // path fingerprint into a portable Cloud package.
1200
+ rootFingerprint: sha(`agentlas-package-root:${packageHash}`),
928
1201
  packageHash,
1202
+ packageHashVersion,
929
1203
  fileCount: scan.files.length,
930
1204
  includedFileCount: scan.included.length,
931
1205
  totalBytes: scan.included.reduce((sum, file) => sum + file.bytes, 0),
932
1206
  createdAt: new Date().toISOString(),
933
- billingMode: opts.llmReview ? "submitter-local-runtime" : "static-only",
934
- costOwner: opts.llmReview ? "submitter" : "none",
935
- security: cloudSecuritySummary(scan.findings),
1207
+ billingMode: isPublicHubPublish && opts.llmReview ? "submitter-local-runtime" : "static-only",
1208
+ costOwner: isPublicHubPublish && opts.llmReview ? "submitter" : "none",
1209
+ security: cloudSecuritySummary(packageFindings),
1210
+ ...(careerGraph ? { careerGraph } : {}),
936
1211
  };
937
1212
  if (routingCard.card) manifest.routingCard = routingCard.card;
938
1213
  const packageDir = cloudPackageDir(slug);
939
1214
  fs.mkdirSync(packageDir, { recursive: true });
940
1215
  const manifestPath = path.join(packageDir, "package.manifest.json");
941
1216
  const bundlePath = path.join(packageDir, "package.bundle.json");
942
- const bundle = { manifest, files: scan.included, source: { packagedBy: "agentlas-cli", packagedAt: manifest.createdAt, costOwner: manifest.costOwner } };
1217
+ const bundle = {
1218
+ manifest,
1219
+ files: scan.included,
1220
+ source: { packagedBy: "agentlas-cli", packagedAt: manifest.createdAt, costOwner: manifest.costOwner },
1221
+ ...(careerGraph ? { careerGraph } : {}),
1222
+ };
943
1223
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
944
1224
  fs.writeFileSync(bundlePath, JSON.stringify(bundle, null, 2) + "\n", "utf8");
945
- const review = opts.llmReview
946
- ? await runCloudLocalReviewCli(db, rootPath, manifest, scan.findings, opts.runtimeOverride)
947
- : cloudStaticReview(scan.findings);
948
- const allFindings = [...scan.findings, ...review.findings.filter((f) => !scan.findings.some((s) => s.id === f.id))];
1225
+ const review = isPublicHubPublish && opts.llmReview
1226
+ ? await runCloudLocalReviewCli(db, rootPath, manifest, packageFindings, opts.runtimeOverride)
1227
+ : cloudStaticReview(packageFindings, isPublicHubPublish ? "hub-public" : "owner-private");
1228
+ const allFindings = [...packageFindings, ...review.findings.filter((f) => !packageFindings.some((s) => s.id === f.id))];
949
1229
  manifest.security = cloudSecuritySummary(allFindings);
950
1230
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
951
1231
  fs.writeFileSync(bundlePath, JSON.stringify({ ...bundle, manifest }, null, 2) + "\n", "utf8");
@@ -953,7 +1233,34 @@ async function packageCloudAgentCli(db, root, opts) {
953
1233
  let registration = null;
954
1234
  let status = blocked ? "blocked" : opts.dryRun ? "dry-run" : "ready";
955
1235
  if (!blocked && !opts.dryRun) {
956
- registration = await registerCloudAgentCli(manifest, bundlePath, review, opts.visibility || "marketplace");
1236
+ registration = await registerCloudAgentCli(manifest, bundlePath, review, visibility, { baseDescriptor });
1237
+ let descriptor;
1238
+ try {
1239
+ descriptor = rememberCloudAssetDescriptorCli(registration, { sourceRoot: rootPath });
1240
+ } catch (error) {
1241
+ const stateError = new Error(
1242
+ `Cloud save committed on the server, but this machine could not persist revision ${registration.revision}. ` +
1243
+ "Do not retry blindly; run `agentlas cloud list` and restore the asset before the next update. " +
1244
+ `Local state error: ${error.message || error}`,
1245
+ );
1246
+ stateError.code = "AGENTLAS_CLOUD_LOCAL_STATE_COMMIT_FAILED";
1247
+ stateError.receipt = registration;
1248
+ throw stateError;
1249
+ }
1250
+ try {
1251
+ writeCloudSourceMarkerCli(rootPath, scan, descriptor, {
1252
+ previousMarker: scan.localPackageMarker,
1253
+ packageHash,
1254
+ packageHashVersion,
1255
+ fileCount: scan.included.length,
1256
+ totalBytes: manifest.totalBytes,
1257
+ executablePaths: packageHashVersion === CLOUD_PACKAGE_HASH_V2
1258
+ ? scan.included.filter((file) => file.executable).map((file) => file.path).sort()
1259
+ : undefined,
1260
+ });
1261
+ } catch (error) {
1262
+ registration.localStateWarning = `Cloud save succeeded, but the source marker could not be updated: ${error.message || error}`;
1263
+ }
957
1264
  status = "registered";
958
1265
  }
959
1266
  return {
@@ -966,83 +1273,466 @@ async function packageCloudAgentCli(db, root, opts) {
966
1273
  files: scan.files,
967
1274
  review,
968
1275
  registration,
969
- summary: status === "registered" ? `Registered ${slug}.` : status === "blocked" ? `Blocked: ${review.summary}` : `Ready: ${slug}.`,
1276
+ summary: status === "registered"
1277
+ ? isPublicHubPublish
1278
+ ? `Published ${slug} publicly to Agentlas Hub.`
1279
+ : `Saved ${slug} privately in Agent Cloud.`
1280
+ : status === "blocked"
1281
+ ? isPublicHubPublish
1282
+ ? `Hub publish blocked: ${review.summary}`
1283
+ : `Private Agent Cloud save blocked: ${review.summary}`
1284
+ : isPublicHubPublish
1285
+ ? `Hub package ready: ${slug}.`
1286
+ : `Private Agent Cloud package ready: ${slug}.`,
1287
+ };
1288
+ }
1289
+
1290
+ function cloudScopeForVisibility(visibility) {
1291
+ return visibility === "marketplace" ? "hub-public" : "owner-private";
1292
+ }
1293
+
1294
+ function normalizeCloudScopeFlagCli(value) {
1295
+ if (value === "owner-private" || value === "private" || value === "private-link") return "owner-private";
1296
+ if (value === "hub-public" || value === "marketplace" || value === "public") return "hub-public";
1297
+ return null;
1298
+ }
1299
+
1300
+ function cloudRevisionEtag(revision) {
1301
+ return `"${revision}"`;
1302
+ }
1303
+
1304
+ function normalizeCloudAssetDescriptorCli(value, label = "cloud asset descriptor") {
1305
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1306
+ throw new Error(`${label} is missing`);
1307
+ }
1308
+ const cloudId = typeof value.cloudId === "string" ? value.cloudId.trim() : "";
1309
+ const slug = typeof value.slug === "string" ? value.slug.trim() : "";
1310
+ const scope = value.scope;
1311
+ const packageHash = String(value.packageHash || "").replace(/^sha256:/i, "").toLowerCase();
1312
+ const packageHashVersion = cloudPackageHashVersion(value.packageHashVersion);
1313
+ const revision = typeof value.revision === "string" ? value.revision : "";
1314
+ const etag = typeof value.etag === "string" ? value.etag : cloudRevisionEtag(revision);
1315
+ const updatedAt = typeof value.updatedAt === "string"
1316
+ ? value.updatedAt
1317
+ : typeof value.savedAt === "string"
1318
+ ? value.savedAt
1319
+ : typeof value.registeredAt === "string"
1320
+ ? value.registeredAt
1321
+ : "";
1322
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(cloudId)) {
1323
+ throw new Error(`${label} cloudId is invalid`);
1324
+ }
1325
+ if (!slug || cloudSlug(slug) !== slug) throw new Error(`${label} slug is invalid`);
1326
+ if (!CLOUD_ASSET_SCOPES.has(scope)) throw new Error(`${label} scope is invalid`);
1327
+ if (!/^[a-f0-9]{64}$/.test(packageHash) || !packageHashVersion) {
1328
+ throw new Error(`${label} package identity is invalid`);
1329
+ }
1330
+ if (!revision || revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(revision)) {
1331
+ throw new Error(`${label} revision is invalid`);
1332
+ }
1333
+ if (etag !== cloudRevisionEtag(revision)) throw new Error(`${label} ETag does not authenticate revision`);
1334
+ if (!updatedAt || !Number.isFinite(Date.parse(updatedAt))) throw new Error(`${label} updatedAt is invalid`);
1335
+ return { cloudId, slug, scope, packageHash, packageHashVersion, revision, etag, updatedAt };
1336
+ }
1337
+
1338
+ function cloudDescriptorKey(descriptor) {
1339
+ return `${descriptor.scope}:${descriptor.slug}`;
1340
+ }
1341
+
1342
+ function cloudAssetStatePathCli() {
1343
+ return path.join(userDataDir(), CLOUD_ASSET_STATE_FILE);
1344
+ }
1345
+
1346
+ function readCloudAssetStateCli() {
1347
+ const statePath = cloudAssetStatePathCli();
1348
+ if (!fs.existsSync(statePath)) return { schemaVersion: 1, assets: {}, deletedBases: [] };
1349
+ let fd;
1350
+ try {
1351
+ fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
1352
+ const stat = fs.fstatSync(fd);
1353
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("state file is not a bounded regular file");
1354
+ const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
1355
+ if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
1356
+ throw new Error("state schema is invalid");
1357
+ }
1358
+ const assets = {};
1359
+ for (const [key, raw] of Object.entries(parsed.assets)) {
1360
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
1361
+ const descriptor = normalizeCloudAssetDescriptorCli(raw.descriptor, `state entry ${key}`);
1362
+ if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
1363
+ const sourceRoots = Array.isArray(raw.sourceRoots)
1364
+ ? [...new Set(raw.sourceRoots.filter((item) => typeof item === "string" && path.isAbsolute(item)).map((item) => path.resolve(item)))].slice(0, 32)
1365
+ : [];
1366
+ assets[key] = { descriptor, sourceRoots };
1367
+ }
1368
+ const deletedBases = Array.isArray(parsed.deletedBases)
1369
+ ? parsed.deletedBases.filter((item) =>
1370
+ item && typeof item === "object" && !Array.isArray(item) &&
1371
+ typeof item.rootPath === "string" && path.isAbsolute(item.rootPath) &&
1372
+ typeof item.slug === "string" && cloudSlug(item.slug) === item.slug &&
1373
+ CLOUD_ASSET_SCOPES.has(item.scope) && typeof item.cloudId === "string" &&
1374
+ typeof item.revision === "string"
1375
+ ).map((item) => ({
1376
+ rootPath: path.resolve(item.rootPath),
1377
+ slug: item.slug,
1378
+ scope: item.scope,
1379
+ cloudId: item.cloudId,
1380
+ revision: item.revision,
1381
+ })).slice(-256)
1382
+ : [];
1383
+ return { schemaVersion: 1, assets, deletedBases };
1384
+ } catch (error) {
1385
+ throw new Error(`Agent Cloud local revision state is unreadable: ${error.message || error}`);
1386
+ } finally {
1387
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ }
1388
+ }
1389
+ }
1390
+
1391
+ function writeCloudAssetStateCli(state) {
1392
+ const statePath = cloudAssetStatePathCli();
1393
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
1394
+ const temp = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
1395
+ const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
1396
+ try {
1397
+ fs.writeFileSync(fd, JSON.stringify(state, null, 2) + "\n", "utf8");
1398
+ fs.fsyncSync(fd);
1399
+ } finally {
1400
+ fs.closeSync(fd);
1401
+ }
1402
+ fs.renameSync(temp, statePath);
1403
+ cloudApplyPortableFileMode(statePath, 0o600);
1404
+ cloudFsyncDirectoryCli(path.dirname(statePath));
1405
+ }
1406
+
1407
+ function rememberCloudAssetDescriptorCli(value, options = {}) {
1408
+ const descriptor = normalizeCloudAssetDescriptorCli(value);
1409
+ const state = readCloudAssetStateCli();
1410
+ const key = cloudDescriptorKey(descriptor);
1411
+ const previous = state.assets[key];
1412
+ const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
1413
+ const roots = sameRevision ? [...previous.sourceRoots] : [];
1414
+ if (options.sourceRoot) {
1415
+ const sourceRoot = path.resolve(options.sourceRoot);
1416
+ roots.push(sourceRoot);
1417
+ state.deletedBases = state.deletedBases.filter(
1418
+ (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
1419
+ );
1420
+ }
1421
+ state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
1422
+ writeCloudAssetStateCli(state);
1423
+ return descriptor;
1424
+ }
1425
+
1426
+ function findCloudAssetDescriptorCli(slug, scope) {
1427
+ const safeSlug = cloudSlug(slug);
1428
+ const state = readCloudAssetStateCli();
1429
+ const matches = Object.values(state.assets).filter(
1430
+ (entry) => entry.descriptor.slug === safeSlug && (!scope || entry.descriptor.scope === scope),
1431
+ );
1432
+ if (!scope && matches.length > 1) {
1433
+ throw new Error(`Cloud asset ${safeSlug} exists in multiple scopes. Retry with --scope owner-private or --scope hub-public.`);
1434
+ }
1435
+ return matches.length === 1 ? matches[0] : null;
1436
+ }
1437
+
1438
+ function cloudMarkerDescriptorsCli(marker) {
1439
+ const descriptors = {};
1440
+ if (!marker || typeof marker !== "object" || Array.isArray(marker)) return descriptors;
1441
+ if (marker.cloudAssets && typeof marker.cloudAssets === "object" && !Array.isArray(marker.cloudAssets)) {
1442
+ for (const scope of CLOUD_ASSET_SCOPES) {
1443
+ if (!marker.cloudAssets[scope]) continue;
1444
+ try {
1445
+ const descriptor = normalizeCloudAssetDescriptorCli(marker.cloudAssets[scope], `local marker ${scope}`);
1446
+ if (descriptor.scope === scope) descriptors[scope] = descriptor;
1447
+ } catch { /* legacy or corrupt CAS entry is not adopted as a base revision */ }
1448
+ }
1449
+ }
1450
+ if (marker.revision && marker.cloudId && marker.scope) {
1451
+ try {
1452
+ const descriptor = normalizeCloudAssetDescriptorCli(marker, "local marker");
1453
+ if (!descriptors[descriptor.scope]) descriptors[descriptor.scope] = descriptor;
1454
+ } catch { /* legacy marker */ }
1455
+ }
1456
+ return descriptors;
1457
+ }
1458
+
1459
+ function cloudBaseDescriptorFromMarkerCli(marker, slug, scope) {
1460
+ const descriptor = cloudMarkerDescriptorsCli(marker)[scope];
1461
+ return descriptor && descriptor.slug === slug ? descriptor : null;
1462
+ }
1463
+
1464
+ function cloudBaseDescriptorForSourceCli(marker, rootPath, slug, scope) {
1465
+ const state = readCloudAssetStateCli();
1466
+ const normalizedRoot = path.resolve(rootPath);
1467
+ let markerDescriptor = cloudBaseDescriptorFromMarkerCli(marker, slug, scope);
1468
+ if (markerDescriptor && state.deletedBases.some((item) =>
1469
+ item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope &&
1470
+ item.cloudId === markerDescriptor.cloudId && item.revision === markerDescriptor.revision
1471
+ )) {
1472
+ markerDescriptor = null;
1473
+ }
1474
+ const entry = state.assets[`${scope}:${slug}`];
1475
+ const stateDescriptor = entry && entry.sourceRoots.includes(normalizedRoot) ? entry.descriptor : null;
1476
+ if (!markerDescriptor) return stateDescriptor;
1477
+ if (!stateDescriptor) return markerDescriptor;
1478
+ return stateDescriptor.cloudId === markerDescriptor.cloudId && stateDescriptor.updatedAt >= markerDescriptor.updatedAt
1479
+ ? stateDescriptor
1480
+ : markerDescriptor;
1481
+ }
1482
+
1483
+ function writeCloudSourceMarkerCli(rootPath, scan, descriptor, options = {}) {
1484
+ const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
1485
+ if (fs.existsSync(markerPath)) {
1486
+ const stat = fs.lstatSync(markerPath);
1487
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Agent Cloud revision marker is not a regular file");
1488
+ }
1489
+ const descriptors = cloudMarkerDescriptorsCli(options.previousMarker);
1490
+ if (descriptor) descriptors[descriptor.scope] = descriptor;
1491
+ if (options.removeDescriptor) {
1492
+ const current = descriptors[options.removeDescriptor.scope];
1493
+ if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
1494
+ delete descriptors[options.removeDescriptor.scope];
1495
+ }
1496
+ }
1497
+ const latest = descriptor || Object.values(descriptors)[0] || null;
1498
+ const marker = {
1499
+ schemaVersion: 1,
1500
+ source: "agentlas-cloud",
1501
+ slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
1502
+ packageHash: descriptor?.packageHash || options.packageHash || options.previousMarker?.packageHash || "",
1503
+ packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || options.previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
1504
+ fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (options.previousMarker?.fileCount || 0),
1505
+ totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (options.previousMarker?.totalBytes || 0),
1506
+ executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : options.previousMarker?.executablePaths,
1507
+ cloudAssets: descriptors,
1508
+ ...(latest ? latest : {}),
1509
+ restoredAt: options.previousMarker?.restoredAt,
1510
+ savedAt: new Date().toISOString(),
970
1511
  };
1512
+ for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
1513
+ const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
1514
+ const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
1515
+ try {
1516
+ fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
1517
+ fs.fsyncSync(fd);
1518
+ } finally {
1519
+ fs.closeSync(fd);
1520
+ }
1521
+ fs.renameSync(temp, markerPath);
1522
+ cloudApplyPortableFileMode(markerPath, 0o600);
1523
+ cloudFsyncDirectoryCli(rootPath);
1524
+ return marker;
1525
+ }
1526
+
1527
+ function readCloudSourceMarkerCli(rootPath) {
1528
+ const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
1529
+ if (!fs.existsSync(markerPath)) return null;
1530
+ let fd;
1531
+ try {
1532
+ fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
1533
+ const stat = fs.fstatSync(fd);
1534
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("marker is not a bounded regular file");
1535
+ return JSON.parse(fs.readFileSync(fd, "utf8"));
1536
+ } finally {
1537
+ if (fd !== undefined) fs.closeSync(fd);
1538
+ }
971
1539
  }
972
1540
 
973
1541
  function scanCloudFolderCli(rootPath) {
974
1542
  const files = [];
975
1543
  const included = [];
976
1544
  const findings = [];
1545
+ const restoredExecutablePaths = cloudReadRestoreExecutablePaths(rootPath);
1546
+ let localPackageMarker = null;
977
1547
  let totalBytes = 0;
978
1548
  let count = 0;
979
1549
  let hasDefinition = false;
980
1550
  function addFinding(kind, severity, category, message, file, remediation) {
981
1551
  findings.push({ id: `${kind}-${sha(file || message).slice(0, 10)}`, severity, category, message, ...(file ? { file } : {}), ...(remediation ? { remediation } : {}) });
982
1552
  }
1553
+ function insideRoot(candidate) {
1554
+ const relative = path.relative(rootPath, candidate);
1555
+ return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative));
1556
+ }
1557
+ function readStableFile(file, rel) {
1558
+ const beforeReal = fs.realpathSync.native(file);
1559
+ if (!insideRoot(beforeReal)) throw new Error("file resolves outside the approved package root");
1560
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
1561
+ const nonBlock = fs.constants.O_NONBLOCK || 0;
1562
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
1563
+ try {
1564
+ const before = fs.fstatSync(fd);
1565
+ if (!before.isFile()) throw new Error("package entry is not a regular file");
1566
+ if (before.size > CLOUD_MAX_FILE_BYTES) throw new Error(`file exceeds ${CLOUD_MAX_FILE_BYTES} bytes`);
1567
+ const chunks = [];
1568
+ let actualBytes = 0;
1569
+ for (;;) {
1570
+ const capacity = Math.min(64 * 1024, CLOUD_MAX_FILE_BYTES + 1 - actualBytes);
1571
+ if (capacity <= 0) throw new Error(`file exceeds ${CLOUD_MAX_FILE_BYTES} bytes`);
1572
+ const chunk = Buffer.allocUnsafe(capacity);
1573
+ const read = fs.readSync(fd, chunk, 0, chunk.length, null);
1574
+ if (read === 0) break;
1575
+ actualBytes += read;
1576
+ if (actualBytes > CLOUD_MAX_FILE_BYTES) throw new Error(`file exceeds ${CLOUD_MAX_FILE_BYTES} bytes`);
1577
+ chunks.push(chunk.subarray(0, read));
1578
+ }
1579
+ const after = fs.fstatSync(fd);
1580
+ const afterReal = fs.realpathSync.native(file);
1581
+ const pathStat = fs.statSync(file);
1582
+ if (
1583
+ !insideRoot(afterReal) || beforeReal !== afterReal ||
1584
+ before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size ||
1585
+ before.mode !== after.mode || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
1586
+ after.dev !== pathStat.dev || after.ino !== pathStat.ino || after.mode !== pathStat.mode ||
1587
+ actualBytes !== after.size
1588
+ ) {
1589
+ throw new Error("package entry changed while it was being read");
1590
+ }
1591
+ return {
1592
+ bytes: Buffer.concat(chunks, actualBytes),
1593
+ executable: cloudPortableExecutableForFile(rel, after.mode, restoredExecutablePaths),
1594
+ };
1595
+ } finally {
1596
+ fs.closeSync(fd);
1597
+ }
1598
+ }
983
1599
  function walk(dir) {
984
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1600
+ let directoryBefore;
1601
+ let directoryRealBefore;
1602
+ try {
1603
+ directoryBefore = fs.lstatSync(dir);
1604
+ directoryRealBefore = fs.realpathSync.native(dir);
1605
+ if (!directoryBefore.isDirectory() || directoryBefore.isSymbolicLink() || !insideRoot(directoryRealBefore)) {
1606
+ throw new Error("directory is not stable inside the approved root");
1607
+ }
1608
+ } catch (error) {
1609
+ addFinding("unsafe-directory", "blocker", "policy", `Package directory could not be read safely: ${error.message || error}`, path.relative(rootPath, dir).split(path.sep).join("/"), "Remove linked or changing directories and retry.");
1610
+ return;
1611
+ }
1612
+ let entries;
1613
+ try {
1614
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1615
+ } catch (error) {
1616
+ addFinding("unsafe-directory", "blocker", "policy", `Package directory could not be read safely: ${error.message || error}`, path.relative(rootPath, dir).split(path.sep).join("/"), "Remove linked or changing directories and retry.");
1617
+ return;
1618
+ }
985
1619
  for (const entry of entries) {
986
1620
  if (entry.name.startsWith("._")) continue;
987
1621
  const abs = path.join(dir, entry.name);
988
1622
  const rel = path.relative(rootPath, abs).split(path.sep).join("/");
1623
+ if (cloudPortablePathKey(rel) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
1624
+ // Local restore/CAS metadata is runtime state, never portable asset
1625
+ // data, but it must be captured with the same no-follow stability gate.
1626
+ if (entry.isSymbolicLink() || !entry.isFile()) {
1627
+ addFinding("unsafe-local-state", "blocker", "policy", "Agent Cloud local revision marker must be a stable regular file.", rel, "Remove the linked or special marker and restore/list the asset again.");
1628
+ continue;
1629
+ }
1630
+ try {
1631
+ const stableMarker = readStableFile(abs, rel);
1632
+ localPackageMarker = JSON.parse(stableMarker.bytes.toString("utf8"));
1633
+ } catch (error) {
1634
+ addFinding("invalid-local-state", "blocker", "policy", `Agent Cloud local revision marker could not be read safely: ${error.message || error}`, rel, "Repair or remove the marker, then restore/list the asset again.");
1635
+ }
1636
+ continue;
1637
+ }
989
1638
  if (entry.isSymbolicLink()) {
990
1639
  addFinding("symlink", "blocker", "policy", "Symbolic links are not allowed in cloud agent packages.", rel, "Replace the symlink with an ordinary file or remove it.");
991
1640
  files.push({ path: rel, bytes: 0, sha256: "", kind: "binary", included: false, reason: "symlink-blocked" });
992
1641
  continue;
993
1642
  }
994
1643
  if (entry.isDirectory()) {
995
- if (!CLOUD_SKIP_DIRS.has(entry.name)) walk(abs);
1644
+ if (CLOUD_SKIP_DIRS.has(entry.name)) continue;
1645
+ walk(abs);
1646
+ continue;
1647
+ }
1648
+ if (!entry.isFile()) {
1649
+ addFinding("unsupported-entry", "blocker", "policy", "Only stable ordinary files and directories are allowed in Cloud packages.", rel, "Remove sockets, FIFOs, devices, and other special filesystem entries.");
1650
+ files.push({ path: rel, bytes: 0, sha256: "", kind: "binary", included: false, reason: "unsupported-entry" });
1651
+ continue;
1652
+ }
1653
+ if (!cloudPortableRelativePath(rel)) {
1654
+ addFinding("unsafe-path", "blocker", "policy", "File path is not portable across supported hosts.", rel, "Rename the file to a Unicode NFC, relative, cross-platform-safe path.");
1655
+ files.push({ path: rel, bytes: 0, sha256: "", kind: "binary", included: false, reason: "unsafe-path" });
996
1656
  continue;
997
1657
  }
998
- if (!entry.isFile()) continue;
999
1658
  count++;
1000
1659
  if (count > CLOUD_MAX_FILES) {
1001
1660
  addFinding("file-count-limit", "blocker", "size", `Package has more than ${CLOUD_MAX_FILES} files.`, "", "Publish a focused agent/team folder.");
1002
1661
  continue;
1003
1662
  }
1004
1663
  if (CLOUD_AGENT_FILES.has(entry.name)) hasDefinition = true;
1005
- const stat = fs.statSync(abs);
1006
- totalBytes += stat.size;
1007
- const digest = sha(fs.readFileSync(abs));
1664
+ let hint;
1665
+ try { hint = fs.lstatSync(abs); } catch { hint = { size: 0 }; }
1008
1666
  if (CLOUD_BLOCKED_FILE_RE.some((re) => re.test(entry.name))) {
1009
1667
  addFinding("blocked-file", "blocker", "secret", "Secret-bearing file names are not allowed in cloud packages.", rel, "Remove credentials and publish only env key names.");
1010
- files.push({ path: rel, bytes: stat.size, sha256: digest, kind: "binary", included: false, reason: "secret-file-blocked" });
1668
+ files.push({ path: rel, bytes: Number(hint.size) || 0, sha256: "", kind: "binary", included: false, reason: "secret-file-blocked" });
1011
1669
  continue;
1012
1670
  }
1013
- if (stat.size > CLOUD_MAX_FILE_BYTES) {
1014
- addFinding("large-file", "high", "size", `File exceeds ${CLOUD_MAX_FILE_BYTES} bytes.`, rel, "Move large assets out of the package.");
1015
- files.push({ path: rel, bytes: stat.size, sha256: digest, kind: "binary", included: false, reason: "file-too-large" });
1671
+ if (Number(hint.size) > CLOUD_MAX_FILE_BYTES) {
1672
+ addFinding("large-file", "blocker", "size", `File exceeds ${CLOUD_MAX_FILE_BYTES} bytes.`, rel, "Move large assets out of the package.");
1673
+ files.push({ path: rel, bytes: Number(hint.size), sha256: "", kind: "binary", included: false, reason: "file-too-large" });
1016
1674
  continue;
1017
1675
  }
1018
1676
  const ext = path.extname(entry.name).toLowerCase();
1019
1677
  const isText = CLOUD_TEXT_EXTS.has(ext) || CLOUD_AGENT_FILES.has(entry.name);
1020
- if (!isText) {
1021
- files.push({ path: rel, bytes: stat.size, sha256: digest, kind: "binary", included: false, reason: "binary-skipped" });
1678
+ let stable;
1679
+ try {
1680
+ stable = readStableFile(abs, rel);
1681
+ } catch (error) {
1682
+ addFinding("unstable-file", "blocker", "policy", `Package file could not be read safely: ${error.message || error}`, rel, "Remove linked or concurrently changing files and retry.");
1683
+ files.push({ path: rel, bytes: Number(hint.size) || 0, sha256: "", kind: isText ? "text" : "binary", included: false, reason: "unstable-file" });
1022
1684
  continue;
1023
1685
  }
1024
- const text = fs.readFileSync(abs, "utf8");
1025
- for (const [id, re, label] of CLOUD_SECRET_RE) {
1026
- if (re.test(text)) addFinding(id, "blocker", "secret", `Possible ${label} found in package content.`, rel, "Remove the value and require users to configure their own key.");
1686
+ const content = stable.bytes;
1687
+ const executable = stable.executable;
1688
+ totalBytes += content.length;
1689
+ const digest = sha(content);
1690
+ cloudAddSecretFindingsFromBytes(content, rel, addFinding);
1691
+ if (isText) {
1692
+ const decoded = cloudDecodeTextAsset(content);
1693
+ if (!decoded.ok) {
1694
+ addFinding("invalid-text-encoding", "blocker", "policy", "A text agent asset is not valid UTF-8 or BOM-marked UTF-16.", rel, "Save the file as UTF-8 or BOM-marked UTF-16 before packaging.");
1695
+ files.push({ path: rel, bytes: content.length, sha256: digest, kind: "text", executable, included: false, reason: "invalid-text-encoding" });
1696
+ continue;
1697
+ }
1698
+ const text = decoded.text;
1699
+ if (/(?:curl|wget)[^\n|&;]+[|]\s*(?:sh|bash)/i.test(text)) {
1700
+ addFinding("curl-pipe-shell", "high", "network", "Remote shell install pattern detected.", rel, "Use explicit, reviewable install steps.");
1701
+ }
1027
1702
  }
1028
- if (/(?:curl|wget)[^\n|&;]+[|]\s*(?:sh|bash)/i.test(text)) {
1029
- addFinding("curl-pipe-shell", "high", "network", "Remote shell install pattern detected.", rel, "Use explicit, reviewable install steps.");
1703
+ files.push({ path: rel, bytes: content.length, sha256: digest, kind: isText ? "text" : "binary", executable, included: true });
1704
+ included.push({ path: rel, bytes: content.length, sha256: digest, executable, contentBase64: content.toString("base64") });
1705
+ }
1706
+ try {
1707
+ const directoryAfter = fs.lstatSync(dir);
1708
+ const directoryRealAfter = fs.realpathSync.native(dir);
1709
+ if (
1710
+ !directoryAfter.isDirectory() || directoryAfter.isSymbolicLink() || !insideRoot(directoryRealAfter) ||
1711
+ directoryRealBefore !== directoryRealAfter || directoryBefore.dev !== directoryAfter.dev ||
1712
+ directoryBefore.ino !== directoryAfter.ino || directoryBefore.mtimeMs !== directoryAfter.mtimeMs ||
1713
+ directoryBefore.ctimeMs !== directoryAfter.ctimeMs
1714
+ ) {
1715
+ throw new Error("directory changed while it was scanned");
1030
1716
  }
1031
- files.push({ path: rel, bytes: stat.size, sha256: digest, kind: "text", included: true });
1032
- included.push({ path: rel, bytes: stat.size, sha256: digest, contentBase64: Buffer.from(text, "utf8").toString("base64") });
1717
+ } catch (error) {
1718
+ addFinding("unstable-directory", "blocker", "policy", `Package directory changed while it was scanned: ${error.message || error}`, path.relative(rootPath, dir).split(path.sep).join("/"), "Stop concurrent edits and retry.");
1033
1719
  }
1034
1720
  }
1035
1721
  walk(rootPath);
1722
+ const pathConflict = cloudPortablePathConflict(included.map((file) => file.path));
1723
+ if (pathConflict) {
1724
+ addFinding(pathConflict.code, "blocker", "policy", pathConflict.message, "", "Rename aliased paths so every file and ancestor directory has one portable identity.");
1725
+ }
1036
1726
  if (!hasDefinition) addFinding("missing-agent-definition", "blocker", "structure", "No agent definition file was found.", "", "Add AGENTS.md, CLAUDE.md, GEMINI.md, AGENT.md, or README.md at the package root.");
1037
1727
  if (totalBytes > CLOUD_MAX_TOTAL_BYTES) addFinding("package-size-limit", "blocker", "size", `Package exceeds ${CLOUD_MAX_TOTAL_BYTES} bytes.`, "", "Publish a smaller agent folder.");
1038
- files.sort((a, b) => a.path.localeCompare(b.path));
1039
- included.sort((a, b) => a.path.localeCompare(b.path));
1040
- return { files, included, findings, totalBytes };
1728
+ files.sort(cloudCodePointPathOrder);
1729
+ included.sort(cloudCodePointPathOrder);
1730
+ return { files, included, findings, totalBytes, localPackageMarker };
1041
1731
  }
1042
1732
 
1043
- function readCloudRoutingCardCli(rootPath) {
1044
- const abs = path.join(rootPath, CLOUD_ROUTING_CARD_PATH);
1045
- if (!fs.existsSync(abs)) {
1733
+ function readCloudRoutingCardCli(snapshot) {
1734
+ const file = snapshot.get(CLOUD_ROUTING_CARD_PATH);
1735
+ if (!file) {
1046
1736
  return {
1047
1737
  finding: {
1048
1738
  id: "routing-card-required",
@@ -1055,7 +1745,7 @@ function readCloudRoutingCardCli(rootPath) {
1055
1745
  };
1056
1746
  }
1057
1747
  try {
1058
- const parsed = JSON.parse(fs.readFileSync(abs, "utf8"));
1748
+ const parsed = JSON.parse(Buffer.from(file.contentBase64, "base64").toString("utf8"));
1059
1749
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1060
1750
  return cloudRoutingCardFinding("routing-card-invalid", "Routing card must be a JSON object.", "Replace .agentlas/routing-card.json with a routing-card/2.0 object.");
1061
1751
  }
@@ -1100,14 +1790,25 @@ function cloudRoutingCardProblem(card) {
1100
1790
  return null;
1101
1791
  }
1102
1792
 
1103
- function cloudStaticReview(findings) {
1793
+ function privateCloudSafetyFindingsCli(findings) {
1794
+ return findings.filter((finding) =>
1795
+ (finding.severity === "blocker" && !finding.id.startsWith("missing-agent-definition"))
1796
+ || finding.category === "secret"
1797
+ || finding.category === "size");
1798
+ }
1799
+
1800
+ function cloudStaticReview(findings, scope = "hub-public") {
1104
1801
  const blockers = findings.filter((f) => f.severity === "blocker").length;
1105
1802
  const high = findings.filter((f) => f.severity === "high").length;
1106
1803
  return {
1107
1804
  mode: "static-only",
1108
1805
  verdict: blockers ? "fail" : high ? "needs-review" : "pass",
1109
1806
  costOwner: "none",
1110
- summary: blockers || high ? `${blockers} blocker(s), ${high} high-risk finding(s).` : "Static package review passed.",
1807
+ summary: blockers || high
1808
+ ? `${blockers} blocker(s), ${high} high-risk finding(s).`
1809
+ : scope === "owner-private"
1810
+ ? "Private Agent Cloud safety checks passed."
1811
+ : "Static public package review passed.",
1111
1812
  findings,
1112
1813
  reviewedAt: new Date().toISOString(),
1113
1814
  };
@@ -1153,42 +1854,178 @@ async function runCloudLocalReviewCli(db, rootPath, manifest, staticFindings, ru
1153
1854
  };
1154
1855
  }
1155
1856
 
1156
- async function registerCloudAgentCli(manifest, bundlePath, review, visibility) {
1857
+ function cloudCasResponseErrorCli(response, label) {
1858
+ let body = null;
1859
+ try { body = JSON.parse(response.text || "null"); } catch { /* generic below */ }
1860
+ const code = body && typeof body.code === "string" ? body.code : "cloud_request_failed";
1861
+ let message = `${label} 실패 ${response.status}`;
1862
+ if (response.status === 412 && code === "cloud_agent_revision_conflict") {
1863
+ const current = body && body.current ? body.current : body && body.conflict && body.conflict.current;
1864
+ message = current
1865
+ ? `다른 PC에서 이 Agent Cloud 자산이 변경되었습니다. 자동 덮어쓰기는 중단했습니다. \`agentlas cloud list\`로 최신 revision을 확인하고 \`agentlas cloud restore ${current.slug || "<slug>"}\`로 복원한 뒤 변경 사항을 병합하세요.`
1866
+ : "이 Agent Cloud 자산은 다른 PC에서 삭제되었거나 다른 식별자로 다시 생성되었습니다. 자동 재생성은 중단했습니다. `agentlas cloud list`로 현재 상태를 확인하세요.";
1867
+ } else if (response.status === 428 && code === "client_upgrade_required") {
1868
+ message = "기존 Cloud 자산을 안전하게 갱신할 base revision이 없습니다. 서버 revision을 자동 복사하지 않습니다. `agentlas cloud list`로 확인하고 `agentlas cloud restore <slug>`로 복원한 뒤 다시 저장하세요.";
1869
+ } else if (response.status === 503 && code === "cloud_mutations_maintenance") {
1870
+ const retryAfter = response.headers && typeof response.headers.get === "function" ? response.headers.get("retry-after") : null;
1871
+ message = `Agent Cloud 저장/삭제가 잠시 점검 중입니다${retryAfter ? ` (약 ${retryAfter}초 후 재시도)` : ""}. 읽기·목록·복원은 계속 사용할 수 있습니다.`;
1872
+ } else if (body && typeof body.error === "string") {
1873
+ message = `${label} 실패 ${response.status}: ${body.error.slice(0, 300)}`;
1874
+ }
1875
+ const error = new Error(message);
1876
+ error.code = code;
1877
+ error.status = response.status;
1878
+ if (body && body.current) error.current = body.current;
1879
+ if (body && body.conflict) error.conflict = body.conflict;
1880
+ return error;
1881
+ }
1882
+
1883
+ async function registerCloudAgentCli(manifest, bundlePath, review, visibility, options = {}) {
1157
1884
  const cookie = await cloudSessionCookieCli();
1158
1885
  if (!cookie) fail("agentlas.cloud 로그인이 필요합니다. 데스크톱 앱에서 로그인하거나 AGENTLAS_SESSION을 설정하세요.");
1159
1886
  if (typeof fetch !== "function") fail("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
1160
1887
  const base = (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
1161
1888
  const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
1162
- const resp = await fetch(`${base}/api/cloud-agents/v1/register`, {
1889
+ const expectedScope = cloudScopeForVisibility(visibility);
1890
+ const baseDescriptor = options.baseDescriptor
1891
+ ? normalizeCloudAssetDescriptorCli(options.baseDescriptor, "base revision")
1892
+ : null;
1893
+ if (baseDescriptor && (baseDescriptor.slug !== manifest.slug || baseDescriptor.scope !== expectedScope)) {
1894
+ throw new Error("Agent Cloud base revision does not match the requested slug/scope.");
1895
+ }
1896
+ const headers = { "content-type": "application/json", cookie, origin: base };
1897
+ if (baseDescriptor) {
1898
+ headers["if-match"] = baseDescriptor.etag;
1899
+ headers["x-agentlas-cloud-id"] = baseDescriptor.cloudId;
1900
+ } else {
1901
+ headers["if-none-match"] = "*";
1902
+ }
1903
+ const resp = await fetchHubCli(`${base}/api/cloud-agents/v1/register`, {
1163
1904
  method: "POST",
1164
- headers: { "content-type": "application/json", cookie, origin: base },
1905
+ headers,
1165
1906
  body: JSON.stringify({ manifest, bundle, review, visibility, billing: { modelCallsPaidBy: review.costOwner, localRuntime: review.runtimeLabel || null } }),
1166
1907
  });
1167
- if (!resp.ok) fail(`Agentlas Cloud 등록 실패 ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 300)}`);
1168
- const json = await resp.json();
1908
+ if (!resp.ok) throw cloudCasResponseErrorCli(resp, "Agentlas Cloud 등록");
1909
+ const json = parseHubJsonCli(resp, "Agentlas Cloud 등록");
1910
+ const expectedSource = visibility === "marketplace" ? "hub" : "agent-cloud";
1911
+ const expectedVisibility = visibility === "marketplace" ? "marketplace" : "owner-private";
1912
+ const etag = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("etag") : null;
1913
+ const cacheControl = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("cache-control") : null;
1914
+ const expectedOperations = baseDescriptor ? new Set(["updated", "unchanged"]) : new Set(["created"]);
1915
+ if (
1916
+ json.schema !== "agentlas.agent_cloud.registration.v1" ||
1917
+ !expectedOperations.has(json.operation) ||
1918
+ json.source !== expectedSource ||
1919
+ json.visibility !== expectedVisibility ||
1920
+ json.scope !== expectedScope ||
1921
+ json.owner !== true ||
1922
+ json.publicHubPublished !== (visibility === "marketplace") ||
1923
+ json.dryRun !== false ||
1924
+ typeof json.cloudId !== "string" || !json.cloudId.trim() ||
1925
+ json.slug !== manifest.slug ||
1926
+ json.packageHash !== manifest.packageHash ||
1927
+ json.packageHashVersion !== manifest.packageHashVersion ||
1928
+ typeof json.revision !== "string" || etag !== cloudRevisionEtag(json.revision) ||
1929
+ typeof json.registeredAt !== "string" || !Number.isFinite(Date.parse(json.registeredAt)) ||
1930
+ !String(cacheControl || "").toLowerCase().includes("no-store") ||
1931
+ (baseDescriptor && json.cloudId !== baseDescriptor.cloudId)
1932
+ ) {
1933
+ throw new Error("Agentlas Cloud register returned an invalid or mismatched registration receipt.");
1934
+ }
1935
+ const descriptor = normalizeCloudAssetDescriptorCli({
1936
+ cloudId: json.cloudId,
1937
+ slug: json.slug,
1938
+ scope: json.scope,
1939
+ packageHash: json.packageHash,
1940
+ packageHashVersion: json.packageHashVersion,
1941
+ revision: json.revision,
1942
+ etag,
1943
+ updatedAt: json.savedAt || json.registeredAt,
1944
+ }, "registration receipt");
1169
1945
  return {
1170
- cloudId: json.cloudId || crypto.randomUUID(),
1171
- slug: json.slug || manifest.slug,
1172
- url: json.url,
1173
- marketplaceUrl: json.marketplaceUrl,
1174
- registeredAt: json.registeredAt || new Date().toISOString(),
1946
+ ...descriptor,
1947
+ operation: json.operation,
1948
+ ...(typeof json.url === "string" ? { url: json.url } : {}),
1949
+ ...(typeof json.marketplaceUrl === "string" ? { marketplaceUrl: json.marketplaceUrl } : {}),
1950
+ registeredAt: json.registeredAt,
1175
1951
  dryRun: false,
1176
1952
  };
1177
1953
  }
1178
1954
 
1179
- async function deleteCloudAgentCli(slug) {
1955
+ async function deleteCloudAgentCli(slug, options = {}) {
1180
1956
  const safeSlug = String(slug || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1181
1957
  if (!safeSlug) fail("usage: agentlas cloud delete <slug> [--json]");
1182
1958
  const cookie = await cloudSessionCookieCli();
1183
1959
  if (!cookie) fail("agentlas.cloud 로그인이 필요합니다. 데스크톱 앱에서 로그인하거나 AGENTLAS_SESSION을 설정하세요.");
1184
1960
  if (typeof fetch !== "function") fail("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
1961
+ const scope = options.scope == null ? null : normalizeCloudScopeFlagCli(options.scope);
1962
+ if (options.scope != null && !scope) throw new Error("--scope must be owner-private or hub-public");
1963
+ const localEntry = findCloudAssetDescriptorCli(safeSlug, scope);
1964
+ if (!localEntry) {
1965
+ throw new Error(`No observed base revision for ${safeSlug}${scope ? ` (${scope})` : ""}. Run \`agentlas cloud list\` first, then retry the exact asset deletion.`);
1966
+ }
1967
+ const descriptor = localEntry.descriptor;
1185
1968
  const base = (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
1186
- const resp = await fetch(`${base}/api/cloud-agents/v1/register?slug=${encodeURIComponent(safeSlug)}`, {
1969
+ const query = new URLSearchParams({ slug: safeSlug, scope: descriptor.scope, cloudId: descriptor.cloudId });
1970
+ const resp = await fetchHubCli(`${base}/api/cloud-agents/v1/register?${query.toString()}`, {
1187
1971
  method: "DELETE",
1188
- headers: { "content-type": "application/json", cookie, origin: base },
1972
+ headers: {
1973
+ "content-type": "application/json",
1974
+ cookie,
1975
+ origin: base,
1976
+ "if-match": descriptor.etag,
1977
+ "x-agentlas-cloud-id": descriptor.cloudId,
1978
+ },
1189
1979
  });
1190
- if (!resp.ok) fail(`Agentlas Cloud 삭제 실패 ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 300)}`);
1191
- return resp.json();
1980
+ if (!resp.ok) throw cloudCasResponseErrorCli(resp, "Agentlas Cloud 삭제");
1981
+ const json = parseHubJsonCli(resp, "Agentlas Cloud 삭제");
1982
+ const responseEtag = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("etag") : null;
1983
+ const cacheControl = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("cache-control") : null;
1984
+ const expectedSource = descriptor.scope === "hub-public" ? "hub" : "agent-cloud";
1985
+ const expectedVisibility = descriptor.scope === "hub-public" ? "marketplace" : "owner-private";
1986
+ const deletionTimestamp = descriptor.scope === "hub-public" ? json.unpublishedAt : json.deletedAt;
1987
+ if (
1988
+ json.schema !== "agentlas.agent_cloud.delete.v1" || json.ok !== true ||
1989
+ json.source !== expectedSource || json.visibility !== expectedVisibility ||
1990
+ json.scope !== descriptor.scope || json.cloudId !== descriptor.cloudId || json.slug !== descriptor.slug ||
1991
+ json.packageHash !== descriptor.packageHash || json.packageHashVersion !== descriptor.packageHashVersion ||
1992
+ json.revision !== descriptor.revision ||
1993
+ responseEtag !== descriptor.etag || !String(cacheControl || "").toLowerCase().includes("no-store") ||
1994
+ (descriptor.scope === "hub-public" && json.operation !== "unpublished") ||
1995
+ typeof deletionTimestamp !== "string" || !Number.isFinite(Date.parse(deletionTimestamp))
1996
+ ) {
1997
+ throw new Error("Agentlas Cloud delete returned an invalid or mismatched deletion receipt.");
1998
+ }
1999
+ const state = readCloudAssetStateCli();
2000
+ const key = cloudDescriptorKey(descriptor);
2001
+ const roots = state.assets[key]?.sourceRoots || [];
2002
+ const warnings = [];
2003
+ for (const rootPath of roots) {
2004
+ state.deletedBases.push({ rootPath, slug: descriptor.slug, scope: descriptor.scope, cloudId: descriptor.cloudId, revision: descriptor.revision });
2005
+ }
2006
+ delete state.assets[key];
2007
+ state.deletedBases = state.deletedBases.slice(-256);
2008
+ try {
2009
+ writeCloudAssetStateCli(state);
2010
+ } catch (error) {
2011
+ const stateError = new Error(
2012
+ `Cloud delete committed on the server, but this machine could not persist the deletion tombstone. ` +
2013
+ "Run `agentlas cloud list` before saving this slug again. " +
2014
+ `Local state error: ${error.message || error}`,
2015
+ );
2016
+ stateError.code = "AGENTLAS_CLOUD_LOCAL_STATE_COMMIT_FAILED";
2017
+ stateError.receipt = json;
2018
+ throw stateError;
2019
+ }
2020
+ for (const rootPath of roots) {
2021
+ try {
2022
+ const marker = readCloudSourceMarkerCli(rootPath);
2023
+ if (marker) writeCloudSourceMarkerCli(rootPath, null, null, { previousMarker: marker, removeDescriptor: descriptor });
2024
+ } catch (error) {
2025
+ warnings.push(`Could not clear ${rootPath}: ${error.message || error}`);
2026
+ }
2027
+ }
2028
+ return { ...json, ...(warnings.length ? { localStateWarnings: warnings } : {}) };
1192
2029
  }
1193
2030
 
1194
2031
  // `agentlas login`이 저장하는 CLI 세션 파일 (평문·0600 — 데스크탑의 safeStorage 파일과 별개).
@@ -1223,101 +2060,635 @@ async function cloudSessionCookieCli() {
1223
2060
  async function cmdCloudInstall(db, slug) {
1224
2061
  if (!slug) fail("usage: agentlas cloud install <slug>");
1225
2062
  const listing = await fetchCloudManifestCli(slug);
1226
- if (!listing) fail(`cloud agent를 찾을 수 없습니다: ${slug}`);
2063
+ if (!listing) fail(`Hub agent를 찾을 수 없습니다: ${slug}`);
2064
+ if (listing.delivery && listing.delivery.mode === "call_only") {
2065
+ fail(`이 Hub 에이전트는 소스 설치가 허용되지 않은 call-only 자산입니다. 실행: agentlas call ${slug}`);
2066
+ }
1227
2067
  const agent = persistCloudListingCli(db, listing);
1228
- out(`✓ installed ${agent.slug} — ${agent.name}`);
2068
+ out(`✓ Hub installed ${agent.slug} — ${agent.name}`);
1229
2069
  if (agent.localPath) out(` files: ${agent.localPath}`);
1230
2070
  }
1231
2071
 
1232
- async function fetchCloudManifestCli(slug) {
2072
+ async function callAgentlasMcpToolCli(name, args, { requireSession = false } = {}) {
1233
2073
  if (typeof fetch !== "function") fail("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
1234
2074
  const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
1235
2075
  const headers = { "content-type": "application/json" };
1236
2076
  const cookie = await cloudSessionCookieCli();
2077
+ if (requireSession && !cookie) fail("Agent Cloud에는 로그인이 필요합니다. 먼저 `agentlas login`을 실행하세요.");
1237
2078
  if (cookie) headers.cookie = cookie;
1238
- const resp = await fetch(`${base.replace(/\/$/, "")}/tools/call`, {
2079
+ const resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
1239
2080
  method: "POST",
1240
2081
  headers,
1241
- body: JSON.stringify({ method: "marketplace.get_manifest", params: { name: "marketplace.get_manifest", arguments: { kind: "agent", slug } } }),
2082
+ body: JSON.stringify({ method: name, params: { name, arguments: args || {} } }),
1242
2083
  });
1243
- if (!resp.ok) fail(`marketplace.get_manifest 실패 ${resp.status}`);
1244
- const json = await resp.json();
1245
- if (json.error) fail(`marketplace.get_manifest: ${json.error.message || "unknown error"}`);
2084
+ if (!resp.ok) fail(`${name} 실패 ${resp.status}`);
2085
+ const json = parseHubJsonCli(resp, name);
2086
+ if (json.error) fail(`${name}: ${json.error.message || "unknown error"}`);
1246
2087
  return json.result || null;
1247
2088
  }
1248
2089
 
2090
+ async function fetchCloudManifestCli(slug) {
2091
+ return callAgentlasMcpToolCli("marketplace.get_manifest", { kind: "agent", slug });
2092
+ }
2093
+
2094
+ async function listOwnedCloudAgentsCli(limit = 100) {
2095
+ const safeLimit = Math.max(1, Math.min(100, Number.isFinite(limit) ? Math.floor(limit) : 100));
2096
+ const result = (await callAgentlasMcpToolCli("cargo.search_agents", { q: "", limit: safeLimit }, { requireSession: true })) || {
2097
+ schema: "agentlas.agent_cloud.search.v1",
2098
+ source: "cloud",
2099
+ status: "ok",
2100
+ count: 0,
2101
+ total: 0,
2102
+ results: [],
2103
+ };
2104
+ if (!Array.isArray(result.results)) throw new Error("Agent Cloud list returned an invalid results contract.");
2105
+ if (result.results.length) {
2106
+ const state = readCloudAssetStateCli();
2107
+ for (const raw of result.results) {
2108
+ const descriptor = normalizeCloudAssetDescriptorCli(raw, "Agent Cloud list result");
2109
+ const key = cloudDescriptorKey(descriptor);
2110
+ const previous = state.assets[key];
2111
+ const preserveRoots = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
2112
+ state.assets[key] = { descriptor, sourceRoots: preserveRoots ? previous.sourceRoots : [] };
2113
+ }
2114
+ writeCloudAssetStateCli(state);
2115
+ }
2116
+ return result;
2117
+ }
2118
+
2119
+ async function restoreOwnedCloudAgentCli(db, slug) {
2120
+ const raw = await callAgentlasMcpToolCli("cargo.restore_package", { slug }, { requireSession: true });
2121
+ if (!raw || raw.error) {
2122
+ const code = raw && raw.error ? raw.error : "agent_not_found";
2123
+ const message = raw && raw.message ? raw.message : `Agent Cloud package not found: ${slug}`;
2124
+ throw new Error(`${code}: ${message}`);
2125
+ }
2126
+ const restored = normalizeOwnerRestorePayloadCli(raw, slug);
2127
+ const cloudPackage = restored.cloudPackage;
2128
+ const listing = {
2129
+ slug: restored.slug || slug,
2130
+ name: restored.name || restored.nameEn || restored.slug || slug,
2131
+ nameEn: restored.nameEn || restored.name || restored.slug || slug,
2132
+ tagline: restored.tagline || restored.taglineEn || "",
2133
+ taglineEn: restored.taglineEn || restored.tagline || "",
2134
+ trustGrade: "A",
2135
+ visibility: "visible",
2136
+ source: "cloud",
2137
+ assetDescriptor: restored.descriptor,
2138
+ cloudPackage,
2139
+ };
2140
+ const agent = persistCloudListingCli(db, listing);
2141
+ let descriptor = restored.descriptor;
2142
+ let localStateWarning;
2143
+ try {
2144
+ descriptor = rememberCloudAssetDescriptorCli(restored.descriptor, { sourceRoot: agent.localPath || undefined });
2145
+ } catch (error) {
2146
+ localStateWarning = `Restore completed, but observed revision state could not be indexed: ${error.message || error}`;
2147
+ }
2148
+ return {
2149
+ schema: restored.schema || "agentlas.agent_cloud.restore.v1",
2150
+ source: "cloud",
2151
+ slug: agent.slug,
2152
+ name: agent.name,
2153
+ packageHash: cloudPackage.packageHash,
2154
+ packageHashVersion: cloudPackage.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
2155
+ cloudId: descriptor.cloudId,
2156
+ scope: descriptor.scope,
2157
+ revision: descriptor.revision,
2158
+ etag: descriptor.etag,
2159
+ updatedAt: descriptor.updatedAt,
2160
+ localPath: agent.localPath || null,
2161
+ ...(localStateWarning ? { localStateWarning } : {}),
2162
+ };
2163
+ }
2164
+
2165
+ function normalizeOwnerRestorePayloadCli(raw, expectedSlug) {
2166
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_restore_contract");
2167
+ if (raw.schema !== "agentlas.agent_cloud.restore.v1" || raw.source !== "cloud" || raw.owner !== true) {
2168
+ throw new Error("invalid_restore_contract");
2169
+ }
2170
+ if (typeof raw.slug !== "string" || !raw.slug || raw.slug !== expectedSlug) {
2171
+ throw new Error(`restore_slug_mismatch: requested ${expectedSlug}; received ${String(raw.slug || "")}`);
2172
+ }
2173
+ const pkg = raw.cloudPackage;
2174
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg) || !Array.isArray(pkg.files)) {
2175
+ throw new Error("invalid_restore_contract");
2176
+ }
2177
+ const version = cloudPackageHashVersion(pkg.packageHashVersion);
2178
+ if (!version || !/^[a-f0-9]{64}$/i.test(String(pkg.packageHash || "").replace(/^sha256:/i, ""))) {
2179
+ throw new Error("invalid_restore_contract");
2180
+ }
2181
+ let descriptor;
2182
+ let nestedDescriptor;
2183
+ try {
2184
+ descriptor = normalizeCloudAssetDescriptorCli(raw, "owner restore receipt");
2185
+ nestedDescriptor = normalizeCloudAssetDescriptorCli({
2186
+ ...pkg,
2187
+ slug: raw.slug,
2188
+ etag: raw.etag,
2189
+ }, "owner restore package receipt");
2190
+ } catch (error) {
2191
+ throw new Error(`invalid_restore_contract: ${error.message || error}`);
2192
+ }
2193
+ if (JSON.stringify(descriptor) !== JSON.stringify(nestedDescriptor)) {
2194
+ throw new Error("invalid_restore_contract: restore revision envelope and cloudPackage disagree");
2195
+ }
2196
+ if (!["agent", "team", "repo"].includes(pkg.agentKind) || !Number.isSafeInteger(pkg.fileCount) || !Number.isSafeInteger(pkg.totalBytes)) {
2197
+ throw new Error("invalid_restore_contract");
2198
+ }
2199
+ for (const file of pkg.files) {
2200
+ if (!file || typeof file !== "object" || typeof file.path !== "string" || !Number.isSafeInteger(file.bytes) || typeof file.sha256 !== "string" || typeof file.contentBase64 !== "string") {
2201
+ throw new Error("invalid_restore_contract");
2202
+ }
2203
+ }
2204
+ const outerVersion = raw.packageHashVersion == null ? version : cloudPackageHashVersion(raw.packageHashVersion);
2205
+ if (
2206
+ (raw.packageHash != null && String(raw.packageHash) !== String(pkg.packageHash)) ||
2207
+ !outerVersion || outerVersion !== version ||
2208
+ (raw.fileCount != null && raw.fileCount !== pkg.fileCount) ||
2209
+ (raw.totalBytes != null && raw.totalBytes !== pkg.totalBytes) ||
2210
+ (raw.agentKind != null && raw.agentKind !== pkg.agentKind)
2211
+ ) {
2212
+ throw new Error("invalid_restore_contract: restore envelope and cloudPackage disagree");
2213
+ }
2214
+ return {
2215
+ schema: raw.schema,
2216
+ source: raw.source,
2217
+ owner: true,
2218
+ slug: raw.slug,
2219
+ name: typeof raw.name === "string" && raw.name ? raw.name : raw.slug,
2220
+ nameEn: typeof raw.nameEn === "string" && raw.nameEn ? raw.nameEn : (raw.name || raw.slug),
2221
+ tagline: typeof raw.tagline === "string" ? raw.tagline : "",
2222
+ taglineEn: typeof raw.taglineEn === "string" ? raw.taglineEn : (raw.tagline || ""),
2223
+ descriptor,
2224
+ cloudPackage: {
2225
+ cloudId: descriptor.cloudId,
2226
+ scope: descriptor.scope,
2227
+ revision: descriptor.revision,
2228
+ updatedAt: descriptor.updatedAt,
2229
+ packageHash: String(pkg.packageHash).replace(/^sha256:/i, "").toLowerCase(),
2230
+ packageHashVersion: version,
2231
+ fileCount: pkg.fileCount,
2232
+ totalBytes: pkg.totalBytes,
2233
+ agentKind: pkg.agentKind,
2234
+ runtimeLabels: Array.isArray(pkg.runtimeLabels) ? pkg.runtimeLabels.filter((item) => typeof item === "string" && item.trim()) : [],
2235
+ files: pkg.files,
2236
+ },
2237
+ };
2238
+ }
2239
+
2240
+ function cloudSystemPromptFromPackageCli(listing, slug) {
2241
+ const pkg = listing && listing.cloudPackage;
2242
+ if (!pkg || !Array.isArray(pkg.files) || !pkg.files.length) return "";
2243
+ const byPath = new Map();
2244
+ for (const file of pkg.files) {
2245
+ if (!file || typeof file.path !== "string" || typeof file.contentBase64 !== "string") continue;
2246
+ byPath.set(cloudPortablePathKey(file.path), file);
2247
+ }
2248
+ const readText = (candidate) => {
2249
+ const safe = cloudPortableRelativePath(candidate);
2250
+ if (!safe) return "";
2251
+ const file = byPath.get(cloudPortablePathKey(safe));
2252
+ if (!file) return "";
2253
+ let bytes;
2254
+ try { bytes = Buffer.from(file.contentBase64, "base64"); } catch { return ""; }
2255
+ if (!bytes.length || bytes.includes(0)) return "";
2256
+ const text = bytes.toString("utf8");
2257
+ if (!text.trim() || text.includes("\ufffd")) return "";
2258
+ return text.slice(0, 64 * 1024);
2259
+ };
2260
+ let manifest = null;
2261
+ const manifestFile = byPath.get(cloudPortablePathKey("agentlas.json"));
2262
+ if (manifestFile) {
2263
+ try { manifest = JSON.parse(Buffer.from(manifestFile.contentBase64, "base64").toString("utf8")); }
2264
+ catch { manifest = null; }
2265
+ }
2266
+ const declaredEntry = manifest && typeof manifest === "object" && typeof manifest.entry === "string"
2267
+ ? cloudPortableRelativePath(manifest.entry)
2268
+ : null;
2269
+ const candidates = [
2270
+ declaredEntry,
2271
+ "AGENTS.md",
2272
+ "CLAUDE.md",
2273
+ "GEMINI.md",
2274
+ "AGENT.md",
2275
+ "agent.md",
2276
+ "system-prompt.md",
2277
+ "README.md",
2278
+ ].filter(Boolean);
2279
+ let entryPath = "";
2280
+ let entryText = "";
2281
+ for (const candidate of candidates) {
2282
+ const text = readText(candidate);
2283
+ if (!text) continue;
2284
+ entryPath = candidate;
2285
+ entryText = text;
2286
+ break;
2287
+ }
2288
+ if (!entryText) return "";
2289
+ const installRoot = path.join(userDataDir(), "cloud-agent-installs", slug);
2290
+ return [
2291
+ `You are the Agentlas Cloud agent "${listing.name || slug}".`,
2292
+ `IMMUTABLE CLOUD AGENT ROOT: ${installRoot}`,
2293
+ `CANONICAL ENTRY: ${entryPath}`,
2294
+ `PACKAGE HASH: ${String(pkg.packageHash || "").replace(/^sha256:/i, "")}`,
2295
+ "Resolve package-relative references under IMMUTABLE CLOUD AGENT ROOT. Treat that root as read-only and do work in the user's active project.",
2296
+ "",
2297
+ "--- CLOUD AGENT ENTRY ---",
2298
+ entryText,
2299
+ ].join("\n");
2300
+ }
2301
+
1249
2302
  function persistCloudListingCli(db, listing) {
2303
+ if (listing?.delivery?.mode === "call_only") {
2304
+ throw new Error(`call-only Hub asset cannot be source-installed; invoke it with agentlas call ${listing.slug || "<slug>"}`);
2305
+ }
1250
2306
  const slug = cloudSlug(listing.slug || listing.name || "cloud-agent");
2307
+ recoverCloudInstallJournalCli(db, slug);
1251
2308
  const existing = db.prepare("SELECT * FROM installed_agents WHERE slug=?").get(slug);
1252
2309
  const now = new Date().toISOString();
1253
2310
  const envReqs = JSON.stringify(listing.envRequirements || []);
1254
2311
  const mcpServers = JSON.stringify(listing.mcpServers || []);
1255
- if (existing) {
1256
- db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, visibility=? WHERE slug=?")
1257
- .run(listing.name || slug, listing.nameEn || listing.name || slug, listing.tagline || "", listing.taglineEn || listing.tagline || "", listing.systemPrompt || "", mcpServers, envReqs, listing.trustGrade || "unknown", listing.visibility || "visible", slug);
1258
- const localPath = materializeCloudListingCli(existing.id, slug, listing);
1259
- return { ...existing, slug, name: listing.name || slug, ...(localPath ? { localPath } : {}) };
1260
- }
1261
- const id = crypto.randomUUID();
2312
+ const id = existing?.id || crypto.randomUUID();
1262
2313
  const hasVisibility = columnExists(db, "installed_agents", "visibility");
1263
- if (hasVisibility) {
1264
- db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone, visibility) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?,?)")
1265
- .run(id, slug, listing.name || slug, listing.nameEn || listing.name || slug, listing.tagline || "", listing.taglineEn || listing.tagline || "", listing.systemPrompt || "", mcpServers, envReqs, listing.trustGrade || "unknown", now, listing.tone || "blue", listing.visibility || "visible");
1266
- } else {
1267
- db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?)")
1268
- .run(id, slug, listing.name || slug, listing.nameEn || listing.name || slug, listing.tagline || "", listing.taglineEn || listing.tagline || "", listing.systemPrompt || "", mcpServers, envReqs, listing.trustGrade || "unknown", now, listing.tone || "blue");
2314
+ let installedAt = now;
2315
+ if (existing && String(existing.installed_at || "") === installedAt) {
2316
+ installedAt = new Date(Date.now() + 1).toISOString();
1269
2317
  }
1270
- const localPath = materializeCloudListingCli(id, slug, listing);
1271
- return { id, slug, name: listing.name || slug, ...(localPath ? { localPath } : {}) };
2318
+ const tone = listing.tone || "blue";
2319
+ const packageSystemPrompt = cloudSystemPromptFromPackageCli(listing, slug);
2320
+ const dbExpected = {
2321
+ id,
2322
+ slug,
2323
+ name: listing.name || slug,
2324
+ name_en: listing.nameEn || listing.name || slug,
2325
+ tagline: listing.tagline || "",
2326
+ tagline_en: listing.taglineEn || listing.tagline || "",
2327
+ system_prompt: packageSystemPrompt || listing.systemPrompt || "",
2328
+ mcp_servers_json: mcpServers,
2329
+ env_requirements_json: envReqs,
2330
+ trust_grade: listing.trustGrade || "unknown",
2331
+ installed_at: installedAt,
2332
+ tone,
2333
+ ...(!existing ? { preferred_backend: null } : {}),
2334
+ ...(hasVisibility ? { visibility: listing.visibility || "visible" } : {}),
2335
+ };
2336
+ const restore = materializeCloudListingCli(id, slug, listing, { deferCommit: true, dbExpected });
2337
+ const mutate = () => {
2338
+ if (existing) {
2339
+ if (hasVisibility) {
2340
+ db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, installed_at=?, tone=?, visibility=? WHERE slug=?")
2341
+ .run(dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, dbExpected.visibility, slug);
2342
+ } else {
2343
+ db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, installed_at=?, tone=? WHERE slug=?")
2344
+ .run(dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, slug);
2345
+ }
2346
+ return;
2347
+ }
2348
+ if (hasVisibility) {
2349
+ db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone, visibility) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?,?)")
2350
+ .run(id, slug, dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, dbExpected.visibility);
2351
+ } else {
2352
+ db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?)")
2353
+ .run(id, slug, dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone);
2354
+ }
2355
+ };
2356
+ let dbCommitted = false;
2357
+ try {
2358
+ if (typeof db.transaction === "function") db.transaction(mutate)();
2359
+ else mutate();
2360
+ dbCommitted = true;
2361
+ restore?.commit();
2362
+ } catch (error) {
2363
+ if (!dbCommitted) restore?.rollback();
2364
+ throw error;
2365
+ }
2366
+ const localPath = restore?.path || null;
2367
+ return existing
2368
+ ? { ...existing, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) }
2369
+ : { id, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) };
1272
2370
  }
1273
2371
 
1274
- function materializeCloudListingCli(agentId, slug, listing) {
2372
+ function materializeCloudListingCli(agentId, slug, listing, options = {}) {
1275
2373
  const pkg = listing.cloudPackage;
1276
2374
  if (!pkg || !Array.isArray(pkg.files) || pkg.files.length === 0) return null;
2375
+ if (pkg.files.length > CLOUD_MAX_FILES) throw new Error(`cloud package exceeds ${CLOUD_MAX_FILES} files`);
2376
+ if (!Number.isSafeInteger(pkg.fileCount) || pkg.fileCount !== pkg.files.length) {
2377
+ throw new Error("cloud package file count does not match its manifest");
2378
+ }
2379
+ if (!Number.isSafeInteger(pkg.totalBytes) || pkg.totalBytes < 0 || pkg.totalBytes > CLOUD_MAX_TOTAL_BYTES) {
2380
+ throw new Error("cloud package total byte count is invalid");
2381
+ }
2382
+ const packageHashVersion = cloudPackageHashVersion(pkg.packageHashVersion);
2383
+ if (!packageHashVersion) throw new Error(`unsupported cloud package hash version: ${pkg.packageHashVersion}`);
2384
+ const assetDescriptor = listing.assetDescriptor
2385
+ ? normalizeCloudAssetDescriptorCli(listing.assetDescriptor, "restore asset descriptor")
2386
+ : null;
2387
+ if (assetDescriptor && assetDescriptor.slug !== slug) throw new Error("restore asset descriptor slug mismatch");
2388
+ const pathConflict = cloudPortablePathConflict(pkg.files.map((file) => file && file.path));
2389
+ if (pathConflict) throw new Error(pathConflict.message);
1277
2390
  const dir = path.join(userDataDir(), "cloud-agent-installs", slug);
1278
- fs.mkdirSync(dir, { recursive: true });
1279
- const markerPath = path.join(dir, ".agentlas-cloud-package.json");
1280
- let currentHash = null;
2391
+ const parent = path.dirname(dir);
2392
+ fs.mkdirSync(parent, { recursive: true });
2393
+ const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
2394
+ const staging = path.join(parent, `.${path.basename(dir)}.installing-${nonce}`);
2395
+ const backup = path.join(parent, `.${path.basename(dir)}.backup-${nonce}`);
2396
+ const journal = path.join(parent, `.${path.basename(dir)}.install-journal.json`);
2397
+ const seen = new Set();
2398
+ const verifiedFiles = [];
2399
+ let verifiedTotalBytes = 0;
2400
+ let movedExisting = false;
2401
+ let installed = false;
1281
2402
  try {
1282
- currentHash = JSON.parse(fs.readFileSync(markerPath, "utf8")).packageHash || null;
1283
- } catch {}
1284
- const overwrite = currentHash !== pkg.packageHash;
1285
- for (const file of pkg.files) {
1286
- const target = resolveCloudInstallPathCli(dir, file.path);
1287
- const bytes = Buffer.from(String(file.contentBase64 || ""), "base64");
1288
- if (bytes.length !== Number(file.bytes) || sha(bytes) !== String(file.sha256 || "").toLowerCase()) {
1289
- fail(`cloud package file integrity failed: ${file.path}`);
2403
+ fs.mkdirSync(staging, { recursive: false, mode: 0o700 });
2404
+ cloudApplyPrivateDirectoryMode(staging);
2405
+ for (const file of pkg.files) {
2406
+ const target = resolveCloudInstallPathCli(staging, file.path);
2407
+ const normalizedPath = path.relative(staging, target).split(path.sep).join("/");
2408
+ if (seen.has(normalizedPath)) throw new Error(`duplicate cloud package path: ${file.path}`);
2409
+ seen.add(normalizedPath);
2410
+ if (packageHashVersion === CLOUD_PACKAGE_HASH_V2 && typeof file.executable !== "boolean") {
2411
+ throw new Error(`cloud package hash v2 requires executable boolean: ${file.path}`);
2412
+ }
2413
+ if (packageHashVersion === CLOUD_PACKAGE_HASH_V1 && file.executable !== undefined) {
2414
+ throw new Error(`legacy cloud package hash v1 cannot authenticate executable flag: ${file.path}`);
2415
+ }
2416
+ if (!cloudCanonicalBase64(file.contentBase64)) {
2417
+ throw new Error(`cloud package file base64 is not canonical: ${file.path}`);
2418
+ }
2419
+ const bytes = Buffer.from(String(file.contentBase64 || ""), "base64");
2420
+ if (!Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > CLOUD_MAX_FILE_BYTES) {
2421
+ throw new Error(`cloud package file byte count is invalid: ${file.path}`);
2422
+ }
2423
+ if (bytes.length !== Number(file.bytes) || sha(bytes) !== String(file.sha256 || "").toLowerCase()) {
2424
+ throw new Error(`cloud package file integrity failed: ${file.path}`);
2425
+ }
2426
+ verifiedFiles.push({
2427
+ path: normalizedPath,
2428
+ bytes: bytes.length,
2429
+ sha256: String(file.sha256 || "").toLowerCase(),
2430
+ ...(packageHashVersion === CLOUD_PACKAGE_HASH_V2 ? { executable: file.executable } : {}),
2431
+ });
2432
+ verifiedTotalBytes += bytes.length;
2433
+ if (verifiedTotalBytes > CLOUD_MAX_TOTAL_BYTES) throw new Error("cloud package exceeds total byte limit");
2434
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
2435
+ cloudApplyPrivateDirectoryMode(path.dirname(target));
2436
+ const mode = packageHashVersion === CLOUD_PACKAGE_HASH_V2 && file.executable ? 0o700 : 0o600;
2437
+ fs.writeFileSync(target, bytes, { mode });
2438
+ cloudApplyPortableFileMode(target, mode);
2439
+ }
2440
+ const expectedPackageHash = String(pkg.packageHash || "").toLowerCase().replace(/^sha256:/, "");
2441
+ if (!/^[a-f0-9]{64}$/.test(expectedPackageHash)) {
2442
+ throw new Error("cloud package aggregate hash is missing or invalid");
2443
+ }
2444
+ const actualPackageHash = cloudHashPackage(verifiedFiles, packageHashVersion);
2445
+ if (actualPackageHash !== expectedPackageHash) {
2446
+ throw new Error("cloud package aggregate integrity failed");
2447
+ }
2448
+ if (assetDescriptor && (
2449
+ assetDescriptor.packageHash !== expectedPackageHash ||
2450
+ assetDescriptor.packageHashVersion !== packageHashVersion
2451
+ )) {
2452
+ throw new Error("restore asset descriptor package identity mismatch");
2453
+ }
2454
+ if (verifiedTotalBytes !== pkg.totalBytes) throw new Error("cloud package total byte count does not match its files");
2455
+ const restoredAt = new Date().toISOString();
2456
+ fs.writeFileSync(
2457
+ path.join(staging, ".agentlas-cloud-package.json"),
2458
+ JSON.stringify({
2459
+ schemaVersion: 1,
2460
+ source: "agentlas-cloud",
2461
+ slug,
2462
+ packageHash: expectedPackageHash,
2463
+ packageHashVersion,
2464
+ fileCount: verifiedFiles.length,
2465
+ totalBytes: verifiedTotalBytes,
2466
+ executablePaths: packageHashVersion === CLOUD_PACKAGE_HASH_V2
2467
+ ? verifiedFiles.filter((file) => file.executable).map((file) => file.path).sort()
2468
+ : undefined,
2469
+ ...(assetDescriptor ? {
2470
+ cloudId: assetDescriptor.cloudId,
2471
+ scope: assetDescriptor.scope,
2472
+ revision: assetDescriptor.revision,
2473
+ etag: assetDescriptor.etag,
2474
+ updatedAt: assetDescriptor.updatedAt,
2475
+ cloudAssets: { [assetDescriptor.scope]: assetDescriptor },
2476
+ } : {}),
2477
+ restoredAt,
2478
+ }, null, 2) + "\n",
2479
+ { encoding: "utf8", mode: 0o600 },
2480
+ );
2481
+ cloudApplyPortableFileMode(path.join(staging, CLOUD_RESTORE_MARKER_PATH), 0o600);
2482
+ cloudVerifyRestoredSnapshot(staging, verifiedFiles, {
2483
+ slug,
2484
+ packageHash: expectedPackageHash,
2485
+ packageHashVersion,
2486
+ totalBytes: verifiedTotalBytes,
2487
+ assetDescriptor,
2488
+ });
2489
+
2490
+ if (options.deferCommit) {
2491
+ writeCloudInstallJournalCli(journal, {
2492
+ schemaVersion: 1,
2493
+ slug,
2494
+ phase: "prepared",
2495
+ destination: dir,
2496
+ staging,
2497
+ backup,
2498
+ hadExisting: fs.existsSync(dir),
2499
+ dbExpected: options.dbExpected || {},
2500
+ });
1290
2501
  }
1291
- fs.mkdirSync(path.dirname(target), { recursive: true });
1292
- if (overwrite || !fs.existsSync(target)) fs.writeFileSync(target, bytes);
2502
+
2503
+ // A Cloud agent is an immutable asset snapshot. Replace the managed install
2504
+ // as a whole so removed files and local mutations cannot leak across versions.
2505
+ if (fs.existsSync(dir)) {
2506
+ fs.renameSync(dir, backup);
2507
+ movedExisting = true;
2508
+ }
2509
+ fs.renameSync(staging, dir);
2510
+ cloudFsyncDirectoryCli(parent);
2511
+ installed = true;
2512
+ if (options.deferCommit) {
2513
+ writeCloudInstallJournalCli(journal, {
2514
+ schemaVersion: 1,
2515
+ slug,
2516
+ phase: "disk-swapped-db-pending",
2517
+ destination: dir,
2518
+ staging,
2519
+ backup,
2520
+ hadExisting: movedExisting,
2521
+ dbExpected: options.dbExpected || {},
2522
+ });
2523
+ }
2524
+ } catch (error) {
2525
+ rollbackCloudInstallSwapCli({ destination: dir, staging, backup, movedExisting, installed });
2526
+ try { if (fs.existsSync(journal)) fs.unlinkSync(journal); } catch { /* best-effort */ }
2527
+ throw error;
2528
+ } finally {
2529
+ try { if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best-effort */ }
2530
+ try { if (!options.deferCommit && installed && fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true }); } catch { /* best-effort */ }
2531
+ }
2532
+ if (!options.deferCommit) return dir;
2533
+ let settled = false;
2534
+ return {
2535
+ path: dir,
2536
+ commit() {
2537
+ if (settled) return;
2538
+ writeCloudInstallJournalCli(journal, {
2539
+ schemaVersion: 1,
2540
+ slug,
2541
+ phase: "db-committed",
2542
+ destination: dir,
2543
+ staging,
2544
+ backup,
2545
+ hadExisting: movedExisting,
2546
+ dbExpected: options.dbExpected || {},
2547
+ });
2548
+ if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true });
2549
+ if (fs.existsSync(journal)) fs.unlinkSync(journal);
2550
+ cloudFsyncDirectoryCli(parent);
2551
+ settled = true;
2552
+ },
2553
+ rollback() {
2554
+ if (settled) return;
2555
+ rollbackCloudInstallSwapCli({ destination: dir, staging, backup, movedExisting, installed });
2556
+ if (fs.existsSync(journal)) fs.unlinkSync(journal);
2557
+ cloudFsyncDirectoryCli(parent);
2558
+ settled = true;
2559
+ },
2560
+ };
2561
+ }
2562
+
2563
+ function writeCloudInstallJournalCli(journalPath, value) {
2564
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true, mode: 0o700 });
2565
+ const temp = `${journalPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
2566
+ const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
2567
+ try {
2568
+ fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n", "utf8");
2569
+ fs.fsyncSync(fd);
2570
+ } finally {
2571
+ fs.closeSync(fd);
1293
2572
  }
1294
- fs.writeFileSync(
1295
- markerPath,
1296
- JSON.stringify({ agentId, packageHash: pkg.packageHash, installedAt: new Date().toISOString() }, null, 2) + "\n",
1297
- "utf8",
2573
+ fs.renameSync(temp, journalPath);
2574
+ cloudApplyPortableFileMode(journalPath, 0o600);
2575
+ cloudFsyncDirectoryCli(path.dirname(journalPath));
2576
+ }
2577
+
2578
+ function cloudFsyncDirectoryCli(directory) {
2579
+ if (process.platform === "win32") return;
2580
+ let fd;
2581
+ try {
2582
+ fd = fs.openSync(directory, fs.constants.O_RDONLY);
2583
+ fs.fsyncSync(fd);
2584
+ } catch { /* some filesystems do not support directory fsync */ }
2585
+ finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ } }
2586
+ }
2587
+
2588
+ function rollbackCloudInstallSwapCli({ destination, staging, backup, movedExisting, installed }) {
2589
+ if (installed && fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
2590
+ if (movedExisting && fs.existsSync(backup) && !fs.existsSync(destination)) fs.renameSync(backup, destination);
2591
+ if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
2592
+ cloudFsyncDirectoryCli(path.dirname(destination));
2593
+ }
2594
+
2595
+ function recoverCloudInstallJournalCli(db, slug) {
2596
+ const destination = path.join(userDataDir(), "cloud-agent-installs", slug);
2597
+ const parent = path.dirname(destination);
2598
+ const journalPath = path.join(parent, `.${path.basename(destination)}.install-journal.json`);
2599
+ if (!fs.existsSync(journalPath)) return;
2600
+ let journal;
2601
+ try { journal = JSON.parse(fs.readFileSync(journalPath, "utf8")); } catch { throw new Error(`cloud install recovery journal is unreadable for ${slug}`); }
2602
+ const safeSibling = (candidate, prefix) =>
2603
+ typeof candidate === "string" && path.dirname(candidate) === parent && path.basename(candidate).startsWith(prefix);
2604
+ if (
2605
+ journal.schemaVersion !== 1 || journal.slug !== slug || journal.destination !== destination ||
2606
+ !["prepared", "disk-swapped-db-pending", "db-committed"].includes(journal.phase) ||
2607
+ typeof journal.hadExisting !== "boolean" ||
2608
+ !safeSibling(journal.staging, `.${path.basename(destination)}.installing-`) ||
2609
+ !safeSibling(journal.backup, `.${path.basename(destination)}.backup-`)
2610
+ ) {
2611
+ throw new Error(`cloud install recovery journal is invalid for ${slug}`);
2612
+ }
2613
+ const row = db.prepare("SELECT * FROM installed_agents WHERE slug=?").get(slug);
2614
+ const expected = journal.dbExpected && typeof journal.dbExpected === "object" ? journal.dbExpected : {};
2615
+ const expectedEntries = Object.entries(expected);
2616
+ const dbMatches = Boolean(row) && expectedEntries.length > 0 && expectedEntries.every(
2617
+ ([key, value]) => String(row[key] ?? "") === String(value ?? ""),
1298
2618
  );
1299
- return dir;
2619
+ if (journal.phase === "prepared") {
2620
+ // The DB mutation starts only after materializeCloudListingCli returns, so a
2621
+ // prepared journal always represents the pre-DB state. Cover both rename
2622
+ // crash windows: old→backup and staging→destination.
2623
+ if (journal.hadExisting) {
2624
+ if (fs.existsSync(journal.backup)) {
2625
+ if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
2626
+ fs.renameSync(journal.backup, destination);
2627
+ } else if (!fs.existsSync(destination)) {
2628
+ throw new Error(`prepared cloud install lost both destination and backup for ${slug}`);
2629
+ }
2630
+ } else {
2631
+ if (fs.existsSync(journal.backup)) {
2632
+ throw new Error(`prepared first cloud install has an unexpected backup for ${slug}`);
2633
+ }
2634
+ if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
2635
+ }
2636
+ if (fs.existsSync(journal.staging)) fs.rmSync(journal.staging, { recursive: true, force: true });
2637
+ } else if (journal.phase === "db-committed" || dbMatches) {
2638
+ if (!fs.existsSync(destination) && fs.existsSync(journal.staging)) fs.renameSync(journal.staging, destination);
2639
+ if (!fs.existsSync(destination)) throw new Error(`committed cloud install is missing for ${slug}`);
2640
+ if (fs.existsSync(journal.backup)) fs.rmSync(journal.backup, { recursive: true, force: true });
2641
+ if (fs.existsSync(journal.staging)) fs.rmSync(journal.staging, { recursive: true, force: true });
2642
+ } else if (journal.phase === "disk-swapped-db-pending") {
2643
+ if (!fs.existsSync(destination)) throw new Error(`pending cloud install destination is missing for ${slug}`);
2644
+ if (journal.hadExisting !== fs.existsSync(journal.backup)) {
2645
+ throw new Error(`pending cloud install backup state is invalid for ${slug}`);
2646
+ }
2647
+ rollbackCloudInstallSwapCli({
2648
+ destination,
2649
+ staging: journal.staging,
2650
+ backup: journal.backup,
2651
+ movedExisting: Boolean(journal.hadExisting),
2652
+ installed: true,
2653
+ });
2654
+ }
2655
+ fs.unlinkSync(journalPath);
2656
+ cloudFsyncDirectoryCli(parent);
1300
2657
  }
1301
2658
 
1302
- function resolveCloudInstallPathCli(root, relPath) {
1303
- const normalized = String(relPath || "").replace(/\\/g, "/");
1304
- if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) {
1305
- fail(`unsafe cloud package path: ${relPath}`);
2659
+ function recoverCloudInstallJournalsCli(db) {
2660
+ const parent = path.join(userDataDir(), "cloud-agent-installs");
2661
+ if (!fs.existsSync(parent)) return 0;
2662
+ let recovered = 0;
2663
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
2664
+ if (!entry.name.endsWith(".install-journal.json")) continue;
2665
+ const match = entry.name.match(/^\.([a-z0-9][a-z0-9-]{0,63})\.install-journal\.json$/);
2666
+ if (!match || cloudSlug(match[1]) !== match[1] || !entry.isFile() || entry.isSymbolicLink()) {
2667
+ throw new Error(`invalid cloud install recovery journal entry: ${entry.name}`);
2668
+ }
2669
+ recoverCloudInstallJournalCli(db, match[1]);
2670
+ recovered += 1;
1306
2671
  }
1307
- const parts = normalized.split("/").filter((part) => part && part !== ".");
1308
- if (parts.length === 0 || parts.some((part) => part === "..")) {
1309
- fail(`unsafe cloud package path: ${relPath}`);
2672
+ return recovered;
2673
+ }
2674
+
2675
+ function resolveCloudInstallPathCli(root, relPath) {
2676
+ const normalized = cloudPortableRelativePath(relPath);
2677
+ if (!normalized || cloudPortablePathKey(normalized) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
2678
+ throw new Error(`unsafe cloud package path: ${relPath}`);
1310
2679
  }
2680
+ const parts = normalized.split("/");
1311
2681
  const target = path.resolve(root, ...parts);
1312
2682
  const relative = path.relative(root, target);
1313
2683
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
1314
- fail(`cloud package path escapes install folder: ${relPath}`);
2684
+ throw new Error(`cloud package path escapes install folder: ${relPath}`);
1315
2685
  }
1316
2686
  return target;
1317
2687
  }
1318
2688
 
1319
2689
  function printCloudPackageResult(result) {
1320
2690
  out(`${result.status === "blocked" ? "✖" : "✓"} ${result.summary}`);
2691
+ out(` target: ${result.manifest.visibility === "marketplace" ? "Agentlas Hub (public)" : "Agent Cloud (owner-private)"}`);
1321
2692
  out(` slug: ${result.manifest.slug}`);
1322
2693
  out(` files: ${result.manifest.includedFileCount}/${result.manifest.fileCount}`);
1323
2694
  out(` hash: ${result.manifest.packageHash}`);
@@ -1328,11 +2699,106 @@ function printCloudPackageResult(result) {
1328
2699
  out(" findings:");
1329
2700
  for (const f of findings.slice(0, 20)) out(` - ${f.severity} ${f.file ? f.file + ": " : ""}${f.message}`);
1330
2701
  }
1331
- if (result.registration) out(` cloud: ${result.registration.marketplaceUrl || result.registration.url || result.registration.cloudId}`);
2702
+ if (result.registration) {
2703
+ const label = result.manifest.visibility === "marketplace" ? "hub" : "cloud";
2704
+ out(` ${label}: ${result.registration.marketplaceUrl || result.registration.url || result.registration.cloudId}`);
2705
+ if (result.registration.localStateWarning) out(` warning: ${result.registration.localStateWarning}`);
2706
+ }
1332
2707
  }
1333
2708
 
1334
- function cloudReadName(rootPath) {
1335
- const manifest = cloudReadPackageJson(rootPath);
2709
+ function cloudPackageSnapshot(files) {
2710
+ return new Map(files.map((file) => [file.path, file]));
2711
+ }
2712
+ function cloudReadPublicCareerCard(snapshot, findings) {
2713
+ const relativePath = ".agentlas/public-career-card.json";
2714
+ const file = snapshot.get(relativePath);
2715
+ if (!file) return undefined;
2716
+ let parsed;
2717
+ try { parsed = JSON.parse(Buffer.from(file.contentBase64, "base64").toString("utf8")); }
2718
+ catch {
2719
+ findings.push(cloudCareerFinding("career-card-invalid-json", "structure", "Career Graph public card is not valid JSON."));
2720
+ return undefined;
2721
+ }
2722
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.kind !== "agentlas-public-career-card") {
2723
+ findings.push(cloudCareerFinding("career-card-invalid-kind", "structure", "Career Graph public card has an invalid kind."));
2724
+ return undefined;
2725
+ }
2726
+ const privacy = parsed.privacy && typeof parsed.privacy === "object" && !Array.isArray(parsed.privacy) ? parsed.privacy : {};
2727
+ for (const key of ["rawLocalPathsIncluded", "rawPromptsIncluded", "rawTranscriptsIncluded", "sourceTextIncluded"]) {
2728
+ if (privacy[key] !== false) findings.push(cloudCareerFinding(`career-card-privacy-${key}`, "policy", `Career Graph public card must set privacy.${key}=false.`));
2729
+ }
2730
+ if (cloudContainsAbsoluteLocalPath(JSON.stringify(parsed))) {
2731
+ findings.push(cloudCareerFinding("career-card-local-path", "policy", "Career Graph public card contains a local absolute path."));
2732
+ }
2733
+ if (findings.some((finding) => finding.severity === "blocker" && finding.id.startsWith("career-card-"))) return undefined;
2734
+ return cloudSanitizePublicCareerCard(parsed);
2735
+ }
2736
+ function cloudCareerFinding(id, category, message) {
2737
+ return {
2738
+ id,
2739
+ severity: "blocker",
2740
+ category,
2741
+ file: ".agentlas/public-career-card.json",
2742
+ message,
2743
+ remediation: "Regenerate a redacted aggregate-only public Career Graph card before publishing.",
2744
+ };
2745
+ }
2746
+ function cloudContainsAbsoluteLocalPath(value) {
2747
+ return (
2748
+ (os.homedir() && value.includes(os.homedir())) ||
2749
+ /(?:^|["'\s:(])\/(?:Users|home|var|tmp|private|Volumes|opt|etc)\//i.test(value) ||
2750
+ /(?:^|["'\s:(])[A-Za-z]:[\\/]/.test(value) ||
2751
+ /(?:^|["'\s:(])\\\\[^\\\s]+\\/.test(value)
2752
+ );
2753
+ }
2754
+ function cloudSanitizePublicCareerCard(parsed) {
2755
+ const card = { kind: "agentlas-public-career-card" };
2756
+ for (const [key, max] of [["schemaVersion", 80], ["generatedAt", 80], ["projectName", 200], ["indexStatus", 80], ["policy", 160]]) {
2757
+ if (typeof parsed[key] === "string" && parsed[key].length <= max) card[key] = parsed[key];
2758
+ }
2759
+ card.privacy = {
2760
+ rawLocalPathsIncluded: false,
2761
+ rawPromptsIncluded: false,
2762
+ rawTranscriptsIncluded: false,
2763
+ sourceTextIncluded: false,
2764
+ };
2765
+ for (const key of ["counts", "sourceKinds", "nodeTypes", "edgeTypes"]) {
2766
+ const safe = cloudSanitizeCountRecord(parsed[key]);
2767
+ if (safe) card[key] = safe;
2768
+ }
2769
+ for (const key of ["canonicalSources", "staleSourceCount"]) {
2770
+ if (Number.isSafeInteger(parsed[key]) && parsed[key] >= 0) card[key] = parsed[key];
2771
+ }
2772
+ return card;
2773
+ }
2774
+ function cloudSanitizeCountRecord(value) {
2775
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2776
+ const result = {};
2777
+ for (const [key, count] of Object.entries(value).slice(0, 200)) {
2778
+ if (/^[A-Za-z0-9_.:-]{1,80}$/.test(key) && Number.isSafeInteger(count) && count >= 0) result[key] = count;
2779
+ }
2780
+ return Object.keys(result).length ? result : undefined;
2781
+ }
2782
+ function cloudReplacePublicCareerCard(scan, card) {
2783
+ const relativePath = ".agentlas/public-career-card.json";
2784
+ const includedIndex = scan.included.findIndex((file) => file.path === relativePath);
2785
+ const existing = includedIndex >= 0 ? scan.included[includedIndex] : null;
2786
+ if (includedIndex >= 0) scan.included.splice(includedIndex, 1);
2787
+ const fileRecord = scan.files.find((file) => file.path === relativePath);
2788
+ if (!card) {
2789
+ if (fileRecord) { fileRecord.included = false; fileRecord.reason = "public-career-card-blocked"; }
2790
+ return;
2791
+ }
2792
+ const bytes = Buffer.from(JSON.stringify(card, null, 2) + "\n", "utf8");
2793
+ const replacement = { path: relativePath, bytes: bytes.length, sha256: sha(bytes), contentBase64: bytes.toString("base64"), executable: false };
2794
+ scan.included.push(replacement);
2795
+ scan.included.sort(cloudCodePointPathOrder);
2796
+ scan.totalBytes += bytes.length - (existing?.bytes || 0);
2797
+ if (fileRecord) Object.assign(fileRecord, { bytes: bytes.length, sha256: replacement.sha256, kind: "text", executable: false, included: true, reason: undefined });
2798
+ else scan.files.push({ path: relativePath, bytes: bytes.length, sha256: replacement.sha256, kind: "text", executable: false, included: true });
2799
+ }
2800
+ function cloudReadName(snapshot, fallbackName) {
2801
+ const manifest = cloudReadPackageJson(snapshot);
1336
2802
  const explicit = stringFirstCli(
1337
2803
  manifest.agentlas?.displayName,
1338
2804
  manifest.agentlas?.name,
@@ -1341,12 +2807,12 @@ function cloudReadName(rootPath) {
1341
2807
  manifest.routingCard?.name,
1342
2808
  );
1343
2809
  if (explicit) return explicit.replace(/\s+/g, " ").trim().slice(0, 80);
1344
- const text = cloudReadFirst(rootPath, ["agent.md", "AGENT.md", "README.md", "CLAUDE.md", "AGENTS.md"], 2000);
2810
+ const text = cloudReadFirst(snapshot, ["agent.md", "AGENT.md", "README.md", "CLAUDE.md", "AGENTS.md"], 2000);
1345
2811
  const heading = text.match(/^#\s+(.+)$/m);
1346
- return (heading ? heading[1] : path.basename(rootPath)).replace(/\s+/g, " ").trim().slice(0, 80);
2812
+ return (heading ? heading[1] : fallbackName).replace(/\s+/g, " ").trim().slice(0, 80);
1347
2813
  }
1348
- function cloudReadTagline(rootPath) {
1349
- const manifest = cloudReadPackageJson(rootPath);
2814
+ function cloudReadTagline(snapshot) {
2815
+ const manifest = cloudReadPackageJson(snapshot);
1350
2816
  const explicit = stringFirstCli(
1351
2817
  manifest.agentlas?.summary,
1352
2818
  manifest.agentlas?.description,
@@ -1355,15 +2821,15 @@ function cloudReadTagline(rootPath) {
1355
2821
  manifest.routingCard?.summary,
1356
2822
  );
1357
2823
  if (explicit) return explicit.replace(/\s+/g, " ").trim().slice(0, 160);
1358
- const text = cloudReadFirst(rootPath, ["README.md", "agent.md", "AGENT.md"], 3000);
2824
+ const text = cloudReadFirst(snapshot, ["README.md", "agent.md", "AGENT.md"], 3000);
1359
2825
  for (const line of text.split(/\r?\n/)) {
1360
2826
  const t = line.trim();
1361
2827
  if (t && !t.startsWith("#") && !t.startsWith(">")) return t.slice(0, 160);
1362
2828
  }
1363
2829
  return "Portable Agentlas cloud agent package.";
1364
2830
  }
1365
- function cloudReadStableSlug(rootPath) {
1366
- const manifest = cloudReadPackageJson(rootPath);
2831
+ function cloudReadStableSlug(snapshot) {
2832
+ const manifest = cloudReadPackageJson(snapshot);
1367
2833
  return stringFirstCli(
1368
2834
  manifest.agentlas?.slug,
1369
2835
  manifest.agentlas?.id,
@@ -1374,52 +2840,321 @@ function cloudReadStableSlug(rootPath) {
1374
2840
  manifest.routingCard?.agent_card_ref?.slug,
1375
2841
  );
1376
2842
  }
1377
- function cloudReadPackageJson(rootPath) {
2843
+ function cloudReadPackageJson(snapshot) {
1378
2844
  return {
1379
- agentlas: readJsonObjectCli(path.join(rootPath, "agentlas.json"), {}),
1380
- manifest: readJsonObjectCli(path.join(rootPath, "manifest.json"), {}),
1381
- agentCard: readJsonObjectCli(path.join(rootPath, ".agentlas", "agent-card.json"), {}),
1382
- routingCard: readJsonObjectCli(path.join(rootPath, ".agentlas", "routing-card.json"), {}),
2845
+ agentlas: cloudReadSnapshotJson(snapshot, "agentlas.json"),
2846
+ manifest: cloudReadSnapshotJson(snapshot, "manifest.json"),
2847
+ agentCard: cloudReadSnapshotJson(snapshot, ".agentlas/agent-card.json"),
2848
+ routingCard: cloudReadSnapshotJson(snapshot, ".agentlas/routing-card.json"),
1383
2849
  };
1384
2850
  }
2851
+ function cloudReadSnapshotJson(snapshot, relativePath) {
2852
+ try {
2853
+ const parsed = JSON.parse(cloudReadSnapshotText(snapshot, relativePath));
2854
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2855
+ } catch { return {}; }
2856
+ }
1385
2857
  function stringFirstCli(...values) {
1386
2858
  for (const value of values) {
1387
2859
  if (typeof value === "string" && value.trim()) return value.trim();
1388
2860
  }
1389
- return "";
2861
+ return "";
2862
+ }
2863
+ function cloudReadFirst(snapshot, names, maxChars) {
2864
+ for (const name of names) {
2865
+ const text = cloudReadSnapshotText(snapshot, name);
2866
+ if (text) return text.slice(0, maxChars);
2867
+ }
2868
+ return "";
2869
+ }
2870
+ function cloudReadSnapshotText(snapshot, relativePath) {
2871
+ const file = snapshot.get(relativePath);
2872
+ return file ? Buffer.from(file.contentBase64, "base64").toString("utf8") : "";
2873
+ }
2874
+ function cloudInferKind(snapshot) {
2875
+ const paths = [...snapshot.keys()];
2876
+ if (paths.some((file) => file === "TEAM.md" || file === "team.json" || /^(?:agents|team|departments|hr-departments)\//.test(file))) return "team";
2877
+ return "agent";
2878
+ }
2879
+ function cloudDetectRuntimeLabels(snapshot) {
2880
+ const paths = new Set(snapshot.keys());
2881
+ const labels = [];
2882
+ if (paths.has("CLAUDE.md") || [...paths].some((file) => file.startsWith(".claude/"))) labels.push("claude-code");
2883
+ if (paths.has("AGENTS.md")) labels.push("codex");
2884
+ if (paths.has("GEMINI.md")) labels.push("gemini");
2885
+ if (paths.has(".cursorrules") || [...paths].some((file) => file.startsWith(".cursor/"))) labels.push("cursor");
2886
+ return labels.length ? labels : ["generic"];
2887
+ }
2888
+ function cloudPackageDir(slug) {
2889
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2890
+ return path.join(userDataDir(), "cloud-agent-packages", `${slug}-${stamp}`);
2891
+ }
2892
+ function cloudPackageHashVersion(value) {
2893
+ if (value === undefined || value === null || value === "") return CLOUD_PACKAGE_HASH_V1;
2894
+ if (value === CLOUD_PACKAGE_HASH_V1 || value === CLOUD_PACKAGE_HASH_V2) return value;
2895
+ return null;
2896
+ }
2897
+ function cloudHashPackage(files, version = CLOUD_PACKAGE_HASH_V1) {
2898
+ const hashVersion = cloudPackageHashVersion(version);
2899
+ if (!hashVersion) throw new Error(`unsupported cloud package hash version: ${version}`);
2900
+ const h = crypto.createHash("sha256");
2901
+ // 서버 package-contract.ts와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
2902
+ // 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
2903
+ // 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
2904
+ for (const file of [...files].sort(cloudCodePointPathOrder)) {
2905
+ h.update(file.path);
2906
+ h.update("\0");
2907
+ h.update(file.sha256);
2908
+ h.update("\0");
2909
+ if (hashVersion === CLOUD_PACKAGE_HASH_V2) {
2910
+ h.update(file.executable ? "x" : "-");
2911
+ h.update("\0");
2912
+ }
2913
+ }
2914
+ return h.digest("hex");
2915
+ }
2916
+ function cloudCodePointPathOrder(a, b) {
2917
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
2918
+ }
2919
+ function cloudPortablePathKey(value) {
2920
+ return String(value).normalize("NFC").toLowerCase();
2921
+ }
2922
+ function cloudPortableRelativePath(value) {
2923
+ if (typeof value !== "string" || !value || value !== value.normalize("NFC")) return null;
2924
+ if (value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.endsWith("/")) return null;
2925
+ if (value.includes("//") || value.length > 260) return null;
2926
+ const parts = value.split("/");
2927
+ for (const part of parts) {
2928
+ if (!part || part === "." || part === "..") return null;
2929
+ if (part.length > 255 || Buffer.byteLength(part, "utf8") > 255 || cloudHasUnpairedSurrogate(part)) return null;
2930
+ if (/[<>:"|?*\u0000-\u001f]/.test(part) || /[ .]$/.test(part)) return null;
2931
+ if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) return null;
2932
+ }
2933
+ return value;
2934
+ }
2935
+ function cloudHasUnpairedSurrogate(value) {
2936
+ for (let index = 0; index < value.length; index++) {
2937
+ const unit = value.charCodeAt(index);
2938
+ if (unit >= 0xd800 && unit <= 0xdbff) {
2939
+ const next = value.charCodeAt(index + 1);
2940
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
2941
+ index++;
2942
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) return true;
2943
+ }
2944
+ return false;
2945
+ }
2946
+ function cloudPortablePathConflict(paths) {
2947
+ const files = new Map();
2948
+ const directories = new Map();
2949
+ for (const value of paths) {
2950
+ if (typeof value !== "string" || !value) continue;
2951
+ const fileKey = cloudPortablePathKey(value);
2952
+ const existingFile = files.get(fileKey);
2953
+ if (existingFile) {
2954
+ if (existingFile.path === value) {
2955
+ return { code: "duplicate-path", message: `Cloud package repeats file path ${JSON.stringify(value)}.` };
2956
+ }
2957
+ return { code: "path-alias-collision", message: `Cloud package paths ${JSON.stringify(existingFile.path)} and ${JSON.stringify(value)} alias after Unicode NFC normalization and case-folding.` };
2958
+ }
2959
+ files.set(fileKey, { path: value });
2960
+ const parts = value.split("/");
2961
+ for (let index = 1; index < parts.length; index++) {
2962
+ const directory = parts.slice(0, index).join("/");
2963
+ const directoryKey = cloudPortablePathKey(directory);
2964
+ const existingDirectory = directories.get(directoryKey);
2965
+ if (existingDirectory && existingDirectory.directory !== directory) {
2966
+ return {
2967
+ code: "path-alias-collision",
2968
+ message: `Ancestor directories ${JSON.stringify(existingDirectory.directory)} (from ${JSON.stringify(existingDirectory.sourcePath)}) and ${JSON.stringify(directory)} (from ${JSON.stringify(value)}) alias after Unicode NFC normalization and case-folding.`,
2969
+ };
2970
+ }
2971
+ if (!existingDirectory) directories.set(directoryKey, { directory, sourcePath: value });
2972
+ }
2973
+ }
2974
+ for (const [key, file] of files) {
2975
+ const directory = directories.get(key);
2976
+ if (!directory) continue;
2977
+ if (file.path === directory.directory) {
2978
+ return { code: "path-type-collision", message: `Cloud package path ${JSON.stringify(file.path)} is both a file and an ancestor directory.` };
2979
+ }
2980
+ return {
2981
+ code: "path-alias-collision",
2982
+ message: `File path ${JSON.stringify(file.path)} aliases ancestor directory ${JSON.stringify(directory.directory)} from ${JSON.stringify(directory.sourcePath)} after Unicode NFC normalization and case-folding.`,
2983
+ };
2984
+ }
2985
+ return null;
2986
+ }
2987
+ function cloudReadRestoreExecutablePaths(rootPath) {
2988
+ if (process.platform !== "win32") return new Set();
2989
+ const marker = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
2990
+ try {
2991
+ const parsed = JSON.parse(fs.readFileSync(marker, "utf8"));
2992
+ if (cloudPackageHashVersion(parsed.packageHashVersion) !== CLOUD_PACKAGE_HASH_V2) return new Set();
2993
+ if (!Array.isArray(parsed.executablePaths)) return new Set();
2994
+ return new Set(parsed.executablePaths
2995
+ .filter((value) => cloudPortableRelativePath(value))
2996
+ .map((value) => cloudPortablePathKey(value)));
2997
+ } catch {
2998
+ return new Set();
2999
+ }
3000
+ }
3001
+ function cloudPortableExecutableForFile(relativePath, statMode, restoredExecutablePaths, platform = process.platform) {
3002
+ if (platform === "win32") return restoredExecutablePaths.has(cloudPortablePathKey(relativePath));
3003
+ return Boolean(statMode & 0o111);
3004
+ }
3005
+ function cloudApplyPrivateDirectoryMode(directoryPath, platform = process.platform) {
3006
+ if (platform === "win32") return;
3007
+ fs.chmodSync(directoryPath, 0o700);
3008
+ const actual = fs.statSync(directoryPath).mode & 0o777;
3009
+ if (actual !== 0o700) throw new Error(`cloud restore directory mode verification failed: ${directoryPath}`);
3010
+ }
3011
+ function cloudApplyPortableFileMode(filePath, mode, platform = process.platform) {
3012
+ if (platform === "win32") return;
3013
+ fs.chmodSync(filePath, mode);
3014
+ const actual = fs.statSync(filePath).mode & 0o777;
3015
+ if (actual !== mode) throw new Error(`cloud restore file mode verification failed: ${filePath}`);
3016
+ }
3017
+ function cloudVerifyRestoredSnapshot(root, files, expected) {
3018
+ const expectedByPath = new Map(files.map((file) => [file.path, file]));
3019
+ const seen = new Set();
3020
+ function walk(dir) {
3021
+ const dirStat = fs.lstatSync(dir);
3022
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error("cloud restore staging contains an unsafe directory");
3023
+ if (process.platform !== "win32" && (dirStat.mode & 0o777) !== 0o700) throw new Error("cloud restore staging directory mode mismatch");
3024
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
3025
+ const absolute = path.join(dir, entry.name);
3026
+ const relative = path.relative(root, absolute).split(path.sep).join("/");
3027
+ if (relative === CLOUD_RESTORE_MARKER_PATH) continue;
3028
+ const stat = fs.lstatSync(absolute);
3029
+ if (stat.isSymbolicLink()) throw new Error("cloud restore staging contains a symbolic link");
3030
+ if (stat.isDirectory()) { walk(absolute); continue; }
3031
+ if (!stat.isFile()) throw new Error("cloud restore staging contains a special filesystem entry");
3032
+ const expectedFile = expectedByPath.get(relative);
3033
+ if (!expectedFile || seen.has(relative)) throw new Error(`cloud restore staging has an unexpected file: ${relative}`);
3034
+ const bytes = fs.readFileSync(absolute);
3035
+ if (bytes.length !== expectedFile.bytes || sha(bytes) !== expectedFile.sha256) {
3036
+ throw new Error(`cloud restore staging file integrity mismatch: ${relative}`);
3037
+ }
3038
+ if (process.platform !== "win32") {
3039
+ const mode = expected.packageHashVersion === CLOUD_PACKAGE_HASH_V2 && expectedFile.executable ? 0o700 : 0o600;
3040
+ if ((stat.mode & 0o777) !== mode) throw new Error(`cloud restore staging file mode mismatch: ${relative}`);
3041
+ }
3042
+ seen.add(relative);
3043
+ }
3044
+ }
3045
+ walk(root);
3046
+ if (seen.size !== expectedByPath.size) throw new Error("cloud restore staging is missing package files");
3047
+ const markerPath = path.join(root, CLOUD_RESTORE_MARKER_PATH);
3048
+ const markerStat = fs.lstatSync(markerPath);
3049
+ if (!markerStat.isFile() || markerStat.isSymbolicLink()) throw new Error("cloud restore marker is unsafe");
3050
+ if (process.platform !== "win32" && (markerStat.mode & 0o777) !== 0o600) throw new Error("cloud restore marker mode mismatch");
3051
+ const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
3052
+ const expectedExecutablePaths = expected.packageHashVersion === CLOUD_PACKAGE_HASH_V2
3053
+ ? files.filter((file) => file.executable).map((file) => file.path).sort()
3054
+ : undefined;
3055
+ if (
3056
+ marker.schemaVersion !== 1 || marker.source !== "agentlas-cloud" || marker.slug !== expected.slug ||
3057
+ String(marker.packageHash).replace(/^sha256:/i, "").toLowerCase() !== expected.packageHash ||
3058
+ marker.packageHashVersion !== expected.packageHashVersion || marker.fileCount !== files.length ||
3059
+ marker.totalBytes !== expected.totalBytes || typeof marker.restoredAt !== "string" ||
3060
+ !Number.isFinite(Date.parse(marker.restoredAt)) ||
3061
+ JSON.stringify(marker.executablePaths) !== JSON.stringify(expectedExecutablePaths)
3062
+ ) {
3063
+ throw new Error("cloud restore marker contract mismatch");
3064
+ }
3065
+ if (expected.assetDescriptor) {
3066
+ const descriptor = normalizeCloudAssetDescriptorCli(marker, "cloud restore marker");
3067
+ const nested = normalizeCloudAssetDescriptorCli(marker.cloudAssets?.[descriptor.scope], "cloud restore marker scope");
3068
+ if (
3069
+ JSON.stringify(descriptor) !== JSON.stringify(expected.assetDescriptor) ||
3070
+ JSON.stringify(nested) !== JSON.stringify(expected.assetDescriptor)
3071
+ ) {
3072
+ throw new Error("cloud restore marker revision contract mismatch");
3073
+ }
3074
+ }
1390
3075
  }
1391
- function cloudReadFirst(rootPath, names, maxChars) {
1392
- for (const name of names) {
1393
- const file = path.join(rootPath, name);
1394
- try {
1395
- const stat = fs.statSync(file);
1396
- if (stat.isFile() && stat.size <= CLOUD_MAX_FILE_BYTES) return fs.readFileSync(file, "utf8").slice(0, maxChars);
1397
- } catch { /* continue */ }
3076
+ function cloudDecodeUtf16CredentialText(bytes) {
3077
+ if (bytes.length < 4) return null;
3078
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
3079
+ return bytes.subarray(2, bytes.length - ((bytes.length - 2) % 2)).toString("utf16le");
3080
+ }
3081
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) {
3082
+ const body = Buffer.from(bytes.subarray(2, bytes.length - ((bytes.length - 2) % 2)));
3083
+ body.swap16();
3084
+ return body.toString("utf16le");
3085
+ }
3086
+ const sampleLength = Math.min(bytes.length - (bytes.length % 2), 4096);
3087
+ if (sampleLength < 8) return null;
3088
+ let oddNuls = 0;
3089
+ let evenNuls = 0;
3090
+ for (let index = 0; index < sampleLength; index += 2) {
3091
+ if (bytes[index] === 0) evenNuls++;
3092
+ if (bytes[index + 1] === 0) oddNuls++;
3093
+ }
3094
+ const pairs = sampleLength / 2;
3095
+ const fullLength = bytes.length - (bytes.length % 2);
3096
+ if (oddNuls / pairs > 0.3) return bytes.subarray(0, fullLength).toString("utf16le");
3097
+ if (evenNuls / pairs > 0.3) {
3098
+ const body = Buffer.from(bytes.subarray(0, fullLength));
3099
+ body.swap16();
3100
+ return body.toString("utf16le");
1398
3101
  }
1399
- return "";
3102
+ return null;
1400
3103
  }
1401
- function cloudInferKind(rootPath) {
1402
- for (const name of ["TEAM.md", "team.json", "agents", "team", "departments", "hr-departments"]) {
1403
- if (fs.existsSync(path.join(rootPath, name))) return "team";
3104
+ function cloudDecodeTextAsset(bytes) {
3105
+ const utf16 = cloudDecodeUtf16CredentialText(bytes);
3106
+ if (utf16 !== null) return { ok: true, text: utf16 };
3107
+ try {
3108
+ return { ok: true, text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
3109
+ } catch {
3110
+ return { ok: false };
1404
3111
  }
1405
- return "agent";
1406
3112
  }
1407
- function cloudPackageDir(slug) {
1408
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1409
- return path.join(userDataDir(), "cloud-agent-packages", `${slug}-${stamp}`);
3113
+ function cloudCredentialValueLooksReal(rawValue) {
3114
+ let value = String(rawValue || "").trim().replace(/^['"]|['"]$/g, "").trim();
3115
+ try { value = decodeURIComponent(value); } catch { /* keep raw */ }
3116
+ if (value.length < 8) return false;
3117
+ if (/^(?:\$\{[^}]+\}|\$[A-Z_][A-Z0-9_]*|\{\{[^}]+\}\}|<[^>]+>)$/i.test(value)) return false;
3118
+ if (/^(?:process\.env\.|os\.environ|env\(|secret\(|vault:)/i.test(value)) return false;
3119
+ const compact = value.toLowerCase().replace(/[^a-z0-9]+/g, "");
3120
+ if (/^(?:your|example|sample|dummy|placeholder|configure|configureonthismachine|changeme|replaceme|replacewith|redacted|masked|notareal|none|null|undefined|x+|star+)(?:api)?(?:key|secret|token|password)?(?:here)?$/.test(compact)) return false;
3121
+ if (/^(?:\*+|x+|_+|-+)$/.test(value)) return false;
3122
+ return true;
1410
3123
  }
1411
- function cloudHashPackage(files) {
1412
- const h = crypto.createHash("sha256");
1413
- // 서버(register/route.ts hashPackage)와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
1414
- // 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
1415
- // 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
1416
- for (const file of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
1417
- h.update(file.path);
1418
- h.update("\0");
1419
- h.update(file.sha256);
1420
- h.update("\0");
3124
+ function cloudTextContainsStructuredCredential(text) {
3125
+ const assignment = /(?:^|\n)\s*["']?(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret|token|password|passwd|pwd)["']?\s*[:=]\s*([^\r\n#;]+)/gi;
3126
+ for (const match of text.matchAll(assignment)) {
3127
+ if (cloudCredentialValueLooksReal(match[1])) return true;
1421
3128
  }
1422
- return h.digest("hex");
3129
+ const urlCredential = /\bhttps?:\/\/[^/\s:@]+:([^@\s/]{8,})@/gi;
3130
+ for (const match of text.matchAll(urlCredential)) {
3131
+ if (cloudCredentialValueLooksReal(match[1])) return true;
3132
+ }
3133
+ const queryCredential = /[?&](?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret|token|password)=([^&#\s]+)/gi;
3134
+ for (const match of text.matchAll(queryCredential)) {
3135
+ if (cloudCredentialValueLooksReal(match[1])) return true;
3136
+ }
3137
+ return false;
3138
+ }
3139
+ function cloudAddSecretFindingsFromBytes(bytes, relativePath, addFinding) {
3140
+ const candidates = new Set([bytes.toString("utf8")]);
3141
+ const utf16 = cloudDecodeUtf16CredentialText(bytes);
3142
+ if (utf16) candidates.add(utf16);
3143
+ for (const text of candidates) {
3144
+ for (const [id, re, label] of CLOUD_SECRET_RE) {
3145
+ if (re.test(text)) addFinding(id, "blocker", "secret", `Possible ${label} found in package content.`, relativePath, "Remove the value and require users to configure their own key.");
3146
+ }
3147
+ if (cloudTextContainsStructuredCredential(text)) {
3148
+ addFinding("generic-unquoted-secret", "blocker", "secret", "Possible unquoted or URL-embedded credential found in package content.", relativePath, "Replace the value with an environment/BYOK placeholder.");
3149
+ }
3150
+ }
3151
+ }
3152
+ function cloudCanonicalBase64(value) {
3153
+ if (typeof value !== "string") return false;
3154
+ if (value === "") return true;
3155
+ if (value.length % 4 !== 0) return false;
3156
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false;
3157
+ return Buffer.from(value, "base64").toString("base64") === value;
1423
3158
  }
1424
3159
  function cloudSecuritySummary(findings) {
1425
3160
  const blockerCount = findings.filter((f) => f.severity === "blocker").length;
@@ -5958,67 +7693,154 @@ function resolveRuntime(db, override) {
5958
7693
 
5959
7694
  // ── API 러너 (BYOK / Ollama) — 비스트리밍, 최종 텍스트 반환 ──
5960
7695
  const DEFAULT_API_MODEL = {
5961
- anthropic: "claude-sonnet-4-5",
7696
+ anthropic: "claude-sonnet-4-6",
5962
7697
  openai: "gpt-4o-mini",
5963
7698
  google: "gemini-1.5-flash",
5964
7699
  ollama: "llama3.1",
5965
7700
  upstage: "solar-pro2",
7701
+ custom: "deepseek-chat",
7702
+ glm: "glm-4.6",
7703
+ kimi: "kimi-k2-0711-preview",
7704
+ deepseek: "deepseek-chat",
5966
7705
  };
7706
+ const ANTHROPIC_COMPAT_API = {
7707
+ glm: { label: "GLM", baseUrl: "https://api.z.ai/api/anthropic" },
7708
+ kimi: { label: "Kimi", baseUrl: "https://api.moonshot.ai/anthropic" },
7709
+ deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic" },
7710
+ };
7711
+ const DEFAULT_CUSTOM_API_BASE_URL = "https://api.openai.com/v1";
7712
+
5967
7713
  async function apiKey(backend) {
5968
7714
  const keytar = readKeytar();
5969
7715
  if (!keytar) return null;
5970
7716
  // 키체인 접근 거부(서명 안 된 standalone Node)는 "키 없음"으로 조용히 처리.
5971
7717
  return keytar.getPassword(SERVICE, "byok:" + backend).catch(() => null);
5972
7718
  }
5973
- async function runApi(backend, model, system, prompt) {
7719
+
7720
+ /**
7721
+ * Custom BYOK 키가 전송될 origin을 Terminal에서도 다시 검증한다.
7722
+ * Desktop IPC와 동일하게 공개 주소는 HTTPS만, HTTP는 localhost/LAN만 허용한다.
7723
+ */
7724
+ function normalizeCustomApiBaseUrl(raw) {
7725
+ const value = String(raw || "").trim();
7726
+ if (!value) return DEFAULT_CUSTOM_API_BASE_URL;
7727
+ let parsed;
7728
+ try {
7729
+ parsed = new URL(value);
7730
+ } catch {
7731
+ throw new Error("Custom API base URL이 올바르지 않습니다.");
7732
+ }
7733
+ const host = parsed.hostname.toLowerCase();
7734
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
7735
+ const isPrivateLan =
7736
+ /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
7737
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (isLoopback || isPrivateLan))) {
7738
+ throw new Error("Custom API base URL은 HTTPS 또는 localhost/LAN의 HTTP여야 합니다.");
7739
+ }
7740
+ return value.replace(/\/+$/, "");
7741
+ }
7742
+
7743
+ /** Desktop과 공유하는 SQLite meta에서 Custom OpenAI base URL을 읽는다. */
7744
+ function readCustomApiBaseUrl() {
7745
+ const p = dbPath();
7746
+ if (!fs.existsSync(p)) return DEFAULT_CUSTOM_API_BASE_URL;
7747
+ let db = null;
7748
+ let raw = "";
7749
+ try {
7750
+ try {
7751
+ const Database = require("better-sqlite3");
7752
+ db = new Database(p, { readonly: true, fileMustExist: true });
7753
+ } catch {
7754
+ db = openNodeSqliteDb(p);
7755
+ }
7756
+ try {
7757
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'custom_base_url'").get();
7758
+ raw = row && row.value ? row.value : "";
7759
+ } catch {
7760
+ // 구버전 DB에 meta 테이블/키가 없으면 Desktop과 동일하게 OpenAI 기본 URL.
7761
+ raw = "";
7762
+ }
7763
+ } catch (e) {
7764
+ throw new Error(`Custom API base URL을 공유 DB에서 읽지 못했습니다: ${(e && e.message) || e}`);
7765
+ } finally {
7766
+ try { if (db && typeof db.close === "function") db.close(); } catch { /* ignore close failure */ }
7767
+ }
7768
+ return normalizeCustomApiBaseUrl(raw);
7769
+ }
7770
+
7771
+ /**
7772
+ * BYOK/Ollama 한 턴. 재사용 경로(swarm/automation)이므로 절대 process.exit하지 않고
7773
+ * 오류를 throw해 호출자의 catch/finally가 리스 해제·부분 실패를 처리하게 한다.
7774
+ * options는 회귀 테스트의 fetch/키 주입용이며 상용 호출자는 사용하지 않는다.
7775
+ */
7776
+ async function runApi(backend, model, system, prompt, options) {
7777
+ options = options || {};
5974
7778
  model = model || DEFAULT_API_MODEL[backend];
5975
- if (typeof fetch !== "function") fail("이 런타임에 fetch 없습니다(앱 런타임으로 실행 필요).");
7779
+ const fetchImpl = options.fetch || globalThis.fetch;
7780
+ if (typeof fetchImpl !== "function") throw new Error("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
5976
7781
  if (backend === "ollama") {
5977
- const resp = await fetch("http://127.0.0.1:11434/api/chat", {
7782
+ const resp = await fetchImpl("http://127.0.0.1:11434/api/chat", {
5978
7783
  method: "POST",
5979
7784
  headers: { "content-type": "application/json" },
5980
7785
  body: JSON.stringify({ model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
5981
7786
  });
5982
- if (!resp.ok) fail(`Ollama ${resp.status} — 'ollama serve' 실행/모델 확인`);
7787
+ if (!resp.ok) throw new Error(`Ollama ${resp.status} — 'ollama serve' 실행/모델 확인`);
5983
7788
  const j = await resp.json();
5984
7789
  return (j.message && j.message.content) || "";
5985
7790
  }
5986
- const key = await apiKey(backend);
5987
- if (!key) fail(`${backend} API 키가 없습니다. 설정 BYOK에서 키를 등록하세요.`);
5988
- if (backend === "anthropic") {
5989
- const resp = await fetch("https://api.anthropic.com/v1/messages", {
7791
+ const supported = backend === "anthropic" || backend === "openai" || backend === "google" ||
7792
+ backend === "upstage" || backend === "custom" || !!ANTHROPIC_COMPAT_API[backend];
7793
+ if (!supported) throw new Error("지원하지 않는 backend: " + backend);
7794
+ const key = Object.prototype.hasOwnProperty.call(options, "apiKey") ? options.apiKey : await apiKey(backend);
7795
+ if (!key) throw new Error(`${backend} API 키가 없습니다. 앱 설정 → BYOK에서 키를 등록하세요.`);
7796
+
7797
+ const anthropicCompat = ANTHROPIC_COMPAT_API[backend];
7798
+ if (backend === "anthropic" || anthropicCompat) {
7799
+ const label = anthropicCompat ? anthropicCompat.label : "Anthropic";
7800
+ const base = anthropicCompat ? anthropicCompat.baseUrl : "https://api.anthropic.com";
7801
+ const authHeaders = anthropicCompat
7802
+ ? { "x-api-key": key, authorization: "Bearer " + key }
7803
+ : { "x-api-key": key };
7804
+ const resp = await fetchImpl(`${base}/v1/messages`, {
5990
7805
  method: "POST",
5991
- headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
7806
+ headers: { "content-type": "application/json", ...authHeaders, "anthropic-version": "2023-06-01" },
5992
7807
  body: JSON.stringify({ model, max_tokens: 4096, system, messages: [{ role: "user", content: prompt }] }),
5993
7808
  });
5994
- if (!resp.ok) fail(`Anthropic ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
7809
+ if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
5995
7810
  const j = await resp.json();
5996
7811
  return (j.content && j.content[0] && j.content[0].text) || "";
5997
7812
  }
5998
- if (backend === "openai" || backend === "upstage") {
5999
- const base = backend === "upstage" ? "https://api.upstage.ai/v1" : "https://api.openai.com/v1";
6000
- const resp = await fetch(`${base}/chat/completions`, {
7813
+ if (backend === "openai" || backend === "upstage" || backend === "custom") {
7814
+ const base = backend === "upstage"
7815
+ ? "https://api.upstage.ai/v1"
7816
+ : backend === "custom"
7817
+ ? normalizeCustomApiBaseUrl(Object.prototype.hasOwnProperty.call(options, "customBaseUrl")
7818
+ ? options.customBaseUrl
7819
+ : readCustomApiBaseUrl())
7820
+ : "https://api.openai.com/v1";
7821
+ const label = backend === "custom" ? "Custom API" : backend === "upstage" ? "Upstage" : "OpenAI";
7822
+ const resp = await fetchImpl(`${base}/chat/completions`, {
6001
7823
  method: "POST",
6002
7824
  headers: { "content-type": "application/json", authorization: "Bearer " + key },
6003
7825
  body: JSON.stringify({ model, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
6004
7826
  });
6005
- if (!resp.ok) fail(`OpenAI ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
7827
+ if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
6006
7828
  const j = await resp.json();
6007
7829
  return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
6008
7830
  }
6009
7831
  if (backend === "google") {
6010
7832
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`;
6011
- const resp = await fetch(url, {
7833
+ const resp = await fetchImpl(url, {
6012
7834
  method: "POST",
6013
7835
  headers: { "content-type": "application/json" },
6014
7836
  body: JSON.stringify({ systemInstruction: { parts: [{ text: system }] }, contents: [{ role: "user", parts: [{ text: prompt }] }] }),
6015
7837
  });
6016
- if (!resp.ok) fail(`Google ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
7838
+ if (!resp.ok) throw new Error(`Google ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
6017
7839
  const j = await resp.json();
6018
7840
  const c = j.candidates && j.candidates[0];
6019
7841
  return (c && c.content && c.content.parts && c.content.parts[0] && c.content.parts[0].text) || "";
6020
7842
  }
6021
- fail("지원하지 않는 backend: " + backend);
7843
+ throw new Error("지원하지 않는 backend: " + backend);
6022
7844
  }
6023
7845
 
6024
7846
  // 1회 실행 — CLI면 spawn(스트리밍 stdout), API면 호출 후 텍스트 출력. 종료코드 반환.
@@ -6039,7 +7861,7 @@ async function executeOnce(db, system, prompt, override, ctx) {
6039
7861
  const { Ui } = require("./agentlas-ui.cjs");
6040
7862
  const ui = new Ui({ lang: prefsLang() });
6041
7863
  let mcpServers = [];
6042
- if (permission !== "read") {
7864
+ if (permission === "full") {
6043
7865
  try {
6044
7866
  mcpServers = db.prepare("SELECT id, name, transport, command, args_json, enabled FROM mcp_servers WHERE enabled=1 AND transport='stdio'").all();
6045
7867
  } catch { /* ignore */ }
@@ -6134,25 +7956,10 @@ function runCwd() {
6134
7956
  }
6135
7957
 
6136
7958
  function cliMcpConfigPath() {
6137
- const dir = path.join(userDataDir(), "mcp");
6138
- fs.mkdirSync(dir, { recursive: true });
6139
- const file = path.join(dir, "agentlas-cli-mcp.json");
6140
- fs.writeFileSync(
6141
- file,
6142
- JSON.stringify({
6143
- mcpServers: {
6144
- playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
6145
- },
6146
- }, null, 2),
6147
- "utf8",
6148
- );
6149
- return file;
7959
+ return require("./agentlas-native-host.cjs").cliMcpConfigPath([]).file;
6150
7960
  }
6151
7961
 
6152
- const CODEX_PLAYWRIGHT_MCP_ARGS = [
6153
- "-c", 'mcp_servers.playwright.command="npx"',
6154
- "-c", 'mcp_servers.playwright.args=["-y","@playwright/mcp@latest"]',
6155
- ];
7962
+ const CODEX_PLAYWRIGHT_MCP_ARGS = require("./agentlas-native-host.cjs").codexMcpArgs([]);
6156
7963
 
6157
7964
  // 에이전트가 실제로 실행될 작업 폴더 = 사용자가 명령을 친 현재 디렉터리(= 대상 프로젝트).
6158
7965
  // 단, home/userData/agent-cwd 같은 "프로젝트 아님" 위치면 안전한 전용 폴더로 폴백한다.
@@ -6231,14 +8038,40 @@ function readVaultEnvValuesCli(keys, projectPath) {
6231
8038
  ),
6232
8039
  ).then(() => result);
6233
8040
  }
8041
+
8042
+ // 프로젝트/에이전트 dotenv는 일반 API 키 우선순위를 유지하되, 호스트 CLI의 신원·설치·
8043
+ // 플러그인 탐색 루트는 바꾸지 못한다. Windows 환경변수도 안전하게 대소문자 무관 비교한다.
8044
+ const PROTECTED_CHILD_ENV_KEYS_CLI = new Set([
8045
+ "HOME", "PATH", "PATHEXT", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA",
8046
+ "XDG_CONFIG_HOME", "XDG_DATA_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_SAFE_MODE",
8047
+ "AGENTLAS_CODEX_HOME", "AGENTLAS_USER_DATA_DIR",
8048
+ "CLAUDE_CODE_SIMPLE", "CLAUDE_PLUGIN_ROOT", "CLAUDE_PLUGIN_DATA", "CLAUDE_PROJECT_DIR",
8049
+ "GEMINI_CLI_HOME", "GEMINI_CLI_SYSTEM_SETTINGS_PATH", "GEMINI_CLI_USER_SETTINGS",
8050
+ "GEMINI_CLI_TRUSTED_FOLDERS_PATH", "GEMINI_CLI_TRUST_WORKSPACE", "GEMINI_CLI_EXTENSION_REGISTRY_URI",
8051
+ "HEPHAESTUS_RUNTIME_ROOT", "HEPHAESTUS_RUNTIME_BASE", "HEPHAESTUS_PYTHON", "HEPHAESTUS_AUTO_UPDATE",
8052
+ "HEPHAESTUS_UPDATE_CHECK", "NPM_CONFIG_PREFIX", "NODE_OPTIONS", "NODE_PATH",
8053
+ "PYTHONHOME", "PYTHONPATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH",
8054
+ "AGENTLAS_HUB_CONNECT_TIMEOUT_MS", "AGENTLAS_HUB_IDLE_TIMEOUT_MS", "AGENTLAS_HUB_TOTAL_TIMEOUT_MS",
8055
+ "AGENTLAS_NATIVE_IDLE_TIMEOUT_MS", "AGENTLAS_NATIVE_TOTAL_TIMEOUT_MS", "AGENTLAS_NATIVE_KILL_GRACE_MS",
8056
+ "AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES",
8057
+ ]);
8058
+ function isProtectedChildEnvKeyCli(key) {
8059
+ return PROTECTED_CHILD_ENV_KEYS_CLI.has(String(key || "").trim().toUpperCase());
8060
+ }
8061
+ function mergeChildEnvValuesCli(target, values, overwrite) {
8062
+ const injected = [];
8063
+ for (const [key, value] of Object.entries(values || {})) {
8064
+ if (!value || isProtectedChildEnvKeyCli(key)) continue;
8065
+ if (!overwrite && target[key]) continue;
8066
+ target[key] = value;
8067
+ injected.push(key);
8068
+ }
8069
+ return injected;
8070
+ }
6234
8071
  async function buildChildEnvCli(db, ctx) {
6235
8072
  const env = { ...process.env };
6236
8073
  const apply = (values, overwrite) => {
6237
- for (const [key, value] of Object.entries(values || {})) {
6238
- if (!value) continue;
6239
- if (!overwrite && env[key]) continue;
6240
- env[key] = value;
6241
- }
8074
+ mergeChildEnvValuesCli(env, values, overwrite);
6242
8075
  };
6243
8076
  const globalCredentials = {
6244
8077
  ...readDotEnvFileCli(path.join(userDataDir(), "credentials.env")),
@@ -6265,33 +8098,27 @@ async function buildChildEnvCli(db, ctx) {
6265
8098
  return env;
6266
8099
  }
6267
8100
 
6268
- // 권한 네이티브 CLI 권한 모드 매핑 (앱의 claude-code.ts 동일 의미).
6269
- // read=기본(헤드리스에서 위험 자동 거부) · write=편집 허용 · full=셸 포함 전체 자동.
8101
+ // One-shot/background capture uses the same permission truth as the interactive host.
8102
+ // Keep its plain-output argument shape, but never duplicate the security mapping here.
6270
8103
  function buildArgs(kind, systemPrompt, prompt, permission) {
8104
+ const native = require("./agentlas-native-host.cjs");
8105
+ const level = require("./agentlas-permissions.cjs").normalize(permission);
6271
8106
  if (kind === "claude-code") {
6272
- const perm =
6273
- permission === "full"
6274
- ? ["--permission-mode", "bypassPermissions"]
6275
- : permission === "write"
6276
- ? ["--permission-mode", "acceptEdits"]
6277
- : [];
6278
- const mcp = permission === "write" || permission === "full"
6279
- ? ["--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
6280
- : [];
8107
+ const perm = native.claudePermissionArgs(level);
8108
+ const mcp = level === "full"
8109
+ ? ["--strict-mcp-config", "--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
8110
+ : native.claudeMcpIsolationArgs();
6281
8111
  return ["-p", prompt, "--append-system-prompt", systemPrompt, ...perm, ...mcp];
6282
8112
  }
6283
8113
  if (kind === "codex") {
6284
- // codex exec: browser/account setup flows must not stall on approval prompts.
6285
- const perm =
6286
- permission === "full" || permission === "write"
6287
- ? ["--dangerously-bypass-approvals-and-sandbox"]
6288
- : ["--sandbox", "read-only", "--ask-for-approval", "never"];
6289
- const mcp = permission === "write" || permission === "full" ? CODEX_PLAYWRIGHT_MCP_ARGS : [];
8114
+ const perm = native.codexPermissionArgs(level);
8115
+ const mcp = level === "full" ? CODEX_PLAYWRIGHT_MCP_ARGS : [];
6290
8116
  return ["exec", "--skip-git-repo-check", ...perm, ...mcp, `[SYSTEM]\n${systemPrompt}\n\n${prompt}`];
6291
8117
  }
6292
8118
  if (kind === "gemini") {
6293
- const perm = permission === "full" || permission === "write" ? ["--yolo"] : [];
6294
- return ["--prompt", `[SYSTEM]\n${systemPrompt}\n\n${prompt}`, ...perm];
8119
+ const perm = native.geminiPermissionArgs(level);
8120
+ const mcp = level === "full" ? [] : native.geminiMcpIsolationArgs();
8121
+ return ["--prompt", `[SYSTEM]\n${systemPrompt}\n\n${prompt}`, ...perm, ...mcp];
6295
8122
  }
6296
8123
  return [prompt];
6297
8124
  }
@@ -6307,7 +8134,7 @@ function launchInteractive(db, agent, runtimeOverride) {
6307
8134
  id: agent.id,
6308
8135
  slug: agent.slug,
6309
8136
  label: agent.name,
6310
- system: agent.system_prompt || `You are ${agent.name}.`,
8137
+ system: agentSystemPromptCli(agent),
6311
8138
  capAgent: agent,
6312
8139
  };
6313
8140
  return launchTui(db, subject, runtimeOverride);
@@ -6336,9 +8163,10 @@ function buildHelpers(db) {
6336
8163
  autoRouteAgent: (db_, prompt, lang) => autoRouteAgent(db_, prompt, lang),
6337
8164
  autoRouteNote: (choice, lang) => autoRouteNote(choice, lang),
6338
8165
  autoRoutePreamble: (choice, lang) => autoRoutePreamble(choice, lang),
8166
+ directSystemPrompt: (lang) => directSystemPrompt(lang),
6339
8167
  cliMemoryContext: (db_, pp) => cliMemoryContext(db_, pp),
6340
8168
  importLocal: (db_, p) => importLocalFolderCli(db_, p),
6341
- // REPL-safe 마켓플레이스 설치: fail()(process.exit) 대신 Error를 throw 해 REPL이 직접 렌더하게 한다.
8169
+ // REPL-safe public Hub install: fail()(process.exit) 대신 Error를 throw 해 REPL이 직접 렌더하게 한다.
6342
8170
  cloudInstall: async (db_, slug) => {
6343
8171
  if (typeof fetch !== "function") throw new Error("이 런타임에 fetch가 없습니다(앱 런타임 필요).");
6344
8172
  const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
@@ -6347,22 +8175,25 @@ function buildHelpers(db) {
6347
8175
  if (cookie) headers.cookie = cookie;
6348
8176
  let resp;
6349
8177
  try {
6350
- resp = await fetch(`${base.replace(/\/$/, "")}/tools/call`, {
8178
+ resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
6351
8179
  method: "POST",
6352
8180
  headers,
6353
8181
  body: JSON.stringify({ method: "marketplace.get_manifest", params: { name: "marketplace.get_manifest", arguments: { kind: "agent", slug } } }),
6354
8182
  });
6355
8183
  } catch (e) {
6356
- throw new Error(`마켓플레이스 연결 실패: ${(e && e.message) || e}`);
8184
+ throw new Error(`Hub 연결 실패: ${(e && e.message) || e}`);
6357
8185
  }
6358
8186
  if (!resp.ok) {
6359
8187
  const authHint = resp.status === 401 || resp.status === 403 ? " — 로그인이 필요합니다 (앱에서 로그인 또는 AGENTLAS_SESSION 설정)" : "";
6360
- throw new Error(`마켓플레이스 응답 ${resp.status}${authHint}`);
8188
+ throw new Error(`Hub 응답 ${resp.status}${authHint}`);
6361
8189
  }
6362
- const json = await resp.json();
6363
- if (json.error) throw new Error(json.error.message || "marketplace error");
8190
+ const json = parseHubJsonCli(resp, "marketplace.get_manifest");
8191
+ if (json.error) throw new Error(json.error.message || "Hub error");
6364
8192
  const listing = json.result;
6365
- if (!listing) throw new Error(`마켓플레이스에서 찾을 수 없음: ${slug}`);
8193
+ if (!listing) throw new Error(`Hub에서 찾을 수 없음: ${slug}`);
8194
+ if (listing.delivery && listing.delivery.mode === "call_only") {
8195
+ throw new Error(`이 Hub 에이전트는 call-only 자산입니다. 실행: agentlas call ${slug}`);
8196
+ }
6366
8197
  return persistCloudListingCli(db_, listing);
6367
8198
  },
6368
8199
  hasCloudSession: async () => {
@@ -6473,10 +8304,11 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
6473
8304
  const cwd = opts.cwd || runCwd();
6474
8305
  return new Promise((resolve) => {
6475
8306
  const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
8307
+ const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
6476
8308
  const child = spawn(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
6477
8309
  cwd,
6478
8310
  stdio: ["ignore", "inherit", "inherit"],
6479
- env: opts.env || process.env,
8311
+ env,
6480
8312
  });
6481
8313
  child.on("error", (err) => {
6482
8314
  process.stderr.write(`\n실행 실패(${kind}): ${err.message}\n`);
@@ -6486,32 +8318,160 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
6486
8318
  });
6487
8319
  }
6488
8320
 
8321
+ const CAPTURE_OUTPUT_DEFAULT_BYTES = 4 * 1024 * 1024;
8322
+ function captureOutputLimit(env = process.env) {
8323
+ return finiteTimeoutMs(env.AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES, CAPTURE_OUTPUT_DEFAULT_BYTES, 64 * 1024, 32 * 1024 * 1024);
8324
+ }
8325
+ function directCaptureOutputLimit(value) {
8326
+ return finiteTimeoutMs(value, CAPTURE_OUTPUT_DEFAULT_BYTES, 128, 32 * 1024 * 1024);
8327
+ }
8328
+
6489
8329
  function captureRuntime(kind, systemPrompt, prompt, opts) {
6490
8330
  opts = opts || {};
6491
8331
  const cwd = opts.cwd || runCwd();
8332
+ const { nativeTimeoutConfig, directNativeTimeoutConfig } = require("./agentlas-native-host.cjs");
8333
+ const timeout = opts.timeoutConfig
8334
+ ? directNativeTimeoutConfig(opts.timeoutConfig)
8335
+ : nativeTimeoutConfig(opts.env || process.env);
8336
+ const outputLimit = opts.outputLimitBytes == null
8337
+ ? captureOutputLimit(opts.env || process.env)
8338
+ : directCaptureOutputLimit(opts.outputLimitBytes);
6492
8339
  return new Promise((resolve, reject) => {
6493
8340
  const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
6494
- const child = spawn(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
6495
- cwd,
6496
- stdio: ["ignore", "pipe", "pipe"],
6497
- env: opts.env || process.env,
6498
- });
6499
- let stdout = "";
6500
- let stderr = "";
6501
- child.stdout.on("data", (chunk) => {
6502
- stdout += chunk.toString();
6503
- });
6504
- child.stderr.on("data", (chunk) => {
6505
- stderr += chunk.toString();
6506
- });
6507
- child.on("error", reject);
6508
- child.on("close", (code) => {
8341
+ let child;
8342
+ try {
8343
+ const spawnImpl = opts.spawn || spawn;
8344
+ const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
8345
+ child = spawnImpl(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
8346
+ cwd,
8347
+ stdio: ["ignore", "pipe", "pipe"],
8348
+ env,
8349
+ });
8350
+ } catch (error) {
8351
+ reject(error);
8352
+ return;
8353
+ }
8354
+
8355
+ const stdoutChunks = [];
8356
+ const stderrChunks = [];
8357
+ let capturedBytes = 0;
8358
+ let settled = false;
8359
+ let terminationError = null;
8360
+ let idleTimer = null;
8361
+ let totalTimer = null;
8362
+ let killTimer = null;
8363
+ let forceTimer = null;
8364
+ let onStdout = () => {};
8365
+ let onStderr = () => {};
8366
+ let onError = () => {};
8367
+ let onClose = () => {};
8368
+ let onAbort = () => {};
8369
+
8370
+ const clearTimers = () => {
8371
+ if (idleTimer) clearTimeout(idleTimer);
8372
+ if (totalTimer) clearTimeout(totalTimer);
8373
+ if (killTimer) clearTimeout(killTimer);
8374
+ if (forceTimer) clearTimeout(forceTimer);
8375
+ idleTimer = totalTimer = killTimer = forceTimer = null;
8376
+ };
8377
+ const cleanup = () => {
8378
+ clearTimers();
8379
+ child.stdout?.removeListener("data", onStdout);
8380
+ child.stderr?.removeListener("data", onStderr);
8381
+ child.removeListener("error", onError);
8382
+ child.removeListener("close", onClose);
8383
+ if (opts.signal) opts.signal.removeEventListener?.("abort", onAbort);
8384
+ };
8385
+ const finishReject = (error) => {
8386
+ if (settled) return;
8387
+ settled = true;
8388
+ cleanup();
8389
+ reject(error);
8390
+ };
8391
+ const finishResolve = (value) => {
8392
+ if (settled) return;
8393
+ settled = true;
8394
+ cleanup();
8395
+ resolve(value);
8396
+ };
8397
+ const requestStop = (error) => {
8398
+ if (settled || terminationError) return;
8399
+ terminationError = error;
8400
+ if (idleTimer) clearTimeout(idleTimer);
8401
+ if (totalTimer) clearTimeout(totalTimer);
8402
+ idleTimer = totalTimer = null;
8403
+ try { child.kill("SIGTERM"); } catch { /* ignore */ }
8404
+ if (settled) return;
8405
+ killTimer = setTimeout(() => {
8406
+ if (settled) return;
8407
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
8408
+ if (settled) return;
8409
+ forceTimer = setTimeout(() => finishReject(terminationError), Math.max(250, Math.min(1_000, timeout.killGraceMs)));
8410
+ }, timeout.killGraceMs);
8411
+ };
8412
+ const timeoutError = (phase, ms) => {
8413
+ const error = new Error(
8414
+ phase === "idle"
8415
+ ? `${kind} capture idle timeout: ${ms}ms 동안 출력이 없습니다.`
8416
+ : `${kind} capture total timeout: 전체 실행 시간이 ${ms}ms를 초과했습니다.`,
8417
+ );
8418
+ error.code = `AGENTLAS_CAPTURE_${phase.toUpperCase()}_TIMEOUT`;
8419
+ return error;
8420
+ };
8421
+ const armIdle = () => {
8422
+ if (settled || terminationError) return;
8423
+ if (idleTimer) clearTimeout(idleTimer);
8424
+ idleTimer = setTimeout(() => requestStop(timeoutError("idle", timeout.idleMs)), timeout.idleMs);
8425
+ };
8426
+ const append = (target, chunk) => {
8427
+ armIdle();
8428
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8429
+ const remaining = Math.max(0, outputLimit - capturedBytes);
8430
+ if (remaining > 0) {
8431
+ const kept = bytes.length > remaining ? bytes.subarray(0, remaining) : bytes;
8432
+ target.push(kept);
8433
+ capturedBytes += kept.length;
8434
+ }
8435
+ if (bytes.length > remaining) {
8436
+ const error = new Error(`${kind} capture output limit: ${outputLimit} bytes를 초과했습니다.`);
8437
+ error.code = "AGENTLAS_CAPTURE_OUTPUT_LIMIT";
8438
+ requestStop(error);
8439
+ }
8440
+ };
8441
+
8442
+ onStdout = (chunk) => append(stdoutChunks, chunk);
8443
+ onStderr = (chunk) => append(stderrChunks, chunk);
8444
+ onError = (error) => finishReject(terminationError || error);
8445
+ onClose = (code) => {
8446
+ if (terminationError) {
8447
+ finishReject(terminationError);
8448
+ return;
8449
+ }
8450
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
8451
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
6509
8452
  if (code && code !== 0) {
6510
- reject(new Error(`${kind} exited ${code}: ${stderr.slice(0, 500)}`));
8453
+ finishReject(new Error(`${kind} exited ${code}: ${stderr.slice(-500)}`));
6511
8454
  return;
6512
8455
  }
6513
- resolve(stdout.trim() || stderr.trim());
6514
- });
8456
+ finishResolve(stdout.trim() || stderr.trim());
8457
+ };
8458
+ onAbort = () => {
8459
+ const reason = opts.signal && opts.signal.reason;
8460
+ const error = reason instanceof Error ? reason : new Error(`${kind} capture aborted`);
8461
+ if (!error.code) error.code = "ABORT_ERR";
8462
+ requestStop(error);
8463
+ };
8464
+
8465
+ child.stdout.on("data", onStdout);
8466
+ child.stderr.on("data", onStderr);
8467
+ child.on("error", onError);
8468
+ child.on("close", onClose);
8469
+ armIdle();
8470
+ totalTimer = setTimeout(() => requestStop(timeoutError("total", timeout.totalMs)), timeout.totalMs);
8471
+ if (opts.signal) {
8472
+ if (opts.signal.aborted) onAbort();
8473
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
8474
+ }
6515
8475
  });
6516
8476
  }
6517
8477
 
@@ -6578,7 +8538,7 @@ function cmdList(db) {
6578
8538
 
6579
8539
  function ensureNativeFiles(agent, folder) {
6580
8540
  fs.mkdirSync(folder, { recursive: true });
6581
- const sys = agent.system_prompt || `You are ${agent.name}.`;
8541
+ const sys = agentSystemPromptCli(agent);
6582
8542
  writeIfMissing(path.join(folder, "system-prompt.md"), sys);
6583
8543
  const header = `# ${agent.name}\n\n${agent.tagline || ""}\n\n${sys}\n`;
6584
8544
  // 네이티브 CLI가 프로젝트 지시로 자동 인식하는 파일들
@@ -6615,7 +8575,7 @@ async function cmdRun(db, query, prompt, runtimeOverride) {
6615
8575
  if (!userPrompt) userPrompt = await readStdin();
6616
8576
  if (!userPrompt || !userPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 stdin으로 전달하세요.");
6617
8577
  process.stderr.write(`▸ ${agent.name}\n`);
6618
- const code = await executeOnce(db, agent.system_prompt || "", userPrompt.trim(), runtimeOverride, { projectPath: activeProjectPath(db), agentId: agent.id, permission: PERMISSION });
8578
+ const code = await executeOnce(db, agentSystemPromptCli(agent), userPrompt.trim(), runtimeOverride, { projectPath: activeProjectPath(db), agentId: agent.id, permission: PERMISSION });
6619
8579
  process.exit(code);
6620
8580
  }
6621
8581
 
@@ -6623,9 +8583,21 @@ async function cmdAutoRun(db, prompt, runtimeOverride) {
6623
8583
  const lang = prefsLang();
6624
8584
  const choice = autoRouteAgent(db, prompt, lang);
6625
8585
  if (!choice) fail("자동 라우팅할 에이전트가 없습니다. agentlas list로 설치 상태를 확인하세요.");
8586
+ if (choice.direct) {
8587
+ // 전문 에이전트 확신 없음 → 페르소나/능력 라우팅 없이 현재 런타임으로 직답.
8588
+ process.stderr.write(`▸ direct (no agent)\n`);
8589
+ process.stderr.write(` ${autoRouteNote(choice, lang)}\n`);
8590
+ const sys = `${autoRoutePreamble(choice, lang)}\n\n${directSystemPrompt(lang)}`;
8591
+ const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
8592
+ projectPath: activeProjectPath(db),
8593
+ agentId: null,
8594
+ permission: PERMISSION,
8595
+ });
8596
+ process.exit(code);
8597
+ }
6626
8598
  process.stderr.write(`▸ ${choice.agent.name} (auto)\n`);
6627
8599
  process.stderr.write(` ${autoRouteNote(choice, lang)}\n`);
6628
- const sys = `${autoRoutePreamble(choice, lang)}\n\n${choice.agent.system_prompt || ""}`;
8600
+ const sys = `${autoRoutePreamble(choice, lang)}\n\n${agentSystemPromptCli(choice.agent)}`;
6629
8601
  const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
6630
8602
  projectPath: activeProjectPath(db),
6631
8603
  agentId: choice.agent.id,
@@ -6722,7 +8694,15 @@ function upsertEnvLine(file, key, value) {
6722
8694
  if (re.test(body)) body = body.replace(re, line);
6723
8695
  else body = body ? body.replace(/\n?$/, "\n") + line + "\n" : line + "\n";
6724
8696
  fs.mkdirSync(path.dirname(file), { recursive: true });
6725
- fs.writeFileSync(file, body, "utf8");
8697
+ // 헬퍼의 모든 호출자는 credential 값/경로를 기록한다. 새 파일뿐 아니라 기존 0644
8698
+ // 파일도 매번 0600으로 수렴시켜 같은 머신의 다른 계정이 읽지 못하게 한다.
8699
+ fs.writeFileSync(file, body, { encoding: "utf8", mode: 0o600 });
8700
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows/읽기전용 FS best-effort */ }
8701
+ }
8702
+ function resolveCredentialSourcePath(source, cwd) {
8703
+ // `agentlas creds file`은 일반 CLI 명령이므로 상대경로 기준은 사용자가 명령을 실행한
8704
+ // 셸 cwd다. 런타임 격리용 agent-cwd를 쓰면 실제 프로젝트 파일을 조용히 못 찾는다.
8705
+ return path.resolve(cwd || process.cwd(), source);
6726
8706
  }
6727
8707
  async function cmdCredsFile(db, args) {
6728
8708
  const f = parseCredFlags(args);
@@ -6740,7 +8720,7 @@ async function cmdCredsFile(db, args) {
6740
8720
  ensureLocalCredentialStoreCli(project, projectName, arch);
6741
8721
  ensureSoulCredentialIndexCli(project, projectName, arch);
6742
8722
 
6743
- const sourceAbs = path.resolve(runCwd(), source);
8723
+ const sourceAbs = resolveCredentialSourcePath(source);
6744
8724
  let stat;
6745
8725
  try { stat = fs.statSync(sourceAbs); } catch { fail(`credential source not found: ${source}`); }
6746
8726
  if (!stat.isFile()) fail(`credential source is not a file: ${source}`);
@@ -6993,48 +8973,220 @@ function cmdUpdateHelp() {
6993
8973
  );
6994
8974
  }
6995
8975
 
6996
- function versionParts(value) {
6997
- return String(value || "")
6998
- .trim()
6999
- .replace(/^v/i, "")
7000
- .split(/[.-]/)
7001
- .slice(0, 3)
7002
- .map((part) => {
7003
- const parsed = Number.parseInt(part, 10);
7004
- return Number.isFinite(parsed) ? parsed : 0;
7005
- });
8976
+ function macReleaseArch() {
8977
+ if (process.arch === "arm64") return "arm64";
8978
+ if (process.arch === "x64") return "x64";
8979
+ return null;
8980
+ }
8981
+
8982
+ const UPDATE_METADATA_MAX_BYTES = 1024 * 1024;
8983
+ const UPDATE_DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
8984
+ const UPDATE_TIMEOUT_DEFAULTS = Object.freeze({
8985
+ metadata: Object.freeze({ connectMs: 15_000, idleMs: 15_000, totalMs: 30_000 }),
8986
+ download: Object.freeze({ connectMs: 20_000, idleMs: 60_000, totalMs: 30 * 60_000 }),
8987
+ });
8988
+
8989
+ function updateTimeoutConfig(env = process.env, kind = "download") {
8990
+ const selected = kind === "metadata" ? "metadata" : "download";
8991
+ const defaults = UPDATE_TIMEOUT_DEFAULTS[selected];
8992
+ const prefix = selected === "metadata" ? "AGENTLAS_UPDATE_METADATA" : "AGENTLAS_UPDATE_DOWNLOAD";
8993
+ const totalMs = finiteTimeoutMs(env[`${prefix}_TOTAL_TIMEOUT_MS`], defaults.totalMs, 5_000, 60 * 60_000);
8994
+ return {
8995
+ connectMs: Math.min(totalMs, finiteTimeoutMs(env[`${prefix}_CONNECT_TIMEOUT_MS`], defaults.connectMs, 1_000, 120_000)),
8996
+ idleMs: Math.min(totalMs, finiteTimeoutMs(env[`${prefix}_IDLE_TIMEOUT_MS`], defaults.idleMs, 1_000, 300_000)),
8997
+ totalMs,
8998
+ };
7006
8999
  }
7007
9000
 
7008
- function compareVersions(a, b) {
7009
- const left = versionParts(a);
7010
- const right = versionParts(b);
7011
- for (let i = 0; i < 3; i++) {
7012
- const delta = (left[i] || 0) - (right[i] || 0);
7013
- if (delta !== 0) return delta;
9001
+ function directUpdateTimeoutConfig(value = {}, kind = "download") {
9002
+ const defaults = UPDATE_TIMEOUT_DEFAULTS[kind === "metadata" ? "metadata" : "download"];
9003
+ const totalMs = finiteTimeoutMs(value.totalMs, defaults.totalMs, 10, 60 * 60_000);
9004
+ return {
9005
+ connectMs: Math.min(totalMs, finiteTimeoutMs(value.connectMs, defaults.connectMs, 10, 120_000)),
9006
+ idleMs: Math.min(totalMs, finiteTimeoutMs(value.idleMs, defaults.idleMs, 10, 300_000)),
9007
+ totalMs,
9008
+ };
9009
+ }
9010
+
9011
+ function updateDownloadMaxBytes(env = process.env) {
9012
+ return finiteTimeoutMs(env.AGENTLAS_UPDATE_DOWNLOAD_MAX_BYTES, UPDATE_DOWNLOAD_MAX_BYTES, 16 * 1024 * 1024, 2 * 1024 * 1024 * 1024);
9013
+ }
9014
+
9015
+ function updateTransferError(code, message, cause) {
9016
+ const error = new Error(message, cause ? { cause } : undefined);
9017
+ error.code = code;
9018
+ return error;
9019
+ }
9020
+
9021
+ function updateTimeoutError(kind, ms) {
9022
+ const message = kind === "connect"
9023
+ ? `업데이트 서버 연결 제한 시간(${ms}ms)을 초과했습니다.`
9024
+ : kind === "idle"
9025
+ ? `업데이트 전송이 ${ms}ms 동안 멈췄습니다.`
9026
+ : `업데이트 요청 전체 제한 시간(${ms}ms)을 초과했습니다.`;
9027
+ return updateTransferError(`AGENTLAS_UPDATE_${kind.toUpperCase()}_TIMEOUT`, message);
9028
+ }
9029
+
9030
+ function parseSafeUpdateUrl(value, label = "업데이트 URL") {
9031
+ let parsed;
9032
+ try {
9033
+ parsed = new URL(String(value || ""));
9034
+ } catch (error) {
9035
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label} 형식이 올바르지 않습니다.`, error);
9036
+ }
9037
+ const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
9038
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
9039
+ throw updateTransferError("AGENTLAS_UPDATE_INSECURE_URL", `${label}은 HTTPS여야 합니다(로컬 루프백 제외).`);
9040
+ }
9041
+ if (parsed.username || parsed.password) {
9042
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label}에 사용자 정보가 포함될 수 없습니다.`);
9043
+ }
9044
+ return parsed.toString();
9045
+ }
9046
+
9047
+ /** Headers 전 connect, chunk 사이 idle, 전체 total 제한을 적용하는 bounded 스트림 reader. */
9048
+ async function consumeUpdateResponse(url, init = {}, options = {}) {
9049
+ const fetchImpl = options.fetch || globalThis.fetch;
9050
+ if (typeof fetchImpl !== "function") throw updateTransferError("AGENTLAS_UPDATE_FETCH_UNAVAILABLE", "이 런타임에 fetch가 없습니다.");
9051
+ const kind = options.kind === "metadata" ? "metadata" : "download";
9052
+ const timeout = options.timeoutConfig
9053
+ ? directUpdateTimeoutConfig(options.timeoutConfig, kind)
9054
+ : updateTimeoutConfig(options.env || process.env, kind);
9055
+ const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0
9056
+ ? options.maxBytes
9057
+ : kind === "metadata" ? UPDATE_METADATA_MAX_BYTES : updateDownloadMaxBytes(options.env || process.env);
9058
+ const expectedBytes = options.expectedBytes == null ? null : Number(options.expectedBytes);
9059
+ const controller = new AbortController();
9060
+ const upstreamSignal = init.signal;
9061
+ let connectTimer = null;
9062
+ let idleTimer = null;
9063
+ let totalTimer = null;
9064
+ let reader = null;
9065
+ let terminalError = null;
9066
+ let caughtError = null;
9067
+ let rejectTerminal;
9068
+ const terminal = new Promise((_, reject) => { rejectTerminal = reject; });
9069
+ const stop = (error) => {
9070
+ if (terminalError) return;
9071
+ terminalError = error;
9072
+ try { controller.abort(error); } catch { controller.abort(); }
9073
+ rejectTerminal(error);
9074
+ };
9075
+ const onUpstreamAbort = () => {
9076
+ const reason = upstreamSignal && upstreamSignal.reason;
9077
+ const error = reason instanceof Error ? reason : updateTransferError("ABORT_ERR", "업데이트 요청이 취소되었습니다.");
9078
+ if (!error.code) error.code = "ABORT_ERR";
9079
+ stop(error);
9080
+ };
9081
+ const armIdle = () => {
9082
+ if (idleTimer) clearTimeout(idleTimer);
9083
+ idleTimer = setTimeout(() => stop(updateTimeoutError("idle", timeout.idleMs)), timeout.idleMs);
9084
+ };
9085
+
9086
+ if (upstreamSignal) {
9087
+ if (upstreamSignal.aborted) onUpstreamAbort();
9088
+ else upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
9089
+ }
9090
+ connectTimer = setTimeout(() => stop(updateTimeoutError("connect", timeout.connectMs)), timeout.connectMs);
9091
+ totalTimer = setTimeout(() => stop(updateTimeoutError("total", timeout.totalMs)), timeout.totalMs);
9092
+
9093
+ try {
9094
+ const response = await Promise.race([
9095
+ Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal })),
9096
+ terminal,
9097
+ ]);
9098
+ if (connectTimer) clearTimeout(connectTimer);
9099
+ connectTimer = null;
9100
+ if (!response || typeof response.ok !== "boolean") {
9101
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_RESPONSE", "업데이트 서버 응답 형식이 올바르지 않습니다.");
9102
+ }
9103
+ if (response.url) parseSafeUpdateUrl(response.url, "리디렉션된 업데이트 URL");
9104
+ if (!response.ok) {
9105
+ throw updateTransferError("AGENTLAS_UPDATE_HTTP_ERROR", `업데이트 요청 실패: HTTP ${response.status}`);
9106
+ }
9107
+ const contentLengthValue = response.headers && response.headers.get ? response.headers.get("content-length") : null;
9108
+ if (contentLengthValue != null && contentLengthValue !== "") {
9109
+ const contentLength = Number(contentLengthValue);
9110
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
9111
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_CONTENT_LENGTH", "업데이트 서버의 Content-Length가 올바르지 않습니다.");
9112
+ }
9113
+ if (contentLength > maxBytes) {
9114
+ throw updateTransferError("AGENTLAS_UPDATE_TOO_LARGE", `업데이트 응답이 허용 크기(${maxBytes} bytes)를 초과합니다.`);
9115
+ }
9116
+ if (Number.isSafeInteger(expectedBytes) && contentLength !== expectedBytes) {
9117
+ throw updateTransferError("AGENTLAS_UPDATE_SIZE_MISMATCH", `다운로드 크기가 맞지 않습니다: expected=${expectedBytes} header=${contentLength}`);
9118
+ }
9119
+ }
9120
+ if (!response.body || typeof response.body.getReader !== "function") {
9121
+ throw updateTransferError("AGENTLAS_UPDATE_BODY_UNAVAILABLE", "업데이트 응답을 스트림으로 읽을 수 없습니다.");
9122
+ }
9123
+
9124
+ reader = response.body.getReader();
9125
+ let bytes = 0;
9126
+ armIdle();
9127
+ while (true) {
9128
+ const part = await Promise.race([reader.read(), terminal]);
9129
+ if (part.done) break;
9130
+ armIdle();
9131
+ const chunk = Buffer.from(part.value || []);
9132
+ bytes += chunk.length;
9133
+ if (bytes > maxBytes) {
9134
+ const error = updateTransferError("AGENTLAS_UPDATE_TOO_LARGE", `업데이트 응답이 허용 크기(${maxBytes} bytes)를 초과했습니다.`);
9135
+ stop(error);
9136
+ throw error;
9137
+ }
9138
+ if (typeof options.onChunk === "function") await options.onChunk(chunk);
9139
+ }
9140
+ if (idleTimer) clearTimeout(idleTimer);
9141
+ idleTimer = null;
9142
+ return { response, bytes };
9143
+ } catch (error) {
9144
+ caughtError = terminalError || error;
9145
+ try { controller.abort(caughtError); } catch { controller.abort(); }
9146
+ throw caughtError;
9147
+ } finally {
9148
+ if (connectTimer) clearTimeout(connectTimer);
9149
+ if (idleTimer) clearTimeout(idleTimer);
9150
+ if (totalTimer) clearTimeout(totalTimer);
9151
+ if (upstreamSignal) upstreamSignal.removeEventListener?.("abort", onUpstreamAbort);
9152
+ if (reader && caughtError) {
9153
+ try { await reader.cancel(caughtError); } catch { /* ignore */ }
9154
+ }
7014
9155
  }
7015
- return 0;
7016
9156
  }
7017
9157
 
7018
- function macReleaseArch() {
7019
- if (process.arch === "arm64") return "arm64";
7020
- if (process.arch === "x64") return "x64";
7021
- return null;
9158
+ async function fetchUpdateMetadata(url, options = {}) {
9159
+ const safeUrl = parseSafeUpdateUrl(url, "업데이트 메타데이터 URL");
9160
+ const chunks = [];
9161
+ let bytes = 0;
9162
+ await consumeUpdateResponse(safeUrl, { headers: { accept: "application/json", "accept-encoding": "identity" }, signal: options.signal }, {
9163
+ ...options,
9164
+ kind: "metadata",
9165
+ maxBytes: Number.isSafeInteger(options.maxBytes) ? options.maxBytes : UPDATE_METADATA_MAX_BYTES,
9166
+ onChunk(chunk) {
9167
+ bytes += chunk.length;
9168
+ chunks.push(chunk);
9169
+ },
9170
+ });
9171
+ let json;
9172
+ try {
9173
+ json = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
9174
+ } catch (error) {
9175
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_METADATA", "업데이트 정보가 올바른 JSON이 아닙니다.", error);
9176
+ }
9177
+ if (!json || typeof json !== "object" || Array.isArray(json) || !parseSemVer(json.version)) {
9178
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_METADATA", "업데이트 정보 형식이 올바르지 않습니다.");
9179
+ }
9180
+ return { ...json, version: normalizeSemVer(json.version) };
7022
9181
  }
7023
9182
 
7024
9183
  async function fetchDesktopRelease(url) {
7025
9184
  const controller = new AbortController();
7026
- const timer = setTimeout(() => controller.abort(), 20_000);
7027
9185
  try {
7028
- const resp = await fetch(url, { headers: { accept: "application/json" }, signal: controller.signal });
7029
- if (!resp.ok) fail(`업데이트 정보를 가져오지 못했습니다: ${resp.status}`);
7030
- const json = await resp.json();
7031
- if (!json || typeof json !== "object" || !json.version) fail("업데이트 정보 형식이 올바르지 않습니다.");
7032
- return json;
9186
+ return await fetchUpdateMetadata(url, { signal: controller.signal });
7033
9187
  } catch (error) {
7034
- const message = error && error.name === "AbortError" ? "요청 시간이 초과되었습니다." : String((error && error.message) || error);
9188
+ const message = String((error && error.message) || error);
7035
9189
  fail(`업데이트 확인 실패: ${message}`);
7036
- } finally {
7037
- clearTimeout(timer);
7038
9190
  }
7039
9191
  }
7040
9192
 
@@ -7069,8 +9221,9 @@ async function cmdUpdateStandalone(flags) {
7069
9221
  const resp = await fetch("https://registry.npmjs.org/agentlas/latest", { headers: { accept: "application/json" } });
7070
9222
  if (resp.ok) latestVersion = String((await resp.json()).version || "");
7071
9223
  } catch { /* offline 등 — 아래에서 안내 */ }
9224
+ const comparison = latestVersion ? compareSemVer(currentVersion, latestVersion) : null;
7072
9225
  if (flags.json) {
7073
- return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable: latestVersion ? compareVersions(currentVersion, latestVersion) < 0 : null, channel: "npm" }, null, 2));
9226
+ return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable: comparison == null ? null : comparison < 0, channel: "npm" }, null, 2));
7074
9227
  }
7075
9228
  out(`현재 버전: ${currentVersion}`);
7076
9229
  if (!latestVersion) {
@@ -7079,7 +9232,9 @@ async function cmdUpdateStandalone(flags) {
7079
9232
  return;
7080
9233
  }
7081
9234
  out(`최신 버전: ${latestVersion}`);
7082
- if (compareVersions(currentVersion, latestVersion) < 0) {
9235
+ if (comparison == null) {
9236
+ out("버전 형식을 비교하지 못했습니다. 수동 업데이트: npm i -g agentlas@latest");
9237
+ } else if (comparison < 0) {
7083
9238
  out("업데이트: npm i -g agentlas@latest");
7084
9239
  } else {
7085
9240
  out("이미 최신 버전입니다.");
@@ -7096,7 +9251,9 @@ async function cmdUpdate(args) {
7096
9251
  const release = await fetchDesktopRelease(flags.url);
7097
9252
  const latestVersion = String(release.version || "");
7098
9253
  const artifact = findCurrentArtifact(release);
7099
- const updateAvailable = compareVersions(currentVersion, latestVersion) < 0;
9254
+ const comparison = compareSemVer(currentVersion, latestVersion);
9255
+ if (comparison == null) fail(`현재/최신 버전이 SemVer 형식이 아닙니다: current=${currentVersion} latest=${latestVersion}`);
9256
+ const updateAvailable = comparison < 0;
7100
9257
  const status = {
7101
9258
  currentVersion,
7102
9259
  latestVersion,
@@ -7153,17 +9310,80 @@ function sleep(ms) {
7153
9310
  return new Promise((resolve) => setTimeout(resolve, ms));
7154
9311
  }
7155
9312
 
7156
- async function downloadUpdateFile(url, destination, artifact) {
7157
- const resp = await fetch(url);
7158
- if (!resp.ok) fail(`다운로드 실패: ${resp.status}`);
7159
- const bytes = Buffer.from(await resp.arrayBuffer());
7160
- fs.writeFileSync(destination, bytes);
7161
- if (artifact.sizeBytes && bytes.length !== Number(artifact.sizeBytes)) {
7162
- fail(`다운로드 크기가 맞지 않습니다: expected=${artifact.sizeBytes} actual=${bytes.length}`);
9313
+ function validateDesktopUpdateArtifact(artifact, options = {}) {
9314
+ if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
9315
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_ARTIFACT", "업데이트 아티팩트 정보가 없습니다.");
9316
+ }
9317
+ const url = parseSafeUpdateUrl(artifact.url, "업데이트 아티팩트 URL");
9318
+ const sha256 = String(artifact.sha256 || "").trim().toLowerCase();
9319
+ if (!/^[a-f0-9]{64}$/.test(sha256)) {
9320
+ throw updateTransferError("AGENTLAS_UPDATE_MISSING_DIGEST", "안전한 자동 업데이트를 위해 64자리 SHA-256이 반드시 필요합니다.");
9321
+ }
9322
+ const sizeBytes = Number(artifact.sizeBytes);
9323
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {
9324
+ throw updateTransferError("AGENTLAS_UPDATE_MISSING_SIZE", "안전한 자동 업데이트를 위해 정확한 sizeBytes가 반드시 필요합니다.");
9325
+ }
9326
+ const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0
9327
+ ? options.maxBytes
9328
+ : updateDownloadMaxBytes(options.env || process.env);
9329
+ if (sizeBytes > maxBytes) {
9330
+ throw updateTransferError("AGENTLAS_UPDATE_TOO_LARGE", `업데이트 파일 크기(${sizeBytes} bytes)가 허용 한도(${maxBytes} bytes)를 초과합니다.`);
9331
+ }
9332
+ let fileName = artifact.fileName == null ? "" : String(artifact.fileName).trim();
9333
+ if (fileName) {
9334
+ if (fileName.length > 180 || /[\\/\0]/.test(fileName) || path.basename(fileName) !== fileName || !fileName.toLowerCase().endsWith(".dmg")) {
9335
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_FILENAME", "업데이트 파일 이름이 안전하지 않습니다.");
9336
+ }
9337
+ }
9338
+ return { ...artifact, url, sha256, sizeBytes, fileName };
9339
+ }
9340
+
9341
+ async function downloadUpdateFile(url, destination, artifact, options = {}) {
9342
+ const validated = validateDesktopUpdateArtifact({ ...artifact, url }, options);
9343
+ if (fs.existsSync(destination)) {
9344
+ throw updateTransferError("AGENTLAS_UPDATE_DESTINATION_EXISTS", `업데이트 다운로드 대상이 이미 존재합니다: ${destination}`);
9345
+ }
9346
+ const partialPath = options.partialPath || `${destination}.partial.${process.pid}.${crypto.randomBytes(6).toString("hex")}`;
9347
+ if (fs.existsSync(partialPath)) {
9348
+ throw updateTransferError("AGENTLAS_UPDATE_PARTIAL_EXISTS", `업데이트 임시 파일이 이미 존재합니다: ${partialPath}`);
7163
9349
  }
7164
- if (artifact.sha256) {
7165
- const actual = crypto.createHash("sha256").update(bytes).digest("hex");
7166
- if (actual !== artifact.sha256) fail(`다운로드 해시가 맞지 않습니다: ${actual}`);
9350
+ const hash = crypto.createHash("sha256");
9351
+ let fd = null;
9352
+ let actualBytes = 0;
9353
+ try {
9354
+ fd = fs.openSync(partialPath, "wx", 0o600);
9355
+ await consumeUpdateResponse(validated.url, {
9356
+ headers: { accept: "application/octet-stream", "accept-encoding": "identity" },
9357
+ signal: options.signal,
9358
+ }, {
9359
+ ...options,
9360
+ kind: "download",
9361
+ maxBytes: Number.isSafeInteger(options.maxBytes) ? options.maxBytes : updateDownloadMaxBytes(options.env || process.env),
9362
+ expectedBytes: validated.sizeBytes,
9363
+ onChunk(chunk) {
9364
+ fs.writeSync(fd, chunk, 0, chunk.length);
9365
+ hash.update(chunk);
9366
+ actualBytes += chunk.length;
9367
+ },
9368
+ });
9369
+ fs.fsyncSync(fd);
9370
+ fs.closeSync(fd);
9371
+ fd = null;
9372
+ if (actualBytes !== validated.sizeBytes) {
9373
+ throw updateTransferError("AGENTLAS_UPDATE_SIZE_MISMATCH", `다운로드 크기가 맞지 않습니다: expected=${validated.sizeBytes} actual=${actualBytes}`);
9374
+ }
9375
+ const actualSha256 = hash.digest("hex");
9376
+ if (actualSha256 !== validated.sha256) {
9377
+ throw updateTransferError("AGENTLAS_UPDATE_DIGEST_MISMATCH", `다운로드 SHA-256이 맞지 않습니다: expected=${validated.sha256} actual=${actualSha256}`);
9378
+ }
9379
+ fs.renameSync(partialPath, destination);
9380
+ return { bytes: actualBytes, sha256: actualSha256, destination };
9381
+ } catch (error) {
9382
+ if (fd != null) {
9383
+ try { fs.closeSync(fd); } catch { /* ignore */ }
9384
+ }
9385
+ try { fs.rmSync(partialPath, { force: true }); } catch { /* ignore */ }
9386
+ throw error;
7167
9387
  }
7168
9388
  }
7169
9389
 
@@ -7180,10 +9400,199 @@ function macAppInstallPath() {
7180
9400
  return "/Applications/Agentlas.app";
7181
9401
  }
7182
9402
 
9403
+ async function verifyMacAppBundle(appPath, options = {}) {
9404
+ const runner = options.runCommand || runCommand;
9405
+ const commands = options.commands || {};
9406
+ if (!commands.codesign || !commands.spctl) {
9407
+ throw updateTransferError("AGENTLAS_UPDATE_VERIFY_TOOL_MISSING", "앱 서명 검증 도구가 지정되지 않았습니다.");
9408
+ }
9409
+ await runner(commands.codesign, ["--verify", "--deep", "--strict", "--verbose=2", appPath]);
9410
+ const detail = await runner(commands.codesign, ["-d", "--verbose=4", appPath], { capture: true });
9411
+ const signatureText = `${(detail && detail.stdout) || ""}\n${(detail && detail.stderr) || ""}`;
9412
+ const identifier = (signatureText.match(/^Identifier=(.+)$/m) || [])[1]?.trim() || "";
9413
+ const teamIdentifier = (signatureText.match(/^TeamIdentifier=(.+)$/m) || [])[1]?.trim() || "";
9414
+ if (identifier !== "com.agentlas.desktop") {
9415
+ throw updateTransferError("AGENTLAS_UPDATE_SIGNER_MISMATCH", `앱 번들 식별자가 올바르지 않습니다: ${identifier || "missing"}`);
9416
+ }
9417
+ if (!teamIdentifier || teamIdentifier.toLowerCase() === "not set") {
9418
+ throw updateTransferError("AGENTLAS_UPDATE_SIGNER_MISSING", "앱 서명에서 Apple TeamIdentifier를 확인하지 못했습니다.");
9419
+ }
9420
+ await runner(commands.spctl, ["-a", "-t", "exec", "-vv", appPath]);
9421
+ return { identifier, teamIdentifier };
9422
+ }
9423
+
9424
+ function assertSameMacSigningIdentity(expected, actual, phase) {
9425
+ if (!expected || !actual) return;
9426
+ if (expected.identifier !== actual.identifier || expected.teamIdentifier !== actual.teamIdentifier) {
9427
+ throw updateTransferError(
9428
+ "AGENTLAS_UPDATE_SIGNER_MISMATCH",
9429
+ `${phase} 앱의 서명 주체가 다릅니다: expected=${expected.identifier}/${expected.teamIdentifier} actual=${actual.identifier}/${actual.teamIdentifier}`,
9430
+ );
9431
+ }
9432
+ }
9433
+
9434
+ async function removeUpdatePathChecked(targetPath, options) {
9435
+ const fsImpl = options.fs || fs;
9436
+ if (!fsImpl.existsSync(targetPath)) return;
9437
+ try {
9438
+ await options.runCommand(options.commands.rm, ["-rf", targetPath]);
9439
+ } catch (error) {
9440
+ if (fsImpl.existsSync(targetPath)) throw error;
9441
+ }
9442
+ if (fsImpl.existsSync(targetPath)) {
9443
+ throw updateTransferError("AGENTLAS_UPDATE_REMOVE_FAILED", `업데이트 임시 경로를 제거하지 못했습니다: ${targetPath}`);
9444
+ }
9445
+ }
9446
+
9447
+ /**
9448
+ * 기존 앱을 같은 디렉터리의 backup으로 원자 이동한 뒤 staging 앱을 검증해 교체한다.
9449
+ * backup이 생긴 이후 어느 단계든 실패하면 원본을 다시 이동하고 서명까지 재검증한다.
9450
+ */
9451
+ async function replaceMacAppBundle(options) {
9452
+ const rawPaths = [options.sourceApp, options.targetApp, options.backupPath, options.stagingPath];
9453
+ if (rawPaths.some((value) => typeof value !== "string" || !value.trim())) {
9454
+ throw updateTransferError("AGENTLAS_UPDATE_PATH_MISSING", "업데이트 source/target/backup/staging 경로가 모두 필요합니다.");
9455
+ }
9456
+ const sourceApp = path.resolve(options.sourceApp);
9457
+ const targetApp = path.resolve(options.targetApp);
9458
+ const backupPath = path.resolve(options.backupPath);
9459
+ const stagingPath = path.resolve(options.stagingPath);
9460
+ const runner = options.runCommand || runCommand;
9461
+ const fsImpl = options.fs || fs;
9462
+ const commands = options.commands || {};
9463
+ const verifyApp = options.verifyApp || ((appPath, context) => verifyMacAppBundle(appPath, {
9464
+ runCommand: runner,
9465
+ commands,
9466
+ context,
9467
+ }));
9468
+ if (!commands.mv || !commands.rm || !commands.ditto) {
9469
+ throw updateTransferError("AGENTLAS_UPDATE_INSTALL_TOOL_MISSING", "앱 교체 도구가 지정되지 않았습니다.");
9470
+ }
9471
+ if (!fsImpl.existsSync(sourceApp)) {
9472
+ throw updateTransferError("AGENTLAS_UPDATE_SOURCE_MISSING", `설치할 앱을 찾지 못했습니다: ${sourceApp}`);
9473
+ }
9474
+ if (!sourceApp.toLowerCase().endsWith(".app") || !targetApp.toLowerCase().endsWith(".app")) {
9475
+ throw updateTransferError("AGENTLAS_UPDATE_INVALID_APP_PATH", "업데이트 source와 target은 .app 번들이어야 합니다.");
9476
+ }
9477
+ if (new Set([sourceApp, targetApp, backupPath, stagingPath]).size !== 4) {
9478
+ throw updateTransferError("AGENTLAS_UPDATE_PATH_COLLISION", "업데이트 source/target/backup/staging 경로가 서로 달라야 합니다.");
9479
+ }
9480
+ if (path.dirname(backupPath) !== path.dirname(targetApp) || path.dirname(stagingPath) !== path.dirname(targetApp)) {
9481
+ throw updateTransferError("AGENTLAS_UPDATE_NONATOMIC_PATH", "backup과 staging은 대상 앱과 같은 디렉터리에 있어야 합니다.");
9482
+ }
9483
+ if (fsImpl.existsSync(backupPath) || fsImpl.existsSync(stagingPath)) {
9484
+ throw updateTransferError("AGENTLAS_UPDATE_PATH_EXISTS", "업데이트 backup 또는 staging 경로가 이미 존재합니다.");
9485
+ }
9486
+
9487
+ const hadOriginal = fsImpl.existsSync(targetApp);
9488
+ let sourceIdentity = null;
9489
+ let originalIdentity = null;
9490
+ try {
9491
+ sourceIdentity = await verifyApp(sourceApp, { phase: "source" });
9492
+ if (hadOriginal) {
9493
+ originalIdentity = await verifyApp(targetApp, { phase: "original" });
9494
+ assertSameMacSigningIdentity(originalIdentity, sourceIdentity, "새 릴리즈");
9495
+ await runner(commands.mv, [targetApp, backupPath]);
9496
+ if (fsImpl.existsSync(targetApp) || !fsImpl.existsSync(backupPath)) {
9497
+ throw updateTransferError("AGENTLAS_UPDATE_BACKUP_FAILED", "기존 앱 백업 이동을 확인하지 못했습니다.");
9498
+ }
9499
+ const backupIdentity = await verifyApp(backupPath, { phase: "backup" });
9500
+ assertSameMacSigningIdentity(originalIdentity, backupIdentity, "백업");
9501
+ }
9502
+
9503
+ await runner(commands.ditto, [sourceApp, stagingPath]);
9504
+ if (!fsImpl.existsSync(stagingPath)) {
9505
+ throw updateTransferError("AGENTLAS_UPDATE_STAGE_MISSING", "복사 후 staging 앱을 찾지 못했습니다.");
9506
+ }
9507
+ const stagingIdentity = await verifyApp(stagingPath, { phase: "staging" });
9508
+ assertSameMacSigningIdentity(sourceIdentity, stagingIdentity, "staging");
9509
+ await runner(commands.mv, [stagingPath, targetApp]);
9510
+ if (fsImpl.existsSync(stagingPath) || !fsImpl.existsSync(targetApp)) {
9511
+ throw updateTransferError("AGENTLAS_UPDATE_COMMIT_FAILED", "검증된 앱의 최종 이동을 확인하지 못했습니다.");
9512
+ }
9513
+ const installedIdentity = await verifyApp(targetApp, { phase: "installed" });
9514
+ assertSameMacSigningIdentity(sourceIdentity, installedIdentity, "설치된");
9515
+
9516
+ let backupRetained = false;
9517
+ if (hadOriginal && fsImpl.existsSync(backupPath)) {
9518
+ try {
9519
+ await removeUpdatePathChecked(backupPath, { fs: fsImpl, runCommand: runner, commands });
9520
+ } catch {
9521
+ backupRetained = fsImpl.existsSync(backupPath);
9522
+ }
9523
+ }
9524
+ return { hadOriginal, backupRetained, backupPath: backupRetained ? backupPath : null };
9525
+ } catch (originalError) {
9526
+ if (hadOriginal && fsImpl.existsSync(backupPath)) {
9527
+ let rollbackError = null;
9528
+ try {
9529
+ if (fsImpl.existsSync(stagingPath)) {
9530
+ try { await removeUpdatePathChecked(stagingPath, { fs: fsImpl, runCommand: runner, commands }); } catch { /* does not block original restore */ }
9531
+ }
9532
+ if (fsImpl.existsSync(targetApp)) {
9533
+ await removeUpdatePathChecked(targetApp, { fs: fsImpl, runCommand: runner, commands });
9534
+ }
9535
+ await runner(commands.mv, [backupPath, targetApp]);
9536
+ if (fsImpl.existsSync(backupPath) || !fsImpl.existsSync(targetApp)) {
9537
+ throw updateTransferError("AGENTLAS_UPDATE_RESTORE_MOVE_FAILED", "백업 앱의 원위치 복구를 확인하지 못했습니다.");
9538
+ }
9539
+ const restoredIdentity = await verifyApp(targetApp, { phase: "restored" });
9540
+ assertSameMacSigningIdentity(originalIdentity, restoredIdentity, "복구된");
9541
+ } catch (error) {
9542
+ rollbackError = error;
9543
+ }
9544
+ if (rollbackError) {
9545
+ const critical = updateTransferError(
9546
+ "AGENTLAS_UPDATE_ROLLBACK_FAILED",
9547
+ `앱 교체 실패 후 원본 복구를 완료하지 못했습니다. target=${targetApp} backup=${backupPath}: ${rollbackError.message || rollbackError}`,
9548
+ originalError,
9549
+ );
9550
+ critical.rollbackError = rollbackError;
9551
+ critical.backupPath = fsImpl.existsSync(backupPath) ? backupPath : null;
9552
+ critical.targetPath = fsImpl.existsSync(targetApp) ? targetApp : null;
9553
+ throw critical;
9554
+ }
9555
+ const rolledBack = updateTransferError(
9556
+ "AGENTLAS_UPDATE_REPLACEMENT_FAILED_ROLLED_BACK",
9557
+ `앱 교체에 실패했지만 기존 앱을 복구하고 서명을 확인했습니다: ${originalError.message || originalError}`,
9558
+ originalError,
9559
+ );
9560
+ rolledBack.restoredPath = targetApp;
9561
+ throw rolledBack;
9562
+ }
9563
+
9564
+ try {
9565
+ if (fsImpl.existsSync(stagingPath)) {
9566
+ await removeUpdatePathChecked(stagingPath, { fs: fsImpl, runCommand: runner, commands });
9567
+ }
9568
+ if (!hadOriginal && fsImpl.existsSync(targetApp)) {
9569
+ await removeUpdatePathChecked(targetApp, { fs: fsImpl, runCommand: runner, commands });
9570
+ }
9571
+ } catch (cleanupError) {
9572
+ const cleanupFailure = updateTransferError(
9573
+ "AGENTLAS_UPDATE_CLEANUP_FAILED",
9574
+ `앱 교체 실패 후 임시 앱을 제거하지 못했습니다: ${cleanupError.message || cleanupError}`,
9575
+ originalError,
9576
+ );
9577
+ cleanupFailure.cleanupError = cleanupError;
9578
+ throw cleanupFailure;
9579
+ }
9580
+ if (hadOriginal && !fsImpl.existsSync(targetApp)) {
9581
+ throw updateTransferError(
9582
+ "AGENTLAS_UPDATE_ROLLBACK_FAILED",
9583
+ `앱 교체 실패 후 기존 앱과 백업을 모두 찾지 못했습니다. target=${targetApp} backup=${backupPath}`,
9584
+ originalError,
9585
+ );
9586
+ }
9587
+ throw originalError;
9588
+ }
9589
+ }
9590
+
7183
9591
  async function installMacDesktopUpdate(release, artifact, flags) {
7184
9592
  const hdiutil = requirePath("/usr/bin/hdiutil", "hdiutil");
7185
9593
  const xcrun = requirePath("/usr/bin/xcrun", "xcrun");
7186
9594
  const spctl = requirePath("/usr/sbin/spctl", "spctl");
9595
+ const codesign = requirePath("/usr/bin/codesign", "codesign");
7187
9596
  const osascript = requirePath("/usr/bin/osascript", "osascript");
7188
9597
  const ditto = requirePath("/usr/bin/ditto", "ditto");
7189
9598
  const plistBuddy = requirePath("/usr/libexec/PlistBuddy", "PlistBuddy");
@@ -7192,16 +9601,21 @@ async function installMacDesktopUpdate(release, artifact, flags) {
7192
9601
  const open = requirePath("/usr/bin/open", "open");
7193
9602
  const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
7194
9603
 
9604
+ const validatedArtifact = validateDesktopUpdateArtifact(artifact);
7195
9605
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-update."));
7196
- const fileName = artifact.fileName || `Agentlas-${release.version}-${artifact.arch || macReleaseArch()}.dmg`;
9606
+ const fileName = validatedArtifact.fileName || `Agentlas-${macReleaseArch() || "mac"}.dmg`;
7197
9607
  const dmgPath = path.join(tmpDir, fileName);
7198
9608
  let mountPoint = "";
7199
- let backupPath = "";
7200
9609
  const targetApp = macAppInstallPath();
9610
+ const transactionId = `${Date.now()}.${process.pid}.${crypto.randomBytes(5).toString("hex")}`;
9611
+ const targetDir = path.dirname(targetApp);
9612
+ const targetName = path.basename(targetApp, path.extname(targetApp));
9613
+ const backupPath = path.join(targetDir, `.${targetName}.backup.${transactionId}.app`);
9614
+ const stagingPath = path.join(targetDir, `.${targetName}.installing.${transactionId}.app`);
7201
9615
 
7202
9616
  try {
7203
9617
  out(`다운로드: ${fileName}`);
7204
- await downloadUpdateFile(artifact.url, dmgPath, artifact);
9618
+ await downloadUpdateFile(validatedArtifact.url, dmgPath, validatedArtifact);
7205
9619
  out("검증: DMG, notarization, Gatekeeper");
7206
9620
  await runCommand(hdiutil, ["verify", dmgPath]);
7207
9621
  await runCommand(xcrun, ["stapler", "validate", dmgPath]);
@@ -7210,34 +9624,29 @@ async function installMacDesktopUpdate(release, artifact, flags) {
7210
9624
  const mount = await runCommand(hdiutil, ["attach", "-nobrowse", "-readonly", dmgPath], { capture: true });
7211
9625
  mountPoint = parseHdiutilMountPoint(mount.stdout);
7212
9626
  const sourceApp = mountPoint ? path.join(mountPoint, "Agentlas.app") : "";
7213
- if (!sourceApp || !fs.existsSync(sourceApp)) fail("DMG 안에서 Agentlas.app을 찾지 못했습니다.");
9627
+ if (!sourceApp || !fs.existsSync(sourceApp)) {
9628
+ throw updateTransferError("AGENTLAS_UPDATE_APP_MISSING", "DMG 안에서 Agentlas.app을 찾지 못했습니다.");
9629
+ }
7214
9630
 
7215
9631
  const installedVersion = await runCommand(plistBuddy, ["-c", "Print :CFBundleShortVersionString", path.join(sourceApp, "Contents", "Info.plist")], { capture: true });
7216
9632
  const appVersion = installedVersion.stdout.trim();
7217
- if (appVersion !== String(release.version)) fail(`앱 버전이 릴리즈와 다릅니다: release=${release.version} app=${appVersion}`);
7218
- await runCommand(spctl, ["-a", "-vv", sourceApp]);
9633
+ if (appVersion !== String(release.version)) {
9634
+ throw updateTransferError("AGENTLAS_UPDATE_VERSION_MISMATCH", `앱 버전이 릴리즈와 다릅니다: release=${release.version} app=${appVersion}`);
9635
+ }
7219
9636
 
7220
9637
  out("설치: 기존 Agentlas 종료 후 앱 교체");
7221
9638
  await runCommand(osascript, ["-e", 'tell application "Agentlas" to quit'], { capture: true, allowFailure: true });
7222
9639
  await sleep(2_000);
7223
- if (fs.existsSync(targetApp)) {
7224
- backupPath = `${targetApp}.backup.${Date.now()}`;
7225
- await runCommand(mv, [targetApp, backupPath]);
7226
- }
7227
- await runCommand(ditto, [sourceApp, targetApp]);
9640
+ const replacement = await replaceMacAppBundle({
9641
+ sourceApp,
9642
+ targetApp,
9643
+ backupPath,
9644
+ stagingPath,
9645
+ runCommand,
9646
+ commands: { codesign, spctl, ditto, mv, rm },
9647
+ });
9648
+ if (replacement.backupRetained) out(`주의: 검증된 새 앱은 설치됐지만 이전 앱 백업을 지우지 못했습니다: ${replacement.backupPath}`);
7228
9649
  if (fs.existsSync(lsregister)) await runCommand(lsregister, ["-f", targetApp], { allowFailure: true });
7229
-
7230
- try {
7231
- await runCommand(spctl, ["-a", "-vv", targetApp]);
7232
- } catch (error) {
7233
- if (backupPath && fs.existsSync(backupPath)) {
7234
- await runCommand(rm, ["-rf", targetApp], { allowFailure: true });
7235
- await runCommand(mv, [backupPath, targetApp], { allowFailure: true });
7236
- }
7237
- throw error;
7238
- }
7239
-
7240
- if (backupPath && fs.existsSync(backupPath)) await runCommand(rm, ["-rf", backupPath], { allowFailure: true });
7241
9650
  if (flags.launch) await runCommand(open, ["-a", "Agentlas"], { allowFailure: true });
7242
9651
  out(`Agentlas ${release.version} 설치 완료.`);
7243
9652
  } finally {
@@ -7581,7 +9990,8 @@ function cmdHelp() {
7581
9990
  " search \"<what you need>\" discover agents in the Hub + local (hep-search)",
7582
9991
  " install <slug> install an agent from the Hub (hep-cloud)",
7583
9992
  " build \"<request>\" build/repair/package an agent or team (hep-build)",
7584
- " upload <path> package + publish an agent to the Hub (hep-upload)",
9993
+ " upload <path> save owner-private in Agent Cloud (default) (hep-upload)",
9994
+ " --visibility marketplace explicit compatibility flag: publish to Hub",
7585
9995
  " connect [<sub>] wire Telegram / platforms to an agent team (hep-connect)",
7586
9996
  " import <path> import a local agent/team folder",
7587
9997
  " list installed agents/companies + active runtime",
@@ -7614,7 +10024,7 @@ function cmdHelp() {
7614
10024
  hdr("ADVANCED"),
7615
10025
  " hep <sub…> full Hephaestus passthrough (wizard·security·cards·ao·plugins·meta-agent…)",
7616
10026
  " netadmin <sub> local network admin: init|status|reindex|bench|add-source",
7617
- " cloud <sub> agent packaging: wizard|security|bundle|package|publish|field-test",
10027
+ " cloud <sub> cloud assets: save|publish|package|list|restore|field-test",
7618
10028
  " cd <agent> print the agent folder — cd \"$(agentlas cd seo)\" && claude",
7619
10029
  " oberon <sub> AI film render (scaffold|render|list)",
7620
10030
  "",
@@ -7666,6 +10076,10 @@ async function main() {
7666
10076
 
7667
10077
  const db = openDb();
7668
10078
 
10079
+ // Finish or compensate any Cloud install interrupted between the durable
10080
+ // filesystem swap and the SQLite transaction before normal agent resolution.
10081
+ recoverCloudInstallJournalsCli(db);
10082
+
7669
10083
  // Agentlas 아키텍처 빌트인 에이전트를 보장(앱과 동일, 멱등·버전 게이팅). 스키마가 준비됐을 때만.
7670
10084
  try { seedBuiltins(db); } catch { /* best-effort */ }
7671
10085
 
@@ -7724,12 +10138,14 @@ async function main() {
7724
10138
  case "search": // hep-search — 에이전트 디렉터리 발견 (Hub + 로컬)
7725
10139
  if (!rest[1]) return fail('usage: agentlas search "<찾는 일>" [--limit 10]');
7726
10140
  return parity().cloudSearch(db, rest.slice(1));
7727
- case "install": // hep-cloud import — slug로 에이전트 설치
10141
+ case "install": // public Hub package install — slug로 에이전트 설치
7728
10142
  if (!rest[1]) return fail('usage: agentlas install <slug> (먼저 agentlas search "할 일" 로 찾으세요)');
7729
10143
  return cmdCloudInstall(db, rest[1]);
7730
- case "upload": // hep-upload 컴파일된 에이전트를 Hub 배포 (경로 필요)
7731
- if (!rest[1]) return fail("usage: agentlas upload <에이전트 폴더 경로> (패키징 Hub 배포)");
7732
- return cmdCloud(db, ["publish", ...rest.slice(1)], runtimeOverride);
10144
+ case "upload": { // 기본은 owner-private Agent Cloud, public Hub 명시 flag로만.
10145
+ if (!rest[1]) return fail("usage: agentlas upload <에이전트 폴더 경로> [--visibility marketplace]");
10146
+ const uploadArgs = rest.slice(1);
10147
+ return cmdCloud(db, [cloudActionForTopLevelUpload(uploadArgs), ...uploadArgs], runtimeOverride);
10148
+ }
7733
10149
  case "connect": // hep-connect — Telegram 등 플랫폼 연결
7734
10150
  return parity().cmdHep(db, ["hep-connect", ...rest.slice(1)]);
7735
10151
  case "browser": // hep-browser — 실제 브라우저 실행 하드포인트
@@ -7791,4 +10207,56 @@ async function main() {
7791
10207
  }
7792
10208
  }
7793
10209
 
7794
- main().catch((e) => fail(String(e && e.stack ? e.stack : e)));
10210
+ // 런처가 스폰하는 실행 파일일 때만 CLI main을 돌린다. 회귀 테스트/라이브러리 require는 종료하지 않는다.
10211
+ if (require.main === module) {
10212
+ main().catch((e) => fail(String(e && e.stack ? e.stack : e)));
10213
+ }
10214
+
10215
+ module.exports = {
10216
+ runApi,
10217
+ normalizeCustomApiBaseUrl,
10218
+ readCustomApiBaseUrl,
10219
+ parseDotEnvCli,
10220
+ isProtectedChildEnvKeyCli,
10221
+ mergeChildEnvValuesCli,
10222
+ resolveCredentialSourcePath,
10223
+ upsertEnvLine,
10224
+ fetchHubCli,
10225
+ hubTimeoutConfig,
10226
+ compareSemVer,
10227
+ parseSemVer,
10228
+ updateTimeoutConfig,
10229
+ fetchUpdateMetadata,
10230
+ validateDesktopUpdateArtifact,
10231
+ downloadUpdateFile,
10232
+ verifyMacAppBundle,
10233
+ replaceMacAppBundle,
10234
+ captureRuntime,
10235
+ buildArgs,
10236
+ captureOutputLimit,
10237
+ materializeCloudListingCli,
10238
+ recoverCloudInstallJournalCli,
10239
+ recoverCloudInstallJournalsCli,
10240
+ persistCloudListingCli,
10241
+ cloudSystemPromptFromPackageCli,
10242
+ agentSystemPromptCli,
10243
+ listOwnedCloudAgentsCli,
10244
+ restoreOwnedCloudAgentCli,
10245
+ deleteCloudAgentCli,
10246
+ readCloudAssetStateCli,
10247
+ normalizeCloudAssetDescriptorCli,
10248
+ packageCloudAgentCli,
10249
+ cloudVisibilityForAction,
10250
+ cloudActionForTopLevelUpload,
10251
+ cloudHashPackage,
10252
+ cloudPackageHashVersion,
10253
+ cloudPortablePathConflict,
10254
+ cloudPortableExecutableForFile,
10255
+ DEFAULT_API_MODEL,
10256
+ ANTHROPIC_COMPAT_API,
10257
+ // 자동 라우팅 회귀 테스트 표면 — 약한 매치 직답/오라우팅 방지 규칙 검증용.
10258
+ autoRouteAgent,
10259
+ autoRouteNote,
10260
+ autoRoutePreamble,
10261
+ directSystemPrompt,
10262
+ };