Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"plugins": [
{
"name": "simulator",
"version": "2.4.2",
"version": "2.4.3",
"source": {
"source": "local",
"path": "./plugins/simulator"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "simulator",
"source": "./plugins/simulator",
"description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.",
"version": "2.4.2",
"version": "2.4.3",
"author": {
"name": "Simulator.Company",
"url": "https://simulator.company"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ bin/
# Redis snapshot left behind when running a local Redis from the repo dir.
dump.rdb
/plugins/simulator/mcp-server/.env
/plugins/simulator/mcp-server/.env.bak
.claude/settings.local.json

# Stray Go binaries built into the module dir (e.g. `go build ./cmd/<x>`).
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [2.4.3]

### Added
- `logout` tool: removes `ACCESS_TOKEN` from `.env` (with a `.env.bak` backup first) and warns that the file may be shared with sibling plugins
- `status` tool: self-diagnosis — environment, token state, `.env` path; `probe=true` verifies `ACCOUNT_URL` against the gateway's public config (cloud and on-prem)

### Changed
- OAuth hardening per OAuth 2.1 / RFC 8414 / RFC 8252: endpoints resolved from the account service's `/.well-known/oauth-authorization-server` metadata (fallback to the conventional paths for older on-prem installs); silent token renewal via the `refresh_token` grant before any browser round-trip — the refreshed token is trusted only after one authenticated papi call succeeds, otherwise the flow falls back to the browser (the refresh token is stored in `.env`, removed by `logout` and included in its backup); token-endpoint responses are never echoed into error messages (only field names); `ACCOUNT_URL` scheme validation (https, or http for localhost only) before the browser opens; standard `access_token` accepted alongside `simulator_token`; the token request is bounded by a timeout
- `login` self-heals a wrong `ACCOUNT_URL` before opening the browser: it asks the chosen gateway's public config for the real account service (`saUrl`) and corrects `.env` when they disagree, reporting what was fixed
- OAuth login failures now explain what to check and how to fix it, and show which consent URL was used (diagnosis — the flow must be re-run, its callback listener is closed by then); the wait and the token exchange honour client cancellation instead of blocking for the full timeout

## [2.4.2]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion POWER.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: simulator
displayName: Simulator.Company
version: 2.4.2
version: 2.4.3
description: BPM, graph, and financial-tracking toolkit for the Simulator.Company platform. Exposes the Simulator REST API as MCP tools plus 16 skills covering actors, forms, graphs, layers, accounts, transactions, transfers, charts, smart forms, meetings, reactions, and attachments.
author:
name: Simulator.Company
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ the actor/node items.)
| Meetings | `getTranscription` (read a meeting call's speech transcription — summarize / extract action items; needs a live room) |
| Smart Forms (runtime) | `appGetPage` `appSendForm` (drive any Smart Form / CDU app via the `get`/`send` page protocol — render a page, submit a form; Corezoid supplies data & control flow. Universal primitives for the `simulator-smart-forms-runtime` skill) |
| Users | `getUsers` `getUser` `searchUsers` (workspace members — resolve a userId/groupId for sharing) |
| Setup | `set-environment` (cloud preset or custom/local URL) `login` `getWorkspaces` `set-workspace` (by accId or name) |
| Setup | `set-environment` (cloud preset or custom/local URL) `login` (verifies/self-heals `ACCOUNT_URL` against the gateway config before opening the browser) `logout` (removes the token from `.env`, backing it up to `.env.bak` first) `status` (self-diagnosis: environment, token state, `.env` path; `probe=true` also checks `ACCOUNT_URL` against the gateway) `getWorkspaces` `set-workspace` (by accId or name) |


**Engine tools** (multi-call workflows + client-side computation):
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ backend operation, with typed parameters:
| Meetings | `getTranscription` (read a meeting call's speech transcription — summarize / extract action items; needs a live room) |
| Smart Forms (runtime) | `appGetPage` `appSendForm` (`smartforms.go`): drive any Smart Form / CDU app via the `get`/`send` page protocol (render a page → submit a form). The env's Corezoid process supplies runtime data, validation and control flow; the tools carry no app-specific logic, so the same two drive every mini-app. Runtime counterpart to the authoring surface (`createSmartForm` / `app_content`). See [`smart-forms.md`](../plugins/simulator/docs/user-flows/smart-forms.md) / [`cdu-page-protocol.md`](../plugins/simulator/docs/user-flows/cdu-page-protocol.md). |
| Users | `getUsers` `getUser` `searchUsers` (workspace members — resolve a userId/groupId for sharing) |
| Setup | `set-environment` (cloud preset or custom/local URL; derives the account URL from the gateway's public config) `login` `getWorkspaces` `set-workspace` (by accId or name) |
| Setup | `set-environment` (cloud preset or custom/local URL; derives the account URL from the gateway's public config) `login` `logout` (remove stored credentials; `.env` backed up to `.env.bak`) `status` (no-side-effect diagnosis: connectivity, token validity, account/API URLs) `getWorkspaces` `set-workspace` (by accId or name) |
| Web links | `buildLink` (local, no HTTP — `deeplinks.go`): build an absolute web-app deep-link for an entity (actor / event / chat / layer / transaction / …). Mirrors the web routes published at `<web-base>/routes.json`; derives the web base by dropping `/papi/1.0` from the API base and defaults `acc` to the active workspace. Workspace mode only (hidden by `ActorToolFilter` in actor sessions). |
| Skill registry | `findSkill` `getSkill` (local composite — `skills.go`, registered outside `allOps()` so the drift gate skips them, like `buildLink`/`readAttachment`): the data-driven skill registry. A skill is an actor of the `Skills` system form; `findSkill` discovers published (`verified`) skills by intent via `filterActors` (empty query lists all), `getSkill` loads one in full (including the `description` body) by `ref`/slug (`getActorByRef`) or `id`. Both resolve the `Skills` form id by listing system forms and matching the title (cached per workspace). See [`ai-skills.md`](../plugins/simulator/docs/entities/ai-skills.md) and the `/simulator-skills` skill. |
| Actor agents | `findAgent` `getAgent` (local composite — `agents.go`, registered outside `allOps()`, drift gate skips them): the actor-analog of the skill registry. Any actor whose `description` is an "# Agent" profile is an agent — the common case is a user twin (`systemObjType="user"` on the `System` form), but a non-user system actor or a plain business actor (team/service/org/process) can be one too. `findAgent` discovers agents by competency over their profiles via `searchAll` — scoped to the `System` form by default, or to another agent-registry form via `formId` (it is always form-scoped, never an unscoped workspace search; empty query enumerates the registry: members via `getUsers` for the default, `filterActors` for a `formId`). `getAgent` loads one profile in full by `userId` (person twin, get-or-create via the system-actor endpoint) or `actorId` (any agent actor). Resolves the `System` form id like `skills.go` (cached per workspace). See [`digital-twin-agents.md`](../plugins/simulator/docs/entities/digital-twin-agents.md) and the `/simulator-agents` skill. |
Expand Down
2 changes: 1 addition & 1 deletion plugins/simulator/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simulator",
"version": "2.4.2",
"version": "2.4.3",
"description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.",
"author": {
"name": "Simulator.Company",
Expand Down
2 changes: 1 addition & 1 deletion plugins/simulator/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simulator",
"version": "2.4.2",
"version": "2.4.3",
"description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.",
"author": {
"name": "Simulator.Company",
Expand Down
2 changes: 1 addition & 1 deletion plugins/simulator/.kiro-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simulator",
"version": "2.4.2",
"version": "2.4.3",
"description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.",
"author": {
"name": "Simulator.Company",
Expand Down
95 changes: 90 additions & 5 deletions plugins/simulator/mcp-server/app/auth/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ var envMu sync.Mutex

// Credentials holds the Simulator JWT token.
type Credentials struct {
AccessToken string `json:"access_token"`
ExpiresAt time.Time `json:"expires_at"`
TokenType string `json:"token_type"` // always "Simulator"
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
TokenType string `json:"token_type"` // always "Simulator"
}

// AuthorizationHeader returns the value to use for the Authorization header.
Expand Down Expand Up @@ -114,8 +115,9 @@ func Load() (*Credentials, error) {
return nil, nil
}
creds := &Credentials{
AccessToken: token,
TokenType: "Simulator",
AccessToken: token,
RefreshToken: os.Getenv("REFRESH_TOKEN"),
TokenType: "Simulator",
}
if expiryStr := os.Getenv("ACCESS_TOKEN_EXPIRES_AT"); expiryStr != "" {
if t, err := time.Parse(time.RFC3339, expiryStr); err == nil {
Expand All @@ -137,13 +139,21 @@ func Save(creds *Credentials) error {
expStr = creds.ExpiresAt.Format(time.RFC3339)
kv = append(kv, [2]string{"ACCESS_TOKEN_EXPIRES_AT", expStr})
}
// Only overwrite the stored refresh token when the server actually issued
// one — some grants return an access token alone.
if creds.RefreshToken != "" {
kv = append(kv, [2]string{"REFRESH_TOKEN", creds.RefreshToken})
}
if err := updateEnvFileMulti(envFilePath(), kv); err != nil {
return fmt.Errorf("failed to save token to .env: %w", err)
}
os.Setenv("ACCESS_TOKEN", creds.AccessToken)
if expStr != "" {
os.Setenv("ACCESS_TOKEN_EXPIRES_AT", expStr)
}
if creds.RefreshToken != "" {
os.Setenv("REFRESH_TOKEN", creds.RefreshToken)
}
return nil
}

Expand All @@ -159,8 +169,12 @@ func Delete() error {
if err := removeEnvKey(path, "ACCESS_TOKEN_EXPIRES_AT"); err != nil {
return err
}
if err := removeEnvKey(path, "REFRESH_TOKEN"); err != nil {
return err
}
os.Unsetenv("ACCESS_TOKEN")
os.Unsetenv("ACCESS_TOKEN_EXPIRES_AT")
os.Unsetenv("REFRESH_TOKEN")
return nil
}

Expand Down Expand Up @@ -219,6 +233,77 @@ func SaveWorkspaceID(accID string) error {
return nil
}

// EnvPath reports which .env file the auth helpers read and write, so tools
// can name the exact file in user-facing messages.
func EnvPath() string { return envFilePath() }

// BackupToken appends the current ACCESS_TOKEN / ACCESS_TOKEN_EXPIRES_AT lines
// from .env to .env.bak before a logout removes them: the .env is shared with
// sibling plugins (e.g. corezoid), so an accidental logout must stay
// recoverable by hand. Returns the backup path, or "" if there was nothing to
// back up.
func BackupToken() (string, error) {
envMu.Lock()
defer envMu.Unlock()
path := envFilePath()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
var saved []string
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "ACCESS_TOKEN=") || strings.HasPrefix(line, "ACCESS_TOKEN_EXPIRES_AT=") || strings.HasPrefix(line, "REFRESH_TOKEN=") {
saved = append(saved, line)
}
}
if len(saved) == 0 {
return "", nil
}
bakPath := path + ".bak"
f, err := os.OpenFile(bakPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return "", err
}
// 0600 above only applies at creation — tighten a pre-existing backup too.
_ = f.Chmod(0600)
defer f.Close()
stamp := time.Now().UTC().Format(time.RFC3339)
if _, err := fmt.Fprintf(f, "# saved by simulator logout at %s\n%s\n", stamp, strings.Join(saved, "\n")); err != nil {
return "", err
}
return bakPath, nil
}

// SaveRefreshToken persists only the refresh token — used when the account
// service rotates it on a refresh attempt whose access token was rejected, so
// a rotating AS cannot burn the stored token.
func SaveRefreshToken(rt string) error {
envMu.Lock()
defer envMu.Unlock()
if err := updateEnvFile(envFilePath(), "REFRESH_TOKEN", rt); err != nil {
return err
}
os.Setenv("REFRESH_TOKEN", rt)
return nil
}

// DeleteRefreshToken removes only the refresh token — used when the account
// service's refresh grant returned a token the API rejects, so re-trying it on
// every login would just add round-trips (a fresh browser login stores a new
// refresh token anyway).
func DeleteRefreshToken() error {
envMu.Lock()
defer envMu.Unlock()
if err := removeEnvKey(envFilePath(), "REFRESH_TOKEN"); err != nil {
return err
}
os.Unsetenv("REFRESH_TOKEN")
return nil
}

// IsExpired reports whether the credentials are expired.
func IsExpired(creds *Credentials) bool {
if creds == nil || creds.AccessToken == "" {
Expand Down
115 changes: 115 additions & 0 deletions plugins/simulator/mcp-server/app/auth/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package auth

import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)

func TestPKCEFlow_HonorsCtxCancel(t *testing.T) {
orig := openBrowserFn
openBrowserFn = func(string) error { return nil }
defer func() { openBrowserFn = orig }()

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, authURL, err := PKCEFlow(ctx, "https://account.example.com", "", nil)
if err == nil || !strings.Contains(err.Error(), "cancelled by the client") {
t.Fatalf("want cancellation error, got %v", err)
}
if !strings.Contains(authURL, "https://account.example.com/oauth2/authorize?") {
t.Fatalf("authURL must be returned for a manual fallback, got %q", authURL)
}
}

func TestBackupToken_SavesRemovedLines(t *testing.T) {
dir := t.TempDir()
t.Setenv("SIMULATOR_WORK_DIR", dir)
envPath := filepath.Join(dir, ".env")
content := "SIMULATOR_API_BASE_URL=https://gw.example.com/papi/1.0\nACCESS_TOKEN=tok123\nACCESS_TOKEN_EXPIRES_AT=2026-12-01T00:00:00Z\n"
if err := os.WriteFile(envPath, []byte(content), 0600); err != nil {
t.Fatal(err)
}

bak, err := BackupToken()
if err != nil {
t.Fatal(err)
}
if bak != envPath+".bak" {
t.Fatalf("unexpected backup path %q", bak)
}
data, err := os.ReadFile(bak)
if err != nil {
t.Fatal(err)
}
s := string(data)
if !strings.Contains(s, "ACCESS_TOKEN=tok123") || !strings.Contains(s, "ACCESS_TOKEN_EXPIRES_AT=2026-12-01T00:00:00Z") {
t.Fatalf("backup missing token lines: %s", s)
}
if !strings.Contains(s, "# saved by simulator logout") {
t.Fatalf("backup missing annotation: %s", s)
}
if strings.Contains(s, "SIMULATOR_API_BASE_URL") {
t.Fatalf("backup must contain only the token lines: %s", s)
}
}

func TestBackupToken_NothingToBackup(t *testing.T) {
dir := t.TempDir()
t.Setenv("SIMULATOR_WORK_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("WORKSPACE_ID=1\n"), 0600); err != nil {
t.Fatal(err)
}

bak, err := BackupToken()
if err != nil {
t.Fatal(err)
}
if bak != "" {
t.Fatalf("want empty path when there is nothing to back up, got %q", bak)
}
if _, err := os.Stat(filepath.Join(dir, ".env.bak")); !os.IsNotExist(err) {
t.Fatalf(".env.bak must not be created when there is nothing to back up")
}
}

func TestBackupToken_MissingEnvFile(t *testing.T) {
t.Setenv("SIMULATOR_WORK_DIR", t.TempDir())
bak, err := BackupToken()
if err != nil || bak != "" {
t.Fatalf("missing .env must be a no-op, got path=%q err=%v", bak, err)
}
}

func TestSaveDeleteRefreshTokenRoundtrip(t *testing.T) {
dir := t.TempDir()
t.Setenv("SIMULATOR_WORK_DIR", dir)
t.Setenv("REFRESH_TOKEN", "")
os.Unsetenv("REFRESH_TOKEN")

if err := Save(&Credentials{AccessToken: "at-1", RefreshToken: "rt-1", TokenType: "Simulator"}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(filepath.Join(dir, ".env"))
if !strings.Contains(string(data), "REFRESH_TOKEN=rt-1") {
t.Fatalf("refresh token not persisted: %s", data)
}
// A grant without a refresh token must not clobber the stored one.
if err := Save(&Credentials{AccessToken: "at-2", TokenType: "Simulator"}); err != nil {
t.Fatal(err)
}
data, _ = os.ReadFile(filepath.Join(dir, ".env"))
if !strings.Contains(string(data), "REFRESH_TOKEN=rt-1") {
t.Fatalf("token-only save must keep the refresh token: %s", data)
}
if err := Delete(); err != nil {
t.Fatal(err)
}
data, _ = os.ReadFile(filepath.Join(dir, ".env"))
if strings.Contains(string(data), "REFRESH_TOKEN") || os.Getenv("REFRESH_TOKEN") != "" {
t.Fatalf("logout must remove the refresh token: %s", data)
}
}
Loading
Loading