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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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"
|