Compare commits
11
Commits
b085947547
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a009446fdb | ||
|
|
6c39ce7781 | ||
|
|
b84c79816c | ||
|
|
ce0c924c58 | ||
|
|
fcdc2af2b9 | ||
|
|
301210ff25 | ||
|
|
96489cd051 | ||
|
|
34ff4c1ea8 | ||
|
|
1ede03d989 | ||
|
|
a7fc75bf6b | ||
|
|
c1caed3d42 |
No files matched your search
@@ -47,6 +47,15 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
|
|||||||
- Preserve JSONC comments, line endings, and unknown future hook fields. Reject trailing commas and duplicate keys, validate all 21 current events plus command, HTTP, and prompt handlers, reject settings-defined function handlers, and apply Node.js `RegExp` semantics only to the 15 matcher-enabled events.
|
- Preserve JSONC comments, line endings, and unknown future hook fields. Reject trailing commas and duplicate keys, validate all 21 current events plus command, HTTP, and prompt handlers, reject settings-defined function handlers, and apply Node.js `RegExp` semantics only to the 15 matcher-enabled events.
|
||||||
- Own one exact named matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple with 10,000 millisecond timeouts. Generate guard, strict runtime config, canonical private key, and native-payload audit runtime with user settings in one rollback-capable transaction. Propagate frozen and revoked agents through exit code `2`; require effective hooks to be enabled, one exact triple, physical runtime files, strict identity, and exact generated sources for healthy status; direct users to `/hooks` for review.
|
- Own one exact named matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple with 10,000 millisecond timeouts. Generate guard, strict runtime config, canonical private key, and native-payload audit runtime with user settings in one rollback-capable transaction. Propagate frozen and revoked agents through exit code `2`; require effective hooks to be enabled, one exact triple, physical runtime files, strict identity, and exact generated sources for healthy status; direct users to `/hooks` for review.
|
||||||
|
|
||||||
|
## Shared Modules
|
||||||
|
|
||||||
|
- `cmd/elydora/plugins/managed_runtime.go` owns install-config validation, managed runtime paths, runtime config validation, runtime identity checks, and the strict and presence-only status contracts. `managed_changes.go` owns runtime config encoding, the four runtime file changes, and the shared directory-plus-commit writer.
|
||||||
|
- `cmd/elydora/plugins/shell_command.go` owns Node.js resolution, POSIX and PowerShell quoting, encoded Windows commands, the shell and encoded command builders and parsers, and the pre-2.1 quoted Windows parser.
|
||||||
|
- `cmd/elydora/plugins/jsonc.go` owns strict JSON object decoding, JSONC editing, and `cloneJSONObject`; `managed_files.go` owns physical file and directory inspection; `transaction*.go` owns the rollback-capable commit.
|
||||||
|
- Provider files keep only their hook document schema, ownership rules, rendering, and provider-specific preconditions. Add a provider-local helper only when the shared module cannot express the provider contract.
|
||||||
|
- `jsonutil.go` and `legacy_hook_entries.go` serve the Kiro CLI, Letta, and OpenCode adapters, which write settings without the managed transaction; moving them onto the managed model is a product decision.
|
||||||
|
- Recognizing pre-2.1 Elydora commands stays in place so `elydora install` replaces older entries during upgrades.
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
- Preserve the minimum Go version declared in `go.mod`.
|
- Preserve the minimum Go version declared in `go.mod`.
|
||||||
@@ -65,7 +74,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
|
|||||||
|
|
||||||
Run the focused adapter test during development, then execute all release gates before commit:
|
Run the focused adapter test during development, then execute all release gates before commit:
|
||||||
|
|
||||||
```powershell
|
```sh
|
||||||
go test ./cmd/elydora/plugins -run <Provider> -count=1
|
go test ./cmd/elydora/plugins -run <Provider> -count=1
|
||||||
go test ./...
|
go test ./...
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
@@ -75,6 +84,8 @@ govulncheck ./...
|
|||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Linux test environment leaks provider config into `$HOME`; run the suite with `HOME` and `XDG_CONFIG_HOME` pointed at a scratch directory while `GOPATH`, `GOMODCACHE`, and `GOCACHE` keep their real values.
|
||||||
|
|
||||||
Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation.
|
Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation.
|
||||||
|
|
||||||
Commit and push one root issue before starting the next one.
|
Commit and push one root issue before starting the next one.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Opening a pull request on git.zacharyzhang.com
|
||||||
|
|
||||||
|
Forking is disabled on this server. Open pull requests with AGit instead:
|
||||||
|
|
||||||
|
1. Register an account at https://git.zacharyzhang.com
|
||||||
|
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.zacharyzhang.com/Elydora-Infrastructure/Elydora-Go-SDK.git
|
||||||
|
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. To update the pull request, push again with the same `topic`.
|
||||||
@@ -10,6 +10,8 @@ go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
|||||||
|
|
||||||
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
|
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
|
||||||
|
|
||||||
|
Upgrading from 2.0.x: run `elydora install` again for every agent. The generated runtimes changed, and `elydora status` does not detect stale runtimes for every adapter.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -23,18 +25,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Authenticate
|
// Create client with an API token issued in the Console
|
||||||
auth, err := elydora.Login("https://api.elydora.com", "user@example.com", "password")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
client, err := elydora.NewClient(&elydora.Config{
|
client, err := elydora.NewClient(&elydora.Config{
|
||||||
OrgID: auth.User.OrgID,
|
OrgID: "org-123",
|
||||||
AgentID: "my-agent-id",
|
AgentID: "my-agent-id",
|
||||||
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
||||||
Token: auth.Token,
|
Token: "<api-token>",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -113,8 +109,9 @@ Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `elydora install` | Install Elydora audit hook for a coding agent |
|
| `elydora install` | Install Elydora audit hook for a coding agent |
|
||||||
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
||||||
| `elydora status` | Show installation status for all agents |
|
| `elydora status` | Show installation status for one agent or all agents |
|
||||||
| `elydora agents` | List supported coding agents |
|
| `elydora agents` | List supported coding agents |
|
||||||
|
| `elydora version` | Print the CLI version |
|
||||||
|
|
||||||
### Supported Agents
|
### Supported Agents
|
||||||
|
|
||||||
@@ -162,24 +159,23 @@ client.SetToken("new-api-token")
|
|||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Register a new user and organization
|
// Get the authenticated user and current organization
|
||||||
reg, err := elydora.Register(baseURL, email, password,
|
|
||||||
elydora.WithDisplayName("Alice"),
|
|
||||||
elydora.WithOrgName("Acme Corp"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Login and receive a session token
|
|
||||||
auth, err := elydora.Login(baseURL, email, password)
|
|
||||||
|
|
||||||
// Get current authenticated user profile
|
|
||||||
me, err := client.GetMe()
|
me, err := client.GetMe()
|
||||||
|
// me.User.Email, me.User.Role, me.CurrentOrganization.OrgID
|
||||||
|
|
||||||
// Issue a new API token (with optional TTL)
|
// Issue an API token; this call needs a client whose Token is a Console session token
|
||||||
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
||||||
TTLSeconds: &ttlSeconds,
|
TTLSeconds: &ttlSeconds,
|
||||||
})
|
})
|
||||||
|
client.SetToken(tokenResp.Token)
|
||||||
|
|
||||||
|
// Rotate the installed API token; the response carries the replacement
|
||||||
|
rotated, err := client.RotateApiToken()
|
||||||
|
client.SetToken(rotated.Token)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`elydora.Register` and `elydora.Login` remain for password accounts and are deprecated; use the Console sign-in flow.
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -219,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
|
|||||||
// Get agent details
|
// Get agent details
|
||||||
details, err := client.GetAgent(agentID)
|
details, err := client.GetAgent(agentID)
|
||||||
|
|
||||||
|
// Change the integration type
|
||||||
|
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
|
||||||
|
|
||||||
// Freeze an agent
|
// Freeze an agent
|
||||||
err := client.FreezeAgent(agentID, "security review")
|
frozen, err := client.FreezeAgent(agentID, "security review")
|
||||||
|
|
||||||
// Revoke a key
|
// Revoke a key
|
||||||
err := client.RevokeKey(agentID, kid, "key rotation")
|
err := client.RevokeKey(agentID, kid, "key rotation")
|
||||||
@@ -229,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
|
|||||||
agents, err := client.ListAgents()
|
agents, err := client.ListAgents()
|
||||||
|
|
||||||
// Unfreeze a previously frozen agent
|
// Unfreeze a previously frozen agent
|
||||||
err := client.UnfreezeAgent(agentID, "review complete")
|
unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
|
||||||
|
|
||||||
// Delete an agent permanently
|
// Delete an agent permanently
|
||||||
deleted, err := client.DeleteAgent(agentID)
|
deleted, err := client.DeleteAgent(agentID)
|
||||||
@@ -270,6 +269,25 @@ detail, err := client.GetExport(exportID)
|
|||||||
data, err := client.DownloadExport(exportID)
|
data, err := client.DownloadExport(exportID)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
```go
|
||||||
|
webhook, err := client.RegisterWebhook(&elydora.RegisterWebhookRequest{
|
||||||
|
EndpointURL: "https://example.com/elydora",
|
||||||
|
Events: []string{"operation.accepted"},
|
||||||
|
Secret: "<signing-secret>",
|
||||||
|
})
|
||||||
|
webhooks, err := client.ListWebhooks()
|
||||||
|
err = client.DeleteWebhook(webhook.Webhook.WebhookID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
|
||||||
|
```go
|
||||||
|
members, err := client.ListMembers()
|
||||||
|
events, err := client.ListAdminEvents(50)
|
||||||
|
```
|
||||||
|
|
||||||
### JWKS
|
### JWKS
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -281,13 +299,17 @@ jwks, err := client.GetJWKS()
|
|||||||
```go
|
```go
|
||||||
// Check API health (no authentication required, does not need a client)
|
// Check API health (no authentication required, does not need a client)
|
||||||
health, err := elydora.Health("https://api.elydora.com")
|
health, err := elydora.Health("https://api.elydora.com")
|
||||||
// health.Status, health.Version, health.ProtocolVersion, health.Timestamp
|
// health.Status, health.Version, health.ProtocolVersion, health.Capabilities, health.Timestamp
|
||||||
|
|
||||||
|
// Check dependency health (D1, auth storage, R2, KV)
|
||||||
|
deep, err := elydora.DeepHealth("https://api.elydora.com")
|
||||||
|
// deep.Dependencies.D1.Status, deep.Dependencies.KV.LatencyMs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Constants
|
### Constants
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Genesis chain hash — initial prev_chain_hash for an agent's first operation
|
// Genesis chain hash: initial prev_chain_hash for an agent's first operation
|
||||||
elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -299,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|||||||
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
||||||
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
||||||
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
|
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
|
||||||
|
| `WebhookStatus` | `WebhookStatusActive`, `WebhookStatusDisabled` |
|
||||||
|
| `IntegrationType` | One constant per supported agent key plus `IntegrationTypeEnterprise`, `IntegrationTypeGUI`, `IntegrationTypeSDK`, `IntegrationTypeOther` |
|
||||||
|
| `AdminAction` | One constant per admin event action, for example `AdminActionAgentFreeze` |
|
||||||
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
|
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
|
||||||
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeAgentFrozen`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
|
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeKeyRetired`, `ErrorCodeAgentFrozen`, `ErrorCodeAgentRevoked`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package elydora
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterAgent registers a new AI agent within the organization.
|
// RegisterAgent registers a new AI agent within the organization.
|
||||||
@@ -17,7 +18,7 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
|
|||||||
// GetAgent retrieves an agent by ID.
|
// GetAgent retrieves an agent by ID.
|
||||||
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
||||||
var result GetAgentResponse
|
var result GetAgentResponse
|
||||||
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil {
|
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
@@ -26,7 +27,7 @@ func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
|||||||
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
||||||
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
|
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
|
||||||
var result FreezeAgentResponse
|
var result FreezeAgentResponse
|
||||||
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{
|
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}, &result); err != nil {
|
}, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -36,7 +37,7 @@ func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, erro
|
|||||||
|
|
||||||
// RevokeKey revokes a specific key for an agent.
|
// RevokeKey revokes a specific key for an agent.
|
||||||
func (c *Client) RevokeKey(agentID, kid, reason string) error {
|
func (c *Client) RevokeKey(agentID, kid, reason string) error {
|
||||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{
|
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", url.PathEscape(agentID)), &RevokeAgentRequest{
|
||||||
KID: kid,
|
KID: kid,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -54,7 +55,7 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
|
|||||||
// UnfreezeAgent unfreezes a previously frozen agent.
|
// UnfreezeAgent unfreezes a previously frozen agent.
|
||||||
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
|
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
|
||||||
var result UnfreezeAgentResponse
|
var result UnfreezeAgentResponse
|
||||||
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{
|
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}, &result); err != nil {
|
}, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -65,7 +66,7 @@ func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse,
|
|||||||
// UpdateAgent updates an agent's integration type.
|
// UpdateAgent updates an agent's integration type.
|
||||||
func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) {
|
func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) {
|
||||||
var result UpdateAgentResponse
|
var result UpdateAgentResponse
|
||||||
if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", agentID), &UpdateAgentRequest{
|
if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{
|
||||||
IntegrationType: integrationType,
|
IntegrationType: integrationType,
|
||||||
}, &result); err != nil {
|
}, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -76,7 +77,7 @@ func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*
|
|||||||
// DeleteAgent permanently deletes an agent and all associated data.
|
// DeleteAgent permanently deletes an agent and all associated data.
|
||||||
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
||||||
var result DeleteAgentResponse
|
var result DeleteAgentResponse
|
||||||
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil {
|
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), nil, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
|
|||||||
@@ -151,7 +151,16 @@ func retryable(status int) bool {
|
|||||||
return status >= 500 || status == http.StatusTooManyRequests
|
return status >= 500 || status == http.StatusTooManyRequests
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRequest executes an authenticated request, retrying transport and 5xx failures.
|
// A lost response to a non-idempotent request must not be replayed.
|
||||||
|
func idempotent(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest executes an authenticated request; only idempotent methods retry.
|
||||||
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
|
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
|
||||||
data, err := marshalBody(body)
|
data, err := marshalBody(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,18 +178,24 @@ func (c *Client) doRequest(method, path string, body interface{}, result interfa
|
|||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = fmt.Errorf("elydora: http request: %w", err)
|
lastErr = fmt.Errorf("elydora: http request: %w", err)
|
||||||
|
if !idempotent(method) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
respBody, err := readBody(resp, maxResponseBytes)
|
respBody, err := readBody(resp, maxResponseBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
if !idempotent(method) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
return decodeSuccess(respBody, result)
|
return decodeSuccess(respBody, result)
|
||||||
}
|
}
|
||||||
lastErr = apiError(resp.StatusCode, respBody)
|
lastErr = apiError(resp.StatusCode, respBody)
|
||||||
if !retryable(resp.StatusCode) {
|
if !retryable(resp.StatusCode) || !idempotent(method) {
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "2.0.1"
|
const version = "2.1.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ type AugmentPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Auggie commits its provider guard together
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// with both wrappers, the audit runtime, and user settings.
|
|
||||||
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
|
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeAugmentChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Augment Code CLI hooks",
|
"Install Augment Code CLI hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.configPath),
|
filepath.Dir(document.configPath),
|
||||||
|
"Auggie configuration directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeAugmentChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Augment Code CLI hooks",
|
"Uninstall Augment Code CLI hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
filepath.Dir(document.configPath),
|
filepath.Dir(document.configPath),
|
||||||
|
"Auggie configuration directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const (
|
|||||||
augmentGuardScript = "guard.js"
|
augmentGuardScript = "guard.js"
|
||||||
augmentAuditScript = "hook.js"
|
augmentAuditScript = "hook.js"
|
||||||
augmentHookTimeout = float64(10_000)
|
augmentHookTimeout = float64(10_000)
|
||||||
augmentPOSIXApostrophe = `'"'"'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -68,14 +67,6 @@ type augmentRuntimeContract struct {
|
|||||||
auditWrapper string
|
auditWrapper string
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneAugmentObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentHandler(
|
func validateAugmentHandler(
|
||||||
value any,
|
value any,
|
||||||
event string,
|
event string,
|
||||||
@@ -114,7 +105,7 @@ func validateAugmentHandler(
|
|||||||
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloneAugmentObject(handler), nil
|
return cloneJSONObject(handler), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAugmentMetadata(value any, label string) error {
|
func validateAugmentMetadata(value any, label string) error {
|
||||||
@@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup
|
|||||||
}
|
}
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
}
|
}
|
||||||
return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil
|
return augmentGroup{object: cloneJSONObject(object), handlers: handlers}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
|
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
|
||||||
@@ -248,7 +239,7 @@ func renderAugmentDocument(
|
|||||||
if !document.exists && len(hooks) == 0 {
|
if !document.exists && len(hooks) == 0 {
|
||||||
return &augmentRenderedDocument{document: document}, nil
|
return &augmentRenderedDocument{document: document}, nil
|
||||||
}
|
}
|
||||||
root := cloneAugmentObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any {
|
|||||||
for event, groups := range hooks {
|
for event, groups := range hooks {
|
||||||
values := make([]any, 0, len(groups))
|
values := make([]any, 0, len(groups))
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
object := cloneAugmentObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
handlers := make([]any, 0, len(group.handlers))
|
handlers := make([]any, 0, len(group.handlers))
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
@@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readAugmentPOSIXArgument(command string) (string, bool) {
|
func readAugmentPOSIXArgument(command string) (string, bool) {
|
||||||
if len(command) < 2 || command[0] != '\'' {
|
value, end, ok := readPOSIXArgument(command, 0)
|
||||||
return "", false
|
return value, ok && end == len(command) && value != ""
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := 1; index < len(command); {
|
|
||||||
if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(augmentPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index == len(command)-1 && value.Len() > 0
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAugmentCommand(command string) (string, bool) {
|
func parseAugmentCommand(command string) (string, bool) {
|
||||||
@@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) {
|
|||||||
return readAugmentPOSIXArgument(command)
|
return readAugmentPOSIXArgument(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAugmentPath(value string) string {
|
|
||||||
absolute, err := filepath.Abs(value)
|
|
||||||
if err == nil {
|
|
||||||
value = absolute
|
|
||||||
}
|
|
||||||
value = filepath.ToSlash(filepath.Clean(value))
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(value)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameAugmentPath(left, right string) bool {
|
|
||||||
return normalizeAugmentPath(left) == normalizeAugmentPath(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameAugmentAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameAugmentFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedAugmentAgentID(
|
func managedAugmentAgentID(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
wrapperName, runtimeRoot string,
|
wrapperName, runtimeRoot string,
|
||||||
@@ -385,11 +331,11 @@ func managedAugmentAgentID(
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
wrapperPath, ok := parseAugmentCommand(command)
|
wrapperPath, ok := parseAugmentCommand(command)
|
||||||
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) {
|
if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(wrapperPath)
|
agentDirectory := filepath.Dir(wrapperPath)
|
||||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentID := filepath.Base(agentDirectory)
|
agentID := filepath.Base(agentDirectory)
|
||||||
@@ -407,7 +353,7 @@ func removeManagedAugmentGroups(
|
|||||||
groupChanged := false
|
groupChanged := false
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||||
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID))
|
remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
|
||||||
if remove {
|
if remove {
|
||||||
changed = true
|
changed = true
|
||||||
groupChanged = true
|
groupChanged = true
|
||||||
|
|||||||
@@ -1,96 +1,42 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type augmentRuntimePaths struct {
|
type augmentRuntimePaths struct {
|
||||||
runtimeRoot string
|
managedRuntimePaths
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
guardWrapperPath string
|
guardWrapperPath string
|
||||||
auditWrapperPath string
|
auditWrapperPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAugmentInstallConfig(config InstallConfig) error {
|
func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
|
||||||
for _, field := range []struct{ name, value string }{
|
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
||||||
{"agent name", config.AgentName},
|
return []managedArtifact{
|
||||||
{"organization ID", config.OrgID},
|
{wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
|
||||||
{"agent ID", config.AgentID},
|
{wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if config.AgentName != augmentAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Augment Code CLI installation requires agent name %s",
|
|
||||||
augmentAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
|
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
|
||||||
if err := validateAugmentInstallConfig(config); err != nil {
|
if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
|
||||||
if err != nil {
|
return &augmentRuntimePaths{
|
||||||
return nil, err
|
managedRuntimePaths: *paths,
|
||||||
}
|
|
||||||
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
|
||||||
paths := &augmentRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, augmentGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, augmentAuditScript),
|
|
||||||
guardWrapperPath: wrappers.guard,
|
guardWrapperPath: wrappers.guard,
|
||||||
auditWrapperPath: wrappers.audit,
|
auditWrapperPath: wrappers.audit,
|
||||||
}
|
}, nil
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
}
|
||||||
!sameAugmentPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
return validateManagedRuntimeIdentity(
|
||||||
paths.guardPath,
|
agentDirectory, agentID, augmentAgentKey, "Auggie",
|
||||||
|
augmentWrapperArtifacts(agentDirectory)...,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameAugmentPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightAugmentInstallation(
|
func preflightAugmentInstallation(
|
||||||
@@ -98,55 +44,25 @@ func preflightAugmentInstallation(
|
|||||||
document *augmentDocument,
|
document *augmentDocument,
|
||||||
) (*augmentRuntimePaths, string, error) {
|
) (*augmentRuntimePaths, string, error) {
|
||||||
if document == nil || document.configPath == "" {
|
if document == nil || document.configPath == "" {
|
||||||
return nil, "", fmt.Errorf(
|
return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
|
||||||
"Augment Code CLI installation requires a user settings document",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
paths, err := augmentAgentPaths(config)
|
paths, err := augmentAgentPaths(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateAugmentRuntimeIdentity(
|
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
paths.agentDirectory,
|
|
||||||
config.AgentID,
|
|
||||||
); err != nil {
|
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAugmentRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": augmentAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareAugmentInstallationChanges(
|
func prepareAugmentInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *augmentRuntimePaths,
|
paths *augmentRuntimePaths,
|
||||||
@@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges(
|
|||||||
if paths == nil || rendered == nil {
|
if paths == nil || rendered == nil {
|
||||||
return nil, fmt.Errorf("prepared Auggie installation is required")
|
return nil, fmt.Errorf("prepared Auggie installation is required")
|
||||||
}
|
}
|
||||||
if err := validateAugmentRuntimeIdentity(
|
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
paths.agentDirectory,
|
|
||||||
config.AgentID,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildAugmentRuntimeConfig(config)
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config,
|
||||||
|
&paths.managedRuntimePaths,
|
||||||
|
augmentAgentKey,
|
||||||
|
generateGuardScript(augmentAgentKey, config.AgentID, "", false, ""),
|
||||||
|
buildHookScriptWithOutput(augmentAgentKey, config.AgentID, "", false, true),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
for _, item := range []struct {
|
||||||
path, label string
|
path, label string
|
||||||
content []byte
|
content []byte
|
||||||
mode os.FileMode
|
|
||||||
}{
|
}{
|
||||||
{
|
{paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
|
||||||
paths.guardPath,
|
{paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
|
||||||
"Elydora guard runtime",
|
} {
|
||||||
[]byte(generateGuardScript(
|
change, err := prepareFileChange(item.path, item.label, item.content, 0700)
|
||||||
augmentAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
augmentAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
paths.guardWrapperPath,
|
|
||||||
"Auggie guard wrapper",
|
|
||||||
buildAugmentWrapper(nodePath, paths.guardPath),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
paths.auditWrapperPath,
|
|
||||||
"Auggie audit wrapper",
|
|
||||||
buildAugmentWrapper(nodePath, paths.auditPath),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,43 +83,6 @@ func prepareRenderedAugmentChange(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAugmentChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
settingsDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if settingsDirectory != "" {
|
|
||||||
if err := ensureManagedDirectory(
|
|
||||||
settingsDirectory,
|
|
||||||
"Auggie configuration directory",
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
|
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
|
||||||
events := make([]string, 0, len(hooks))
|
events := make([]string, 0, len(hooks))
|
||||||
for event := range hooks {
|
for event := range hooks {
|
||||||
@@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
|
|||||||
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
|
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireAugmentAbsoluteNode(nodePath string) error {
|
|
||||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
|
||||||
return fmt.Errorf("Auggie hooks require an absolute Node.js executable path")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,8 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func managedAugmentIDs(
|
func managedAugmentIDs(
|
||||||
@@ -17,19 +14,10 @@ func managedAugmentIDs(
|
|||||||
result := map[string]string{}
|
result := map[string]string{}
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
agentID, managed := managedAugmentAgentID(
|
agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||||
handler,
|
if managed {
|
||||||
wrapperName,
|
result[managedReferenceKey(agentID)] = agentID
|
||||||
runtimeRoot,
|
|
||||||
)
|
|
||||||
if !managed {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
key := agentID
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
result[key] = agentID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -39,16 +27,8 @@ func augmentRuntimeContracts(
|
|||||||
hooks augmentHooks,
|
hooks augmentHooks,
|
||||||
runtimeRoot string,
|
runtimeRoot string,
|
||||||
) []augmentRuntimeContract {
|
) []augmentRuntimeContract {
|
||||||
guards := managedAugmentIDs(
|
guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
|
||||||
hooks["PreToolUse"],
|
audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
|
||||||
augmentGuardWrapperName(),
|
|
||||||
runtimeRoot,
|
|
||||||
)
|
|
||||||
audits := managedAugmentIDs(
|
|
||||||
hooks["PostToolUse"],
|
|
||||||
augmentAuditWrapperName(),
|
|
||||||
runtimeRoot,
|
|
||||||
)
|
|
||||||
keys := make([]string, 0, len(guards))
|
keys := make([]string, 0, len(guards))
|
||||||
for key := range guards {
|
for key := range guards {
|
||||||
if _, exists := audits[key]; exists {
|
if _, exists := audits[key]; exists {
|
||||||
@@ -72,282 +52,47 @@ func augmentRuntimeContracts(
|
|||||||
return contracts
|
return contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAugmentRuntimeConfig(path string) (map[string]any, bool, error) {
|
func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
|
||||||
snapshot, err := readManagedFile(
|
wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
|
||||||
path,
|
if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
|
||||||
"Elydora runtime config",
|
!sameManagedPath(contract.auditWrapper, wrappers.audit) {
|
||||||
maxRuntimeConfigBytes,
|
return false, nil
|
||||||
)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
}
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireAugmentRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id",
|
|
||||||
"agent_id",
|
|
||||||
"kid",
|
|
||||||
"base_url",
|
|
||||||
"token",
|
|
||||||
"agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireAugmentRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireAugmentRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireAugmentRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameAugmentAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != augmentAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Auggie hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireAugmentRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireAugmentRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readAugmentRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
path string
|
path, label string
|
||||||
label string
|
expected []byte
|
||||||
limit int64
|
|
||||||
}{
|
}{
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
{contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
|
||||||
{filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
{contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
|
||||||
{filepath.Join(agentDirectory, augmentAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, augmentGuardWrapperName()), "Auggie guard wrapper", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, augmentAuditWrapperName()), "Auggie audit wrapper", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
} {
|
||||||
exists, inspectErr := managedPhysicalFileExists(
|
snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
|
||||||
item.path,
|
if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
|
||||||
item.label,
|
|
||||||
item.limit,
|
|
||||||
)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != augmentAgentKey ||
|
|
||||||
!sameAugmentAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Auggie agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func augmentRuntimeContractExists(
|
|
||||||
contract augmentRuntimeContract,
|
|
||||||
runtimeRoot string,
|
|
||||||
nodePath string,
|
|
||||||
) (bool, error) {
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
|
||||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameAugmentPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, augmentAuditScript),
|
|
||||||
) ||
|
|
||||||
!sameAugmentPath(contract.guardWrapper, wrappers.guard) ||
|
|
||||||
!sameAugmentPath(contract.auditWrapper, wrappers.audit) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
return true, nil
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readAugmentRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guardWrapper, err := readManagedFile(
|
|
||||||
contract.guardWrapper,
|
|
||||||
"Auggie guard wrapper",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
auditWrapper, err := readManagedFile(
|
|
||||||
contract.auditWrapper,
|
|
||||||
"Auggie audit wrapper",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil ||
|
|
||||||
guardWrapper == nil || auditWrapper == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateAugmentRuntimeConfig(
|
|
||||||
config,
|
|
||||||
contract.agentID,
|
|
||||||
configPath,
|
|
||||||
); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 &&
|
|
||||||
len(audit.contents) > 0 &&
|
|
||||||
bytes.Equal(
|
|
||||||
guardWrapper.contents,
|
|
||||||
buildAugmentWrapper(nodePath, contract.guardPath),
|
|
||||||
) &&
|
|
||||||
bytes.Equal(
|
|
||||||
auditWrapper.contents,
|
|
||||||
buildAugmentWrapper(nodePath, contract.auditPath),
|
|
||||||
), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func augmentRuntimeFilesExist(
|
func augmentRuntimeFilesExist(
|
||||||
contracts []augmentRuntimeContract,
|
contracts []augmentRuntimeContract,
|
||||||
runtimeRoot string,
|
runtimeRoot string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath)
|
runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}
|
||||||
|
exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil)
|
||||||
|
if err != nil || !exists {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if exists {
|
continue
|
||||||
return true, nil
|
}
|
||||||
|
matches, err := augmentWrappersMatch(contract, nodePath)
|
||||||
|
if err != nil || matches {
|
||||||
|
return matches, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
|
|||||||
)
|
)
|
||||||
failed := false
|
failed := false
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameAugmentPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") &&
|
strings.HasSuffix(source, ".tmp") &&
|
||||||
!failed {
|
!failed {
|
||||||
failed = true
|
failed = true
|
||||||
@@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
|||||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||||
t.Fatalf("write concurrent settings: %v", err)
|
t.Fatalf("write concurrent settings: %v", err)
|
||||||
}
|
}
|
||||||
err := writeAugmentChanges(
|
err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Augment Code CLI hooks",
|
"Install Augment Code CLI hooks",
|
||||||
nil,
|
nil,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.configPath),
|
filepath.Dir(document.configPath),
|
||||||
|
"Auggie configuration directory",
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
@@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed settings: %v", err)
|
t.Fatalf("read installed settings: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameAugmentPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Auggie uninstall failure")
|
return errors.New("injected Auggie uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ type ClaudeCodePlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Claude Code commits its provider guard with
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// the audit runtime and user settings document.
|
|
||||||
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
|
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeClaudeChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Claude Code hooks",
|
"Install Claude Code hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Claude Code configuration directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeClaudeChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Claude Code hooks",
|
"Uninstall Claude Code hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Claude Code configuration directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,19 +77,7 @@ type claudeRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type claudeRuntimeContract struct {
|
type claudeRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneClaudeObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneClaudeHooks(source claudeHooks) claudeHooks {
|
func cloneClaudeHooks(source claudeHooks) claudeHooks {
|
||||||
clone := make(claudeHooks, len(source))
|
clone := make(claudeHooks, len(source))
|
||||||
@@ -299,7 +287,7 @@ func validateClaudeHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloneClaudeObject(handler), nil
|
return cloneJSONObject(handler), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
|
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
|
||||||
@@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup,
|
|||||||
}
|
}
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
}
|
}
|
||||||
return claudeGroup{object: cloneClaudeObject(object), handlers: handlers}, nil
|
return claudeGroup{object: cloneJSONObject(object), handlers: handlers}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readClaudeHooks(root map[string]any) (claudeHooks, error) {
|
func readClaudeHooks(root map[string]any) (claudeHooks, error) {
|
||||||
@@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any {
|
|||||||
for event, groups := range hooks {
|
for event, groups := range hooks {
|
||||||
values := make([]any, 0, len(groups))
|
values := make([]any, 0, len(groups))
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
object := cloneClaudeObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
handlers := make([]any, 0, len(group.handlers))
|
handlers := make([]any, 0, len(group.handlers))
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
@@ -439,7 +427,7 @@ func renderClaudeDocument(
|
|||||||
document: document, changed: true, remove: true,
|
document: document, changed: true, remove: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
root := cloneClaudeObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,182 +1,48 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type claudeRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClaudeInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != claudeAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Claude Code installation requires agent name %s",
|
|
||||||
claudeAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) {
|
|
||||||
if err := validateClaudeInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &claudeRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, claudeGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, claudeAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameClaudePath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameClaudePath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightClaudeInstallation(
|
func preflightClaudeInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
document *claudeDocument,
|
document *claudeDocument,
|
||||||
) (*claudeRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if document == nil || document.filePath == "" {
|
if document == nil || document.filePath == "" {
|
||||||
return nil, "", fmt.Errorf(
|
return nil, "", fmt.Errorf("Claude Code installation requires a user settings document")
|
||||||
"Claude Code installation requires a user settings document",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
paths, err := claudeAgentPaths(config)
|
if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := requireClaudeAbsoluteNode(nodePath); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClaudeRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": claudeAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareClaudeInstallationChanges(
|
func prepareClaudeInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *claudeRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered *claudeRenderedDocument,
|
rendered *claudeRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildClaudeRuntimeConfig(config)
|
changes, err := managedRuntimeFileChanges(
|
||||||
if err != nil {
|
config,
|
||||||
return nil, err
|
paths,
|
||||||
}
|
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(claudeAgentKey, config.AgentID, "", false, "")),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
claudeAgentKey,
|
claudeAgentKey,
|
||||||
config.AgentID,
|
generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
|
||||||
"",
|
buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
|
||||||
false,
|
)
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedClaudeChange(rendered)
|
documentChange, err := prepareRenderedClaudeChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -71,38 +71,3 @@ func prepareRenderedClaudeChange(
|
|||||||
rendered.remove,
|
rendered.remove,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeClaudeChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
settingsDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(
|
|
||||||
settingsDirectory,
|
|
||||||
"Claude Code configuration directory",
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
@@ -1,73 +1,18 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type claudeRuntimeReference struct {
|
type claudeRuntimeReference = managedScriptReference
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClaudePath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClaudeAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func isClaudeNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClaudeFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRuntimeReferenceForScript(
|
func claudeRuntimeReferenceForScript(
|
||||||
scriptPath string,
|
scriptPath string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*claudeRuntimeReference, error) {
|
) (*claudeRuntimeReference, error) {
|
||||||
if !filepath.IsAbs(scriptPath) ||
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
!sameClaudeFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func claudeLegacyReference(
|
func claudeLegacyReference(
|
||||||
@@ -95,7 +40,7 @@ func managedClaudeReference(
|
|||||||
handler["statusMessage"] == statusMessage {
|
handler["statusMessage"] == statusMessage {
|
||||||
nodePath, nodeOK := handler["command"].(string)
|
nodePath, nodeOK := handler["command"].(string)
|
||||||
args, argsOK := handler["args"].([]any)
|
args, argsOK := handler["args"].([]any)
|
||||||
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) &&
|
if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
|
||||||
argsOK && len(args) == 1 {
|
argsOK && len(args) == 1 {
|
||||||
if scriptPath, ok := args[0].(string); ok {
|
if scriptPath, ok := args[0].(string); ok {
|
||||||
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
||||||
@@ -146,7 +91,7 @@ func removeManagedClaudeGroups(
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
owned := reference != nil &&
|
owned := reference != nil &&
|
||||||
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if owned {
|
if owned {
|
||||||
removed = true
|
removed = true
|
||||||
continue
|
continue
|
||||||
@@ -154,7 +99,7 @@ func removeManagedClaudeGroups(
|
|||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
||||||
object := cloneClaudeObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
object["hooks"] = group.object["hooks"]
|
object["hooks"] = group.object["hooks"]
|
||||||
result = append(result, claudeGroup{object: object, handlers: kept})
|
result = append(result, claudeGroup{object: object, handlers: kept})
|
||||||
}
|
}
|
||||||
@@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool {
|
|||||||
return handlerCount > 0
|
return handlerCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func claudeReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeReferencesForEvent(
|
func claudeReferencesForEvent(
|
||||||
groups []claudeGroup,
|
groups []claudeGroup,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
@@ -263,7 +201,7 @@ func claudeReferencesForEvent(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if reference != nil {
|
if reference != nil {
|
||||||
key := claudeReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
|||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameClaudePath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, claudeRuntimeContract{
|
contracts = append(contracts, claudeRuntimeContract{
|
||||||
@@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
|||||||
}
|
}
|
||||||
return contracts, nil
|
return contracts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireClaudeAbsoluteNode(nodePath string) error {
|
|
||||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
|
||||||
return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -150,10 +150,10 @@ func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
|
|||||||
}, "query parameters"},
|
}, "query parameters"},
|
||||||
{"guard path", func(f *claudeFixture) {
|
{"guard path", func(f *claudeFixture) {
|
||||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
{"audit path", func(f *claudeFixture) {
|
{"audit path", func(f *claudeFixture) {
|
||||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
}
|
}
|
||||||
for _, testCase := range tests {
|
for _, testCase := range tests {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,231 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"fmt"
|
return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readClaudeRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireClaudeRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClaudeRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireClaudeRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameClaudeAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != claudeAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity does not match Claude Code hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireClaudeRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClaudeRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readClaudeRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, claudeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != claudeAgentKey ||
|
|
||||||
!sameClaudeAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Claude Code agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameClaudePath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, claudeAuditScript),
|
|
||||||
) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readClaudeRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateClaudeRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := claudeRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
before := snapshotClaudeFiles(t, paths...)
|
before := snapshotClaudeFiles(t, paths...)
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameClaudePath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Claude settings failure")
|
return errors.New("injected Claude settings failure")
|
||||||
}
|
}
|
||||||
@@ -114,13 +114,14 @@ func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
|||||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||||
t.Fatalf("write concurrent settings: %v", err)
|
t.Fatalf("write concurrent settings: %v", err)
|
||||||
}
|
}
|
||||||
err = writeClaudeChanges(
|
err = writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Claude Code hooks",
|
"Install Claude Code hooks",
|
||||||
nil,
|
nil,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Claude Code configuration directory",
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
@@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed settings: %v", err)
|
t.Fatalf("read installed settings: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameClaudePath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Claude uninstall failure")
|
return errors.New("injected Claude uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ type ClinePlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Cline commits both generated runtimes and
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// both native hook files in one transaction.
|
|
||||||
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameClineAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeClinePath(value string) (string, error) {
|
|
||||||
absolute, err := filepath.Abs(value)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
normalized := filepath.Clean(absolute)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
normalized = strings.ToLower(normalized)
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClinePath(left, right string) (bool, error) {
|
|
||||||
normalizedLeft, err := normalizeClinePath(left)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
normalizedRight, err := normalizeClinePath(right)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return normalizedLeft == normalizedRight, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
|
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
|
||||||
metadata := clineHookMetadata{
|
metadata := clineHookMetadata{
|
||||||
Version: clineMetadataVersion,
|
Version: clineMetadataVersion,
|
||||||
@@ -314,7 +282,7 @@ func clineContractForFiles(
|
|||||||
if guard.Kind != "guard" || audit.Kind != "audit" {
|
if guard.Kind != "guard" || audit.Kind != "audit" {
|
||||||
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
|
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
|
||||||
}
|
}
|
||||||
if !sameClineAgentID(guard.AgentID, audit.AgentID) {
|
if !sameManagedAgentID(guard.AgentID, audit.AgentID) {
|
||||||
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
||||||
}
|
}
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
||||||
@@ -323,15 +291,8 @@ func clineContractForFiles(
|
|||||||
}
|
}
|
||||||
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
|
||||||
if err != nil {
|
!sameManagedPath(audit.RuntimePath, expectedAudit) {
|
||||||
return nil, fmt.Errorf("compare Cline guard runtime path: %w", err)
|
|
||||||
}
|
|
||||||
auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("compare Cline audit runtime path: %w", err)
|
|
||||||
}
|
|
||||||
if !guardMatches || !auditMatches {
|
|
||||||
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
||||||
}
|
}
|
||||||
return &clineRuntimeContract{
|
return &clineRuntimeContract{
|
||||||
|
|||||||
@@ -1,97 +1,21 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type clineRuntimePaths struct {
|
type clineRuntimePaths struct {
|
||||||
runtimeRoot string
|
managedRuntimePaths
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
hooks clineHookPaths
|
hooks clineHookPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateClineInstallConfig(config InstallConfig) error {
|
func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
|
||||||
for _, field := range []struct{ name, value string }{
|
if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != clineAgentKey {
|
|
||||||
return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clineAgentPaths(
|
|
||||||
config InstallConfig,
|
|
||||||
hooks clineHookPaths,
|
|
||||||
) (*clineRuntimePaths, error) {
|
|
||||||
if err := validateClineInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &clineRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, clineGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, clineAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameClinePathValue(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameClinePathValue(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClinePathValue(left, right string) bool {
|
|
||||||
matches, err := sameClinePath(left, right)
|
|
||||||
return err == nil && matches
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightClineInstallation(
|
func preflightClineInstallation(
|
||||||
@@ -102,39 +26,20 @@ func preflightClineInstallation(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
|
||||||
return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path")
|
|
||||||
}
|
|
||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
value := map[string]any{
|
return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
[]byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
|
||||||
"base_url": config.BaseURL, "agent_name": clineAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareClineInstallationChanges(
|
func prepareClineInstallationChanges(
|
||||||
@@ -146,11 +51,9 @@ func prepareClineInstallationChanges(
|
|||||||
if paths == nil {
|
if paths == nil {
|
||||||
return nil, fmt.Errorf("prepared Cline installation is required")
|
return nil, fmt.Errorf("prepared Cline installation is required")
|
||||||
}
|
}
|
||||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
return nil, err
|
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
|
||||||
}
|
); err != nil {
|
||||||
runtimeConfig, err := buildClineRuntimeConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
||||||
@@ -175,24 +78,13 @@ func prepareClineInstallationChanges(
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeItems := []struct {
|
guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
|
||||||
path, label string
|
changes, err := managedRuntimeFileChanges(
|
||||||
content []byte
|
config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
|
||||||
mode os.FileMode
|
)
|
||||||
}{
|
|
||||||
{paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(runtimeItems)+2)
|
|
||||||
for _, item := range runtimeItems {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
file clineHookFile
|
file clineHookFile
|
||||||
label string
|
label string
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func writeClineHookChanges(
|
|||||||
rename renameFunc,
|
rename renameFunc,
|
||||||
paths clineHookPaths,
|
paths clineHookPaths,
|
||||||
) error {
|
) error {
|
||||||
if !hasClineChanges(changes) {
|
if !hasFileChanges(changes) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := ensureManagedDirectory(
|
if err := ensureManagedDirectory(
|
||||||
@@ -95,7 +95,7 @@ func writeClineChanges(
|
|||||||
rename renameFunc,
|
rename renameFunc,
|
||||||
paths *clineRuntimePaths,
|
paths *clineRuntimePaths,
|
||||||
) error {
|
) error {
|
||||||
if !hasClineChanges(changes) {
|
if !hasFileChanges(changes) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
|
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
|
||||||
@@ -107,15 +107,6 @@ func writeClineChanges(
|
|||||||
return writeClineHookChanges(changes, label, rename, paths.hooks)
|
return writeClineHookChanges(changes, label, rename, paths.hooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasClineChanges(changes []*fileChange) bool {
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareClineUninstallChanges(
|
func prepareClineUninstallChanges(
|
||||||
files []clineHookFile,
|
files []clineHookFile,
|
||||||
agentID string,
|
agentID string,
|
||||||
@@ -123,7 +114,7 @@ func prepareClineUninstallChanges(
|
|||||||
changes := make([]*fileChange, 0, len(files))
|
changes := make([]*fileChange, 0, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if file.metadata == nil ||
|
if file.metadata == nil ||
|
||||||
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) {
|
(agentID != "" && !sameManagedAgentID(file.metadata.AgentID, agentID)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := assertClineWrapperIntegrity(file); err != nil {
|
if err := assertClineWrapperIntegrity(file); err != nil {
|
||||||
|
|||||||
@@ -1,206 +1,15 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "path/filepath"
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readClineRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireClineRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClineRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireClineRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireClineRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireClineRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameClineAgentID(agentID, expectedAgentID) || config["agent_name"] != clineAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Cline hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireClineRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireClineRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClineRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readClineRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, clineGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, clineAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != clineAgentKey ||
|
|
||||||
!sameClineAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Cline agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
if !sameManagedPath(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) {
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !sameClinePathValue(filepath.Dir(contract.agentDirectory), runtimeRoot) ||
|
|
||||||
!sameClinePathValue(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) ||
|
|
||||||
!sameClinePathValue(contract.auditPath, filepath.Join(contract.agentDirectory, clineAuditScript)) {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
return managedRuntimeContractExists(
|
||||||
if err != nil || !rootExists {
|
managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath},
|
||||||
return false, err
|
clineAgentKey,
|
||||||
}
|
"Cline",
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
clineExpectedScripts,
|
||||||
contract.agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
)
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(contract.agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(contract.agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readClineRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateClineRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
expectedGuard := []byte(generateGuardScript(clineAgentKey, contract.agentID, "", false, ""))
|
|
||||||
expectedAudit := []byte(buildHookScriptWithOutput(clineAgentKey, contract.agentID, "", false, true))
|
|
||||||
return bytes.Equal(guard.contents, expectedGuard) && bytes.Equal(audit.contents, expectedAudit), nil
|
|
||||||
}
|
}
|
||||||
@@ -10,14 +10,12 @@ type CodexPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Codex commits its provider guard with the
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// audit runtime and user hook document.
|
|
||||||
func (p *CodexPlugin) ManagesGuardRuntime() bool {
|
func (p *CodexPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreflightInstall validates every existing source before the CLI creates
|
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||||
// runtime state.
|
|
||||||
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
|
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
document, err := readCodexDocument()
|
document, err := readCodexDocument()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,13 +59,14 @@ func (p *CodexPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeCodexChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Codex hooks",
|
"Install Codex hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Codex hooks directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -90,13 +89,14 @@ func (p *CodexPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeCodexChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Codex hooks",
|
"Uninstall Codex hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Codex hooks directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,12 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "path/filepath"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf16"
|
|
||||||
)
|
|
||||||
|
|
||||||
func quoteCodexPowerShell(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func codexPowerShellPath() string {
|
|
||||||
root := "C:\\Windows"
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
configured := os.Getenv("SystemRoot")
|
|
||||||
if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") {
|
|
||||||
root = configured
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimRight(root, `\/`) +
|
|
||||||
`\System32\WindowsPowerShell\v1.0\powershell.exe`
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeCodexPowerShell(source string) string {
|
|
||||||
runes := utf16.Encode([]rune(source))
|
|
||||||
raw := make([]byte, len(runes)*2)
|
|
||||||
for index, value := range runes {
|
|
||||||
binary.LittleEndian.PutUint16(raw[index*2:], value)
|
|
||||||
}
|
|
||||||
return base64.StdEncoding.EncodeToString(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func codexWindowsCommand(runtimePath, scriptPath string) string {
|
|
||||||
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
|
|
||||||
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
|
||||||
return `"` + codexPowerShellPath() +
|
|
||||||
`" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` +
|
|
||||||
encodeCodexPowerShell(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
|
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath),
|
"command": posixSource(runtimePath, scriptPath),
|
||||||
"commandWindows": codexWindowsCommand(runtimePath, scriptPath),
|
"commandWindows": encodedWindowsCommand(runtimePath, scriptPath),
|
||||||
"timeout": codexHookTimeout,
|
"timeout": codexHookTimeout,
|
||||||
"statusMessage": statusMessage,
|
"statusMessage": statusMessage,
|
||||||
}
|
}
|
||||||
@@ -57,124 +16,6 @@ func codexMatcherGroup(handler map[string]any) map[string]any {
|
|||||||
return map[string]any{"matcher": "*", "hooks": []any{handler}}
|
return map[string]any{"matcher": "*", "hooks": []any{handler}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCodexPOSIXArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); {
|
|
||||||
if strings.HasPrefix(command[index:], codexPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(codexPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCodexPOSIXCommand(value any) (string, string, bool) {
|
|
||||||
command, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
runtimePath, next, ok := readCodexPOSIXArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
scriptPath, end, ok := readCodexPOSIXArgument(command, next+1)
|
|
||||||
return runtimePath, scriptPath, ok && end == len(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCodexPowerShellArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); index++ {
|
|
||||||
if command[index] != '\'' {
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if index+1 < len(command) && command[index+1] == '\'' {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCodexPowerShellSource(source string) (string, string, bool) {
|
|
||||||
if !strings.HasPrefix(source, "& ") {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
runtimePath, next, ok := readCodexPowerShellArgument(source, 2)
|
|
||||||
if !ok || next >= len(source) || source[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
scriptPath, end, ok := readCodexPowerShellArgument(source, next+1)
|
|
||||||
return runtimePath, scriptPath, ok && source[end:] == "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
|
|
||||||
func windowsPathBase(value string) string {
|
|
||||||
index := strings.LastIndexAny(value, `\/`)
|
|
||||||
return value[index+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAbsoluteWindowsPath(value string) bool {
|
|
||||||
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') ||
|
|
||||||
(value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' &&
|
|
||||||
(value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeCodexPowerShell(encoded string) (string, bool) {
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
|
||||||
if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
units := make([]uint16, len(raw)/2)
|
|
||||||
for index := range units {
|
|
||||||
units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8
|
|
||||||
}
|
|
||||||
return string(utf16.Decode(units)), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCodexWindowsCommand(value any) (string, string, bool) {
|
|
||||||
command, ok := value.(string)
|
|
||||||
if !ok || !strings.HasPrefix(command, `"`) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
closing := strings.IndexByte(command[1:], '"')
|
|
||||||
if closing < 0 {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
closing++
|
|
||||||
powerShell := command[1:closing]
|
|
||||||
prefix := `" -NoLogo -NoProfile -NonInteractive -EncodedCommand `
|
|
||||||
if !isAbsoluteWindowsPath(powerShell) ||
|
|
||||||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") ||
|
|
||||||
!strings.HasPrefix(command[closing:], prefix) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
encoded := command[closing+len(prefix):]
|
|
||||||
source, ok := decodeCodexPowerShell(encoded)
|
|
||||||
if !ok {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return parseCodexPowerShellSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCodexNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func exactCodexHandlerKeys(handler map[string]any) bool {
|
func exactCodexHandlerKeys(handler map[string]any) bool {
|
||||||
if len(handler) != 5 {
|
if len(handler) != 5 {
|
||||||
return false
|
return false
|
||||||
@@ -192,20 +33,21 @@ func codexManagedScriptPath(handler map[string]any, status string) (string, bool
|
|||||||
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
|
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"])
|
command, _ := handler["command"].(string)
|
||||||
windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"])
|
commandWindows, _ := handler["commandWindows"].(string)
|
||||||
|
posixRuntime, posixScript, posixOK := parsePOSIXCommand(command)
|
||||||
|
windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows)
|
||||||
if !windowsOK && posixOK {
|
if !windowsOK && posixOK {
|
||||||
legacy, ok := handler["commandWindows"].(string)
|
|
||||||
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
|
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
|
||||||
if ok && legacy == expected {
|
if commandWindows == expected {
|
||||||
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
|
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
|
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
|
||||||
!filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) ||
|
!filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) ||
|
||||||
!isCodexNodeExecutable(windowsRuntime) ||
|
!isNodeExecutable(windowsRuntime) ||
|
||||||
!sameCodexPath(posixRuntime, windowsRuntime) ||
|
!sameManagedPath(posixRuntime, windowsRuntime) ||
|
||||||
!sameCodexPath(posixScript, windowsScript) {
|
!sameManagedPath(posixScript, windowsScript) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return posixScript, true
|
return posixScript, true
|
||||||
@@ -217,14 +59,12 @@ func codexManagedAgentID(
|
|||||||
status string,
|
status string,
|
||||||
) (string, bool) {
|
) (string, bool) {
|
||||||
scriptPath, managed := codexManagedScriptPath(handler, status)
|
scriptPath, managed := codexManagedScriptPath(handler, status)
|
||||||
if !managed || filepath.Base(scriptPath) != scriptName {
|
if !managed {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
reference, err := resolveManagedScript(scriptPath, scriptName)
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
if err != nil || reference == nil {
|
||||||
if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentID := filepath.Base(agentDirectory)
|
return reference.agentID, true
|
||||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +18,6 @@ const (
|
|||||||
codexOwnedDescription = "Elydora audit and freeze enforcement"
|
codexOwnedDescription = "Elydora audit and freeze enforcement"
|
||||||
codexGuardStatusMessage = "Checking Elydora agent state"
|
codexGuardStatusMessage = "Checking Elydora agent state"
|
||||||
codexAuditStatusMessage = "Recording Elydora tool use"
|
codexAuditStatusMessage = "Recording Elydora tool use"
|
||||||
codexPOSIXApostrophe = `'"'"'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type codexHooks map[string][]map[string]any
|
type codexHooks map[string][]map[string]any
|
||||||
@@ -40,19 +37,7 @@ type codexRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type codexRuntimeContract struct {
|
type codexRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneCodexObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneCodexHooks(source codexHooks) codexHooks {
|
func cloneCodexHooks(source codexHooks) codexHooks {
|
||||||
clone := make(codexHooks, len(source))
|
clone := make(codexHooks, len(source))
|
||||||
@@ -62,28 +47,6 @@ func cloneCodexHooks(source codexHooks) codexHooks {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameCodexPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameCodexAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCodexHooks(value any, label string) (codexHooks, error) {
|
func readCodexHooks(value any, label string) (codexHooks, error) {
|
||||||
object, ok := value.(map[string]any)
|
object, ok := value.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -126,16 +89,6 @@ func readCodexHooks(value any, label string) (codexHooks, error) {
|
|||||||
return hooks, nil
|
return hooks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
|
|
||||||
if !json.Valid(source) {
|
|
||||||
var value any
|
|
||||||
if err := json.Unmarshal(source, &value); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse %s: %w", label, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decodeJSONCObject(source, label, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
|
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
|
||||||
label := fmt.Sprintf("Codex user hooks at %s", filePath)
|
label := fmt.Sprintf("Codex user hooks at %s", filePath)
|
||||||
root, err := decodeStrictJSONObject(raw, label)
|
root, err := decodeStrictJSONObject(raw, label)
|
||||||
@@ -180,13 +133,13 @@ func removeCodexGroups(
|
|||||||
for _, value := range handlers {
|
for _, value := range handlers {
|
||||||
handler := value.(map[string]any)
|
handler := value.(map[string]any)
|
||||||
managedID, managed := codexManagedAgentID(handler, scriptName, status)
|
managedID, managed := codexManagedAgentID(handler, scriptName, status)
|
||||||
if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) {
|
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
if len(kept) > 0 || !exactCodexMatcherGroup(group) {
|
if len(kept) > 0 || !exactCodexMatcherGroup(group) {
|
||||||
next := cloneCodexObject(group)
|
next := cloneJSONObject(group)
|
||||||
next["hooks"] = kept
|
next["hooks"] = kept
|
||||||
result = append(result, next)
|
result = append(result, next)
|
||||||
}
|
}
|
||||||
@@ -262,7 +215,7 @@ func renderCodexDocument(
|
|||||||
if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
|
if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
|
||||||
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
|
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
|
||||||
}
|
}
|
||||||
root := cloneCodexObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
} else {
|
} else {
|
||||||
@@ -295,11 +248,7 @@ func managedCodexIDs(
|
|||||||
if !managed {
|
if !managed {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := agentID
|
result[managedReferenceKey(agentID)] = agentID
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
result[key] = agentID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,94 +1,26 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "path/filepath"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
type codexRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCodexInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != codexAgentKey {
|
|
||||||
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
|
|
||||||
if err := validateCodexInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &codexRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot,
|
|
||||||
agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, codexGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, codexAuditScript),
|
|
||||||
}
|
|
||||||
if !sameCodexPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && !sameCodexPath(config.HookScript, paths.auditPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightCodexInstallation(
|
func preflightCodexInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
hooksPath string,
|
hooksPath string,
|
||||||
) (*codexRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
paths, err := codexAgentPaths(config)
|
if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if _, err := managedPhysicalDirectoryExists(
|
if _, err := managedPhysicalDirectoryExists(
|
||||||
filepath.Dir(hooksPath),
|
filepath.Dir(hooksPath), "Codex hooks directory",
|
||||||
"Codex hooks directory",
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateCodexRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, codexAgentKey, "Codex",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
@@ -98,64 +30,21 @@ func preflightCodexInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCodexRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
encoded, err := json.MarshalIndent(agentRuntimeConfig{
|
|
||||||
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
|
|
||||||
BaseURL: config.BaseURL, Token: config.Token, AgentName: codexAgentKey,
|
|
||||||
}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareCodexInstallationChanges(
|
func prepareCodexInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *codexRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered *codexRenderedDocument,
|
rendered *codexRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildCodexRuntimeConfig(config)
|
changes, err := managedRuntimeFileChanges(
|
||||||
if err != nil {
|
config,
|
||||||
return nil, err
|
paths,
|
||||||
}
|
codexAgentKey,
|
||||||
items := []struct {
|
generateGuardScript(codexAgentKey, config.AgentID, "", false, ""),
|
||||||
path, label string
|
buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true),
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(codexAgentKey, config.AgentID, "", false, "")), 0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
codexAgentKey, config.AgentID, "", false, true,
|
|
||||||
)), 0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(
|
|
||||||
item.path,
|
|
||||||
item.label,
|
|
||||||
item.content,
|
|
||||||
item.mode,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedCodexChange(rendered)
|
documentChange, err := prepareRenderedCodexChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -82,152 +82,6 @@ func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, e
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeCodexChanges(
|
func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
changes []*fileChange,
|
return managedRuntimeFilesPresent(contracts, codexAgentKey)
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
hooksDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(hooksDirectory, "Codex hooks directory"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCodexRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCodexRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readCodexRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, codexGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, codexAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != codexAgentKey ||
|
|
||||||
!sameCodexAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Codex agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func codexRuntimeFilesExist(contracts []codexRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !directoryExists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
config, exists, err := readCodexRuntimeConfig(
|
|
||||||
filepath.Join(agentDirectory, "config.json"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configuredID, idOK := config["agent_id"].(string)
|
|
||||||
if !exists || !idOK || config["agent_name"] != codexAgentKey ||
|
|
||||||
!sameCodexAgentID(configuredID, contract.agentID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
complete := true
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return false, inspectErr
|
|
||||||
}
|
|
||||||
complete = complete && exists
|
|
||||||
}
|
|
||||||
if complete {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) {
|
|||||||
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
|
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
|
||||||
fixture := prepareCodexFixture(t, string(source))
|
fixture := prepareCodexFixture(t, string(source))
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameCodexPath(destination, fixture.configPath) {
|
if sameManagedPath(destination, fixture.configPath) {
|
||||||
return errors.New("injected Codex hooks commit failure")
|
return errors.New("injected Codex hooks commit failure")
|
||||||
}
|
}
|
||||||
return os.Rename(source, destination)
|
return os.Rename(source, destination)
|
||||||
|
|||||||
@@ -355,10 +355,10 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
|
|||||||
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
||||||
t.Fatalf("unexpected remaining hooks: %#v", hooks)
|
t.Fatalf("unexpected remaining hooks: %#v", hooks)
|
||||||
}
|
}
|
||||||
_, remainingScript, parsed := parseCodexPOSIXCommand(
|
_, remainingScript, parsed := parsePOSIXCommand(
|
||||||
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"],
|
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string),
|
||||||
)
|
)
|
||||||
if !parsed || !sameCodexPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) {
|
if !parsed || !sameManagedPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) {
|
||||||
t.Fatalf("other agent handler was removed: %#v", hooks)
|
t.Fatalf("other agent handler was removed: %#v", hooks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ type CopilotPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Copilot commits its provider guard with the
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// audit runtime and hook documents.
|
|
||||||
func (p *CopilotPlugin) ManagesGuardRuntime() bool {
|
func (p *CopilotPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreflightInstall validates hook sources, settings, matchers, credentials,
|
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||||
// and runtime identity before any write.
|
|
||||||
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
|
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
sources, _, err := readCopilotSources()
|
sources, _, err := readCopilotSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,7 +163,7 @@ func mergedCopilotContracts(
|
|||||||
}
|
}
|
||||||
unique := map[string]copilotRuntimeContract{}
|
unique := map[string]copilotRuntimeContract{}
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
unique[copilotEntryKey(contract.agentID)] = contract
|
unique[managedReferenceKey(contract.agentID)] = contract
|
||||||
}
|
}
|
||||||
result := make([]copilotRuntimeContract, 0, len(unique))
|
result := make([]copilotRuntimeContract, 0, len(unique))
|
||||||
for _, contract := range unique {
|
for _, contract := range unique {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -17,7 +16,6 @@ const (
|
|||||||
copilotConfigFile = "elydora-audit.json"
|
copilotConfigFile = "elydora-audit.json"
|
||||||
copilotHookTimeout = float64(10)
|
copilotHookTimeout = float64(10)
|
||||||
copilotLegacyTimeout = float64(5)
|
copilotLegacyTimeout = float64(5)
|
||||||
copilotPOSIXApostrophe = `'"'"'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var copilotManagedEvents = []struct {
|
var copilotManagedEvents = []struct {
|
||||||
@@ -61,23 +59,19 @@ type copilotRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type copilotRuntimeContract struct {
|
type copilotRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type copilotManagedEntry struct {
|
type copilotManagedEntry = managedScriptReference
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneCopilotObject(value map[string]any) map[string]any {
|
func copilotManagedEntryForHandler(
|
||||||
clone := make(map[string]any, len(value))
|
handler map[string]any,
|
||||||
for key, item := range value {
|
scriptName string,
|
||||||
clone[key] = item
|
) (*copilotManagedEntry, error) {
|
||||||
|
scriptPath, managed := copilotManagedScriptPath(handler)
|
||||||
|
if !managed {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
return clone
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
||||||
@@ -90,109 +84,15 @@ func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameCopilotPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameCopilotAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameCopilotFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteCopilotPowerShell(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCopilotHandler(nodePath, scriptPath string) map[string]any {
|
func buildCopilotHandler(nodePath, scriptPath string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"bash": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath),
|
"bash": posixSource(nodePath, scriptPath),
|
||||||
"powershell": "& " + quoteCopilotPowerShell(nodePath) + " " + quoteCopilotPowerShell(scriptPath) + "; exit $LASTEXITCODE",
|
"powershell": powerShellSource(nodePath, scriptPath),
|
||||||
"timeoutSec": copilotHookTimeout,
|
"timeoutSec": copilotHookTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCopilotPOSIXArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); {
|
|
||||||
if strings.HasPrefix(command[index:], copilotPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(copilotPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCopilotPowerShellArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); index++ {
|
|
||||||
if command[index] != '\'' {
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if index+1 < len(command) && command[index+1] == '\'' {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCopilotBash(command string) (string, string, bool) {
|
|
||||||
executable, next, ok := readCopilotPOSIXArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readCopilotPOSIXArgument(command, next+1)
|
|
||||||
return executable, script, ok && end == len(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCopilotPowerShell(command string) (string, string, bool) {
|
|
||||||
if !strings.HasPrefix(command, "& ") {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
executable, next, ok := readCopilotPowerShellArgument(command, 2)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readCopilotPowerShellArgument(command, next+1)
|
|
||||||
return executable, script, ok && command[end:] == "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCopilotLegacyCommand(command string) (string, bool) {
|
func parseCopilotLegacyCommand(command string) (string, bool) {
|
||||||
command = strings.TrimSpace(command)
|
command = strings.TrimSpace(command)
|
||||||
separator := strings.IndexAny(command, " \t")
|
separator := strings.IndexAny(command, " \t")
|
||||||
@@ -211,11 +111,6 @@ func parseCopilotLegacyCommand(command string) (string, bool) {
|
|||||||
return script, script != ""
|
return script, script != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func isCopilotNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
||||||
if len(handler) != 4 || handler["type"] != "command" {
|
if len(handler) != 4 || handler["type"] != "command" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -226,11 +121,11 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if handler["timeoutSec"] == copilotHookTimeout {
|
if handler["timeoutSec"] == copilotHookTimeout {
|
||||||
bashNode, bashScript, bashParsed := parseCopilotBash(bash)
|
bashNode, bashScript, bashParsed := parsePOSIXCommand(bash)
|
||||||
psNode, psScript, psParsed := parseCopilotPowerShell(powershell)
|
psNode, psScript, psParsed := parsePowerShellSource(powershell)
|
||||||
if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) &&
|
if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) &&
|
||||||
isCopilotNodeExecutable(bashNode) && sameCopilotPath(bashNode, psNode) &&
|
isNodeExecutable(bashNode) && sameManagedPath(bashNode, psNode) &&
|
||||||
sameCopilotPath(bashScript, psScript) {
|
sameManagedPath(bashScript, psScript) {
|
||||||
return bashScript, true
|
return bashScript, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,30 +135,6 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
|||||||
return parseCopilotLegacyCommand(bash)
|
return parseCopilotLegacyCommand(bash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func copilotManagedEntryForHandler(
|
|
||||||
handler map[string]any,
|
|
||||||
scriptName string,
|
|
||||||
) (*copilotManagedEntry, error) {
|
|
||||||
scriptPath, managed := copilotManagedScriptPath(handler)
|
|
||||||
if !managed || !filepath.IsAbs(scriptPath) ||
|
|
||||||
!sameCopilotFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
|
||||||
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &copilotManagedEntry{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeManagedCopilotHooks(
|
func removeManagedCopilotHooks(
|
||||||
hooks copilotHooks,
|
hooks copilotHooks,
|
||||||
agentID string,
|
agentID string,
|
||||||
@@ -277,7 +148,7 @@ func removeManagedCopilotHooks(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
remove := entry != nil &&
|
remove := entry != nil &&
|
||||||
(agentID == "" || sameCopilotAgentID(entry.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(entry.agentID, agentID))
|
||||||
if !remove {
|
if !remove {
|
||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
@@ -361,7 +232,7 @@ func renderCopilotDocument(
|
|||||||
document: document, changed: true, remove: true,
|
document: document, changed: true, remove: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
root := cloneCopilotObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
root["version"] = float64(1)
|
root["version"] = float64(1)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
@@ -387,13 +258,6 @@ func renderCopilotDocument(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copilotEntryKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotManagedEntries(
|
func copilotManagedEntries(
|
||||||
handlers []map[string]any,
|
handlers []map[string]any,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
@@ -405,7 +269,7 @@ func copilotManagedEntries(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if entry != nil {
|
if entry != nil {
|
||||||
key := copilotEntryKey(entry.agentID)
|
key := managedReferenceKey(entry.agentID)
|
||||||
result[key] = append(result[key], *entry)
|
result[key] = append(result[key], *entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,7 +302,7 @@ func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, erro
|
|||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, copilotRuntimeContract{
|
contracts = append(contracts, copilotRuntimeContract{
|
||||||
|
|||||||
@@ -1,103 +1,19 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type copilotRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type preparedCopilotInstallation struct {
|
type preparedCopilotInstallation struct {
|
||||||
changes []*fileChange
|
changes []*fileChange
|
||||||
preconditions []filePrecondition
|
preconditions []filePrecondition
|
||||||
paths *copilotRuntimePaths
|
paths *managedRuntimePaths
|
||||||
}
|
|
||||||
|
|
||||||
func validateCopilotInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != copilotAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"GitHub Copilot CLI installation requires agent name %s",
|
|
||||||
copilotAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotAgentPaths(config InstallConfig) (*copilotRuntimePaths, error) {
|
|
||||||
if err := validateCopilotInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &copilotRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, copilotAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameCopilotPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameCopilotPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightCopilotInstallation(
|
func preflightCopilotInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
sources *copilotSources,
|
sources *copilotSources,
|
||||||
) (*copilotRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
||||||
return nil, "", fmt.Errorf(
|
return nil, "", fmt.Errorf(
|
||||||
"GitHub Copilot CLI installation requires a user hook path",
|
"GitHub Copilot CLI installation requires a user hook path",
|
||||||
@@ -106,22 +22,22 @@ func preflightCopilotInstallation(
|
|||||||
if err := requireCopilotHooksEnabled(sources); err != nil {
|
if err := requireCopilotHooksEnabled(sources); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
paths, err := copilotAgentPaths(config)
|
if err := validateManagedInstallConfig(config, copilotAgentKey, "GitHub Copilot CLI"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, copilotGuardScript, copilotAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateCopilotRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, copilotAgentKey, "Copilot",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("GitHub Copilot CLI")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isCopilotNodeExecutable(nodePath) {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"GitHub Copilot CLI hooks require an absolute Node.js executable path",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
hookSources := []copilotHooks{sources.user.hooks}
|
hookSources := []copilotHooks{sources.user.hooks}
|
||||||
if sources.legacy != nil {
|
if sources.legacy != nil {
|
||||||
hookSources = append(hookSources, sources.legacy.hooks)
|
hookSources = append(hookSources, sources.legacy.hooks)
|
||||||
@@ -132,28 +48,6 @@ func preflightCopilotInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCopilotRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": copilotAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCopilotRenderedSet(
|
func validateCopilotRenderedSet(
|
||||||
sources *copilotSources,
|
sources *copilotSources,
|
||||||
rendered []*copilotRenderedDocument,
|
rendered []*copilotRenderedDocument,
|
||||||
@@ -169,7 +63,7 @@ func validateCopilotRenderedSet(
|
|||||||
matches := 0
|
matches := 0
|
||||||
for _, item := range rendered {
|
for _, item := range rendered {
|
||||||
if item != nil && item.document != nil &&
|
if item != nil && item.document != nil &&
|
||||||
sameCopilotPath(item.document.filePath, document.filePath) {
|
sameManagedPath(item.document.filePath, document.filePath) {
|
||||||
matches++
|
matches++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,34 +112,13 @@ func prepareCopilotInstallation(
|
|||||||
if err := validateCopilotRenderedSet(sources, rendered); err != nil {
|
if err := validateCopilotRenderedSet(sources, rendered); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildCopilotRuntimeConfig(config)
|
guardScript, auditScript := copilotExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, copilotAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(copilotAgentKey, config.AgentID, "", false, "")), 0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(copilotAgentKey, config.AgentID, "", false, true)), 0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
|
||||||
for _, item := range items {
|
|
||||||
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if changeErr != nil {
|
|
||||||
return nil, changeErr
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, document := range rendered {
|
for _, document := range rendered {
|
||||||
change, changeErr := prepareRenderedCopilotChange(document)
|
change, changeErr := prepareRenderedCopilotChange(document)
|
||||||
if changeErr != nil {
|
if changeErr != nil {
|
||||||
@@ -275,3 +148,8 @@ func commitCopilotInstallation(
|
|||||||
prepared.preconditions,
|
prepared.preconditions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copilotExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
|
return []byte(generateGuardScript(copilotAgentKey, agentID, "", false, "")),
|
||||||
|
[]byte(buildHookScriptWithOutput(copilotAgentKey, agentID, "", false, true))
|
||||||
|
}
|
||||||
@@ -240,14 +240,7 @@ func writeCopilotChanges(
|
|||||||
agentDirectory string,
|
agentDirectory string,
|
||||||
preconditions []filePrecondition,
|
preconditions []filePrecondition,
|
||||||
) error {
|
) error {
|
||||||
hasChanges := false
|
if !hasFileChanges(changes) {
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return writeChanges(changes, label, rename, preconditions...)
|
return writeChanges(changes, label, rename, preconditions...)
|
||||||
}
|
}
|
||||||
if agentDirectory != "" {
|
if agentDirectory != "" {
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ func validateCopilotHooks(value any, label string) (copilotHooks, error) {
|
|||||||
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
|
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
handlers = append(handlers, cloneCopilotObject(handler))
|
handlers = append(handlers, cloneJSONObject(handler))
|
||||||
}
|
}
|
||||||
hooks[event] = handlers
|
hooks[event] = handlers
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,261 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func copilotRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"bytes"
|
return managedRuntimeFilesExist(contracts, copilotAgentKey, "Copilot", copilotExpectedScripts)
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireCopilotRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCopilotRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameCopilotAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != copilotAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Copilot hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readCopilotRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != copilotAgentKey ||
|
|
||||||
!sameCopilotAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Copilot agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) &&
|
|
||||||
sameCopilotPath(
|
|
||||||
contract.guardPath,
|
|
||||||
filepath.Join(agentDirectory, copilotGuardScript),
|
|
||||||
) &&
|
|
||||||
sameCopilotPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, copilotAuditScript),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) {
|
|
||||||
validPaths, err := validCopilotContractPaths(contract)
|
|
||||||
if err != nil || !validPaths {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readCopilotRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
expectedGuard := []byte(generateGuardScript(
|
|
||||||
copilotAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
))
|
|
||||||
expectedAudit := []byte(buildHookScriptWithOutput(
|
|
||||||
copilotAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
))
|
|
||||||
return bytes.Equal(guard.contents, expectedGuard) &&
|
|
||||||
bytes.Equal(audit.contents, expectedAudit), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := copilotRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -10,55 +10,27 @@ type CursorPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Cursor commits its provider-specific guard
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// in the same transaction as the rest of the installation.
|
|
||||||
func (p *CursorPlugin) ManagesGuardRuntime() bool {
|
func (p *CursorPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func cursorAgentPaths(config InstallConfig) (
|
func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) {
|
||||||
runtimeRoot, agentDirectory, guardPath, auditPath string,
|
return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript)
|
||||||
err error,
|
|
||||||
) {
|
|
||||||
if config.AgentID == "" {
|
|
||||||
return "", "", "", "", fmt.Errorf("agent ID is required")
|
|
||||||
}
|
|
||||||
runtimeRoot, err = AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", "", err
|
|
||||||
}
|
|
||||||
agentDirectory, err = ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", "", err
|
|
||||||
}
|
|
||||||
guardPath = filepath.Join(agentDirectory, cursorGuardScript)
|
|
||||||
if !sameCursorPath(config.GuardScriptPath, guardPath) {
|
|
||||||
return "", "", "", "", fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
auditPath = filepath.Join(agentDirectory, cursorAuditScript)
|
|
||||||
if config.HookScript != "" && !sameCursorPath(config.HookScript, auditPath) {
|
|
||||||
return "", "", "", "", fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return runtimeRoot, agentDirectory, guardPath, auditPath, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreflightInstall validates every existing source before the CLI creates a
|
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||||
// runtime directory or guard file.
|
|
||||||
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
|
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
if _, err := readCursorDocument(); err != nil {
|
if _, err := readCursorDocument(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, agentDirectory, _, _, err := cursorAgentPaths(config)
|
paths, err := cursorAgentPaths(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
|
||||||
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = resolveNodeRuntime()
|
_, err = resolveNodeRuntime()
|
||||||
@@ -70,44 +42,42 @@ func (p *CursorPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config)
|
paths, err := cursorAgentPaths(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
|
||||||
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "")
|
hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "")
|
||||||
hooks["preToolUse"] = append(
|
hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath))
|
||||||
hooks["preToolUse"],
|
hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath))
|
||||||
buildCursorHandler(nodePath, guardPath),
|
|
||||||
)
|
|
||||||
hooks["postToolUse"] = append(
|
|
||||||
hooks["postToolUse"],
|
|
||||||
buildCursorHandler(nodePath, auditPath),
|
|
||||||
)
|
|
||||||
hooks["postToolUseFailure"] = append(
|
hooks["postToolUseFailure"] = append(
|
||||||
hooks["postToolUseFailure"],
|
hooks["postToolUseFailure"],
|
||||||
buildCursorHandler(nodePath, auditPath),
|
buildCursorHandler(nodePath, paths.auditPath),
|
||||||
)
|
)
|
||||||
rendered, err := renderCursorDocument(document, hooks, runtimeRoot)
|
rendered, err := renderCursorDocument(document, hooks, paths.runtimeRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("render Cursor user hooks: %w", err)
|
return fmt.Errorf("render Cursor user hooks: %w", err)
|
||||||
}
|
}
|
||||||
changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered)
|
changes, err := prepareCursorInstallationChanges(config, paths, rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeCursorChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Cursor hooks",
|
"Install Cursor hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
runtimeRoot,
|
paths.runtimeRoot,
|
||||||
agentDirectory,
|
paths.agentDirectory,
|
||||||
|
filepath.Dir(document.filePath),
|
||||||
|
"Cursor hooks directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -133,12 +103,14 @@ func (p *CursorPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeCursorChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Cursor hooks",
|
"Uninstall Cursor hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
|
filepath.Dir(document.filePath),
|
||||||
|
"Cursor hooks directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const (
|
|||||||
cursorGuardScript = "guard.js"
|
cursorGuardScript = "guard.js"
|
||||||
cursorAuditScript = "hook.js"
|
cursorAuditScript = "hook.js"
|
||||||
cursorHookTimeout = float64(10)
|
cursorHookTimeout = float64(10)
|
||||||
cursorPOSIXApostrophe = `'"'"'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type cursorHooks map[string][]map[string]any
|
type cursorHooks map[string][]map[string]any
|
||||||
@@ -36,11 +35,7 @@ type cursorRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type cursorRuntimeContract struct {
|
type cursorRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneCursorHooks(source cursorHooks) cursorHooks {
|
func cloneCursorHooks(source cursorHooks) cursorHooks {
|
||||||
clone := make(cursorHooks, len(source))
|
clone := make(cursorHooks, len(source))
|
||||||
@@ -50,104 +45,16 @@ func cloneCursorHooks(source cursorHooks) cursorHooks {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameCursorPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameCursorAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteCursorPowerShell(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCursorHandler(nodePath, scriptPath string) map[string]any {
|
func buildCursorHandler(nodePath, scriptPath string) map[string]any {
|
||||||
command := quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
command := posixSource(nodePath, scriptPath)
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
command = "& " + quoteCursorPowerShell(nodePath) + " " +
|
command = powerShellSource(nodePath, scriptPath)
|
||||||
quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"command": command, "timeout": cursorHookTimeout, "failClosed": true,
|
"command": command, "timeout": cursorHookTimeout, "failClosed": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCursorPOSIXArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); {
|
|
||||||
if strings.HasPrefix(command[index:], cursorPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(cursorPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCursorPowerShellArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); index++ {
|
|
||||||
if command[index] != '\'' {
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if index+1 < len(command) && command[index+1] == '\'' {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCursorPOSIXCommand(command string) (string, string, bool) {
|
|
||||||
executable, next, ok := readCursorPOSIXArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readCursorPOSIXArgument(command, next+1)
|
|
||||||
return executable, script, ok && end == len(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCursorPowerShellCommand(command string) (string, string, bool) {
|
|
||||||
if !strings.HasPrefix(command, "& ") {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
executable, next, ok := readCursorPowerShellArgument(command, 2)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readCursorPowerShellArgument(command, next+1)
|
|
||||||
return executable, script, ok && command[end:] == "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCursorLegacyCommand(command string) (string, bool) {
|
func parseCursorLegacyCommand(command string) (string, bool) {
|
||||||
separator := strings.IndexByte(command, ' ')
|
separator := strings.IndexByte(command, ' ')
|
||||||
if separator < 0 {
|
if separator < 0 {
|
||||||
@@ -164,11 +71,6 @@ func parseCursorLegacyCommand(command string) (string, bool) {
|
|||||||
return script, script != "" && !strings.ContainsAny(script, "\r\n")
|
return script, script != "" && !strings.ContainsAny(script, "\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isCursorNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cursorManagedScriptPath(handler map[string]any) (string, bool) {
|
func cursorManagedScriptPath(handler map[string]any) (string, bool) {
|
||||||
if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true {
|
if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true {
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
@@ -178,12 +80,12 @@ func cursorManagedScriptPath(handler map[string]any) (string, bool) {
|
|||||||
var nodePath, scriptPath string
|
var nodePath, scriptPath string
|
||||||
var parsed bool
|
var parsed bool
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command)
|
nodePath, scriptPath, parsed = parsePowerShellSource(command)
|
||||||
} else {
|
} else {
|
||||||
nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command)
|
nodePath, scriptPath, parsed = parsePOSIXCommand(command)
|
||||||
}
|
}
|
||||||
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
|
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
|
||||||
isCursorNodeExecutable(nodePath) {
|
isNodeExecutable(nodePath) {
|
||||||
return scriptPath, true
|
return scriptPath, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +109,7 @@ func cursorManagedAgentID(
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentID := filepath.Base(agentDirectory)
|
agentID := filepath.Base(agentDirectory)
|
||||||
@@ -297,7 +199,7 @@ func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cu
|
|||||||
kept := make([]map[string]any, 0, len(result[contract.event]))
|
kept := make([]map[string]any, 0, len(result[contract.event]))
|
||||||
for _, handler := range result[contract.event] {
|
for _, handler := range result[contract.event] {
|
||||||
managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot)
|
managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot)
|
||||||
if managed && (agentID == "" || sameCursorAgentID(managedID, agentID)) {
|
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
@@ -384,11 +286,7 @@ func managedCursorIDs(
|
|||||||
if !managed {
|
if !managed {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := agentID
|
result[managedReferenceKey(agentID)] = agentID
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
result[key] = agentID
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func cursorConfigPath() (string, error) {
|
func cursorConfigPath() (string, error) {
|
||||||
@@ -27,21 +23,14 @@ func readCursorDocument() (*cursorDocument, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
info, err := os.Lstat(filePath)
|
snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes)
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
return createCursorDocument(filePath), nil
|
return createCursorDocument(filePath), nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
return parseCursorDocument(filePath, snapshot.contents, runtimeRoot)
|
||||||
return nil, fmt.Errorf("inspect Cursor user hooks at %s: %w", filePath, err)
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
||||||
return nil, fmt.Errorf("Cursor user hooks path is not a physical file: %s", filePath)
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is the fixed Cursor user configuration path.
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read Cursor user hooks at %s: %w", filePath, err)
|
|
||||||
}
|
|
||||||
return parseCursorDocument(filePath, raw, runtimeRoot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
|
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
|
||||||
@@ -59,168 +48,26 @@ func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cursorPhysicalFileExists(path, label string) (bool, error) {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
||||||
return false, fmt.Errorf("%s path is not a physical file: %s", label, path)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cursorPhysicalDirectoryExists(path string) (bool, error) {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("inspect Elydora runtime directory at %s: %w", path, err)
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
||||||
return false, fmt.Errorf("Elydora runtime path is not a physical directory: %s", path)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCursorRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
exists, err := cursorPhysicalFileExists(path, "Elydora runtime config")
|
|
||||||
if err != nil || !exists {
|
|
||||||
return nil, exists, err
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(path) // #nosec G304 -- path is confined to one validated Elydora agent directory.
|
|
||||||
if err != nil {
|
|
||||||
return nil, true, fmt.Errorf("read Elydora runtime config at %s: %w", path, err)
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
if !json.Valid(raw) {
|
|
||||||
var value any
|
|
||||||
if err := json.Unmarshal(raw, &value); err != nil {
|
|
||||||
return nil, true, fmt.Errorf("parse %s: %w", label, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config, err := decodeJSONCObject(raw, label, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, true, err
|
|
||||||
}
|
|
||||||
return config, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCursorRuntimeIdentity(path, agentID string) (bool, error) {
|
|
||||||
config, exists, err := readCursorRuntimeConfig(path)
|
|
||||||
if err != nil || !exists {
|
|
||||||
return exists, err
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != cursorAgentKey ||
|
|
||||||
!sameCursorAgentID(configuredID, agentID) {
|
|
||||||
return true, fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Cursor agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightCursorRuntime(agentDirectory, agentID string) error {
|
|
||||||
exists, err := cursorPhysicalDirectoryExists(agentDirectory)
|
|
||||||
if err != nil || !exists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
identityExists, err := validateCursorRuntimeIdentity(
|
|
||||||
filepath.Join(agentDirectory, "config.json"), agentID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
runtimeExists := false
|
|
||||||
for _, item := range []struct{ path, label string }{
|
|
||||||
{filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime"},
|
|
||||||
{filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime"},
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key"},
|
|
||||||
} {
|
|
||||||
exists, err := cursorPhysicalFileExists(item.path, item.label)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
runtimeExists = runtimeExists || exists
|
|
||||||
}
|
|
||||||
if runtimeExists && !identityExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareCursorInstallationChanges(
|
func prepareCursorInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
agentDirectory string,
|
paths *managedRuntimePaths,
|
||||||
rendered *cursorRenderedDocument,
|
rendered *cursorRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
baseURL := config.BaseURL
|
if config.BaseURL == "" {
|
||||||
if baseURL == "" {
|
config.BaseURL = "https://api.elydora.com"
|
||||||
baseURL = "https://api.elydora.com"
|
|
||||||
}
|
}
|
||||||
runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{
|
changes, err := managedRuntimeFileChanges(
|
||||||
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
|
config,
|
||||||
BaseURL: baseURL, Token: config.Token, AgentName: cursorAgentKey,
|
paths,
|
||||||
}, "", " ")
|
cursorAgentKey,
|
||||||
if err != nil {
|
generateGuardScript(
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor",
|
||||||
}
|
),
|
||||||
runtimeConfig = append(runtimeConfig, '\n')
|
buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true),
|
||||||
if len(runtimeConfig) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(
|
|
||||||
cursorAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
`{"permission":"allow"}`+"\n",
|
|
||||||
true,
|
|
||||||
"cursor",
|
|
||||||
)), 0700,
|
|
||||||
},
|
|
||||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
cursorAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"{}\n",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
)), 0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
if err := validateRuntimeFileTarget(item.path, item.label); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedCursorChange(rendered)
|
documentChange, err := prepareRenderedCursorChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -228,98 +75,6 @@ func prepareCursorInstallationChanges(
|
|||||||
return append(changes, documentChange), nil
|
return append(changes, documentChange), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureCursorDirectory(path, label string) error {
|
func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
if err := os.MkdirAll(path, 0700); err != nil {
|
return managedRuntimeFilesPresent(contracts, cursorAgentKey)
|
||||||
return fmt.Errorf("create %s directory at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("inspect %s directory at %s: %w", label, path, err)
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
||||||
return fmt.Errorf("%s directory is not a physical directory: %s", label, path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeCursorChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot, agentDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
directories := map[string]string{}
|
|
||||||
for _, change := range changes {
|
|
||||||
if change == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
directory := filepath.Dir(change.filePath)
|
|
||||||
key := filepath.Clean(directory)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
directories[key] = directory
|
|
||||||
}
|
|
||||||
for _, directory := range directories {
|
|
||||||
if err := ensureCursorDirectory(directory, label); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cursorRuntimeFilesExist(contracts []cursorRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
directoryExists, err := cursorPhysicalDirectoryExists(agentDirectory)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !directoryExists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
config, exists, err := readCursorRuntimeConfig(filepath.Join(agentDirectory, "config.json"))
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configuredID, idOK := config["agent_id"].(string)
|
|
||||||
if !exists || !idOK || config["agent_name"] != cursorAgentKey ||
|
|
||||||
!sameCursorAgentID(configuredID, contract.agentID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
complete := true
|
|
||||||
for _, item := range []struct{ path, label string }{
|
|
||||||
{contract.guardPath, "Elydora guard runtime"},
|
|
||||||
{contract.auditPath, "Elydora audit runtime"},
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key"},
|
|
||||||
} {
|
|
||||||
exists, err := cursorPhysicalFileExists(item.path, item.label)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
complete = complete && exists
|
|
||||||
}
|
|
||||||
if complete {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) {
|
|||||||
fixture.guardPath,
|
fixture.guardPath,
|
||||||
fixture.hookPath,
|
fixture.hookPath,
|
||||||
} {
|
} {
|
||||||
if exists, err := cursorPhysicalFileExists(path, "Cursor runtime"); err != nil || !exists {
|
if exists, err := managedPhysicalFileExists(path, "Cursor runtime", maxManagedSourceBytes); err != nil || !exists {
|
||||||
t.Fatalf("runtime file %s exists = %v, %v", path, exists, err)
|
t.Fatalf("runtime file %s exists = %v, %v", path, exists, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
failed := false
|
failed := false
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if !failed && sameCursorPath(destination, fixture.configPath) {
|
if !failed && sameManagedPath(destination, fixture.configPath) {
|
||||||
failed = true
|
failed = true
|
||||||
return errors.New("injected Cursor config commit failure")
|
return errors.New("injected Cursor config commit failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ type DroidPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Droid commits its guard, audit runtime,
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// credentials, and hook documents in one transaction.
|
|
||||||
func (p *DroidPlugin) ManagesGuardRuntime() bool {
|
func (p *DroidPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreflightInstall validates every hook source, policy layer, credential,
|
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||||
// matcher, and runtime identity before any write.
|
|
||||||
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
|
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
sources, err := readDroidSources()
|
sources, err := readDroidSources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,7 +105,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := droidRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -133,7 +131,7 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
|
|||||||
if droidHookBlocked(sources) != nil {
|
if droidHookBlocked(sources) != nil {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
runtimeRoot, err := droidRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ func droidAdditionsFor(
|
|||||||
document, target *droidDocument,
|
document, target *droidDocument,
|
||||||
groups map[string]map[string]any,
|
groups map[string]map[string]any,
|
||||||
) map[string]map[string]any {
|
) map[string]map[string]any {
|
||||||
if sameDroidPath(document.filePath, target.filePath) {
|
if sameManagedPath(document.filePath, target.filePath) {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
return map[string]map[string]any{}
|
return map[string]map[string]any{}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const (
|
|||||||
droidAuditScript = "hook.js"
|
droidAuditScript = "hook.js"
|
||||||
droidHookTimeout = float64(10)
|
droidHookTimeout = float64(10)
|
||||||
droidOwnedFileMarker = "// Managed by Elydora"
|
droidOwnedFileMarker = "// Managed by Elydora"
|
||||||
droidPOSIXApostrophe = `'"'"'`
|
|
||||||
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
|
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
|
||||||
@@ -35,11 +33,7 @@ type droidManagedRemoval struct {
|
|||||||
removeGroup bool
|
removeGroup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type droidRuntimeContract struct {
|
type droidRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type droidRegexEntry struct {
|
type droidRegexEntry struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -178,145 +172,21 @@ for (const entry of entries) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDroidCommand(nodePath, scriptPath string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "& " + quoteDroidPowerShellArgument(nodePath) + " " +
|
|
||||||
quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteDroidPowerShellArgument(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
|
func buildDroidGroup(nodePath, scriptPath string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"matcher": "*",
|
"matcher": "*",
|
||||||
"hooks": []any{map[string]any{
|
"hooks": []any{map[string]any{
|
||||||
"type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
|
"type": "command", "command": buildShellCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
|
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
|
||||||
if runtime.GOOS != "windows" {
|
executable, script, ok := parseShellCommand(command)
|
||||||
return parseDroidPOSIXCommand(command)
|
if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] {
|
||||||
}
|
|
||||||
executable, script, ok := parseDroidPowerShellCommand(command)
|
|
||||||
if ok || len(includeLegacy) == 0 || !includeLegacy[0] {
|
|
||||||
return executable, script, ok
|
return executable, script, ok
|
||||||
}
|
}
|
||||||
return parseDroidLegacyWindowsCommand(command)
|
return parseQuotedWindowsCommand(command)
|
||||||
}
|
|
||||||
|
|
||||||
func parseDroidPOSIXCommand(command string) (string, string, bool) {
|
|
||||||
executable, next, ok := readDroidPOSIXArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readDroidPOSIXArgument(command, next+1)
|
|
||||||
return executable, script, ok && end == len(command) && executable != "" && script != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDroidPowerShellCommand(command string) (string, string, bool) {
|
|
||||||
if !strings.HasPrefix(command, "& ") {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
executable, next, ok := readDroidPowerShellArgument(command, 2)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readDroidPowerShellArgument(command, next+1)
|
|
||||||
return executable, script, ok && command[end:] == droidWindowsExitSuffix && executable != "" && script != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDroidLegacyWindowsCommand(command string) (string, string, bool) {
|
|
||||||
executable, next, ok := readDroidLegacyWindowsArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readDroidLegacyWindowsArgument(command, next+1)
|
|
||||||
return executable, script, ok && end == len(command) && executable != "" && script != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDroidPowerShellArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); index++ {
|
|
||||||
if command[index] != '\'' {
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if index+1 < len(command) && command[index+1] == '\'' {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '"' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
end := strings.IndexByte(command[start+1:], '"')
|
|
||||||
if end < 0 {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
end += start + 1
|
|
||||||
value := command[start+1 : end]
|
|
||||||
return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readDroidPOSIXArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); {
|
|
||||||
if strings.HasPrefix(command[index:], droidPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(droidPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameDroidPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameDroidAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func droidRuntimeRoot() (string, error) {
|
|
||||||
return AgentRuntimeRoot()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func managedDroidAgentID(
|
func managedDroidAgentID(
|
||||||
@@ -333,29 +203,17 @@ func managedDroidAgentID(
|
|||||||
}
|
}
|
||||||
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
|
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
|
||||||
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
||||||
!isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) {
|
!isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
if !sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentID := filepath.Base(agentDirectory)
|
agentID := filepath.Base(agentDirectory)
|
||||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDroidNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameDroidFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval {
|
func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval {
|
||||||
removals := make([]droidManagedRemoval, 0)
|
removals := make([]droidManagedRemoval, 0)
|
||||||
for _, contract := range []struct{ event, script string }{
|
for _, contract := range []struct{ event, script string }{
|
||||||
@@ -370,7 +228,7 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
|
|||||||
managedID, managed := managedDroidAgentID(
|
managedID, managed := managedDroidAgentID(
|
||||||
handlerValue.(map[string]any), contract.script, runtimeRoot, true,
|
handlerValue.(map[string]any), contract.script, runtimeRoot, true,
|
||||||
)
|
)
|
||||||
if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) {
|
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
|
||||||
indexes = append(indexes, handlerIndex)
|
indexes = append(indexes, handlerIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +258,7 @@ func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []dro
|
|||||||
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
|
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
|
||||||
for _, guardID := range guardIDs {
|
for _, guardID := range guardIDs {
|
||||||
for auditID := range audits {
|
for auditID := range audits {
|
||||||
if !sameDroidAgentID(guardID, auditID) {
|
if !sameManagedAgentID(guardID, auditID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
root := filepath.Join(runtimeRoot, guardID)
|
root := filepath.Join(runtimeRoot, guardID)
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type droidRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type preparedDroidInstallation struct {
|
type preparedDroidInstallation struct {
|
||||||
changes []*fileChange
|
changes []*fileChange
|
||||||
preconditions []filePrecondition
|
preconditions []filePrecondition
|
||||||
paths *droidRuntimePaths
|
paths *managedRuntimePaths
|
||||||
}
|
}
|
||||||
|
|
||||||
type preparedDroidUninstall struct {
|
type preparedDroidUninstall struct {
|
||||||
@@ -39,80 +28,10 @@ func droidSourceLabel(document *droidDocument) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDroidInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != droidAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Factory Droid installation requires agent name %s",
|
|
||||||
droidAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) {
|
|
||||||
if err := validateDroidInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &droidRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot,
|
|
||||||
agentDirectory: agentDirectory,
|
|
||||||
guardPath: filepath.Join(agentDirectory, droidGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, droidAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameDroidPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameDroidPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightDroidInstallation(
|
func preflightDroidInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
sources *droidSources,
|
sources *droidSources,
|
||||||
) (*droidRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if sources == nil || sources.root == nil || sources.policy == nil {
|
if sources == nil || sources.root == nil || sources.policy == nil {
|
||||||
return nil, "", fmt.Errorf("Factory Droid installation sources are required")
|
return nil, "", fmt.Errorf("Factory Droid installation sources are required")
|
||||||
}
|
}
|
||||||
@@ -126,50 +45,25 @@ func preflightDroidInstallation(
|
|||||||
if err := validateDroidRegexes(hooks...); err != nil {
|
if err := validateDroidRegexes(hooks...); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
paths, err := droidAgentPaths(config)
|
if err := validateManagedInstallConfig(config, droidAgentKey, "Factory Droid"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, droidGuardScript, droidAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, droidAgentKey, "Factory Droid",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Factory Droid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"Factory Droid hooks require an absolute Node.js executable path",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDroidRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID,
|
|
||||||
"agent_id": config.AgentID,
|
|
||||||
"kid": config.KID,
|
|
||||||
"base_url": config.BaseURL,
|
|
||||||
"agent_name": droidAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareRenderedDroidChange(
|
func prepareRenderedDroidChange(
|
||||||
rendered *droidRenderedDocument,
|
rendered *droidRenderedDocument,
|
||||||
) (*fileChange, error) {
|
) (*fileChange, error) {
|
||||||
@@ -198,7 +92,7 @@ func validateDroidRenderedSet(
|
|||||||
matches := 0
|
matches := 0
|
||||||
for _, item := range rendered {
|
for _, item := range rendered {
|
||||||
if item != nil && item.document != nil &&
|
if item != nil && item.document != nil &&
|
||||||
sameDroidPath(item.document.filePath, document.filePath) {
|
sameManagedPath(item.document.filePath, document.filePath) {
|
||||||
matches++
|
matches++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +111,7 @@ func droidSourcePreconditions(
|
|||||||
for _, document := range droidSourceDocuments(sources) {
|
for _, document := range droidSourceDocuments(sources) {
|
||||||
changed := false
|
changed := false
|
||||||
for _, path := range changedPaths {
|
for _, path := range changedPaths {
|
||||||
if sameDroidPath(document.filePath, path) {
|
if sameManagedPath(document.filePath, path) {
|
||||||
changed = true
|
changed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -246,38 +140,13 @@ func prepareDroidInstallation(
|
|||||||
if err := validateDroidRenderedSet(sources, rendered); err != nil {
|
if err := validateDroidRenderedSet(sources, rendered); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildDroidRuntimeConfig(config)
|
guardScript, auditScript := droidExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, droidAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(droidAgentKey, config.AgentID, "", false, true)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
|
||||||
for _, item := range items {
|
|
||||||
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if changeErr != nil {
|
|
||||||
return nil, changeErr
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
changedPaths := make([]string, 0, len(rendered))
|
changedPaths := make([]string, 0, len(rendered))
|
||||||
for _, document := range rendered {
|
for _, document := range rendered {
|
||||||
change, changeErr := prepareRenderedDroidChange(document)
|
change, changeErr := prepareRenderedDroidChange(document)
|
||||||
@@ -305,14 +174,7 @@ func writeDroidChanges(
|
|||||||
runtimeRoot, agentDirectory string,
|
runtimeRoot, agentDirectory string,
|
||||||
preconditions []filePrecondition,
|
preconditions []filePrecondition,
|
||||||
) error {
|
) error {
|
||||||
hasChanges := false
|
if !hasFileChanges(changes) {
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return writeChanges(changes, label, rename, preconditions...)
|
return writeChanges(changes, label, rename, preconditions...)
|
||||||
}
|
}
|
||||||
if err := assertFilePreconditions(preconditions, label); err != nil {
|
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||||
@@ -365,7 +227,7 @@ func prepareDroidUninstall(
|
|||||||
for _, item := range rendered {
|
for _, item := range rendered {
|
||||||
changed := false
|
changed := false
|
||||||
for _, path := range changedPaths {
|
for _, path := range changedPaths {
|
||||||
if sameDroidPath(item.document.filePath, path) {
|
if sameManagedPath(item.document.filePath, path) {
|
||||||
changed = true
|
changed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -398,3 +260,8 @@ func commitDroidUninstall(
|
|||||||
prepared.preconditions,
|
prepared.preconditions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func droidExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
|
return []byte(generateGuardScript(droidAgentKey, agentID, "", false, "")),
|
||||||
|
[]byte(buildHookScriptWithOutput(droidAgentKey, agentID, "", false, true))
|
||||||
|
}
|
||||||
@@ -1,251 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func droidRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"bytes"
|
return managedRuntimeFilesExist(contracts, droidAgentKey, "Factory Droid", droidExpectedScripts)
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readDroidRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
config, err := decodeStrictJSONObject(
|
|
||||||
snapshot.contents,
|
|
||||||
fmt.Sprintf("Elydora runtime config at %s", path),
|
|
||||||
)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireDroidRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field, configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDroidRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID, configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id",
|
|
||||||
"agent_id",
|
|
||||||
"kid",
|
|
||||||
"base_url",
|
|
||||||
"token",
|
|
||||||
"agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireDroidRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireDroidRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireDroidRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameDroidAgentID(agentID, expectedAgentID) || config["agent_name"] != droidAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Factory Droid hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireDroidRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireDroidRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateDroidRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readDroidRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, droidGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, droidAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != droidAgentKey ||
|
|
||||||
!sameDroidAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Factory Droid agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validDroidContractPaths(contract droidRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
return sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) &&
|
|
||||||
sameDroidPath(
|
|
||||||
contract.guardPath,
|
|
||||||
filepath.Join(agentDirectory, droidGuardScript),
|
|
||||||
) &&
|
|
||||||
sameDroidPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, droidAuditScript),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func droidRuntimeContractExists(contract droidRuntimeContract) (bool, error) {
|
|
||||||
validPaths, err := validDroidContractPaths(contract)
|
|
||||||
if err != nil || !validPaths {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readDroidRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateDroidRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
expectedGuard := []byte(generateGuardScript(droidAgentKey, contract.agentID, "", false, ""))
|
|
||||||
expectedAudit := []byte(buildHookScriptWithOutput(
|
|
||||||
droidAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
))
|
|
||||||
return bytes.Equal(guard.contents, expectedGuard) &&
|
|
||||||
bytes.Equal(audit.contents, expectedAudit), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := droidRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
|
|||||||
requireDroidNativeGroup(t, auditGroup)
|
requireDroidNativeGroup(t, auditGroup)
|
||||||
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
|
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
|
||||||
if runtime.GOOS == "windows" &&
|
if runtime.GOOS == "windows" &&
|
||||||
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) {
|
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, powerShellExitSuffix)) {
|
||||||
t.Fatalf("Windows Factory command = %q", guardCommand)
|
t.Fatalf("Windows Factory command = %q", guardCommand)
|
||||||
}
|
}
|
||||||
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
|
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
|
|||||||
handler := requireDroidObject(t, handlerValue)
|
handler := requireDroidObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
command, _ := handler["command"].(string)
|
||||||
_, configuredPath, managed := parseDroidCommand(command, true)
|
_, configuredPath, managed := parseDroidCommand(command, true)
|
||||||
if managed && sameDroidPath(configuredPath, scriptPath) {
|
if managed && sameManagedPath(configuredPath, scriptPath) {
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[s
|
|||||||
handler := requireDroidObject(t, handlerValue)
|
handler := requireDroidObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
command, _ := handler["command"].(string)
|
||||||
_, configuredPath, managed := parseDroidCommand(command, true)
|
_, configuredPath, managed := parseDroidCommand(command, true)
|
||||||
if managed && sameDroidPath(configuredPath, scriptPath) {
|
if managed && sameManagedPath(configuredPath, scriptPath) {
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) {
|
|||||||
prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
|
prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
|
||||||
failed := false
|
failed := false
|
||||||
rename := func(source, destination string) error {
|
rename := func(source, destination string) error {
|
||||||
if !failed && sameDroidPath(destination, fixture.configPath) {
|
if !failed && sameManagedPath(destination, fixture.configPath) {
|
||||||
failed = true
|
failed = true
|
||||||
return errors.New("injected Droid hook commit failure")
|
return errors.New("injected Droid hook commit failure")
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T)
|
|||||||
t.Fatalf("concurrent source error = %v", err)
|
t.Fatalf("concurrent source error = %v", err)
|
||||||
}
|
}
|
||||||
for path, source := range before {
|
for path, source := range before {
|
||||||
if sameDroidPath(path, fixture.configPath) {
|
if sameManagedPath(path, fixture.configPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if readDroidTestFile(t, path) != source {
|
if readDroidTestFile(t, path) != source {
|
||||||
@@ -205,7 +205,7 @@ func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) {
|
|||||||
t.Fatal("same-content replacement changed")
|
t.Fatal("same-content replacement changed")
|
||||||
}
|
}
|
||||||
for path, source := range before {
|
for path, source := range before {
|
||||||
if !sameDroidPath(path, fixture.configPath) && readDroidTestFile(t, path) != source {
|
if !sameManagedPath(path, fixture.configPath) && readDroidTestFile(t, path) != source {
|
||||||
t.Fatalf("runtime changed after inode replacement: %s", path)
|
t.Fatalf("runtime changed after inode replacement: %s", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read uninstall sources: %v", err)
|
t.Fatalf("read uninstall sources: %v", err)
|
||||||
}
|
}
|
||||||
runtimeRoot, err := droidRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve runtime root: %v", err)
|
t.Fatalf("resolve runtime root: %v", err)
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
failed := false
|
failed := false
|
||||||
rename := func(source, destination string) error {
|
rename := func(source, destination string) error {
|
||||||
if !failed && sameDroidPath(destination, fixture.settingsPath) {
|
if !failed && sameManagedPath(destination, fixture.settingsPath) {
|
||||||
failed = true
|
failed = true
|
||||||
return errors.New("injected Droid uninstall failure")
|
return errors.New("injected Droid uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ type GeminiPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Gemini commits both generated runtimes and
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// the user settings document through one transaction.
|
|
||||||
func (p *GeminiPlugin) ManagesGuardRuntime() bool {
|
func (p *GeminiPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -92,13 +91,14 @@ func (p *GeminiPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeGeminiChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Gemini CLI hooks",
|
"Install Gemini CLI hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Gemini CLI configuration directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -120,13 +120,14 @@ func (p *GeminiPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeGeminiChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Gemini CLI hooks",
|
"Uninstall Gemini CLI hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Gemini CLI configuration directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,83 +7,37 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type geminiRuntimeReference struct {
|
type geminiRuntimeReference = managedScriptReference
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGeminiPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGeminiAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGeminiFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGeminiNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
|
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
|
||||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths")
|
||||||
"Gemini CLI hook commands require absolute runtime and script paths",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
return posixSource(runtimePath, scriptPath), nil
|
||||||
}
|
}
|
||||||
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
|
return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags +
|
||||||
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil
|
||||||
return "& " + quoteCodexPowerShell(codexPowerShellPath()) +
|
|
||||||
" -NoLogo -NoProfile -NonInteractive -EncodedCommand " +
|
|
||||||
encodeCodexPowerShell(source), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGeminiWindowsCommand(command string) (string, string, bool) {
|
func parseGeminiWindowsCommand(command string) (string, string, bool) {
|
||||||
if !strings.HasPrefix(command, "& ") {
|
if !strings.HasPrefix(command, "& ") {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
powerShell, next, ok := readCodexPowerShellArgument(command, 2)
|
powerShell, next, ok := readPowerShellArgument(command, 2)
|
||||||
if !ok || !isAbsoluteWindowsPath(powerShell) ||
|
if !ok || !isPowerShellExecutable(powerShell) ||
|
||||||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") {
|
!strings.HasPrefix(command[next:], powerShellEncodedFlags) {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):])
|
||||||
if !strings.HasPrefix(command[next:], prefix) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
source, ok := decodeCodexPowerShell(command[next+len(prefix):])
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
return parseCodexPowerShellSource(source)
|
return parsePowerShellSource(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGeminiCommand(command string) (string, string, bool) {
|
func parseGeminiCommand(command string) (string, string, bool) {
|
||||||
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok {
|
if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
|
||||||
return runtimePath, scriptPath, true
|
return runtimePath, scriptPath, true
|
||||||
}
|
}
|
||||||
return parseGeminiWindowsCommand(command)
|
return parseGeminiWindowsCommand(command)
|
||||||
@@ -110,23 +64,10 @@ func geminiRuntimeReferenceForCommand(
|
|||||||
if !current && includeLegacy {
|
if !current && includeLegacy {
|
||||||
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
|
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
|
||||||
}
|
}
|
||||||
validRuntime := (current && filepath.IsAbs(runtimePath) &&
|
validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) ||
|
||||||
isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node")
|
(legacy && runtimePath == "node")
|
||||||
if !validRuntime || !filepath.IsAbs(scriptPath) ||
|
if !validRuntime {
|
||||||
!sameGeminiFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,11 +24,7 @@ type geminiManagedRemoval struct {
|
|||||||
removeGroup bool
|
removeGroup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type geminiRuntimeContract struct {
|
type geminiRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
|
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
|
||||||
command, err := buildGeminiCommand(runtimePath, scriptPath)
|
command, err := buildGeminiCommand(runtimePath, scriptPath)
|
||||||
@@ -151,7 +144,7 @@ func managedGeminiRemovals(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if reference != nil &&
|
if reference != nil &&
|
||||||
(agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) {
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
|
||||||
indexes = append(indexes, handlerIndex)
|
indexes = append(indexes, handlerIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,13 +161,6 @@ func managedGeminiRemovals(
|
|||||||
return removals, nil
|
return removals, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func geminiReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func geminiReferencesForEvent(
|
func geminiReferencesForEvent(
|
||||||
groups []any,
|
groups []any,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
@@ -196,7 +182,7 @@ func geminiReferencesForEvent(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if reference != nil {
|
if reference != nil {
|
||||||
key := geminiReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,11 +229,3 @@ func geminiRuntimeContracts(
|
|||||||
}
|
}
|
||||||
return contracts, nil
|
return contracts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func geminiRuntimeRoot() (string, error) {
|
|
||||||
root, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Clean(root), nil
|
|
||||||
}
|
|
||||||
@@ -1,190 +1,48 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type geminiRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGeminiInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != geminiAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Gemini CLI installation requires agent name %s",
|
|
||||||
geminiAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) {
|
|
||||||
if err := validateGeminiInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := geminiRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &geminiRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, geminiGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, geminiAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameGeminiPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameGeminiPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightGeminiInstallation(
|
func preflightGeminiInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
document *geminiDocument,
|
document *geminiDocument,
|
||||||
) (*geminiRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if document == nil || document.filePath == "" {
|
if document == nil || document.filePath == "" {
|
||||||
return nil, "", fmt.Errorf(
|
return nil, "", fmt.Errorf("Gemini CLI installation requires a user settings document")
|
||||||
"Gemini CLI installation requires a user settings document",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
paths, err := geminiAgentPaths(config)
|
if err := validateManagedInstallConfig(config, geminiAgentKey, "Gemini CLI"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, geminiGuardScript, geminiAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, geminiAgentKey, "Gemini CLI",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"Gemini CLI hooks require an absolute Node.js executable path",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGeminiRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": geminiAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareGeminiInstallationChanges(
|
func prepareGeminiInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *geminiRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered *geminiRenderedDocument,
|
rendered *geminiRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildGeminiRuntimeConfig(config)
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config,
|
||||||
|
paths,
|
||||||
|
geminiAgentKey,
|
||||||
|
generateGuardScript(geminiAgentKey, config.AgentID, "{}\n", false, ""),
|
||||||
|
buildHookScriptWithOutput(geminiAgentKey, config.AgentID, "{}\n", false, true),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(
|
|
||||||
geminiAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"{}\n",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
geminiAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"{}\n",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedGeminiChange(rendered)
|
documentChange, err := prepareRenderedGeminiChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -67,38 +67,3 @@ func prepareRenderedGeminiChange(
|
|||||||
rendered.remove,
|
rendered.remove,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeGeminiChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
settingsDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(
|
|
||||||
settingsDirectory,
|
|
||||||
"Gemini CLI configuration directory",
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
@@ -164,10 +164,10 @@ func TestGeminiValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
|
|||||||
}, "query parameters"},
|
}, "query parameters"},
|
||||||
{"guard path", func(f *geminiFixture) {
|
{"guard path", func(f *geminiFixture) {
|
||||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
{"audit path", func(f *geminiFixture) {
|
{"audit path", func(f *geminiFixture) {
|
||||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
} {
|
} {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
|
fixture := prepareGeminiFixture(t, geminiFixtureOptions{})
|
||||||
|
|||||||
@@ -1,231 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func geminiRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"fmt"
|
return managedRuntimeFilesExist(contracts, geminiAgentKey, "Gemini CLI", nil)
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readGeminiRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireGeminiRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGeminiRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireGeminiRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireGeminiRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireGeminiRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameGeminiAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != geminiAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity does not match Gemini CLI hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireGeminiRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireGeminiRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGeminiRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readGeminiRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, geminiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, geminiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != geminiAgentKey ||
|
|
||||||
!sameGeminiAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Gemini CLI agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func geminiRuntimeContractExists(contract geminiRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := geminiRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameGeminiPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, geminiAuditScript),
|
|
||||||
) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readGeminiRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateGeminiRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func geminiRuntimeFilesExist(contracts []geminiRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := geminiRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,8 @@ func TestGeminiCommandRoundTripAndRejectsInjection(t *testing.T) {
|
|||||||
t.Fatalf("build Gemini command: %v", err)
|
t.Fatalf("build Gemini command: %v", err)
|
||||||
}
|
}
|
||||||
executable, script, ok := parseGeminiCommand(command)
|
executable, script, ok := parseGeminiCommand(command)
|
||||||
if !ok || !sameGeminiPath(executable, nodePath) ||
|
if !ok || !sameManagedPath(executable, nodePath) ||
|
||||||
!sameGeminiPath(script, fixture.guardPath) {
|
!sameManagedPath(script, fixture.guardPath) {
|
||||||
t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok)
|
t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok)
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") ||
|
if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") ||
|
||||||
@@ -425,12 +425,12 @@ func TestGeminiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if !sameGeminiAgentID("AGENT-1", "agent-1") {
|
if !sameManagedAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatal("Windows Gemini agent IDs should compare case-insensitively")
|
t.Fatal("Windows Gemini agent IDs should compare case-insensitively")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sameGeminiAgentID("AGENT-1", "agent-1") {
|
if sameManagedAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatal("POSIX Gemini agent IDs should preserve case")
|
t.Fatal("POSIX Gemini agent IDs should preserve case")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ func runGeminiRawCommand(
|
|||||||
var process *exec.Cmd
|
var process *exec.Cmd
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
process = exec.Command(
|
process = exec.Command(
|
||||||
codexPowerShellPath(),
|
windowsPowerShellPath(),
|
||||||
"-NoLogo",
|
"-NoLogo",
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func prepareGeminiTestChanges(
|
|||||||
t *testing.T,
|
t *testing.T,
|
||||||
fixture *geminiFixture,
|
fixture *geminiFixture,
|
||||||
document *geminiDocument,
|
document *geminiDocument,
|
||||||
) ([]*fileChange, *geminiRuntimePaths) {
|
) ([]*fileChange, *managedRuntimePaths) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
paths, nodePath, err := preflightGeminiInstallation(fixture.config, document)
|
paths, nodePath, err := preflightGeminiInstallation(fixture.config, document)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,7 +110,7 @@ func TestGeminiInstallRollsBackAllFiveFilesAfterSettingsFailure(t *testing.T) {
|
|||||||
fixture.config.OrgID = "org-updated"
|
fixture.config.OrgID = "org-updated"
|
||||||
fixture.config.Token = "token-updated"
|
fixture.config.Token = "token-updated"
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameGeminiPath(destination, fixture.settingsPath) &&
|
if sameManagedPath(destination, fixture.settingsPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Gemini settings failure")
|
return errors.New("injected Gemini settings failure")
|
||||||
}
|
}
|
||||||
@@ -139,13 +139,14 @@ func TestPreparedGeminiInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
|||||||
if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil {
|
if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil {
|
||||||
t.Fatalf("write concurrent settings: %v", err)
|
t.Fatalf("write concurrent settings: %v", err)
|
||||||
}
|
}
|
||||||
err = writeGeminiChanges(
|
err = writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Gemini CLI hooks",
|
"Install Gemini CLI hooks",
|
||||||
nil,
|
nil,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Gemini CLI configuration directory",
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
@@ -204,7 +205,7 @@ func TestGeminiUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed Gemini settings: %v", err)
|
t.Fatalf("read installed Gemini settings: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameGeminiPath(destination, fixture.settingsPath) &&
|
if sameManagedPath(destination, fixture.settingsPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Gemini uninstall failure")
|
return errors.New("injected Gemini uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ func (p *GrokPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,21 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "path/filepath"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type grokRuntimeReference struct {
|
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGrokCommand(runtimePath, scriptPath string) (string, error) {
|
|
||||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Grok hook commands require absolute runtime and script paths",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return codexWindowsCommand(runtimePath, scriptPath), nil
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteGrokLegacyWindowsArgument(value string) string {
|
|
||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func readGrokLegacyWindowsArgument(
|
|
||||||
command string,
|
|
||||||
start int,
|
|
||||||
) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '"' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); index++ {
|
|
||||||
if command[index] == '"' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
if command[index] == '\r' || command[index] == '\n' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGrokLegacyWindowsCommand(command string) (string, string, bool) {
|
|
||||||
runtimePath, next, ok := readGrokLegacyWindowsArgument(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
scriptPath, end, ok := readGrokLegacyWindowsArgument(command, next+1)
|
|
||||||
if !ok || end != len(command) {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
expected := quoteGrokLegacyWindowsArgument(runtimePath) + " " +
|
|
||||||
quoteGrokLegacyWindowsArgument(scriptPath)
|
|
||||||
return runtimePath, scriptPath, command == expected
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGrokCommand(command string) (string, string, bool) {
|
func parseGrokCommand(command string) (string, string, bool) {
|
||||||
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok {
|
if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok {
|
||||||
return runtimePath, scriptPath, true
|
return runtimePath, scriptPath, true
|
||||||
}
|
}
|
||||||
if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok {
|
return parseQuotedWindowsCommand(command)
|
||||||
return runtimePath, scriptPath, true
|
|
||||||
}
|
|
||||||
return parseGrokLegacyWindowsCommand(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isGrokNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func grokRuntimeReferenceForCommand(
|
func grokRuntimeReferenceForCommand(
|
||||||
command string,
|
command string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*grokRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
runtimePath, scriptPath, ok := parseGrokCommand(command)
|
runtimePath, scriptPath, ok := parseGrokCommand(command)
|
||||||
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
|
||||||
!isGrokNodeExecutable(runtimePath) ||
|
|
||||||
!sameGrokFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &grokRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -48,20 +46,6 @@ type grokRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type grokRuntimeContract struct {
|
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneGrokObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneGrokHooks(source grokHooks) grokHooks {
|
func cloneGrokHooks(source grokHooks) grokHooks {
|
||||||
clone := make(grokHooks, len(source))
|
clone := make(grokHooks, len(source))
|
||||||
for event, groups := range source {
|
for event, groups := range source {
|
||||||
@@ -128,7 +112,7 @@ func validateGrokHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloneGrokObject(handler), nil
|
return cloneJSONObject(handler), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) {
|
func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) {
|
||||||
@@ -166,7 +150,7 @@ func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, erro
|
|||||||
}
|
}
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
}
|
}
|
||||||
return grokGroup{object: cloneGrokObject(object), handlers: handlers}, nil
|
return grokGroup{object: cloneJSONObject(object), handlers: handlers}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readGrokHooks(root map[string]any) (grokHooks, error) {
|
func readGrokHooks(root map[string]any) (grokHooks, error) {
|
||||||
@@ -229,7 +213,7 @@ func renderGrokHooks(hooks grokHooks) map[string]any {
|
|||||||
for event, groups := range hooks {
|
for event, groups := range hooks {
|
||||||
values := make([]any, 0, len(groups))
|
values := make([]any, 0, len(groups))
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
object := cloneGrokObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
handlers := make([]any, 0, len(group.handlers))
|
handlers := make([]any, 0, len(group.handlers))
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
@@ -262,7 +246,7 @@ func exactManagedGrokGroup(group grokGroup) bool {
|
|||||||
func managedGrokReference(
|
func managedGrokReference(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*grokRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
if len(handler) != 3 || handler["type"] != "command" ||
|
if len(handler) != 3 || handler["type"] != "command" ||
|
||||||
handler["timeout"] != grokHookTimeout {
|
handler["timeout"] != grokHookTimeout {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -303,7 +287,7 @@ func removeManagedGrokGroups(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
remove := reference != nil &&
|
remove := reference != nil &&
|
||||||
(agentID == "" || sameGrokAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if !remove {
|
if !remove {
|
||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
@@ -386,7 +370,7 @@ func renderGrokDocument(
|
|||||||
document: document, changed: true, remove: true,
|
document: document, changed: true, remove: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
root := cloneGrokObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
} else {
|
} else {
|
||||||
@@ -407,18 +391,11 @@ func renderGrokDocument(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func grokReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokReferencesForEvent(
|
func grokReferencesForEvent(
|
||||||
groups []grokGroup,
|
groups []grokGroup,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (map[string][]grokRuntimeReference, error) {
|
) (map[string][]managedScriptReference, error) {
|
||||||
result := map[string][]grokRuntimeReference{}
|
result := map[string][]managedScriptReference{}
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if !exactManagedGrokGroup(group) {
|
if !exactManagedGrokGroup(group) {
|
||||||
continue
|
continue
|
||||||
@@ -431,14 +408,14 @@ func grokReferencesForEvent(
|
|||||||
if reference == nil {
|
if reference == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := grokReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) {
|
func grokRuntimeContracts(hooks grokHooks) ([]managedRuntimeContract, error) {
|
||||||
guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript)
|
guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -459,16 +436,16 @@ func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) {
|
|||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
contracts := make([]grokRuntimeContract, 0, len(keys))
|
contracts := make([]managedRuntimeContract, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
guard := guards[key]
|
guard := guards[key]
|
||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameGrokPath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, grokRuntimeContract{
|
contracts = append(contracts, managedRuntimeContract{
|
||||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||||
auditPath: success[0].scriptPath,
|
auditPath: success[0].scriptPath,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,102 +1,24 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type grokRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGrokInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != grokAgentKey {
|
|
||||||
return fmt.Errorf("grok installation requires agent name %s", grokAgentKey)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokAgentPaths(config InstallConfig) (*grokRuntimePaths, error) {
|
|
||||||
if err := validateGrokInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &grokRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, grokGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, grokAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameGrokPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameGrokPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightGrokInstallation(
|
func preflightGrokInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
document *grokDocument,
|
document *grokDocument,
|
||||||
) (*grokRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if document == nil {
|
if document == nil {
|
||||||
return nil, "", fmt.Errorf("grok installation requires a hook document")
|
return nil, "", fmt.Errorf("grok installation requires a hook document")
|
||||||
}
|
}
|
||||||
paths, err := grokAgentPaths(config)
|
if err := validateManagedInstallConfig(config, grokAgentKey, "grok"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, grokGuardScript, grokAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateGrokRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, grokAgentKey, "Grok",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
@@ -106,63 +28,23 @@ func preflightGrokInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGrokRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func grokExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
value := map[string]any{
|
return []byte(generateGuardScript(grokAgentKey, agentID, "", false, "grok")),
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
[]byte(buildHookScriptWithOutput(grokAgentKey, agentID, "", false, true))
|
||||||
"base_url": config.BaseURL, "agent_name": grokAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareGrokInstallationChanges(
|
func prepareGrokInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *grokRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered *grokRenderedDocument,
|
rendered *grokRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildGrokRuntimeConfig(config)
|
guardScript, auditScript := grokExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, grokAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(grokAgentKey, config.AgentID, "", false, "grok")), 0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
grokAgentKey, config.AgentID, "", false, true,
|
|
||||||
)), 0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedGrokChange(rendered)
|
documentChange, err := prepareRenderedGrokChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -85,32 +85,17 @@ func writeGrokChanges(
|
|||||||
agentDirectory string,
|
agentDirectory string,
|
||||||
hooksDirectory string,
|
hooksDirectory string,
|
||||||
) error {
|
) error {
|
||||||
hasChanges := false
|
if !hasFileChanges(changes) {
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(
|
if err := ensureManagedDirectory(
|
||||||
filepath.Dir(hooksDirectory),
|
filepath.Dir(hooksDirectory),
|
||||||
"Grok home directory",
|
"Grok home directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil {
|
return writeManagedChanges(
|
||||||
return err
|
changes, label, rename, runtimeRoot, agentDirectory,
|
||||||
}
|
hooksDirectory, "Grok hooks directory",
|
||||||
return writeChanges(changes, label, rename)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,10 +164,10 @@ func TestGrokValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
|
|||||||
}, "query parameters"},
|
}, "query parameters"},
|
||||||
{"guard path", func(f *grokFixture) {
|
{"guard path", func(f *grokFixture) {
|
||||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
{"audit path", func(f *grokFixture) {
|
{"audit path", func(f *grokFixture) {
|
||||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
} {
|
} {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
||||||
|
|||||||
@@ -1,232 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func grokRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"fmt"
|
return managedRuntimeFilesExist(contracts, grokAgentKey, "Grok", nil)
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readGrokRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireGrokRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGrokRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireGrokRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireGrokRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireGrokRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameGrokAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != grokAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity does not match Grok hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireGrokRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireGrokRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGrokRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readGrokRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, grokGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, grokAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != grokAgentKey ||
|
|
||||||
!sameGrokAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Grok agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokRuntimeContractExists(contract grokRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameGrokPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, grokAuditScript),
|
|
||||||
) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readGrokRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateGrokRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokRuntimeFilesExist(contracts []grokRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := grokRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -27,22 +27,22 @@ func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||||
}
|
}
|
||||||
command, err := buildGrokCommand(nodePath, fixture.guardPath)
|
command, err := buildEncodedCommand("Grok", nodePath, fixture.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build Grok command: %v", err)
|
t.Fatalf("build Grok command: %v", err)
|
||||||
}
|
}
|
||||||
executable, script, ok := parseGrokCommand(command)
|
executable, script, ok := parseGrokCommand(command)
|
||||||
if !ok || !sameGrokPath(executable, nodePath) ||
|
if !ok || !sameManagedPath(executable, nodePath) ||
|
||||||
!sameGrokPath(script, fixture.guardPath) {
|
!sameManagedPath(script, fixture.guardPath) {
|
||||||
t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok)
|
t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok)
|
||||||
}
|
}
|
||||||
if strings.Contains(command, "%ELYDORA_HOOK_PATH%") {
|
if runtime.GOOS == "windows" && strings.Contains(command, "%ELYDORA_HOOK_PATH%") {
|
||||||
t.Fatalf("Grok command exposes an expandable path: %q", command)
|
t.Fatalf("Grok command exposes an expandable path: %q", command)
|
||||||
}
|
}
|
||||||
if _, err := buildGrokCommand("node", fixture.guardPath); err == nil {
|
if _, err := buildEncodedCommand("Grok", "node", fixture.guardPath); err == nil {
|
||||||
t.Fatal("accepted a relative Grok runtime path")
|
t.Fatal("accepted a relative Grok runtime path")
|
||||||
}
|
}
|
||||||
if _, err := buildGrokCommand(nodePath, "guard.js"); err == nil {
|
if _, err := buildEncodedCommand("Grok", nodePath, "guard.js"); err == nil {
|
||||||
t.Fatal("accepted a relative Grok script path")
|
t.Fatal("accepted a relative Grok script path")
|
||||||
}
|
}
|
||||||
for _, invalid := range []string{
|
for _, invalid := range []string{
|
||||||
@@ -337,12 +337,12 @@ func TestGrokRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if !sameGrokAgentID("AGENT-1", "agent-1") {
|
if !sameManagedAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatal("Windows Grok agent IDs should compare case-insensitively")
|
t.Fatal("Windows Grok agent IDs should compare case-insensitively")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sameGrokAgentID("AGENT-1", "agent-1") {
|
if sameManagedAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatal("POSIX Grok agent IDs should preserve case")
|
t.Fatal("POSIX Grok agent IDs should preserve case")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,8 +275,7 @@ func legacyGrokCommand(t *testing.T, scriptPath string) string {
|
|||||||
t.Fatalf("resolve Node.js runtime: %v", err)
|
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return quoteGrokLegacyWindowsArgument(nodePath) + " " +
|
return `"` + nodePath + `" "` + scriptPath + `"`
|
||||||
quoteGrokLegacyWindowsArgument(scriptPath)
|
|
||||||
}
|
}
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGrokInstallRollsBackFourRuntimesAfterConfigCommitFailure(t *testing.T)
|
|||||||
}
|
}
|
||||||
before := snapshotGrokFiles(t, paths...)
|
before := snapshotGrokFiles(t, paths...)
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameGrokPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Grok config failure")
|
return errors.New("injected Grok config failure")
|
||||||
}
|
}
|
||||||
@@ -85,11 +85,11 @@ func TestPreparedGrokInstallRejectsConcurrentConfigChange(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("preflight Grok installation: %v", err)
|
t.Fatalf("preflight Grok installation: %v", err)
|
||||||
}
|
}
|
||||||
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("Grok", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build guard command: %v", err)
|
t.Fatalf("build guard command: %v", err)
|
||||||
}
|
}
|
||||||
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("Grok", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build audit command: %v", err)
|
t.Fatalf("build audit command: %v", err)
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func TestGrokUninstallPreservesConfigAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed Grok config: %v", err)
|
t.Fatalf("read installed Grok config: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameGrokPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Grok uninstall failure")
|
return errors.New("injected Grok uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ func rejectDuplicateJSONCKeys(value *hujson.Value, label, location string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
|
||||||
|
if !json.Valid(source) {
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(source, &value); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decodeJSONCObject(source, label, false)
|
||||||
|
}
|
||||||
|
|
||||||
func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) {
|
func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) {
|
||||||
value, err := parseJSONC(source, label, allowTrailingCommas)
|
value, err := parseJSONC(source, label, allowTrailingCommas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -283,3 +293,11 @@ func jsonPointer(path []any) string {
|
|||||||
}
|
}
|
||||||
return pointer.String()
|
return pointer.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneJSONObject(value map[string]any) map[string]any {
|
||||||
|
clone := make(map[string]any, len(value))
|
||||||
|
for key, item := range value {
|
||||||
|
clone[key] = item
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -49,6 +50,20 @@ func hookScriptPath(agentId string) (string, error) {
|
|||||||
return filepath.Join(agentDirectory, "hook.js"), nil
|
return filepath.Join(agentDirectory, "hook.js"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeAgentScripts deletes hook.js and guard.js; missing files are fine.
|
||||||
|
func removeAgentScripts(agentID string) error {
|
||||||
|
for _, resolve := range []func(string) (string, error){hookScriptPath, guardScriptPath} {
|
||||||
|
path, err := resolve(agentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// guardScriptPath returns ~/.elydora/<agentId>/guard.js.
|
// guardScriptPath returns ~/.elydora/<agentId>/guard.js.
|
||||||
func guardScriptPath(agentId string) (string, error) {
|
func guardScriptPath(agentId string) (string, error) {
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
|
agentDirectory, err := ResolveAgentRuntimeDirectory(agentId)
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ func (p *KimiPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -70,12 +70,9 @@ func (p *KimiPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeKimiChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes, "Install Kimi hooks", p.rename,
|
||||||
"Install Kimi hooks",
|
paths.runtimeRoot, paths.agentDirectory, "", "",
|
||||||
p.rename,
|
|
||||||
paths.runtimeRoot,
|
|
||||||
paths.agentDirectory,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -107,13 +104,7 @@ func (p *KimiPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeKimiChanges(
|
return writeManagedChanges(changes, "Uninstall Kimi hooks", p.rename, "", "", "", "")
|
||||||
changes,
|
|
||||||
"Uninstall Kimi hooks",
|
|
||||||
p.rename,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
||||||
|
|||||||
@@ -1,49 +1,10 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type kimiRuntimeReference struct {
|
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameKimiPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameKimiAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildKimiCommand(runtimePath, scriptPath string) (string, error) {
|
|
||||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
|
||||||
return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths")
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return codexWindowsCommand(runtimePath, scriptPath), nil
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
||||||
if start >= len(command) {
|
if start >= len(command) {
|
||||||
return "", start, false
|
return "", start, false
|
||||||
@@ -66,6 +27,7 @@ func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool
|
|||||||
return command[start:end], end, end > start
|
return command[start:end], end, end > start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseKimiLegacyWindowsCommand reads the pre-2.1 quoteWindowsArgument form.
|
||||||
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
||||||
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
|
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
if !ok || next >= len(command) || command[next] != ' ' {
|
||||||
@@ -80,40 +42,19 @@ func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseKimiCommand(command string) (string, string, bool) {
|
func parseKimiCommand(command string) (string, string, bool) {
|
||||||
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok {
|
if runtimePath, scriptPath, ok := parseEncodedCommand(command); ok {
|
||||||
return runtimePath, scriptPath, true
|
|
||||||
}
|
|
||||||
if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok {
|
|
||||||
return runtimePath, scriptPath, true
|
return runtimePath, scriptPath, true
|
||||||
}
|
}
|
||||||
return parseKimiLegacyWindowsCommand(command)
|
return parseKimiLegacyWindowsCommand(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKimiNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeReferenceForCommand(
|
func kimiRuntimeReferenceForCommand(
|
||||||
command string,
|
command string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*kimiRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
runtimePath, scriptPath, ok := parseKimiCommand(command)
|
runtimePath, scriptPath, ok := parseKimiCommand(command)
|
||||||
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
|
||||||
!isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,20 +52,10 @@ type kimiHook struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type kimiRuntimeContract struct {
|
type kimiRuntimeContract struct {
|
||||||
agentID string
|
managedRuntimeContract
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
configPath string
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringSet(values ...string) map[string]struct{} {
|
|
||||||
result := make(map[string]struct{}, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
result[value] = struct{}{}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} {
|
func mergeStringSets(sets ...map[string]struct{}) map[string]struct{} {
|
||||||
result := map[string]struct{}{}
|
result := map[string]struct{}{}
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
@@ -173,7 +161,7 @@ func managedKimiReference(
|
|||||||
hook kimiHook,
|
hook kimiHook,
|
||||||
event string,
|
event string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*kimiRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
|
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
|
||||||
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -196,7 +184,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
|||||||
indices := make([]int, 0, len(hooks))
|
indices := make([]int, 0, len(hooks))
|
||||||
for index, hook := range hooks {
|
for index, hook := range hooks {
|
||||||
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
||||||
var reference *kimiRuntimeReference
|
var reference *managedScriptReference
|
||||||
var err error
|
var err error
|
||||||
if managedEvent {
|
if managedEvent {
|
||||||
reference, err = managedKimiReference(hook, event, scriptName)
|
reference, err = managedKimiReference(hook, event, scriptName)
|
||||||
@@ -205,7 +193,7 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
remove := reference != nil &&
|
remove := reference != nil &&
|
||||||
(agentID == "" || sameKimiAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if !remove {
|
if !remove {
|
||||||
indices = append(indices, index)
|
indices = append(indices, index)
|
||||||
}
|
}
|
||||||
@@ -213,19 +201,12 @@ func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
|||||||
return indices, nil
|
return indices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiReferencesForEvent(
|
func kimiReferencesForEvent(
|
||||||
hooks []kimiHook,
|
hooks []kimiHook,
|
||||||
event string,
|
event string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (map[string][]kimiRuntimeReference, error) {
|
) (map[string][]managedScriptReference, error) {
|
||||||
result := map[string][]kimiRuntimeReference{}
|
result := map[string][]managedScriptReference{}
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
reference, err := managedKimiReference(hook, event, scriptName)
|
reference, err := managedKimiReference(hook, event, scriptName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -234,7 +215,7 @@ func kimiReferencesForEvent(
|
|||||||
if reference == nil {
|
if reference == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := kimiReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -267,12 +248,15 @@ func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, erro
|
|||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameKimiPath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, kimiRuntimeContract{
|
contracts = append(contracts, kimiRuntimeContract{
|
||||||
|
managedRuntimeContract: managedRuntimeContract{
|
||||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||||
auditPath: success[0].scriptPath, configPath: document.contract.configPath,
|
auditPath: success[0].scriptPath,
|
||||||
|
},
|
||||||
|
configPath: document.contract.configPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,107 +2,32 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type kimiRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type kimiRenderedDocument struct {
|
type kimiRenderedDocument struct {
|
||||||
document kimiDocument
|
document kimiDocument
|
||||||
next []byte
|
next []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateKimiInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != kimiAgentKey {
|
|
||||||
return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) {
|
|
||||||
if err := validateKimiInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &kimiRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, kimiGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, kimiAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameKimiPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameKimiPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightKimiInstallation(
|
func preflightKimiInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
documents []kimiDocument,
|
documents []kimiDocument,
|
||||||
) (*kimiRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if len(documents) == 0 {
|
if len(documents) == 0 {
|
||||||
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
|
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
|
||||||
}
|
}
|
||||||
paths, err := kimiAgentPaths(config)
|
if err := validateManagedInstallConfig(config, kimiAgentKey, "kimi"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, kimiGuardScript, kimiAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, kimiAgentKey, "Kimi",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
@@ -112,26 +37,9 @@ func preflightKimiInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func kimiExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
value := map[string]any{
|
return []byte(generateGuardScript(kimiAgentKey, agentID, "", false, "")),
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
[]byte(buildHookScriptWithOutput(kimiAgentKey, agentID, "", false, true))
|
||||||
"base_url": config.BaseURL, "agent_name": kimiAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderKimiChange(
|
func renderKimiChange(
|
||||||
@@ -172,47 +80,21 @@ func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, erro
|
|||||||
|
|
||||||
func prepareKimiInstallationChanges(
|
func prepareKimiInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *kimiRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered []kimiRenderedDocument,
|
rendered []kimiRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildKimiRuntimeConfig(config)
|
guardScript, auditScript := kimiExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, kimiAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
documentChanges, err := prepareKimiUninstallChanges(rendered)
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false, "")), 0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
kimiAgentKey, config.AgentID, "", false, true,
|
|
||||||
)), 0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
return append(changes, documentChanges...), nil
|
||||||
}
|
|
||||||
for _, document := range rendered {
|
|
||||||
change, err := prepareRenderedKimiChange(document)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
return changes, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareKimiUninstallChanges(
|
func prepareKimiUninstallChanges(
|
||||||
@@ -228,31 +110,3 @@ func prepareKimiUninstallChanges(
|
|||||||
}
|
}
|
||||||
return changes, nil
|
return changes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeKimiChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ func resolveKimiContracts() ([]kimiContract, error) {
|
|||||||
legacyHome := filepath.Join(home, ".kimi")
|
legacyHome := filepath.Join(home, ".kimi")
|
||||||
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
||||||
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
||||||
if sameKimiPath(stable.configPath, legacy.configPath) {
|
if sameManagedPath(stable.configPath, legacy.configPath) {
|
||||||
return []kimiContract{stable}, nil
|
return []kimiContract{stable}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,194 +1,12 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readKimiRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"elydora runtime config %s is invalid: %s", field, configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKimiRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name")
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`elydora runtime config has unsupported field %q: %s`, field, configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireKimiRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey {
|
|
||||||
return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireKimiRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKimiRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory, "Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readKimiRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != kimiAgentKey ||
|
|
||||||
!sameKimiAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Kimi agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory, "Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readKimiRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
|
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
exists, err := kimiRuntimeContractExists(contract)
|
exists, err := managedRuntimeContractExists(
|
||||||
if err != nil {
|
contract.managedRuntimeContract, kimiAgentKey, "Kimi", nil,
|
||||||
return false, err
|
)
|
||||||
}
|
if err != nil || exists {
|
||||||
if exists {
|
return exists, err
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) {
|
|||||||
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
|
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
|
||||||
})
|
})
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameKimiPath(destination, fixture.legacyPath) {
|
if sameManagedPath(destination, fixture.legacyPath) {
|
||||||
return errors.New("injected legacy config failure")
|
return errors.New("injected legacy config failure")
|
||||||
}
|
}
|
||||||
return os.Rename(source, destination)
|
return os.Rename(source, destination)
|
||||||
@@ -160,11 +160,11 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("preflight Kimi installation: %v", err)
|
t.Fatalf("preflight Kimi installation: %v", err)
|
||||||
}
|
}
|
||||||
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
guardCommand, err := buildEncodedCommand("kimi", nodePath, paths.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build guard command: %v", err)
|
t.Fatalf("build guard command: %v", err)
|
||||||
}
|
}
|
||||||
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
auditCommand, err := buildEncodedCommand("kimi", nodePath, paths.auditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build audit command: %v", err)
|
t.Fatalf("build audit command: %v", err)
|
||||||
}
|
}
|
||||||
@@ -204,12 +204,9 @@ func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
|
|||||||
t.Fatalf("write concurrent source: %v", err)
|
t.Fatalf("write concurrent source: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = writeKimiChanges(
|
err = writeManagedChanges(
|
||||||
changes,
|
changes, "Install Kimi hooks", nil,
|
||||||
"Install Kimi hooks",
|
paths.runtimeRoot, paths.agentDirectory, "", "",
|
||||||
nil,
|
|
||||||
paths.runtimeRoot,
|
|
||||||
paths.agentDirectory,
|
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error {
|
|||||||
copyKiroObject(nextV2, v2Settings)
|
copyKiroObject(nextV2, v2Settings)
|
||||||
nextV2["hooks"] = v2Hooks
|
nextV2["hooks"] = v2Hooks
|
||||||
|
|
||||||
nextV3 := cloneKiroObject(v3Settings)
|
nextV3 := cloneJSONObject(v3Settings)
|
||||||
nextV3["version"] = "v1"
|
nextV3["version"] = "v1"
|
||||||
v3Hooks := make([]any, 0, len(currentV3Hooks)+2)
|
v3Hooks := make([]any, 0, len(currentV3Hooks)+2)
|
||||||
for _, hook := range currentV3Hooks {
|
for _, hook := range currentV3Hooks {
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ type kiroConfigMutation struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneKiroObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
copyKiroObject(clone, value)
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyKiroObject(destination, source map[string]any) {
|
func copyKiroObject(destination, source map[string]any) {
|
||||||
for key, value := range source {
|
for key, value := range source {
|
||||||
destination[key] = value
|
destination[key] = value
|
||||||
@@ -49,7 +43,7 @@ func kiroHooksObject(settings map[string]any, label string) (map[string]any, err
|
|||||||
if !ok || hooks == nil {
|
if !ok || hooks == nil {
|
||||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||||
}
|
}
|
||||||
return cloneKiroObject(hooks), nil
|
return cloneJSONObject(hooks), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) {
|
func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) {
|
||||||
@@ -193,7 +187,7 @@ func prepareKiroV2Uninstall(
|
|||||||
}
|
}
|
||||||
hooks["preToolUse"] = filteredPre
|
hooks["preToolUse"] = filteredPre
|
||||||
hooks["postToolUse"] = filteredPost
|
hooks["postToolUse"] = filteredPost
|
||||||
next := cloneKiroObject(settings)
|
next := cloneJSONObject(settings)
|
||||||
next["hooks"] = hooks
|
next["hooks"] = hooks
|
||||||
mutation.value = next
|
mutation.value = next
|
||||||
mutation.changed = true
|
mutation.changed = true
|
||||||
@@ -250,7 +244,7 @@ func prepareKiroV3Uninstall(
|
|||||||
if len(filtered) == len(hooks) {
|
if len(filtered) == len(hooks) {
|
||||||
return mutation, nil
|
return mutation, nil
|
||||||
}
|
}
|
||||||
next := cloneKiroObject(settings)
|
next := cloneJSONObject(settings)
|
||||||
next["hooks"] = filtered
|
next["hooks"] = filtered
|
||||||
owned := true
|
owned := true
|
||||||
for key := range settings {
|
for key := range settings {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package plugins
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,18 +17,6 @@ func kiroConfigPaths() (string, string, error) {
|
|||||||
filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil
|
filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveNodeRuntime() (string, error) {
|
|
||||||
nodePath, err := exec.LookPath("node")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolve Node.js runtime: %w", err)
|
|
||||||
}
|
|
||||||
absolute, err := filepath.Abs(nodePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolve Node.js runtime path: %w", err)
|
|
||||||
}
|
|
||||||
return absolute, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildKiroCommand(runtimePath, scriptPath string) string {
|
func buildKiroCommand(runtimePath, scriptPath string) string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
||||||
@@ -37,10 +24,6 @@ func buildKiroCommand(runtimePath, scriptPath string) string {
|
|||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
|
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quotePOSIXArgument(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteWindowsArgument(value string) string {
|
func quoteWindowsArgument(value string) string {
|
||||||
if value != "" && !strings.ContainsAny(value, " \t\n\v\"") {
|
if value != "" && !strings.ContainsAny(value, " \t\n\v\"") {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,101 +1,32 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type kiroIdeRuntimeReference struct {
|
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameKiroIdePath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameKiroIdeAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameKiroIdeFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildKiroIdeCommand(runtimePath, scriptPath string) (string, error) {
|
|
||||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Kiro IDE hook commands require absolute runtime and script paths",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return codexWindowsCommand(runtimePath, scriptPath), nil
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseKiroIdeCommand(command string) (string, string, bool) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return parseCodexWindowsCommand(command)
|
|
||||||
}
|
|
||||||
return parseCodexPOSIXCommand(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
func kiroIdeRuntimeReferenceForCommand(
|
func kiroIdeRuntimeReferenceForCommand(
|
||||||
command string,
|
command string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*kiroIdeRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
runtimePath, scriptPath, ok := parseKiroIdeCommand(command)
|
runtimePath, scriptPath, ok := parseEncodedCommand(command)
|
||||||
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
if !ok || !filepath.IsAbs(runtimePath) || !isNodeExecutable(runtimePath) {
|
||||||
!isCodexNodeExecutable(runtimePath) ||
|
|
||||||
!sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentKiroIdeReference(
|
func currentKiroIdeReference(
|
||||||
hook map[string]any,
|
hook map[string]any,
|
||||||
nodePath string,
|
nodePath string,
|
||||||
) (*kiroIdeRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
reference, err := managedKiroIdeReference(hook)
|
reference, err := managedKiroIdeReference(hook)
|
||||||
if err != nil || reference == nil {
|
if err != nil || reference == nil {
|
||||||
return reference, err
|
return reference, err
|
||||||
}
|
}
|
||||||
action := hook["action"].(map[string]any)
|
action := hook["action"].(map[string]any)
|
||||||
command := action["command"].(string)
|
command := action["command"].(string)
|
||||||
expected, err := buildKiroIdeCommand(nodePath, reference.scriptPath)
|
expected, err := buildEncodedCommand("Kiro IDE", nodePath, reference.scriptPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -105,30 +36,14 @@ func currentKiroIdeReference(
|
|||||||
return reference, nil
|
return reference, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacyKiroIdeGoReference reads the pre-2.1 `node <script>` form.
|
||||||
func legacyKiroIdeGoReference(
|
func legacyKiroIdeGoReference(
|
||||||
command any,
|
command any,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*kiroIdeRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
text, ok := command.(string)
|
text, ok := command.(string)
|
||||||
if !ok || !strings.HasPrefix(text, "node ") {
|
if !ok || !strings.HasPrefix(text, "node ") {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
scriptPath := strings.TrimPrefix(text, "node ")
|
return resolveManagedScript(strings.TrimPrefix(text, "node "), scriptName)
|
||||||
if !filepath.IsAbs(scriptPath) ||
|
|
||||||
!sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &kiroIdeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ type kiroIdeDocument struct {
|
|||||||
type kiroIdeLegacyDocument struct {
|
type kiroIdeLegacyDocument struct {
|
||||||
filePath string
|
filePath string
|
||||||
snapshot *managedFileSnapshot
|
snapshot *managedFileSnapshot
|
||||||
contract *kiroIdeRuntimeContract
|
contract *managedRuntimeContract
|
||||||
}
|
}
|
||||||
|
|
||||||
type kiroIdeRenderedDocument struct {
|
type kiroIdeRenderedDocument struct {
|
||||||
@@ -55,12 +55,6 @@ type kiroIdeRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type kiroIdeRuntimeContract struct {
|
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type kiroIdeHookSpecification struct {
|
type kiroIdeHookSpecification struct {
|
||||||
description string
|
description string
|
||||||
trigger string
|
trigger string
|
||||||
@@ -82,14 +76,6 @@ func kiroIdeSpecification(name any) (kiroIdeHookSpecification, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneKiroIdeObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKiroIdeAction(value any, label string) error {
|
func validateKiroIdeAction(value any, label string) error {
|
||||||
action, ok := value.(map[string]any)
|
action, ok := value.(map[string]any)
|
||||||
if !ok || action == nil {
|
if !ok || action == nil {
|
||||||
@@ -212,7 +198,7 @@ func exactKiroIdeKeys(value map[string]any, keys ...string) bool {
|
|||||||
|
|
||||||
func ownedKiroIdeReference(
|
func ownedKiroIdeReference(
|
||||||
hook map[string]any,
|
hook map[string]any,
|
||||||
) (*kiroIdeRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
specification, managed := kiroIdeSpecification(hook["name"])
|
specification, managed := kiroIdeSpecification(hook["name"])
|
||||||
if !managed {
|
if !managed {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -230,7 +216,7 @@ func ownedKiroIdeReference(
|
|||||||
|
|
||||||
func managedKiroIdeReference(
|
func managedKiroIdeReference(
|
||||||
hook map[string]any,
|
hook map[string]any,
|
||||||
) (*kiroIdeRuntimeReference, error) {
|
) (*managedScriptReference, error) {
|
||||||
specification, managed := kiroIdeSpecification(hook["name"])
|
specification, managed := kiroIdeSpecification(hook["name"])
|
||||||
if !managed ||
|
if !managed ||
|
||||||
!exactKiroIdeKeys(
|
!exactKiroIdeKeys(
|
||||||
@@ -278,7 +264,7 @@ func buildKiroIdeHook(
|
|||||||
if !managed {
|
if !managed {
|
||||||
return nil, fmt.Errorf("unsupported Elydora Kiro IDE hook name %q", name)
|
return nil, fmt.Errorf("unsupported Elydora Kiro IDE hook name %q", name)
|
||||||
}
|
}
|
||||||
command, err := buildKiroIdeCommand(runtimePath, scriptPath)
|
command, err := buildEncodedCommand("Kiro IDE", runtimePath, scriptPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -298,7 +284,7 @@ func withoutManagedKiroIdeHooks(
|
|||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
reference, err := ownedKiroIdeReference(hook)
|
reference, err := ownedKiroIdeReference(hook)
|
||||||
remove := err == nil && reference != nil &&
|
remove := err == nil && reference != nil &&
|
||||||
(agentID == "" || sameKiroIdeAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if !remove {
|
if !remove {
|
||||||
filtered = append(filtered, hook)
|
filtered = append(filtered, hook)
|
||||||
}
|
}
|
||||||
@@ -339,7 +325,7 @@ func renderKiroIdeDocument(
|
|||||||
document: document, changed: true, remove: true,
|
document: document, changed: true, remove: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
root := cloneKiroIdeObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
root["version"] = "v1"
|
root["version"] = "v1"
|
||||||
values := make([]any, 0, len(hooks))
|
values := make([]any, 0, len(hooks))
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
@@ -364,7 +350,7 @@ func renderKiroIdeDocument(
|
|||||||
|
|
||||||
func kiroIdeRuntimeContracts(
|
func kiroIdeRuntimeContracts(
|
||||||
hooks []map[string]any,
|
hooks []map[string]any,
|
||||||
) ([]kiroIdeRuntimeContract, error) {
|
) ([]managedRuntimeContract, error) {
|
||||||
managedHooks := make([]map[string]any, 0, 2)
|
managedHooks := make([]map[string]any, 0, 2)
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
_, managed := kiroIdeSpecification(hook["name"])
|
_, managed := kiroIdeSpecification(hook["name"])
|
||||||
@@ -373,38 +359,38 @@ func kiroIdeRuntimeContracts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(managedHooks) != 2 {
|
if len(managedHooks) != 2 {
|
||||||
return []kiroIdeRuntimeContract{}, nil
|
return []managedRuntimeContract{}, nil
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveNodeRuntime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var guard, audit *kiroIdeRuntimeReference
|
var guard, audit *managedScriptReference
|
||||||
for _, hook := range managedHooks {
|
for _, hook := range managedHooks {
|
||||||
reference, err := currentKiroIdeReference(hook, nodePath)
|
reference, err := currentKiroIdeReference(hook, nodePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if reference == nil {
|
if reference == nil {
|
||||||
return []kiroIdeRuntimeContract{}, nil
|
return []managedRuntimeContract{}, nil
|
||||||
}
|
}
|
||||||
if hook["name"] == kiroIdeGuardName {
|
if hook["name"] == kiroIdeGuardName {
|
||||||
if guard != nil {
|
if guard != nil {
|
||||||
return []kiroIdeRuntimeContract{}, nil
|
return []managedRuntimeContract{}, nil
|
||||||
}
|
}
|
||||||
guard = reference
|
guard = reference
|
||||||
} else {
|
} else {
|
||||||
if audit != nil {
|
if audit != nil {
|
||||||
return []kiroIdeRuntimeContract{}, nil
|
return []managedRuntimeContract{}, nil
|
||||||
}
|
}
|
||||||
audit = reference
|
audit = reference
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if guard == nil || audit == nil ||
|
if guard == nil || audit == nil ||
|
||||||
!sameKiroIdeAgentID(guard.agentID, audit.agentID) {
|
!sameManagedAgentID(guard.agentID, audit.agentID) {
|
||||||
return []kiroIdeRuntimeContract{}, nil
|
return []managedRuntimeContract{}, nil
|
||||||
}
|
}
|
||||||
return []kiroIdeRuntimeContract{{
|
return []managedRuntimeContract{{
|
||||||
agentID: guard.agentID, guardPath: guard.scriptPath,
|
agentID: guard.agentID, guardPath: guard.scriptPath,
|
||||||
auditPath: audit.scriptPath,
|
auditPath: audit.scriptPath,
|
||||||
}}, nil
|
}}, nil
|
||||||
@@ -449,10 +435,10 @@ func parseKiroIdeLegacyDocument(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if guardReference == nil || auditReference == nil ||
|
if guardReference == nil || auditReference == nil ||
|
||||||
!sameKiroIdeAgentID(guardReference.agentID, auditReference.agentID) {
|
!sameManagedAgentID(guardReference.agentID, auditReference.agentID) {
|
||||||
return document, nil
|
return document, nil
|
||||||
}
|
}
|
||||||
document.contract = &kiroIdeRuntimeContract{
|
document.contract = &managedRuntimeContract{
|
||||||
agentID: guardReference.agentID, guardPath: guardReference.scriptPath,
|
agentID: guardReference.agentID, guardPath: guardReference.scriptPath,
|
||||||
auditPath: auditReference.scriptPath,
|
auditPath: auditReference.scriptPath,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type kiroIdeRuntimePaths struct {
|
type kiroIdeRuntimePaths struct {
|
||||||
runtimeRoot string
|
managedRuntimePaths
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
initialDirectories []kiroIdeDirectoryIdentity
|
initialDirectories []kiroIdeDirectoryIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,84 +21,17 @@ type kiroIdePreparedTransaction struct {
|
|||||||
initialDirectories []kiroIdeDirectoryIdentity
|
initialDirectories []kiroIdeDirectoryIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateKiroIdeInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != kiroIdeAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Kiro IDE installation requires agent name %s",
|
|
||||||
kiroIdeAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func kiroIdeAgentPaths(config InstallConfig) (*kiroIdeRuntimePaths, error) {
|
|
||||||
if err := validateKiroIdeInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &kiroIdeRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, kiroIdeGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, kiroIdeAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameKiroIdePath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameKiroIdePath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightKiroIdeInstallation(
|
func preflightKiroIdeInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
) (*kiroIdeRuntimePaths, string, error) {
|
) (*kiroIdeRuntimePaths, string, error) {
|
||||||
paths, err := kiroIdeAgentPaths(config)
|
if err := validateManagedInstallConfig(config, kiroIdeAgentKey, "Kiro IDE"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
managed, err := resolveManagedRuntimePaths(config, kiroIdeGuardScript, kiroIdeAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
paths := &kiroIdeRuntimePaths{managedRuntimePaths: *managed}
|
||||||
initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 3)
|
initialDirectories := make([]kiroIdeDirectoryIdentity, 0, 3)
|
||||||
for _, item := range []struct{ path, label string }{
|
for _, item := range []struct{ path, label string }{
|
||||||
{filepath.Dir(paths.runtimeRoot), "home directory"},
|
{filepath.Dir(paths.runtimeRoot), "home directory"},
|
||||||
@@ -124,9 +50,8 @@ func preflightKiroIdeInstallation(
|
|||||||
initialDirectories[0].path,
|
initialDirectories[0].path,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err := validateKiroIdeRuntimeIdentity(
|
if err := validateManagedRuntimeIdentity(
|
||||||
paths.agentDirectory,
|
paths.agentDirectory, config.AgentID, kiroIdeAgentKey, "Kiro IDE",
|
||||||
config.AgentID,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
@@ -144,29 +69,6 @@ func preflightKiroIdeInstallation(
|
|||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildKiroIdeRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID,
|
|
||||||
"kid": config.KID, "base_url": config.BaseURL,
|
|
||||||
"agent_name": kiroIdeAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareKiroIdeFile(
|
func prepareKiroIdeFile(
|
||||||
filePath string,
|
filePath string,
|
||||||
label string,
|
label string,
|
||||||
@@ -221,10 +123,11 @@ func prepareKiroIdeInstallation(
|
|||||||
if sources == nil || paths == nil || rendered == nil {
|
if sources == nil || paths == nil || rendered == nil {
|
||||||
return nil, fmt.Errorf("Kiro IDE installation requires prepared sources")
|
return nil, fmt.Errorf("Kiro IDE installation requires prepared sources")
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildKiroIdeRuntimeConfig(config)
|
runtimeConfig, err := buildManagedRuntimeConfig(config, kiroIdeAgentKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
guardScript, auditScript := kiroIdeExpectedScripts(config.AgentID, nil)
|
||||||
prepared := &kiroIdePreparedTransaction{
|
prepared := &kiroIdePreparedTransaction{
|
||||||
operation: "installation", paths: sources.paths, runtimePaths: paths,
|
operation: "installation", paths: sources.paths, runtimePaths: paths,
|
||||||
initialDirectories: append(
|
initialDirectories: append(
|
||||||
@@ -238,20 +141,10 @@ func prepareKiroIdeInstallation(
|
|||||||
mode os.FileMode
|
mode os.FileMode
|
||||||
maximum int64
|
maximum int64
|
||||||
}{
|
}{
|
||||||
{
|
{paths.guardPath, "Elydora guard runtime", guardScript, 0700, maxManagedSourceBytes},
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(kiroIdeAgentKey, config.AgentID, "", false, "")),
|
|
||||||
0700, maxManagedSourceBytes,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600, maxRuntimeConfigBytes},
|
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600, maxRuntimeConfigBytes},
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600, maxProtectedSecretBytes},
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600, maxProtectedSecretBytes},
|
||||||
{
|
{paths.auditPath, "Elydora audit runtime", auditScript, 0700, maxManagedSourceBytes},
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
kiroIdeAgentKey, config.AgentID, "", false, true,
|
|
||||||
)),
|
|
||||||
0700, maxManagedSourceBytes,
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
change, condition, err := prepareKiroIdeFile(
|
change, condition, err := prepareKiroIdeFile(
|
||||||
item.path, item.label, item.contents, item.mode, item.maximum,
|
item.path, item.label, item.contents, item.mode, item.maximum,
|
||||||
@@ -274,7 +167,7 @@ func prepareKiroIdeInstallation(
|
|||||||
prepared.changes = append(prepared.changes, documentChange)
|
prepared.changes = append(prepared.changes, documentChange)
|
||||||
}
|
}
|
||||||
removeLegacy := sources.legacy.contract != nil &&
|
removeLegacy := sources.legacy.contract != nil &&
|
||||||
sameKiroIdeAgentID(sources.legacy.contract.agentID, config.AgentID)
|
sameManagedAgentID(sources.legacy.contract.agentID, config.AgentID)
|
||||||
if removeLegacy {
|
if removeLegacy {
|
||||||
legacyChange, err := prepareSnapshotSourceChange(
|
legacyChange, err := prepareSnapshotSourceChange(
|
||||||
sources.legacy.filePath,
|
sources.legacy.filePath,
|
||||||
@@ -335,7 +228,7 @@ func prepareKiroIdeUninstall(
|
|||||||
prepared.changes = append(prepared.changes, documentChange)
|
prepared.changes = append(prepared.changes, documentChange)
|
||||||
}
|
}
|
||||||
removeLegacy := sources.legacy.contract != nil &&
|
removeLegacy := sources.legacy.contract != nil &&
|
||||||
(agentID == "" || sameKiroIdeAgentID(sources.legacy.contract.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(sources.legacy.contract.agentID, agentID))
|
||||||
if removeLegacy {
|
if removeLegacy {
|
||||||
change, err := prepareSnapshotSourceChange(
|
change, err := prepareSnapshotSourceChange(
|
||||||
sources.legacy.filePath,
|
sources.legacy.filePath,
|
||||||
@@ -383,7 +276,7 @@ func commitKiroIdeTransaction(
|
|||||||
}
|
}
|
||||||
workspaceChange := false
|
workspaceChange := false
|
||||||
for _, change := range prepared.changes {
|
for _, change := range prepared.changes {
|
||||||
if change != nil && sameKiroIdePath(change.filePath, prepared.paths.configPath) {
|
if change != nil && sameManagedPath(change.filePath, prepared.paths.configPath) {
|
||||||
workspaceChange = true
|
workspaceChange = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +1,13 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func readKiroIdeRuntimeConfig(path string) (map[string]any, bool, error) {
|
func kiroIdeExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
return []byte(generateGuardScript(kiroIdeAgentKey, agentID, "", false, "")),
|
||||||
if err != nil || snapshot == nil {
|
[]byte(buildHookScriptWithOutput(kiroIdeAgentKey, agentID, "", false, true))
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireKiroIdeRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKiroIdeRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireKiroIdeRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireKiroIdeRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireKiroIdeRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameKiroIdeAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != kiroIdeAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Kiro IDE hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireKiroIdeRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireKiroIdeRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateKiroIdeRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readKiroIdeRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, kiroIdeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, kiroIdeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != kiroIdeAgentKey ||
|
|
||||||
!sameKiroIdeAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Kiro IDE agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool {
|
func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool {
|
||||||
@@ -161,116 +17,44 @@ func kiroIdeRuntimeFileMode(snapshot *managedFileSnapshot, expected uint32) bool
|
|||||||
return uint32(snapshot.mode.Perm()) == expected
|
return uint32(snapshot.mode.Perm()) == expected
|
||||||
}
|
}
|
||||||
|
|
||||||
func kiroIdeRuntimeContractExists(contract kiroIdeRuntimeContract) (bool, error) {
|
// kiroIdeRuntimeModesMatch requires the private modes the installer writes.
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
func kiroIdeRuntimeModesMatch(contract managedRuntimeContract) (bool, error) {
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
if !sameKiroIdePath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
for _, item := range []struct {
|
||||||
!sameKiroIdeAgentID(filepath.Base(agentDirectory), contract.agentID) ||
|
path, label string
|
||||||
!sameKiroIdePath(
|
limit int64
|
||||||
contract.guardPath,
|
mode uint32
|
||||||
filepath.Join(agentDirectory, kiroIdeGuardScript),
|
}{
|
||||||
) ||
|
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", maxRuntimeConfigBytes, 0600},
|
||||||
!sameKiroIdePath(
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes, 0600},
|
||||||
contract.auditPath,
|
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes, 0700},
|
||||||
filepath.Join(agentDirectory, kiroIdeAuditScript),
|
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes, 0700},
|
||||||
) {
|
} {
|
||||||
|
snapshot, err := readManagedFile(item.path, item.label, item.limit)
|
||||||
|
if err != nil || snapshot == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !kiroIdeRuntimeFileMode(snapshot, item.mode) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
return true, nil
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
configSnapshot, err := readManagedFile(
|
|
||||||
configPath,
|
|
||||||
"Elydora runtime config",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if configSnapshot == nil || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
config, err := decodeStrictJSONObject(
|
|
||||||
configSnapshot.contents,
|
|
||||||
fmt.Sprintf("Elydora runtime config at %s", configPath),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateKiroIdeRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
if !kiroIdeRuntimeFileMode(configSnapshot, 0600) ||
|
|
||||||
!kiroIdeRuntimeFileMode(key, 0600) ||
|
|
||||||
!kiroIdeRuntimeFileMode(guard, 0700) ||
|
|
||||||
!kiroIdeRuntimeFileMode(audit, 0700) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
expectedGuard := []byte(generateGuardScript(
|
|
||||||
kiroIdeAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
))
|
|
||||||
expectedAudit := []byte(buildHookScriptWithOutput(
|
|
||||||
kiroIdeAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
))
|
|
||||||
return bytes.Equal(guard.contents, expectedGuard) &&
|
|
||||||
bytes.Equal(audit.contents, expectedAudit), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func kiroIdeRuntimeFilesExist(contracts []kiroIdeRuntimeContract) (bool, error) {
|
func kiroIdeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
exists, err := kiroIdeRuntimeContractExists(contract)
|
exists, err := managedRuntimeContractExists(
|
||||||
|
contract, kiroIdeAgentKey, "Kiro IDE", kiroIdeExpectedScripts,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if exists {
|
if !exists {
|
||||||
return true, nil
|
continue
|
||||||
|
}
|
||||||
|
modes, err := kiroIdeRuntimeModesMatch(contract)
|
||||||
|
if err != nil || modes {
|
||||||
|
return modes, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func TestKiroIdeStatusRequiresUniqueExactPairAndRuntimeIntegrity(t *testing.T) {
|
|||||||
document = readKiroIdeObject(t, fixture.configPath)
|
document = readKiroIdeObject(t, fixture.configPath)
|
||||||
document["hooks"] = append(
|
document["hooks"] = append(
|
||||||
requireArray(t, document["hooks"]),
|
requireArray(t, document["hooks"]),
|
||||||
cloneKiroIdeObject(findKiroIdeHook(t, document, kiroIdeGuardName)),
|
cloneJSONObject(findKiroIdeHook(t, document, kiroIdeGuardName)),
|
||||||
)
|
)
|
||||||
writeKiroIdeJSON(t, fixture.configPath, document)
|
writeKiroIdeJSON(t, fixture.configPath, document)
|
||||||
assertStatus(false, false)
|
assertStatus(false, false)
|
||||||
@@ -283,7 +283,7 @@ func TestKiroIdeStatusRejectsExtraOrphanAndStaleRuntimeCommands(t *testing.T) {
|
|||||||
name = "node.exe"
|
name = "node.exe"
|
||||||
}
|
}
|
||||||
staleNode := filepath.Join(fixture.homeDir, "old-node", name)
|
staleNode := filepath.Join(fixture.homeDir, "old-node", name)
|
||||||
command, err := buildKiroIdeCommand(staleNode, fixture.guardPath)
|
command, err := buildEncodedCommand("Kiro IDE", staleNode, fixture.guardPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build stale Kiro IDE command: %v", err)
|
t.Fatalf("build stale Kiro IDE command: %v", err)
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ func TestKiroIdeStatusRejectsStaleWindowsPowerShellLauncher(t *testing.T) {
|
|||||||
command := action["command"].(string)
|
command := action["command"].(string)
|
||||||
action["command"] = strings.Replace(
|
action["command"] = strings.Replace(
|
||||||
command,
|
command,
|
||||||
codexPowerShellPath(),
|
windowsPowerShellPath(),
|
||||||
`C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`,
|
`C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`,
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestKiroIdeRollbackRestoresRuntimeWorkspaceAndLegacySources(t *testing.T) {
|
|||||||
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
|
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
|
||||||
)
|
)
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameKiroIdePath(source, fixture.legacyPath) &&
|
if sameManagedPath(source, fixture.legacyPath) &&
|
||||||
strings.HasSuffix(destination, ".rollback") {
|
strings.HasSuffix(destination, ".rollback") {
|
||||||
return errors.New("injected Kiro IDE legacy cleanup failure")
|
return errors.New("injected Kiro IDE legacy cleanup failure")
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ func TestKiroIdeInstallSurvivesAdversarialWorkspaceLinkSwap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
swapped := false
|
swapped := false
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if !swapped && sameKiroIdePath(destination, fixture.configPath) &&
|
if !swapped && sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
swapped = true
|
swapped = true
|
||||||
backup := kiroDirectory + ".original"
|
backup := kiroDirectory + ".original"
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ func extractPathFromNodeCommand(command string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withoutElydoraEntries(hookArray any) []any {
|
||||||
|
entries, _ := hookArray.([]any)
|
||||||
|
kept := make([]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if object, ok := entry.(map[string]any); ok && isElydoraHookEntry(object) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, entry)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
func hasElydoraEntry(hookArray any) bool {
|
func hasElydoraEntry(hookArray any) bool {
|
||||||
entries, _ := hookArray.([]any)
|
entries, _ := hookArray.([]any)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
|||||||
+34
-120
@@ -6,11 +6,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LettaPlugin manages the Elydora audit hook for Letta Code.
|
// LettaPlugin merges Elydora hooks into ~/.letta/settings.json.
|
||||||
// It merges PreToolUse/PostToolUse hooks into ~/.letta/settings.json.
|
|
||||||
type LettaPlugin struct{}
|
type LettaPlugin struct{}
|
||||||
|
|
||||||
func (p *LettaPlugin) configPath() (string, error) {
|
func lettaConfigPath() (string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||||
@@ -18,6 +17,16 @@ func (p *LettaPlugin) configPath() (string, error) {
|
|||||||
return filepath.Join(home, ".letta", "settings.json"), nil
|
return filepath.Join(home, ".letta", "settings.json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lettaHookEntry(scriptPath string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": []any{map[string]any{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node " + scriptPath,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *LettaPlugin) Install(config InstallConfig) error {
|
func (p *LettaPlugin) Install(config InstallConfig) error {
|
||||||
scriptPath, err := hookScriptPath(config.AgentID)
|
scriptPath, err := hookScriptPath(config.AgentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,11 +35,9 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
|
|||||||
if config.HookScript != "" {
|
if config.HookScript != "" {
|
||||||
scriptPath = config.HookScript
|
scriptPath = config.HookScript
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
return fmt.Errorf("generate hook script: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
guardPath := config.GuardScriptPath
|
guardPath := config.GuardScriptPath
|
||||||
if guardPath == "" {
|
if guardPath == "" {
|
||||||
guardPath, err = guardScriptPath(config.AgentID)
|
guardPath, err = guardScriptPath(config.AgentID)
|
||||||
@@ -38,70 +45,21 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
configPath, err := lettaConfigPath()
|
||||||
configPath, err := p.configPath()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
settings, err := readJSONFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
hooks, _ := settings["hooks"].(map[string]any)
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
|
||||||
if hooks == nil {
|
if hooks == nil {
|
||||||
hooks = make(map[string]interface{})
|
hooks = map[string]any{}
|
||||||
}
|
}
|
||||||
|
hooks["PreToolUse"] = append(withoutElydoraEntries(hooks["PreToolUse"]), lettaHookEntry(guardPath))
|
||||||
// --- PreToolUse (guard — freeze enforcement) ---
|
hooks["PostToolUse"] = append(withoutElydoraEntries(hooks["PostToolUse"]), lettaHookEntry(scriptPath))
|
||||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
|
||||||
var preFiltered []interface{}
|
|
||||||
for _, entry := range preToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preFiltered = append(preFiltered, entry)
|
|
||||||
}
|
|
||||||
guardEntry := map[string]interface{}{
|
|
||||||
"matcher": "*",
|
|
||||||
"hooks": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node " + guardPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
preFiltered = append(preFiltered, guardEntry)
|
|
||||||
hooks["PreToolUse"] = preFiltered
|
|
||||||
|
|
||||||
// --- PostToolUse (audit logging) ---
|
|
||||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
|
||||||
var postFiltered []interface{}
|
|
||||||
for _, entry := range postToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
postFiltered = append(postFiltered, entry)
|
|
||||||
}
|
|
||||||
hookEntry := map[string]interface{}{
|
|
||||||
"matcher": "*",
|
|
||||||
"hooks": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node " + scriptPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
postFiltered = append(postFiltered, hookEntry)
|
|
||||||
hooks["PostToolUse"] = postFiltered
|
|
||||||
|
|
||||||
settings["hooks"] = hooks
|
settings["hooks"] = hooks
|
||||||
|
|
||||||
if err := writeJSONFile(configPath, settings); err != nil {
|
if err := writeJSONFile(configPath, settings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -110,74 +68,38 @@ func (p *LettaPlugin) Install(config InstallConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LettaPlugin) Uninstall(agentID string) error {
|
func (p *LettaPlugin) Uninstall(agentID string) error {
|
||||||
configPath, err := p.configPath()
|
configPath, err := lettaConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
settings, err := readJSONFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
hooks, _ := settings["hooks"].(map[string]any)
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
|
||||||
if hooks == nil {
|
if hooks == nil {
|
||||||
fmt.Println("No Letta Code hooks found.")
|
fmt.Println("No Letta Code hooks found.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
for _, event := range []string{"PreToolUse", "PostToolUse"} {
|
||||||
// Remove PreToolUse Elydora entries
|
kept := withoutElydoraEntries(hooks[event])
|
||||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
if len(kept) == 0 {
|
||||||
var preFiltered []interface{}
|
delete(hooks, event)
|
||||||
for _, entry := range preToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preFiltered = append(preFiltered, entry)
|
|
||||||
}
|
|
||||||
if len(preFiltered) == 0 {
|
|
||||||
delete(hooks, "PreToolUse")
|
|
||||||
} else {
|
} else {
|
||||||
hooks["PreToolUse"] = preFiltered
|
hooks[event] = kept
|
||||||
}
|
|
||||||
|
|
||||||
// Remove PostToolUse Elydora entries
|
|
||||||
postToolUse, _ := hooks["PostToolUse"].([]interface{})
|
|
||||||
var postFiltered []interface{}
|
|
||||||
for _, entry := range postToolUse {
|
|
||||||
if m, ok := entry.(map[string]interface{}); ok {
|
|
||||||
if isElydoraHookEntry(m) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
postFiltered = append(postFiltered, entry)
|
|
||||||
}
|
|
||||||
if len(postFiltered) == 0 {
|
|
||||||
delete(hooks, "PostToolUse")
|
|
||||||
} else {
|
|
||||||
hooks["PostToolUse"] = postFiltered
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(settings, "hooks")
|
delete(settings, "hooks")
|
||||||
} else {
|
} else {
|
||||||
settings["hooks"] = hooks
|
settings["hooks"] = hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeJSONFile(configPath, settings); err != nil {
|
if err := writeJSONFile(configPath, settings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if agentID != "" {
|
if agentID != "" {
|
||||||
scriptPath, _ := hookScriptPath(agentID)
|
if err := removeAgentScripts(agentID); err != nil {
|
||||||
if scriptPath != "" {
|
return err
|
||||||
os.Remove(scriptPath)
|
|
||||||
}
|
|
||||||
gPath, _ := guardScriptPath(agentID)
|
|
||||||
if gPath != "" {
|
|
||||||
os.Remove(gPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("Uninstalled Elydora hook for Letta Code.")
|
fmt.Println("Uninstalled Elydora hook for Letta Code.")
|
||||||
@@ -185,38 +107,30 @@ func (p *LettaPlugin) Uninstall(agentID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LettaPlugin) Status() (PluginStatus, error) {
|
func (p *LettaPlugin) Status() (PluginStatus, error) {
|
||||||
configPath, err := p.configPath()
|
configPath, err := lettaConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PluginStatus{}, err
|
return PluginStatus{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: "letta",
|
AgentName: "letta",
|
||||||
DisplayName: "Letta Code",
|
DisplayName: "Letta Code",
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
settings, err := readJSONFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
hooks, _ := settings["hooks"].(map[string]any)
|
||||||
|
if hooks == nil {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
status.HookConfigured = hasElydoraEntry(hooks["PreToolUse"]) &&
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
hasElydoraEntry(hooks["PostToolUse"])
|
||||||
if hooks != nil {
|
if scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]); scriptPath != "" {
|
||||||
preConfigured := hasElydoraEntry(hooks["PreToolUse"])
|
|
||||||
postConfigured := hasElydoraEntry(hooks["PostToolUse"])
|
|
||||||
status.HookConfigured = preConfigured && postConfigured
|
|
||||||
|
|
||||||
// Extract hook script path from the configured command
|
|
||||||
scriptPath := extractElydoraScriptPath(hooks["PostToolUse"])
|
|
||||||
if scriptPath != "" {
|
|
||||||
if _, err := os.Stat(scriptPath); err == nil {
|
if _, err := os.Stat(scriptPath); err == nil {
|
||||||
status.HookScriptExists = true
|
status.HookScriptExists = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildManagedRuntimeConfig(config InstallConfig, agentKey string) ([]byte, error) {
|
||||||
|
value := map[string]any{
|
||||||
|
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
||||||
|
"base_url": config.BaseURL, "agent_name": agentKey,
|
||||||
|
}
|
||||||
|
if config.Token != "" {
|
||||||
|
value["token"] = config.Token
|
||||||
|
}
|
||||||
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
if len(encoded) > maxRuntimeConfigBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"elydora runtime config exceeds %d bytes after JSON encoding",
|
||||||
|
maxRuntimeConfigBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return encoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedRuntimeFileChanges prepares the guard, config, key, and audit runtime changes.
|
||||||
|
func managedRuntimeFileChanges(
|
||||||
|
config InstallConfig,
|
||||||
|
paths *managedRuntimePaths,
|
||||||
|
agentKey, guardScript, auditScript string,
|
||||||
|
) ([]*fileChange, error) {
|
||||||
|
runtimeConfig, err := buildManagedRuntimeConfig(config, agentKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []struct {
|
||||||
|
path, label string
|
||||||
|
content []byte
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{paths.guardPath, "Elydora guard runtime", []byte(guardScript), 0700},
|
||||||
|
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
{paths.auditPath, "Elydora audit runtime", []byte(auditScript), 0700},
|
||||||
|
}
|
||||||
|
changes := make([]*fileChange, 0, len(items)+1)
|
||||||
|
for _, item := range items {
|
||||||
|
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFileChanges(changes []*fileChange) bool {
|
||||||
|
for _, change := range changes {
|
||||||
|
if change != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeManagedChanges creates the runtime and settings directories, then commits.
|
||||||
|
func writeManagedChanges(
|
||||||
|
changes []*fileChange,
|
||||||
|
label string,
|
||||||
|
rename renameFunc,
|
||||||
|
runtimeRoot, agentDirectory, settingsDirectory, settingsLabel string,
|
||||||
|
) error {
|
||||||
|
if !hasFileChanges(changes) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if agentDirectory != "" {
|
||||||
|
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settingsDirectory != "" {
|
||||||
|
if err := ensureManagedDirectory(settingsDirectory, settingsLabel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeChanges(changes, label, rename)
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type managedRuntimeContract struct {
|
||||||
|
agentID string
|
||||||
|
guardPath string
|
||||||
|
auditPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type managedRuntimePaths struct {
|
||||||
|
runtimeRoot string
|
||||||
|
agentDirectory string
|
||||||
|
configPath string
|
||||||
|
keyPath string
|
||||||
|
guardPath string
|
||||||
|
auditPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type managedScriptReference struct {
|
||||||
|
agentID string
|
||||||
|
scriptPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSet(values ...string) map[string]struct{} {
|
||||||
|
result := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
result[value] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedRuntimeConfigFields = stringSet(
|
||||||
|
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
||||||
|
)
|
||||||
|
|
||||||
|
func sameManagedPath(left, right string) bool {
|
||||||
|
left = filepath.Clean(left)
|
||||||
|
right = filepath.Clean(right)
|
||||||
|
if absolute, err := filepath.Abs(left); err == nil {
|
||||||
|
left = absolute
|
||||||
|
}
|
||||||
|
if absolute, err := filepath.Abs(right); err == nil {
|
||||||
|
right = absolute
|
||||||
|
}
|
||||||
|
return sameManagedName(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameManagedName compares identifiers case-insensitively on Windows.
|
||||||
|
func sameManagedName(left, right string) bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
|
return left == right
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameManagedAgentID(left, right string) bool {
|
||||||
|
return sameManagedName(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedReferenceKey(agentID string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.ToLower(agentID)
|
||||||
|
}
|
||||||
|
return agentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedScriptWithin accepts absolute <runtimeRoot>/<agent>/<scriptName> paths.
|
||||||
|
func managedScriptWithin(scriptPath, scriptName, runtimeRoot string) *managedScriptReference {
|
||||||
|
if !filepath.IsAbs(scriptPath) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
agentID := filepath.Base(agentDirectory)
|
||||||
|
if agentID == "" || agentID == "." || agentID == ".." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &managedScriptReference{agentID: agentID, scriptPath: scriptPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveManagedScript(scriptPath, scriptName string) (*managedScriptReference, error) {
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return managedScriptWithin(scriptPath, scriptName, runtimeRoot), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManagedInstallConfig(config InstallConfig, agentKey, product string) error {
|
||||||
|
for _, field := range []struct{ name, value string }{
|
||||||
|
{"agent name", config.AgentName},
|
||||||
|
{"organization ID", config.OrgID},
|
||||||
|
{"agent ID", config.AgentID},
|
||||||
|
{"key ID", config.KID},
|
||||||
|
{"private key", config.PrivateKey},
|
||||||
|
{"base URL", config.BaseURL},
|
||||||
|
{"guard script path", config.GuardScriptPath},
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(field.value) == "" {
|
||||||
|
return fmt.Errorf("%s is required", field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.AgentName != agentKey {
|
||||||
|
return fmt.Errorf("%s installation requires agent name %s", product, agentKey)
|
||||||
|
}
|
||||||
|
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
||||||
|
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateManagedBaseURL(config.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveManagedRuntimePaths(
|
||||||
|
config InstallConfig,
|
||||||
|
guardScript, auditScript string,
|
||||||
|
) (*managedRuntimePaths, error) {
|
||||||
|
if config.AgentID == "" {
|
||||||
|
return nil, fmt.Errorf("agent ID is required")
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := &managedRuntimePaths{
|
||||||
|
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
||||||
|
configPath: filepath.Join(agentDirectory, "config.json"),
|
||||||
|
keyPath: filepath.Join(agentDirectory, "private.key"),
|
||||||
|
guardPath: filepath.Join(agentDirectory, guardScript),
|
||||||
|
auditPath: filepath.Join(agentDirectory, auditScript),
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||||
|
!sameManagedPath(config.GuardScriptPath, paths.guardPath) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora guard runtime must use the managed agent directory: %s",
|
||||||
|
paths.guardPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
||||||
|
!sameManagedPath(config.HookScript, paths.auditPath)) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"Elydora audit runtime must use the managed agent directory: %s",
|
||||||
|
paths.auditPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManagedRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||||
|
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||||
|
if err != nil || snapshot == nil {
|
||||||
|
return nil, snapshot != nil, err
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||||
|
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||||
|
return config, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireManagedRuntimeString(config map[string]any, field, configPath string) (string, error) {
|
||||||
|
value, ok := config[field].(string)
|
||||||
|
if !ok || strings.TrimSpace(value) == "" {
|
||||||
|
return "", fmt.Errorf("elydora runtime config %s is invalid: %s", field, configPath)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManagedRuntimeConfig(
|
||||||
|
config map[string]any,
|
||||||
|
expectedAgentID, configPath, agentKey, product string,
|
||||||
|
) error {
|
||||||
|
fields := make([]string, 0, len(config))
|
||||||
|
for field := range config {
|
||||||
|
fields = append(fields, field)
|
||||||
|
}
|
||||||
|
sort.Strings(fields)
|
||||||
|
for _, field := range fields {
|
||||||
|
if _, ok := managedRuntimeConfigFields[field]; !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
`elydora runtime config has unsupported field %q: %s`,
|
||||||
|
field,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"org_id", "kid"} {
|
||||||
|
if _, err := requireManagedRuntimeString(config, field, configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agentID, err := requireManagedRuntimeString(config, "agent_id", configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !sameManagedAgentID(agentID, expectedAgentID) || config["agent_name"] != agentKey {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"elydora runtime identity does not match %s hooks: %s",
|
||||||
|
product,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if _, exists := config["token"]; exists {
|
||||||
|
if _, err := requireManagedRuntimeString(config, "token", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseURL, err := requireManagedRuntimeString(config, "base_url", configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||||
|
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type managedArtifact struct {
|
||||||
|
path string
|
||||||
|
label string
|
||||||
|
limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedRuntimeArtifacts(agentDirectory, guardScript, auditScript string) []managedArtifact {
|
||||||
|
return []managedArtifact{
|
||||||
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||||
|
{filepath.Join(agentDirectory, guardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, auditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateManagedRuntimeIdentity rejects an agent directory owned by another identity.
|
||||||
|
func validateManagedRuntimeIdentity(
|
||||||
|
agentDirectory, agentID, agentKey, product string,
|
||||||
|
extraArtifacts ...managedArtifact,
|
||||||
|
) error {
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(
|
||||||
|
filepath.Dir(agentDirectory), "Elydora runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory, "Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
config, configExists, err := readManagedRuntimeConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
artifactExists := false
|
||||||
|
artifacts := append(managedRuntimeArtifacts(agentDirectory, "guard.js", "hook.js"), extraArtifacts...)
|
||||||
|
for _, item := range artifacts {
|
||||||
|
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||||
|
if inspectErr != nil {
|
||||||
|
return inspectErr
|
||||||
|
}
|
||||||
|
artifactExists = artifactExists || exists
|
||||||
|
}
|
||||||
|
if !configExists {
|
||||||
|
if artifactExists {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"elydora runtime identity cannot be verified without config.json: %s",
|
||||||
|
agentDirectory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configuredID, ok := config["agent_id"].(string)
|
||||||
|
if !ok || config["agent_name"] != agentKey || !sameManagedAgentID(configuredID, agentID) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"elydora runtime config identity does not match %s agent %s: %s",
|
||||||
|
product,
|
||||||
|
agentID,
|
||||||
|
configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type expectedRuntimeScripts func(agentID string, config map[string]any) (guard, audit []byte)
|
||||||
|
|
||||||
|
// managedRuntimeContractExists is the strict status check; nil expected accepts non-empty scripts.
|
||||||
|
func managedRuntimeContractExists(
|
||||||
|
contract managedRuntimeContract,
|
||||||
|
agentKey, product string,
|
||||||
|
expected expectedRuntimeScripts,
|
||||||
|
) (bool, error) {
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
||||||
|
!sameManagedPath(contract.auditPath, filepath.Join(agentDirectory, "hook.js")) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, directory := range []struct{ path, label string }{
|
||||||
|
{runtimeRoot, "Elydora runtime directory"},
|
||||||
|
{agentDirectory, "Elydora agent runtime directory"},
|
||||||
|
} {
|
||||||
|
exists, err := managedPhysicalDirectoryExists(directory.path, directory.label)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||||
|
config, configExists, err := readManagedRuntimeConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !configExists || key == nil || guard == nil || audit == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := validateManagedRuntimeConfig(config, contract.agentID, configPath, agentKey, product); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||||
|
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
||||||
|
}
|
||||||
|
if expected == nil {
|
||||||
|
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
||||||
|
}
|
||||||
|
expectedGuard, expectedAudit := expected(contract.agentID, config)
|
||||||
|
return string(guard.contents) == string(expectedGuard) &&
|
||||||
|
string(audit.contents) == string(expectedAudit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedRuntimeFilesExist(
|
||||||
|
contracts []managedRuntimeContract,
|
||||||
|
agentKey, product string,
|
||||||
|
expected expectedRuntimeScripts,
|
||||||
|
) (bool, error) {
|
||||||
|
for _, contract := range contracts {
|
||||||
|
exists, err := managedRuntimeContractExists(contract, agentKey, product, expected)
|
||||||
|
if err != nil || exists {
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedRuntimePresent is the presence-only status check; identity mismatch reports false.
|
||||||
|
func managedRuntimePresent(contract managedRuntimeContract, agentKey string) (bool, error) {
|
||||||
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory, "Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
config, exists, err := readManagedRuntimeConfig(filepath.Join(agentDirectory, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
configuredID, idOK := config["agent_id"].(string)
|
||||||
|
if !exists || !idOK || config["agent_name"] != agentKey ||
|
||||||
|
!sameManagedAgentID(configuredID, contract.agentID) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, item := range []managedArtifact{
|
||||||
|
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes},
|
||||||
|
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||||
|
} {
|
||||||
|
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||||
|
if inspectErr != nil || !exists {
|
||||||
|
return false, inspectErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedRuntimeFilesPresent(contracts []managedRuntimeContract, agentKey string) (bool, error) {
|
||||||
|
for _, contract := range contracts {
|
||||||
|
present, err := managedRuntimePresent(contract, agentKey)
|
||||||
|
if err != nil || present {
|
||||||
|
return present, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenCodePlugin manages the Elydora audit hook for OpenCode.
|
var openCodeScriptPathPattern = regexp.MustCompile(`(?m)^const (GUARD_SCRIPT_PATH|HOOK_SCRIPT_PATH) = (".*");$`)
|
||||||
// It writes .config/opencode/plugins/elydora-audit.mjs in the home directory.
|
|
||||||
|
// OpenCodePlugin writes ~/.config/opencode/plugins/elydora-audit.mjs.
|
||||||
type OpenCodePlugin struct{}
|
type OpenCodePlugin struct{}
|
||||||
|
|
||||||
func (p *OpenCodePlugin) configPath() (string, error) {
|
func (p *OpenCodePlugin) configPath() (string, error) {
|
||||||
@@ -59,8 +62,7 @@ func (p *OpenCodePlugin) Install(config InstallConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildOpenCodePlugin(scriptPath, guardPath string) string {
|
func buildOpenCodePlugin(scriptPath, guardPath string) string {
|
||||||
return fmt.Sprintf(`// Elydora Audit Plugin for OpenCode
|
return fmt.Sprintf(`// Elydora audit plugin for OpenCode. Generated by the Elydora CLI; do not edit.
|
||||||
// Generated by @elydora/sdk — DO NOT EDIT
|
|
||||||
|
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
@@ -112,62 +114,72 @@ func (p *OpenCodePlugin) Uninstall(agentID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if agentID != "" {
|
if agentID != "" {
|
||||||
scriptPath, _ := hookScriptPath(agentID)
|
if err := removeAgentScripts(agentID); err != nil {
|
||||||
if scriptPath != "" {
|
return err
|
||||||
os.Remove(scriptPath)
|
|
||||||
}
|
|
||||||
gPath, _ := guardScriptPath(agentID)
|
|
||||||
if gPath != "" {
|
|
||||||
os.Remove(gPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Uninstalled Elydora hook for OpenCode.")
|
fmt.Println("Uninstalled Elydora hook for OpenCode.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openCodeScriptPaths(data []byte, pluginFile string) (string, string, error) {
|
||||||
|
paths := map[string]string{}
|
||||||
|
for _, match := range openCodeScriptPathPattern.FindAllSubmatch(data, -1) {
|
||||||
|
value, err := strconv.Unquote(string(match[2]))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf(
|
||||||
|
"OpenCode plugin at %s has an invalid %s: %w", pluginFile, match[1], err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
paths[string(match[1])] = value
|
||||||
|
}
|
||||||
|
guardPath, hasGuard := paths["GUARD_SCRIPT_PATH"]
|
||||||
|
hookPath, hasHook := paths["HOOK_SCRIPT_PATH"]
|
||||||
|
if !hasGuard || !hasHook {
|
||||||
|
return "", "", fmt.Errorf("OpenCode plugin at %s has no Elydora script paths", pluginFile)
|
||||||
|
}
|
||||||
|
return guardPath, hookPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *OpenCodePlugin) Status() (PluginStatus, error) {
|
func (p *OpenCodePlugin) Status() (PluginStatus, error) {
|
||||||
pluginFile, err := p.configPath()
|
pluginFile, err := p.configPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PluginStatus{}, err
|
return PluginStatus{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: "opencode",
|
AgentName: "opencode",
|
||||||
DisplayName: "OpenCode",
|
DisplayName: "OpenCode",
|
||||||
ConfigPath: pluginFile,
|
ConfigPath: pluginFile,
|
||||||
}
|
}
|
||||||
|
data, err := os.ReadFile(pluginFile)
|
||||||
if _, err := os.Stat(pluginFile); err == nil {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return status, fmt.Errorf("read %s: %w", pluginFile, err)
|
||||||
|
}
|
||||||
status.HookConfigured = true
|
status.HookConfigured = true
|
||||||
|
guardPath, hookPath, err := openCodeScriptPaths(data, pluginFile)
|
||||||
// Read the plugin file to extract the hook script path
|
if err != nil {
|
||||||
data, readErr := os.ReadFile(pluginFile)
|
return status, err
|
||||||
if readErr == nil {
|
|
||||||
content := string(data)
|
|
||||||
// Look for the elydora hook.js path in the plugin file content
|
|
||||||
if idx := strings.Index(content, ".elydora"); idx >= 0 {
|
|
||||||
// Extract path from the spawn('node', ['/path/to/hook.js']) pattern
|
|
||||||
// Pragmatic: scan ~/.elydora/*/hook.js for existence
|
|
||||||
home, homeErr := os.UserHomeDir()
|
|
||||||
if homeErr == nil {
|
|
||||||
entries, dirErr := os.ReadDir(filepath.Join(home, ".elydora"))
|
|
||||||
if dirErr == nil {
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
hookPath := filepath.Join(home, ".elydora", entry.Name(), "hook.js")
|
|
||||||
if _, statErr := os.Stat(hookPath); statErr == nil {
|
|
||||||
status.HookScriptExists = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
guard, err := resolveManagedScript(guardPath, "guard.js")
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
}
|
}
|
||||||
|
audit, err := resolveManagedScript(hookPath, "hook.js")
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
}
|
}
|
||||||
|
if guard == nil || audit == nil || !sameManagedAgentID(guard.agentID, audit.agentID) {
|
||||||
|
return status, nil
|
||||||
}
|
}
|
||||||
|
status.HookScriptExists, err = managedRuntimePresent(managedRuntimeContract{
|
||||||
|
agentID: guard.agentID, guardPath: guard.scriptPath, auditPath: audit.scriptPath,
|
||||||
|
}, "opencode")
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
}
|
}
|
||||||
}
|
status.Installed = status.HookScriptExists
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func (p *QwenPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ func (p *QwenPlugin) Status() (PluginStatus, error) {
|
|||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
status.ConfigPath = sources.user.filePath
|
status.ConfigPath = sources.user.filePath
|
||||||
runtimeRoot, err := qwenRuntimeRoot()
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const qwenPOSIXApostrophe = `'"'"'`
|
|
||||||
|
|
||||||
type qwenRuntimeReference struct {
|
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildQwenCommand(nodePath, scriptPath string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "& " + quoteQwenPowerShell(nodePath) + " " +
|
|
||||||
quoteQwenPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteQwenPowerShell(value string) string {
|
|
||||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseQwenCommand(command string) (string, string, bool) {
|
|
||||||
start := 0
|
|
||||||
suffix := ""
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
if !strings.HasPrefix(command, "& ") {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
start = 2
|
|
||||||
suffix = "; exit $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
executable, next, ok := readQwenQuotedArgument(command, start)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := readQwenQuotedArgument(command, next+1)
|
|
||||||
return executable, script, ok && command[end:] == suffix &&
|
|
||||||
executable != "" && script != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func readQwenQuotedArgument(command string, start int) (string, int, bool) {
|
|
||||||
if start >= len(command) || command[start] != '\'' {
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := start + 1; index < len(command); {
|
|
||||||
if runtime.GOOS == "windows" && strings.HasPrefix(command[index:], "''") {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if runtime.GOOS != "windows" && strings.HasPrefix(command[index:], qwenPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(qwenPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index + 1, true
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", start, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameQwenPath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameQwenAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func isQwenNodeExecutable(filePath string) bool {
|
|
||||||
name := strings.ToLower(filepath.Base(filePath))
|
|
||||||
return name == "node" || name == "node.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenRuntimeReferenceForCommand(
|
|
||||||
command, scriptName, runtimeRoot string,
|
|
||||||
) (qwenRuntimeReference, bool) {
|
|
||||||
executable, scriptPath, ok := parseQwenCommand(command)
|
|
||||||
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
|
||||||
!isQwenNodeExecutable(executable) || filepath.Base(scriptPath) != scriptName {
|
|
||||||
return qwenRuntimeReference{}, false
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
|
||||||
if !sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return qwenRuntimeReference{}, false
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return qwenRuntimeReference{}, false
|
|
||||||
}
|
|
||||||
return qwenRuntimeReference{agentID: agentID, scriptPath: scriptPath}, true
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -40,12 +39,6 @@ type qwenManagedRemoval struct {
|
|||||||
removeGroup bool
|
removeGroup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type qwenRuntimeContract struct {
|
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenExpectedShell() string {
|
func qwenExpectedShell() string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return "powershell"
|
return "powershell"
|
||||||
@@ -58,41 +51,52 @@ func buildQwenGroup(nodePath, scriptPath, name string) map[string]any {
|
|||||||
"hooks": []any{map[string]any{
|
"hooks": []any{map[string]any{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"name": name,
|
"name": name,
|
||||||
"command": buildQwenCommand(nodePath, scriptPath),
|
"command": buildShellCommand(nodePath, scriptPath),
|
||||||
"shell": qwenExpectedShell(),
|
"shell": qwenExpectedShell(),
|
||||||
"timeout": qwenHookTimeout,
|
"timeout": qwenHookTimeout,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func qwenRuntimeReferenceForCommand(
|
||||||
|
command, scriptName, runtimeRoot string,
|
||||||
|
) *managedScriptReference {
|
||||||
|
executable, scriptPath, ok := parseShellCommand(command)
|
||||||
|
if !ok || !filepath.IsAbs(executable) || !isNodeExecutable(executable) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return managedScriptWithin(scriptPath, scriptName, runtimeRoot)
|
||||||
|
}
|
||||||
|
|
||||||
func currentQwenReference(
|
func currentQwenReference(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName, hookName, runtimeRoot string,
|
scriptName, hookName, runtimeRoot string,
|
||||||
) (qwenRuntimeReference, bool) {
|
) *managedScriptReference {
|
||||||
if len(handler) != 5 || handler["type"] != "command" ||
|
if len(handler) != 5 || handler["type"] != "command" ||
|
||||||
handler["name"] != hookName || handler["shell"] != qwenExpectedShell() ||
|
handler["name"] != hookName || handler["shell"] != qwenExpectedShell() ||
|
||||||
handler["timeout"] != qwenHookTimeout {
|
handler["timeout"] != qwenHookTimeout {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// legacyQwenReference reads the pre-2.1 unnamed handler.
|
||||||
func legacyQwenReference(
|
func legacyQwenReference(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName, runtimeRoot string,
|
scriptName, runtimeRoot string,
|
||||||
) (qwenRuntimeReference, bool) {
|
) *managedScriptReference {
|
||||||
if len(handler) != 4 || handler["type"] != "command" ||
|
if len(handler) != 4 || handler["type"] != "command" ||
|
||||||
handler["shell"] != qwenExpectedShell() ||
|
handler["shell"] != qwenExpectedShell() ||
|
||||||
handler["timeout"] != qwenHookTimeout {
|
handler["timeout"] != qwenHookTimeout {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
return qwenRuntimeReferenceForCommand(command, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
@@ -101,19 +105,19 @@ func managedQwenReference(
|
|||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
scriptName, hookName, runtimeRoot string,
|
scriptName, hookName, runtimeRoot string,
|
||||||
includeLegacy bool,
|
includeLegacy bool,
|
||||||
) (qwenRuntimeReference, bool) {
|
) *managedScriptReference {
|
||||||
if reference, ok := currentQwenReference(
|
if reference := currentQwenReference(
|
||||||
handler,
|
handler,
|
||||||
scriptName,
|
scriptName,
|
||||||
hookName,
|
hookName,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
); ok {
|
); reference != nil {
|
||||||
return reference, true
|
return reference
|
||||||
}
|
}
|
||||||
if includeLegacy {
|
if includeLegacy {
|
||||||
return legacyQwenReference(handler, scriptName, runtimeRoot)
|
return legacyQwenReference(handler, scriptName, runtimeRoot)
|
||||||
}
|
}
|
||||||
return qwenRuntimeReference{}, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func exactCurrentQwenGroup(group map[string]any) bool {
|
func exactCurrentQwenGroup(group map[string]any) bool {
|
||||||
@@ -138,14 +142,15 @@ func managedQwenRemovals(
|
|||||||
handlers := group["hooks"].([]any)
|
handlers := group["hooks"].([]any)
|
||||||
indexes := make([]int, 0)
|
indexes := make([]int, 0)
|
||||||
for handlerIndex, handlerValue := range handlers {
|
for handlerIndex, handlerValue := range handlers {
|
||||||
reference, managed := managedQwenReference(
|
reference := managedQwenReference(
|
||||||
handlerValue.(map[string]any),
|
handlerValue.(map[string]any),
|
||||||
contract.script,
|
contract.script,
|
||||||
contract.name,
|
contract.name,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
if managed && (agentID == "" || sameQwenAgentID(reference.agentID, agentID)) {
|
if reference != nil &&
|
||||||
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
|
||||||
indexes = append(indexes, handlerIndex)
|
indexes = append(indexes, handlerIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,18 +168,11 @@ func managedQwenRemovals(
|
|||||||
return removals
|
return removals
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedQwenAgentID(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenReferencesForEvent(
|
func qwenReferencesForEvent(
|
||||||
settings qwenHookSettings,
|
settings qwenHookSettings,
|
||||||
event, scriptName, hookName, runtimeRoot string,
|
event, scriptName, hookName, runtimeRoot string,
|
||||||
) map[string][]qwenRuntimeReference {
|
) map[string][]managedScriptReference {
|
||||||
references := map[string][]qwenRuntimeReference{}
|
references := map[string][]managedScriptReference{}
|
||||||
groups, _ := settings[event].([]any)
|
groups, _ := settings[event].([]any)
|
||||||
for _, groupValue := range groups {
|
for _, groupValue := range groups {
|
||||||
group := groupValue.(map[string]any)
|
group := groupValue.(map[string]any)
|
||||||
@@ -182,17 +180,17 @@ func qwenReferencesForEvent(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, handlerValue := range group["hooks"].([]any) {
|
for _, handlerValue := range group["hooks"].([]any) {
|
||||||
reference, ok := currentQwenReference(
|
reference := currentQwenReference(
|
||||||
handlerValue.(map[string]any),
|
handlerValue.(map[string]any),
|
||||||
scriptName,
|
scriptName,
|
||||||
hookName,
|
hookName,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
)
|
)
|
||||||
if !ok {
|
if reference == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := normalizedQwenAgentID(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
references[key] = append(references[key], reference)
|
references[key] = append(references[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return references
|
return references
|
||||||
@@ -201,7 +199,7 @@ func qwenReferencesForEvent(
|
|||||||
func qwenRuntimeContracts(
|
func qwenRuntimeContracts(
|
||||||
settings qwenHookSettings,
|
settings qwenHookSettings,
|
||||||
runtimeRoot string,
|
runtimeRoot string,
|
||||||
) []qwenRuntimeContract {
|
) []managedRuntimeContract {
|
||||||
guards := qwenReferencesForEvent(
|
guards := qwenReferencesForEvent(
|
||||||
settings,
|
settings,
|
||||||
"PreToolUse",
|
"PreToolUse",
|
||||||
@@ -223,31 +221,23 @@ func qwenRuntimeContracts(
|
|||||||
qwenAuditHookName,
|
qwenAuditHookName,
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
)
|
)
|
||||||
contracts := make([]qwenRuntimeContract, 0)
|
contracts := make([]managedRuntimeContract, 0)
|
||||||
for key, guard := range guards {
|
for key, guard := range guards {
|
||||||
post := posts[key]
|
post := posts[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(post) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(post) != 1 || len(failure) != 1 ||
|
||||||
!sameQwenPath(post[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(post[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, qwenRuntimeContract{
|
contracts = append(contracts, managedRuntimeContract{
|
||||||
agentID: guard[0].agentID,
|
agentID: guard[0].agentID,
|
||||||
guardPath: guard[0].scriptPath,
|
guardPath: guard[0].scriptPath,
|
||||||
auditPath: post[0].scriptPath,
|
auditPath: post[0].scriptPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(contracts, func(left, right int) bool {
|
sort.Slice(contracts, func(left, right int) bool {
|
||||||
return normalizedQwenAgentID(contracts[left].agentID) <
|
return managedReferenceKey(contracts[left].agentID) <
|
||||||
normalizedQwenAgentID(contracts[right].agentID)
|
managedReferenceKey(contracts[right].agentID)
|
||||||
})
|
})
|
||||||
return contracts
|
return contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
func qwenRuntimeRoot() (string, error) {
|
|
||||||
return AgentRuntimeRoot()
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenContractDirectory(contract qwenRuntimeContract) string {
|
|
||||||
return filepath.Dir(contract.guardPath)
|
|
||||||
}
|
|
||||||
@@ -1,158 +1,45 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type qwenRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type preparedQwenInstallation struct {
|
type preparedQwenInstallation struct {
|
||||||
changes []*fileChange
|
changes []*fileChange
|
||||||
preconditions []filePrecondition
|
preconditions []filePrecondition
|
||||||
paths *qwenRuntimePaths
|
paths *managedRuntimePaths
|
||||||
}
|
|
||||||
|
|
||||||
type qwenRuntimeConfig struct {
|
|
||||||
OrgID string `json:"org_id"`
|
|
||||||
AgentID string `json:"agent_id"`
|
|
||||||
KID string `json:"kid"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
AgentName string `json:"agent_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateQwenInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != qwenAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Qwen Code installation requires agent name %s",
|
|
||||||
qwenAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenAgentPaths(config InstallConfig) (*qwenRuntimePaths, error) {
|
|
||||||
if err := validateQwenInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &qwenRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot,
|
|
||||||
agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, qwenGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, qwenAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameQwenPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameQwenPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightQwenInstallation(
|
func preflightQwenInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
sources *qwenSources,
|
sources *qwenSources,
|
||||||
) (*qwenRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
||||||
return nil, "", fmt.Errorf("Qwen Code installation requires user settings")
|
return nil, "", fmt.Errorf("Qwen Code installation requires user settings")
|
||||||
}
|
}
|
||||||
if err := requireQwenHooksEnabled(sources); err != nil {
|
if err := requireQwenHooksEnabled(sources); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
paths, err := qwenAgentPaths(config)
|
if err := validateManagedInstallConfig(config, qwenAgentKey, "Qwen Code"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, qwenGuardScript, qwenAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateQwenRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, qwenAgentKey, "Qwen Code",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Qwen Code")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isQwenNodeExecutable(nodePath) {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"Qwen Code hooks require an absolute Node.js executable path",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQwenRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func qwenExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
encoded, err := json.MarshalIndent(qwenRuntimeConfig{
|
return []byte(generateGuardScript(qwenAgentKey, agentID, "", false, "")),
|
||||||
OrgID: config.OrgID,
|
[]byte(buildHookScriptWithOutput(qwenAgentKey, agentID, "", false, true))
|
||||||
AgentID: config.AgentID,
|
|
||||||
KID: config.KID,
|
|
||||||
BaseURL: config.BaseURL,
|
|
||||||
Token: config.Token,
|
|
||||||
AgentName: qwenAgentKey,
|
|
||||||
}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareRenderedQwenChange(
|
func prepareRenderedQwenChange(
|
||||||
@@ -180,7 +67,7 @@ func qwenReadOnlyPreconditions(
|
|||||||
}
|
}
|
||||||
result := make([]filePrecondition, 0, len(sources.preconditions))
|
result := make([]filePrecondition, 0, len(sources.preconditions))
|
||||||
for _, condition := range sources.preconditions {
|
for _, condition := range sources.preconditions {
|
||||||
if changedPath != "" && sameQwenPath(condition.filePath, changedPath) {
|
if changedPath != "" && sameManagedPath(condition.filePath, changedPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, condition)
|
result = append(result, condition)
|
||||||
@@ -198,47 +85,16 @@ func prepareQwenInstallation(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if rendered == nil || rendered.document == nil ||
|
if rendered == nil || rendered.document == nil ||
|
||||||
!sameQwenPath(rendered.document.filePath, sources.user.filePath) {
|
!sameManagedPath(rendered.document.filePath, sources.user.filePath) {
|
||||||
return nil, fmt.Errorf("Qwen Code rendered user settings are required")
|
return nil, fmt.Errorf("Qwen Code rendered user settings are required")
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildQwenRuntimeConfig(config)
|
guardScript, auditScript := qwenExpectedScripts(config.AgentID, nil)
|
||||||
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config, paths, qwenAgentKey, string(guardScript), string(auditScript),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(qwenAgentKey, config.AgentID, "", false, "")),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
qwenAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if changeErr != nil {
|
|
||||||
return nil, changeErr
|
|
||||||
}
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
settingsChange, err := prepareRenderedQwenChange(rendered)
|
settingsChange, err := prepareRenderedQwenChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
package plugins
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`)
|
|
||||||
|
|
||||||
func readQwenRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireQwenRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field, configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateQwenRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID, configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id",
|
|
||||||
"agent_id",
|
|
||||||
"kid",
|
|
||||||
"base_url",
|
|
||||||
"token",
|
|
||||||
"agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireQwenRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireQwenRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireQwenRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameQwenAgentID(agentID, expectedAgentID) || config["agent_name"] != qwenAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Qwen Code hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireQwenRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireQwenRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateQwenRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readQwenRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, qwenGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, qwenAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != qwenAgentKey ||
|
|
||||||
!sameQwenAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Qwen Code agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validQwenContractPaths(contract qwenRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := qwenContractDirectory(contract)
|
|
||||||
return sameQwenPath(filepath.Dir(agentDirectory), runtimeRoot) &&
|
|
||||||
sameQwenPath(
|
|
||||||
contract.guardPath,
|
|
||||||
filepath.Join(agentDirectory, qwenGuardScript),
|
|
||||||
) &&
|
|
||||||
sameQwenPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, qwenAuditScript),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenRuntimeContractExists(contract qwenRuntimeContract) (bool, error) {
|
|
||||||
validPaths, err := validQwenContractPaths(contract)
|
|
||||||
if err != nil || !validPaths {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := qwenContractDirectory(contract)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readQwenRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateQwenRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
expectedGuard := []byte(generateGuardScript(
|
|
||||||
qwenAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
))
|
|
||||||
expectedAudit := []byte(buildHookScriptWithOutput(
|
|
||||||
qwenAgentKey,
|
|
||||||
contract.agentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
))
|
|
||||||
return bytes.Equal(guard.contents, expectedGuard) &&
|
|
||||||
bytes.Equal(audit.contents, expectedAudit), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func qwenRuntimeFilesExist(contracts []qwenRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := qwenRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
@@ -244,7 +244,7 @@ func TestQwenTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) {
|
|||||||
original := readQwenTestFile(t, fixture.configPath)
|
original := readQwenTestFile(t, fixture.configPath)
|
||||||
failed := false
|
failed := false
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if !failed && sameQwenPath(destination, fixture.configPath) {
|
if !failed && sameManagedPath(destination, fixture.configPath) {
|
||||||
failed = true
|
failed = true
|
||||||
return errors.New("simulated settings commit failure")
|
return errors.New("simulated settings commit failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var qwenPathSeparatorPattern = regexp.MustCompile(`[/\\]+`)
|
||||||
|
|
||||||
var qwenHomeEnvironmentKeys = [...]string{"QWEN_HOME", "QWEN_RUNTIME_DIR"}
|
var qwenHomeEnvironmentKeys = [...]string{"QWEN_HOME", "QWEN_RUNTIME_DIR"}
|
||||||
|
|
||||||
type qwenDisableControl struct {
|
type qwenDisableControl struct {
|
||||||
@@ -154,7 +157,7 @@ func resolveQwenRouting() (*qwenRoutingResult, error) {
|
|||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
return nil, resolveErr
|
return nil, resolveErr
|
||||||
}
|
}
|
||||||
if !sameQwenPath(discoveredDirectory, initialDirectory) {
|
if !sameManagedPath(discoveredDirectory, initialDirectory) {
|
||||||
if err := readCandidate(filepath.Join(discoveredDirectory, ".env")); err != nil {
|
if err := readCandidate(filepath.Join(discoveredDirectory, ".env")); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user