diff --git a/AgentCompany-New/src/main/database/migrations/035-task-run-isolation.ts b/AgentCompany-New/src/main/database/migrations/035-task-run-isolation.ts new file mode 100644 index 00000000..16aee7d6 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/035-task-run-isolation.ts @@ -0,0 +1,16 @@ +export const taskRunIsolationSchema = ` + alter table task_runs add column execution_context_id text; + alter table task_runs add column service_identity_id text references service_identities(id) on delete set null; + alter table task_runs add column workspace_mount_json text not null default '[]'; + alter table task_runs add column secret_scope_json text not null default '{}'; + + create unique index if not exists idx_task_runs_execution_context + on task_runs(execution_context_id) + where execution_context_id is not null; + create index if not exists idx_task_runs_service_identity + on task_runs(service_identity_id) + where service_identity_id is not null; + + insert into schema_migrations(version, name, applied_at) + values (35, 'task_run_isolation', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index b0b629ed..00cc74b7 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -33,6 +33,7 @@ import { providerRequestWorkspaceSchema } from "./migrations/031-provider-reques import { browserActionMetadataSchema } from "./migrations/032-browser-action-metadata"; import { auditEventImmutabilitySchema } from "./migrations/033-audit-event-immutability"; import { providerAuthSessionsSchema } from "./migrations/034-provider-auth-sessions"; +import { taskRunIsolationSchema } from "./migrations/035-task-run-isolation"; interface Migration { version: number; @@ -75,6 +76,7 @@ const MIGRATIONS: Migration[] = [ { version: 32, name: "browser_action_metadata", sql: browserActionMetadataSchema }, { version: 33, name: "audit_event_immutability", sql: auditEventImmutabilitySchema }, { version: 34, name: "provider_auth_sessions", sql: providerAuthSessionsSchema }, + { version: 35, name: "task_run_isolation", sql: taskRunIsolationSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 5a3404f5..4b005f74 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -151,6 +151,7 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "task_runs", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" }, { table: "task_runs", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, { table: "task_runs", column: "provider_request_id", referencedTable: "provider_requests", referencedColumn: "id", reason: "dangling_provider_request_id" }, + { table: "task_runs", column: "service_identity_id", referencedTable: "service_identities", referencedColumn: "id", reason: "dangling_service_identity_id" }, { table: "approvals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "approvals", column: "decided_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_decider_id" }, { table: "approval_routing_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, diff --git a/AgentCompany-New/src/main/services/onboarding-service.ts b/AgentCompany-New/src/main/services/onboarding-service.ts index 151cd90e..dcd64681 100644 --- a/AgentCompany-New/src/main/services/onboarding-service.ts +++ b/AgentCompany-New/src/main/services/onboarding-service.ts @@ -67,7 +67,7 @@ export class OnboardingService { const projectId = this.createProject(orgId, goalId, ceoAgentId, input, now); if (workspaceId) this.bindWorkspaceToProject(workspaceId, projectId, now); const taskId = this.createKickoffTask(orgId, goalId, projectId, workspaceId, ceoAgentId, input, now); - const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, now); + const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, workspaceId, now); this.audit.record({ orgId, @@ -193,17 +193,54 @@ export class OnboardingService { return taskId; } - private createTaskRun(orgId: string, taskId: string, ceoAgentId: string, providerId: string, now: string): string { + private createTaskRun(orgId: string, taskId: string, ceoAgentId: string, providerId: string, workspaceId: string | null, now: string): string { const runId = createId("run"); + const executionContextId = createId("ctx"); + const serviceIdentityId = this.requireServiceIdentity(orgId, ceoAgentId); + const workspaceMountJson = JSON.stringify(workspaceId ? [{ + workspaceId, + mode: "read_write", + }] : []); + const secretScopeJson = JSON.stringify({ + serviceIdentityId, + providerIds: [providerId], + scope: "bootstrap", + credentialAccess: "provider_runtime_only", + }); this.deps.db.prepare(` insert into task_runs( - id, org_id, task_id, agent_id, provider_id, trigger, state, created_at, started_at, completed_at, error_message + id, org_id, task_id, agent_id, provider_id, provider_request_id, + execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json, + trigger, state, created_at, started_at, completed_at, error_message ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(runId, orgId, taskId, ceoAgentId, providerId, "bootstrap", "queued", now, null, null, null); + values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + runId, + orgId, + taskId, + ceoAgentId, + providerId, + executionContextId, + serviceIdentityId, + workspaceMountJson, + secretScopeJson, + "bootstrap", + "queued", + now, + null, + null, + null, + ); return runId; } + private requireServiceIdentity(orgId: string, agentId: string): string { + const row = this.deps.db.prepare("select id from service_identities where org_id = ? and agent_id = ?") + .get(orgId, agentId) as { id: string } | undefined; + if (!row) throw new Error("Agent service identity was not found."); + return row.id; + } + private createKickoffTaskDescription(input: BootstrapOnboardingInput): string { return [ `Company: ${input.companyName.trim()}`, diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index af3e386f..f2fd7715 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -56,6 +56,7 @@ interface GoalRow { } interface WorkspaceRow { + id: string; name: string; local_path: string; repo_url: string; @@ -74,6 +75,13 @@ interface SkillRuntimeSnapshotRow { source_revision_ids: string; } +interface TaskRunIsolation { + executionContextId: string; + serviceIdentityId: string; + workspaceMountJson: string; + secretScopeJson: string; +} + export class TaskExecutionService { private readonly audit: AuditService; @@ -90,7 +98,9 @@ export class TaskExecutionService { this.assertExecutionApproval(input.orgId, task); const agent = this.requireAssignedAgent(input.orgId, task); const provider = this.requireAgentProvider(input.orgId, agent); - const runId = this.createRun(input.orgId, task.id, agent.id, provider.id); + const workspace = this.workspace(task.workspace_id); + const isolation = this.createIsolationContext(input.orgId, task, agent, provider, workspace); + const runId = this.createRun(input.orgId, task.id, agent.id, provider.id, isolation); let providerRequestId: string | null = null; let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null; @@ -106,7 +116,8 @@ export class TaskExecutionService { provider, project: this.project(task.project_id), goal: this.goal(task.goal_id), - workspace: this.workspace(task.workspace_id), + workspace, + isolation, skillRuntimeSnapshot, }), instructions: buildAgentInstructions(agent), @@ -129,6 +140,8 @@ export class TaskExecutionService { agentId: agent.id, providerId: provider.id, providerRequestId: requestId, + executionContextId: isolation.executionContextId, + serviceIdentityId: isolation.serviceIdentityId, skillRuntimeSnapshotId: skillRuntimeSnapshot?.id ?? null, }, }); @@ -148,6 +161,8 @@ export class TaskExecutionService { agentId: agent.id, providerId: provider.id, providerRequestId, + executionContextId: isolation.executionContextId, + serviceIdentityId: isolation.serviceIdentityId, skillRuntimeSnapshotId: skillRuntimeSnapshot?.id ?? null, errorMessage: message, }, @@ -201,15 +216,30 @@ export class TaskExecutionService { return provider; } - private createRun(orgId: string, taskId: string, agentId: string, providerId: string): string { + private createRun(orgId: string, taskId: string, agentId: string, providerId: string, isolation: TaskRunIsolation): string { const runId = createId("run"); const now = new Date().toISOString(); this.db.prepare(` insert into task_runs( id, org_id, task_id, agent_id, provider_id, provider_request_id, + execution_context_id, service_identity_id, workspace_mount_json, secret_scope_json, trigger, state, created_at, started_at, completed_at, error_message - ) values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, null, null) - `).run(runId, orgId, taskId, agentId, providerId, "manual", TASK_RUN_STATE.RUNNING, now, now); + ) values (?, ?, ?, ?, ?, null, ?, ?, ?, ?, ?, ?, ?, ?, null, null) + `).run( + runId, + orgId, + taskId, + agentId, + providerId, + isolation.executionContextId, + isolation.serviceIdentityId, + isolation.workspaceMountJson, + isolation.secretScopeJson, + "manual", + TASK_RUN_STATE.RUNNING, + now, + now, + ); this.audit.record({ orgId, entityType: "task_run", @@ -217,7 +247,7 @@ export class TaskExecutionService { actorType: "system", actorId: null, action: "started", - payload: { taskId, agentId, providerId, trigger: "manual" }, + payload: { taskId, agentId, providerId, trigger: "manual", executionContextId: isolation.executionContextId, serviceIdentityId: isolation.serviceIdentityId }, }); return runId; } @@ -272,7 +302,44 @@ export class TaskExecutionService { private workspace(workspaceId: string | null): WorkspaceRow | null { if (!workspaceId) return null; - return (this.db.prepare("select name, local_path, repo_url, repo_ref from workspaces where id = ?").get(workspaceId) as unknown as WorkspaceRow | undefined) ?? null; + return (this.db.prepare("select id, name, local_path, repo_url, repo_ref from workspaces where id = ?").get(workspaceId) as unknown as WorkspaceRow | undefined) ?? null; + } + + private createIsolationContext( + orgId: string, + task: TaskExecutionTaskRow, + agent: TaskExecutionAgentRow, + provider: TaskExecutionProviderRow, + workspace: WorkspaceRow | null, + ): TaskRunIsolation { + if (task.requires_filesystem === 1 && !workspace) throw new Error("Filesystem tasks require a workspace."); + const serviceIdentityId = this.requireServiceIdentity(orgId, agent.id); + const executionContextId = createId("ctx"); + const workspaceMounts = workspace ? [{ + workspaceId: workspace.id, + name: workspace.name, + localPath: workspace.local_path, + repoRef: workspace.repo_ref, + mode: task.requires_filesystem === 1 ? "read_write" : "read_only", + }] : []; + return { + executionContextId, + serviceIdentityId, + workspaceMountJson: JSON.stringify(workspaceMounts), + secretScopeJson: JSON.stringify({ + serviceIdentityId, + providerIds: [provider.id], + scope: "task_run", + credentialAccess: "provider_runtime_only", + }), + }; + } + + private requireServiceIdentity(orgId: string, agentId: string): string { + const row = this.db.prepare("select id from service_identities where org_id = ? and agent_id = ?") + .get(orgId, agentId) as { id: string } | undefined; + if (!row) throw new Error("Agent service identity was not found."); + return row.id; } } @@ -292,6 +359,7 @@ function buildTaskPrompt(input: { project: ProjectRow | null; goal: GoalRow | null; workspace: WorkspaceRow | null; + isolation: TaskRunIsolation; skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null; }): string { const lines = [ @@ -305,6 +373,8 @@ function buildTaskPrompt(input: { `Requires filesystem: ${input.task.requires_filesystem === 1 ? "yes" : "no"}`, `Assignee: ${input.agent.name} (${input.agent.role})`, `Provider: ${input.provider.label} ${input.provider.model ?? input.agent.model_id ?? ""}`.trim(), + `Execution context: ${input.isolation.executionContextId}`, + `Service identity: ${input.isolation.serviceIdentityId}`, ]; if (input.goal) lines.push("", `Goal: ${input.goal.title}`, `Goal detail: ${input.goal.description}`); if (input.project) lines.push("", `Project: ${input.project.name}`, `Project detail: ${input.project.description}`); @@ -315,8 +385,10 @@ function buildTaskPrompt(input: { `Local path: ${input.workspace.local_path}`, `Repo: ${input.workspace.repo_url}`, `Ref: ${input.workspace.repo_ref}`, + `Mounts: ${input.isolation.workspaceMountJson}`, ); } + lines.push("", `Secret scope: ${input.isolation.secretScopeJson}`); if (input.skillRuntimeSnapshot) { lines.push( "", diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 0b4dc237..d8349335 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -142,6 +142,10 @@ export interface TaskRunRecord { agentId: string | null; providerId: string | null; providerRequestId: string | null; + executionContextId: string | null; + serviceIdentityId: string | null; + workspaceMountJson: string; + secretScopeJson: string; trigger: string; state: string; createdAt: string; diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 502ccd8e..c7fc6625 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]); connection.close(); }); @@ -520,6 +520,8 @@ describe("domain services", () => { expect(state.projects[0]?.leadAgentId).toBe(state.agents[0]?.id); expect(state.tasks[0]?.assigneeAgentId).toBe(state.agents[0]?.id); expect(state.taskRuns[0]?.trigger).toBe("bootstrap"); + expect(state.taskRuns[0]?.executionContextId).toMatch(/^ctx_/); + expect(state.taskRuns[0]?.serviceIdentityId).toMatch(/^sid_/); connection.close(); }); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index 127e9d1d..b770adb5 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -123,7 +123,7 @@ describe("migration service", () => { create table workspaces(id text primary key, org_id text, project_id text); create table goals(id text primary key, org_id text, owner_agent_id text); create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text); - create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text, provider_request_id text); + create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text, provider_request_id text, service_identity_id text); create table approvals(id text primary key, org_id text, decided_by_employee_id text); create table approval_routing_rules(id text primary key, org_id text); create table approval_assignments(id text primary key, org_id text, approval_id text, routing_rule_id text); @@ -201,7 +201,7 @@ describe("migration service", () => { insert into goals(id, org_id, owner_agent_id) values ('goal-1', 'org-1', 'agent-1'); insert into projects(id, org_id, goal_id, lead_agent_id) values ('prj-1', 'org-1', 'goal-1', 'agent-1'); insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1'); - insert into task_runs(id, org_id, task_id, agent_id, provider_id, provider_request_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1', 'req-1'); + insert into task_runs(id, org_id, task_id, agent_id, provider_id, provider_request_id, service_identity_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1', 'req-1', 'sid-1'); insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1'); insert into approval_routing_rules(id, org_id) values ('route-1', 'org-1'); insert into approval_assignments(id, org_id, approval_id, routing_rule_id) values ('assign-1', 'org-1', 'approval-1', 'route-1'); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index 270a4e4f..1ade2d46 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -126,11 +126,17 @@ describe("task execution service", () => { expect(run).toMatchObject({ taskId, agentId: expect.any(String), providerId, state: "completed", errorMessage: null }); expect(request).toMatchObject({ providerId, workspaceId, state: "succeeded", responseText: "executed:gpt-5.4" }); expect(skillRuntimeSnapshot).toMatchObject({ agentId, runId }); + expect(run?.executionContextId).toMatch(/^ctx_/); + expect(run?.serviceIdentityId).toMatch(/^sid_/); + expect(JSON.parse(run?.workspaceMountJson ?? "[]")).toEqual([expect.objectContaining({ workspaceId, mode: "read_only" })]); + expect(JSON.parse(run?.secretScopeJson ?? "{}")).toMatchObject({ providerIds: [providerId], credentialAccess: "provider_runtime_only" }); expect(skillRuntimeSnapshot?.compiledPrompt).toContain("Verify release readiness"); expect(skillRuntimeSnapshot?.sourceRevisionIds).toContain(revisionId); expect(capturedPrompt).toContain("Task: Draft launch checklist"); expect(capturedPrompt).toContain("Assignee: Ada (Execution lead)"); expect(capturedPrompt).toContain("Workspace: Task Workspace"); + expect(capturedPrompt).toContain("Execution context:"); + expect(capturedPrompt).toContain("Secret scope:"); expect(capturedPrompt).toContain("Skill runtime:"); expect(capturedPrompt).toContain("Tool allowlist:"); expect(state.auditEvents.some((event) => event.entityType === "task_run" && event.action === "completed")).toBe(true); @@ -160,6 +166,7 @@ describe("task execution service", () => { let employeeId = ""; let taskId = ""; let approvalId = ""; + let workspaceId = ""; connection.transaction(() => { orgId = organization.createOrganization({ @@ -171,6 +178,12 @@ describe("task execution service", () => { employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; const providerId = openAiProviderId(connection, orgId); providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" }); + workspaceId = "wsp_production"; + const now = new Date().toISOString(); + connection.db.prepare(` + insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, null, ?, ?, ?) + `).run(workspaceId, orgId, "Production", "C:/work/production", "https://example.com/production.git", "main", 1, now, now); const agentId = organization.createAgent({ orgId, name: "Ada", @@ -190,7 +203,7 @@ describe("task execution service", () => { risk: RISK_LEVEL.HIGH, projectId: null, goalId: null, - workspaceId: null, + workspaceId, priority: TASK_PRIORITY.HIGH, requiresFilesystem: true, }); @@ -209,6 +222,8 @@ describe("task execution service", () => { expect(state.approvals.find((approval) => approval.id === approvalId)).toMatchObject({ state: APPROVAL_STATE.APPROVED }); expect(state.taskRuns.find((run) => run.id === runId)).toMatchObject({ taskId, state: "completed" }); + expect(JSON.parse(state.taskRuns.find((run) => run.id === runId)?.workspaceMountJson ?? "[]")) + .toEqual([expect.objectContaining({ workspaceId, mode: "read_write" })]); connection.close(); });