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.
- package/README.md +48 -6
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +40 -56
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +112 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +136 -12
- package/engine/agentlas-input.cjs +118 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +315 -45
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +239 -70
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +352 -23
- package/engine/agentlas.cjs +2819 -351
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/route-regression.cjs +121 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +20 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +472 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
package/engine/agentlas.cjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
497
|
-
|
|
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
|
|
500
|
-
|
|
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
|
|
512
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
821
|
-
"
|
|
822
|
-
"
|
|
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
|
|
1056
|
+
" search the public Hub (no sign-in needed)",
|
|
825
1057
|
"",
|
|
826
|
-
"
|
|
827
|
-
"--llm-review
|
|
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 <
|
|
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
|
|
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
|
|
1165
|
+
const requestedRoot = path.resolve(root);
|
|
909
1166
|
let st;
|
|
910
|
-
try { st = fs.
|
|
911
|
-
if (!st.isDirectory())
|
|
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
|
-
|
|
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
|
|
916
|
-
const
|
|
917
|
-
const
|
|
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(
|
|
924
|
-
agentKind: cloudInferKind(
|
|
925
|
-
runtimeLabels:
|
|
926
|
-
visibility
|
|
927
|
-
|
|
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(
|
|
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 = {
|
|
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,
|
|
947
|
-
: cloudStaticReview(
|
|
948
|
-
const allFindings = [...
|
|
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,
|
|
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"
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1006
|
-
|
|
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:
|
|
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 (
|
|
1014
|
-
addFinding("large-file", "
|
|
1015
|
-
files.push({ path: rel, bytes:
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
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(
|
|
1039
|
-
included.sort(
|
|
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(
|
|
1044
|
-
const
|
|
1045
|
-
if (!
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
1168
|
-
const 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
|
-
|
|
1171
|
-
|
|
1172
|
-
url: json.url,
|
|
1173
|
-
marketplaceUrl: json.marketplaceUrl,
|
|
1174
|
-
registeredAt: json.registeredAt
|
|
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
|
|
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: {
|
|
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)
|
|
1191
|
-
|
|
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(`
|
|
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
|
|
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
|
|
2079
|
+
const resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
|
|
1239
2080
|
method: "POST",
|
|
1240
2081
|
headers,
|
|
1241
|
-
body: JSON.stringify({ method:
|
|
2082
|
+
body: JSON.stringify({ method: name, params: { name, arguments: args || {} } }),
|
|
1242
2083
|
});
|
|
1243
|
-
if (!resp.ok) fail(
|
|
1244
|
-
const json =
|
|
1245
|
-
if (json.error) fail(
|
|
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
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
1271
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
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.
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
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
|
|
1303
|
-
const
|
|
1304
|
-
if (!
|
|
1305
|
-
|
|
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
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
1335
|
-
|
|
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(
|
|
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] :
|
|
2812
|
+
return (heading ? heading[1] : fallbackName).replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1347
2813
|
}
|
|
1348
|
-
function cloudReadTagline(
|
|
1349
|
-
const manifest = cloudReadPackageJson(
|
|
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(
|
|
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(
|
|
1366
|
-
const manifest = cloudReadPackageJson(
|
|
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(
|
|
2843
|
+
function cloudReadPackageJson(snapshot) {
|
|
1378
2844
|
return {
|
|
1379
|
-
agentlas:
|
|
1380
|
-
manifest:
|
|
1381
|
-
agentCard:
|
|
1382
|
-
routingCard:
|
|
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
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
5987
|
-
|
|
5988
|
-
if (
|
|
5989
|
-
|
|
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",
|
|
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)
|
|
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"
|
|
6000
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
6269
|
-
//
|
|
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
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
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
|
-
|
|
6285
|
-
const
|
|
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 =
|
|
6294
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
8188
|
+
throw new Error(`Hub 응답 ${resp.status}${authHint}`);
|
|
6361
8189
|
}
|
|
6362
|
-
const json =
|
|
6363
|
-
if (json.error) throw new Error(json.error.message || "
|
|
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(
|
|
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
|
|
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
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
env
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
})
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
}
|
|
6507
|
-
|
|
6508
|
-
|
|
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
|
-
|
|
8453
|
+
finishReject(new Error(`${kind} exited ${code}: ${stderr.slice(-500)}`));
|
|
6511
8454
|
return;
|
|
6512
8455
|
}
|
|
6513
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
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
|
|
7009
|
-
const
|
|
7010
|
-
const
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
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
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
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
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
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 =
|
|
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(
|
|
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))
|
|
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))
|
|
7218
|
-
|
|
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
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
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>
|
|
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>
|
|
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": //
|
|
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": //
|
|
7731
|
-
if (!rest[1]) return fail("usage: agentlas upload <에이전트 폴더 경로>
|
|
7732
|
-
|
|
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
|
-
|
|
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
|
+
};
|