Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions docs/platforms/teams.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,11 @@ Tenant ID: <tenant-id> # recommended for single-tenant apps
Webhook path: /teams/messages # default
```

For scripted setup, use:

```bash
opentag setup \
--platform teams \
--teams-app-id <microsoft-app-id> \
--teams-app-password <client-secret-value> \
--teams-tenant-id <tenant-id> \
--teams-webhook-path /teams/messages
```
Do not put the client secret in command-line arguments, including
`--teams-app-password`; shell history and process listings can expose it. For
unattended operation, provision `platforms.teams.appPassword` through an
operator-managed ignored config or secret store before starting OpenTag. Keep
scripted command-line flags limited to non-secret values.

The setup command saves:

Expand Down
53 changes: 50 additions & 3 deletions packages/cli/test/docs-contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { globSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

Expand Down Expand Up @@ -137,7 +137,18 @@ describe("platform setup docs contract", () => {

it("keeps the OpenTag skill aligned with Codex askhuman setup guidance", () => {
const skill = repoFile("skills/opentag/SKILL.md");
const skillDocs = globSync("skills/opentag/**/*.md")
.map((path) => repoFile(path))
.join("\n");

expect(skill).toMatch(/^---\nname: opentag\ndescription: Use when /u);
const controlPlane = repoFile("skills/opentag/references/control-plane.md");
const completion = repoFile("skills/opentag/references/completion-governance.md");
expect(skillDocs).not.toContain("@opentag/cli@latest");
expect(skillDocs).not.toMatch(/\bnpx(?: --yes)? @opentag\/cli(?:\s|$)/u);
expect(skillDocs).not.toMatch(/\bnpm install -g @opentag\/cli(?:\s|$)/u);
expect(skill).toContain("npm install -g @opentag/cli@0.10.0");
expect(skill).toContain("npx @opentag/cli@0.10.0 setup");
expect(skill).toContain("request_user_input");
expect(skill).toContain("askhuman");
expect(skill).toContain("Codex Plan mode");
Expand Down Expand Up @@ -169,13 +180,15 @@ describe("platform setup docs contract", () => {
'HTTPS_PROXY="<proxy-url>" HTTP_PROXY="<proxy-url>" npm view @opentag/cli version --fetch-timeout=15000'
);
expect(skill).toContain("Only after npm registry metadata is reachable");
expect(skill).toContain("npx --yes @opentag/cli --help");
expect(skill).toContain("npx --yes @opentag/cli@0.10.0 --help");
expect(skill).toContain("do not permanently change `npm config` without explicit user confirmation");
expect(skill).toContain("Only use a proxy URL the user provides or that is already active in the environment");
expect(skill).toContain("npm cache metadata exists");
expect(skill).toContain("`npx --offline` or `npm pack --offline`");
expect(skill).toContain("do not claim the CLI is available offline");
expect(skill).toContain("Platform: Slack, GitHub, GitLab, Linear, Lark / Feishu, Telegram, or Discord");
expect(skill).toContain(
"Platform: Slack, GitHub, GitLab, Linear, Lark / Feishu, Telegram, Discord, or Microsoft Teams"
);
expect(skill).toContain("Coding agent: Codex, Claude Code, Cursor, OpenCode, Hermes, OpenClaw, or Echo");
expect(skill).toContain("Local project: the current working directory");
expect(skill).toContain("Slack Socket Mode vs Events API");
Expand All @@ -189,6 +202,40 @@ describe("platform setup docs contract", () => {
expect(skill).toContain("--tenant");
expect(skill).toContain("--lark-setup");
expect(skill).toContain("--binding");
expect(skill).toContain("references/control-plane.md");
expect(skill).toContain("references/completion-governance.md");
expect(skill).toContain("references/teams-setup.md");
expect(skill).toContain("docs/platforms/teams.en.md");
expect(skill).toContain("opentag service install");
expect(skill).toContain("opentag service logs");
expect(skill).toContain("opentag cancel --run <run_id>");
expect(controlPlane).toContain("opentag pair --relay <url>");
expect(controlPlane).toContain("Hosted Control V1");
expect(controlPlane).toContain("bootstrap pairing token");
expect(controlPlane).toContain("Do not use `--no-register`");
expect(controlPlane).toContain("without calling `/healthz`");
expect(controlPlane).toContain("empty capabilities list");
expect(controlPlane).toContain("does not bind Project Targets");
expect(controlPlane).toContain("run metadata, command text, and progress");
expect(controlPlane).toContain("controls which queued runs the local runner claims");
expect(controlPlane).toContain("opentag config show");
expect(completion).toContain("executor success is not completion");
expect(completion).toContain("complete current-head check rollup");
expect(completion).toContain("opentag status --work-thread <work_thread_id>");
expect(completion).toContain("opentag status --attention");
expect(completion).toContain("opentag completion escalations --run <run_id>");
expect(completion).toContain("opentag completion waive");
expect(completion).toContain("Do not fabricate provider evidence");
const teams = repoFile("skills/opentag/references/teams-setup.md");
expect(teams).toContain("opentag setup --platform teams");
expect(teams).toContain("Do not put `--teams-app-password`");
expect(teams).toContain("`activity.conversation.id`");
expect(teams).toContain("removing only a trailing `;messageid=<root>` suffix");
expect(teams).toContain("no standalone Teams channel-binding CLI command");
expect(teams).toContain("docs/platforms/teams.en.md");
const teamsGuide = repoFile("docs/platforms/teams.en.md");
expect(teamsGuide).not.toMatch(/--teams-app-password(?:\s+|=)/u);
expect(teamsGuide).toContain("Do not put the client secret in command-line arguments");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(skill).toContain(
"Stop before entering any credential, token, app ID, app secret, signing secret, channel ID, repository name, or unconfirmed project path."
);
Expand Down
11 changes: 8 additions & 3 deletions packages/dispatcher/src/completion-governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,16 +269,21 @@ function githubCompletionSemanticDigest(snapshot: GitHubVerifiedPullRequestSnaps
provider: snapshot.provider,
repository: snapshot.repository,
pullRequest: snapshot.pullRequest,
checks: snapshot.checks
checks: snapshot.checks,
checksComplete: snapshot.checksComplete
};
return `sha256:${createHash("sha256")
.update(JSON.stringify(canonicalizeGitHubCompletionValue(semanticSnapshot)))
.digest("hex")}`;
}

function observedChecksRollupOutcome(checks: Record<string, "passed" | "failed" | "pending">): "passed" | "failed" | "pending" {
function observedChecksRollupOutcome(
checks: Record<string, "passed" | "failed" | "pending">,
checksComplete: boolean
): "passed" | "failed" | "pending" {
const states = Object.values(checks);
if (states.some((state) => state === "failed")) return "failed";
if (!checksComplete || states.length === 0) return "pending";
if (states.some((state) => state === "pending")) return "pending";
return "passed";
}
Expand Down Expand Up @@ -342,7 +347,7 @@ function githubFactTemplates(input: {
kind: "source_control.observed_checks_rollup",
claim: {
predicate: "checks_rollup",
outcome: observedChecksRollupOutcome(input.snapshot.checks),
outcome: observedChecksRollupOutcome(input.snapshot.checks, input.snapshot.checksComplete),
observations: input.snapshot.checks
},
provenance: provenance("source_control.observed_checks_rollup")
Expand Down
1 change: 1 addition & 0 deletions packages/dispatcher/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ const GitHubCompletionEvidenceSchema = z.object({
state: z.enum(["open", "closed", "merged"])
}).strict(),
checks: z.record(z.string().min(1), z.enum(["passed", "failed", "pending"])),
checksComplete: z.boolean().default(false),
observedAt: z.string().datetime(),
payloadDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u)
}).strict();
Expand Down
47 changes: 47 additions & 0 deletions packages/dispatcher/test/completion-governance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function githubSnapshot(input: {
headSha?: string;
state?: "open" | "closed" | "merged";
checks?: Record<string, "passed" | "failed" | "pending">;
checksComplete?: boolean;
observedAt?: string;
resourceRef?: string;
}) {
Expand All @@ -48,6 +49,7 @@ function githubSnapshot(input: {
state: input.state ?? "merged"
},
checks: input.checks ?? { build: "passed", test: "passed" },
checksComplete: input.checksComplete ?? true,
observedAt: input.observedAt ?? "2026-07-21T10:05:00.000Z",
payloadDigest: `sha256:${(input.deliveryId === "delivery-old" ? "d" : "e").repeat(64)}`
};
Expand Down Expand Up @@ -706,6 +708,51 @@ describe("dispatcher completion governance", () => {
});
});

it("keeps the zero-config observed-checks gate unsatisfied when no checks were observed", async () => {
const setup = await startRun({ runId: "run_default_empty_checks" });
await completeRun({ setup, runId: "run_default_empty_checks", conclusion: "success" });

const evidence = await setup.app.request(
"/v1/completion-evidence/github",
jsonRequest(githubSnapshot({
deliveryId: "delivery-default-empty",
state: "open",
checks: {}
}))
);

expect(evidence.status).toBe(201);
await expect(evidence.json()).resolves.toMatchObject({
completion: {
completion: "unsatisfied",
failedGateIds: ["observed_checks"]
}
});
});

it("keeps the zero-config observed-checks gate unsatisfied for an incomplete all-passed rollup", async () => {
const setup = await startRun({ runId: "run_default_incomplete_checks" });
await completeRun({ setup, runId: "run_default_incomplete_checks", conclusion: "success" });

const evidence = await setup.app.request(
"/v1/completion-evidence/github",
jsonRequest(githubSnapshot({
deliveryId: "delivery-default-incomplete",
state: "open",
checks: { build: "passed" },
checksComplete: false
}))
);

expect(evidence.status).toBe(201);
await expect(evidence.json()).resolves.toMatchObject({
completion: {
completion: "unsatisfied",
failedGateIds: ["observed_checks"]
}
});
});

it("upgrades a compatibility thread to the default verified contract once a run ships a pull request", async () => {
const setup = await startRun({ runId: "run_upgrade_1" });
const first = await completeRun({ setup, runId: "run_upgrade_1", conclusion: "success", omitPullRequest: true });
Expand Down
67 changes: 47 additions & 20 deletions packages/github/src/completion-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type GitHubVerifiedPullRequestSnapshot = {
state: "open" | "closed" | "merged";
};
checks: Record<string, GitHubCheckState>;
checksComplete: boolean;
observedAt: string;
payloadDigest: string;
};
Expand All @@ -28,17 +29,23 @@ export type GitHubCompletionApi = {
head: { sha: string };
base: { ref: string; sha: string; repo?: { full_name?: string } | null };
}>;
listCheckRunsForRef(input: { owner: string; repo: string; ref: string }): Promise<Array<{
name: string;
status: string;
conclusion: string | null;
head_sha: string;
}>>;
getCombinedStatusForRef(input: { owner: string; repo: string; ref: string }): Promise<Array<{
context: string;
state: string;
sha: string;
}>>;
listCheckRunsForRef(input: { owner: string; repo: string; ref: string }): Promise<{
totalCount: number;
checkRuns: Array<{
name: string;
status: string;
conclusion: string | null;
head_sha: string;
}>;
}>;
getCombinedStatusForRef(input: { owner: string; repo: string; ref: string }): Promise<{
totalCount: number;
statuses: Array<{
context: string;
state: string;
sha: string;
}>;
}>;
listPullRequestsForCommit(input: { owner: string; repo: string; ref: string }): Promise<Array<{ number: number }>>;
};

Expand All @@ -54,6 +61,10 @@ function positiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
}

function nonNegativeInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}

function repositoryFromPayload(payload: unknown): { owner: string; repo: string } | null {
if (!isRecord(payload) || !isRecord(payload["repository"])) return null;
const repository = payload["repository"];
Expand Down Expand Up @@ -139,8 +150,8 @@ function commitStatusState(state: string): GitHubCheckState {

function normalizedChecks(input: {
headSha: string;
checkRuns: Awaited<ReturnType<GitHubCompletionApi["listCheckRunsForRef"]>>;
statuses: Awaited<ReturnType<GitHubCompletionApi["getCombinedStatusForRef"]>>;
checkRuns: Awaited<ReturnType<GitHubCompletionApi["listCheckRunsForRef"]>>["checkRuns"];
statuses: Awaited<ReturnType<GitHubCompletionApi["getCombinedStatusForRef"]>>["statuses"];
}): Record<string, GitHubCheckState> {
const checks = new Map<string, GitHubCheckState>();
for (const run of input.checkRuns) {
Expand Down Expand Up @@ -183,11 +194,20 @@ export async function reconcileGitHubCompletionEvidence(input: {
if (pullRequest.base.repo?.full_name && pullRequest.base.repo.full_name.toLowerCase() !== expectedRepository) {
throw new Error("GitHub pull request reconciliation returned a mismatched target repository.");
}
const [checkRuns, statuses] = await Promise.all([
const [checkRunPage, statusPage] = await Promise.all([
input.api.listCheckRunsForRef({ ...correlation.repository, ref: pullRequest.head.sha }),
input.api.getCombinedStatusForRef({ ...correlation.repository, ref: pullRequest.head.sha })
]);
const checks = normalizedChecks({ headSha: pullRequest.head.sha, checkRuns, statuses });
const checks = normalizedChecks({
headSha: pullRequest.head.sha,
checkRuns: checkRunPage.checkRuns,
statuses: statusPage.statuses
});
const checksComplete =
checkRunPage.checkRuns.length === checkRunPage.totalCount
&& statusPage.statuses.length === statusPage.totalCount
&& checkRunPage.checkRuns.every((run) => run.head_sha === pullRequest.head.sha)
&& statusPage.statuses.every((status) => status.sha === pullRequest.head.sha);
const state: GitHubVerifiedPullRequestSnapshot["pullRequest"]["state"] = pullRequest.merged
? "merged"
: pullRequest.state === "closed"
Expand All @@ -207,13 +227,15 @@ export async function reconcileGitHubCompletionEvidence(input: {
state
},
checks,
checksComplete,
observedAt: input.now()
};
const semanticSnapshot = {
provider: snapshotWithoutDigest.provider,
repository: snapshotWithoutDigest.repository,
pullRequest: snapshotWithoutDigest.pullRequest,
checks: snapshotWithoutDigest.checks
checks: snapshotWithoutDigest.checks,
checksComplete: snapshotWithoutDigest.checksComplete
};
snapshots.push({ ...snapshotWithoutDigest, payloadDigest: digest(semanticSnapshot) });
}
Expand Down Expand Up @@ -264,8 +286,10 @@ export function createGitHubCompletionApi(input: {
},
async listCheckRunsForRef({ owner, repo, ref }) {
const value = await request(`/repos/${segment(owner)}/${segment(repo)}/commits/${segment(ref)}/check-runs?filter=latest&per_page=100`);
if (!isRecord(value) || !Array.isArray(value["check_runs"])) throw new Error("GitHub check-run reconciliation returned an invalid response.");
return value["check_runs"].map((candidate) => {
if (!isRecord(value)) throw new Error("GitHub check-run reconciliation returned an invalid response.");
const totalCount = nonNegativeInteger(value["total_count"]);
if (totalCount === null || !Array.isArray(value["check_runs"])) throw new Error("GitHub check-run reconciliation returned an invalid response.");
const checkRuns = value["check_runs"].map((candidate) => {
if (!isRecord(candidate) || !nonEmptyString(candidate["name"]) || !nonEmptyString(candidate["status"])
|| !nonEmptyString(candidate["head_sha"])
|| (candidate["conclusion"] !== null && typeof candidate["conclusion"] !== "string")) {
Expand All @@ -278,14 +302,16 @@ export function createGitHubCompletionApi(input: {
head_sha: candidate["head_sha"] as string
};
});
return { totalCount, checkRuns };
},
async getCombinedStatusForRef({ owner, repo, ref }) {
const value = await request(`/repos/${segment(owner)}/${segment(repo)}/commits/${segment(ref)}/status?per_page=100`);
if (!isRecord(value)) throw new Error("GitHub commit-status reconciliation returned an invalid response.");
const sha = nonEmptyString(value["sha"]);
const totalCount = nonNegativeInteger(value["total_count"]);
const statuses = value["statuses"];
if (!sha || !Array.isArray(statuses)) throw new Error("GitHub commit-status reconciliation returned an invalid response.");
return statuses.map((candidate) => {
if (!sha || totalCount === null || !Array.isArray(statuses)) throw new Error("GitHub commit-status reconciliation returned an invalid response.");
const normalizedStatuses = statuses.map((candidate) => {
if (!isRecord(candidate) || !nonEmptyString(candidate["context"])
|| !nonEmptyString(candidate["state"])) {
throw new Error("GitHub commit-status reconciliation returned an invalid response.");
Expand All @@ -296,6 +322,7 @@ export function createGitHubCompletionApi(input: {
sha
};
});
return { totalCount, statuses: normalizedStatuses };
},
async listPullRequestsForCommit({ owner, repo, ref }) {
const value = await request(`/repos/${segment(owner)}/${segment(repo)}/commits/${segment(ref)}/pulls?per_page=100`);
Expand Down
Loading