@awak-app/simy-cli 0.2.3 → 0.3.3

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.
@@ -0,0 +1,116 @@
1
+ import { localTaskFileCapabilityMetadata } from "./local-task-file-capabilities.js";
2
+
3
+ export const EXECUTION_CAPABILITY_SCHEMA_VERSION = "execution_capability.v1";
4
+ export const EXECUTION_CAPABILITY_CONTRACT_VERSION = 1;
5
+
6
+ export function buildExecutionCapabilityContract({
7
+ backends = {},
8
+ backendVersions = {},
9
+ authenticated = false,
10
+ } = {}) {
11
+ const artifactTypes = supportedArtifactTypes();
12
+ return Object.freeze(
13
+ ["codex", "claude"].map((provider) =>
14
+ Object.freeze(
15
+ localExecutionCapability({
16
+ provider,
17
+ inspection: backendVersions?.[provider],
18
+ legacyAvailable: backends?.[provider] === true,
19
+ authenticated,
20
+ artifactTypes,
21
+ }),
22
+ ),
23
+ ),
24
+ );
25
+ }
26
+
27
+ function localExecutionCapability({
28
+ provider,
29
+ inspection,
30
+ legacyAvailable,
31
+ authenticated,
32
+ artifactTypes,
33
+ }) {
34
+ const binary = normalizeInspection(provider, inspection, legacyAvailable);
35
+ const ready = authenticated && binary.compatible;
36
+ return {
37
+ schema_version: EXECUTION_CAPABILITY_SCHEMA_VERSION,
38
+ capability_contract_version: EXECUTION_CAPABILITY_CONTRACT_VERSION,
39
+ execution_target: "local",
40
+ provider,
41
+ // The CLI does not inspect Provider-managed skills, MCP configuration, or
42
+ // connectors. Unknown is materially different from unavailable.
43
+ skills: { status: "unknown", identifiers: [] },
44
+ mcp_servers: [],
45
+ connectors: [],
46
+ cli_binaries: [binary.public],
47
+ browser_capability: { status: "unknown", modes: [] },
48
+ supported_input_artifact_types: [...artifactTypes],
49
+ supported_output_artifact_types: [...artifactTypes],
50
+ side_effect_classes: {
51
+ read: { supported: true, approval_required: false },
52
+ write: { supported: true, approval_required: true },
53
+ external_side_effect: { supported: false, approval_required: true },
54
+ },
55
+ authentication_readiness: {
56
+ device_session: authenticated ? "ready" : "required",
57
+ // A version probe proves only that the binary is runnable. It does not
58
+ // prove the Provider account is authenticated.
59
+ provider: "unknown",
60
+ },
61
+ readiness: {
62
+ ready,
63
+ reason: ready
64
+ ? "ready"
65
+ : !authenticated
66
+ ? "device_session_required"
67
+ : binary.status === "outdated" || binary.status === "unknown_version"
68
+ ? "provider_update_required"
69
+ : "provider_missing",
70
+ },
71
+ missing_capability_recovery_actions: ready
72
+ ? []
73
+ : [
74
+ {
75
+ action: binary.status === "missing" ? "install_provider" : "update_provider",
76
+ command: binary.update_command,
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ function normalizeInspection(provider, value, legacyAvailable = false) {
83
+ const inspection = value && typeof value === "object" ? value : {};
84
+ const compatible = inspection.compatible === true || legacyAvailable;
85
+ const status = cleanString(inspection.status) || (compatible ? "compatible" : "missing");
86
+ const publicBinary = {
87
+ id: provider,
88
+ status,
89
+ available: inspection.available === true || legacyAvailable,
90
+ compatible,
91
+ installed_version: cleanString(inspection.installed_version),
92
+ minimum_version: cleanString(inspection.minimum_version),
93
+ update_command: cleanString(inspection.update_command),
94
+ };
95
+ return {
96
+ ...publicBinary,
97
+ public: publicBinary,
98
+ };
99
+ }
100
+
101
+ function supportedArtifactTypes() {
102
+ const registry = localTaskFileCapabilityMetadata();
103
+ return Object.freeze(
104
+ [
105
+ ...new Set(
106
+ registry.categories.flatMap((category) =>
107
+ category.formats.map((format) => format.canonical_mime),
108
+ ),
109
+ ),
110
+ ].sort(),
111
+ );
112
+ }
113
+
114
+ function cleanString(value) {
115
+ return typeof value === "string" && value.trim() ? value.trim() : null;
116
+ }
@@ -1,13 +1,21 @@
1
- export const EXECUTION_GUARDRAIL_POLICY_VERSION = "2026-07-21.1";
1
+ export const EXECUTION_GUARDRAIL_POLICY_VERSION = "2026-07-26.1";
2
2
 
3
3
  const OBSERVE_PATTERN =
4
4
  /\b(monitor|watch|observe|track|status|progress|keep an eye on)\b|監視|見守|進捗|状況を?(?:見|確認)|监控|監控|观察|觀察|进度|進度|状态|狀態/i;
5
5
  const READ_PATTERN =
6
6
  /\b(explain|summari[sz]e|inspect|review|look at|read|check|diagnose|investigate)\b|説明|要約|確認|調査|レビュー|查看|解释|說明|总结|總結|检查|檢查|调查|調查|诊断|診斷/i;
7
+ const INQUIRY_PATTERN =
8
+ /\b(?:can|could|would|what|which|how|whether|tell me|is it possible)\b|できますか|可能ですか|教えて|どうすれば|何が|どのように|可以吗|可以嗎|能否|怎么|怎麼|如何|什么|什麼|[??]/i;
7
9
  const CHANGE_PATTERN =
8
- /\b(implement|fix|build|create|change|update|refactor|add|remove|rename|ship|write|edit|merge|release|publish)\b|実装|修正|改善|変更|追加|削除|作成|開発|マージ|公開|发布|發佈|实现|實現|修复|修復|修改|改掉|改一下|新增|删除|刪除|重构|重構|合并|合併/i;
10
+ /\b(implement|fix|build|create|change|update|refactor|add|remove|delete|rename|ship|write|edit|merge|release|publish)\b|実装|修正|改善|変更|追加|削除|作成|生成|開発|マージ|公開|发布|發佈|实现|實現|修复|修復|修改|改掉|改一下|新增|删除|刪除|重构|重構|合并|合併|制作|製作|生成/i;
9
11
  const OBJECT_PATTERN =
10
12
  /\b(code|file|readme|api|ui|ux|feature|bug|pr|pull request|repository|repo|branch|test|migration|endpoint|component|page|cli|backend|extension)\b|コード|ファイル|機能|不具合|画面|ページ|テスト|移行|组件|組件|页面|頁面|代码|代碼|文件|功能|错误|錯誤|仓库|倉庫|分支/i;
13
+ const CODING_TARGET_PATTERN =
14
+ /\b(code|readme|api|ui|ux|feature|bug|pr|pull request|repository|repo|branch|test|migration|endpoint|component|page|cli|backend|extension)\b|コード|機能|不具合|画面|ページ|テスト|移行|组件|組件|页面|頁面|代码|代碼|功能|错误|錯誤|仓库|倉庫|分支/i;
15
+ const KNOWLEDGE_WORK_PATTERN =
16
+ /\b(report|summary|memo|brief|document|presentation|slides?|powerpoint|pptx|spreadsheet|excel|xlsx|csv|analysis|research|plan|proposal|transcript|notes?|table|chart|image|illustration|poster|banner|thumbnail|png|jpe?g)\b|レポート|要約|資料|文書|プレゼン|スライド|表計算|分析|調査|計画|提案|文字起こし|画像|イラスト|ポスター|バナー|サムネイル|报告|報告|总结|總結|文档|文檔|演示|簡報|幻灯片|試算表|表格|分析|调研|調研|计划|計劃|提案|转录|轉錄|图片|圖片|插图|插圖|海报|海報|横幅|橫幅|缩略图|縮略圖/i;
17
+ const EXTERNAL_EFFECT_PATTERN =
18
+ /\b(send|post|submit|upload|purchase|pay|book|register)\b[\s\S]*\b(email|mail|slack|message|form|youtube|social|payment|order|reservation|accounting)\b|メール送信|Slack.*投稿|フォーム.*送信|アップロード|購入|支払|予約|会計.*登録|发送.*(?:邮件|Slack|消息)|發送.*(?:郵件|Slack|訊息)|发布.*消息|發佈.*訊息|提交.*表单|提交.*表單|上传|上傳|购买|購買|支付|预订|預訂|登记.*会计|登記.*會計/i;
11
19
  const VAGUE_PATTERN = /^(?:fix|do|handle|continue|change)?\s*(?:it|this|that)?[.!。!]?$/i;
12
20
  const VAGUE_CJK_PATTERN = /^(?:これ|それ|あれ|やって|直して|続けて|这个|這個|那个|那個|处理|處理|修一下)[。!]?$/i;
13
21
  const UNSUPPORTED_PATTERN =
@@ -15,7 +23,7 @@ const UNSUPPORTED_PATTERN =
15
23
 
16
24
  const MANAGED_SIGNAL_PATTERNS = [
17
25
  ["pr_merge_release", /\b(?:pull request|pr|merge|release|publish|deploy)\b|PR|マージ|リリース|デプロイ|合并|合併|发布|發佈|部署/i],
18
- ["verification", /\b(?:test|e2e|integration test|until green|ci)\b|テスト|検証|通过测试|通過測試|直到.*通过|直到.*通過/i],
26
+ ["verification", /\b(?:tests?|e2e|integration tests?|until green|ci)\b|テスト|検証|通过测试|通過測試|直到.*通过|直到.*通過/i],
19
27
  ["browser_evidence", /\b(?:browser|screenshots?|visual evidence|evidence)\b|ブラウザ|スクリーンショット|証跡|截图|截圖|浏览器|瀏覽器|证据|證據/i],
20
28
  ["cross_repository", /\b(?:cross[- ]repo|multiple repos|multi[- ]repo)\b|複数(?:の)?リポジトリ|跨仓库|跨倉庫|多个仓库|多個倉庫/i],
21
29
  ["high_risk_change", /\b(?:migration|authentication|authorization|security|rollback)\b|移行|認証|認可|セキュリティ|ロールバック|迁移|遷移|认证|認證|授权|授權|安全|回滚|回滾/i],
@@ -42,39 +50,75 @@ export function classifyExecutionRequirement(sourceMessage) {
42
50
  reason_code: "prohibited_operation",
43
51
  });
44
52
  }
45
- const changeRequested = CHANGE_PATTERN.test(source);
46
- const observeRequested = OBSERVE_PATTERN.test(source);
47
- const readRequested = READ_PATTERN.test(source);
48
- const hasObject = OBJECT_PATTERN.test(source) || source.length >= 24;
49
- const agenticSignals = MANAGED_SIGNAL_PATTERNS.filter(([, pattern]) => pattern.test(source)).map(
53
+ const intent = classificationIntent(source);
54
+ const changeRequested = CHANGE_PATTERN.test(intent);
55
+ const observeRequested = OBSERVE_PATTERN.test(intent);
56
+ const readRequested =
57
+ READ_PATTERN.test(intent) || (INQUIRY_PATTERN.test(intent) && !changeRequested);
58
+ const codingTarget = CODING_TARGET_PATTERN.test(intent);
59
+ const knowledgeWork = KNOWLEDGE_WORK_PATTERN.test(source);
60
+ const externalEffect = EXTERNAL_EFFECT_PATTERN.test(intent);
61
+ const hasObject = OBJECT_PATTERN.test(intent) || source.length >= 24;
62
+ const agenticSignals = MANAGED_SIGNAL_PATTERNS.filter(([, pattern]) => pattern.test(intent)).map(
50
63
  ([signal]) => signal,
51
64
  );
52
- if (changeRequested && hasObject && agenticSignals.length >= 2) {
65
+ if (externalEffect && !codingTarget) {
66
+ return guardrail("unsupported", {
67
+ change_requested: true,
68
+ operation: "execute",
69
+ permission_mode: "workspace_write",
70
+ domain: "external_operation",
71
+ external_effects: "requested",
72
+ reason_code: "external_effect_not_supported",
73
+ });
74
+ }
75
+ if (changeRequested && codingTarget && hasObject && agenticSignals.length >= 2) {
53
76
  return guardrail("agentic_loop", {
54
77
  change_requested: true,
55
78
  managed_lifecycle_required: true,
56
79
  repository_required: true,
57
80
  operation: "execute",
58
81
  permission_mode: "workspace_write",
82
+ domain: "coding",
83
+ workspace_kind: "repository",
59
84
  agentic_signals: agenticSignals,
60
85
  reason_code: "managed_change_lifecycle_required",
61
86
  });
62
87
  }
63
88
  if (observeRequested && !changeRequested) {
64
89
  return guardrail("direct_executor", {
65
- repository_required: /\b(?:repo|repository|pr|branch)\b|リポジトリ|仓库|倉庫|分支/i.test(source),
90
+ repository_required:
91
+ /\b(?:repo|repository|pr|branch)\b|リポジトリ|仓库|倉庫|分支/i.test(source) ||
92
+ codingTarget,
66
93
  operation: "observe",
67
94
  permission_mode: "read_only",
95
+ domain: codingTarget ? "coding" : "knowledge_work",
96
+ workspace_kind: codingTarget ? "repository" : "task_sandbox",
68
97
  direct_signals: ["bounded_observation", "no_persistent_change"],
69
98
  reason_code: "observe_existing_provider_session",
70
99
  });
71
100
  }
72
- if (changeRequested && hasObject) {
101
+ if (changeRequested && knowledgeWork && !codingTarget) {
102
+ return guardrail("direct_executor", {
103
+ change_requested: true,
104
+ repository_required: false,
105
+ operation: "execute",
106
+ permission_mode: "workspace_write",
107
+ domain: "knowledge_work",
108
+ workspace_kind: "task_sandbox",
109
+ agentic_signals: [],
110
+ direct_signals: ["bounded_knowledge_work", "managed_lifecycle_not_required"],
111
+ reason_code: "bounded_knowledge_work",
112
+ });
113
+ }
114
+ if (changeRequested && codingTarget && hasObject) {
73
115
  return guardrail("direct_executor", {
74
116
  change_requested: true,
75
117
  repository_required: true,
76
118
  operation: "execute",
77
119
  permission_mode: "workspace_write",
120
+ domain: "coding",
121
+ workspace_kind: "repository",
78
122
  agentic_signals: agenticSignals,
79
123
  direct_signals: ["single_bounded_change", "managed_lifecycle_not_required"],
80
124
  reason_code: "bounded_change_direct_execution",
@@ -90,9 +134,11 @@ export function classifyExecutionRequirement(sourceMessage) {
90
134
  }
91
135
  if (readRequested || source.length >= 8) {
92
136
  return guardrail("direct_executor", {
93
- repository_required: /\b(?:repo|repository|pr|file|code)\b|コード|ファイル|仓库|倉庫|文件|代码|代碼/i.test(source),
137
+ repository_required: codingTarget,
94
138
  operation: "read",
95
139
  permission_mode: "read_only",
140
+ domain: codingTarget ? "coding" : "knowledge_work",
141
+ workspace_kind: codingTarget ? "repository" : "task_sandbox",
96
142
  direct_signals: ["single_read_task", "no_managed_lifecycle"],
97
143
  reason_code: "bounded_read_direct_execution",
98
144
  });
@@ -100,6 +146,9 @@ export function classifyExecutionRequirement(sourceMessage) {
100
146
  return guardrail("needs_clarification", {
101
147
  operation: "read",
102
148
  permission_mode: "read_only",
149
+ domain: "knowledge_work",
150
+ workspace_kind: "task_sandbox",
151
+ external_effects: "deny",
103
152
  reason_code: "action_missing",
104
153
  });
105
154
  }
@@ -118,3 +167,11 @@ function guardrail(decision, overrides) {
118
167
  ...overrides,
119
168
  };
120
169
  }
170
+
171
+ function classificationIntent(source) {
172
+ const leadingQuestion = source.match(/^([\s\S]{1,280}?[??])/);
173
+ if (leadingQuestion && INQUIRY_PATTERN.test(leadingQuestion[1])) {
174
+ return leadingQuestion[1].trim();
175
+ }
176
+ return source;
177
+ }
package/src/index.js CHANGED
@@ -12,6 +12,10 @@ import {
12
12
  writeUpdateHandoffFromEnvironment,
13
13
  } from "./auto-update.js";
14
14
  import { openAuthorizationUrl } from "./browser.js";
15
+ import {
16
+ createAgentShutdown,
17
+ installAgentShutdownSignalHandlers,
18
+ } from "./shutdown.js";
15
19
  import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
16
20
 
17
21
  const argv = process.argv.slice(2);
@@ -73,6 +77,8 @@ const agent = await startAgent({
73
77
  quiet: interactive,
74
78
  updateManager,
75
79
  });
80
+ const shutdownAgent = createAgentShutdown(agent);
81
+ installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
76
82
  await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
77
83
  updateManager.start({
78
84
  registry: agent.registry,
@@ -82,7 +88,7 @@ updateManager.start({
82
88
  argv: process.argv.slice(2),
83
89
  targetVersion,
84
90
  });
85
- await closeServer(agent.server);
91
+ await shutdownAgent();
86
92
  },
87
93
  });
88
94
  if (agent.loginUrl && !args.has("--no-open")) {
@@ -93,7 +99,7 @@ if (interactive) {
93
99
  try {
94
100
  await runAgenticLoopConsole(agent);
95
101
  } finally {
96
- await closeServer(agent.server);
102
+ await shutdownAgent();
97
103
  }
98
104
  }
99
105
 
@@ -117,8 +123,3 @@ function readOption(argv, name) {
117
123
  }
118
124
  return value;
119
125
  }
120
-
121
- function closeServer(server) {
122
- if (!server.listening) return Promise.resolve();
123
- return new Promise((resolve) => server.close(resolve));
124
- }
@@ -3,27 +3,26 @@ import { mkdir, open, readFile, readdir, realpath, rm, stat } from "node:fs/prom
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
- export const MAX_ATTACHMENT_COUNT = 5;
7
- export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
8
- export const MAX_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024;
6
+ import {
7
+ canonicalMimeTypeForFilename,
8
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY,
9
+ LocalTaskFileCapabilityError,
10
+ normalizeLocalTaskMimeType,
11
+ validateLocalTaskFile,
12
+ validateLocalTaskFileCollection,
13
+ } from "./local-task-file-capabilities.js";
14
+
15
+ export const MAX_ATTACHMENT_COUNT =
16
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.input.max_files;
17
+ export const MAX_ATTACHMENT_BYTES = Math.max(
18
+ ...LOCAL_TASK_FILE_CAPABILITY_REGISTRY.categories.map(
19
+ (category) => category.input_max_bytes,
20
+ ),
21
+ );
22
+ export const MAX_ATTACHMENTS_TOTAL_BYTES =
23
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.input.max_total_bytes;
9
24
  const RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
10
- const SAFE_RUN_ID = /^(?:agentic_loop|coding_loop)_[A-Za-z0-9_-]+$/;
11
- const ALLOWED_EXACT_MIME_TYPES = new Set([
12
- "application/json",
13
- "application/octet-stream",
14
- "application/pdf",
15
- "application/rtf",
16
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
17
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
18
- "application/vnd.openxmlformats-officedocument.presentationml.presentation",
19
- "application/zip",
20
- "application/x-yaml",
21
- "image/gif",
22
- "image/jpeg",
23
- "image/png",
24
- "image/svg+xml",
25
- "image/webp",
26
- ]);
25
+ const SAFE_RUN_ID = /^(?:agentic_loop|coding_loop|local_task)_[A-Za-z0-9_-]+$/;
27
26
 
28
27
  export function runsRoot(override) {
29
28
  return path.resolve(
@@ -32,56 +31,75 @@ export function runsRoot(override) {
32
31
  }
33
32
 
34
33
  export async function stageRunAttachments({ runId, attachments, manifest, root }) {
35
- if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid agentic loop run_id");
34
+ const prepared = await prepareRunAttachments({ attachments, manifest });
35
+ return stagePreparedRunAttachments({ runId, prepared, root });
36
+ }
37
+
38
+ export async function prepareRunAttachments({ attachments, manifest }) {
36
39
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
37
- if (attachments.length > MAX_ATTACHMENT_COUNT) {
38
- throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
39
- }
40
40
  if (manifest !== undefined && (!Array.isArray(manifest) || manifest.length !== attachments.length)) {
41
- throw new Error("attachment manifest does not match uploaded files");
41
+ throw new LocalTaskFileCapabilityError(
42
+ "The attachment list no longer matches the files you selected.",
43
+ {
44
+ code: "local_task_attachment_manifest_mismatch",
45
+ recoveryAction:
46
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions.reattach_file,
47
+ },
48
+ );
49
+ }
50
+ const prepared = [];
51
+ for (const [index, attachment] of attachments.entries()) {
52
+ const name = String(attachment.name || "");
53
+ const mimeType = String(attachment.type || "").trim()
54
+ ? normalizeLocalTaskMimeType(attachment.type)
55
+ : canonicalMimeTypeForFilename(name);
56
+ const bytes = Buffer.from(await attachment.arrayBuffer());
57
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
58
+ if (manifest) verifyManifestEntry(manifest[index], { name, mimeType, size: bytes.length, sha256 });
59
+ const validated = validateLocalTaskFile({
60
+ name,
61
+ mime_type: mimeType,
62
+ size_bytes: bytes.length,
63
+ sha256,
64
+ bytes,
65
+ });
66
+ prepared.push({ ...validated, bytes });
42
67
  }
68
+ validateLocalTaskFileCollection(prepared, {
69
+ direction: "input",
70
+ requireBytes: true,
71
+ });
72
+ return prepared;
73
+ }
74
+
75
+ export async function stagePreparedRunAttachments({ runId, prepared, root }) {
76
+ if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid local execution run_id");
77
+ if (!Array.isArray(prepared) || prepared.length === 0) return [];
43
78
 
44
79
  const runRoot = path.join(runsRoot(root), runId);
45
80
  const inputsRoot = path.join(runRoot, "inputs");
81
+ const localTask = runId.startsWith("local_task_");
46
82
  await mkdir(runsRoot(root), { recursive: true, mode: 0o700 });
47
- await mkdir(runRoot, { mode: 0o700 });
48
- await mkdir(inputsRoot, { mode: 0o700 });
83
+ await mkdir(runRoot, { recursive: localTask, mode: 0o700 });
84
+ await mkdir(inputsRoot, { recursive: localTask, mode: 0o700 });
49
85
  const staged = [];
50
- let totalBytes = 0;
51
86
 
52
87
  try {
53
- for (const [index, attachment] of attachments.entries()) {
54
- const name = safeAttachmentName(attachment.name);
55
- const mimeType = normalizeMimeType(attachment.type);
56
- if (!isAllowedMimeType(mimeType)) {
57
- throw new Error(`attachment ${name} has unsupported MIME type ${mimeType || "unknown"}`);
58
- }
59
- const bytes = Buffer.from(await attachment.arrayBuffer());
60
- if (bytes.length > MAX_ATTACHMENT_BYTES) {
61
- throw new Error(`attachment ${name} exceeds the 20 MB limit`);
62
- }
63
- totalBytes += bytes.length;
64
- if (totalBytes > MAX_ATTACHMENTS_TOTAL_BYTES) {
65
- throw new Error("attachments exceed the 50 MB total limit");
66
- }
67
-
68
- const sha256 = createHash("sha256").update(bytes).digest("hex");
69
- if (manifest) verifyManifestEntry(manifest[index], { name, mimeType, size: bytes.length, sha256 });
70
-
88
+ for (const attachment of prepared) {
71
89
  const id = `attachment_${randomUUID()}`;
72
- const localPath = path.join(inputsRoot, `${id}-${name}`);
90
+ const localPath = path.join(inputsRoot, `${id}-${attachment.name}`);
73
91
  const handle = await open(localPath, "wx", 0o600);
74
92
  try {
75
- await handle.writeFile(bytes);
93
+ await handle.writeFile(attachment.bytes);
76
94
  } finally {
77
95
  await handle.close();
78
96
  }
79
97
  staged.push({
80
98
  id,
81
- name,
82
- mime_type: mimeType,
83
- size_bytes: bytes.length,
84
- sha256,
99
+ name: attachment.name,
100
+ mime_type: attachment.mime_type,
101
+ size_bytes: attachment.size_bytes,
102
+ sha256: attachment.sha256,
85
103
  local_path: localPath,
86
104
  integrity_status: "verified",
87
105
  staged_at: new Date().toISOString(),
@@ -110,29 +128,28 @@ export async function cleanupRunAttachments(attachments) {
110
128
 
111
129
  export async function referenceLocalAttachmentPaths(values) {
112
130
  const paths = [...new Set((values || []).map((value) => path.resolve(String(value))))];
113
- if (paths.length > MAX_ATTACHMENT_COUNT) {
114
- throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
115
- }
116
- let totalBytes = 0;
117
131
  const attachments = [];
118
132
  for (const value of paths) {
119
133
  const resolved = await realpath(value);
120
134
  const details = await stat(resolved);
121
- if (!details.isFile()) throw new Error(`${value} is not a file`);
122
- if (details.size > MAX_ATTACHMENT_BYTES) {
123
- throw new Error(`${path.basename(resolved)} exceeds the 20 MB limit`);
124
- }
125
- totalBytes += details.size;
126
- if (totalBytes > MAX_ATTACHMENTS_TOTAL_BYTES) {
127
- throw new Error("attachments exceed the 50 MB total limit");
135
+ if (!details.isFile()) {
136
+ throw new LocalTaskFileCapabilityError(`${value} is not a file`, {
137
+ code: "local_task_file_not_found",
138
+ recoveryAction:
139
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions.reattach_file,
140
+ });
128
141
  }
129
142
  const bytes = await readFile(resolved);
130
- attachments.push({
131
- id: `attachment_${randomUUID()}`,
132
- name: safeAttachmentName(path.basename(resolved)),
133
- mime_type: inferMimeType(resolved),
143
+ const validated = validateLocalTaskFile({
144
+ name: path.basename(resolved),
145
+ mime_type: canonicalMimeTypeForFilename(resolved),
134
146
  size_bytes: details.size,
135
147
  sha256: createHash("sha256").update(bytes).digest("hex"),
148
+ bytes,
149
+ }, { requireBytes: true });
150
+ attachments.push({
151
+ id: `attachment_${randomUUID()}`,
152
+ ...validated,
136
153
  local_path: resolved,
137
154
  integrity_status: "verified",
138
155
  staged_at: new Date().toISOString(),
@@ -144,6 +161,7 @@ export async function referenceLocalAttachmentPaths(values) {
144
161
  storage: "source_path",
145
162
  });
146
163
  }
164
+ validateLocalTaskFileCollection(attachments, { direction: "input" });
147
165
  return attachments;
148
166
  }
149
167
 
@@ -212,59 +230,22 @@ export function markAttachmentsCleanupFailed(attachments, error) {
212
230
  }
213
231
  }
214
232
 
215
- function safeAttachmentName(value) {
216
- const name = String(value || "")
217
- .normalize("NFKC")
218
- .replace(/[\u0000-\u001f\u007f]/g, "")
219
- .trim();
220
- if (!name || name !== path.basename(name) || name === "." || name === "..") {
221
- throw new Error("attachment has an invalid file name");
222
- }
223
- if (name.length > 180) throw new Error("attachment file name exceeds 180 characters");
224
- return name;
225
- }
226
-
227
- function normalizeMimeType(value) {
228
- return String(value || "application/octet-stream")
229
- .split(";", 1)[0]
230
- .trim()
231
- .toLowerCase();
232
- }
233
-
234
- function isAllowedMimeType(value) {
235
- return value.startsWith("text/") || ALLOWED_EXACT_MIME_TYPES.has(value);
236
- }
237
-
238
- function inferMimeType(value) {
239
- const extension = path.extname(value).toLowerCase();
240
- return (
241
- {
242
- ".gif": "image/gif",
243
- ".jpeg": "image/jpeg",
244
- ".jpg": "image/jpeg",
245
- ".json": "application/json",
246
- ".md": "text/markdown",
247
- ".pdf": "application/pdf",
248
- ".png": "image/png",
249
- ".svg": "image/svg+xml",
250
- ".txt": "text/plain",
251
- ".webp": "image/webp",
252
- ".yaml": "application/x-yaml",
253
- ".yml": "application/x-yaml",
254
- ".zip": "application/zip",
255
- }[extension] || "application/octet-stream"
256
- );
257
- }
258
-
259
233
  function verifyManifestEntry(value, actual) {
260
234
  if (
261
235
  !value ||
262
236
  typeof value !== "object" ||
263
237
  value.name !== actual.name ||
264
- normalizeMimeType(value.mime_type) !== actual.mimeType ||
238
+ normalizeLocalTaskMimeType(value.mime_type) !== actual.mimeType ||
265
239
  value.size_bytes !== actual.size ||
266
240
  String(value.sha256 || "").toLowerCase() !== actual.sha256
267
241
  ) {
268
- throw new Error(`attachment integrity check failed for ${actual.name}`);
242
+ throw new LocalTaskFileCapabilityError(
243
+ `The attachment integrity check failed for ${actual.name}.`,
244
+ {
245
+ code: "local_task_attachment_manifest_mismatch",
246
+ recoveryAction:
247
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions.reattach_file,
248
+ },
249
+ );
269
250
  }
270
251
  }