@app-vitals/agent-runtime 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/agent-repos-ref.ts
|
|
3
|
+
*
|
|
4
|
+
* A tiny mutable box holding the agent's most recently synced list of scoped
|
|
5
|
+
* repos (org/repo strings from AgentConfigResponse.repos), so downstream
|
|
6
|
+
* consumers can read a live view without closing over a single syncConfig()
|
|
7
|
+
* tick. Kept pure and zero-I/O so it's unit-testable in isolation — mirrors
|
|
8
|
+
* loop-jobs-ref.ts's style.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AgentReposRef {
|
|
12
|
+
/**
|
|
13
|
+
* Returns the most recently set repos list, or [] if set() was never
|
|
14
|
+
* called. Callers that need to distinguish "never synced" (config-bundle
|
|
15
|
+
* fetch has never succeeded — e.g. a persistent 404) from "synced to a
|
|
16
|
+
* deliberately empty scope" must check hasSynced() rather than inferring
|
|
17
|
+
* it from an empty get() result, since both states return [].
|
|
18
|
+
*/
|
|
19
|
+
get(): string[];
|
|
20
|
+
/**
|
|
21
|
+
* True once set() has been called at least once. Consumers that gate
|
|
22
|
+
* candidacy on scope should fail open (skip filtering) while this is
|
|
23
|
+
* false, so a persistent config-sync failure doesn't silently exclude
|
|
24
|
+
* every repo from candidacy — before this ref existed, scope was never a
|
|
25
|
+
* candidacy gate at all, and that pre-sync behavior must be preserved.
|
|
26
|
+
*/
|
|
27
|
+
hasSynced(): boolean;
|
|
28
|
+
/** Replaces the current repos list. */
|
|
29
|
+
set(repos: string[]): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Creates a new, independent agent repos ref defaulting to an empty list. */
|
|
33
|
+
export function createAgentReposRef(): AgentReposRef {
|
|
34
|
+
let repos: string[] = [];
|
|
35
|
+
let synced = false;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
get(): string[] {
|
|
39
|
+
return repos;
|
|
40
|
+
},
|
|
41
|
+
hasSynced(): boolean {
|
|
42
|
+
return synced;
|
|
43
|
+
},
|
|
44
|
+
set(next: string[]): void {
|
|
45
|
+
repos = next;
|
|
46
|
+
synced = true;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The process-wide agent repos ref. index.ts's syncConfig() calls
|
|
53
|
+
* `.set(bundle.repos)` on every successful config sync tick; check-review.ts,
|
|
54
|
+
* check-patch.ts, and check-deploy.ts's buildProductionDeps default their
|
|
55
|
+
* getScopedRepos/hasSynced dependencies to `.get`/`.hasSynced` from this same
|
|
56
|
+
* instance, so scope changes take effect on their very next
|
|
57
|
+
* candidate-collection call without requiring loop-orchestrator.ts (which
|
|
58
|
+
* builds those deps once and reuses them for the orchestrator's lifetime) to
|
|
59
|
+
* be touched at all. syncConfig()'s 404 branch never calls `.set()`, so
|
|
60
|
+
* `hasSynced()` stays false for the process lifetime if the agent's config
|
|
61
|
+
* bundle never becomes available — consumers must fail open in that case.
|
|
62
|
+
*/
|
|
63
|
+
export const agentReposRef: AgentReposRef = createAgentReposRef();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/agent-repos-ref.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for createAgentReposRef() — pure logic, no I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { agentReposRef, createAgentReposRef } from "./agent-repos-ref.ts";
|
|
9
|
+
|
|
10
|
+
describe("createAgentReposRef", () => {
|
|
11
|
+
it("initial get() before any set() returns an empty/safe default", () => {
|
|
12
|
+
const ref = createAgentReposRef();
|
|
13
|
+
expect(ref.get()).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("hasSynced() is false before any set() call", () => {
|
|
17
|
+
const ref = createAgentReposRef();
|
|
18
|
+
expect(ref.hasSynced()).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("hasSynced() becomes true after the first set(), even if set to an empty list", () => {
|
|
22
|
+
const ref = createAgentReposRef();
|
|
23
|
+
ref.set([]);
|
|
24
|
+
expect(ref.hasSynced()).toBe(true);
|
|
25
|
+
expect(ref.get()).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("hasSynced() stays true after subsequent set() calls, regardless of value", () => {
|
|
29
|
+
const ref = createAgentReposRef();
|
|
30
|
+
ref.set(["org/repo-a"]);
|
|
31
|
+
expect(ref.hasSynced()).toBe(true);
|
|
32
|
+
|
|
33
|
+
ref.set([]);
|
|
34
|
+
expect(ref.hasSynced()).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns the most recently set repos list regardless of how many times set() was called", () => {
|
|
38
|
+
const ref = createAgentReposRef();
|
|
39
|
+
const first = ["org/repo-a"];
|
|
40
|
+
const second = ["org/repo-b"];
|
|
41
|
+
const third = ["org/repo-c", "org/repo-d"];
|
|
42
|
+
|
|
43
|
+
ref.set(first);
|
|
44
|
+
expect(ref.get()).toBe(first);
|
|
45
|
+
|
|
46
|
+
ref.set(second);
|
|
47
|
+
expect(ref.get()).toBe(second);
|
|
48
|
+
|
|
49
|
+
ref.set(third);
|
|
50
|
+
expect(ref.get()).toEqual(third);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("multiple independent ref instances don't share state", () => {
|
|
54
|
+
const refA = createAgentReposRef();
|
|
55
|
+
const refB = createAgentReposRef();
|
|
56
|
+
|
|
57
|
+
refA.set(["org/repo-a"]);
|
|
58
|
+
|
|
59
|
+
expect(refA.get()).toEqual(["org/repo-a"]);
|
|
60
|
+
expect(refB.get()).toEqual([]);
|
|
61
|
+
expect(refA.hasSynced()).toBe(true);
|
|
62
|
+
expect(refB.hasSynced()).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("set() with an empty array is reflected by the next get()", () => {
|
|
66
|
+
const ref = createAgentReposRef();
|
|
67
|
+
ref.set(["org/repo-a"]);
|
|
68
|
+
expect(ref.get()).toHaveLength(1);
|
|
69
|
+
|
|
70
|
+
ref.set([]);
|
|
71
|
+
expect(ref.get()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("agentReposRef (process-wide singleton)", () => {
|
|
76
|
+
it("is a working ref that reflects set() through get(), independent of createAgentReposRef() instances", () => {
|
|
77
|
+
const independent = createAgentReposRef();
|
|
78
|
+
independent.set(["org/should-not-leak"]);
|
|
79
|
+
|
|
80
|
+
agentReposRef.set(["org/scoped-repo"]);
|
|
81
|
+
expect(agentReposRef.get()).toEqual(["org/scoped-repo"]);
|
|
82
|
+
expect(independent.get()).toEqual(["org/should-not-leak"]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/agent-slack-membership-ref.ts
|
|
3
|
+
*
|
|
4
|
+
* A tiny mutable box holding the agent's most recently synced
|
|
5
|
+
* Slack membership restriction config (whether to restrict Slack access to
|
|
6
|
+
* specific members + the list of allowed member email addresses), so
|
|
7
|
+
* downstream consumers can read a live view without closing over a single
|
|
8
|
+
* syncConfig() tick. Kept pure and zero-I/O so it's unit-testable in
|
|
9
|
+
* isolation — mirrors review-author-allowlist-ref.ts's style.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface SlackMembershipConfig {
|
|
13
|
+
restrict: boolean;
|
|
14
|
+
emails: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AgentSlackMembershipRef {
|
|
18
|
+
/**
|
|
19
|
+
* Returns the most recently set Slack membership config, or
|
|
20
|
+
* {restrict: false, emails: []} if set() was never called. Callers that
|
|
21
|
+
* need to distinguish "never synced" (config-bundle fetch has never
|
|
22
|
+
* succeeded — e.g. a persistent 404) from "synced to a deliberately
|
|
23
|
+
* unrestricted state" must check hasSynced() rather than inferring it
|
|
24
|
+
* from the default value, since both states return {restrict: false,
|
|
25
|
+
* emails: []}.
|
|
26
|
+
*/
|
|
27
|
+
get(): SlackMembershipConfig;
|
|
28
|
+
/**
|
|
29
|
+
* True once set() has been called at least once. Consumers that gate on
|
|
30
|
+
* the membership restriction should fail open (skip filtering / allow) while
|
|
31
|
+
* this is false, so a persistent config-sync failure doesn't silently
|
|
32
|
+
* restrict Slack access — before the first successful config sync,
|
|
33
|
+
* unrestricted access is the safe default.
|
|
34
|
+
*/
|
|
35
|
+
hasSynced(): boolean;
|
|
36
|
+
/** Replaces the current Slack membership config. */
|
|
37
|
+
set(config: SlackMembershipConfig): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Defaults a config-bundle's restrictSlackToMembers and memberEmails to
|
|
42
|
+
* fail-open when the values are null/undefined before they're handed to
|
|
43
|
+
* agentSlackMembershipRef.set().
|
|
44
|
+
*
|
|
45
|
+
* The AgentConfigResponse type declares restrictSlackToMembers as a
|
|
46
|
+
* non-nullable boolean and memberEmails as a non-nullable string[], but
|
|
47
|
+
* the live config-bundle API has been observed returning null/undefined
|
|
48
|
+
* for fields in production — a runtime/type mismatch that, left unguarded,
|
|
49
|
+
* gets baked into the process-wide agentSlackMembershipRef singleton.
|
|
50
|
+
* Defaulting here, at the config-sync write site, ensures every downstream
|
|
51
|
+
* consumer of the ref never sees null and always respects fail-open semantics
|
|
52
|
+
* (restrict: false, emails: []) on config-sync failure.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveSlackMembership(
|
|
55
|
+
restrictSlackToMembers: boolean | null | undefined,
|
|
56
|
+
memberEmails: string[] | null | undefined,
|
|
57
|
+
): SlackMembershipConfig {
|
|
58
|
+
return {
|
|
59
|
+
restrict: restrictSlackToMembers ?? false,
|
|
60
|
+
emails: memberEmails ?? [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Creates a new, independent agent Slack membership ref defaulting to an unrestricted state. */
|
|
65
|
+
export function createAgentSlackMembershipRef(): AgentSlackMembershipRef {
|
|
66
|
+
let config: SlackMembershipConfig = { restrict: false, emails: [] };
|
|
67
|
+
let synced = false;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
get(): SlackMembershipConfig {
|
|
71
|
+
return config;
|
|
72
|
+
},
|
|
73
|
+
hasSynced(): boolean {
|
|
74
|
+
return synced;
|
|
75
|
+
},
|
|
76
|
+
set(next: SlackMembershipConfig): void {
|
|
77
|
+
config = next;
|
|
78
|
+
synced = true;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The process-wide agent Slack membership ref. Downstream consumers
|
|
85
|
+
* (e.g. Slack message handlers checking membership restriction) will
|
|
86
|
+
* default their restriction-checking behavior to this same instance
|
|
87
|
+
* (once the consuming code lands), so membership changes take effect on
|
|
88
|
+
* the very next candidate-collection or message-handling call without
|
|
89
|
+
* requiring any agent restart. If the agent's config bundle never becomes
|
|
90
|
+
* available, `hasSynced()` stays false for the process lifetime —
|
|
91
|
+
* consumers must fail open (allow) in that case.
|
|
92
|
+
*/
|
|
93
|
+
export const agentSlackMembershipRef: AgentSlackMembershipRef =
|
|
94
|
+
createAgentSlackMembershipRef();
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/agent-slack-membership-ref.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for createAgentSlackMembershipRef() — pure logic, no I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
agentSlackMembershipRef,
|
|
10
|
+
createAgentSlackMembershipRef,
|
|
11
|
+
resolveSlackMembership,
|
|
12
|
+
} from "./agent-slack-membership-ref.ts";
|
|
13
|
+
|
|
14
|
+
describe("createAgentSlackMembershipRef", () => {
|
|
15
|
+
it("initial get() before any set() returns the fail-open default", () => {
|
|
16
|
+
const ref = createAgentSlackMembershipRef();
|
|
17
|
+
expect(ref.get()).toEqual({ restrict: false, emails: [] });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("hasSynced() is false before any set() call", () => {
|
|
21
|
+
const ref = createAgentSlackMembershipRef();
|
|
22
|
+
expect(ref.hasSynced()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("hasSynced() becomes true after the first set(), even if set to empty state", () => {
|
|
26
|
+
const ref = createAgentSlackMembershipRef();
|
|
27
|
+
ref.set({ restrict: false, emails: [] });
|
|
28
|
+
expect(ref.hasSynced()).toBe(true);
|
|
29
|
+
expect(ref.get()).toEqual({ restrict: false, emails: [] });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("hasSynced() stays true after subsequent set() calls, regardless of value", () => {
|
|
33
|
+
const ref = createAgentSlackMembershipRef();
|
|
34
|
+
ref.set({ restrict: true, emails: ["alice@example.com"] });
|
|
35
|
+
expect(ref.hasSynced()).toBe(true);
|
|
36
|
+
|
|
37
|
+
ref.set({ restrict: false, emails: [] });
|
|
38
|
+
expect(ref.hasSynced()).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns the most recently set membership config regardless of how many times set() was called", () => {
|
|
42
|
+
const ref = createAgentSlackMembershipRef();
|
|
43
|
+
const first = { restrict: false, emails: [] };
|
|
44
|
+
const second = { restrict: true, emails: ["alice@example.com"] };
|
|
45
|
+
const third = {
|
|
46
|
+
restrict: true,
|
|
47
|
+
emails: ["bob@example.com", "carol@example.com"],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
ref.set(first);
|
|
51
|
+
expect(ref.get()).toBe(first);
|
|
52
|
+
|
|
53
|
+
ref.set(second);
|
|
54
|
+
expect(ref.get()).toBe(second);
|
|
55
|
+
|
|
56
|
+
ref.set(third);
|
|
57
|
+
expect(ref.get()).toEqual(third);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("multiple independent ref instances don't share state", () => {
|
|
61
|
+
const refA = createAgentSlackMembershipRef();
|
|
62
|
+
const refB = createAgentSlackMembershipRef();
|
|
63
|
+
|
|
64
|
+
refA.set({ restrict: true, emails: ["alice@example.com"] });
|
|
65
|
+
|
|
66
|
+
expect(refA.get()).toEqual({
|
|
67
|
+
restrict: true,
|
|
68
|
+
emails: ["alice@example.com"],
|
|
69
|
+
});
|
|
70
|
+
expect(refB.get()).toEqual({ restrict: false, emails: [] });
|
|
71
|
+
expect(refA.hasSynced()).toBe(true);
|
|
72
|
+
expect(refB.hasSynced()).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("set() with restrict:true and emails is reflected by the next get()", () => {
|
|
76
|
+
const ref = createAgentSlackMembershipRef();
|
|
77
|
+
ref.set({ restrict: false, emails: [] });
|
|
78
|
+
expect(ref.get()).toEqual({ restrict: false, emails: [] });
|
|
79
|
+
|
|
80
|
+
ref.set({
|
|
81
|
+
restrict: true,
|
|
82
|
+
emails: ["alice@example.com", "bob@example.com"],
|
|
83
|
+
});
|
|
84
|
+
expect(ref.get()).toEqual({
|
|
85
|
+
restrict: true,
|
|
86
|
+
emails: ["alice@example.com", "bob@example.com"],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("agentSlackMembershipRef (process-wide singleton)", () => {
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
agentSlackMembershipRef.set({ restrict: false, emails: [] });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("is a working ref that reflects set() through get(), independent of createAgentSlackMembershipRef() instances", () => {
|
|
97
|
+
const independent = createAgentSlackMembershipRef();
|
|
98
|
+
independent.set({
|
|
99
|
+
restrict: true,
|
|
100
|
+
emails: ["should-not-leak@example.com"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
agentSlackMembershipRef.set({
|
|
104
|
+
restrict: true,
|
|
105
|
+
emails: ["alice@example.com"],
|
|
106
|
+
});
|
|
107
|
+
expect(agentSlackMembershipRef.get()).toEqual({
|
|
108
|
+
restrict: true,
|
|
109
|
+
emails: ["alice@example.com"],
|
|
110
|
+
});
|
|
111
|
+
expect(independent.get()).toEqual({
|
|
112
|
+
restrict: true,
|
|
113
|
+
emails: ["should-not-leak@example.com"],
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("resolveSlackMembership", () => {
|
|
119
|
+
// Regression coverage: the live config-bundle API may return null/undefined
|
|
120
|
+
// for these fields even though AgentConfigResponse types them as
|
|
121
|
+
// non-nullable. resolveSlackMembership must default to a fail-open state
|
|
122
|
+
// (restrict: false, emails: []) to protect every downstream consumer.
|
|
123
|
+
|
|
124
|
+
it("defaults a null restrictSlackToMembers to false", () => {
|
|
125
|
+
expect(
|
|
126
|
+
resolveSlackMembership(null as unknown as boolean, ["alice@example.com"]),
|
|
127
|
+
).toEqual({
|
|
128
|
+
restrict: false,
|
|
129
|
+
emails: ["alice@example.com"],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("defaults an undefined restrictSlackToMembers to false", () => {
|
|
134
|
+
expect(
|
|
135
|
+
resolveSlackMembership(undefined as unknown as boolean, [
|
|
136
|
+
"alice@example.com",
|
|
137
|
+
]),
|
|
138
|
+
).toEqual({
|
|
139
|
+
restrict: false,
|
|
140
|
+
emails: ["alice@example.com"],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("defaults a null memberEmails to an empty array", () => {
|
|
145
|
+
expect(resolveSlackMembership(true, null as unknown as string[])).toEqual({
|
|
146
|
+
restrict: true,
|
|
147
|
+
emails: [],
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("defaults an undefined memberEmails to an empty array", () => {
|
|
152
|
+
expect(
|
|
153
|
+
resolveSlackMembership(true, undefined as unknown as string[]),
|
|
154
|
+
).toEqual({
|
|
155
|
+
restrict: true,
|
|
156
|
+
emails: [],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("defaults both null restrictSlackToMembers and null memberEmails to fail-open state", () => {
|
|
161
|
+
expect(
|
|
162
|
+
resolveSlackMembership(
|
|
163
|
+
null as unknown as boolean,
|
|
164
|
+
null as unknown as string[],
|
|
165
|
+
),
|
|
166
|
+
).toEqual({
|
|
167
|
+
restrict: false,
|
|
168
|
+
emails: [],
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("syncing a null restrictSlackToMembers into the ref does not throw and leaves restrict as false", () => {
|
|
173
|
+
const ref = createAgentSlackMembershipRef();
|
|
174
|
+
const result = resolveSlackMembership(null as unknown as boolean, [
|
|
175
|
+
"alice@example.com",
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
expect(() => ref.set(result)).not.toThrow();
|
|
179
|
+
expect(ref.get()).toEqual({
|
|
180
|
+
restrict: false,
|
|
181
|
+
emails: ["alice@example.com"],
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("passes through a real restrictSlackToMembers and memberEmails array unchanged (no behavior change for real values)", () => {
|
|
186
|
+
const emails = ["alice@example.com", "bob@example.com"];
|
|
187
|
+
const result = resolveSlackMembership(true, emails);
|
|
188
|
+
expect(result).toEqual({
|
|
189
|
+
restrict: true,
|
|
190
|
+
emails: emails,
|
|
191
|
+
});
|
|
192
|
+
// Verify emails array is the same reference (not copied)
|
|
193
|
+
expect(result.emails).toBe(emails);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("passes through an already-empty memberEmails array unchanged", () => {
|
|
197
|
+
const config = { restrict: false, emails: [] };
|
|
198
|
+
const result = resolveSlackMembership(config.restrict, config.emails);
|
|
199
|
+
expect(result).toEqual(config);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/agent-trial-expiry-ref.ts
|
|
3
|
+
*
|
|
4
|
+
* A tiny mutable box holding the agent's most recently synced trial-expiry
|
|
5
|
+
* timestamp (ATE-3.1), so downstream consumers (agent/src/runtime/slack.ts's
|
|
6
|
+
* isTrialExpired() gate) can read a live view without closing over a single
|
|
7
|
+
* syncConfig() tick. Kept pure and zero-I/O so it's unit-testable in
|
|
8
|
+
* isolation — mirrors agent-slack-membership-ref.ts's style exactly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AgentTrialExpiryRef {
|
|
12
|
+
/**
|
|
13
|
+
* Returns the most recently synced trial-expiry Date, or null if either
|
|
14
|
+
* set() was never called or the agent has no trial configured. Callers
|
|
15
|
+
* that need to distinguish "never synced" (config-bundle fetch has never
|
|
16
|
+
* succeeded) from "synced to no-trial-configured" must check hasSynced()
|
|
17
|
+
* rather than inferring it from the null return value, since both states
|
|
18
|
+
* return null.
|
|
19
|
+
*/
|
|
20
|
+
get(): Date | null;
|
|
21
|
+
/**
|
|
22
|
+
* True once set() has been called at least once. Consumers gating on
|
|
23
|
+
* trial expiry should fail open (treat as not-expired) while this is
|
|
24
|
+
* false, so a persistent config-sync failure never locks a customer out
|
|
25
|
+
* of their own paying agent — before the first successful config sync,
|
|
26
|
+
* unexpired access is the safe default.
|
|
27
|
+
*/
|
|
28
|
+
hasSynced(): boolean;
|
|
29
|
+
/** Replaces the current trial-expiry timestamp. */
|
|
30
|
+
set(trialExpiresAt: Date | null): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Creates a new, independent agent trial-expiry ref defaulting to "no trial configured". */
|
|
34
|
+
export function createAgentTrialExpiryRef(): AgentTrialExpiryRef {
|
|
35
|
+
let trialExpiresAt: Date | null = null;
|
|
36
|
+
let synced = false;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
get(): Date | null {
|
|
40
|
+
return trialExpiresAt;
|
|
41
|
+
},
|
|
42
|
+
hasSynced(): boolean {
|
|
43
|
+
return synced;
|
|
44
|
+
},
|
|
45
|
+
set(next: Date | null): void {
|
|
46
|
+
trialExpiresAt = next;
|
|
47
|
+
synced = true;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The process-wide agent trial-expiry ref. agent/src/index.ts's syncConfig()
|
|
54
|
+
* writes into this on every successful config-sync tick;
|
|
55
|
+
* agent/src/runtime/slack.ts's isTrialExpired() gate reads it on every inbound Slack
|
|
56
|
+
* event, so a trial-expiry change (set via PATCH /agents/:id) takes effect
|
|
57
|
+
* on the very next config-sync tick without requiring an agent restart. If
|
|
58
|
+
* the agent's config bundle never becomes available, hasSynced() stays
|
|
59
|
+
* false for the process lifetime — consumers must fail open (allow) in that
|
|
60
|
+
* case.
|
|
61
|
+
*/
|
|
62
|
+
export const agentTrialExpiryRef: AgentTrialExpiryRef =
|
|
63
|
+
createAgentTrialExpiryRef();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/agent-trial-expiry-ref.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for createAgentTrialExpiryRef() — pure logic, no I/O.
|
|
5
|
+
* Mirrors agent-slack-membership-ref.unit.test.ts's coverage pattern.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
agentTrialExpiryRef,
|
|
11
|
+
createAgentTrialExpiryRef,
|
|
12
|
+
} from "./agent-trial-expiry-ref.ts";
|
|
13
|
+
|
|
14
|
+
describe("createAgentTrialExpiryRef", () => {
|
|
15
|
+
it("initial get() before any set() returns null (fail-open default)", () => {
|
|
16
|
+
const ref = createAgentTrialExpiryRef();
|
|
17
|
+
expect(ref.get()).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("hasSynced() is false before any set() call", () => {
|
|
21
|
+
const ref = createAgentTrialExpiryRef();
|
|
22
|
+
expect(ref.hasSynced()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("hasSynced() becomes true after the first set(), even if set to null", () => {
|
|
26
|
+
const ref = createAgentTrialExpiryRef();
|
|
27
|
+
ref.set(null);
|
|
28
|
+
expect(ref.hasSynced()).toBe(true);
|
|
29
|
+
expect(ref.get()).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("hasSynced() stays true after subsequent set() calls, regardless of value", () => {
|
|
33
|
+
const ref = createAgentTrialExpiryRef();
|
|
34
|
+
ref.set(new Date("2026-01-01T00:00:00.000Z"));
|
|
35
|
+
expect(ref.hasSynced()).toBe(true);
|
|
36
|
+
|
|
37
|
+
ref.set(null);
|
|
38
|
+
expect(ref.hasSynced()).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns the most recently set trial-expiry date regardless of how many times set() was called", () => {
|
|
42
|
+
const ref = createAgentTrialExpiryRef();
|
|
43
|
+
const first = new Date("2026-01-01T00:00:00.000Z");
|
|
44
|
+
const second = null;
|
|
45
|
+
const third = new Date("2026-06-15T00:00:00.000Z");
|
|
46
|
+
|
|
47
|
+
ref.set(first);
|
|
48
|
+
expect(ref.get()).toBe(first);
|
|
49
|
+
|
|
50
|
+
ref.set(second);
|
|
51
|
+
expect(ref.get()).toBeNull();
|
|
52
|
+
|
|
53
|
+
ref.set(third);
|
|
54
|
+
expect(ref.get()).toBe(third);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("multiple independent ref instances don't share state", () => {
|
|
58
|
+
const refA = createAgentTrialExpiryRef();
|
|
59
|
+
const refB = createAgentTrialExpiryRef();
|
|
60
|
+
|
|
61
|
+
refA.set(new Date("2026-01-01T00:00:00.000Z"));
|
|
62
|
+
|
|
63
|
+
expect(refA.get()).toEqual(new Date("2026-01-01T00:00:00.000Z"));
|
|
64
|
+
expect(refB.get()).toBeNull();
|
|
65
|
+
expect(refA.hasSynced()).toBe(true);
|
|
66
|
+
expect(refB.hasSynced()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("agentTrialExpiryRef (process-wide singleton)", () => {
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
agentTrialExpiryRef.set(null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is a working ref that reflects set() through get(), independent of createAgentTrialExpiryRef() instances", () => {
|
|
76
|
+
const independent = createAgentTrialExpiryRef();
|
|
77
|
+
independent.set(new Date("2099-01-01T00:00:00.000Z"));
|
|
78
|
+
|
|
79
|
+
const expiry = new Date("2026-03-01T00:00:00.000Z");
|
|
80
|
+
agentTrialExpiryRef.set(expiry);
|
|
81
|
+
expect(agentTrialExpiryRef.get()).toEqual(expiry);
|
|
82
|
+
expect(independent.get()).toEqual(new Date("2099-01-01T00:00:00.000Z"));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/anthropic-federation-guard.ts
|
|
3
|
+
*
|
|
4
|
+
* Defense in depth for Anthropic workload identity federation (WIF-2.1). The
|
|
5
|
+
* admin already refuses to store or serve these keys for federated agents; this
|
|
6
|
+
* guards the agent process itself. When ANTHROPIC_FEDERATION_RULE_ID is in the
|
|
7
|
+
* pod env, CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
|
|
8
|
+
* are skipped and deleted from process.env — each outranks federation in the
|
|
9
|
+
* Anthropic client, even when set to an empty string.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const ANTHROPIC_CREDENTIAL_KEYS = [
|
|
13
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
14
|
+
"ANTHROPIC_API_KEY",
|
|
15
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
interface GuardDeps {
|
|
19
|
+
processEnv?: Record<string, string | undefined>;
|
|
20
|
+
/** Keys already logged by this process — log once per key. */
|
|
21
|
+
logged?: Set<string>;
|
|
22
|
+
log?: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const loggedKeys = new Set<string>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns `env` without the Anthropic credential keys and removes them from
|
|
29
|
+
* process.env, when federation is configured. A no-op otherwise. Logs key
|
|
30
|
+
* names only, once per key.
|
|
31
|
+
*/
|
|
32
|
+
export function filterAnthropicCredentials(
|
|
33
|
+
env: Record<string, string>,
|
|
34
|
+
deps: GuardDeps = {},
|
|
35
|
+
): Record<string, string> {
|
|
36
|
+
const processEnv = deps.processEnv ?? process.env;
|
|
37
|
+
if (!processEnv.ANTHROPIC_FEDERATION_RULE_ID) return env;
|
|
38
|
+
|
|
39
|
+
const logged = deps.logged ?? loggedKeys;
|
|
40
|
+
const log = deps.log ?? ((m: string) => console.warn(m));
|
|
41
|
+
const filtered = { ...env };
|
|
42
|
+
for (const key of ANTHROPIC_CREDENTIAL_KEYS) {
|
|
43
|
+
const present = key in filtered || processEnv[key] !== undefined;
|
|
44
|
+
delete filtered[key];
|
|
45
|
+
delete processEnv[key];
|
|
46
|
+
if (present && !logged.has(key)) {
|
|
47
|
+
logged.add(key);
|
|
48
|
+
log(
|
|
49
|
+
`[federation] ignoring ${key}: Anthropic workload identity federation is active`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return filtered;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply a config-bundle env to process.env, honoring the federation guard.
|
|
58
|
+
* Used by both the entrypoint and syncConfig(). Returns the keys whose value
|
|
59
|
+
* changed (names only, for logging).
|
|
60
|
+
*/
|
|
61
|
+
export function applyEnvToProcess(
|
|
62
|
+
env: Record<string, string>,
|
|
63
|
+
deps: GuardDeps = {},
|
|
64
|
+
): string[] {
|
|
65
|
+
const processEnv = deps.processEnv ?? process.env;
|
|
66
|
+
const filtered = filterAnthropicCredentials(env, deps);
|
|
67
|
+
const changed = Object.keys(filtered).filter(
|
|
68
|
+
(key) => filtered[key] !== processEnv[key],
|
|
69
|
+
);
|
|
70
|
+
Object.assign(processEnv, filtered);
|
|
71
|
+
return changed;
|
|
72
|
+
}
|