ags-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import yaml
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from harness.adapters.registry import build_adapter
10
+ from harness.models.adapter_health import AdapterHealth
11
+ from harness.models.enums import adapter_kind_from_config
12
+ from harness.models.provider_config import ProviderConfig
13
+ from harness.observability.audit import write_audit
14
+ from harness.schemas.adapter import AdapterTestResult
15
+ from harness.security.secrets import delete_secret_file, write_secret_file
16
+ from harness.settings import PROVIDERS_DIR, get_settings
17
+
18
+ _NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
19
+
20
+
21
+ def resolve_model_alias(config: dict, provider: str, name: str) -> str:
22
+ """Resolve a model alias for a given provider.
23
+
24
+ If `name` is an alias defined in config["model_aliases"], return the mapped model
25
+ for the provider. If the alias exists but not for this provider, raise ValueError
26
+ listing which providers the alias covers. If `name` is not an alias, return it unchanged.
27
+ Aliases cannot reference other aliases; the resolved value must be a real model id.
28
+
29
+ Args:
30
+ config: The parsed config.yaml dictionary
31
+ provider: Provider name (e.g., 'claude', 'local', 'antigravity')
32
+ name: Model name or alias name
33
+
34
+ Returns:
35
+ The resolved model name, or the input `name` if it's not an alias.
36
+
37
+ Raises:
38
+ ValueError: If the alias exists but has no entry for this provider.
39
+ """
40
+ aliases = config.get("model_aliases", {})
41
+
42
+ # If name is not in aliases, it's a regular model name — pass through
43
+ if name not in aliases:
44
+ return name
45
+
46
+ # name is an alias; look it up for this provider
47
+ alias_def = aliases[name]
48
+ if provider in alias_def:
49
+ return alias_def[provider]
50
+
51
+ # Alias exists but not for this provider
52
+ providers = ", ".join(sorted(alias_def.keys()))
53
+ raise ValueError(
54
+ f"alias '{name}' is not defined for provider '{provider}' (defined for: {providers})"
55
+ )
56
+
57
+
58
+ def _provider_file(name: str):
59
+ return PROVIDERS_DIR / f"{name}.yaml"
60
+
61
+
62
+ def managed_provider_names() -> set[str]:
63
+ """Names of GUI-added (overlay) providers — the ones deletable from the GUI."""
64
+ if not PROVIDERS_DIR.is_dir():
65
+ return set()
66
+ return {f.stem for f in PROVIDERS_DIR.glob("*.yaml")}
67
+
68
+
69
+ async def list_provider_configs(db: AsyncSession) -> list[ProviderConfig]:
70
+ return list(
71
+ (await db.execute(select(ProviderConfig).order_by(ProviderConfig.name))).scalars()
72
+ )
73
+
74
+
75
+ async def create_provider(
76
+ db: AsyncSession, *, name: str, base_url: str, model: str | None = None,
77
+ api_key: str | None = None, tool_calling: bool = False,
78
+ params: dict | None = None, actor: str = "gui",
79
+ update: bool = False,
80
+ ) -> dict:
81
+ """Add or update an OpenAI-compatible provider from the GUI. Persists durably as a
82
+ ``~/.ags/providers.d/<name>.yaml`` overlay (so ``ags init`` keeps it) plus a
83
+ ``0600`` file secret for the key — never stored in the DB. Also upserts the DB
84
+ row so it's usable immediately."""
85
+ from sqlalchemy import select
86
+ name = (name or "").strip().lower()
87
+ if not _NAME_RE.fullmatch(name):
88
+ raise ValueError("name must be lowercase letters/digits/-/_ (e.g. 'groq')")
89
+ existing = await _configs_named(db, name)
90
+ if existing and not update:
91
+ raise ValueError(f"provider '{name}' already exists")
92
+ if not existing and update:
93
+ raise ValueError(f"provider '{name}' not found for update")
94
+
95
+ # If updating but api_key was empty, preserve the existing secret_ref
96
+ secret_ref = None
97
+ if api_key and api_key.strip():
98
+ secret_ref = write_secret_file(name, api_key.strip())
99
+ elif update and existing:
100
+ secret_ref = existing[0].secret_ref
101
+ capabilities = {"streaming": True, "tool_calling": bool(tool_calling)}
102
+ entry = {
103
+ "name": name, "kind": "openai", "enabled": True, "base_url": base_url,
104
+ "model": model, "secret_ref": secret_ref, "capabilities": capabilities,
105
+ "params": params or {},
106
+ }
107
+ PROVIDERS_DIR.mkdir(parents=True, exist_ok=True)
108
+ _provider_file(name).write_text(yaml.safe_dump(entry, sort_keys=False))
109
+ get_settings().reset_config_cache()
110
+
111
+ if update and existing:
112
+ pc = existing[0]
113
+ pc.base_url = base_url
114
+ pc.model = model
115
+ pc.secret_ref = secret_ref
116
+ pc.capabilities = capabilities
117
+ pc.params = params or {}
118
+ else:
119
+ db.add(ProviderConfig(
120
+ name=name, kind=adapter_kind_from_config("openai"), base_url=base_url,
121
+ model=model, secret_ref=secret_ref, params=params or {}, capabilities=capabilities,
122
+ enabled=True,
123
+ ))
124
+ await db.flush()
125
+ await write_audit(
126
+ db, actor=actor, action="update_provider" if update else "create_provider", target_type="provider", target_id=name,
127
+ before=None, after={"base_url": base_url, "model": model, "has_key": bool(secret_ref)},
128
+ )
129
+ await db.commit() # durable before we respond, so an immediate GET /adapters sees it
130
+ return {"name": name, "kind": "openai_local", "base_url": base_url, "model": model,
131
+ "enabled": True, "capabilities": capabilities}
132
+
133
+
134
+ async def delete_provider(db: AsyncSession, name: str, *, actor: str = "gui") -> dict:
135
+ """Remove a GUI-added provider (overlay file + file secret + DB row). Refuses for
136
+ providers declared in config.yaml — edit that file to change those."""
137
+ if not _provider_file(name).exists():
138
+ raise ValueError(f"'{name}' is not a GUI-managed provider (edit config.yaml instead)")
139
+ _provider_file(name).unlink()
140
+ delete_secret_file(name)
141
+ get_settings().reset_config_cache()
142
+ for pc in await _configs_named(db, name):
143
+ await db.delete(pc)
144
+ await db.flush()
145
+ await write_audit(
146
+ db, actor=actor, action="delete_provider", target_type="provider", target_id=name,
147
+ before={"name": name}, after=None,
148
+ )
149
+ await db.commit()
150
+ return {"deleted": name}
151
+
152
+
153
+ async def _configs_named(db: AsyncSession, name: str) -> list[ProviderConfig]:
154
+ return list((
155
+ await db.execute(select(ProviderConfig).where(ProviderConfig.name == name))
156
+ ).scalars())
157
+
158
+
159
+ async def list_models(db: AsyncSession, name: str) -> dict:
160
+ """Available models for a provider + the currently-selected one."""
161
+ configs = await _configs_named(db, name)
162
+ if not configs:
163
+ raise ValueError(f"provider '{name}' not found")
164
+ pc = configs[0]
165
+ models = await build_adapter(pc).list_models()
166
+ return {"provider": pc.name, "kind": pc.kind.value, "current": pc.model, "models": models}
167
+
168
+
169
+ async def set_model(db: AsyncSession, name: str, model: str, *, actor: str = "cli") -> dict:
170
+ """Select the active model for a provider (validated against its catalogue when
171
+ that catalogue is enumerable). Persists onto the provider config(s)."""
172
+ # Resolve model aliases first
173
+ resolved_model = resolve_model_alias(get_settings().config(), name, model)
174
+
175
+ configs = await _configs_named(db, name)
176
+ if not configs:
177
+ raise ValueError(f"provider '{name}' not found")
178
+ available = await build_adapter(configs[0]).list_models()
179
+ if available and resolved_model not in available:
180
+ raise ValueError(
181
+ f"model '{resolved_model}' is not offered by '{name}'. Available: {', '.join(available)}"
182
+ )
183
+ before = configs[0].model
184
+ for pc in configs:
185
+ pc.model = resolved_model
186
+ await db.flush()
187
+ await write_audit(
188
+ db, actor=actor, action="set_model", target_type="provider", target_id=name,
189
+ before={"model": before}, after={"model": resolved_model},
190
+ )
191
+ return {"provider": name, "current": resolved_model}
192
+
193
+
194
+ async def test_adapter(db: AsyncSession, name: str) -> AdapterTestResult:
195
+ pc = (
196
+ await db.execute(select(ProviderConfig).where(ProviderConfig.name == name))
197
+ ).scalar_one_or_none()
198
+ if pc is None:
199
+ raise ValueError(f"provider '{name}' not found")
200
+ adapter = build_adapter(pc)
201
+ health = await adapter.health_check()
202
+ db.add(
203
+ AdapterHealth(
204
+ provider_config_id=pc.id, ok=health.ok,
205
+ latency_ms=health.latency_ms, detail=health.detail,
206
+ )
207
+ )
208
+ await db.flush()
209
+ return AdapterTestResult(
210
+ name=pc.name, kind=pc.kind, ok=health.ok,
211
+ latency_ms=health.latency_ms, detail=health.detail,
212
+ )
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import UTC, datetime
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from harness.models.approval import Approval
10
+ from harness.models.enums import ApprovalActionClass, ApprovalStatus
11
+ from harness.observability.audit import write_audit
12
+
13
+
14
+ async def create_approval(
15
+ db: AsyncSession,
16
+ *,
17
+ session_id: uuid.UUID,
18
+ action_class: ApprovalActionClass,
19
+ payload: dict,
20
+ run_id: uuid.UUID | None = None,
21
+ ) -> Approval:
22
+ approval = Approval(
23
+ session_id=session_id,
24
+ run_id=run_id,
25
+ action_class=action_class,
26
+ payload=payload,
27
+ status=ApprovalStatus.pending,
28
+ )
29
+ db.add(approval)
30
+ await db.flush()
31
+ return approval
32
+
33
+
34
+ async def decide_approval(
35
+ db: AsyncSession,
36
+ *,
37
+ approval_id: uuid.UUID,
38
+ decision: str, # "approved" or "denied"
39
+ actor: str,
40
+ ) -> Approval:
41
+ approval = (
42
+ await db.execute(select(Approval).where(Approval.id == approval_id))
43
+ ).scalar_one_or_none()
44
+ if not approval:
45
+ raise ValueError("approval not found")
46
+ if approval.status != ApprovalStatus.pending:
47
+ raise ValueError(f"approval is already {approval.status.value}")
48
+
49
+ new_status = ApprovalStatus.approved if decision == "approved" else ApprovalStatus.denied
50
+
51
+ old_status = approval.status.value
52
+
53
+ approval.status = new_status
54
+ approval.decided_by = actor
55
+ approval.decided_at = datetime.now(UTC)
56
+
57
+ await db.flush()
58
+
59
+ await write_audit(
60
+ db,
61
+ actor=actor,
62
+ action="decide_approval",
63
+ target_type="approval",
64
+ target_id=str(approval.id),
65
+ before={"status": old_status},
66
+ after={"status": new_status.value}
67
+ )
68
+
69
+ return approval
70
+
71
+
72
+ async def list_approvals(
73
+ db: AsyncSession, *, status: ApprovalStatus | None = ApprovalStatus.pending
74
+ ) -> list[Approval]:
75
+ """List approvals, newest first. Defaults to pending only (what the GUI and
76
+ CLI poll for); pass ``status=None`` for all, or a specific status to filter."""
77
+ stmt = select(Approval).order_by(Approval.created_at.desc())
78
+ if status is not None:
79
+ stmt = stmt.where(Approval.status == status)
80
+ return list((await db.execute(stmt)).scalars().all())
81
+
82
+
83
+ async def list_pending_approvals(db: AsyncSession) -> list[Approval]:
84
+ return await list_approvals(db, status=ApprovalStatus.pending)
@@ -0,0 +1,78 @@
1
+ """Read-only access to agent-produced artifacts for the GUI.
2
+
3
+ Content resolution is union-typed by the artifact ``uri``:
4
+
5
+ - ``harness://transcript/{run_id}`` — content lives on the row (``meta["content"]``),
6
+ written by ``engine._persist_artifacts``; served from the DB, never the disk.
7
+ - a ``file://`` URI or filesystem path — validated against the workspace ``project_dir``
8
+ via :mod:`harness.services.files`, then read from disk.
9
+ - an inline ``plan``/``doc``/``patch`` carrying ``meta["content"]`` — served as-is.
10
+ - anything else — metadata only, with a reason (no content).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+
17
+ from sqlalchemy import select
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from harness.models.artifact import Artifact
21
+ from harness.services.files import read_file
22
+
23
+ TRANSCRIPT_PREFIX = "harness://transcript/"
24
+
25
+
26
+ async def list_session_artifacts(db: AsyncSession, session_id: uuid.UUID) -> list[Artifact]:
27
+ rows = await db.execute(
28
+ select(Artifact)
29
+ .where(Artifact.session_id == session_id)
30
+ .order_by(Artifact.created_at.desc())
31
+ )
32
+ return list(rows.scalars().all())
33
+
34
+
35
+ async def get_artifact(db: AsyncSession, artifact_id: uuid.UUID) -> Artifact | None:
36
+ return await db.get(Artifact, artifact_id)
37
+
38
+
39
+ def resolve_content(artifact: Artifact, project_dir: str, *,
40
+ project_host: str | None = None) -> dict:
41
+ """Resolve an artifact's previewable content. Returns the same shape as
42
+ ``files.read_file`` plus a ``content`` text field, or ``content: None`` with a
43
+ ``reason`` when nothing is previewable. Raises ``ValueError`` if a file URI escapes
44
+ the project folder. ``project_host`` marks a remote project: file-backed artifacts
45
+ live on that host and degrade to a reason; DB-backed content still resolves."""
46
+ uri = artifact.uri or ""
47
+ name = uri.rsplit("/", 1)[-1] or "artifact"
48
+
49
+ if uri.startswith(TRANSCRIPT_PREFIX):
50
+ text = artifact.meta.get("content") if artifact.meta else None
51
+ return {"name": "transcript", "content": text, "truncated": False, "binary": False,
52
+ "reason": None if text else "transcript content unavailable"}
53
+
54
+ if uri.startswith("file://") or uri.startswith("/") or uri.startswith("./"):
55
+ if project_host:
56
+ return {"name": name, "content": None, "truncated": False, "binary": False,
57
+ "reason": f"file lives on remote host {project_host!r}"}
58
+ path = uri[len("file://"):] if uri.startswith("file://") else uri
59
+ result = read_file(project_dir, path)
60
+ return {"name": result["name"], "content": result["text"],
61
+ "truncated": result["truncated"], "binary": result["binary"],
62
+ "reason": _file_reason(result)}
63
+
64
+ inline = artifact.meta.get("content") if artifact.meta else None
65
+ if inline:
66
+ return {"name": name, "content": inline, "truncated": False, "binary": False,
67
+ "reason": None}
68
+
69
+ return {"name": name, "content": None, "truncated": False, "binary": False,
70
+ "reason": "no previewable content for this artifact"}
71
+
72
+
73
+ def _file_reason(result: dict) -> str | None:
74
+ if result["binary"]:
75
+ return "binary file"
76
+ if result["truncated"]:
77
+ return "file too large to preview"
78
+ return None
@@ -0,0 +1,228 @@
1
+ """GUI attachments: uploaded documents/images that ride along with a message.
2
+
3
+ Files are streamed to ``<project_dir>/.attachments/<artifact-id>/<safe-name>``
4
+ (size-capped, never buffered unbounded) and persisted as ``Artifact`` rows
5
+ (``kind=file``, ``meta.attachment=true``). Images are detected by magic bytes —
6
+ the client's content-type is never trusted. A generated ``.gitignore`` keeps
7
+ uploads out of pre-run git snapshots. At send time ``resolve_attachments``
8
+ re-validates each id (same workspace, path still inside the project dir, file
9
+ still present) and returns descriptors the engine threads to the adapter:
10
+ images become native content blocks, documents are referenced by path."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ from sqlalchemy import select
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from harness.models.artifact import Artifact
23
+ from harness.models.enums import ArtifactKind
24
+ from harness.observability.audit import write_audit
25
+ from harness.observability.logging import get_logger
26
+ from harness.services.workspaces import (
27
+ effective_host,
28
+ effective_project,
29
+ get_workspace_by_slug,
30
+ )
31
+
32
+ log = get_logger("attachments")
33
+
34
+ ATTACHMENTS_DIR = ".attachments"
35
+ # Raw-byte ceiling for images kept under the API's ~5 MB post-base64 limit.
36
+ MAX_IMAGE_BYTES = 3_750_000
37
+ MAX_FILE_BYTES = 25_000_000
38
+ MAX_PER_MESSAGE = 10
39
+ _CHUNK = 1 << 16
40
+
41
+ # Magic-byte signatures for the image types Claude accepts natively.
42
+ _IMAGE_MAGIC: list[tuple[bytes, str]] = [
43
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
44
+ (b"\xff\xd8\xff", "image/jpeg"),
45
+ (b"GIF87a", "image/gif"),
46
+ (b"GIF89a", "image/gif"),
47
+ ]
48
+
49
+
50
+ def _sniff_image(head: bytes) -> str | None:
51
+ for magic, mt in _IMAGE_MAGIC:
52
+ if head.startswith(magic):
53
+ return mt
54
+ # WEBP: RIFF....WEBP
55
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
56
+ return "image/webp"
57
+ return None
58
+
59
+
60
+ def safe_filename(name: str) -> str:
61
+ """Basename only, conservative charset, never empty, capped length."""
62
+ base = Path(name or "").name
63
+ cleaned = re.sub(r"[^-._a-zA-Z0-9]+", "_", base)
64
+ cleaned = re.sub(r"_*\._*", ".", cleaned).strip("._ ") or "file"
65
+ if cleaned in {"file", ""} and base not in {"", ".", ".."}:
66
+ cleaned = "file"
67
+ if len(cleaned) > 128:
68
+ stem, dot, ext = cleaned.rpartition(".")
69
+ cleaned = (stem[: 128 - len(ext) - 1] + dot + ext) if dot else cleaned[:128]
70
+ return cleaned or "file"
71
+
72
+
73
+ async def _workspace_and_dir(db: AsyncSession, workspace_slug: str):
74
+ """(workspace, local project dir) for a slug — one DB lookup for both."""
75
+ ws = await get_workspace_by_slug(db, workspace_slug)
76
+ if ws is None:
77
+ raise ValueError(f"unknown workspace: {workspace_slug}")
78
+ settings = ws.settings or {}
79
+ if effective_host(settings):
80
+ raise ValueError("attachments are unsupported for remote projects")
81
+ project_dir, _ = effective_project(settings)
82
+ return ws, Path(project_dir)
83
+
84
+
85
+ def _descriptor(artifact: Artifact, path: str) -> dict:
86
+ meta = artifact.meta or {}
87
+ return {
88
+ "id": str(artifact.id),
89
+ "name": meta.get("original_name") or Path(path).name,
90
+ "path": path,
91
+ "media_type": meta.get("media_type") or "application/octet-stream",
92
+ "size": artifact.size,
93
+ "image": bool(meta.get("image")),
94
+ }
95
+
96
+
97
+ async def save_attachment(
98
+ db: AsyncSession, workspace_slug: str, upload, actor: str = "api",
99
+ ) -> dict:
100
+ """Stream one uploaded file to disk and persist its Artifact row.
101
+
102
+ Raises ValueError for user-fixable rejections (remote project, oversize)."""
103
+ ws, project_dir = await _workspace_and_dir(db, workspace_slug)
104
+
105
+ attachment_id = uuid.uuid4()
106
+ name = safe_filename(getattr(upload, "filename", "") or "file")
107
+ root = project_dir / ATTACHMENTS_DIR
108
+ dest_dir = root / str(attachment_id)
109
+ dest_dir.mkdir(parents=True, exist_ok=True)
110
+ gitignore = root / ".gitignore"
111
+ if not gitignore.exists():
112
+ gitignore.write_text("*\n") # keep uploads out of git snapshots
113
+
114
+ dest = dest_dir / name
115
+ sha = hashlib.sha256()
116
+ size = 0
117
+ head = b""
118
+ try:
119
+ with dest.open("wb") as f:
120
+ while chunk := await upload.read(_CHUNK):
121
+ if not head:
122
+ head = chunk[:16]
123
+ size += len(chunk)
124
+ if size > MAX_FILE_BYTES:
125
+ raise ValueError(
126
+ f"file too large (> {MAX_FILE_BYTES // 1_000_000} MB)"
127
+ )
128
+ sha.update(chunk)
129
+ f.write(chunk)
130
+ except ValueError:
131
+ dest.unlink(missing_ok=True)
132
+ dest_dir.rmdir()
133
+ raise
134
+
135
+ image_type = _sniff_image(head)
136
+ if image_type and size > MAX_IMAGE_BYTES:
137
+ # Too big to embed as a content block — treat as a document (path reference).
138
+ image_type = None
139
+ media_type = image_type or (getattr(upload, "content_type", None)
140
+ or "application/octet-stream")
141
+
142
+ artifact = Artifact(
143
+ id=attachment_id,
144
+ workspace_id=ws.id,
145
+ kind=ArtifactKind.file,
146
+ uri=str(dest),
147
+ sha256=sha.hexdigest(),
148
+ size=size,
149
+ meta={
150
+ "attachment": True,
151
+ "original_name": name,
152
+ "media_type": media_type,
153
+ "image": image_type is not None,
154
+ },
155
+ )
156
+ db.add(artifact)
157
+ await db.flush()
158
+ await write_audit(
159
+ db, actor=actor, action="attachment.upload", target_type="artifact",
160
+ target_id=str(attachment_id),
161
+ after={"name": name, "size": size, "media_type": media_type},
162
+ )
163
+ return _descriptor(artifact, str(dest))
164
+
165
+
166
+ async def resolve_attachments(
167
+ db: AsyncSession, ids: list[uuid.UUID], workspace_slug: str,
168
+ ) -> list[dict]:
169
+ """Load + re-validate attachment ids for a message send. Silently skips
170
+ (with a warning) anything missing, foreign, or escaped — a stale chip in
171
+ the GUI must not sink the run."""
172
+ if not ids:
173
+ return []
174
+ if len(ids) > MAX_PER_MESSAGE:
175
+ raise ValueError(f"too many attachments (max {MAX_PER_MESSAGE})")
176
+ ws, project_dir = await _workspace_and_dir(db, workspace_slug)
177
+ project_dir = project_dir.resolve()
178
+ rows = {
179
+ a.id: a
180
+ for a in (
181
+ await db.execute(select(Artifact).where(Artifact.id.in_(ids)))
182
+ ).scalars()
183
+ }
184
+ out: list[dict] = []
185
+ for aid in ids:
186
+ a = rows.get(aid)
187
+ if a is None or not (a.meta or {}).get("attachment"):
188
+ log.warning("attachment_skipped_unknown", attachment_id=str(aid))
189
+ continue
190
+ if a.workspace_id != ws.id:
191
+ log.warning("attachment_skipped_foreign", attachment_id=str(aid))
192
+ continue
193
+ try:
194
+ path = Path(a.uri).resolve()
195
+ path.relative_to(project_dir) # traversal guard
196
+ except (ValueError, OSError):
197
+ log.warning("attachment_skipped_escaped", attachment_id=str(aid), uri=a.uri)
198
+ continue
199
+ if not path.is_file():
200
+ log.warning("attachment_skipped_missing", attachment_id=str(aid), uri=a.uri)
201
+ continue
202
+ out.append(_descriptor(a, str(path)))
203
+ return out
204
+
205
+
206
+ async def get_attachment_file(
207
+ db: AsyncSession, attachment_id: uuid.UUID,
208
+ ) -> tuple[Path, str]:
209
+ """Path + media type for the preview endpoint. Raises ValueError when the
210
+ attachment is unknown, escaped, or gone. Same containment invariant as
211
+ ``resolve_attachments``: the file must live under the owning workspace's
212
+ ``<project_dir>/.attachments``."""
213
+ from harness.models.workspace import Workspace
214
+
215
+ a = await db.get(Artifact, attachment_id)
216
+ if a is None or not (a.meta or {}).get("attachment"):
217
+ raise ValueError("attachment not found")
218
+ ws = await db.get(Workspace, a.workspace_id)
219
+ settings = (ws.settings if ws else None) or {}
220
+ root = (Path(effective_project(settings)[0]) / ATTACHMENTS_DIR).resolve()
221
+ path = Path(a.uri).resolve()
222
+ try:
223
+ path.relative_to(root) # traversal guard
224
+ except ValueError as exc:
225
+ raise ValueError("attachment not found") from exc
226
+ if not path.is_file():
227
+ raise ValueError("attachment file is gone")
228
+ return path, (a.meta or {}).get("media_type") or "application/octet-stream"